• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mcallegari / qlcplus / 18357067171

08 Oct 2025 08:16PM UTC coverage: 34.26% (+2.2%) from 32.066%
18357067171

push

github

mcallegari
Merge branch 'master' into filedialog

1282 of 4424 new or added lines in 152 files covered. (28.98%)

1342 existing lines in 152 files now uncovered.

17704 of 51675 relevant lines covered (34.26%)

19430.31 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

72.0
/engine/src/qlcinputprofile.cpp
1
/*
2
  Q Light Controller
3
  qlcinputprofile.cpp
4

5
  Copyright (c) Heikki Junnila
6

7
  Licensed under the Apache License, Version 2.0 (the "License");
8
  you may not use this file except in compliance with the License.
9
  You may obtain a copy of the License at
10

11
      http://www.apache.org/licenses/LICENSE-2.0.txt
12

13
  Unless required by applicable law or agreed to in writing, software
14
  distributed under the License is distributed on an "AS IS" BASIS,
15
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
  See the License for the specific language governing permissions and
17
  limitations under the License.
18
*/
19

20
#include <QXmlStreamReader>
21
#include <QXmlStreamWriter>
22
#include <unistd.h>
23
#include <QString>
24
#include <QDebug>
25
#include <QMap>
26

27
#include "qlcinputchannel.h"
28
#include "qlcinputprofile.h"
29
#include "qlcchannel.h"
30
#include "qlcfile.h"
31

32
#define KXMLQLCInputProfileTypeMidi QStringLiteral("MIDI")
33
#define KXMLQLCInputProfileTypeOs2l QStringLiteral("OS2L")
34
#define KXMLQLCInputProfileTypeOsc QStringLiteral("OSC")
35
#define KXMLQLCInputProfileTypeHid QStringLiteral("HID")
36
#define KXMLQLCInputProfileTypeDmx QStringLiteral("DMX")
37
#define KXMLQLCInputProfileTypeEnttec QStringLiteral("Enttec")
38

39
#define KXMLQLCInputProfileValue QStringLiteral("Value")
40
#define KXMLQLCInputProfileLabel QStringLiteral("Label")
41
#define KXMLQLCInputProfileColorRGB QStringLiteral("RGB")
42

43
/****************************************************************************
44
 * Initialization
45
 ****************************************************************************/
46

47
QLCInputProfile::QLCInputProfile()
139✔
48
    : m_manufacturer(QString())
139✔
49
    , m_model(QString())
139✔
50
    , m_path(QString())
139✔
51
    , m_type(MIDI)
139✔
52
    , m_midiSendNoteOff(true)
278✔
53
{
54
}
139✔
55

56
QLCInputProfile::~QLCInputProfile()
257✔
57
{
58
    destroyChannels();
137✔
59
}
257✔
60

61
QLCInputProfile *QLCInputProfile::createCopy()
1✔
62
{
63
    QLCInputProfile *copy = new QLCInputProfile();
1✔
64
    copy->setManufacturer(this->manufacturer());
1✔
65
    copy->setModel(this->model());
1✔
66
    copy->setType(this->type());
1✔
67
    copy->setPath(this->path());
1✔
68
    copy->setMidiSendNoteOff(this->midiSendNoteOff());
1✔
69

70
    /* Copy the other profile's channels */
71
    QMapIterator <quint32,QLCInputChannel*> it(this->channels());
1✔
72
    while (it.hasNext() == true)
5✔
73
    {
74
        it.next();
4✔
75
        copy->insertChannel(it.key(), it.value()->createCopy());
4✔
76
    }
77

78
    /* Copy the other profile's color table */
79
    QMapIterator <uchar, QPair<QString, QColor>> it2(this->colorTable());
1✔
80
    while (it2.hasNext() == true)
1✔
81
    {
NEW
82
        it2.next();
×
NEW
83
        QPair<QString, QColor> lc = it2.value();
×
NEW
84
        copy->addColor(it2.key(), lc.first, lc.second);
×
NEW
85
    }
×
86

87
    /* Copy the other profile's MIDI channel tabel */
88
    QMapIterator <uchar, QString> it3(this->midiChannelTable());
1✔
89
    while (it3.hasNext() == true)
1✔
90
    {
NEW
91
        it3.next();
×
NEW
92
        copy->addMidiChannel(it3.key(), it3.value());
×
93
    }
94

95
    return copy;
1✔
96
}
1✔
97

