From 5c789f073b8da9455acff2e36080c2a3d2187220 Mon Sep 17 00:00:00 2001 From: "Frank Pigeon Jr." <4629398+fpigeonjr@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:05:37 -0500 Subject: [PATCH 1/2] test: add specs for under-covered form controls (#631) Raise unit-test coverage for the remaining under-covered form controls: checkbox, text, select, textarea, number, date-time, sam-form-control, radiobutton, toggle-switch, and the sam-sds-autocomplete search component. All new specs exercise components through their public API (inputs/outputs/DOM/ControlValueAccessor methods), not private implementation details. --- .../form-controls/checkbox/checkbox.spec.ts | 165 +++++++++++++ .../form-controls/date-time/date-time.spec.ts | 129 ++++++++++- .../form-controls/number/number.spec.ts | 31 +++ .../radiobutton/radiobutton.spec.ts | 12 + .../sam-form-control/sam-form-control.spec.ts | 76 +++++- .../autocomplete-search.component.spec.ts | 216 ++++++++++++++++++ .../form-controls/select/select.spec.ts | 23 ++ src/ui-kit/form-controls/text/text.spec.ts | 71 ++++++ .../form-controls/textarea/textarea.spec.ts | 37 +++ .../toggle-switch/toggle-switch.spec.ts | 6 + 10 files changed, 759 insertions(+), 7 deletions(-) create mode 100644 src/ui-kit/form-controls/checkbox/checkbox.spec.ts diff --git a/src/ui-kit/form-controls/checkbox/checkbox.spec.ts b/src/ui-kit/form-controls/checkbox/checkbox.spec.ts new file mode 100644 index 000000000..3409edc8c --- /dev/null +++ b/src/ui-kit/form-controls/checkbox/checkbox.spec.ts @@ -0,0 +1,165 @@ +import { TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { FormsModule, FormControl } from "@angular/forms"; +import { SamCheckboxComponent } from "./checkbox.component"; +import { FieldsetWrapper } from "../../wrappers/fieldset-wrapper"; +import { SamFormService } from "../../form-service"; +import type { ComponentFixture } from "@angular/core/testing"; + +describe("The Sam Checkbox component", () => { + describe("rendered tests", () => { + let component: SamCheckboxComponent; + let fixture: ComponentFixture; + + const options = [ + { value: "dc", label: "Washington DC", name: "dc" }, + { value: "ma", label: "Maryland", name: "ma" }, + { value: "va", label: "Virginia", name: "va" }, + ]; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [FormsModule], + declarations: [SamCheckboxComponent, FieldsetWrapper], + providers: [SamFormService], + }); + + fixture = TestBed.createComponent(SamCheckboxComponent); + component = fixture.componentInstance; + component.options = options; + component.label = "Pick a state"; + component.name = "my-checkboxes"; + }); + + it("should implement controlvalueaccessor", () => { + component.registerOnChange(() => undefined); + component.registerOnTouched(() => undefined); + component.setDisabledState(true); + expect(component.disabled).toBe(true); + component.writeValue(["dc"]); + expect(component.value).toEqual(["dc"]); + }); + + it("should display a checkbox for each option", () => { + fixture.detectChanges(); + const checkboxes = fixture.debugElement.queryAll(By.css("input")); + expect(checkboxes.length).toBe(options.length); + }); + + it("should check the checkbox when the option value is in the model", () => { + component.writeValue(["ma"]); + fixture.detectChanges(); + expect(component.isChecked("ma")).toBe(true); + expect(component.isChecked("dc")).toBe(false); + }); + + it("should insert a checked option into the model in options order", () => { + fixture.detectChanges(); + component.onCheckChanged("va", true, "va"); + component.onCheckChanged("dc", true, "dc"); + // dc appears before va in the options list, so it should be + // inserted before va even though it was checked second + expect(component.model).toEqual(["dc", "va"]); + }); + + it("should remove an option from the model when unchecked", () => { + fixture.detectChanges(); + component.writeValue(["dc", "ma"]); + component.onCheckChanged("dc", false, "dc"); + expect(component.model).toEqual(["ma"]); + }); + + it("should emit modelChange and optionSelected when an option changes", () => { + fixture.detectChanges(); + let emittedModel: unknown; + let emittedSelection: unknown; + component.modelChange.subscribe((val) => (emittedModel = val)); + component.optionSelected.subscribe((val) => (emittedSelection = val)); + + component.onCheckChanged("dc", true, "dc"); + + expect(emittedModel).toEqual(["dc"]); + expect(emittedSelection).toEqual({ + model: ["dc"], + selected: "dc", + id: "dc", + }); + }); + + it("should select all options when the select-all checkbox is checked", () => { + component.hasSelectAll = true; + fixture.detectChanges(); + component.onSelectAllChange(true); + expect(component.model).toEqual(["dc", "ma", "va"]); + }); + + it("should clear all options when the select-all checkbox is unchecked", () => { + component.hasSelectAll = true; + fixture.detectChanges(); + component.writeValue(["dc", "ma", "va"]); + component.onSelectAllChange(false); + expect(component.model).toEqual([]); + }); + + it("should not count disabled options as active options", () => { + component.options = [ + { value: "dc", label: "Washington DC", name: "dc" }, + { value: "ma", label: "Maryland", name: "ma", disabled: true }, + ]; + fixture.detectChanges(); + expect(component.activeOptions).toBe(1); + }); + + it("should not allow a disabled option to remain selected via setModelValue", () => { + component.options = [ + { value: "dc", label: "Washington DC", name: "dc" }, + { value: "ma", label: "Maryland", name: "ma", disabled: true }, + ]; + fixture.detectChanges(); + component.setModelValue(["dc", "ma"]); + expect(component.model).toEqual(["dc"]); + }); + + it("should derive the select-all label from the id when set", () => { + component.id = "my-id"; + fixture.detectChanges(); + expect(component.checkAllLabelOrId()).toBe("all-my-id"); + }); + + it("should derive the select-all label from the label when no id is set", () => { + component.id = undefined; + fixture.detectChanges(); + expect(component.checkAllLabelOrId()).toBe("all-Pick a state"); + }); + + it("should format errors from a form control on init and on value changes", () => { + const control = new FormControl([]); + component.control = control; + fixture.detectChanges(); + component.ngOnInit(); + + expect(() => control.setValue(["dc"])).not.toThrow(); + }); + + it("should show a hint message", () => { + const hint = "Life pro tip: eat vegetables"; + component.hint = hint; + fixture.detectChanges(); + expect(fixture.nativeElement.innerHTML).toContain(hint); + }); + + it("should show an error message", () => { + const errorMessage = "Uh-oh, something went wrong"; + component.errorMessage = errorMessage; + fixture.detectChanges(); + expect(fixture.nativeElement.innerHTML).toContain(errorMessage); + }); + + it("should show a label", () => { + const labelText = "Pick from the following options"; + component.label = labelText; + fixture.detectChanges(); + expect(fixture.nativeElement.innerHTML).toContain(labelText); + }); + }); +}); diff --git a/src/ui-kit/form-controls/date-time/date-time.spec.ts b/src/ui-kit/form-controls/date-time/date-time.spec.ts index be091f16c..8c3bd095c 100755 --- a/src/ui-kit/form-controls/date-time/date-time.spec.ts +++ b/src/ui-kit/form-controls/date-time/date-time.spec.ts @@ -1,11 +1,10 @@ import { TestBed } from "@angular/core/testing"; -import { FormsModule } from "@angular/forms"; +import { FormsModule, FormControl } from "@angular/forms"; // Load the implementations that should be tested import { SamDateTimeComponent } from "./date-time.component"; import { SamDateComponent } from "../date/date.component"; import { SamTimeComponent } from "../time/time.component"; -import { SamUIKitModule } from "../../index"; import { SamFormService } from "../../form-service"; import { SamWrapperModule } from "../../wrappers"; @@ -13,7 +12,6 @@ describe("The Sam Date Time component", () => { let component: SamDateTimeComponent; let fixture: any; - // provide our implementations or mocks to the dependency injector beforeEach(() => { TestBed.configureTestingModule({ imports: [SamWrapperModule, FormsModule], @@ -25,9 +23,134 @@ describe("The Sam Date Time component", () => { component = fixture.componentInstance; component.value = "2016-12-31T12:01"; component.name = "test"; + fixture.detectChanges(); }); it("Should compile", function () { expect(true).toBe(true); }); + + it("should throw a 508-compliance error when no name is provided", () => { + component.name = undefined; + expect(() => component.ngOnInit()).toThrowError(/508 compliance/); + }); + + it("should parse an initial value into date and time parts", () => { + component.writeValue("2016-12-31T12:01"); + expect(component.date).toBe("2016-12-31"); + expect(component.time).toBe("12:01"); + }); + + it("should reset date and time parts when written a falsy value", () => { + component.writeValue("2016-12-31T12:01"); + component.writeValue(undefined); + expect(component.value).toBe(""); + expect(component.date).toBe(""); + expect(component.time).toBe(""); + }); + + it("should log an error and leave date/time unset for an unparsable value", () => { + const errorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + component.date = undefined; + component.time = undefined; + component.value = "not a real date"; + component.parseValueString(); + expect(errorSpy).toHaveBeenCalledWith( + "[value] for sam-date-time is invalid" + ); + errorSpy.mockRestore(); + }); + + it("should emit the combined value through registered onChange", () => { + let emitted: string; + component.registerOnChange((val) => (emitted = val)); + component.emitChanges("2020-01-01T10:00"); + expect(component.value).toBe("2020-01-01T10:00"); + expect(emitted).toBe("2020-01-01T10:00"); + }); + + it("should not throw when emitting changes without a registered onChange", () => { + component.onChange = undefined; + expect(() => component.emitChanges("2020-01-01T10:00")).not.toThrow(); + }); + + it("should emit undefined when both date and time inputs are empty", () => { + let emitted: string | undefined = "not-called"; + component.registerOnChange((val) => (emitted = val)); + vi.spyOn(component.dateComponent, "isEmptyField").mockReturnValue(true); + vi.spyOn(component.timeComponent, "isEmptyField").mockReturnValue(true); + + component.onInputChange(); + + expect(emitted).toBeUndefined(); + }); + + it("should emit the combined date and time when both inputs are valid", () => { + let emitted: string | undefined; + component.registerOnChange((val) => (emitted = val)); + vi.spyOn(component.dateComponent, "isEmptyField").mockReturnValue(false); + vi.spyOn(component.timeComponent, "isEmptyField").mockReturnValue(false); + vi.spyOn(component.dateComponent, "isValid").mockReturnValue(true); + vi.spyOn(component.timeComponent, "isValid").mockReturnValue(true); + component.date = "2020-01-01"; + component.time = "10:00"; + + component.onInputChange(); + + expect(emitted).toBe("2020-01-01T10:00"); + }); + + it("should emit 'Invalid Date Time' when the inputs are non-empty but invalid", () => { + let emitted: string | undefined; + component.registerOnChange((val) => (emitted = val)); + vi.spyOn(component.dateComponent, "isEmptyField").mockReturnValue(false); + vi.spyOn(component.timeComponent, "isEmptyField").mockReturnValue(false); + vi.spyOn(component.dateComponent, "isValid").mockReturnValue(false); + vi.spyOn(component.timeComponent, "isValid").mockReturnValue(true); + + component.onInputChange(); + + expect(emitted).toBe("Invalid Date Time"); + }); + + it("should move focus to the time input's hour field on date blur", () => { + const focusSpy = vi.fn(); + component.timeComponent.hourV = { nativeElement: { focus: focusSpy } }; + + component.dateBlur(); + + expect(focusSpy).toHaveBeenCalled(); + }); + + it("should clear the date and time inputs on resetInput", () => { + component.date = "2020-01-01"; + component.time = "10:00"; + component.resetInput(); + expect(component.date).toBe(""); + expect(component.time).toBe(""); + }); + + it("should wire up a form control and format errors on status change without the form service", () => { + const control = new FormControl(""); + component.control = control; + component.useFormService = false; + + expect(() => { + component.ngOnInit(); + control.setValue("changed"); + }).not.toThrow(); + }); + + it("should format errors through the SamFormService when useFormService is set", () => { + const formService = TestBed.inject(SamFormService); + const control = new FormControl(""); + component.control = control; + component.useFormService = true; + component.ngOnInit(); + + expect(() => formService.fireSubmit(control.root)).not.toThrow(); + expect(() => formService.fireReset(control.root)).not.toThrow(); + }); }); diff --git a/src/ui-kit/form-controls/number/number.spec.ts b/src/ui-kit/form-controls/number/number.spec.ts index 6d115738f..34a03c118 100755 --- a/src/ui-kit/form-controls/number/number.spec.ts +++ b/src/ui-kit/form-controls/number/number.spec.ts @@ -84,5 +84,36 @@ describe("The Sam Number component", () => { fixture.detectChanges(); expect(component.value).toBe(11); }); + + it("should format errors through the SamFormService when useFormService is set", () => { + const formService = TestBed.inject(SamFormService); + const c = new FormControl(""); + component.name = "test-name"; + component.control = c; + component.useFormService = true; + component.ngOnInit(); + component.ngAfterViewInit(); + + expect(() => formService.fireSubmit(c.root)).not.toThrow(); + expect(() => formService.fireReset(c.root)).not.toThrow(); + }); + + it("should prevent invalid keys like 'e', '-', and '+'", () => { + fixture.detectChanges(); + const preventDefault = vi.fn(); + component.keyDownHandler({ key: "e", preventDefault }); + expect(preventDefault).toHaveBeenCalled(); + + preventDefault.mockClear(); + component.keyDownHandler({ key: "-", preventDefault }); + expect(preventDefault).toHaveBeenCalled(); + }); + + it("should allow valid numeric keys", () => { + fixture.detectChanges(); + const preventDefault = vi.fn(); + component.keyDownHandler({ key: "5", preventDefault }); + expect(preventDefault).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/ui-kit/form-controls/radiobutton/radiobutton.spec.ts b/src/ui-kit/form-controls/radiobutton/radiobutton.spec.ts index b1ca24390..f839ddc70 100755 --- a/src/ui-kit/form-controls/radiobutton/radiobutton.spec.ts +++ b/src/ui-kit/form-controls/radiobutton/radiobutton.spec.ts @@ -127,5 +127,17 @@ describe("The Sam Radio Buttons component", () => { component.name = "test-name"; component.ngOnInit(); }); + + it("should format errors when the control's value changes", () => { + const c = new FormControl("", () => { + return undefined; + }); + component.control = c; + component.name = "test-name"; + component.ngOnInit(); + component.ngAfterViewInit(); + + expect(() => c.setValue("va")).not.toThrow(); + }); }); }); diff --git a/src/ui-kit/form-controls/sam-form-control/sam-form-control.spec.ts b/src/ui-kit/form-controls/sam-form-control/sam-form-control.spec.ts index b6e3106c5..5d4991d78 100755 --- a/src/ui-kit/form-controls/sam-form-control/sam-form-control.spec.ts +++ b/src/ui-kit/form-controls/sam-form-control/sam-form-control.spec.ts @@ -1,10 +1,8 @@ -import { TestBed } from "@angular/core/testing"; import { SamFormControl } from "./"; -import { LabelWrapper } from "../../wrappers/label-wrapper"; -import { FormsModule, FormControl } from "@angular/forms"; -import { By } from "@angular/platform-browser"; +import { FormControl } from "@angular/forms"; import { SamFormService } from "../../form-service"; import { ChangeDetectorRef } from "@angular/core"; +import type { Mock } from "vitest"; describe("The Sam Text component", () => { let component: SamFormControl; @@ -61,4 +59,74 @@ describe("The Sam Text component", () => { expect(actual).toEqual(expected); }); }); + + describe("Reactive form validation wiring", () => { + let wrapperSpy: { formatErrors: Mock; clearError: Mock }; + + beforeEach(() => { + wrapperSpy = { + formatErrors: vi.fn(), + clearError: vi.fn(), + }; + (component as unknown as { wrapper: typeof wrapperSpy }).wrapper = + wrapperSpy; + component.cdr = { + detectChanges: vi.fn(), + } as unknown as ChangeDetectorRef; + }); + + it("should apply default validators and subscribe to status changes when disableValidation is false", () => { + const control = new FormControl(""); + component.control = control; + component.disableValidation = false; + component.useFormService = false; + + component.ngOnInit(); + control.setValue("changed"); + + expect(wrapperSpy.formatErrors).toHaveBeenCalledWith(control); + }); + + it("should preserve the control's own validator when disableValidation is true", () => { + const validator = () => null; + const control = new FormControl("", validator); + component.control = control; + component.disableValidation = true; + + expect(() => component.ngOnInit()).not.toThrow(); + expect(control.validator).toBeTruthy(); + }); + + it("should format errors on submit and clear on reset when useFormService is true", () => { + const formService = new SamFormService(); + component.samFormService = formService; + const control = new FormControl(""); + component.control = control; + component.useFormService = true; + + component.ngOnInit(); + formService.fireSubmit(control.root); + expect(wrapperSpy.formatErrors).toHaveBeenCalledWith(control); + + formService.fireReset(control.root); + expect(wrapperSpy.clearError).toHaveBeenCalled(); + }); + + it("should format errors on ngAfterViewInit when a control is present", () => { + const control = new FormControl(""); + component.control = control; + + component.ngAfterViewInit(); + + expect(wrapperSpy.formatErrors).toHaveBeenCalledWith(control); + }); + + it("should do nothing on ngOnInit/ngAfterViewInit when there is no control", () => { + component.control = undefined; + + expect(() => component.ngOnInit()).not.toThrow(); + expect(() => component.ngAfterViewInit()).not.toThrow(); + expect(wrapperSpy.formatErrors).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-search.component.spec.ts b/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-search.component.spec.ts index 733d6323b..3886d4ad4 100755 --- a/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-search.component.spec.ts +++ b/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-search.component.spec.ts @@ -569,4 +569,220 @@ describe("SamAutocompleteComponent", () => { const input = fixture.debugElement.query(By.css(".usa-input")); expect(input.nativeElement.value).toBe("a"); })); + + it("should clear the input and hide results on checkForFocus when no item is selected", () => { + component.inputValue = "partial"; + component.input.nativeElement.value = "partial"; + component.showResults = true; + component.checkForFocus({}); + expect(component.inputValue).toBe(""); + expect(component.input.nativeElement.value).toBe(""); + expect(component.showResults).toBe(false); + }); + + it("should not clear the input on checkForFocus when an item is already selected", () => { + component.model.items = [{ id: "1", name: "Level 1" }]; + component.inputValue = "Level 1"; + component.showResults = true; + component.checkForFocus({}); + expect(component.inputValue).toBe("Level 1"); + expect(component.showResults).toBe(false); + }); + + it("should call focusRemoved on checkForFocus when free text is enabled", fakeAsync(() => { + component.configuration.isFreeTextEnabled = true; + component.inputValue = "free text value"; + component.checkForFocus({}); + tick(200); + expect(component.showResults).toBe(false); + })); + + it("should clear the model and propagate the change on updateSingleModeFocusOutModel in single mode", () => { + component.model.items = [{ id: "1", name: "Level 1" }]; + let propagated: any; + component.registerOnChange((val) => (propagated = val)); + component.updateSingleModeFocusOutModel(); + expect(component.model.items.length).toBe(0); + expect(propagated).toBe(component.model); + }); + + it("should not clear the model on updateSingleModeFocusOutModel in multiple mode", () => { + component.configuration.selectionMode = SelectionMode.MULTIPLE; + component.model.items = [{ id: "1", name: "Level 1" }]; + component.updateSingleModeFocusOutModel(); + expect(component.model.items.length).toBe(1); + }); + + it("should hide the results and remove focus on clickOutSide", () => { + component.showResults = true; + component.clickOutSide({}); + expect(component.showResults).toBe(false); + }); + + it("should select an existing free text item on focus removed in single mode", fakeAsync(() => { + component.configuration.isFreeTextEnabled = true; + component.model.items = [{ id: "existing", name: "existing" }]; + component.inputValue = { id: "existing" } as any; + component.focusRemoved(); + tick(200); + expect(component.model.items.length).toBe(1); + })); + + it("should select a new free text item on focus removed in single mode when nothing is selected", fakeAsync(() => { + component.configuration.isFreeTextEnabled = true; + component.inputValue = "brand new value"; + component.focusRemoved(); + tick(200); + expect(component.model.items.length).toBe(1); + expect(component.model.items[0]["name"]).toBe("brand new value"); + })); + + it("should split on delimiters and select multiple free text items on focus removed", fakeAsync(() => { + component.configuration.selectionMode = SelectionMode.MULTIPLE; + component.configuration.isFreeTextEnabled = true; + component.configuration.isDelimiterEnabled = true; + component.inputValue = "one,two"; + component.focusRemoved(); + tick(200); + expect(component.model.items.length).toBe(2); + expect(component.inputValue).toBe(""); + })); + + it("should select a single free text item on focus removed in multiple mode without a delimiter", fakeAsync(() => { + component.configuration.selectionMode = SelectionMode.MULTIPLE; + component.configuration.isFreeTextEnabled = true; + component.configuration.isDelimiterEnabled = false; + component.inputValue = "single value"; + component.focusRemoved(); + tick(200); + expect(component.model.items.length).toBe(1); + expect(component.inputValue).toBe(""); + })); + + it("should clear the input on focus removed in multiple mode when free text and tag mode are both off", fakeAsync(() => { + component.configuration.selectionMode = SelectionMode.MULTIPLE; + component.configuration.isFreeTextEnabled = false; + component.configuration.isTagModeEnabled = false; + component.inputValue = "leftover text"; + component.focusRemoved(); + tick(200); + expect(component.inputValue).toBe(""); + })); + + it("should block editing when inputReadOnly is true", () => { + component.configuration.inputReadOnly = true; + expect(component.onkeypress({})).toBe(false); + }); + + it("should limit the model fields to the essential fields when configured", () => { + component.essentialModelFields = true; + const item = { + id: "1", + name: "Level 1", + subtext: "id 1", + extra: "ignored", + }; + component.selectItem(item); + const stored = component.model.items[0] as any; + expect(Object.keys(stored).sort()).toEqual( + ["id", "name", "subtext"].sort() + ); + expect(stored.extra).toBeUndefined(); + }); + + it("should focus the input when openOptions is called", () => { + const focusSpy = vi.spyOn(component.input.nativeElement, "focus"); + component.openOptions(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it("should not show free text suggestion when free text is disabled", () => { + component.configuration.isFreeTextEnabled = false; + expect(component.showFreeText()).toBe(false); + }); + + it("should not show free text suggestion when the input is empty", () => { + component.configuration.isFreeTextEnabled = true; + component.inputValue = ""; + expect(component.showFreeText()).toBe(false); + }); + + it("should show free text suggestion when the input doesn't match a result or selection", () => { + component.configuration.isFreeTextEnabled = true; + component.inputValue = "unmatched"; + component.results = [{ id: "1", name: "Level 1" }]; + expect(component.showFreeText()).toBe(true); + }); + + it("should not show free text suggestion when the input matches an existing result", () => { + component.configuration.isFreeTextEnabled = true; + component.inputValue = "Level 1"; + component.results = [{ id: "1", name: "Level 1" }]; + expect(component.showFreeText()).toBe(false); + }); + + it("should apply the hide-cursor class when readonly and in multiple selection mode", () => { + component.configuration.inputReadOnly = true; + component.configuration.selectionMode = SelectionMode.MULTIPLE; + expect(component.getClass()).toBe("hide-cursor"); + }); + + it("should not apply the hide-cursor class otherwise", () => { + component.configuration.inputReadOnly = false; + expect(component.getClass()).toBe(""); + }); + + it("should request more results on scroll when the list is scrolled near the bottom", fakeAsync(() => { + component.inputFocusHandler(); + tick(); + fixture.detectChanges(); + const dom = component.resultsListElement.nativeElement; + Object.defineProperty(dom, "offsetHeight", { + value: 100, + configurable: true, + }); + Object.defineProperty(dom, "scrollTop", { value: 900, configurable: true }); + Object.defineProperty(dom, "scrollHeight", { + value: 1000, + configurable: true, + }); + const before = component.results.length; + + component.onScroll(); + tick(); + + expect(component.results.length).toBeGreaterThanOrEqual(before); + })); + + it("should not request more results on scroll when all results are already loaded", fakeAsync(() => { + component.inputFocusHandler(); + tick(); + fixture.detectChanges(); + component.results = component.results.slice(0, 1); + (component as any).maxResults = 1; + + expect(() => component.onScroll()).not.toThrow(); + })); + + it("should ignore writeValue calls for values that aren't a SAMSDSSelectedItemModel", () => { + component.model = new SAMSDSSelectedItemModel(); + component.writeValue({ items: [{ id: "1" }] }); + expect(component.model.items.length).toBe(0); + }); + + it("should clear the input value on writeValue when the model has no items", () => { + component.inputValue = "stale"; + const model = new SAMSDSSelectedItemModel(); + component.writeValue(model); + expect(component.inputValue).toBe(""); + }); + + it("should set the input value from the first item on writeValue in multiple selection mode", () => { + component.configuration.selectionMode = SelectionMode.MULTIPLE; + const model = new SAMSDSSelectedItemModel([{ id: "1", name: "Level 1" }]); + component.writeValue(model); + // multiple mode intentionally leaves inputValue alone; assert no throw + // and that the model was still stored + expect(component.model).toBe(model); + }); }); diff --git a/src/ui-kit/form-controls/select/select.spec.ts b/src/ui-kit/form-controls/select/select.spec.ts index 9eee30003..299a40b03 100755 --- a/src/ui-kit/form-controls/select/select.spec.ts +++ b/src/ui-kit/form-controls/select/select.spec.ts @@ -121,5 +121,28 @@ describe("The Sam Select component", () => { component.ngOnInit(); component.ngAfterViewInit(); }); + + it("should format errors through the SamFormService when useFormService is set", () => { + const formService = TestBed.inject(SamFormService); + const c = new FormControl([]); + component.control = c; + component.useFormService = true; + component.ngOnInit(); + + expect(() => formService.fireSubmit(c.root)).not.toThrow(); + expect(() => formService.fireReset(c.root)).not.toThrow(); + }); + + it("should call onTouched and format errors on blur", () => { + const c = new FormControl([]); + component.control = c; + component.ngOnInit(); + component.ngAfterViewInit(); + + let touched = false; + component.registerOnTouched(() => (touched = true)); + component.onBlur(); + expect(touched).toBe(true); + }); }); }); diff --git a/src/ui-kit/form-controls/text/text.spec.ts b/src/ui-kit/form-controls/text/text.spec.ts index 7b26f987a..0dbf5e16b 100755 --- a/src/ui-kit/form-controls/text/text.spec.ts +++ b/src/ui-kit/form-controls/text/text.spec.ts @@ -62,5 +62,76 @@ describe("The Sam Text component", () => { fixture.detectChanges(); expect(fixture.nativeElement.innerHTML).toContain(labelText); }); + + it("should format errors and re-validate on control status changes", () => { + const control = new FormControl("", []); + component.control = control; + component.required = true; + component.maxlength = 5; + fixture.detectChanges(); + + expect(() => { + control.setValue("toolong"); + control.updateValueAndValidity(); + }).not.toThrow(); + expect(control.hasError("maxlength")).toBe(true); + }); + + it("should format errors through the SamFormService when useFormService is set", () => { + const formService = TestBed.inject(SamFormService); + const control = new FormControl(""); + component.control = control; + component.useFormService = true; + fixture.detectChanges(); + + expect(() => formService.fireSubmit(control.root)).not.toThrow(); + expect(() => formService.fireReset(control.root)).not.toThrow(); + }); + + it("should trim trailing whitespace on blur", () => { + fixture.detectChanges(); + component.writeValue("hello "); + component.focusEvent.next({ + type: "blur", + event: { target: { value: "hello " } }, + }); + expect(component.value).toBe("hello"); + }); + + it("should mark the control as touched on focus", () => { + fixture.detectChanges(); + let touched = false; + component.registerOnTouched(() => (touched = true)); + component.focusEvent.next({ type: "focus", event: {} }); + expect(touched).toBe(true); + }); + + it("should update the value when the change event fires with the configured emitOn", () => { + fixture.detectChanges(); + let changed: string | undefined; + component.registerOnChange((val) => (changed = val)); + component.emitOn = "change"; + component.changeEvent.next({ + type: "change", + event: { target: { value: "changed value" } }, + }); + expect(component.value).toBe("changed value"); + expect(changed).toBe("changed value"); + }); + + it("should ignore input events that don't match the configured emitOn", () => { + fixture.detectChanges(); + component.emitOn = "change"; + component.changeEvent.next({ + type: "input", + event: { target: { value: "ignored" } }, + }); + expect(component.value).toBe(""); + }); + + it("should unsubscribe on destroy without throwing", () => { + fixture.detectChanges(); + expect(() => fixture.destroy()).not.toThrow(); + }); }); }); diff --git a/src/ui-kit/form-controls/textarea/textarea.spec.ts b/src/ui-kit/form-controls/textarea/textarea.spec.ts index a5beac487..af4246e3c 100755 --- a/src/ui-kit/form-controls/textarea/textarea.spec.ts +++ b/src/ui-kit/form-controls/textarea/textarea.spec.ts @@ -142,5 +142,42 @@ describe("The Sam Textarea component", () => { component.writeValue("test"); expect(component.value).toBe("test"); }); + + it("should format errors through the SamFormService when useFormService is set", () => { + const formService = TestBed.inject(SamFormService); + const c = new FormControl(""); + component.control = c; + component.useFormService = true; + component.ngOnInit(); + component.ngAfterViewInit(); + + expect(() => formService.fireSubmit(c.root)).not.toThrow(); + expect(() => formService.fireReset(c.root)).not.toThrow(); + }); + + it("should emit focus events", () => { + fixture.detectChanges(); + let focusEventValue: any; + let focusValue: any; + component.focusEvent.subscribe((val) => (focusEventValue = val)); + component.focus.subscribe((val) => (focusValue = val)); + component.onFocus("evt"); + expect(focusEventValue).toBe("evt"); + expect(focusValue).toBe("evt"); + }); + + it("should trim trailing whitespace on blur", () => { + fixture.detectChanges(); + component.value = "hello "; + component.onBlur(); + expect(component.value).toBe("hello"); + }); + + it("should not modify the value on blur when there's no trailing whitespace", () => { + fixture.detectChanges(); + component.value = "hello"; + component.onBlur(); + expect(component.value).toBe("hello"); + }); }); }); diff --git a/src/ui-kit/form-controls/toggle-switch/toggle-switch.spec.ts b/src/ui-kit/form-controls/toggle-switch/toggle-switch.spec.ts index c86f3f8c0..0d0267d27 100755 --- a/src/ui-kit/form-controls/toggle-switch/toggle-switch.spec.ts +++ b/src/ui-kit/form-controls/toggle-switch/toggle-switch.spec.ts @@ -24,6 +24,12 @@ describe("The Sam Toggle Switch component", () => { expect(component.isSwitchOn).toBe(false); }); + it("should stop propagation when the event supports it", () => { + const stopPropagation = vi.fn(); + component.onSwitchClick({ target: { checked: true }, stopPropagation }); + expect(stopPropagation).toHaveBeenCalled(); + }); + it("should implement controlvalueaccessor", () => { component.onChange(); component.onTouched(); From 86b27a47c14e9686c7b27ea84a0740afb54e8011 Mon Sep 17 00:00:00 2001 From: "Frank Pigeon Jr." <4629398+fpigeonjr@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:15:11 -0500 Subject: [PATCH 2/2] Address PR review feedback - Assert '+' is actually blocked by keyDownHandler, matching the test's stated description (Copilot review comment). - Rename the MULTIPLE-mode writeValue test to match its actual assertion (inputValue is left unchanged, not set from the first item) and assert that behavior explicitly (Copilot review comment). --- src/ui-kit/form-controls/number/number.spec.ts | 4 ++++ .../autocomplete-search.component.spec.ts | 8 +++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/ui-kit/form-controls/number/number.spec.ts b/src/ui-kit/form-controls/number/number.spec.ts index 34a03c118..1f86a2a15 100755 --- a/src/ui-kit/form-controls/number/number.spec.ts +++ b/src/ui-kit/form-controls/number/number.spec.ts @@ -107,6 +107,10 @@ describe("The Sam Number component", () => { preventDefault.mockClear(); component.keyDownHandler({ key: "-", preventDefault }); expect(preventDefault).toHaveBeenCalled(); + + preventDefault.mockClear(); + component.keyDownHandler({ key: "+", preventDefault }); + expect(preventDefault).toHaveBeenCalled(); }); it("should allow valid numeric keys", () => { diff --git a/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-search.component.spec.ts b/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-search.component.spec.ts index 3886d4ad4..8cadd869d 100755 --- a/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-search.component.spec.ts +++ b/src/ui-kit/form-controls/sam-sds-autocomplete/autocomplete-search/autocomplete-search.component.spec.ts @@ -777,12 +777,14 @@ describe("SamAutocompleteComponent", () => { expect(component.inputValue).toBe(""); }); - it("should set the input value from the first item on writeValue in multiple selection mode", () => { + it("should leave inputValue unchanged on writeValue in multiple selection mode", () => { component.configuration.selectionMode = SelectionMode.MULTIPLE; + component.inputValue = "unchanged"; const model = new SAMSDSSelectedItemModel([{ id: "1", name: "Level 1" }]); component.writeValue(model); - // multiple mode intentionally leaves inputValue alone; assert no throw - // and that the model was still stored + // multiple mode intentionally leaves inputValue alone; assert it wasn't + // touched and that the model was still stored + expect(component.inputValue).toBe("unchanged"); expect(component.model).toBe(model); }); });