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

IgniteUI / igniteui-angular / 9447616629

10 Jun 2024 11:37AM UTC coverage: 92.227% (+0.02%) from 92.203%
9447616629

push

github

web-flow
fix(simple-combo): prevent Enter key default behavior when filtering data - 16.1.x (#14296)

* fix(simple-combo): prevent Enter key default behavior when filtering data

* test(simple-combo): refactor test expect

* test(simple-combo): use KeyboardEvent object

15439 of 18156 branches covered (85.04%)

3 of 3 new or added lines in 1 file covered. (100.0%)

4 existing lines in 1 file now uncovered.

27017 of 29294 relevant lines covered (92.23%)

29519.9 hits per line

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

94.31
/projects/igniteui-angular/src/lib/simple-combo/simple-combo.component.ts
1
import { NgIf, NgTemplateOutlet } from '@angular/common';
2
import {
3
    AfterViewInit, ChangeDetectorRef, Component, DoCheck, ElementRef, EventEmitter, HostListener, Inject, Injector,
4
    Optional, Output, ViewChild
5
} from '@angular/core';
6
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
7
import { takeUntil } from 'rxjs/operators';
8

9
import { IgxComboAddItemComponent } from '../combo/combo-add-item.component';
10
import { IgxComboDropDownComponent } from '../combo/combo-dropdown.component';
11
import { IgxComboItemComponent } from '../combo/combo-item.component';
12
import { IgxComboAPIService } from '../combo/combo.api';
13
import { IgxComboBaseDirective, IGX_COMBO_COMPONENT } from '../combo/combo.common';
14
import { DisplayDensityToken, IDisplayDensityOptions } from '../core/density';
15
import { IgxSelectionAPIService } from '../core/selection';
16
import { CancelableEventArgs, IBaseCancelableBrowserEventArgs, IBaseEventArgs, PlatformUtil } from '../core/utils';
17
import { IgxButtonDirective } from '../directives/button/button.directive';
18
import { IgxForOfDirective } from '../directives/for-of/for_of.directive';
19
import { IgxRippleDirective } from '../directives/ripple/ripple.directive';
20
import { IgxTextSelectionDirective } from '../directives/text-selection/text-selection.directive';
21
import { IgxIconService } from '../icon/icon.service';
22
import { IgxInputGroupType, IGX_INPUT_GROUP_TYPE } from '../input-group/public_api';
23
import { IgxComboFilteringPipe, IgxComboGroupingPipe } from '../combo/combo.pipes';
24
import { IgxDropDownItemNavigationDirective } from '../drop-down/drop-down-navigation.directive';
25
import { IgxIconComponent } from '../icon/icon.component';
26
import { IgxSuffixDirective } from '../directives/suffix/suffix.directive';
27
import { IgxInputDirective } from '../directives/input/input.directive';
28
import { IgxInputGroupComponent } from '../input-group/input-group.component';
29

30
/** Emitted when an igx-simple-combo's selection is changing.  */
31
export interface ISimpleComboSelectionChangingEventArgs extends CancelableEventArgs, IBaseEventArgs {
32
    /** An object which represents the value that is currently selected */
33
    oldSelection: any;
34
    /** An object which represents the value that will be selected after this event */
35
    newSelection: any;
36
    /** The text that will be displayed in the combo text box */
37
    displayText: string;
38
}
39

40
/**
41
 * Represents a drop-down list that provides filtering functionality, allowing users to choose a single option from a predefined list.
42
 *
43
 * @igxModule IgxSimpleComboModule
44
 * @igxTheme igx-combo-theme
45
 * @igxKeywords combobox, single combo selection
46
 * @igxGroup Grids & Lists
47
 *
2✔
48
 * @remarks
49
 * It provides the ability to filter items as well as perform single selection on the provided data.
50
 * Additionally, it exposes keyboard navigation and custom styling capabilities.
1,103✔
51
 * @example
52
 * ```html
53
 * <igx-simple-combo [itemsMaxHeight]="250" [data]="locationData"
54
 *  [displayKey]="'field'" [valueKey]="'field'"
5,284!
55
 *  placeholder="Location" searchPlaceholder="Search...">
201✔
56
 * </igx-simple-combo>
57
 * ```
58
 */
59
@Component({
9,842✔
60
    selector: 'igx-simple-combo',
61
    templateUrl: 'simple-combo.component.html',
62
    providers: [
210✔
63
        IgxComboAPIService,
64
        { provide: IGX_COMBO_COMPONENT, useExisting: IgxSimpleComboComponent },
65
        { provide: NG_VALUE_ACCESSOR, useExisting: IgxSimpleComboComponent, multi: true }
90✔
66
    ],
67
    standalone: true,
68
    imports: [IgxInputGroupComponent, IgxInputDirective, IgxTextSelectionDirective, NgIf, IgxSuffixDirective, NgTemplateOutlet, IgxIconComponent, IgxComboDropDownComponent, IgxDropDownItemNavigationDirective, IgxForOfDirective, IgxComboItemComponent, IgxComboAddItemComponent, IgxButtonDirective, IgxRippleDirective, IgxComboFilteringPipe, IgxComboGroupingPipe]
91✔
69
})
91✔
70
export class IgxSimpleComboComponent extends IgxComboBaseDirective implements ControlValueAccessor, AfterViewInit, DoCheck {
91✔
71
    /** @hidden @internal */
72
    @ViewChild(IgxComboDropDownComponent, { static: true })
91✔
73
    public dropdown: IgxComboDropDownComponent;
91✔
74

91✔
75
    /** @hidden @internal */
91✔
76
    @ViewChild(IgxComboAddItemComponent)
1,547✔
77
    public addItem: IgxComboAddItemComponent;
1,547✔
78

79
    /**
15✔
80
     * Emitted when item selection is changing, before the selection completes
81
     *
1,532✔
82
     * ```html
1,532✔
83
     * <igx-simple-combo (selectionChanging)='handleSelection()'></igx-simple-combo>
84
     * ```
91✔
85
     */
86
    @Output()
87
    public selectionChanging = new EventEmitter<ISimpleComboSelectionChangingEventArgs>();
5✔
88

2✔
89
    @ViewChild(IgxTextSelectionDirective, { static: true })
2✔
90
    private textSelection: IgxTextSelectionDirective;
2✔
91

92
    /** @hidden @internal */
93
    public composing = false;
3✔
94

2✔
95
    private _updateInput = true;
2✔
96

97
    private _collapsing = false;
1!
98

1✔
99
    /** @hidden @internal */
100
    public get filteredData(): any[] | null {
101
        return this._filteredData;
102
    }
103
    /** @hidden @internal */
104
    public set filteredData(val: any[] | null) {
105
        this._filteredData = this.groupKey ? (val || []).filter((e) => e.isHeader !== true) : val;
106
        this.checkMatch();
107
    }
108

109
    /** @hidden @internal */
110
    public override get searchValue(): string {
111
        return this._searchValue;
60!
112
    }
60✔
113
    public override set searchValue(val: string) {
60✔
114
        this._searchValue = val;
115
    }
116

117
    private get selectedItem(): any {
118
        return this.selectionService.get(this.id).values().next().value;
119
    }
120

121
    constructor(elementRef: ElementRef,
122
        cdr: ChangeDetectorRef,
123
        selectionService: IgxSelectionAPIService,
124
        comboAPI: IgxComboAPIService,
125
        _iconService: IgxIconService,
4✔
126
        private platformUtil: PlatformUtil,
127
        @Optional() @Inject(DisplayDensityToken) _displayDensityOptions: IDisplayDensityOptions,
128
        @Optional() @Inject(IGX_INPUT_GROUP_TYPE) _inputGroupType: IgxInputGroupType,
129
        @Optional() _injector: Injector) {
56✔
130
        super(elementRef, cdr, selectionService, comboAPI,
55✔
131
            _iconService, _displayDensityOptions, _inputGroupType, _injector);
55✔
132
        this.comboAPI.register(this);
55✔
133
    }
55✔
134

55✔
135
    /** @hidden @internal */
136
    @HostListener('keydown.ArrowDown', ['$event'])
137
    @HostListener('keydown.Alt.ArrowDown', ['$event'])
138
    public onArrowDown(event: Event): void {
76✔
139
        if (this.collapsed) {
3✔
140
            event.preventDefault();
1✔
141
            event.stopPropagation();
10!
142
            this.open();
10!
143
        } else {
×
144
            if (this.virtDir.igxForOf.length > 0 && !this.selectedItem) {
145
                this.dropdown.navigateNext();
10✔
146
                this.dropdownContainer.nativeElement.focus();
147
            } else if (this.allowCustomValues) {
1!
148
                this.addItem?.element.nativeElement.focus();
149
            }
150
        }
151
    }
×
152

153
    /**
154
     * Select a defined item
155
     *
76✔
156
     * @param item the item to be selected
66!
157
     * ```typescript
×
158
     * this.combo.select("New York");
159
     * ```
66✔
160
     */
66✔
161
    public select(item: any): void {
66✔
162
        if (item !== undefined) {
38✔
163
            const newSelection = this.selectionService.add_items(this.id, item instanceof Array ? item : [item], true);
38✔
164
            this.setSelection(newSelection);
165
        }
28✔
166
    }
167

76✔
168
    /**
19✔
169
     * Deselect the currently selected item
4✔
170
     *
171
     * @param item the items to be deselected
172
     * ```typescript
76✔
173
     * this.combo.deselect("New York");
30!
174
     * ```
×
175
     */
176
    public deselect(): void {
30✔
177
        this.clearSelection();
29✔
178
    }
179

180
    /** @hidden @internal */
1✔
181
    public writeValue(value: any): void {
1✔
182
        const oldSelection = this.selection;
183
        this.selectionService.select_items(this.id, this.isValid(value) ? [value] : [], true);
30✔
184
        this.cdr.markForCheck();
185
        this._displayValue = this.createDisplayText(super.selection, oldSelection);
186
        this._value = this.valueKey ? super.selection.map(item => item[this.valueKey]) : super.selection;
187
        this.filterValue = this._displayValue?.toString() || '';
76✔
188
    }
69✔
189

69✔
190
    /** @hidden @internal */
191
    public override ngAfterViewInit(): void {
76✔
192
        this.virtDir.contentSizeChange.pipe(takeUntil(this.destroy$)).subscribe(() => {
193
            if (this.selection.length > 0) {
194
                const index = this.virtDir.igxForOf.findIndex(e => {
195
                    let current = e? e[this.valueKey] : undefined;
291✔
196
                    if (this.valueKey === null || this.valueKey === undefined) {
10✔
197
                        current = e;
10✔
198
                    }
199
                    return current === this.selection[0];
291✔
200
                });
201
                if (!this.isRemote) {
202
                    // navigate to item only if we have local data
203
                    // as with remote data this will fiddle with igxFor's scroll handler
40✔
204
                    // and will trigger another chunk load which will break the visualization
38✔
205
                    this.dropdown.navigateItem(index);
206
                }
40✔
207
            }
9✔
208
        });
209
        this.dropdown.opening.pipe(takeUntil(this.destroy$)).subscribe((args) => {
40✔
210
            if (args.cancel) {
211
                return;
1✔
212
            }
1✔
213
            this._collapsing = false;
1✔
214
            const filtered = this.filteredData.find(this.findAllMatches);
215
            if (filtered === undefined || filtered === null) {
40✔
216
                this.filterValue = this.searchValue = this.comboInput.value;
2✔
217
                return;
218
            }
219
            this.filterValue = this.searchValue = '';
40✔
220
        });
21✔
221
        this.dropdown.opened.pipe(takeUntil(this.destroy$)).subscribe(() => {
222
            if (this.composing) {
40✔
223
                this.comboInput.focus();
40✔
224
            }
225
        });
226
        this.dropdown.closing.pipe(takeUntil(this.destroy$)).subscribe((args) => {
227
            if (args.cancel) {
3✔
228
                return;
1✔
229
            }
1✔
230
            if (this.getEditElement() && !args.event) {
231
                this._collapsing = true;
232
            } else {
233
                this.clearOnBlur();
234
                this._onTouchedCallback();
56✔
235
            }
7✔
236
            this.comboInput.focus();
7!
237
        });
×
238

239
        // in reactive form the control is not present initially
7✔
240
        // and sets the selection to an invalid value in writeValue method
6✔
241
        if (!this.isValid(this.selectedItem)) {
6✔
242
            this.selectionService.clear(this.id);
6✔
243
            this._displayValue = '';
6✔
244
        }
245

246
        super.ngAfterViewInit();
7✔
247
    }
7✔
248

249
    /** @hidden @internal */
49✔
250
    public override ngDoCheck(): void {
251
        if (this.data?.length && this.selection.length && !this._displayValue) {
2✔
252
            this._displayValue = this.createDisplayText(this.selection, []);
2✔
253
            this._value = this.valueKey ? this.selection.map(item => item[this.valueKey]) : this.selection;
254
        }
49✔
255
        super.ngDoCheck();
10✔
256
    }
10✔
257

258
    /** @hidden @internal */
49✔
259
    public override handleInputChange(event?: any): void {
49✔
260
        if (event !== undefined) {
261
            this.filterValue = this.searchValue = typeof event === 'string' ? event : event.target.value;
262
        }
263
        if (this.collapsed && this.comboInput.focused) {
33✔
264
            this.open();
1✔
265
        }
1!
UNCOV
266
        if (!this.comboInput.value.trim() && this.selection.length) {
×
267
            // handle clearing of input by space
268
            this.clearSelection();
1✔
269
            this._onChangeCallback(null);
270
            this.filterValue = '';
271
        }
272
        if (this.selection.length) {
273
            this.selectionService.clear(this.id);
4!
274
        }
×
UNCOV
275
        // when filtering the focused item should be the first item or the currently selected item
×
UNCOV
276
        if (!this.dropdown.focusedItem || this.dropdown.focusedItem.id !== this.dropdown.items[0].id) {
×
277
            this.dropdown.navigateFirst();
278
        }
4!
UNCOV
279
        super.handleInputChange(event);
×
280
        this.composing = true;
281
    }
282

283
    /** @hidden @internal */
284
    public handleInputClick(): void {
26✔
285
        if (this.collapsed) {
26✔
286
            this.open();
287
            this.comboInput.focus();
288
        }
289
    }
290

291
    /** @hidden @internal */
16✔
292
    public override handleKeyDown(event: KeyboardEvent): void {
13✔
293
        if (event.key === this.platformUtil.KEYMAP.ENTER) {
294
            const filtered = this.filteredData.find(this.findAllMatches);
16✔
295
            if (filtered === null || filtered === undefined) {
296
                return;
297
            }
298
            if (!this.dropdown.collapsed) {
30✔
299
                this.select(this.dropdown.focusedItem.itemID);
300
                event.preventDefault();
301
                event.stopPropagation();
302
                this.close();
6✔
303
            }
1✔
304
            // manually trigger text selection as it will not be triggered during editing
305
            this.textSelection.trigger();
5✔
306
            return;
5✔
307
        }
2✔
308
        if (event.key === this.platformUtil.KEYMAP.BACKSPACE
309
            || event.key === this.platformUtil.KEYMAP.DELETE) {
5✔
310
            this._updateInput = false;
5✔
311
            this.clearSelection(true);
5✔
312
        }
5✔
313
        if (!this.collapsed && event.key === this.platformUtil.KEYMAP.TAB) {
5✔
314
            this.clearOnBlur();
315
            this.close();
316
        }
317
        this.composing = false;
19✔
318
        super.handleKeyDown(event);
19✔
319
    }
16✔
320

321
    /** @hidden @internal */
19✔
322
    public handleKeyUp(event: KeyboardEvent): void {
323
        if (event.key === this.platformUtil.KEYMAP.ARROW_DOWN) {
324
            const firstItem = this.selectionService.first_item(this.id);
325
            this.dropdown.focusedItem = firstItem && this.filteredData.length > 0
32✔
326
                ? this.dropdown.items.find(i => i.itemID === firstItem)
32✔
327
                : this.dropdown.items[0];
32✔
328
            this.dropdownContainer.nativeElement.focus();
32✔
329
        }
1✔
330
    }
331

31✔
332
    /** @hidden @internal */
333
    public handleItemKeyDown(event: KeyboardEvent): void {
31✔
334
        if (event.key === this.platformUtil.KEYMAP.ARROW_UP && event.altKey) {
31✔
335
            this.close();
336
            this.comboInput.focus();
337
            return;
338
        }
6✔
339
        if (event.key === this.platformUtil.KEYMAP.ENTER) {
2✔
340
            this.comboInput.focus();
341
        }
342
    }
4✔
343

344
    /** @hidden @internal */
345
    public handleItemClick(): void {
346
        this.close();
347
        this.comboInput.focus();
5✔
348
    }
5✔
349

3✔
350
    /** @hidden @internal */
351
    public override onBlur(): void {
352
        // when clicking the toggle button to close the combo and immediately clicking outside of it
353
        // the collapsed state is not modified as the dropdown is still not closed
88✔
354
        if (this.collapsed || this._collapsing) {
88!
355
            this.clearOnBlur();
88✔
356
        }
88✔
357
        super.onBlur();
358
    }
359

360
    /** @hidden @internal */
361
    public getEditElement(): HTMLElement {
362
        return this.comboInput.nativeElement;
363
    }
88✔
364

71✔
365
    /** @hidden @internal */
366
    public handleClear(event: Event): void {
367
        if (this.disabled) {
88✔
368
            return;
87✔
369
        }
370
        this.clearSelection(true);
371
        if(!this.collapsed){
87✔
372
            this.focusSearchInput(true);
87✔
373
        }
87✔
374
        event.stopPropagation();
87✔
375

85!
376
        this.comboInput.value = this.filterValue = this.searchValue = '';
377
        this.dropdown.focusedItem = null;
378
        this.composing = false;
379
        this.comboInput.focus();
87✔
380
    }
87✔
381

382
    /** @hidden @internal */
1!
383
    public handleOpened(): void {
1✔
384
        this.triggerCheck();
385
        if (!this.comboInput.focused) {
386
            this.dropdownContainer.nativeElement.focus();
387
        }
238✔
388
        this.opened.emit({ owner: this });
31!
389
    }
31✔
390

391
    /** @hidden @internal */
207✔
392
    public override handleClosing(e: IBaseCancelableBrowserEventArgs): void {
393
        const args: IBaseCancelableBrowserEventArgs = { owner: this, event: e.event, cancel: e.cancel };
394
        this.closing.emit(args);
81✔
395
        e.cancel = args.cancel;
396
        if (e.cancel) {
126✔
397
            return;
398
        }
399

31✔
400
        this.composing = false;
10✔
401
        // explicitly update selection and trigger text selection so that we don't have to force CD
10✔
402
        this.textSelection.selected = true;
403
        this.textSelection.trigger();
21✔
404
    }
21✔
405

21!
406
    /** @hidden @internal */
407
    public focusSearchInput(opening?: boolean): void {
408
        if (opening) {
21✔
409
            this.dropdownContainer.nativeElement.focus();
53✔
410
        } else {
53✔
411
            this.comboInput.nativeElement.focus();
21✔
412
        }
413
    }
414

32✔
415
    /** @hidden @internal */
416
    public override onClick(event: Event): void {
417
        super.onClick(event);
418
        if (this.comboInput.value.length === 0) {
28✔
419
            this.virtDir.scrollTo(0);
28✔
420
        }
1✔
421
    }
422

28✔
423
    protected findAllMatches = (element: any): boolean => {
424
        const value = this.displayKey ? element[this.displayKey] : element;
425
        if (value === null || value === undefined || value === '') {
24✔
426
            // we can accept null, undefined and empty strings as empty display values
2!
427
            return true;
2✔
428
        }
2✔
429
        const searchValue = this.searchValue || this.comboInput.value;
1✔
430
        return !!searchValue && value.toString().toLowerCase().includes(searchValue.toLowerCase());
431
    };
2✔
432

433
    protected setSelection(newSelection: any): void {
22✔
434
        const newSelectionAsArray = newSelection ? Array.from(newSelection) as IgxComboItemComponent[] : [];
435
        const oldSelectionAsArray = Array.from(this.selectionService.get(this.id) || []);
22✔
436
        const displayText = this.createDisplayText(this.convertKeysToItems(newSelectionAsArray), oldSelectionAsArray);
15✔
437
        const args: ISimpleComboSelectionChangingEventArgs = {
438
            newSelection: newSelectionAsArray[0],
439
            oldSelection: oldSelectionAsArray[0],
440
            displayText,
×
441
            owner: this,
×
442
            cancel: false
443
        };
444
        if (args.newSelection !== args.oldSelection) {
16✔
445
            this.selectionChanging.emit(args);
16✔
446
        }
447
        // TODO: refactor below code as it sets the selection and the display text
448
        if (!args.cancel) {
218✔
449
            let argsSelection = this.isValid(args.newSelection)
122✔
450
                ? args.newSelection
451
                : [];
452
            argsSelection = Array.isArray(argsSelection) ? argsSelection : [argsSelection];
2✔
453
            this.selectionService.select_items(this.id, argsSelection, true);
454
            this._value = argsSelection;
455
            if (this._updateInput) {
456
                this.comboInput.value = this._displayValue = this.searchValue = displayText !== args.displayText
457
                    ? args.displayText
458
                    : this.createDisplayText(this.selection, [args.oldSelection]);
459
            }
460
            this._onChangeCallback(args.newSelection);
461
            this._updateInput = true;
462
        } else if (this.isRemote) {
463
            this.registerRemoteEntries(newSelectionAsArray, false);
2✔
464
        }
465
    }
466

467
    protected createDisplayText(newSelection: any[], oldSelection: any[]): string {
468
        if (this.isRemote) {
469
            const selection = this.valueKey ? newSelection.map(item => item[this.valueKey]) : newSelection;
470
            return this.getRemoteSelection(selection, oldSelection);
471
        }
2✔
472

473
        if (this.displayKey !== null
474
            && this.displayKey !== undefined
475
            && newSelection.length > 0) {
476
            return newSelection.filter(e => e).map(e => e[this.displayKey])[0]?.toString() || '';
477
        }
478

479
        return newSelection[0]?.toString() || '';
480
    }
481

482
    protected override getRemoteSelection(newSelection: any[], oldSelection: any[]): string {
483
        if (!newSelection.length) {
484
            this.registerRemoteEntries(oldSelection, false);
485
            return '';
486
        }
487

488
        this.registerRemoteEntries(oldSelection, false);
489
        this.registerRemoteEntries(newSelection);
490
        return Object.keys(this._remoteSelection).map(e => this._remoteSelection[e])[0] || '';
491
    }
492

493
    /** Contains key-value pairs of the selected valueKeys and their resp. displayKeys */
494
    protected override registerRemoteEntries(ids: any[], add = true) {
495
        const selection = this.getValueDisplayPairs(ids)[0];
496

497
        if (add && selection) {
498
            this._remoteSelection[selection[this.valueKey]] = selection[this.displayKey].toString();
499
        } else {
500
            this._remoteSelection = {};
501
        }
502
    }
503

504
    private clearSelection(ignoreFilter?: boolean): void {
505
        let newSelection = this.selectionService.get_empty();
506
        if (this.filteredData.length !== this.data.length && !ignoreFilter) {
507
            newSelection = this.selectionService.delete_items(this.id, this.selectionService.get_all_ids(this.filteredData, this.valueKey));
508
        }
509
        this.setSelection(newSelection);
510
    }
511

512
    private clearOnBlur(): void {
513
        if (this.isRemote) {
514
            const searchValue = this.searchValue || this.comboInput.value;
515
            const remoteValue = Object.keys(this._remoteSelection).map(e => this._remoteSelection[e])[0] || '';
516
            if (searchValue !== remoteValue) {
517
                this.clear();
518
            }
519
            return;
520
        }
521

522
        const filtered = this.filteredData.find(this.findMatch);
523
        // selecting null in primitive data returns undefined as the search text is '', but the item is null
524
        if (filtered === undefined && this.selectedItem !== null || !this.selection.length) {
525
            this.clear();
526
        }
527
    }
528

529
    private getElementVal(element: any): string {
530
        const elementVal = this.displayKey ? element[this.displayKey] : element;
531
        return String(elementVal);
532
    }
533

534
    private clear(): void {
535
        this.clearSelection(true);
536
        this.comboInput.value = this._displayValue = this.searchValue = '';
537
    }
538

539
    private isValid(value: any): boolean {
540
        return this.required
541
        ? value !== null && value !== '' && value !== undefined
542
        : value !== undefined;
543
    }
544
}
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