98
QLCInputProfile& QLCInputProfile::operator=(const QLCInputProfile& profile)
1✔
99
{
100
    if (this != &profile)
1✔
101
    {
102
        /* Copy basic properties */
103
        m_manufacturer = profile.m_manufacturer;
1✔
104
        m_model = profile.m_model;
1✔
105
        m_path = profile.m_path;
1✔
106
        m_type = profile.m_type;
1✔
107
        m_midiSendNoteOff = profile.m_midiSendNoteOff;
1✔
108
        m_globalSettingsMap = profile.m_globalSettingsMap;
1✔
109

110
        /* Destroy all existing channels */
111
        destroyChannels();
1✔
112

113
        /* Copy the other profile's channels */
114
        QMapIterator <quint32, QLCInputChannel*> it(profile.m_channels);
1✔
115
        while (it.hasNext() == true)
5✔
116
        {
117
            it.next();
4✔
118
            insertChannel(it.key(), it.value()->createCopy());
4✔
119
        }
120

121
        /* Copy the other profile's color table */
122
        QMapIterator <uchar, QPair<QString, QColor>> it2(profile.m_colorTable);
1✔
123
        while (it2.hasNext() == true)
1✔
124
        {
NEW
125
            it2.next();
×
NEW
126
            QPair<QString, QColor> lc = it2.value();
×
NEW
127
            addColor(it2.key(), lc.first, lc.second);
×
NEW
128
        }
×
129

130
        /* Copy the other profile's MIDI channel tabel */
131
        QMapIterator <uchar, QString> it3(profile.m_midiChannelTable);
1✔
132
        while (it3.hasNext() == true)
1✔
133
        {
NEW
134
            it3.next();
×
NEW
135
            addMidiChannel(it3.key(), it3.value());
×
136
        }
137
    }
1✔
138

139
    return *this;
1✔
140
}
141

142
/****************************************************************************
143
 * profile information
144
 ****************************************************************************/
145

146
void QLCInputProfile::setManufacturer(const QString& manufacturer)
132✔
147
{
148
    m_manufacturer = manufacturer;
132✔
149
}
132✔
150

151
QString QLCInputProfile::manufacturer() const
7✔
152
{
153
    return m_manufacturer;
7✔
154
}
155

156
void QLCInputProfile::setModel(const QString& model)
128✔
157
{
158
    m_model = model;
128✔
159
}
128✔
160

161
QString QLCInputProfile::model() const
7✔
162
{
163
    return m_model;
7✔
164
}
165

166
QString QLCInputProfile::name() const
2,550✔
167
{
168
    return QString("%1 %2").arg(m_manufacturer).arg(m_model);
2,550✔
169
}
170

171
void QLCInputProfile::setPath(QString path)
1✔
172
{
173
    m_path = path;
1✔
174
}
1✔
175

176
QString QLCInputProfile::path() const
2✔
177
{
178
    return m_path;
2✔
179
}
180

181
void QLCInputProfile::setType(QLCInputProfile::Type type)
120✔
182
{
183
    m_type = type;
120✔
184
}
120✔
185

186
QLCInputProfile::Type QLCInputProfile::type() const
1✔
187
{
188
    return m_type;
1✔
189
}
190

191
QString QLCInputProfile::typeToString(Type type)
1✔
192
{
193
    switch (type)
1✔
194
    {
195
    case MIDI:
1✔
196
        return KXMLQLCInputProfileTypeMidi;
1✔
197
    case OS2L:
×
198
        return KXMLQLCInputProfileTypeOs2l;
×
199
    case OSC:
×
200
        return KXMLQLCInputProfileTypeOsc;
×
201
    case HID:
×
202
        return KXMLQLCInputProfileTypeHid;
×
203
    case DMX:
×
204
        return KXMLQLCInputProfileTypeDmx;
×
205
    case Enttec:
×
206
        return KXMLQLCInputProfileTypeEnttec;
×
207
    default:
×
208
        return QString();
×
209
    }
210
}
211

212
QLCInputProfile::Type QLCInputProfile::stringToType(const QString& str)
119✔
213
{
214
    if (str == KXMLQLCInputProfileTypeMidi)
119✔
215
        return MIDI;
101✔
216
    else if (str == KXMLQLCInputProfileTypeOs2l)
18✔
217
        return OS2L;
×
218
    else if (str == KXMLQLCInputProfileTypeOsc)
18✔
219
        return OSC;
9✔
220
    else if (str == KXMLQLCInputProfileTypeHid)
9✔
221
        return HID;
3✔
222
    else if (str == KXMLQLCInputProfileTypeDmx)
6✔
223
        return DMX;
×
224
    else // if (str == KXMLQLCInputProfileTypeEnttec)
225
        return Enttec;
6✔
226
}
227

228
QList<QLCInputProfile::Type> QLCInputProfile::types()
×
229
{
230
    QList<Type> result;
×
231
    result
232
        << MIDI
×
233
        << OS2L
×
234
        << OSC
×
235
        << HID
×
236
        << DMX
×
237
        << Enttec;
×
238
    return result;
×
UNCOV
239
}
×
240

241
/********************************************************************
242
 * Plugin-specific global settings
243
 ********************************************************************/
244

245
void QLCInputProfile::setMidiSendNoteOff(bool enable)
22✔
246
{
247
    m_midiSendNoteOff = enable;
22✔
248
    m_globalSettingsMap["MIDISendNoteOff"] = QVariant(enable);
22✔
249
}
22✔
250

251
bool QLCInputProfile::midiSendNoteOff() const
2✔
252
{
253
    return m_midiSendNoteOff;
2✔
254
}
255

256
QMap<QString, QVariant> QLCInputProfile::globalSettings() const
5✔
257
{
258
    return m_globalSettingsMap;
5✔
259
}
260

261
/****************************************************************************
262
 * Channels
263
 ****************************************************************************/
264

265
bool QLCInputProfile::insertChannel(quint32 channel,
12,628✔
266
                                    QLCInputChannel* ich)
267
{
268
    if (ich != NULL && m_channels.contains(channel) == false)
12,628✔
269
    {
270
        m_channels.insert(channel, ich);
12,627✔
271
        return true;
12,627✔
272
    }
273
    else
274
    {
275
        return false;
1✔
276
    }
277
}
278

279
bool QLCInputProfile::removeChannel(quint32 channel)
6✔
280
{
281
    if (m_channels.contains(channel) == true)
6✔
282
    {
283
        QLCInputChannel* ich = m_channels.take(channel);
2✔
284
        Q_ASSERT(ich != NULL);
2✔
285
        delete ich;
2✔
286
        return true;
2✔
287
    }
288
    else
289
    {
290
        return false;
4✔
291
    }
292
}
293

294
bool QLCInputProfile::remapChannel(QLCInputChannel* ich, quint32 number)
3✔
295
{
296
    if (ich == NULL)
3✔
297
        return false;
1✔
298

299
    quint32 old = channelNumber(ich);
2✔
300
    if (old != QLCChannel::invalid() && m_channels.contains(number) == false)
2✔
301
    {
302
        m_channels.remove(old);
1✔
303
        insertChannel(number, ich);
1✔
304
        return true;
1✔
305
    }
306
    else
307
    {
308
        return false;
1✔
309
    }
310
}
311

312
QLCInputChannel* QLCInputProfile::channel(quint32 channel) const
75✔
313
{
314
    return m_channels.value(channel, NULL);
75✔
315
}
316

317
quint32 QLCInputProfile::channelNumber(const QLCInputChannel* channel) const
6✔
318
{
319
    if (channel == NULL)
6✔
320
        return QLCChannel::invalid();
1✔
321

322
    QMapIterator <quint32,QLCInputChannel*> it(m_channels);
5✔
323
    while (it.hasNext() == true)
10✔
324
    {
325
        it.next();
9✔
326
        if (it.value() == channel)
9✔
327
            return it.key();
4✔
328
    }
329

330
    return QLCChannel::invalid();
1✔
331
}
5✔
332

333
QMap <quint32,QLCInputChannel*> QLCInputProfile::channels() const
30✔
334
{
335
    return m_channels;
30✔
336
}
337

NEW
338
QVariant QLCInputProfile::channelExtraParams(const QLCInputChannel* channel) const
×
339
{
NEW
340
    if (channel == NULL)
×
NEW
341
        return QVariant();
×
342

NEW
343
    switch (m_type)
×
344
    {
NEW
345
        case OSC: return channel->name();
×
NEW
346
        case MIDI: return channel->lowerChannel();
×
NEW
347
        default: return QVariant();
×
348
    }
349
}
350

351
void QLCInputProfile::destroyChannels()
138✔
352
{
353
    /* Delete existing channels but leave the pointers there */
354
    QMutableMapIterator <quint32,QLCInputChannel*> it(m_channels);
138✔
355
    while (it.hasNext() == true)
12,502✔
356
        delete it.next().value();
12,364✔
357

358
    /* Clear the list of freed pointers */
359
    m_channels.clear();
138✔
360
}
138✔
361

362
bool QLCInputProfile::hasColorTable()
1✔
363
{
364
    return m_colorTable.isEmpty() ? false : true;
1✔
365
}
366

367
void QLCInputProfile::addColor(uchar value, QString label, QColor color)
861✔
368
{
369
    QPair<QString, QColor> lc;
861✔
370
    lc.first = label;
861✔
371
    lc.second = color;
861✔
372
    m_colorTable.insert(value, lc);
861✔
373
}
861✔
374

NEW
375
void QLCInputProfile::removeColor(uchar value)
×
376
{
NEW
377
    m_colorTable.remove(value);
×
NEW
378
}
×
379

380
QMap<uchar, QPair<QString, QColor> > QLCInputProfile::colorTable()
1✔
381
{
382
    return m_colorTable;
1✔
383
}
384

385
/********************************************************************
386
 * MIDI Channel table
387
 ********************************************************************/
388

389
bool QLCInputProfile::hasMidiChannelTable()
1✔
390
{
391
    return m_midiChannelTable.isEmpty() ? false : true;
1✔
392
}
393

394
void QLCInputProfile::addMidiChannel(uchar channel, QString label)
57✔
395
{
396
    m_midiChannelTable.insert(channel, label);
57✔
397
}
57✔
398

NEW
399
void QLCInputProfile::removeMidiChannel(uchar channel)
×
400
{
NEW
401
    m_midiChannelTable.remove(channel);
×
NEW
402
}
×
403

404
QMap<uchar, QString> QLCInputProfile::midiChannelTable()
1✔
405
{
406
    return m_midiChannelTable;
1✔
407
}
408

409
/****************************************************************************
410
 * Load & Save
411
 ****************************************************************************/
412

413
QLCInputProfile* QLCInputProfile::loader(const QString& path)
121✔
414
{
415
    QXmlStreamReader *doc = QLCFile::getXMLReader(path);
121✔
416
    if (doc == NULL || doc->device() == NULL || doc->hasError())
121✔
417
    {
418
        qWarning() << Q_FUNC_INFO << "Unable to load input profile from" << path;
2✔
419
        return NULL;
2✔
420
    }
421

422
    QLCInputProfile* profile = new QLCInputProfile();
119✔
423
    if (profile->loadXML(*doc) == false)
119✔
424
    {
425
        qWarning() << path << QString("%1\nLine %2, column %3")
×
426
                    .arg(doc->errorString())
×
427
                    .arg(doc->lineNumber())
×
428
                    .arg(doc->columnNumber());
×
429

430
        delete profile;
×
431
        profile = NULL;
×
432
    }
433
    else
434
    {
435
        profile->m_path = path;
119✔
436
    }
437

438
    QLCFile::releaseXMLReader(doc);
119✔
439

440
    return profile;
119✔
441
}
442

443
bool QLCInputProfile::loadColorTableXML(QXmlStreamReader &tableRoot)
12✔
444
{
445
    if (tableRoot.name() != KXMLQLCInputProfileColorTable)
12✔
446
    {
NEW
447
        qWarning() << Q_FUNC_INFO << "Color table node not found";
×
NEW
448
        return false;
×
449
    }
450

451
    tableRoot.readNextStartElement();
12✔
452

453
    do
454
    {
455
        if (tableRoot.name() == KXMLQLCInputProfileColor)
861✔
456
        {
457
            /* get value & color */
458
            uchar value = tableRoot.attributes().value(KXMLQLCInputProfileValue).toInt();
1,722✔
459
            QString label = tableRoot.attributes().value(KXMLQLCInputProfileLabel).toString();
1,722✔
460
            QColor color = QColor(tableRoot.attributes().value(KXMLQLCInputProfileColorRGB).toString());
1,722✔
461
            addColor(value, label, color);
861✔
462
        }
861✔
463
        else
464
        {
NEW
465
            qWarning() << Q_FUNC_INFO << "Unknown color table tag:" << tableRoot.name().toString();
×
466
        }
467
        tableRoot.skipCurrentElement();
861✔
468
    } while (tableRoot.readNextStartElement());
861✔
469

470
    return true;
12✔
471
}
472

473
bool QLCInputProfile::loadMidiChannelTableXML(QXmlStreamReader &tableRoot)
6✔
474
{
475
    if (tableRoot.name() != KXMLQLCInputProfileMidiChannelTable)
6✔
476
    {
NEW
477
        qWarning() << Q_FUNC_INFO << "MIDI channel table node not found";
×
NEW
478
        return false;
×
479
    }
480

481
    tableRoot.readNextStartElement();
6✔
482

483
    do
484
    {
485
        if (tableRoot.name() == KXMLQLCInputProfileMidiChannel)
57✔
486
        {
487
            /* get value & color */
488
            uchar value = tableRoot.attributes().value(KXMLQLCInputProfileValue).toInt();
114✔
489
            QString label = tableRoot.attributes().value(KXMLQLCInputProfileLabel).toString();
114✔
490
            addMidiChannel(value, label);
57✔
491
        }
57✔
492
        else
493
        {
NEW
494
            qWarning() << Q_FUNC_INFO << "Unknown MIDI channel table tag:" << tableRoot.name().toString();
×
495
        }
496
        tableRoot.skipCurrentElement();
57✔
497
    } while (tableRoot.readNextStartElement());
57✔
498

499
    return true;
6✔
500
}
501

502
bool QLCInputProfile::loadXML(QXmlStreamReader& doc)
122✔
503
{
504
    if (doc.readNextStartElement() == false)
122✔
505
        return false;
2✔
506

507
    if (doc.name() == KXMLQLCInputProfile)
120✔
508
    {
509
        while (doc.readNextStartElement())
13,230✔
510
        {
511
            if (doc.name() == KXMLQLCCreator)
13,110✔
512
            {
513
                /* Ignore this block */
514
                doc.skipCurrentElement();
119✔
515
            }
516
            else if (doc.name() == KXMLQLCInputProfileManufacturer)
12,991✔
517
            {
518
                setManufacturer(doc.readElementText());
120✔
519
            }
520
            else if (doc.name() == KXMLQLCInputProfileModel)
12,871✔
521
            {
522
                setModel(doc.readElementText());
120✔
523
            }
524
            else if (doc.name() == KXMLQLCInputProfileType)
12,751✔
525
            {
526
                setType(stringToType(doc.readElementText()));
119✔
527
            }
528
            else if (doc.name() == KXMLQLCInputProfileMidiSendNoteOff)
12,632✔
529
            {
530
                setMidiSendNoteOff(doc.readElementText() != KXMLQLCFalse);
21✔
531
            }
532
            else if (doc.name() == KXMLQLCInputChannel)
12,611✔
533
            {
534
                QString str = doc.attributes().value(KXMLQLCInputChannelNumber).toString();
25,186✔
535
                if (str.isEmpty() == false)
12,593✔
536
                {
537
                    quint32 ch = str.toInt();
12,593✔
538
                    QLCInputChannel* ich = new QLCInputChannel();
12,593✔
539
                    if (ich->loadXML(doc) == true)
12,593✔
540
                        insertChannel(ch, ich);
12,593✔
541
                    else
542
                        delete ich;
×
543
                }
544
                else
545
                    doc.skipCurrentElement();
×
546
            }
12,593✔
547
            else if (doc.name() == KXMLQLCInputProfileColorTable)
18✔
548
            {
549
                loadColorTableXML(doc);
12✔
550
            }
551
            else if (doc.name() == KXMLQLCInputProfileMidiChannelTable)
6✔
552
            {
553
                loadMidiChannelTableXML(doc);
6✔
554
            }
555
            else
556
            {
NEW
557
                qWarning() << Q_FUNC_INFO << "Unknown input profile tag:" << doc.name().toString();
×
NEW
558
                doc.skipCurrentElement();
×
559
            }
560
        }
561

562
        return true;
120✔
563
    }
564
    else
565
    {
566
        qWarning() << Q_FUNC_INFO << "Input profile not found";
×
567
        return false;
×
568
    }
569
}
570

571
bool QLCInputProfile::saveXML(const QString& fileName)
2✔
572
{
573
    QFile file(fileName);
2✔
574
    if (file.open(QIODevice::WriteOnly) == false)
2✔
575
    {
576
        qWarning() << Q_FUNC_INFO << "Unable to write to" << fileName;
1✔
577
        return false;
1✔
578
    }
579

580
    QXmlStreamWriter doc(&file);
1✔
581
    doc.setAutoFormatting(true);
1✔
582
    doc.setAutoFormattingIndent(1);
1✔
583
    QLCFile::writeXMLHeader(&doc, KXMLQLCInputProfile);
2✔
584

585
    doc.writeTextElement(KXMLQLCInputProfileManufacturer, m_manufacturer);
2✔
586
    doc.writeTextElement(KXMLQLCInputProfileModel, m_model);
2✔
587
    doc.writeTextElement(KXMLQLCInputProfileType, typeToString(m_type));
2✔
588

589
    if (midiSendNoteOff() == false)
1✔
590
        doc.writeTextElement(KXMLQLCInputProfileMidiSendNoteOff, QString(KXMLQLCFalse));
×
591

592
    /* Write channels to the document */
593
    QMapIterator <quint32, QLCInputChannel*> it(m_channels);
1✔
594
    while (it.hasNext() == true)
4✔
595
    {
596
        it.next();
3✔
597
        it.value()->saveXML(&doc, it.key());
3✔
598
    }
599

600
    if (hasColorTable())
1✔
601
    {
NEW
602
        doc.writeStartElement(KXMLQLCInputProfileColorTable);
×
603

NEW
604
        QMapIterator <uchar, QPair<QString, QColor>> it(m_colorTable);
×
NEW
605
        while (it.hasNext() == true)
×
606
        {
NEW
607
            it.next();
×
NEW
608
            QPair<QString, QColor> lc = it.value();
×
NEW
609
            doc.writeStartElement(KXMLQLCInputProfileColor);
×
NEW
610
            doc.writeAttribute(KXMLQLCInputProfileValue, QString::number(it.key()));
×
NEW
611
            doc.writeAttribute(KXMLQLCInputProfileLabel, lc.first);
×
NEW
612
            doc.writeAttribute(KXMLQLCInputProfileColorRGB, lc.second.name());
×
NEW
613
            doc.writeEndElement();
×
NEW
614
        }
×
615

NEW
616
        doc.writeEndElement();
×
NEW
617
    }
×
618

619
    if (hasMidiChannelTable())
1✔
620
    {
NEW
621
        doc.writeStartElement(KXMLQLCInputProfileMidiChannelTable);
×
622

NEW
623
        QMapIterator <uchar, QString> it(m_midiChannelTable);
×
NEW
624
        while (it.hasNext() == true)
×
625
        {
NEW
626
            it.next();
×
NEW
627
            doc.writeStartElement(KXMLQLCInputProfileMidiChannel);
×
NEW
628
            doc.writeAttribute(KXMLQLCInputProfileValue, QString::number(it.key()));
×
NEW
629
            doc.writeAttribute(KXMLQLCInputProfileLabel, it.value());
×
NEW
630
            doc.writeEndElement();
×
631

632
        }
NEW
633
        doc.writeEndElement();
×
NEW
634
    }
×
635

636
    m_path = fileName;
1✔
637

638
    /* End the document and close all the open elements */
639
    doc.writeEndDocument();
1✔
640
    file.close();
1✔
641
#ifdef Q_OS_UNIX
642
    sync();
1✔
643
#endif
644
    return true;
1✔
645
}
2✔
646

STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc