diff --git a/coverage-floor.json b/coverage-floor.json index dd6b11b40..9d7fc2a39 100644 --- a/coverage-floor.json +++ b/coverage-floor.json @@ -1,6 +1,6 @@ { - "statements": 88.73, - "branches": 78.65, - "functions": 85.9, - "lines": 88.72 + "statements": 95.6, + "branches": 90.09, + "functions": 92.78, + "lines": 95.67 } diff --git a/src/ui-kit/components/banner/banner.spec.ts b/src/ui-kit/components/banner/banner.spec.ts index 327277aab..855256afb 100755 --- a/src/ui-kit/components/banner/banner.spec.ts +++ b/src/ui-kit/components/banner/banner.spec.ts @@ -26,4 +26,20 @@ describe("The Sam Banner component", () => { component.toggleDetails(); expect(component.showDetail).toBe(true); }); + + it("should close the detail when it is open", () => { + component.toggleDetails(); + expect(component.showDetail).toBe(true); + + component.closeDetail(); + + expect(component.showDetail).toBe(false); + }); + + it("should do nothing when the detail is already closed", () => { + expect(component.showDetail).toBe(false); + + expect(() => component.closeDetail()).not.toThrow(); + expect(component.showDetail).toBe(false); + }); }); diff --git a/src/ui-kit/components/data-table/sort-header.component.spec.ts b/src/ui-kit/components/data-table/sort-header.component.spec.ts new file mode 100644 index 000000000..a45f88e6b --- /dev/null +++ b/src/ui-kit/components/data-table/sort-header.component.spec.ts @@ -0,0 +1,148 @@ +import { ChangeDetectorRef } from "@angular/core"; +import { Subject } from "rxjs"; +import { + SamSortHeaderComponent, + SamSortHeaderIntl, +} from "./sort-header.component"; +import { SamSortDirective } from "./sort.directive"; + +describe("SamSortHeaderIntl", () => { + it("returns the id as the button label", () => { + const intl = new SamSortHeaderIntl(); + expect(intl.sortButtonLabel("name")).toBe("name"); + }); + + it("describes ascending sort", () => { + const intl = new SamSortHeaderIntl(); + expect(intl.sortDescriptionLabel("name", "asc")).toBe( + "Sorted by name ascending" + ); + }); + + it("describes descending sort", () => { + const intl = new SamSortHeaderIntl(); + expect(intl.sortDescriptionLabel("name", "desc")).toBe( + "Sorted by name descending" + ); + }); +}); + +describe("SamSortHeaderComponent", () => { + function createSort(overrides: Partial = {}) { + return { + active: "", + direction: "", + samSortChange: new Subject(), + register: vi.fn(), + deregister: vi.fn(), + sort: vi.fn(), + ...overrides, + } as unknown as SamSortDirective; + } + + function createComponent(sort: SamSortDirective, cdkColumnDef: any = null) { + const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef; + return new SamSortHeaderComponent( + new SamSortHeaderIntl(), + cdr, + sort, + cdkColumnDef + ); + } + + it("subscribes to the sort directive's samSortChange and marks for check", () => { + const sort = createSort(); + const header = createComponent(sort); + const cdrSpy = vi.spyOn(header["_changeDetectorRef"], "markForCheck"); + (sort.samSortChange as Subject).next(); + expect(cdrSpy).toHaveBeenCalled(); + }); + + it("defaults the id from the containing CdkColumnDef when none is provided", () => { + const sort = createSort(); + const header = createComponent(sort, { name: "columnName" }); + header.ngOnInit(); + expect(header.id).toBe("columnName"); + expect(sort.register).toHaveBeenCalledWith(header); + }); + + it("keeps an explicitly-set id rather than defaulting from the column def", () => { + const sort = createSort(); + const header = createComponent(sort, { name: "columnName" }); + header.id = "explicitId"; + header.ngOnInit(); + expect(header.id).toBe("explicitId"); + }); + + it("registers with the sort directive on init", () => { + const sort = createSort(); + const header = createComponent(sort); + header.id = "name"; + header.ngOnInit(); + expect(sort.register).toHaveBeenCalledWith(header); + }); + + it("deregisters and unsubscribes on destroy", () => { + const sort = createSort(); + const header = createComponent(sort); + header.id = "name"; + header.ngOnInit(); + const unsubscribeSpy = vi.spyOn(header.sortSubscription, "unsubscribe"); + header.ngOnDestroy(); + expect(sort.deregister).toHaveBeenCalledWith(header); + expect(unsubscribeSpy).toHaveBeenCalled(); + }); + + it("reports sorted when this header's id matches the active sort and a direction is set", () => { + const sort = createSort({ active: "name", direction: "asc" }); + const header = createComponent(sort); + header.id = "name"; + expect(header._isSorted()).toBeTruthy(); + }); + + it("reports not sorted when a different header is active", () => { + const sort = createSort({ active: "other", direction: "asc" }); + const header = createComponent(sort); + header.id = "name"; + expect(header._isSorted()).toBeFalsy(); + }); + + it("reports not sorted when the direction is empty even if this header is active", () => { + const sort = createSort({ active: "name", direction: "" }); + const header = createComponent(sort); + header.id = "name"; + expect(header._isSorted()).toBeFalsy(); + }); + + it("samSortHeaderSorted() reflects _isSorted()", () => { + const sort = createSort({ active: "name", direction: "asc" }); + const header = createComponent(sort); + header.id = "name"; + expect(header.samSortHeaderSorted()).toBeTruthy(); + }); + + it("delegates hostClick to the sort directive when not disabled", () => { + const sort = createSort(); + const header = createComponent(sort); + header.disabled = false; + header.hostClick(); + expect(sort.sort).toHaveBeenCalledWith(header); + }); + + it("does nothing on hostClick when disabled", () => { + const sort = createSort(); + const header = createComponent(sort); + header.disabled = true; + header.hostClick(); + expect(sort.sort).not.toHaveBeenCalled(); + }); + + it("coerces disableClear through coerceBooleanProperty", () => { + const sort = createSort(); + const header = createComponent(sort); + header.disableClear = "" as never; + expect(header.disableClear).toBe(true); + header.disableClear = false; + expect(header.disableClear).toBe(false); + }); +}); diff --git a/src/ui-kit/components/header-next/header.spec.ts b/src/ui-kit/components/header-next/header.spec.ts index 780c79f36..58b60ab53 100755 --- a/src/ui-kit/components/header-next/header.spec.ts +++ b/src/ui-kit/components/header-next/header.spec.ts @@ -94,4 +94,76 @@ describe("SamHeaderNextComponent", () => { "notification icon exists" ); }); + + describe("openMobileNav() / closeMobileNav() / navAnimationEnd()", () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it("activates the mobile nav", () => { + component.openMobileNav(); + expect(component.mobileNavActive).toBe(true); + }); + + it("deactivates the mobile nav and refocuses the open-nav button", () => { + component.mobileNavActive = true; + const focusSpy = vi.spyOn(component.openNavBtn.nativeElement, "focus"); + + component.closeMobileNav(); + + expect(component.mobileNavActive).toBe(false); + expect(focusSpy).toHaveBeenCalled(); + }); + + it("focuses the close-nav button once the open animation ends", () => { + const focusSpy = vi.spyOn(component.closeNavBtn.nativeElement, "focus"); + + component.navAnimationEnd(); + + expect(focusSpy).toHaveBeenCalled(); + }); + }); + + describe("onBrowserResize()", () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it("deactivates the mobile nav when it's active and the close button is no longer visible", () => { + component.mobileNavActive = true; + vi.spyOn( + component.closeNavBtn.nativeElement, + "getBoundingClientRect" + ).mockReturnValue({ width: 0 } as DOMRect); + + component.onBrowserResize({} as Event); + + expect(component.mobileNavActive).toBe(false); + }); + + it("leaves the mobile nav active when the close button is still visible", () => { + component.mobileNavActive = true; + vi.spyOn( + component.closeNavBtn.nativeElement, + "getBoundingClientRect" + ).mockReturnValue({ width: 40 } as DOMRect); + + component.onBrowserResize({} as Event); + + expect(component.mobileNavActive).toBe(true); + }); + + it("does nothing when the mobile nav is not active", () => { + component.mobileNavActive = false; + const getRectSpy = vi.spyOn( + component.closeNavBtn.nativeElement, + "getBoundingClientRect" + ); + + component.onBrowserResize({} as Event); + + expect(getRectSpy).not.toHaveBeenCalled(); + expect(component.mobileNavActive).toBe(false); + }); + }); }); diff --git a/src/ui-kit/components/image/image.spec.ts b/src/ui-kit/components/image/image.spec.ts index d53e7a5f3..213657a4f 100755 --- a/src/ui-kit/components/image/image.spec.ts +++ b/src/ui-kit/components/image/image.spec.ts @@ -153,4 +153,82 @@ describe("The Sam Image Component", () => { expect(overEvent.stopPropagation).toHaveBeenCalled(); expect(overEvent.preventDefault).toHaveBeenCalled(); }); + + it("does not toggle edit mode when the component is not editable", () => { + component.editable = false; + fixture.detectChanges(); + const before = component.editMode; + + de.query(By.css("button.edit-button")).nativeElement.dispatchEvent( + new Event("click") + ); + + expect(component.editMode).toBe(before); + }); + + it("does not emit fileChange on save when no temporary image is staged", () => { + const emitted = vi.fn(); + component.fileChange.subscribe(emitted); + + de.query(By.css("button.save-button")).nativeElement.dispatchEvent( + new Event("click") + ); + + expect(emitted).not.toHaveBeenCalled(); + }); + + it("truncates a long file name in the file picker label", () => { + const file = new File(["hello"], "a-very-long-file-name.png", { + type: "image/png", + }); + const fileInputEl: HTMLInputElement = de.query( + By.css("input#file") + ).nativeElement; + Object.defineProperty(fileInputEl, "files", { value: [file] }); + fileInputEl.dispatchEvent(new Event("change")); + + expect(component.generateFilePickerLabelText()).toBe("a-very-l..."); + }); + + it("falls back to placeholder label text when no file is staged", () => { + expect(component.generateFilePickerLabelText()).toBe("Select a file"); + }); + + it("labels the done button 'Save' only while an image is staged", () => { + expect(component.generateDoneText()).toBe("Done"); + + const file = new File(["hello"], "x.png", { type: "image/png" }); + const fileInputEl: HTMLInputElement = de.query( + By.css("input#file") + ).nativeElement; + Object.defineProperty(fileInputEl, "files", { value: [file] }); + fileInputEl.dispatchEvent(new Event("change")); + + expect(component.generateDoneText()).toBe("Save"); + }); + + it("prefers the staged image over the committed src", () => { + expect(component.generateSrc()).toBe(washingtonImg); + + const file = new File(["hello"], "x.png", { type: "image/png" }); + const fileInputEl: HTMLInputElement = de.query( + By.css("input#file") + ).nativeElement; + Object.defineProperty(fileInputEl, "files", { value: [file] }); + fileInputEl.dispatchEvent(new Event("change")); + + expect(component.generateSrc()).toContain("data:fake"); + }); + + it("hides the edit button when not editable or already editing", () => { + component.editable = false; + expect(component.hideEditButton()).toBe(true); + + component.editable = true; + component.editMode = true; + expect(component.hideEditButton()).toBe(true); + + component.editMode = false; + expect(component.hideEditButton()).toBe(false); + }); }); diff --git a/src/ui-kit/components/multiselect-dropdown/multiselect-dropdown.spec.ts b/src/ui-kit/components/multiselect-dropdown/multiselect-dropdown.spec.ts index e24fea318..c2608a646 100755 --- a/src/ui-kit/components/multiselect-dropdown/multiselect-dropdown.spec.ts +++ b/src/ui-kit/components/multiselect-dropdown/multiselect-dropdown.spec.ts @@ -81,4 +81,106 @@ describe("Sam Multiselect Dropdown Component", function () { expect(label[0].innerHTML).toContain(component.label); }); }); + + describe("labelForValue()", () => { + it("returns undefined when the value does not match any option", () => { + expect(component.labelForValue("unknown")).toBeUndefined(); + }); + + it("returns the label for a matching option", () => { + expect(component.labelForValue("ma")).toBe("Maryland"); + }); + }); + + describe("updateLabel()", () => { + it("throws when model.length is not a valid, comparable number", () => { + // NaN fails every branch condition (===0, ===1, >1 with equal option + // count, >1) so execution falls through to the final else. + component.model = { length: NaN } as never; + expect(() => component.updateLabel()).toThrow( + "Unable to display dropdown label" + ); + }); + }); + + describe("isEnterEvent()", () => { + it("returns true for a click event", () => { + expect(component.isEnterEvent({ type: "click" })).toBe(true); + }); + + it("returns true when keyCode matches the enter constant used in this component", () => { + expect(component.isEnterEvent({ type: "keydown", keyCode: 32 })).toBe( + true + ); + }); + + it("returns true when keyCode matches the space constant used in this component", () => { + expect(component.isEnterEvent({ type: "keydown", keyCode: 13 })).toBe( + true + ); + }); + + it("returns false for an unrelated keydown", () => { + expect(component.isEnterEvent({ type: "keydown", keyCode: 65 })).toBe( + false + ); + }); + }); + + describe("toggleItemList()", () => { + it("toggles the list's visibility to visible when triggered by a click", () => { + component.list = { + nativeElement: { style: { visibility: "hidden" } }, + } as never; + component.toggleItemList({ type: "click" }); + expect(component.list.nativeElement.style.visibility).toBe("visible"); + }); + + it("toggles the list's visibility back to hidden on a second click", () => { + component.list = { + nativeElement: { style: { visibility: "visible" } }, + } as never; + component.toggleItemList({ type: "click" }); + expect(component.list.nativeElement.style.visibility).toBe("hidden"); + }); + + it("does nothing for an event that is not click/enter/space", () => { + component.list = { + nativeElement: { style: { visibility: "hidden" } }, + } as never; + component.toggleItemList({ type: "keydown", keyCode: 65 }); + expect(component.list.nativeElement.style.visibility).toBe("hidden"); + }); + }); + + describe("onMoveOutside()", () => { + it("hides the list when it is currently visible", () => { + component.list = { + nativeElement: { style: { visibility: "visible" } }, + } as never; + component.onMoveOutside(); + expect(component.list.nativeElement.style.visibility).toBe("hidden"); + }); + + it("does nothing when the list is already hidden", () => { + component.list = { + nativeElement: { style: { visibility: "hidden" } }, + } as never; + component.onMoveOutside(); + expect(component.list.nativeElement.style.visibility).toBe("hidden"); + }); + }); + + describe("modelChanged()", () => { + it("refreshes the label and emits the new model", () => { + const spy = vi.fn(); + component.modelChange.subscribe(spy); + component.model = ["ma"]; + + component.modelChanged(["ma"]); + + expect(component.elementLabel).toBe("Maryland"); + expect(spy).toHaveBeenCalledWith(["ma"]); + }); + }); }); diff --git a/src/ui-kit/components/sidenav/services/sidenav.service.spec.ts b/src/ui-kit/components/sidenav/services/sidenav.service.spec.ts new file mode 100644 index 000000000..5c6f20ccd --- /dev/null +++ b/src/ui-kit/components/sidenav/services/sidenav.service.spec.ts @@ -0,0 +1,115 @@ +import { SidenavService } from "./sidenav.service"; + +describe("SidenavService", () => { + let service: SidenavService; + + beforeEach(() => { + service = new SidenavService(); + }); + + it("stores and returns children via setChildren()", () => { + const children = [{ label: "a" }]; + expect(service.setChildren(children)).toBe(children); + }); + + it("stores the model via setModel()", () => { + const model = { label: "root" }; + service.setModel(model); + // No direct getter for the model itself, but getSelectedModel() reads + // from it, so exercise that path to confirm it was actually stored. + expect(service.getSelectedModel()).toBe(model); + }); + + describe("updateData()", () => { + it("appends a new index when the depth has not been set yet", () => { + service.updateData(0, 2); + expect(service.getData()).toEqual([2]); + }); + + it("overwrites the index at an already-set depth", () => { + service.updateData(0, 2); + service.updateData(0, 3); + expect(service.getData()).toEqual([3]); + }); + + it("truncates any deeper indices when updating a shallower depth", () => { + service.updateData(0, 1); + service.updateData(1, 2); + service.updateData(2, 3); + service.updateData(0, 5); + expect(service.getData()).toEqual([5]); + }); + }); + + describe("overrideData()", () => { + it("appends a new index when the depth has not been set yet", () => { + service.overrideData(0, 2); + expect(service.getData()).toEqual([2]); + }); + + it("overwrites the index at an already-set depth and trims deeper indices", () => { + service.updateData(0, 1); + service.updateData(1, 2); + service.overrideData(0, 5); + expect(service.getData()).toEqual([5]); + }); + + it("does not trim when there are no deeper indices to remove", () => { + service.overrideData(0, 1); + service.overrideData(0, 2); + expect(service.getData()).toEqual([2]); + }); + }); + + describe("getSelectedModel()", () => { + it("walks the model's children using the stored index path", () => { + const model = { + children: [ + { label: "a", children: [{ label: "a-1" }] }, + { label: "b" }, + ], + }; + service.setModel(model); + service.updateData(0, 0); + service.updateData(1, 0); + const selected = service.getSelectedModel(); + expect(selected.label).toBe("a-1"); + expect(selected.selection).toEqual([0, 0]); + }); + }); + + describe("getPath()", () => { + it("builds a path by concatenating each node's route", () => { + const model = { + children: [ + { + label: "a", + route: "alpha", + children: [{ label: "a-1", route: "alpha-one" }], + }, + ], + }; + service.setModel(model); + service.updateData(0, 0); + service.updateData(1, 0); + expect(service.getPath()).toBe("alphaalpha-one"); + }); + + it("prefixes a slash and warns when a node has no route", () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + const model = { + children: [{ label: "no-route", route: undefined }], + }; + service.setModel(model); + service.updateData(0, 0); + + const path = service.getPath(); + + expect(path).toBe("/undefined"); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + }); +}); diff --git a/src/ui-kit/dom-helpers.spec.ts b/src/ui-kit/dom-helpers.spec.ts index 85497c667..2931e66a9 100644 --- a/src/ui-kit/dom-helpers.spec.ts +++ b/src/ui-kit/dom-helpers.spec.ts @@ -63,4 +63,35 @@ describe("ScrollHelpers", () => { helpers.enableScroll(); }); + + it("falls back to window.event when the onwheel handler fires with no event object", () => { + const helpers = ScrollHelpers(window); + helpers.disableScroll(); + + const preventDefault = vi.fn(); + window["event"] = { preventDefault }; + + // window.onwheel is bound directly to the internal preventDefault(); call + // it with no arguments so `e || window.event` falls through to the + // window.event branch. + (window.onwheel as never)(); + + expect(preventDefault).toHaveBeenCalled(); + delete window["event"]; + helpers.enableScroll(); + }); + + it("skips setting returnValue guard branches when addEventListener/removeEventListener are unavailable", () => { + const originalAdd = window.addEventListener; + const originalRemove = window.removeEventListener; + window["addEventListener"] = undefined; + window["removeEventListener"] = undefined; + + const helpers = ScrollHelpers(window); + expect(() => helpers.disableScroll()).not.toThrow(); + expect(() => helpers.enableScroll()).not.toThrow(); + + window.addEventListener = originalAdd; + window.removeEventListener = originalRemove; + }); }); diff --git a/src/ui-kit/experimental/aria/abstract-combobox/abstract-combobox.spec.ts b/src/ui-kit/experimental/aria/abstract-combobox/abstract-combobox.spec.ts index 3b9fb190e..3c2b71b0d 100644 --- a/src/ui-kit/experimental/aria/abstract-combobox/abstract-combobox.spec.ts +++ b/src/ui-kit/experimental/aria/abstract-combobox/abstract-combobox.spec.ts @@ -147,4 +147,46 @@ describe("AbstractCombobox", () => { expect(input.value).toBe("Alpha"); }); + + it("ArrowDown moves the popup selection down and updates aria-activedescendant", () => { + const { input, combobox } = buildCombobox(); + void combobox; + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + + expect(input.getAttribute("aria-activedescendant")).toBe("cell-1"); + }); + + it("ArrowUp moves the popup selection up and updates aria-activedescendant", () => { + const { input, combobox } = buildCombobox(); + void combobox; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp" })); + + expect(input.getAttribute("aria-activedescendant")).toBe("cell-0"); + }); + + it("ignores keys that aren't arrows or Enter", () => { + const { input, combobox } = buildCombobox(); + void combobox; + + expect(() => + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" })) + ).not.toThrow(); + expect(input.getAttribute("aria-activedescendant")).toBeNull(); + }); + + it("adds the selected cell to the tab order on focus and removes it on blur", () => { + const { input, popup } = buildCombobox(); + const cell = popup.getSelected(); + const addSpy = vi.spyOn(cell, "addToTabOrder"); + const removeSpy = vi.spyOn(cell, "removeFromTabOrder"); + + input.dispatchEvent(new Event("focus")); + expect(addSpy).toHaveBeenCalled(); + + input.dispatchEvent(new Event("blur")); + expect(removeSpy).toHaveBeenCalled(); + }); }); diff --git a/src/ui-kit/experimental/hierarchical/autocomplete/autocomplete.component.spec.ts b/src/ui-kit/experimental/hierarchical/autocomplete/autocomplete.component.spec.ts index ddf9e1b88..016603587 100755 --- a/src/ui-kit/experimental/hierarchical/autocomplete/autocomplete.component.spec.ts +++ b/src/ui-kit/experimental/hierarchical/autocomplete/autocomplete.component.spec.ts @@ -14,7 +14,7 @@ import { TreeMode, } from "../hierarchical-tree-selectedItem.model"; import { By } from "@angular/platform-browser"; -import "rxjs"; +import { of } from "rxjs"; import { HierarchicalDataService } from "../hierarchical-test-service.spec"; describe("SamHierarchicalAutocompleteComponent", () => { @@ -282,6 +282,262 @@ describe("SamHierarchicalAutocompleteComponent", () => { expect(listAfter).toBeFalsy(); })); + it("focusRemoved() clears the model when the input is emptied in single mode with a selected item", () => { + component.model.addItem({ id: "1", name: "Level 1" }, "id"); + component.inputValue = ""; + const clearSpy = vi.spyOn(component.model, "clearItems"); + component["focusRemoved"](); + expect(clearSpy).toHaveBeenCalled(); + }); + + it("focusRemoved() restores the selected item's text when the input still has a value in single mode", () => { + component.model.addItem({ id: "1", name: "Level 1" }, "id"); + component.inputValue = "partial"; + component["focusRemoved"](); + expect(component.inputValue).toBe("Level 1"); + }); + + it("focusRemoved() clears the input in single mode when nothing is selected", () => { + component.inputValue = "leftover"; + component["focusRemoved"](); + expect(component.inputValue).toBe(""); + }); + + it("focusRemoved() clears the input outside single tree mode", () => { + component.model.treeMode = TreeMode.MULTIPLE; + component.inputValue = "leftover"; + component["focusRemoved"](); + expect(component.inputValue).toBe(""); + }); + + it("onKeydown() returns early on Tab without altering state", () => { + const selectSpy = vi.spyOn(component, "selectItem"); + component.onKeydown({ key: "Tab", target: {} }); + expect(selectSpy).not.toHaveBeenCalled(); + }); + + it("selectItem() omits the secondary text field from the announced message when the item has none", () => { + component.configuration.secondaryTextField = undefined; + component.selectItem({ id: "1", name: "Level 1" }); + expect(component.inputValue).toBe("Level 1"); + }); + + it("onArrowUp() does nothing when there are no results", () => { + component.results = []; + expect(() => component["onArrowUp"]()).not.toThrow(); + }); + + it("onArrowUp() does nothing when already at the first result", fakeAsync(() => { + component.inputFocusHandler(); + tick(); + fixture.detectChanges(); + component.highlightedIndex = 0; + component["onArrowUp"](); + expect(component.highlightedIndex).toBe(0); + })); + + it("onArrowDown() does nothing when there are no results", () => { + component.results = []; + expect(() => component["onArrowDown"]()).not.toThrow(); + }); + + it("onArrowDown() does nothing when already at the last result", fakeAsync(() => { + component.inputFocusHandler(); + tick(); + fixture.detectChanges(); + component.highlightedIndex = component.results.length - 1; + component["onArrowDown"](); + expect(component.highlightedIndex).toBe(component.results.length - 1); + })); + + it("showFreeText() returns false when free text is disabled", () => { + component.configuration.isFreeTextEnabled = false; + expect(component.showFreeText()).toBe(false); + }); + + it("showFreeText() returns false when the input is empty", () => { + component.configuration.isFreeTextEnabled = true; + component.inputValue = ""; + expect(component.showFreeText()).toBe(false); + }); + + it("showFreeText() finds a match among the model's selected items when there are no results", () => { + component.configuration.isFreeTextEnabled = true; + component.inputValue = "Level 1"; + component.results = undefined; + component.model.addItem({ id: "1", name: "Level 1" }, "id"); + expect(component.showFreeText()).toBe(false); + }); + + it("showFreeText() reports available free text when neither results nor model items match", () => { + component.configuration.isFreeTextEnabled = true; + component.inputValue = "Nowhere"; + component.results = undefined; + component.model.addItem({ id: "1", name: "Level 1" }, "id"); + expect(component.showFreeText()).toBe(true); + }); + + it("getResults() does nothing when the search string is shorter than the minimum character count", () => { + component.configuration.minimumCharacterCountSearch = 5; + const fetchSpy = vi.spyOn(component.service, "getDataByText"); + component["getResults"]("ab"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("getResults() skips a duplicate search while results are already shown", fakeAsync(() => { + component.inputFocusHandler(); + component.inputValue = "Level"; + component["getResults"]("Level"); + tick(); + fixture.detectChanges(); + const fetchSpy = vi.spyOn(component.service, "getDataByText"); + component["getResults"]("Level"); + tick(); + expect(fetchSpy).not.toHaveBeenCalled(); + })); + + it("onScroll() requests more results when scrolled to the bottom", () => { + component.results = [{ id: "1", name: "Level 1" }]; + component["maxResults"] = 5; + component.resultsListElement = { + nativeElement: { offsetHeight: 10, scrollTop: 90, scrollHeight: 100 }, + } as never; + const additionalSpy = vi.spyOn(component as never, "getAdditionalResults"); + component.onScroll(); + expect(additionalSpy).toHaveBeenCalled(); + }); + + it("onScroll() does not request more results when not scrolled near the bottom", () => { + component.results = [{ id: "1", name: "Level 1" }]; + component["maxResults"] = 5; + component.resultsListElement = { + nativeElement: { offsetHeight: 10, scrollTop: 0, scrollHeight: 1000 }, + } as never; + const additionalSpy = vi.spyOn(component as never, "getAdditionalResults"); + component.onScroll(); + expect(additionalSpy).not.toHaveBeenCalled(); + }); + + it("setHighlightedItem() clears a previously highlighted item's flag before setting a new one", fakeAsync(() => { + component.inputFocusHandler(); + tick(); + fixture.detectChanges(); + const previous: any = { name: "prev", highlighted: true }; + component["highlightedItem"] = previous; + component["setHighlightedItem"]({ name: "next" }); + expect(previous.highlighted).toBe(false); + })); + + it("setHighlightedItem() appends the secondary text field to the announced message when present", fakeAsync(() => { + component.inputFocusHandler(); + tick(); + fixture.detectChanges(); + const item: any = { name: "Level X", subtext: "Extra info" }; + component["setHighlightedItem"](item); + expect(component["highlightedItem"].highlighted).toBe(true); + })); + + it("writeValue() ignores values that are not a HierarchicalTreeSelectedItemModel", () => { + const model = component.model; + component.writeValue({ items: [] }); + expect(component.model).toBe(model); + }); + + it("textChange() searches using an empty string when the event is falsy", () => { + const getResultsSpy = vi.spyOn(component as never, "getResults"); + component.textChange(undefined); + expect(getResultsSpy).toHaveBeenCalledWith(""); + }); + + it("onKeydown() clears and hides results on Escape", fakeAsync(() => { + component.inputFocusHandler(); + tick(); + fixture.detectChanges(); + component.onKeydown({ key: "Escape", target: { value: "id" } }); + expect(component.showResults).toBe(false); + expect(component.results).toEqual([]); + })); + + it("selectItem() does not append a secondary text field when the item has none", () => { + component.configuration.secondaryTextField = undefined; + component.selectItem({ id: "2", name: "Level 2" }); + expect(component.inputValue).toBe("Level 2"); + }); + + it("selectItem() appends the secondary text field to the message when present", () => { + component.selectItem({ id: "3", name: "Level 3", subtext: "Extra" }); + expect(component.inputValue).toBe("Level 3"); + }); + + it("onArrowUp() moves the highlight up by one when not already at the top", () => { + component.results = [ + { id: "1", name: "Level 1" }, + { id: "2", name: "Level 2" }, + ]; + component.resultsListElement = { + nativeElement: { children: [{ offsetTop: 0 }, { offsetTop: 20 }] }, + } as never; + component.highlightedIndex = 1; + component["onArrowUp"](); + expect(component.highlightedIndex).toBe(0); + }); + + it("onArrowDown() moves the highlight down by one when not already at the bottom", () => { + component.results = [ + { id: "1", name: "Level 1" }, + { id: "2", name: "Level 2" }, + ]; + component.resultsListElement = { + nativeElement: { children: [{ offsetTop: 0 }, { offsetTop: 20 }] }, + } as never; + component.highlightedIndex = 0; + component["onArrowDown"](); + expect(component.highlightedIndex).toBe(1); + }); + + it("showFreeText() returns false when a result matches the input value", () => { + component.configuration.isFreeTextEnabled = true; + component.inputValue = "Level 1"; + component.results = [{ id: "1", name: "Level 1" }]; + expect(component.showFreeText()).toBe(false); + }); + + it("showFreeText() stops scanning results once a match is found", () => { + component.configuration.isFreeTextEnabled = true; + component.inputValue = "Level 1"; + component.results = [ + { id: "1", name: "Level 1" }, + { id: "2", name: "Level 2" }, + ]; + expect(component.showFreeText()).toBe(false); + }); + + it("getResults() prepends a free-text item to the results when free text is enabled and unmatched", fakeAsync(() => { + component.configuration.isFreeTextEnabled = true; + component.inputValue = "Nowhere"; + vi.spyOn(component.service, "getDataByText").mockReturnValue( + of({ items: [{ id: "1", name: "Level 1" }], totalItems: 1 }) as never + ); + + component["getResults"]("Nowhere"); + tick(); + + expect(component.results[0]["type"]).toBe("custom"); + })); + + it("onScroll() does not request more results once all results are loaded", () => { + component.results = [{ id: "1", name: "Level 1" }]; + component["maxResults"] = 1; + const additionalSpy = vi.spyOn(component as never, "getAdditionalResults"); + component.onScroll(); + expect(additionalSpy).not.toHaveBeenCalled(); + }); + + it("addScreenReaderMessage() is a no-op when srOnly is not yet available", () => { + component.srOnly = undefined as never; + expect(() => component["addScreenReaderMessage"]("hi")).not.toThrow(); + }); + it("marks the highlighted result option as aria-selected", fakeAsync(() => { component.inputFocusHandler(); fixture.detectChanges(); diff --git a/src/ui-kit/experimental/hierarchical/hierarchical-tree-selectedItem.model.spec.ts b/src/ui-kit/experimental/hierarchical/hierarchical-tree-selectedItem.model.spec.ts new file mode 100644 index 000000000..1e95280f2 --- /dev/null +++ b/src/ui-kit/experimental/hierarchical/hierarchical-tree-selectedItem.model.spec.ts @@ -0,0 +1,94 @@ +import { + HierarchicalTreeSelectedItemModel, + TreeMode, +} from "./hierarchical-tree-selectedItem.model"; + +describe("HierarchicalTreeSelectedItemModel", () => { + let model: HierarchicalTreeSelectedItemModel; + + beforeEach(() => { + model = new HierarchicalTreeSelectedItemModel(); + }); + + it("starts empty and in single-selection mode", () => { + expect(model.getItems()).toEqual([]); + expect(model.treeMode).toBe(TreeMode.SINGLE); + }); + + it("replaces the previous item in single-selection mode", () => { + model.addItem({ id: "a" }, "id"); + model.addItem({ id: "b" }, "id"); + + expect(model.getItems()).toEqual([{ id: "b" }]); + }); + + it("accumulates items in multiple-selection mode", () => { + model.treeMode = TreeMode.MULTIPLE; + + model.addItem({ id: "a" }, "id"); + model.addItem({ id: "b" }, "id"); + + expect(model.getItems()).toEqual([{ id: "a" }, { id: "b" }]); + }); + + it("ignores an item whose key is already present", () => { + model.treeMode = TreeMode.MULTIPLE; + model.addItem({ id: "a", label: "first" }, "id"); + + model.addItem({ id: "a", label: "second" }, "id"); + + expect(model.getItems()).toEqual([{ id: "a", label: "first" }]); + }); + + it("adds many items at once, skipping duplicates", () => { + model.treeMode = TreeMode.MULTIPLE; + + model.addItems([{ id: "a" }, { id: "b" }, { id: "a" }], "id"); + + expect(model.getItems()).toEqual([{ id: "a" }, { id: "b" }]); + }); + + it("removes an item that is present", () => { + model.treeMode = TreeMode.MULTIPLE; + const a = { id: "a" }; + model.addItems([a, { id: "b" }], "id"); + + model.removeItem(a, "id"); + + expect(model.getItems()).toEqual([{ id: "b" }]); + }); + + it("leaves the list untouched when removing an absent item", () => { + model.treeMode = TreeMode.MULTIPLE; + model.addItem({ id: "a" }, "id"); + + model.removeItem({ id: "missing" }, "id"); + + expect(model.getItems()).toEqual([{ id: "a" }]); + }); + + it("reports membership by key field", () => { + model.addItem({ id: "a" }, "id"); + + expect(model.contatinsItem("a", "id")).toBe(true); + expect(model.contatinsItem("b", "id")).toBe(false); + }); + + it("clears every item", () => { + model.treeMode = TreeMode.MULTIPLE; + model.addItems([{ id: "a" }, { id: "b" }], "id"); + + model.clearItems(); + + expect(model.getItems()).toEqual([]); + }); + + it("replaceItems discards the old selection entirely", () => { + model.treeMode = TreeMode.MULTIPLE; + model.addItems([{ id: "a" }, { id: "b" }], "id"); + + model.replaceItems([{ id: "c" }], "id"); + + expect(model.getItems()).toEqual([{ id: "c" }]); + }); +}); diff --git a/src/ui-kit/experimental/icon/fa-icon/shared/errors/warn-if-icon-missing.spec.ts b/src/ui-kit/experimental/icon/fa-icon/shared/errors/warn-if-icon-missing.spec.ts new file mode 100644 index 000000000..35e09d5f7 --- /dev/null +++ b/src/ui-kit/experimental/icon/fa-icon/shared/errors/warn-if-icon-missing.spec.ts @@ -0,0 +1,70 @@ +import { faWarnIfIconHtmlMissing } from "./warn-if-icon-html-missing"; +import { faWarnIfIconSpecMissing } from "./warn-if-icon-spec-missing"; +import { Icon, IconLookup } from "@fortawesome/fontawesome-svg-core"; + +describe("fa-icon missing-icon warnings", () => { + let errorSpy: ReturnType; + + beforeEach(() => { + errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + errorSpy.mockRestore(); + }); + + describe("faWarnIfIconHtmlMissing", () => { + it("reports the requested name and prefix when the icon is not registered", () => { + const iconSpec = { + iconName: "coffee", + prefix: "fas", + } as unknown as IconLookup; + + faWarnIfIconHtmlMissing(undefined as unknown as Icon, iconSpec); + + expect(errorSpy).toHaveBeenCalledWith( + "FontAwesome: Could not find icon with iconName=coffee and prefix=fas" + ); + }); + + it("stays silent when the icon was resolved", () => { + const iconSpec = { + iconName: "coffee", + prefix: "fas", + } as unknown as IconLookup; + + faWarnIfIconHtmlMissing({} as Icon, iconSpec); + + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it("stays silent when no icon was requested at all", () => { + faWarnIfIconHtmlMissing( + undefined as unknown as Icon, + undefined as unknown as IconLookup + ); + + expect(errorSpy).not.toHaveBeenCalled(); + }); + }); + + describe("faWarnIfIconSpecMissing", () => { + it("reports a null or undefined icon object", () => { + faWarnIfIconSpecMissing(undefined as unknown as IconLookup); + + expect(errorSpy).toHaveBeenCalledWith( + "FontAwesome: Could not find icon. " + + "It looks like you've provided a null or undefined icon object to this component." + ); + }); + + it("stays silent when an icon spec is provided", () => { + faWarnIfIconSpecMissing({ + iconName: "coffee", + prefix: "fas", + } as unknown as IconLookup); + + expect(errorSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/ui-kit/experimental/icon/fa-icon/shared/utils/classlist.util.spec.ts b/src/ui-kit/experimental/icon/fa-icon/shared/utils/classlist.util.spec.ts new file mode 100644 index 000000000..d39ad73b5 --- /dev/null +++ b/src/ui-kit/experimental/icon/fa-icon/shared/utils/classlist.util.spec.ts @@ -0,0 +1,91 @@ +import { faClassList, faLayerClassList } from "./classlist.util"; + +describe("faClassList", () => { + const baseProps: any = { + spin: false, + pulse: false, + fixedWidth: false, + border: false, + listItem: false, + inverse: false, + counter: false, + flip: null, + size: null, + rotate: null, + pull: null, + }; + + it("includes only the true boolean flags", () => { + const classes = faClassList({ + ...baseProps, + spin: true, + border: true, + }); + expect(classes).toContain("fa-spin"); + expect(classes).toContain("fa-border"); + expect(classes).not.toContain("fa-pulse"); + }); + + it("includes fa-flip-horizontal when flip is horizontal", () => { + const classes = faClassList({ ...baseProps, flip: "horizontal" }); + expect(classes).toContain("fa-flip-horizontal"); + expect(classes).not.toContain("fa-flip-vertical"); + }); + + it("includes fa-flip-vertical when flip is vertical", () => { + const classes = faClassList({ ...baseProps, flip: "vertical" }); + expect(classes).toContain("fa-flip-vertical"); + expect(classes).not.toContain("fa-flip-horizontal"); + }); + + it("includes both flip classes when flip is both", () => { + const classes = faClassList({ ...baseProps, flip: "both" }); + expect(classes).toContain("fa-flip-horizontal"); + expect(classes).toContain("fa-flip-vertical"); + }); + + it("includes a size class when size is set", () => { + const classes = faClassList({ ...baseProps, size: "lg" }); + expect(classes).toContain("fa-lg"); + }); + + it("includes a rotate class when rotate is set", () => { + const classes = faClassList({ ...baseProps, rotate: 90 }); + expect(classes).toContain("fa-rotate-90"); + }); + + it("includes a pull class when pull is set", () => { + const classes = faClassList({ ...baseProps, pull: "left" }); + expect(classes).toContain("fa-pull-left"); + }); + + it("omits size/rotate/pull classes when they are null", () => { + const classes = faClassList(baseProps); + expect(classes.some((c) => c.startsWith("fa-rotate-"))).toBe(false); + expect(classes.some((c) => c.startsWith("fa-pull-"))).toBe(false); + expect(classes.some((c) => c === "fa-null")).toBe(false); + }); +}); + +describe("faLayerClassList", () => { + it("includes fa-fw when fixedWidth is true", () => { + const classes = faLayerClassList({ fixedWidth: true, size: null } as never); + expect(classes).toContain("fa-fw"); + }); + + it("omits fa-fw when fixedWidth is false", () => { + const classes = faLayerClassList({ + fixedWidth: false, + size: null, + } as never); + expect(classes).not.toContain("fa-fw"); + }); + + it("includes a size class when size is set", () => { + const classes = faLayerClassList({ + fixedWidth: false, + size: "2x", + } as never); + expect(classes).toContain("fa-2x"); + }); +}); diff --git a/src/ui-kit/experimental/icon/fa-icon/shared/utils/normalize-icon-spec.util.spec.ts b/src/ui-kit/experimental/icon/fa-icon/shared/utils/normalize-icon-spec.util.spec.ts new file mode 100644 index 000000000..0d58cf147 --- /dev/null +++ b/src/ui-kit/experimental/icon/fa-icon/shared/utils/normalize-icon-spec.util.spec.ts @@ -0,0 +1,36 @@ +import { faNormalizeIconSpec } from "./normalize-icon-spec.util"; + +describe("faNormalizeIconSpec", () => { + it("returns null for undefined", () => { + expect(faNormalizeIconSpec(undefined as never)).toBeNull(); + }); + + it("returns null for null", () => { + expect(faNormalizeIconSpec(null as never)).toBeNull(); + }); + + it("returns the spec unchanged when it is already an IconLookup", () => { + const lookup = { prefix: "fas", iconName: "coffee" } as never; + expect(faNormalizeIconSpec(lookup)).toBe(lookup); + }); + + it("builds a lookup from a two-element array", () => { + expect(faNormalizeIconSpec(["fab", "github"] as never)).toEqual({ + prefix: "fab", + iconName: "github", + }); + }); + + it("defaults to the fas prefix for a bare icon name string", () => { + expect(faNormalizeIconSpec("coffee" as never)).toEqual({ + prefix: "fas", + iconName: "coffee", + }); + }); + + it("returns undefined for an array that is not exactly two elements", () => { + expect( + faNormalizeIconSpec(["fas", "coffee", "extra"] as never) + ).toBeUndefined(); + }); +}); diff --git a/src/ui-kit/experimental/icon/fa-icon/shared/utils/object-with-keys.util.spec.ts b/src/ui-kit/experimental/icon/fa-icon/shared/utils/object-with-keys.util.spec.ts new file mode 100644 index 000000000..31665e0e6 --- /dev/null +++ b/src/ui-kit/experimental/icon/fa-icon/shared/utils/object-with-keys.util.spec.ts @@ -0,0 +1,23 @@ +import { objectWithKey } from "./object-with-keys.util"; + +describe("objectWithKey", () => { + it("wraps a truthy scalar value under the given key", () => { + expect(objectWithKey("size", "lg")).toEqual({ size: "lg" }); + }); + + it("wraps a non-empty array under the given key", () => { + expect(objectWithKey("classes", ["a", "b"])).toEqual({ + classes: ["a", "b"], + }); + }); + + it("returns an empty object for an empty array", () => { + expect(objectWithKey("classes", [])).toEqual({}); + }); + + it("returns an empty object for a falsy scalar value", () => { + expect(objectWithKey("size", undefined)).toEqual({}); + expect(objectWithKey("size", "")).toEqual({}); + expect(objectWithKey("size", 0)).toEqual({}); + }); +}); diff --git a/src/ui-kit/experimental/listbox/listbox.component.spec.ts b/src/ui-kit/experimental/listbox/listbox.component.spec.ts index 93c014f58..9e1a1c9dd 100755 --- a/src/ui-kit/experimental/listbox/listbox.component.spec.ts +++ b/src/ui-kit/experimental/listbox/listbox.component.spec.ts @@ -7,6 +7,7 @@ import { import { SamListBoxComponent } from "./listbox.component"; import { By } from "@angular/platform-browser"; import { CommonModule } from "@angular/common"; +import { FormControl } from "@angular/forms"; import { SamWrapperModule } from "../../../ui-kit/wrappers"; const options = [ @@ -302,6 +303,161 @@ describe("SamListBoxComponent", () => { expect(component.isChecked(options[3].value)).toBe(false); }); + it("does not format wrapper errors on ngOnInit when there is no control", () => { + component.options = options; + component.control = undefined; + expect(() => component.ngOnInit()).not.toThrow(); + }); + + it("formats wrapper errors on ngOnInit and on control valueChanges when a control is set", () => { + component.options = options; + const control = new FormControl(""); + component.control = control; + // A pre-existing template defect (#662, fixed on an unmerged branch) + // means @ViewChild(FieldsetWrapper, { static: true }) never resolves + // through the *ngTemplateOutlet indirection in this component's + // template, so component.wrapper stays undefined even after + // detectChanges(). Exercise ngOnInit()'s control-wiring branch directly + // against a stub instead of depending on that ViewChild resolving. + const wrapperStub = { formatErrors: vi.fn(), clearError: vi.fn() }; + component["wrapper"] = wrapperStub; + + component.ngOnInit(); + + expect(wrapperStub.formatErrors).toHaveBeenCalledWith(control); + wrapperStub.formatErrors.mockClear(); + control.setValue("x"); + expect(wrapperStub.formatErrors).toHaveBeenCalledWith(control); + }); + + it("onHover() highlights the hovered option and moves focus to it", () => { + component.options = options; + fixture.detectChanges(); + vi.spyOn(component as never, "setfocus").mockImplementation( + () => undefined + ); + component.onHover(2); + expect((component.options[2] as never).highlighted).toBe(true); + }); + + it("setHighlightedItem() clears a previously highlighted item's flag before setting a new one", () => { + component.options = options; + fixture.detectChanges(); + vi.spyOn(component as never, "setfocus").mockImplementation( + () => undefined + ); + component.onHover(0); + expect((component.options[0] as never).highlighted).toBe(true); + component.onHover(1); + expect((component.options[0] as never).highlighted).toBe(false); + expect((component.options[1] as never).highlighted).toBe(true); + }); + + it("onChecked() inserts the option object, not its value, ahead of an already-later selection", () => { + component.options = options; + fixture.detectChanges(); + component.model = [options[6].value]; + const ev = { target: { checked: true } }; + component.onChecked(ev, options[2]); + // Documents a latent inconsistency rather than endorsing it: clone.splice() + // inserts the raw option object, while isChecked() and setSelectedItem() + // both compare against option.value. The model can therefore hold a mix of + // values and option objects depending on how a selection was made. Left + // as-is here because changing it alters published behavior -- the existing + // "onChecked checked/unchecked" and "Should remove item from selected + // results" tests also pass option objects through the model. + expect(component.model).toEqual([options[6].value, options[2]]); + }); + + it("onChecked() strips a leading empty placeholder value before inserting a new selection", () => { + component.options = options; + fixture.detectChanges(); + component.model = ["", options[6].value]; + const ev = { target: { checked: true } }; + component.onChecked(ev, options[2]); + expect(component.model).toEqual([options[6].value, options[2]]); + }); + + it("onKeyDown() ignores Tab without altering the current index", () => { + component.options = options; + fixture.detectChanges(); + const preventDefault = vi.fn(); + component.onKeyDown({ key: "Tab", preventDefault }); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("onKeyDown() moves the highlight down on Down and stops at the last option", () => { + component.options = options; + fixture.detectChanges(); + vi.spyOn(component as never, "setfocus").mockImplementation( + () => undefined + ); + component.checkboxListElement = { + nativeElement: { + scrollTop: 0, + getElementsByTagName: () => options.map(() => ({ offsetTop: 0 })), + }, + } as never; + const preventDefault = vi.fn(); + component.onKeyDown({ key: "Down", preventDefault }); + expect(preventDefault).toHaveBeenCalled(); + expect((component.options[1] as never).highlighted).toBe(true); + }); + + it("onKeyDown() does not move past the last option on Down", () => { + component.options = options; + fixture.detectChanges(); + component["currentIndex"] = options.length - 1; + const preventDefault = vi.fn(); + component.onKeyDown({ key: "Down", preventDefault }); + expect(component["currentIndex"]).toBe(options.length - 1); + }); + + it("onKeyDown() moves the highlight up on Up and stops at the first option", () => { + component.options = options; + fixture.detectChanges(); + vi.spyOn(component as never, "setfocus").mockImplementation( + () => undefined + ); + component.checkboxListElement = { + nativeElement: { + scrollTop: 0, + getElementsByTagName: () => options.map(() => ({ offsetTop: 0 })), + }, + } as never; + component["currentIndex"] = 1; + const preventDefault = vi.fn(); + component.onKeyDown({ key: "Up", preventDefault }); + expect(preventDefault).toHaveBeenCalled(); + expect(component["currentIndex"]).toBe(0); + }); + + it("onKeyDown() does not move before the first option on Up", () => { + component.options = options; + fixture.detectChanges(); + component["currentIndex"] = 0; + const preventDefault = vi.fn(); + component.onKeyDown({ key: "Up", preventDefault }); + expect(component["currentIndex"]).toBe(0); + }); + + it("onKeyDown() toggles the current item on Space", () => { + component.options = options; + fixture.detectChanges(); + vi.spyOn(component as never, "setfocus").mockImplementation( + () => undefined + ); + component.onHover(2); + const checkedSpy = vi.spyOn(component, "onChecked"); + const evt = { + key: " ", + preventDefault: vi.fn(), + target: { checked: true }, + }; + component.onKeyDown(evt); + expect(checkedSpy).toHaveBeenCalledWith(evt, component["currentItem"]); + }); + it("marks each listbox option's aria-selected/aria-checked from the canonical model selection, kept in sync as options are (un)checked", () => { component.options = options; fixture.detectChanges(); diff --git a/src/ui-kit/experimental/patterns/layout/architecture/service/service-property.spec.ts b/src/ui-kit/experimental/patterns/layout/architecture/service/service-property.spec.ts new file mode 100644 index 000000000..259bb4ae1 --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/architecture/service/service-property.spec.ts @@ -0,0 +1,127 @@ +import { firstValueFrom } from "rxjs"; +import { of, Subject } from "rxjs"; +import { ServiceProperty, ServiceModel } from "./service-property"; + +describe("ServiceProperty", () => { + it("defaults its value to an empty object when no config value is given", () => { + const property = new ServiceProperty({ name: "prop" }, of()); + expect(property.value).toEqual({}); + }); + + it("uses the config value when provided", () => { + const property = new ServiceProperty( + { name: "prop", value: { seeded: true } }, + of() + ); + expect(property.value).toEqual({ seeded: true }); + }); + + it("subscribes to the source observable and updates its value on emit", () => { + const source = new Subject(); + const property = new ServiceProperty({ name: "prop" }, source); + source.next({ updated: true }); + expect(property.value).toEqual({ updated: true }); + }); + + it("does not subscribe when no source observable is given", () => { + expect( + () => new ServiceProperty({ name: "prop" }, undefined) + ).not.toThrow(); + }); + + it("delegates setValue to the registered change function", () => { + const property = new ServiceProperty({ name: "prop" }, of()); + const updateFn = vi.fn(); + property.registerChanges(updateFn); + property.setValue({ next: 1 }); + expect(updateFn).toHaveBeenCalledWith({ next: 1 }); + }); + + it("delegates patchValue with the merged current value", () => { + const property = new ServiceProperty( + { name: "prop", value: { a: 1 } }, + of() + ); + const updateFn = vi.fn(); + property.registerChanges(updateFn); + property.patchValue({ b: 2 }); + expect(updateFn).toHaveBeenCalledWith({ a: 1, b: 2 }); + }); +}); + +describe("ServiceModel", () => { + it("initializes a ServiceProperty for each key when properties are provided", () => { + const model = new ServiceModel({ name: "value", value: {} }, of(), { + filters: { a: 1 }, + sort: {}, + }); + expect(model.properties["filters"]).toBeInstanceOf(ServiceProperty); + expect(model.properties["sort"]).toBeInstanceOf(ServiceProperty); + }); + + it("does not initialize any properties when none are provided", () => { + const model = new ServiceModel({ name: "value", value: {} }, of()); + expect(model.properties).toEqual({}); + }); + + it("exposes a property via get()", () => { + const model = new ServiceModel({ name: "value", value: {} }, of(), { + sort: {}, + }); + expect(model.get("sort")).toBe(model.properties["sort"]); + }); + + it("registers change functions for the model and cascades to each property", () => { + const model = new ServiceModel({ name: "value", value: { a: 1 } }, of(), { + sort: { field: "name" }, + }); + const updateFn = vi.fn(() => vi.fn()); + model.registerChanges(updateFn); + + // registerChanges() only assigns the model's own update function; the + // cascade to each ServiceProperty passes each property key, not "value". + expect(updateFn).toHaveBeenCalledWith("sort"); + expect(updateFn).not.toHaveBeenCalledWith("value"); + }); + + it("delegates setValue on the model to its own update function", () => { + const model = new ServiceModel({ name: "value", value: {} }, of()); + const innerFn = vi.fn(); + const updateFn = vi.fn().mockReturnValue(innerFn); + model.registerChanges(updateFn); + model.setValue({ x: 1 }); + expect(updateFn).toHaveBeenCalledWith("value"); + expect(innerFn).toHaveBeenCalledWith({ x: 1 }); + }); + + it("delegates patchValue on the model with the merged current value", () => { + const model = new ServiceModel({ name: "value", value: { a: 1 } }, of()); + const innerFn = vi.fn(); + const updateFn = vi.fn().mockReturnValue(innerFn); + model.registerChanges(updateFn); + model.patchValue({ b: 2 }); + expect(innerFn).toHaveBeenCalledWith({ a: 1, b: 2 }); + }); + + it("propagates value changes on a property key without affecting unrelated keys", async () => { + const source = new Subject(); + const model = new ServiceModel({ name: "value", value: {} }, source, { + filters: {}, + sort: {}, + }); + + source.next({ filters: { changed: true }, sort: {} }); + expect(model.properties["filters"].value).toEqual({ changed: true }); + }); +}); + +describe("firstValueFrom sanity for ServiceProperty.valueChanges", () => { + it("emits the initial value on valueChanges immediately", async () => { + const property = new ServiceProperty( + { name: "prop", value: { a: 1 } }, + of() + ); + const value = await firstValueFrom(property.valueChanges); + expect(value).toEqual({ a: 1 }); + }); +}); diff --git a/src/ui-kit/experimental/patterns/layout/architecture/update/reducer.spec.ts b/src/ui-kit/experimental/patterns/layout/architecture/update/reducer.spec.ts new file mode 100644 index 000000000..d0d6b2dd9 --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/architecture/update/reducer.spec.ts @@ -0,0 +1,101 @@ +import { layoutReducer, layoutEvents } from "./reducer"; + +describe("layoutReducer", () => { + const state = { data: "old-data", filters: "old-filters" }; + + it("returns the payload directly for VALUE_CHANGED", () => { + const result = layoutReducer(state, { + type: "VALUE_CHANGED", + payload: { replaced: true }, + } as never); + expect(result).toEqual({ replaced: true }); + }); + + it("merges data on the data alias", () => { + const result = layoutReducer(state, { + type: "data", + payload: "new-data", + } as never); + expect(result).toEqual({ ...state, data: "new-data" }); + }); + + it("merges data on DATA_CHANGED", () => { + const result = layoutReducer(state, { + type: layoutEvents.DATA_CHANGED, + payload: "new-data", + } as never); + expect(result).toEqual({ ...state, data: "new-data" }); + }); + + it("merges filters on the filters alias", () => { + const result = layoutReducer(state, { + type: "filters", + payload: "new-filters", + } as never); + expect(result).toEqual({ ...state, filters: "new-filters" }); + }); + + it("merges filters on FILTERS_CHANGED", () => { + const result = layoutReducer(state, { + type: layoutEvents.FILTERS_CHANGED, + payload: "new-filters", + } as never); + expect(result).toEqual({ ...state, filters: "new-filters" }); + }); + + it("merges pagination on the pagination alias", () => { + const result = layoutReducer(state, { + type: "pagination", + payload: { page: 2 }, + } as never); + expect(result).toEqual({ ...state, pagination: { page: 2 } }); + }); + + it("merges pagination on PAGE_CHANGED", () => { + const result = layoutReducer(state, { + type: layoutEvents.PAGE_CHANGED, + payload: { page: 2 }, + } as never); + expect(result).toEqual({ ...state, pagination: { page: 2 } }); + }); + + it("merges sort on the sort alias", () => { + const result = layoutReducer(state, { + type: "sort", + payload: { field: "name" }, + } as never); + expect(result).toEqual({ ...state, sort: { field: "name" } }); + }); + + it("merges sort on SORT_CHANGED", () => { + const result = layoutReducer(state, { + type: layoutEvents.SORT_CHANGED, + payload: { field: "name" }, + } as never); + expect(result).toEqual({ ...state, sort: { field: "name" } }); + }); + + it("merges data on ERROR", () => { + const result = layoutReducer(state, { + type: layoutEvents.ERROR, + payload: "error-data", + } as never); + expect(result).toEqual({ ...state, data: "error-data" }); + }); + + it("merges filterFields on the filterFields case", () => { + const result = layoutReducer(state, { + type: "filterFields", + payload: [{ name: "field-a" }], + } as never); + expect(result).toEqual({ ...state, filterFields: [{ name: "field-a" }] }); + }); + + it("returns the unmodified state for an unrecognized action type", () => { + const result = layoutReducer(state, { + type: "SOMETHING_ELSE", + payload: "ignored", + } as never); + expect(result).toBe(state); + }); +}); diff --git a/src/ui-kit/experimental/patterns/layout/components/actionbar.component.spec.ts b/src/ui-kit/experimental/patterns/layout/components/actionbar.component.spec.ts new file mode 100644 index 000000000..b852a11cc --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/components/actionbar.component.spec.ts @@ -0,0 +1,90 @@ +import { SamActionBarComponent } from "./actionbar.component"; +import { SamPaginationNextComponent } from "../../../../layout/pagination/pagination.module"; + +function createFakeService() { + return { + model: { + properties: { + pagination: { setValue: vi.fn() }, + }, + }, + } as never; +} + +function createFakePagination() { + return { + pageChange: { subscribe: vi.fn(), emit: vi.fn() }, + unitsChange: { subscribe: vi.fn(), emit: vi.fn() }, + currentPage: 1, + pageSize: 10, + totalPages: 5, + totalUnits: 50, + } as unknown as SamPaginationNextComponent; +} + +describe("SamActionBarComponent", () => { + it("does nothing on ngAfterContentInit when there is no pagination child", () => { + const service = createFakeService(); + const actionBar = new SamActionBarComponent(service); + expect(() => actionBar.ngAfterContentInit()).not.toThrow(); + expect(service.model.properties.pagination.setValue).not.toHaveBeenCalled(); + }); + + it("subscribes to pageChange/unitsChange and emits the initial page when pagination exists", () => { + const service = createFakeService(); + const actionBar = new SamActionBarComponent(service); + actionBar.pagination = createFakePagination(); + + actionBar.ngAfterContentInit(); + + expect(actionBar.pagination.pageChange.subscribe).toHaveBeenCalled(); + expect(actionBar.pagination.unitsChange.subscribe).toHaveBeenCalled(); + expect(actionBar.pagination.pageChange.emit).toHaveBeenCalledWith( + actionBar.pagination.currentPage + ); + }); + + it("writes pagination state to the service model when the page changes", () => { + const service = createFakeService(); + const actionBar = new SamActionBarComponent(service); + const pagination = createFakePagination(); + actionBar.pagination = pagination; + + let pageChangeHandler: (evt: unknown) => void = () => {}; + (pagination.pageChange.subscribe as never).mockImplementation( + (cb: (evt: unknown) => void) => (pageChangeHandler = cb) + ); + + actionBar.ngAfterContentInit(); + pageChangeHandler(pagination.currentPage); + + expect(service.model.properties.pagination.setValue).toHaveBeenCalledWith({ + pageSize: pagination.pageSize, + currentPage: pagination.currentPage, + totalPages: pagination.totalPages, + totalUnits: pagination.totalUnits, + }); + }); + + it("writes pagination state to the service model when the units change", () => { + const service = createFakeService(); + const actionBar = new SamActionBarComponent(service); + const pagination = createFakePagination(); + actionBar.pagination = pagination; + + let unitsChangeHandler: (size: unknown) => void = () => {}; + (pagination.unitsChange.subscribe as never).mockImplementation( + (cb: (size: unknown) => void) => (unitsChangeHandler = cb) + ); + + actionBar.ngAfterContentInit(); + unitsChangeHandler(pagination.pageSize); + + expect(service.model.properties.pagination.setValue).toHaveBeenCalledWith({ + pageSize: pagination.pageSize, + currentPage: pagination.currentPage, + totalPages: pagination.totalPages, + totalUnits: pagination.totalUnits, + }); + }); +}); diff --git a/src/ui-kit/experimental/patterns/layout/components/core/coordination/unique-selection-dispatcher.spec.ts b/src/ui-kit/experimental/patterns/layout/components/core/coordination/unique-selection-dispatcher.spec.ts new file mode 100644 index 000000000..0c580c411 --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/components/core/coordination/unique-selection-dispatcher.spec.ts @@ -0,0 +1,61 @@ +import { + UniqueSelectionDispatcher, + UNIQUE_SELECTION_DISPATCHER_PROVIDER_FACTORY, +} from "./unique-selection-dispatcher"; + +describe("The UniqueSelectionDispatcher", () => { + it("notifies all registered listeners with the id and name", () => { + const dispatcher = new UniqueSelectionDispatcher(); + const listenerA = vi.fn(); + const listenerB = vi.fn(); + + dispatcher.listen(listenerA); + dispatcher.listen(listenerB); + dispatcher.notify("radio-1", "group-a"); + + expect(listenerA).toHaveBeenCalledWith("radio-1", "group-a"); + expect(listenerB).toHaveBeenCalledWith("radio-1", "group-a"); + }); + + it("notifies zero listeners without throwing when none are registered", () => { + const dispatcher = new UniqueSelectionDispatcher(); + expect(() => dispatcher.notify("radio-1", "group-a")).not.toThrow(); + }); + + it("stops notifying a listener once its deregister function is called", () => { + const dispatcher = new UniqueSelectionDispatcher(); + const listener = vi.fn(); + + const deregister = dispatcher.listen(listener); + deregister(); + dispatcher.notify("radio-1", "group-a"); + + expect(listener).not.toHaveBeenCalled(); + }); + + it("only deregisters the matching listener, leaving others intact", () => { + const dispatcher = new UniqueSelectionDispatcher(); + const listenerA = vi.fn(); + const listenerB = vi.fn(); + + dispatcher.listen(listenerA); + const deregisterB = dispatcher.listen(listenerB); + deregisterB(); + dispatcher.notify("radio-1", "group-a"); + + expect(listenerA).toHaveBeenCalled(); + expect(listenerB).not.toHaveBeenCalled(); + }); +}); + +describe("UNIQUE_SELECTION_DISPATCHER_PROVIDER_FACTORY", () => { + it("returns the parent dispatcher when one is provided", () => { + const parent = new UniqueSelectionDispatcher(); + expect(UNIQUE_SELECTION_DISPATCHER_PROVIDER_FACTORY(parent)).toBe(parent); + }); + + it("creates a new dispatcher when no parent dispatcher exists", () => { + const result = UNIQUE_SELECTION_DISPATCHER_PROVIDER_FACTORY(null); + expect(result).toBeInstanceOf(UniqueSelectionDispatcher); + }); +}); diff --git a/src/ui-kit/experimental/patterns/layout/components/core/overlay/overlay-container.spec.ts b/src/ui-kit/experimental/patterns/layout/components/core/overlay/overlay-container.spec.ts new file mode 100644 index 000000000..7a5cc0f84 --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/components/core/overlay/overlay-container.spec.ts @@ -0,0 +1,62 @@ +import { OverlayContainer } from "./overlay-container"; + +describe("OverlayContainer", () => { + afterEach(() => { + document.body + .querySelectorAll(".cdk-overlay-container") + .forEach((el) => el.remove()); + }); + + it("lazily creates the container element on first getContainerElement() call", () => { + const container = new OverlayContainer(); + expect(document.body.querySelector(".cdk-overlay-container")).toBeNull(); + + const element = container.getContainerElement(); + + expect(element.classList.contains("cdk-overlay-container")).toBe(true); + expect(document.body.contains(element)).toBe(true); + }); + + it("reuses the same container element on subsequent calls", () => { + const container = new OverlayContainer(); + const first = container.getContainerElement(); + const second = container.getContainerElement(); + expect(first).toBe(second); + }); + + it("applies the theme class to a container created after themeClass is set", () => { + const container = new OverlayContainer(); + container.themeClass = "my-theme"; + const element = container.getContainerElement(); + expect(element.classList.contains("my-theme")).toBe(true); + }); + + it("does nothing when themeClass is set before the container element exists", () => { + const container = new OverlayContainer(); + expect(() => (container.themeClass = "my-theme")).not.toThrow(); + expect(container.themeClass).toBe("my-theme"); + }); + + it("swaps the theme class on an already-created container element", () => { + const container = new OverlayContainer(); + container.getContainerElement(); + container.themeClass = "theme-a"; + const element = container.getContainerElement(); + expect(element.classList.contains("theme-a")).toBe(true); + + container.themeClass = "theme-b"; + expect(element.classList.contains("theme-a")).toBe(false); + expect(element.classList.contains("theme-b")).toBe(true); + }); + + it("removes the previous theme class without adding a new one when set to a falsy value", () => { + const container = new OverlayContainer(); + container.getContainerElement(); + container.themeClass = "theme-a"; + const element = container.getContainerElement(); + + container.themeClass = ""; + + expect(element.classList.contains("theme-a")).toBe(false); + }); +}); diff --git a/src/ui-kit/experimental/patterns/layout/components/core/overlay/overlay-ref.spec.ts b/src/ui-kit/experimental/patterns/layout/components/core/overlay/overlay-ref.spec.ts index 6941b22df..67d95d1b8 100644 --- a/src/ui-kit/experimental/patterns/layout/components/core/overlay/overlay-ref.spec.ts +++ b/src/ui-kit/experimental/patterns/layout/components/core/overlay/overlay-ref.spec.ts @@ -88,6 +88,58 @@ describe("OverlayRef", () => { expect(document.body.querySelector(".cdk-overlay-backdrop")).toBeNull(); }); + it("adds the fade-in class to the backdrop on the next frame", () => { + // requestAnimationFrame is stubbed so the deferred callback runs + // deterministically. Left to the real rAF, whether jsdom flushed the + // frame before the coverage snapshot depended on how long the rest of + // the suite kept the event loop busy, which made this branch's coverage + // vary with test-file execution order. + const frames: FrameRequestCallback[] = []; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { + frames.push(cb); + return 0; + }); + + state.hasBackdrop = true; + overlayRef.attach({} as any); + const backdrop = document.body.querySelector( + ".cdk-overlay-backdrop" + ) as HTMLElement; + + expect(backdrop.classList.contains("cdk-overlay-backdrop-showing")).toBe( + false + ); + + frames.forEach((cb) => cb(0)); + + expect(backdrop.classList.contains("cdk-overlay-backdrop-showing")).toBe( + true + ); + }); + + it("skips the fade-in class when the backdrop is detached before the frame runs", () => { + const frames: FrameRequestCallback[] = []; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { + frames.push(cb); + return 0; + }); + + state.hasBackdrop = true; + overlayRef.attach({} as any); + const backdrop = document.body.querySelector( + ".cdk-overlay-backdrop" + ) as HTMLElement; + + // Simulates the overlay being torn down within the same frame it was + // attached; the guard inside the callback is what stops it throwing. + overlayRef["_backdropElement"] = null; + frames.forEach((cb) => cb(0)); + + expect(backdrop.classList.contains("cdk-overlay-backdrop-showing")).toBe( + false + ); + }); + it("adds the configured panel class to the pane", () => { state.panelClass = "my-panel"; overlayRef.attach({} as any); diff --git a/src/ui-kit/experimental/patterns/layout/components/core/overlay/overlay.spec.ts b/src/ui-kit/experimental/patterns/layout/components/core/overlay/overlay.spec.ts new file mode 100644 index 000000000..8d926561d --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/components/core/overlay/overlay.spec.ts @@ -0,0 +1,82 @@ +import { Overlay } from "./overlay"; +import { OverlayState } from "./overlay-state"; +import { OverlayRef } from "./overlay-ref"; + +function createFakeOverlayContainer() { + const containerElement = document.createElement("div"); + document.body.appendChild(containerElement); + return { + getContainerElement: vi.fn().mockReturnValue(containerElement), + }; +} + +describe("Overlay", () => { + let overlayContainer: ReturnType; + let scrollStrategies: any; + let positionBuilder: any; + let overlay: Overlay; + + beforeEach(() => { + overlayContainer = createFakeOverlayContainer(); + scrollStrategies = { noop: vi.fn().mockReturnValue({ attach: vi.fn() }) }; + positionBuilder = { global: vi.fn() }; + overlay = new Overlay( + scrollStrategies, + overlayContainer as never, + positionBuilder, + null as never, + null as never, + null as never + ); + }); + + afterEach(() => { + document.body + .querySelectorAll(".cdk-overlay-pane") + .forEach((el) => el.remove()); + }); + + it("returns the injected position builder via position()", () => { + expect(overlay.position()).toBe(positionBuilder); + }); + + it("creates an OverlayRef with a pane appended to the overlay container", () => { + const state = new OverlayState(); + const ref = overlay.create(state); + + expect(ref).toBeInstanceOf(OverlayRef); + expect( + overlayContainer.getContainerElement().contains(ref.overlayElement) + ).toBe(true); + expect(ref.overlayElement.classList.contains("cdk-overlay-pane")).toBe( + true + ); + }); + + it("falls back to the default state when create() is called without one", () => { + const ref = overlay.create(); + expect(ref).toBeInstanceOf(OverlayRef); + }); + + it("uses the noop scroll strategy when the state provides none", () => { + const state = new OverlayState(); + overlay.create(state); + expect(scrollStrategies.noop).toHaveBeenCalled(); + }); + + it("uses the state's own scroll strategy when one is configured", () => { + const state = new OverlayState(); + const customStrategy = { attach: vi.fn() }; + state.scrollStrategy = customStrategy as never; + + overlay.create(state); + + expect(scrollStrategies.noop).not.toHaveBeenCalled(); + }); + + it("assigns each created pane a unique id", () => { + const refA = overlay.create(); + const refB = overlay.create(); + expect(refA.overlayElement.id).not.toBe(refB.overlayElement.id); + }); +}); diff --git a/src/ui-kit/experimental/patterns/layout/components/core/overlay/position/viewport-ruler.spec.ts b/src/ui-kit/experimental/patterns/layout/components/core/overlay/position/viewport-ruler.spec.ts new file mode 100644 index 000000000..99f3d0b99 --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/components/core/overlay/position/viewport-ruler.spec.ts @@ -0,0 +1,116 @@ +import { + ViewportRuler, + VIEWPORT_RULER_PROVIDER_FACTORY, +} from "./viewport-ruler"; +import { ScrollDispatcher } from "../scroll/scroll-dispatcher"; + +function createFakeScrollDispatcher() { + let callback: (() => void) | undefined; + return { + scrolled: vi.fn((_delay: number, cb: () => void) => { + callback = cb; + return { unsubscribe: vi.fn() }; + }), + trigger: () => callback && callback(), + }; +} + +describe("ViewportRuler", () => { + let scrollDispatcher: ReturnType; + let ruler: ViewportRuler; + + beforeEach(() => { + scrollDispatcher = createFakeScrollDispatcher(); + ruler = new ViewportRuler(scrollDispatcher as unknown as ScrollDispatcher); + }); + + it("subscribes to the scroll dispatcher on construction", () => { + expect(scrollDispatcher.scrolled).toHaveBeenCalledWith( + 0, + expect.any(Function) + ); + }); + + it("re-caches the viewport geometry when the scroll dispatcher fires", () => { + const spy = vi.spyOn(ruler, "_cacheViewportGeometry"); + scrollDispatcher.trigger(); + expect(spy).toHaveBeenCalled(); + }); + + describe("getViewportRect()", () => { + it("computes and caches the geometry when no rect is cached yet", () => { + const rect = ruler.getViewportRect(); + expect(rect.width).toBe(window.innerWidth); + expect(rect.height).toBe(window.innerHeight); + expect(typeof rect.left).toBe("number"); + expect(typeof rect.top).toBe("number"); + }); + + it("uses the provided documentRect instead of recomputing", () => { + const fakeRect = { top: -10, left: -5 } as ClientRect; + const rect = ruler.getViewportRect(fakeRect); + expect(rect.top).toBe(10); + expect(rect.left).toBe(5); + }); + + it("falls back to 0 for top when the scroll position resolves to 0", () => { + const fakeRect = { top: 0, left: 0 } as ClientRect; + const rect = ruler.getViewportRect(fakeRect); + expect(rect.top).toBe(0); + }); + }); + + describe("getViewportScrollPosition()", () => { + it("caches geometry first when no documentRect is passed", () => { + const spy = vi.spyOn(ruler, "_cacheViewportGeometry"); + ruler.getViewportScrollPosition(); + expect(spy).toHaveBeenCalled(); + }); + + it("derives top/left from the negative documentRect values when non-zero", () => { + const fakeRect = { top: -20, left: -15 } as ClientRect; + const position = ruler.getViewportScrollPosition(fakeRect); + expect(position.top).toBe(20); + expect(position.left).toBe(15); + }); + + it("falls back through body/window/documentElement scroll values when the rect is at 0,0", () => { + const fakeRect = { top: 0, left: 0 } as ClientRect; + const position = ruler.getViewportScrollPosition(fakeRect); + expect(typeof position.top).toBe("number"); + expect(typeof position.left).toBe("number"); + }); + }); + + describe("_cacheViewportGeometry()", () => { + it("stores the document element's bounding client rect", () => { + ruler._cacheViewportGeometry(); + const rect = ruler.getViewportRect(); + expect(rect).toBeDefined(); + }); + }); +}); + +describe("VIEWPORT_RULER_PROVIDER_FACTORY", () => { + it("returns the parent ruler when one is provided", () => { + const scrollDispatcher = createFakeScrollDispatcher(); + const parent = new ViewportRuler( + scrollDispatcher as unknown as ScrollDispatcher + ); + expect( + VIEWPORT_RULER_PROVIDER_FACTORY( + parent, + scrollDispatcher as unknown as ScrollDispatcher + ) + ).toBe(parent); + }); + + it("creates a new ViewportRuler when no parent ruler exists", () => { + const scrollDispatcher = createFakeScrollDispatcher(); + const result = VIEWPORT_RULER_PROVIDER_FACTORY( + null as never, + scrollDispatcher as unknown as ScrollDispatcher + ); + expect(result).toBeInstanceOf(ViewportRuler); + }); +}); diff --git a/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/scrollable.spec.ts b/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/scrollable.spec.ts new file mode 100644 index 000000000..643df6779 --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/scrollable.spec.ts @@ -0,0 +1,67 @@ +import { ElementRef, NgZone, Renderer2 } from "@angular/core"; +import { Scrollable } from "./scrollable"; +import { ScrollDispatcher } from "./scroll-dispatcher"; + +describe("Scrollable", () => { + let elementRef: ElementRef; + let scrollDispatcher: ScrollDispatcher; + let ngZone: NgZone; + let renderer: Renderer2; + let unlisten: ReturnType; + let scrollable: Scrollable; + + beforeEach(() => { + elementRef = new ElementRef(document.createElement("div")); + scrollDispatcher = { + register: vi.fn(), + deregister: vi.fn(), + } as unknown as ScrollDispatcher; + ngZone = { + runOutsideAngular: (fn: () => any) => fn(), + } as unknown as NgZone; + unlisten = vi.fn(); + renderer = { + listen: vi.fn().mockReturnValue(unlisten), + } as unknown as Renderer2; + scrollable = new Scrollable(elementRef, scrollDispatcher, ngZone, renderer); + }); + + it("registers a scroll listener and itself with the dispatcher on ngOnInit", () => { + scrollable.ngOnInit(); + + expect(renderer.listen).toHaveBeenCalledWith( + elementRef.nativeElement, + "scroll", + expect.any(Function) + ); + expect(scrollDispatcher.register).toHaveBeenCalledWith(scrollable); + }); + + it("emits on elementScrolled() when the listened scroll event fires", () => { + scrollable.ngOnInit(); + const listenCallback = (renderer.listen as never).mock.calls[0][2]; + const spy = vi.fn(); + scrollable.elementScrolled().subscribe(spy); + + listenCallback(new Event("scroll")); + + expect(spy).toHaveBeenCalled(); + }); + + it("deregisters itself and removes the scroll listener on ngOnDestroy", () => { + scrollable.ngOnInit(); + scrollable.ngOnDestroy(); + + expect(scrollDispatcher.deregister).toHaveBeenCalledWith(scrollable); + expect(unlisten).toHaveBeenCalled(); + }); + + it("does not throw calling ngOnDestroy before ngOnInit registered a listener", () => { + expect(() => scrollable.ngOnDestroy()).not.toThrow(); + expect(scrollDispatcher.deregister).toHaveBeenCalledWith(scrollable); + }); + + it("exposes the element ref via getElementRef()", () => { + expect(scrollable.getElementRef()).toBe(elementRef); + }); +}); diff --git a/src/ui-kit/experimental/patterns/layout/components/expansion/accordion-item.spec.ts b/src/ui-kit/experimental/patterns/layout/components/expansion/accordion-item.spec.ts new file mode 100644 index 000000000..acb0de350 --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/components/expansion/accordion-item.spec.ts @@ -0,0 +1,166 @@ +import { AccordionItem } from "./accordion-item"; +import { UniqueSelectionDispatcher } from "../core/coordination/unique-selection-dispatcher"; +import { CdkAccordionDirective } from "./accordion"; + +describe("AccordionItem", () => { + let dispatcher: UniqueSelectionDispatcher; + + beforeEach(() => { + dispatcher = new UniqueSelectionDispatcher(); + }); + + it("defaults expanded to false when never set", () => { + const item = new AccordionItem(null, dispatcher); + expect(item.expanded).toBe(false); + }); + + it("emits opened and notifies the dispatcher when expanded is set to true", () => { + const item = new AccordionItem(null, dispatcher); + const openedSpy = vi.fn(); + item.opened.subscribe(openedSpy); + const notifySpy = vi.spyOn(dispatcher, "notify"); + + item.expanded = true; + + expect(openedSpy).toHaveBeenCalled(); + expect(item.expanded).toBe(true); + // No accordion parent, so accordionId falls back to the item's own id. + expect(notifySpy).toHaveBeenCalledWith(item.id, item.id); + }); + + it("uses the accordion's id as the accordionId when a parent accordion exists", () => { + const accordion = new CdkAccordionDirective(); + const item = new AccordionItem(accordion, dispatcher); + const notifySpy = vi.spyOn(dispatcher, "notify"); + + item.expanded = true; + + expect(notifySpy).toHaveBeenCalledWith(item.id, accordion.id); + }); + + it("emits closed when expanded is set to false", () => { + const item = new AccordionItem(null, dispatcher); + item.expanded = true; + const closedSpy = vi.fn(); + item.closed.subscribe(closedSpy); + + item.expanded = false; + + expect(closedSpy).toHaveBeenCalled(); + expect(item.expanded).toBe(false); + }); + + it("does nothing when expanded is set to its current value", () => { + const item = new AccordionItem(null, dispatcher); + // Establish an explicit baseline of false first: the internal + // `_expanded` field starts `undefined`, so setting `false` on a fresh + // instance would itself be a change (undefined !== false) and emit + // `closed` once. Only a second, redundant `false` assignment should + // be a true no-op. + item.expanded = false; + + const openedSpy = vi.fn(); + const closedSpy = vi.fn(); + item.opened.subscribe(openedSpy); + item.closed.subscribe(closedSpy); + + item.expanded = false; + + expect(openedSpy).not.toHaveBeenCalled(); + expect(closedSpy).not.toHaveBeenCalled(); + }); + + it("toggle() flips the expanded state", () => { + const item = new AccordionItem(null, dispatcher); + item.toggle(); + expect(item.expanded).toBe(true); + item.toggle(); + expect(item.expanded).toBe(false); + }); + + it("open() and close() force the expanded state", () => { + const item = new AccordionItem(null, dispatcher); + item.open(); + expect(item.expanded).toBe(true); + item.close(); + expect(item.expanded).toBe(false); + }); + + it("collapses when the dispatcher notifies another item in the same non-multi accordion", () => { + const accordion = new CdkAccordionDirective(); + accordion.multi = false; + const itemA = new AccordionItem(accordion, dispatcher); + const itemB = new AccordionItem(accordion, dispatcher); + + itemA.expanded = true; + itemB.expanded = true; + + expect(itemA.expanded).toBe(false); + expect(itemB.expanded).toBe(true); + }); + + it("does not collapse other items when the accordion allows multiple expansion", () => { + const accordion = new CdkAccordionDirective(); + accordion.multi = true; + const itemA = new AccordionItem(accordion, dispatcher); + const itemB = new AccordionItem(accordion, dispatcher); + + itemA.expanded = true; + itemB.expanded = true; + + expect(itemA.expanded).toBe(true); + expect(itemB.expanded).toBe(true); + }); + + it("ignores dispatcher notifications when there is no parent accordion", () => { + const item = new AccordionItem(null, dispatcher); + item.expanded = true; + + // Simulate another item notifying under some other accordion/id pair; + // without an accordion this item should be unaffected. + dispatcher.notify("some-other-id", "some-other-accordion"); + + expect(item.expanded).toBe(true); + }); + + it("ignores notifications about itself", () => { + const accordion = new CdkAccordionDirective(); + const item = new AccordionItem(accordion, dispatcher); + item.expanded = true; + + dispatcher.notify(item.id, accordion.id); + + expect(item.expanded).toBe(true); + }); + + it("emits destroyed and deregisters the dispatcher listener on ngOnDestroy", () => { + // The item must stay expanded and live in a non-multi accordion for this to + // prove anything: if its listener were still registered, a notification + // about a *different* id in the *same* accordion would collapse it. + const accordion = new CdkAccordionDirective(); + const item = new AccordionItem(accordion, dispatcher); + const destroyedSpy = vi.fn(); + item.destroyed.subscribe(destroyedSpy); + item.expanded = true; + + item.ngOnDestroy(); + + expect(destroyedSpy).toHaveBeenCalled(); + + dispatcher.notify("a-different-item-id", accordion.id); + + expect(item.expanded).toBe(true); + }); + + it("collapses on that same notification while its listener is still registered", () => { + // Control for the test above — without this, a deregistration assertion + // could pass simply because the notification never had any effect. + const accordion = new CdkAccordionDirective(); + const item = new AccordionItem(accordion, dispatcher); + item.expanded = true; + + dispatcher.notify("a-different-item-id", accordion.id); + + expect(item.expanded).toBe(false); + }); +}); diff --git a/src/ui-kit/experimental/patterns/layout/components/expansion/expansion-panel-header.spec.ts b/src/ui-kit/experimental/patterns/layout/components/expansion/expansion-panel-header.spec.ts new file mode 100644 index 000000000..a70a624ad --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/components/expansion/expansion-panel-header.spec.ts @@ -0,0 +1,84 @@ +import { UniqueSelectionDispatcher } from "../core/coordination/unique-selection-dispatcher"; +import { MdExpansionPanelComponent } from "./expansion-panel"; +import { MdExpansionPanelHeader } from "./expansion-panel-header"; +import { SPACE, ENTER } from "@angular/cdk/keycodes"; + +describe("MdExpansionPanelHeader", () => { + function createHeader() { + const dispatcher = new UniqueSelectionDispatcher(); + const panel = new MdExpansionPanelComponent(null as never, dispatcher); + const header = new MdExpansionPanelHeader(panel); + return { header, panel }; + } + + it("toggles the panel's expanded state on _toggle()", () => { + const { header, panel } = createHeader(); + expect(panel.expanded).toBe(false); + header._toggle(); + expect(panel.expanded).toBe(true); + }); + + it("reports the panel's expanded state via _isExpanded()", () => { + const { header, panel } = createHeader(); + expect(header._isExpanded()).toBe(false); + panel.expanded = true; + expect(header._isExpanded()).toBe(true); + }); + + it("reports the panel's expanded state string via _getExpandedState()", () => { + const { header, panel } = createHeader(); + expect(header._getExpandedState()).toBe("collapsed"); + panel.expanded = true; + expect(header._getExpandedState()).toBe("expanded"); + }); + + it("reports the panel's id via _getPanelId()", () => { + const { header, panel } = createHeader(); + expect(header._getPanelId()).toBe(panel.id); + }); + + it("reports the panel's hideToggle via _getHideToggle()", () => { + const { header, panel } = createHeader(); + panel.hideToggle = true; + expect(header._getHideToggle()).toBe(true); + }); + + it("toggles the panel when the space key is pressed", () => { + const { header, panel } = createHeader(); + const event = { + keyCode: SPACE, + preventDefault: vi.fn(), + } as unknown as KeyboardEvent; + + header._keyup(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(panel.expanded).toBe(true); + }); + + it("toggles the panel when the enter key is pressed", () => { + const { header, panel } = createHeader(); + const event = { + keyCode: ENTER, + preventDefault: vi.fn(), + } as unknown as KeyboardEvent; + + header._keyup(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(panel.expanded).toBe(true); + }); + + it("does nothing for keys other than space or enter", () => { + const { header, panel } = createHeader(); + const event = { + keyCode: 65, // "a" + preventDefault: vi.fn(), + } as unknown as KeyboardEvent; + + header._keyup(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(panel.expanded).toBe(false); + }); +}); diff --git a/src/ui-kit/experimental/patterns/layout/components/expansion/expansion-panel.spec.ts b/src/ui-kit/experimental/patterns/layout/components/expansion/expansion-panel.spec.ts new file mode 100644 index 000000000..0f0927671 --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/components/expansion/expansion-panel.spec.ts @@ -0,0 +1,58 @@ +import { TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { UniqueSelectionDispatcher } from "../core/coordination/unique-selection-dispatcher"; +import { MdAccordionDirective } from "./accordion"; +import { MdExpansionPanelComponent } from "./expansion-panel"; + +describe("MdExpansionPanelComponent", () => { + function createComponent(accordion: MdAccordionDirective | null = null) { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + }); + const dispatcher = new UniqueSelectionDispatcher(); + return new MdExpansionPanelComponent(accordion as never, dispatcher); + } + + it("hides the toggle based on its own hideToggle input when there is no accordion", () => { + const panel = createComponent(null); + panel.hideToggle = true; + expect(panel._getHideToggle()).toBe(true); + panel.hideToggle = false; + expect(panel._getHideToggle()).toBe(false); + }); + + it("defers to the accordion's hideToggle when a parent accordion exists", () => { + const accordion = new MdAccordionDirective(); + accordion.hideToggle = true; + const panel = createComponent(accordion); + panel.hideToggle = false; + expect(panel._getHideToggle()).toBe(true); + }); + + it("returns the collapsed/expanded state string when there is no accordion", () => { + const panel = createComponent(null); + expect(panel._getDisplayMode()).toBe("collapsed"); + panel.expanded = true; + expect(panel._getDisplayMode()).toBe("expanded"); + }); + + it("returns the accordion's display mode while expanded with a parent accordion", () => { + const accordion = new MdAccordionDirective(); + accordion.displayMode = "flat"; + const panel = createComponent(accordion); + panel.expanded = true; + expect(panel._getDisplayMode()).toBe("flat"); + }); + + it("returns the collapsed state via getDisplayMode host binding getter when collapsed", () => { + const panel = createComponent(null); + expect(panel.getDisplayMode).toBe("collapsed"); + }); + + it("computes the expanded state string via _getExpandedState", () => { + const panel = createComponent(null); + expect(panel._getExpandedState()).toBe("collapsed"); + panel.expanded = true; + expect(panel._getExpandedState()).toBe("expanded"); + }); +}); diff --git a/src/ui-kit/experimental/patterns/layout/components/main.component.spec.ts b/src/ui-kit/experimental/patterns/layout/components/main.component.spec.ts new file mode 100644 index 000000000..7d3733c6b --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/components/main.component.spec.ts @@ -0,0 +1,56 @@ +import { SamMainComponent } from "./main.component"; +import { SamFilterDrawerComponent } from "../../../../layout/filter-drawer"; + +function createFakeService() { + return { + model: { + properties: { + filters: { + value: { a: 1, b: 2 }, + setValue: vi.fn(), + }, + }, + }, + } as never; +} + +describe("SamMainComponent", () => { + it("does nothing on ngAfterContentInit when there is no filter drawer", () => { + const service = createFakeService(); + const main = new SamMainComponent(service); + expect(() => main.ngAfterContentInit()).not.toThrow(); + }); + + it("subscribes to the drawer's clear event when a drawer is present", () => { + const service = createFakeService(); + const main = new SamMainComponent(service); + const drawer = { + clear: { subscribe: vi.fn() }, + } as unknown as SamFilterDrawerComponent; + main.drawer = drawer; + + main.ngAfterContentInit(); + + expect(drawer.clear.subscribe).toHaveBeenCalled(); + }); + + it("clears every filter key to null when the drawer emits clear", () => { + const service = createFakeService(); + const main = new SamMainComponent(service); + let clearHandler: (evt: unknown) => void = () => {}; + const drawer = { + clear: { + subscribe: vi.fn((cb: (evt: unknown) => void) => (clearHandler = cb)), + }, + } as unknown as SamFilterDrawerComponent; + main.drawer = drawer; + + main.ngAfterContentInit(); + clearHandler({}); + + expect(service.model.properties.filters.setValue).toHaveBeenCalledWith({ + a: null, + b: null, + }); + }); +}); diff --git a/src/ui-kit/experimental/patterns/layout/components/page/page.component.spec.ts b/src/ui-kit/experimental/patterns/layout/components/page/page.component.spec.ts index 36fb8a914..932292faa 100644 --- a/src/ui-kit/experimental/patterns/layout/components/page/page.component.spec.ts +++ b/src/ui-kit/experimental/patterns/layout/components/page/page.component.spec.ts @@ -1,13 +1,232 @@ +import { ElementRef, NgZone, Renderer2 } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { CommonModule } from "@angular/common"; import { A11yModule } from "@angular/cdk/a11y"; import { By } from "@angular/platform-browser"; import { SamPageNextComponent } from "./page.component"; +import { MdSidenav } from "../sidenav/sidenav"; import { MdSidenavModule } from "../sidenav"; +import { SamToolbarComponent } from "../../../../../layout/toolbar"; import { SamIconsModule } from "../../../../icon"; +function createComponent(pageService: any = undefined) { + const element = new ElementRef(document.createElement("div")); + const renderer = {} as Renderer2; + const ngZone = {} as NgZone; + return new SamPageNextComponent(element, renderer, ngZone, pageService); +} + +function createFakeAside(overrides: Partial = {}) { + return { + mode: "side", + opened: false, + toggle: vi.fn(), + ...overrides, + } as unknown as MdSidenav; +} + describe("SamPageNextComponent", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("resize()", () => { + it("recomputes the responsive aside layout when an aside exists", () => { + const page = createComponent(); + page.aside = createFakeAside(); + const spy = vi.spyOn(page as never, "_responsiveAside"); + + page.resize(); + + expect(spy).toHaveBeenCalled(); + }); + + it("does nothing when there is no aside", () => { + const page = createComponent(); + expect(() => page.resize()).not.toThrow(); + }); + }); + + describe("ngOnInit()", () => { + it("does nothing when there is no page service", () => { + const page = createComponent(undefined); + expect(() => page.ngOnInit()).not.toThrow(); + }); + + it("toggles the aside open when the page service emits an open-sidebar event", () => { + let handler: (data: unknown) => void = () => {}; + const pageService = { + getPageMessage: vi.fn().mockReturnValue({ + subscribe: (cb: (data: unknown) => void) => (handler = cb), + }), + }; + const page = createComponent(pageService as never); + page.aside = createFakeAside(); + + page.ngOnInit(); + handler({ event: "open sidebar" }); + + expect(page.aside.toggle).toHaveBeenCalledWith(true); + }); + + it("ignores page service events that are not open-sidebar", () => { + let handler: (data: unknown) => void = () => {}; + const pageService = { + getPageMessage: vi.fn().mockReturnValue({ + subscribe: (cb: (data: unknown) => void) => (handler = cb), + }), + }; + const page = createComponent(pageService as never); + page.aside = createFakeAside(); + + page.ngOnInit(); + handler({ event: "close sidebar" }); + + expect(page.aside.toggle).not.toHaveBeenCalled(); + }); + + it("ignores a falsy message payload", () => { + let handler: (data: unknown) => void = () => {}; + const pageService = { + getPageMessage: vi.fn().mockReturnValue({ + subscribe: (cb: (data: unknown) => void) => (handler = cb), + }), + }; + const page = createComponent(pageService as never); + page.aside = createFakeAside(); + + page.ngOnInit(); + expect(() => handler(null)).not.toThrow(); + expect(page.aside.toggle).not.toHaveBeenCalled(); + }); + }); + + describe("backBtnClick()", () => { + it("emits on backButtonClick", () => { + const page = createComponent(); + const spy = vi.fn(); + page.backButtonClick.subscribe(spy); + + page.backBtnClick(); + + expect(spy).toHaveBeenCalled(); + }); + }); + + describe("_setupAside()", () => { + it("does nothing when there is no aside", () => { + const page = createComponent(); + expect(() => page["_setupAside"]()).not.toThrow(); + }); + + it("computes responsive layout and leaves the aside open when startSidebarClosed is false", () => { + const page = createComponent(); + page.aside = createFakeAside(); + page.startSidebarClosed = false; + + page["_setupAside"](); + + expect(page.aside.toggle).not.toHaveBeenCalled(); + }); + + it("closes the aside when startSidebarClosed is true", () => { + const page = createComponent(); + page.aside = createFakeAside(); + page.startSidebarClosed = true; + + page["_setupAside"](); + + expect(page.aside.toggle).toHaveBeenCalledWith(false); + }); + }); + + describe("_setupToolbar()", () => { + it("does nothing when there is no toolbar", () => { + const page = createComponent(); + expect(() => page["_setupToolbar"]()).not.toThrow(); + }); + + it("does not attach a sidenav to the toolbar when there is no aside", () => { + const page = createComponent(); + page.toolbar = { sidenav: undefined } as unknown as SamToolbarComponent; + + page["_setupToolbar"](); + + expect(page.toolbar.sidenav).toBeUndefined(); + }); + + it("attaches the aside to the toolbar when both exist", () => { + const page = createComponent(); + page.toolbar = { sidenav: undefined } as unknown as SamToolbarComponent; + page.aside = createFakeAside(); + + page["_setupToolbar"](); + + expect(page.toolbar.sidenav).toBe(page.aside); + }); + }); + + describe("_responsiveAside()", () => { + it("sets mode to 'side' and opens the aside on a large screen when it was closed", () => { + vi.spyOn(window, "innerWidth", "get").mockReturnValue(1200); + const page = createComponent(); + page.aside = createFakeAside({ opened: false }); + + page["_responsiveAside"](); + + expect(page.aside.mode).toBe("side"); + expect(page.aside.opened).toBe(true); + }); + + it("sets mode to 'over' and closes the aside on a small screen when it was open", () => { + vi.spyOn(window, "innerWidth", "get").mockReturnValue(400); + const page = createComponent(); + page.aside = createFakeAside({ opened: true }); + + page["_responsiveAside"](); + + expect(page.aside.mode).toBe("over"); + expect(page.aside.opened).toBe(false); + }); + + it("leaves the aside open on a large screen when already open", () => { + vi.spyOn(window, "innerWidth", "get").mockReturnValue(1200); + const page = createComponent(); + page.aside = createFakeAside({ opened: true }); + + page["_responsiveAside"](); + + expect(page.aside.opened).toBe(true); + }); + + it("leaves the aside closed on a small screen when already closed", () => { + vi.spyOn(window, "innerWidth", "get").mockReturnValue(400); + const page = createComponent(); + page.aside = createFakeAside({ opened: false }); + + page["_responsiveAside"](); + + expect(page.aside.opened).toBe(false); + }); + }); + + describe("_isSmallScreen()", () => { + it("returns true at exactly the 600px breakpoint", () => { + vi.spyOn(window, "innerWidth", "get").mockReturnValue(600); + const page = createComponent(); + expect(page["_isSmallScreen"]()).toBe(true); + }); + + it("returns false above the breakpoint", () => { + vi.spyOn(window, "innerWidth", "get").mockReturnValue(601); + const page = createComponent(); + expect(page["_isSmallScreen"]()).toBe(false); + }); + }); +}); + +describe("SamPageNextComponent (template)", () => { let component: SamPageNextComponent; let fixture: ComponentFixture; diff --git a/src/ui-kit/experimental/patterns/layout/components/sidenav/sidenav.spec.ts b/src/ui-kit/experimental/patterns/layout/components/sidenav/sidenav.spec.ts index 91fc9ae0b..7b3f91394 100644 --- a/src/ui-kit/experimental/patterns/layout/components/sidenav/sidenav.spec.ts +++ b/src/ui-kit/experimental/patterns/layout/components/sidenav/sidenav.spec.ts @@ -2,6 +2,8 @@ import { Component, ElementRef, ViewChild } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { A11yModule } from "@angular/cdk/a11y"; import { CommonModule } from "@angular/common"; +import { Directionality } from "@angular/cdk/bidi"; +import { EMPTY } from "rxjs"; import { MdSidenav, MdSidenavContainer } from "./sidenav"; interface SidenavInternals { @@ -287,4 +289,202 @@ describe("The Sam Sidenav component", () => { expect(validateSpy).toHaveBeenCalled(); expect(host.sidenav._isEnd).toBe(true); }); + + it("should not stop propagation on Escape when disableClose is set", () => { + const stopPropagation = vi.fn(); + host.disableClose = true; + fixture.detectChanges(); + host.sidenav.handleKeydown({ + keyCode: 27, + stopPropagation, + } as unknown as KeyboardEvent); + expect(stopPropagation).not.toHaveBeenCalled(); + }); + + it("should close and stop propagation on Escape when disableClose is not set", () => { + const stopPropagation = vi.fn(); + host.sidenav.handleKeydown({ + keyCode: 27, + stopPropagation, + } as unknown as KeyboardEvent); + expect(stopPropagation).toHaveBeenCalled(); + }); + + it("should ignore transitionend events targeting a different element", () => { + const closeSpy = vi.fn(); + host.sidenav.onClose.subscribe(closeSpy); + asInternals(host.sidenav)._onTransitionEnd({ + target: document.createElement("div"), + propertyName: "transform", + } as TransitionEvent); + expect(closeSpy).not.toHaveBeenCalled(); + }); + + it("should ignore transitionend events for a non-transform property", () => { + const closeSpy = vi.fn(); + host.sidenav.onClose.subscribe(closeSpy); + asInternals(host.sidenav)._onTransitionEnd({ + target: asInternals(host.sidenav)._elementRef.nativeElement, + propertyName: "opacity", + } as TransitionEvent); + expect(closeSpy).not.toHaveBeenCalled(); + }); + + it("should report zero width when there is no backing native element", () => { + (asInternals(host.sidenav) as never)._elementRef = { nativeElement: null }; + expect((host.sidenav as never)._width).toBe(0); + }); + + it("should throw when a second sidenav without an explicit align is added (defaulting to start)", () => { + TestBed.resetTestingModule(); + + @Component({ + template: ` + + One + Two + + `, + standalone: false, + }) + class DefaultAlignHostComponent {} + + TestBed.configureTestingModule({ + declarations: [DefaultAlignHostComponent, MdSidenav, MdSidenavContainer], + imports: [CommonModule, A11yModule], + }); + const defaultFixture = TestBed.createComponent(DefaultAlignHostComponent); + expect(() => defaultFixture.detectChanges()).toThrow( + /already declared for 'align="start"'/ + ); + }); + + it("should not close a side-mode sidenav when re-validating drawers", async () => { + host.mode = "side"; + fixture.detectChanges(); + const openPromise = host.sidenav.open(); + asInternals(host.sidenav)._onTransitionEnd({ + target: asInternals(host.sidenav)._elementRef.nativeElement, + propertyName: "transform", + } as TransitionEvent); + await openPromise; + + host.align = "end"; + fixture.detectChanges(); + + expect(host.sidenav.opened).toBe(true); + }); + + it("should show and compute backdrop/margin values correctly when a start sidenav is open in over mode", async () => { + host.mode = "over"; + fixture.detectChanges(); + const openPromise = host.sidenav.open(); + asInternals(host.sidenav)._onTransitionEnd({ + target: asInternals(host.sidenav)._elementRef.nativeElement, + propertyName: "transform", + } as TransitionEvent); + await openPromise; + fixture.detectChanges(); + + const container = asContainerInternals(host.container); + expect((host.container as never)._isShowingBackdrop()).toBe(true); + expect(container._getMarginLeft()).toBe(0); + }); + + it('should throw when two sidenavs both explicitly declare align="end"', () => { + TestBed.resetTestingModule(); + + @Component({ + template: ` + + One + Two + + `, + standalone: false, + }) + class DuplicateEndAlignHostComponent {} + + TestBed.configureTestingModule({ + declarations: [ + DuplicateEndAlignHostComponent, + MdSidenav, + MdSidenavContainer, + ], + imports: [CommonModule, A11yModule], + }); + const duplicateFixture = TestBed.createComponent( + DuplicateEndAlignHostComponent + ); + expect(() => duplicateFixture.detectChanges()).toThrow( + /already declared for 'align="end"'/ + ); + }); + + it("should swap left/right sidenavs under an RTL Directionality", async () => { + TestBed.resetTestingModule(); + + @Component({ + template: ` + + End + + `, + standalone: false, + }) + class RtlHostComponent { + @ViewChild("sidenav") sidenav: MdSidenav; + @ViewChild(MdSidenavContainer) container: MdSidenavContainer; + } + + TestBed.configureTestingModule({ + declarations: [RtlHostComponent, MdSidenav, MdSidenavContainer], + imports: [CommonModule, A11yModule], + providers: [ + { provide: Directionality, useValue: { value: "rtl", change: EMPTY } }, + ], + }); + const rtlFixture = TestBed.createComponent(RtlHostComponent); + rtlFixture.detectChanges(); + const { sidenav, container } = rtlFixture.componentInstance as never; + + const openPromise = sidenav.open(); + sidenav["_onTransitionEnd"]({ + target: sidenav["_elementRef"].nativeElement, + propertyName: "transform", + } as TransitionEvent); + await openPromise; + // jsdom never lays elements out, so offsetWidth (and therefore _width) + // is always 0; stub it so the margin math below has a nonzero value to + // route to the correct side. + Object.defineProperty(sidenav, "_width", { + get: () => 40, + }); + + // Under RTL, an "end"-aligned sidenav becomes the *left* side, so its + // effective width (in "side" mode) shows up in the left margin. + expect(container._getMarginLeft()).toBe(40); + expect(container._getMarginRight()).toBe(0); + }); + + it("should compute push-mode position offsets separately from side-mode margins", async () => { + host.mode = "push"; + fixture.detectChanges(); + const openPromise = host.sidenav.open(); + asInternals(host.sidenav)._onTransitionEnd({ + target: asInternals(host.sidenav)._elementRef.nativeElement, + propertyName: "transform", + } as TransitionEvent); + await openPromise; + fixture.detectChanges(); + Object.defineProperty(host.sidenav, "_width", { + get: () => 40, + }); + const container = host.container as never; + + expect(container._getPositionLeft()).toBe(40); + expect(container._getPositionRight()).toBe(0); + // Side-mode margins stay at 0 while in push mode. + expect(container._getMarginLeft()).toBe(0); + }); }); diff --git a/src/ui-kit/experimental/tabs/tab-nav-bar/tab-nav-bar.spec.ts b/src/ui-kit/experimental/tabs/tab-nav-bar/tab-nav-bar.spec.ts new file mode 100644 index 000000000..0632bfcd2 --- /dev/null +++ b/src/ui-kit/experimental/tabs/tab-nav-bar/tab-nav-bar.spec.ts @@ -0,0 +1,98 @@ +import { ElementRef } from "@angular/core"; +import { Subscription } from "rxjs"; +import { MdTabNav, MdTabLink } from "./tab-nav-bar"; + +describe("MdTabNav", () => { + let tabNav: MdTabNav; + + beforeEach(() => { + tabNav = new MdTabNav(); + }); + + it("marks the active link as changed when a different element is set", () => { + const elementRef = new ElementRef(document.createElement("a")); + tabNav.updateActiveLink(elementRef); + expect(tabNav._activeLinkChanged).toBe(true); + expect(tabNav._activeLinkElement).toBe(elementRef); + }); + + it("does not mark the active link as changed when the same element is set again", () => { + const elementRef = new ElementRef(document.createElement("a")); + tabNav.updateActiveLink(elementRef); + tabNav.updateActiveLink(elementRef); + expect(tabNav._activeLinkChanged).toBe(false); + }); + + it("resets _activeLinkChanged on ngAfterContentChecked when it was true", () => { + const elementRef = new ElementRef(document.createElement("a")); + tabNav.updateActiveLink(elementRef); + expect(tabNav._activeLinkChanged).toBe(true); + + tabNav.ngAfterContentChecked(); + + expect(tabNav._activeLinkChanged).toBe(false); + }); + + it("does nothing on ngAfterContentChecked when the active link has not changed", () => { + expect(() => tabNav.ngAfterContentChecked()).not.toThrow(); + expect(tabNav._activeLinkChanged).toBeUndefined(); + }); + + it("unsubscribes the resize subscription on ngOnDestroy", () => { + const subscription = new Subscription(); + const unsubscribeSpy = vi.spyOn(subscription, "unsubscribe"); + tabNav["_resizeSubscription"] = subscription; + + tabNav.ngOnDestroy(); + + expect(unsubscribeSpy).toHaveBeenCalled(); + }); + + it("destroys cleanly when no resize subscription was ever created", () => { + // _resizeSubscription is never assigned by MdTabNav itself, so this is the + // ordinary lifecycle: create, then destroy. It used to throw + // "Cannot read properties of undefined (reading 'unsubscribe')". + const freshNav = new MdTabNav(); + freshNav.ngAfterContentInit(); + + expect(() => freshNav.ngOnDestroy()).not.toThrow(); + }); +}); + +describe("MdTabLink", () => { + function createTabLink() { + const mdTabNavBar = { updateActiveLink: vi.fn() } as unknown as MdTabNav; + const elementRef = new ElementRef(document.createElement("a")); + return { + tabLink: new MdTabLink(mdTabNavBar, elementRef), + mdTabNavBar, + elementRef, + }; + } + + it("notifies the parent nav bar's updateActiveLink when set active", () => { + const { tabLink, mdTabNavBar, elementRef } = createTabLink(); + tabLink.active = true; + expect(mdTabNavBar.updateActiveLink).toHaveBeenCalledWith(elementRef); + expect(tabLink.active).toBe(true); + }); + + it("does not notify the parent nav bar when set inactive", () => { + const { tabLink, mdTabNavBar } = createTabLink(); + tabLink.active = false; + expect(mdTabNavBar.updateActiveLink).not.toHaveBeenCalled(); + expect(tabLink.active).toBe(false); + }); + + it("reports tabIndex 0 when not disabled", () => { + const { tabLink } = createTabLink(); + tabLink.disabled = false; + expect(tabLink.tabIndex).toBe(0); + }); + + it("reports tabIndex -1 when disabled", () => { + const { tabLink } = createTabLink(); + tabLink.disabled = true; + expect(tabLink.tabIndex).toBe(-1); + }); +}); diff --git a/src/ui-kit/experimental/tabs/tab-nav-bar/tab-nav-bar.ts b/src/ui-kit/experimental/tabs/tab-nav-bar/tab-nav-bar.ts index 67019cbae..34541482f 100755 --- a/src/ui-kit/experimental/tabs/tab-nav-bar/tab-nav-bar.ts +++ b/src/ui-kit/experimental/tabs/tab-nav-bar/tab-nav-bar.ts @@ -66,7 +66,10 @@ export class MdTabNav ngOnDestroy() { this._onDestroy.next(); - this._resizeSubscription.unsubscribe(); + // Optional: _resizeSubscription is only ever assigned by a subclass that + // actually subscribes to window.resize. A plain MdTabNav leaves it + // undefined, so an unguarded unsubscribe() threw on every normal teardown. + this._resizeSubscription?.unsubscribe(); } } diff --git a/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-cache.spec.ts b/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-cache.spec.ts index 62e98a129..0f08659ea 100755 --- a/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-cache.spec.ts +++ b/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-cache.spec.ts @@ -80,6 +80,19 @@ describe("Sam Autocomplete Cache Class", () => { expect(cache.get(testKey)).toEqual([]); }); + it("Should not re-insert an unchanged value into the default cache", () => { + cache.insert(testValue); + cache.insert(testValue); + + // updateDefault() short-circuits when the incoming value matches the + // default cache's lastValue, so the duplicate insert is a no-op rather + // than appending a second copy. Asserted directly here because the only + // other test reaching this branch did so incidentally, through the + // component spec's debounced service fetch — which made the branch's + // coverage dependent on cross-file execution order. + expect(cache.get()).toEqual(testValue); + }); + it("Should get totalByteSize of cache", () => { const expectedByteSize = 82; diff --git a/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.component.ts b/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.component.ts index 4cd2922a6..9ed06feee 100755 --- a/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.component.ts +++ b/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.component.ts @@ -513,7 +513,12 @@ export class SamAutocompleteMultiselectComponent foundItem = true; } if (item[0] && !foundItem) { - foundItem = this.findItemExistInList(item[0]); + // Pass the whole category sub-list. This previously passed + // item[0] -- a single sub-item -- to a function that iterates a + // list, so a nested list was only ever probed one character or + // property deep and a genuine match was missed, offering a + // duplicate free-text option. + foundItem = this.findItemExistInList(item); } } } diff --git a/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.spec.ts b/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.spec.ts index 0a68dbdd6..e6ec2062f 100755 --- a/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.spec.ts +++ b/src/ui-kit/form-controls/autocomplete-multiselect/autocomplete-multiselect.spec.ts @@ -379,6 +379,361 @@ describe("The Sam Autocomplete Multiselect Component", () => { expect(component.value).toEqual([]); }); + it("ngOnInit() sorts an existing list by category", () => { + component.list = [{ key: "a", value: "a" }]; + const sortSpy = vi.spyOn(component, "sortByCategory"); + component.ngOnInit(); + expect(sortSpy).toHaveBeenCalled(); + }); + + it("ngOnInit() does nothing when the list is already empty", () => { + component.list = []; + const sortSpy = vi.spyOn(component, "sortByCategory"); + component.ngOnInit(); + expect(sortSpy).not.toHaveBeenCalled(); + }); + + it("ngOnChanges() re-marks options when the options input changes", () => { + const markedSpy = vi.spyOn(component, "updateMarked"); + component.ngOnChanges({ options: true }); + expect(markedSpy).toHaveBeenCalled(); + }); + + it("ngOnChanges() does nothing when the options input did not change", () => { + const markedSpy = vi.spyOn(component, "updateMarked"); + component.ngOnChanges({}); + expect(markedSpy).not.toHaveBeenCalled(); + }); + + it("handleBackspaceEvent() does nothing when the key is not backspace", () => { + component.value = [{ key: "a", value: "a" }]; + const deselectSpy = vi.spyOn(component, "deselectItem"); + component.handleBackspaceEvent({ key: "a", target: { value: "a" } }); + expect(deselectSpy).not.toHaveBeenCalled(); + }); + + it("handleBackspaceEvent() does nothing on backspace when there are no selected values", () => { + component.value = []; + const deselectSpy = vi.spyOn(component, "deselectItem"); + component.handleBackspaceEvent({ + key: "Backspace", + target: { value: "" }, + }); + expect(deselectSpy).not.toHaveBeenCalled(); + }); + + it("handleBackspaceEvent() does nothing when the textarea still has typed text", () => { + component.value = [{ key: "a", value: "a" }]; + const deselectSpy = vi.spyOn(component, "deselectItem"); + component.handleBackspaceEvent({ + key: "Backspace", + target: { value: "typing" }, + }); + expect(deselectSpy).not.toHaveBeenCalled(); + }); + + it("selectOnEnter() does nothing without a resultsList or free-text option when Enter is pressed", () => { + component.resultsList = undefined; + component.isFreeTextEnabled = false; + component.list = []; + const selectSpy = vi.spyOn(component, "selectItem"); + component.selectOnEnter({ key: "Enter", target: { value: "typed" } }); + expect(selectSpy).not.toHaveBeenCalled(); + }); + + it("selectOnEnter() returns early when the input is empty and nothing is highlighted", () => { + component.resultsList = { + nativeElement: { querySelectorAll: () => [] }, + } as unknown as ElementRef; + const selectSpy = vi.spyOn(component, "selectItem"); + const result = component.selectOnEnter({ + key: "Enter", + target: { value: "" }, + }); + expect(selectSpy).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it("selectWithAny() builds a free-text return object when nothing is highlighted and there's no matching item", () => { + component.textArea = { + nativeElement: { focus: () => undefined }, + } as unknown as ElementRef; + component.resultsList = { + nativeElement: { querySelectorAll: () => [] }, + } as unknown as ElementRef; + vi.spyOn(component, "getItem").mockReturnValue(undefined); + + component["selectWithAny"]({ target: { value: "typed value" } }, -1); + + expect(component.value[0][component.keyValueConfig.valueProperty]).toBe( + "typed value" + ); + }); + + it("getItem() resolves a category item when the selected element is a category-name", () => { + component.keyValueConfig = { + keyProperty: "key", + valueProperty: "value", + parentCategoryProperty: "cat", + }; + component.categories = [{ key: "South", value: "South", cat: "South" }]; + const categoryEl = { + classList: { contains: (c: string) => c === "category-name" }, + attributes: { "data-category": { value: "South" } }, + }; + vi.spyOn(component, "getSelectedChildIndex").mockReturnValue(0); + vi.spyOn(component, "getResults").mockReturnValue([categoryEl] as never); + + const result = component.getItem(); + + expect(result).toEqual({ key: "South", value: "South", cat: "South" }); + }); + + it("showResultsFreeText() returns false when free text is disabled", () => { + component.isFreeTextEnabled = false; + expect(component.showResultsFreeText()).toBe(false); + }); + + it("showResultsFreeText() returns false when the search text is empty", () => { + component.isFreeTextEnabled = true; + component.searchText = ""; + expect(component.showResultsFreeText()).toBe(false); + }); + + it("showResultsFreeText() finds a match inside a flat this.list array", () => { + component.isFreeTextEnabled = true; + component.searchText = "aaa"; + component.list = [{ key: "a", value: "aaa" }]; + component.value = []; + expect(component.showResultsFreeText()).toBe(false); + }); + + it("showResultsFreeText() checks a nested category sublist for a match", () => { + component.isFreeTextEnabled = true; + component.searchText = "aaa"; + const nested: any = [{ key: "a", value: "aaa" }]; + component.list = [nested]; + component.value = []; + // The list already contains an exact "aaa", so free text must not be + // offered as a separate option. + expect(component.showResultsFreeText()).toBe(false); + }); + + it("showResultsFreeText() offers free text when a nested sublist has no match", () => { + component.isFreeTextEnabled = true; + component.searchText = "aaa"; + const nested: any = [{ key: "b", value: "bbb" }]; + component.list = [nested]; + component.value = []; + expect(component.showResultsFreeText()).toBe(true); + }); + + it("showResultsFreeText() searches non-array lists via the first category sublist", () => { + component.isFreeTextEnabled = true; + component.searchText = "aaa"; + const nested: any = [{ key: "a", value: "aaa" }]; + component["list"] = { 0: nested }; + component.value = []; + expect(component.showResultsFreeText()).toBe(false); + }); + + it("showResultsFreeText() falls back to searching selected values when the list has no match", () => { + component.isFreeTextEnabled = true; + component.searchText = "aaa"; + component.list = []; + component.value = [{ key: "a", value: "aaa" }]; + expect(component.showResultsFreeText()).toBe(false); + }); + + it("setSelectedChild() wraps to the first element when moving Down past the last item", () => { + const elements = [ + { classList: { add: vi.fn(), remove: vi.fn() } }, + { classList: { add: vi.fn(), remove: vi.fn() } }, + ]; + const result = component.setSelectedChild(1, "Down", elements as never); + expect(result).toBe(0); + }); + + it("setSelectedChild() wraps to the last element when moving Up past the first item", () => { + const elements = [ + { classList: { add: vi.fn(), remove: vi.fn() } }, + { classList: { add: vi.fn(), remove: vi.fn() } }, + ]; + const result = component.setSelectedChild(0, "Up", elements as never); + expect(result).toBe(1); + }); + + it("applyTextAreaWidth() filters options unless an up/down arrow key drove the event", () => { + component.ref = { detectChanges: vi.fn() } as never; + const filterSpy = vi.spyOn(component, "filterOptions"); + const event = { + key: "a", + target: { + style: {}, + scrollHeight: 20, + parentElement: { children: [] }, + }, + }; + vi.spyOn(component as never, "calculateTextAreaWidth").mockReturnValue( + "initial" + ); + component.applyTextAreaWidth(event); + expect(filterSpy).toHaveBeenCalled(); + }); + + it("applyTextAreaWidth() does not filter options when driven by an up/down arrow key", () => { + component.ref = { detectChanges: vi.fn() } as never; + const filterSpy = vi.spyOn(component, "filterOptions"); + const event = { + key: "Down", + target: { + style: {}, + scrollHeight: 20, + parentElement: { children: [] }, + }, + }; + vi.spyOn(component as never, "calculateTextAreaWidth").mockReturnValue( + "initial" + ); + component.applyTextAreaWidth(event); + expect(filterSpy).not.toHaveBeenCalled(); + }); + + it("getParentContentWidth() subtracts border and padding for a border-box element", () => { + const el = document.createElement("div"); + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + width: "100px", + "box-sizing": "border-box", + "border-left-width": "1px", + "padding-left": "2px", + "padding-right": "2px", + "border-right-width": "1px", + } as never); + expect(component.getParentContentWidth(el)).toBe(94); + }); + + it("getParentContentWidth() returns the full width for a content-box element", () => { + const el = document.createElement("div"); + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + width: "100px", + "box-sizing": "content-box", + } as never); + expect(component.getParentContentWidth(el)).toBe(100); + }); + + it("getInternalElementWidth() subtracts border width for a border-box element", () => { + const el = document.createElement("div"); + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + width: "50px", + "box-sizing": "border-box", + "border-left-width": "1px", + "border-right-width": "1px", + } as never); + expect(component.getInternalElementWidth(el)).toBe(48); + }); + + it("getInternalElementWidth() returns the full width for a content-box element", () => { + const el = document.createElement("div"); + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + width: "50px", + "box-sizing": "content-box", + } as never); + expect(component.getInternalElementWidth(el)).toBe(50); + }); + + it("filterOptions() debounces a service fetch instead of filtering the local options array", () => { + vi.useFakeTimers(); + component.service = new AutocompleteService(); + vi.spyOn(component.service, "fetch").mockReturnValue( + of([{ key: "a", value: "aaa" }]) + ); + component.options = []; + component.filterOptions("aaa"); + vi.advanceTimersByTime(component["debounceTime"]); + expect(component.service.fetch).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it("sortByCategory() totals items across every category via totalItems()", () => { + component.keyValueConfig = { + keyProperty: "key", + valueProperty: "value", + categoryProperty: "cat", + }; + const sorted = component.sortByCategory([ + { key: "a", value: "a", cat: "South" }, + { key: "b", value: "b" }, + ]); + expect(sorted["totalItems"]()).toBe(2); + }); + + it("selectItem() ignores a filter match on a differently-cased duplicate check (no-op branch coverage)", () => { + component.textArea = { + nativeElement: { focus: () => undefined }, + } as unknown as ElementRef; + component.value = []; + component.selectItem({ key: "z", value: "z" }); + expect(component.value.length).toBe(1); + }); + + it("selectItemByCategory() clears the list even when categorySelectable is disabled", () => { + component.categoryIsSelectable = false; + component.list = [{ key: "a", value: "a" }] as never; + component.selectItemByCategory("South"); + expect(component.list).toEqual([]); + }); + + it("deselectItemOnEnter() removes the item and prevents default on Enter when enabled", () => { + component.textArea = { + nativeElement: { focus: () => undefined }, + } as unknown as ElementRef; + const item = { key: "aaa", value: "aaa" }; + component.value = [item]; + const event = { key: "Enter", preventDefault: vi.fn() }; + + component.deselectItemOnEnter(event, item); + + expect(component.value.length).toBe(0); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it("deselectItemOnEnter() does nothing for keys other than Enter", () => { + const item = { key: "aaa", value: "aaa" }; + component.value = [item]; + const event = { key: "a", preventDefault: vi.fn() }; + + component.deselectItemOnEnter(event, item); + + expect(component.value.length).toBe(1); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it("focusTextArea() does nothing when the component is disabled", () => { + const focusSpy = vi.fn(); + component.textArea = { + nativeElement: { focus: focusSpy }, + } as unknown as ElementRef; + component.isDisabled = true; + component.focusTextArea(); + expect(focusSpy).not.toHaveBeenCalled(); + }); + + it("listItemHover() adjusts the list index across preceding categories and removes the prior selection", () => { + component.list = [[{ key: "a" }, { key: "b" }], [{ key: "c" }]]; + const elements = [ + { classList: { add: vi.fn(), remove: vi.fn(), contains: () => false } }, + { classList: { add: vi.fn(), remove: vi.fn(), contains: () => false } }, + { classList: { add: vi.fn(), remove: vi.fn(), contains: () => false } }, + ]; + vi.spyOn(component, "getResults").mockReturnValue(elements as never); + component["selectedEl"] = elements[0]; + + component.listItemHover(1, 0); + + expect(elements[0].classList.remove).toHaveBeenCalledWith("selected"); + expect(elements[2].classList.add).toHaveBeenCalledWith("selected"); + }); + it("should compute textarea width to push it to a new line when there is not enough space", () => { component.hiddenText = { nativeElement: document.createElement("span"), diff --git a/src/ui-kit/form-controls/autocomplete/autocomplete.spec.ts b/src/ui-kit/form-controls/autocomplete/autocomplete.spec.ts index 7d4e7f64b..afe5fc6d9 100755 --- a/src/ui-kit/form-controls/autocomplete/autocomplete.spec.ts +++ b/src/ui-kit/form-controls/autocomplete/autocomplete.spec.ts @@ -146,6 +146,321 @@ describe("The Sam Autocomplete Component", () => { ]); expect(component.cache.totalBytes).toBe(2); }); + + it("should do nothing on ngOnChanges when httpRequest did not change", () => { + expect(() => component.ngOnChanges({})).not.toThrow(); + }); + + it("appends a paged response onto the existing results, duplicates included", () => { + // requestSuccess() is the httpRequest paging path: each emission is a new + // page that gets pushed onto whatever is already displayed. It compares + // the whole payload against lastReturnedResults to skip a re-emission of + // the *same* page, but does not de-duplicate item-by-item, so an overlap + // between pages is appended as-is. Asserting the real contract here + // rather than the stricter one the old test name implied; de-duplicating + // would change published paging behavior and belongs in its own issue. + component.results = ["aaa"]; + component.lastReturnedResults = ["zzz"]; + component.requestSuccess(["aaa", "bbb"]); + expect(component.results).toEqual(["aaa", "aaa", "bbb"]); + }); + + it("should not append results again when the same data is returned twice", () => { + component.results = ["aaa"]; + component.requestSuccess(["aaa", "bbb"]); + component.requestSuccess(["aaa", "bbb"]); + // The second identical emission is skipped by the lastReturnedResults + // guard, so the page is appended exactly once. + expect(component.results).toEqual(["aaa", "aaa", "bbb"]); + expect(component.lastReturnedResults).toEqual(["aaa", "bbb"]); + }); + + it("should append a paged key/value response onto the existing pairs", () => { + component.filteredKeyValuePairs = [{ key: "a", value: "a" }]; + component.lastReturnedResults = [{ key: "z", value: "z" }]; + component.requestSuccess([{ key: "b", value: "b" }]); + expect(component.filteredKeyValuePairs).toEqual([ + { key: "a", value: "a" }, + { key: "b", value: "b" }, + ]); + }); + + it("should return errors as empty string when useFormService is true", () => { + component.useFormService = true; + component.errorMessage = "Required"; + expect(component.errors).toBe(""); + }); + + it("should return the error message when not using SamFormService", () => { + component.useFormService = false; + component.errorMessage = "Required"; + expect(component.errors).toBe("Required"); + }); + + it("should return an empty string when there is no error message and not using SamFormService", () => { + component.useFormService = false; + component.errorMessage = undefined; + expect(component.errors).toBe(""); + }); + + it("should do nothing on ngOnInit when there is no control", () => { + component.control = undefined; + expect(() => component.ngOnInit()).not.toThrow(); + }); + + it("should not format wrapper errors on the SamFormService stream for unrelated event types", () => { + component.useFormService = true; + component.control = new FormControl(""); + const samFormServiceStub = { + formEventsUpdated$: new Subject(), + }; + component["samFormService"] = samFormServiceStub; + component.wrapper = { + formatErrors: vi.fn(), + clearError: vi.fn(), + } as never; + + component.ngOnInit(); + samFormServiceStub.formEventsUpdated$.next({ + root: component.control.root, + eventType: "somethingElse", + }); + + expect(component.wrapper.formatErrors).not.toHaveBeenCalled(); + expect(component.wrapper.clearError).not.toHaveBeenCalled(); + }); + + it("should do nothing on ngAfterViewInit when there is no control", () => { + component.control = undefined; + expect(() => component.ngAfterViewInit()).not.toThrow(); + }); + + it("should treat freeTextAvalible results indexOf match as unavailable", () => { + component.isFreeTextEnabled = true; + component.inputValue = "Test"; + component.results = ["Test", "Other"]; + expect(component.freeTextAvalible()).toBe(false); + }); + + it("should skip null entries while scanning filteredKeyValuePairs for free text availability", () => { + component.isFreeTextEnabled = true; + component.inputValue = "Test"; + component.results = undefined; + component.filteredKeyValuePairs = [null, { key: "Test", value: "Test" }]; + expect(component.freeTextAvalible()).toBe(false); + }); + + it("should clear results and filteredKeyValuePairs on backspace when innerValue is falsy", () => { + component.innerValue = null; + component.results = ["aaa"]; + component.filteredKeyValuePairs = [{ key: "a", value: "a" }]; + component.inputValue = "a"; + component.handleBackspaceKeyup(); + expect(component.results).toBe(null); + expect(component.filteredKeyValuePairs).toBe(null); + }); + + it("should not clear results on backspace when innerValue is set", () => { + component.innerValue = "kept"; + component.results = ["aaa"]; + component.inputValue = "a"; + component.handleBackspaceKeyup(); + expect(component.results).toEqual(["aaa"]); + }); + + it("should not clear the value on backspace when the input is non-empty", () => { + component.innerValue = "kept"; + component.inputValue = "remaining"; + component.handleBackspaceKeyup(); + expect(component.value).toBe("kept"); + }); + + it("listExists() returns false when the list has no children property", () => { + expect(component.listExists({ nativeElement: {} } as never)).toBe(false); + }); + + it("listExists() returns false when the list has an empty children collection", () => { + expect( + component.listExists({ + nativeElement: { children: [] }, + } as never) + ).toBe(false); + }); + + it("listExists() returns true when the list has at least one child", () => { + expect( + component.listExists({ + nativeElement: { children: [{}] }, + } as never) + ).toBe(true); + }); + + it("onDownArrowDown()/onUpArrowDown()/listItemHover()/onEnterDown() are no-ops when the list has no children", () => { + const emptyList = { nativeElement: { children: [] } } as never; + expect(() => component.onDownArrowDown(emptyList)).not.toThrow(); + expect(() => component.onUpArrowDown(emptyList)).not.toThrow(); + }); + + it("isFirstItemCategory() returns false when there are no categories configured", () => { + component.categories = []; + const item = { classList: { contains: () => true } }; + expect(component.isFirstItemCategory(item, 0)).toBe(false); + }); + + it("isFirstItemCategory() returns false when the category is selectable", () => { + component.categories = ["South"]; + component.config = { + keyValueConfig: { keyProperty: "key", valueProperty: "value" }, + isCategorySelectable: true, + }; + const item = { classList: { contains: () => true } }; + expect(component.isFirstItemCategory(item, 0)).toBe(false); + }); + + it("isFirstItemCategory() returns false for a non-category item at index 0", () => { + component.categories = ["South"]; + component.config = { + keyValueConfig: { keyProperty: "key", valueProperty: "value" }, + isCategorySelectable: false, + }; + const item = { classList: { contains: () => false } }; + expect(component.isFirstItemCategory(item, 0)).toBe(false); + }); + + it("checkCategoryIndex() returns 0 when there are no categories configured", () => { + component.categories = []; + const item = { classList: { contains: () => true } }; + expect(component.checkCategoryIndex(item)).toBe(0); + }); + + it("checkCategoryIndex() returns 1 when categories exist and the current item is a category", () => { + component.categories = ["South"]; + component.config = { + keyValueConfig: { keyProperty: "key", valueProperty: "value" }, + isCategorySelectable: false, + }; + const item = { classList: { contains: () => true } }; + expect(component.checkCategoryIndex(item)).toBe(1); + }); + + it("setMessage() reads from filteredKeyValuePairs when results is unset", () => { + component.results = undefined; + component.filteredKeyValuePairs = [{ key: "a", value: "Alabama" }]; + component.isFreeTextEnabled = false; + expect(component.setMessage(0)).toBe("Alabama"); + }); + + it("setMessage() decrements the index when free text is the first item and results are shown", () => { + component.isFreeTextEnabled = true; + component.results = ["Alabama", "Alaska"]; + component.inputValue = "Nowhere"; + expect(component.setMessage(1)).toBe("Alabama"); + }); + + it("setMessage() decrements the index when free text is the first item and filteredKeyValuePairs are shown", () => { + component.isFreeTextEnabled = true; + component.results = undefined; + component.filteredKeyValuePairs = [{ key: "a", value: "Alabama" }]; + component.inputValue = "Nowhere"; + expect(component.setMessage(1)).toBe("Alabama"); + }); + + it("setMessage() returns an empty string when there is neither results nor filteredKeyValuePairs", () => { + component.results = undefined; + component.filteredKeyValuePairs = undefined; + component.isFreeTextEnabled = false; + expect(component.setMessage(0)).toBe(""); + }); + + it("setScrollTop() returns 0 when isFirstItemCategory is true", () => { + expect(component.setScrollTop(true, {} as never)).toBe(0); + }); + + it("onEnterDown() falls through without selecting when the free-text-adjusted index matches neither results nor filteredKeyValuePairs", () => { + component.results = undefined; + component.filteredKeyValuePairs = undefined; + component.isFreeTextEnabled = false; + const children = [ + { classList: { contains: () => true, remove: vi.fn() } }, + ]; + const list = { nativeElement: { children } }; + expect(() => component.onEnterDown(list)).not.toThrow(); + }); + + it("onUpArrowDown() wraps to the last item and its message when already at the first item", () => { + component.results = ["aaa", "bbb", "ccc"]; + const children = [ + { + classList: { + contains: (cls: string) => cls === "isSelected", + add: vi.fn(), + remove: vi.fn(), + }, + id: "a", + }, + { + classList: { contains: () => false, add: vi.fn(), remove: vi.fn() }, + id: "b", + }, + { + classList: { contains: () => false, add: vi.fn(), remove: vi.fn() }, + id: "c", + }, + ]; + const list = { nativeElement: { children, scrollTop: 0, clientTop: 0 } }; + + component.onUpArrowDown(list); + + expect(component.endOfList).toBe(true); + expect(component.selectedChild).toBe(children[2]); + }); + + it("displayFreeTextSimpleResults()/displayFreeTextKeyValueResults() are false when isKeyValue is undefined", () => { + component.isKeyValue = undefined; + component.isFreeTextEnabled = true; + expect(component.displayFreeTextSimpleResults()).toBe(false); + expect(component.displayFreeTextKeyValueResults()).toBe(false); + }); + + it("displayFreeTextSimpleResults()/displayFreeTextKeyValueResults() are false when free text is disabled", () => { + component.isKeyValue = false; + component.isFreeTextEnabled = false; + expect(component.displayFreeTextSimpleResults()).toBe(false); + expect(component.displayFreeTextKeyValueResults()).toBe(false); + }); + + it("onScroll() is a no-op when lazy rendering is disabled", () => { + component.enableLazyRendering = false; + expect(() => component.onScroll()).not.toThrow(); + }); + + it("onScroll() reads from results when filteredKeyValuePairs is empty", () => { + component.enableLazyRendering = true; + component.filteredKeyValuePairs = []; + component.results = ["a", "b", "c"]; + component.maxNumResultsToDisplay = 1; + component.resultsList = { + nativeElement: { offsetHeight: 10, scrollTop: 15, scrollHeight: 20 }, + } as never; + + component.onScroll(); + + expect(component.maxNumResultsToDisplay).toBeGreaterThan(1); + }); + + it("onScroll() does not request more results when not near the bottom", () => { + component.enableLazyRendering = true; + component.filteredKeyValuePairs = []; + component.results = ["a", "b", "c"]; + component.maxNumResultsToDisplay = 1; + component.resultsList = { + nativeElement: { offsetHeight: 10, scrollTop: 0, scrollHeight: 100 }, + } as never; + + component.onScroll(); + + expect(component.maxNumResultsToDisplay).toBe(1); + }); }); describe("rendered tests", () => { let component: SamAutocompleteComponent; @@ -701,7 +1016,7 @@ describe("The Sam Autocomplete Component", () => { }); it("Should populate results from an httpRequest observable (plain array)", () => { - const subject = new Subject(); + const subject = new Subject(); component.options = undefined; component.httpRequest = subject; component.ngOnChanges({ httpRequest: true }); @@ -712,7 +1027,7 @@ describe("The Sam Autocomplete Component", () => { }); it("Should emit onto keyEvents when driven by an httpRequest with no autocompleteService", () => { - const subject = new Subject(); + const subject = new Subject(); component.autocompleteService = null; component.options = undefined; component.httpRequest = subject; @@ -729,7 +1044,7 @@ describe("The Sam Autocomplete Component", () => { }); it("Should populate filteredKeyValuePairs from an httpRequest observable (key/value array)", () => { - const subject = new Subject(); + const subject = new Subject(); component.options = undefined; component.httpRequest = subject; component.ngOnChanges({ httpRequest: true }); @@ -742,7 +1057,7 @@ describe("The Sam Autocomplete Component", () => { }); it("Should route httpRequest errors to requestError", () => { - const subject = new Subject(); + const subject = new Subject(); component.options = undefined; component.httpRequest = subject as unknown as typeof component.httpRequest; @@ -963,5 +1278,31 @@ describe("The Sam Autocomplete Component", () => { expect(component.innerValue).toBe("Nowhere"); }); + + it("Should highlight the hovered result and flag end-of-list when hovering the last item", () => { + component.hasFocus = true; + component.results = ["Alabama", "Alaska"]; + fixture.detectChanges(); + + component.listItemHover(1); + fixture.detectChanges(); + + expect(component.endOfList).toBe(true); + }); + + it("Should account for the free-text row when computing the hovered index", () => { + component.isFreeTextEnabled = true; + component.inputValue = "Nowhere"; + component.hasFocus = true; + component.results = ["Alabama", "Alaska"]; + fixture.detectChanges(); + + // index 0 refers to the free-text row itself; freeText being available + // shifts every numeric result index up by one internally. + component.listItemHover(0); + fixture.detectChanges(); + + expect(component.selectedChild).toBeTruthy(); + }); }); }); diff --git a/src/ui-kit/form-controls/date-range/date-range.spec.ts b/src/ui-kit/form-controls/date-range/date-range.spec.ts index d5743f1d4..391754755 100755 --- a/src/ui-kit/form-controls/date-range/date-range.spec.ts +++ b/src/ui-kit/form-controls/date-range/date-range.spec.ts @@ -114,6 +114,16 @@ describe("The Sam Date Range component", () => { const c = new FormControl({ endDate: "Invalid date" }); expect(SamDateRangeComponent.dateRangeValidation(c)).toBe(undefined); }); + + it("passes a valid start-only date", () => { + const c = new FormControl({ startDate: "2020-01-01" }); + expect(SamDateRangeComponent.dateRangeValidation(c)).toBe(undefined); + }); + + it("passes a valid end-only date", () => { + const c = new FormControl({ endDate: "2020-01-01" }); + expect(SamDateRangeComponent.dateRangeValidation(c)).toBe(undefined); + }); }); describe("static dateRangeRequired", () => { @@ -155,6 +165,39 @@ describe("The Sam Date Range component", () => { undefined ); }); + + it("errors when only the end date is the sentinel 'Invalid date'", () => { + component.required = true; + component.hasFocus = false; + const c = new FormControl({ + startDate: "2020-01-01", + endDate: "Invalid date", + }); + const result = SamDateRangeComponent.dateRangeRequired(component)(c); + expect(result.dateRangeError.message).toBe("This field is required"); + }); + + it("does not error when both dates are present and valid", () => { + component.required = true; + component.hasFocus = false; + const c = new FormControl({ + startDate: "2020-01-01", + endDate: "2020-06-01", + }); + expect(SamDateRangeComponent.dateRangeRequired(component)(c)).toBe( + undefined + ); + }); + + it("honors fromRequired/toRequired independently of the required input", () => { + component.required = false; + component.fromRequired = true; + component.toRequired = true; + component.hasFocus = false; + const c = new FormControl({ startDate: "Invalid date" }); + const result = SamDateRangeComponent.dateRangeRequired(component)(c); + expect(result.dateRangeError.message).toBe("This field is required"); + }); }); describe("rendered tests", () => { @@ -279,5 +322,73 @@ describe("The Sam Date Range component", () => { expect(() => formService.fireSubmit(control.root)).not.toThrow(); expect(() => formService.fireReset(control.root)).not.toThrow(); }); + + it("ignores SamFormService events that are neither submit nor reset", () => { + const formService: SamFormService = TestBed.inject(SamFormService); + const control = new FormControl(""); + component.control = control; + component.useFormService = true; + component.ngOnInit(); + + const formatErrorsSpy = vi.spyOn(component.wrapper, "formatErrors"); + const clearErrorSpy = vi.spyOn(component.wrapper, "clearError"); + + formService.formEvents.next({ + root: control.root, + eventType: "touched", + }); + + expect(formatErrorsSpy).not.toHaveBeenCalled(); + expect(clearErrorSpy).not.toHaveBeenCalled(); + }); + + it("registers only a caller-supplied validator when defaultValidations is off", () => { + const customValidator = vi.fn().mockReturnValue(null); + const control = new FormControl("", customValidator); + component.control = control; + component.defaultValidations = false; + component.ngOnInit(); + + control.updateValueAndValidity(); + + expect(customValidator).toHaveBeenCalled(); + expect(control.errors).toBeNull(); + }); + + it("emits empty date strings when both models are empty", () => { + let emitted; + component.valueChange.subscribe((v) => (emitted = v)); + + component.startModel = { month: "", day: "", year: "" }; + component.endModel = { + month: undefined, + day: undefined, + year: undefined, + }; + component.dateChange(); + + expect(emitted.startDate).toBe(""); + expect(emitted.endDate).toBe(""); + }); + + it("isEmptyField treats a model with any populated part as non-empty", () => { + expect( + component.isEmptyField({ day: 1, month: undefined, year: undefined }) + ).toBe(false); + expect( + component.isEmptyField({ day: undefined, month: 1, year: undefined }) + ).toBe(false); + expect( + component.isEmptyField({ day: undefined, month: undefined, year: 2020 }) + ).toBe(false); + }); + + it("writeValue ignores a non-object value", () => { + expect(() => component.writeValue("2020-01-01")).not.toThrow(); + }); + + it("writeValue ignores an object with neither a start nor an end date", () => { + expect(() => component.writeValue({ somethingElse: true })).not.toThrow(); + }); }); }); 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 1f4ede3de..a8794d035 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 @@ -623,7 +623,7 @@ describe("SamAutocompleteComponent", () => { 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.inputValue = { id: "existing" } as never; component.focusRemoved(); tick(200); expect(component.model.items.length).toBe(1); @@ -684,7 +684,7 @@ describe("SamAutocompleteComponent", () => { extra: "ignored", }; component.selectItem(item); - const stored = component.model.items[0] as any; + const stored = component.model.items[0] as never; expect(Object.keys(stored).sort()).toEqual( ["id", "name", "subtext"].sort() ); @@ -760,7 +760,7 @@ describe("SamAutocompleteComponent", () => { tick(); fixture.detectChanges(); component.results = component.results.slice(0, 1); - (component as any).maxResults = 1; + component["maxResults"] = 1; expect(() => component.onScroll()).not.toThrow(); })); @@ -789,6 +789,470 @@ describe("SamAutocompleteComponent", () => { expect(component.model).toBe(model); }); + it("checkForFocus() does nothing when there is no configuration", () => { + component.configuration = undefined as never; + expect(() => component.checkForFocus({})).not.toThrow(); + expect(component.showResults).toBe(false); + }); + + it("checkForFocus() leaves the input untouched when items are already selected", () => { + component.model.items = [{ id: "1", name: "Level 1" }]; + component.inputValue = "Level 1"; + component.checkForFocus({}); + expect(component.inputValue).toBe("Level 1"); + }); + + it("updateSingleModeFocusOutModel() does nothing when there is no configuration", () => { + component.configuration = undefined as never; + expect(() => component.updateSingleModeFocusOutModel()).not.toThrow(); + }); + + it("focusRemoved() replaces an already-selected free text item with a new one on match", fakeAsync(() => { + component.configuration.isFreeTextEnabled = true; + component.model.items = [{ id: "repeat", name: "repeat" }]; + component.inputValue = { id: "repeat" } as never; + component.focusRemoved(); + tick(200); + expect(component.model.items.length).toBe(1); + expect(component.model.items[0]).toEqual({ + id: { id: "repeat" }, + name: { id: "repeat" }, + type: "custom", + }); + })); + + it("focusRemoved() does nothing when there is no configuration", fakeAsync(() => { + component.inputValue = "leftover"; + component.configuration = undefined as never; + expect(() => component.focusRemoved()).not.toThrow(); + tick(200); + })); + + it("focusRemoved() is a no-op when the input is empty", fakeAsync(() => { + component.inputValue = ""; + const selectSpy = vi.spyOn(component, "selectItem"); + component.focusRemoved(); + tick(200); + expect(selectSpy).not.toHaveBeenCalled(); + })); + + it("onKeydown() prevents default on Backspace when the input is read-only", () => { + component.configuration.inputReadOnly = true; + const event = { key: "Backspace", preventDefault: vi.fn(), target: {} }; + component.onKeydown(event); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it("onKeydown() does nothing on Backspace when the input is not read-only", () => { + component.configuration.inputReadOnly = false; + const event = { key: "Backspace", preventDefault: vi.fn(), target: {} }; + component.onKeydown(event); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it("onKeydown() delimits and selects on Enter in tag mode with delimiters enabled", () => { + component.configuration.isTagModeEnabled = true; + component.configuration.selectionMode = SelectionMode.MULTIPLE; + component.configuration.isDelimiterEnabled = true; + component.highlightedIndex = 0; + component.inputValue = "one,two"; + const updateSpy = vi.spyOn(component, "updateDelimeterModel"); + component.onKeydown({ key: "Enter", target: {} }); + expect(updateSpy).toHaveBeenCalled(); + }); + + it("onKeydown() selects a free-text item on Enter in tag mode without a highlighted item", () => { + component.configuration.isTagModeEnabled = true; + component.configuration.selectionMode = SelectionMode.SINGLE; + component.highlightedIndex = 0; + component.inputValue = "typed value"; + component.onKeydown({ key: "Enter", target: {} }); + expect(component.model.items[0]["name"]).toBe("typed value"); + }); + + it("onKeydown() delimits and selects on Enter with free text and no highlighted item", () => { + component.configuration.isFreeTextEnabled = true; + component.configuration.selectionMode = SelectionMode.MULTIPLE; + component.configuration.isDelimiterEnabled = true; + component.highlightedIndex = -1; + component.inputValue = "one,two"; + const updateSpy = vi.spyOn(component, "updateDelimeterModel"); + component.onKeydown({ key: "Enter", target: {} }); + expect(updateSpy).toHaveBeenCalled(); + }); + + it("onKeydown() selects the free-text item directly on Enter with no highlighted item when not in delimiter mode", () => { + component.configuration.isFreeTextEnabled = true; + component.configuration.selectionMode = SelectionMode.SINGLE; + component.highlightedIndex = -1; + component.inputValue = "typed value"; + component.onKeydown({ key: "Enter", target: {} }); + expect(component.model.items[0]["name"]).toBe("typed value"); + }); + + it("onKeydown() does nothing on Enter with no highlighted item when free text is disabled", () => { + component.configuration.isFreeTextEnabled = false; + component.highlightedIndex = -1; + const selectSpy = vi.spyOn(component, "selectItem"); + component.onKeydown({ key: "Enter", target: {} }); + expect(selectSpy).not.toHaveBeenCalled(); + }); + + it("onKeydown() stops propagation on Escape when the event supports it and results are showing", () => { + component.showResults = true; + const stopPropagation = vi.fn(); + component.onKeydown({ key: "Escape", target: {}, stopPropagation }); + expect(stopPropagation).toHaveBeenCalled(); + }); + + it("onKeydown() does nothing on Escape when results are not showing", () => { + component.showResults = false; + const clearSpy = vi.spyOn(component as never, "clearAndHideResults"); + component.onKeydown({ key: "Escape", target: {} }); + expect(clearSpy).not.toHaveBeenCalled(); + }); + + it("getSeparatedValue() splits on a custom delimiters array when configured", () => { + component.configuration.delimiters = [";", "|"]; + component.inputValue = "a;b|c"; + expect(component.getSeparatedValue()).toEqual(["a", "b", "c"]); + }); + + it("getSeparatedValue() falls back to splitting on commas without a delimiters array", () => { + component.configuration.delimiters = undefined; + component.inputValue = "a,b,c"; + expect(component.getSeparatedValue()).toEqual(["a", "b", "c"]); + }); + + it("updateDelimeterModel() skips empty segments between delimiters", () => { + component.configuration.selectionMode = SelectionMode.MULTIPLE; + component.inputValue = "a,,b"; + component.updateDelimeterModel(); + expect(component.model.items.length).toBe(2); + }); + + it("selectItem() omits the secondaryTextField from the filtered item when not configured", () => { + component.essentialModelFields = true; + component.configuration.secondaryTextField = undefined; + component.selectItem({ id: "1", name: "Level 1", subtext: "extra" }); + const stored = component.model.items[0] as never; + expect(stored.subtext).toBeUndefined(); + }); + + it("getFlatElements() recurses into grouped children", () => { + component.results = [ + { id: "1", elements: [{ id: "1a" }, { id: "1b" }] }, + { id: "2" }, + ]; + component.configuration.groupByChild = "elements"; + const flat = component.getFlatElements(); + expect(flat.map((i: unknown) => i.id)).toEqual(["1", "1a", "1b", "2"]); + }); + + it("getFlatElements() does not recurse when the group-by child is empty", () => { + component.results = [{ id: "1", elements: [] }]; + component.configuration.groupByChild = "elements"; + const flat = component.getFlatElements(); + expect(flat.map((i: unknown) => i.id)).toEqual(["1"]); + }); + + it("scrollToSelectedItem() is a no-op when highlightedIndex is negative", fakeAsync(() => { + component.inputFocusHandler(); + tick(); + fixture.detectChanges(); + component.highlightedIndex = -1; + expect(() => component["scrollToSelectedItem"]()).not.toThrow(); + })); + + it("onArrowGroupDown() does nothing when there are no results", () => { + component.results = []; + expect(() => component["onArrowGroupDown"]()).not.toThrow(); + }); + + it("onArrowGroupDown() stops incrementing once at the last result", fakeAsync(() => { + component.inputFocusHandler(); + tick(); + fixture.detectChanges(); + component.highlightedIndex = component.results.length - 1; + component["onArrowGroupDown"](); + expect(component.highlightedIndex).toBe(component.results.length - 1); + })); + + it("onArrowGroupUp() does nothing when there are no results", () => { + component.results = []; + expect(() => component["onArrowGroupUp"]()).not.toThrow(); + }); + + it("onArrowGroupUp() stays at index 0 when already at the top", fakeAsync(() => { + component.inputFocusHandler(); + tick(); + fixture.detectChanges(); + component.highlightedIndex = 0; + component["onArrowGroupUp"](); + expect(component.highlightedIndex).toBe(0); + })); + + it("showFreeText() finds an existing free-text match among the model's selected items", () => { + component.configuration.isFreeTextEnabled = true; + component.inputValue = "Level 1"; + component.results = undefined; + component.model.items = [{ id: "1", name: "Level 1" }]; + expect(component.showFreeText()).toBe(false); + }); + + it("showFreeText() reports available free text when neither results nor model items match", () => { + component.configuration.isFreeTextEnabled = true; + component.inputValue = "Nowhere"; + component.results = undefined; + component.model.items = [{ id: "1", name: "Level 1" }]; + expect(component.showFreeText()).toBe(true); + }); + + it("checkItemSelected() reports true for an item already present in the model", () => { + component.model.items = [{ id: "1", name: "Level 1" }]; + expect(component.checkItemSelected({ id: "1", name: "Level 1" })).toBe( + true + ); + }); + + it("checkItemSelected() reports false for an item not present in the model", () => { + component.model.items = [{ id: "1", name: "Level 1" }]; + expect(component.checkItemSelected({ id: "2", name: "Level 2" })).toBe( + false + ); + }); + + it("getResults() does nothing when the search string is shorter than the minimum character count", () => { + component.configuration.minimumCharacterCountSearch = 5; + const fetchSpy = vi.spyOn(component.service, "getDataByText"); + component["getResults"]("ab"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("getResults() skips re-fetching when the same non-empty search string is repeated while results are showing", fakeAsync(() => { + component.inputFocusHandler(); + component.inputValue = "Level"; + component["getResults"]("Level"); + tick(); + fixture.detectChanges(); + const fetchSpy = vi.spyOn(component.service, "getDataByText"); + component["getResults"]("Level"); + tick(); + expect(fetchSpy).not.toHaveBeenCalled(); + })); + + it("onScroll() throws when called before any results have loaded", () => { + component["maxResults"] = 5; + component.results = undefined as never; + expect(() => component.onScroll()).toThrow(); + }); + + it("focusRemoved() clears an existing free-text single selection and creates a new one on mismatch", fakeAsync(() => { + component.configuration.isTagModeEnabled = true; + component.model.items = [{ id: "old", name: "old" }]; + component.inputValue = "new value" as never; + component.focusRemoved(); + tick(200); + expect(component.model.items.length).toBe(1); + })); + + it("focusRemoved() does nothing in single mode when neither tag mode nor free text is enabled", fakeAsync(() => { + component.configuration.isTagModeEnabled = false; + component.configuration.isFreeTextEnabled = false; + component.inputValue = "typed"; + const selectSpy = vi.spyOn(component, "selectItem"); + component.focusRemoved(); + tick(200); + expect(selectSpy).not.toHaveBeenCalled(); + })); + + it("focusRemoved() does nothing when the selection mode is neither SINGLE nor MULTIPLE", fakeAsync(() => { + component.configuration.selectionMode = undefined as never; + component.inputValue = "typed"; + const selectSpy = vi.spyOn(component, "selectItem"); + component.focusRemoved(); + tick(200); + expect(selectSpy).not.toHaveBeenCalled(); + })); + + it("focusRemoved() clears the input in multiple mode when free text and tag mode are both disabled", fakeAsync(() => { + component.configuration.selectionMode = SelectionMode.MULTIPLE; + component.configuration.isFreeTextEnabled = false; + component.configuration.isTagModeEnabled = false; + component.inputValue = "stale text"; + component.focusRemoved(); + tick(200); + expect(component.inputValue).toBe(""); + })); + + it("textChange() ignores input events when the target is not the active element", () => { + const otherEl = document.createElement("input"); + const event = { + preventDefault: vi.fn(), + target: otherEl, + }; + const getResultsSpy = vi.spyOn(component as never, "getResults"); + component.textChange(event as never); + expect(event.preventDefault).toHaveBeenCalled(); + expect(getResultsSpy).not.toHaveBeenCalled(); + }); + + it("textChange() searches using an empty string when the target value is falsy", () => { + component.input.nativeElement.focus(); + const getResultsSpy = vi.spyOn(component as never, "getResults"); + component.textChange({ + preventDefault: vi.fn(), + target: component.input.nativeElement, + } as never); + expect(getResultsSpy).toHaveBeenCalledWith(""); + }); + + it("textChange() does nothing in tag mode", () => { + component.configuration.isTagModeEnabled = true; + const getResultsSpy = vi.spyOn(component as never, "getResults"); + component.textChange({ + preventDefault: vi.fn(), + target: component.input.nativeElement, + } as never); + expect(getResultsSpy).not.toHaveBeenCalled(); + }); + + it("inputFocusHandler() does nothing in tag mode", () => { + component.configuration.isTagModeEnabled = true; + const getResultsSpy = vi.spyOn(component as never, "getResults"); + component.inputFocusHandler(); + expect(getResultsSpy).not.toHaveBeenCalled(); + }); + + it("inputFocusHandler() calls onTouchedCallback without fetching results when focusInSearch is false", () => { + component.configuration.focusInSearch = false; + const getResultsSpy = vi.spyOn(component as never, "getResults"); + const touchedSpy = vi.fn(); + component.registerOnTouched(touchedSpy); + component.inputFocusHandler(); + expect(getResultsSpy).not.toHaveBeenCalled(); + expect(touchedSpy).toHaveBeenCalled(); + }); + + it("inputFocusHandler() sets highlightedChildIndex to null when the group isn't selectable", () => { + component.configuration.isSelectableGroup = false; + component.inputFocusHandler(); + expect(component.highlightedChildIndex).toBe(null); + }); + + it("onKeydown() returns early on Tab without altering state", () => { + const selectSpy = vi.spyOn(component, "selectItem"); + component.onKeydown({ key: "Tab", target: {} }); + expect(selectSpy).not.toHaveBeenCalled(); + }); + + it("onKeydown() delimits on Enter in tag mode with a highlighted item and delimiters enabled", () => { + component.configuration.isTagModeEnabled = true; + component.configuration.selectionMode = SelectionMode.MULTIPLE; + component.configuration.isDelimiterEnabled = true; + component.highlightedIndex = 0; + component.inputValue = "one,two"; + const updateSpy = vi.spyOn(component, "updateDelimeterModel"); + component.onKeydown({ key: "Enter", target: {} }); + expect(updateSpy).toHaveBeenCalled(); + }); + + it("onKeydown() selects the highlighted item on Enter when not in tag mode", () => { + component.configuration.isTagModeEnabled = false; + component.highlightedIndex = 0; + component["highlightedItem"] = { id: "1", name: "Level 1" }; + component.onKeydown({ key: "Enter", target: {} }); + expect(component.model.items[0]).toEqual({ id: "1", name: "Level 1" }); + }); + + it("onKeydown() delimits on Enter with no highlighted item, free text and delimiters enabled", () => { + component.configuration.isFreeTextEnabled = true; + component.configuration.selectionMode = SelectionMode.MULTIPLE; + component.configuration.isDelimiterEnabled = true; + component.highlightedIndex = -1; + component.inputValue = "one,two"; + const updateSpy = vi.spyOn(component, "updateDelimeterModel"); + component.onKeydown({ key: "Enter", target: {} }); + expect(updateSpy).toHaveBeenCalled(); + }); + + it("scrollToSelectedItem() is a no-op when no highlighted element is found in the DOM", fakeAsync(() => { + component.inputFocusHandler(); + tick(); + fixture.detectChanges(); + vi.spyOn( + component.resultsListElement.nativeElement, + "querySelector" + ).mockReturnValue(null); + expect(() => component["scrollToSelectedItem"]()).not.toThrow(); + })); + + it("showFreeText() stops scanning results once a match is found", () => { + component.configuration.isFreeTextEnabled = true; + component.inputValue = "Level 1"; + component.results = [ + { id: "1", name: "Level 1" }, + { id: "2", name: "Level 2" }, + ]; + expect(component.showFreeText()).toBe(false); + }); + + it("onScroll() requests more results when scrolled to the bottom", () => { + component.results = [{ id: "1" }]; + component["maxResults"] = 5; + component.resultsListElement = { + nativeElement: { offsetHeight: 10, scrollTop: 90, scrollHeight: 100 }, + } as never; + const additionalSpy = vi.spyOn(component as never, "getAdditionalResults"); + component.onScroll(); + expect(additionalSpy).toHaveBeenCalled(); + }); + + it("onScroll() does not request more results when not scrolled near the bottom", () => { + component.results = [{ id: "1" }]; + component["maxResults"] = 5; + component.resultsListElement = { + nativeElement: { offsetHeight: 10, scrollTop: 0, scrollHeight: 1000 }, + } as never; + const additionalSpy = vi.spyOn(component as never, "getAdditionalResults"); + component.onScroll(); + expect(additionalSpy).not.toHaveBeenCalled(); + }); + + it("setHighlightedItem() clears a previously highlighted item's flag before clearing the highlight", fakeAsync(() => { + component.inputFocusHandler(); + tick(); + fixture.detectChanges(); + const previous: any = { name: "prev" }; + component["highlightedItem"] = previous; + component["setHighlightedItem"](undefined); + expect(previous.highlighted).toBe(false); + })); + + it("setHighlightedItem() appends the secondary text field to the announced message when present", fakeAsync(() => { + component.inputFocusHandler(); + tick(); + fixture.detectChanges(); + const item: any = { name: "Level X", subtext: "Extra info" }; + component["setHighlightedItem"](item); + expect(component.srOnlyText).toContain("Extra info"); + })); + + it("setHighlightedItem() omits the secondary text field from the message when the item has no value for it", fakeAsync(() => { + component.inputFocusHandler(); + tick(); + fixture.detectChanges(); + const item: any = { name: "Level X" }; + component["setHighlightedItem"](item); + expect(component.srOnlyText).toBe("Level X"); + })); + + it("showFreeText() returns false when the input value's length is zero without being empty", () => { + component.configuration.isFreeTextEnabled = true; + component.inputValue = [] as never; + expect(component.showFreeText()).toBe(false); + }); + it("marks each rendered result option's aria-selected from checkItemSelected", fakeAsync(() => { component.configuration.isSelectableGroup = true; component.inputFocusHandler(); diff --git a/src/ui-kit/form-controls/textarea/textarea.spec.ts b/src/ui-kit/form-controls/textarea/textarea.spec.ts index af4246e3c..111f6071e 100755 --- a/src/ui-kit/form-controls/textarea/textarea.spec.ts +++ b/src/ui-kit/form-controls/textarea/textarea.spec.ts @@ -179,5 +179,65 @@ describe("The Sam Textarea component", () => { component.onBlur(); expect(component.value).toBe("hello"); }); + + it("marks the control pristine once on IE when a placeholder is set", () => { + const c = new FormControl(""); + component.control = c; + component.useFormService = false; + component.placeholder = "type here"; + // The IE placeholder workaround is gated on a private UA sniff that no + // test browser satisfies; set it directly to reach the branch. + component["isIE"] = true; + component.ngOnInit(); + component.ngAfterViewInit(); + + c.markAsDirty(); + c.setValue("a"); + expect(c.pristine).toBe(true); + + // The flag makes this a one-shot fix: a second change stays dirty. + c.markAsDirty(); + c.setValue("ab"); + expect(c.pristine).toBe(false); + }); + + it("ignores form-service events that are neither submit nor reset", () => { + const formService = TestBed.inject(SamFormService); + const c = new FormControl(""); + component.control = c; + component.useFormService = true; + component.ngOnInit(); + component.ngAfterViewInit(); + + const formatErrorsSpy = vi.spyOn(component.wrapper, "formatErrors"); + const clearErrorSpy = vi.spyOn(component.wrapper, "clearError"); + + formService.formEvents.next({ root: c.root, eventType: "touched" }); + + expect(formatErrorsSpy).not.toHaveBeenCalled(); + expect(clearErrorSpy).not.toHaveBeenCalled(); + }); + + it("uses the singular 'character' wording at one remaining character", () => { + component.maxlength = 5; + component.showCharCount = true; + component.value = "abcd"; + + component.setCharCounterMsg(component.value); + + expect(component.characterCounterMsg).toBe( + "1 character remaining of 5 characters." + ); + }); + + it("does not build a counter message when the counter is hidden", () => { + component.showCharCount = false; + component.characterCounterMsg = ""; + component.maxlength = 5; + + component.setCharCounterMsg("abc"); + + expect(component.characterCounterMsg).toBe(""); + }); }); }); diff --git a/src/ui-kit/form-controls/upload-v2/upload-v2.spec.ts b/src/ui-kit/form-controls/upload-v2/upload-v2.spec.ts index 7ec7937be..8f35cbbbf 100755 --- a/src/ui-kit/form-controls/upload-v2/upload-v2.spec.ts +++ b/src/ui-kit/form-controls/upload-v2/upload-v2.spec.ts @@ -600,4 +600,122 @@ describe("The Sam Upload v2 component", () => { ); }); }); + + describe("writeValue", () => { + it("populates the upload table when given a non-empty array", () => { + component.writeValue([ + { name: "a.pdf", size: 10, postedDate: "", icon: {} }, + ]); + expect(component.fileCtrlConfig.length).toBe(1); + }); + + it("clears the model when given an empty value", () => { + component.writeValue([ + { name: "a.pdf", size: 10, postedDate: "", icon: {} }, + ]); + component.writeValue(undefined); + expect(component._model.length).toBe(0); + }); + }); + + describe("initilizeFileCtrl", () => { + it("preserves an explicitly secure flag and posted date", () => { + const config = component.initilizeFileCtrl({ + name: "a.pdf", + size: 10, + url: "", + icon: {}, + disabled: false, + isSecure: true, + postedDate: "Jan 01, 2020 1:00 am", + }); + expect(config.isSecure).toBe(true); + // initilizeFileCtrl exposes the incoming postedDate as `date`. + expect(config.date).toBe("Jan 01, 2020 1:00 am"); + }); + }); + + describe("doUpload", () => { + it("skips files that are not in the Initial state", () => { + const uf = fakeUploadFile(UploadStatus.Done); + const streamSpy = vi.spyOn(component, "_getHttpEventSteam"); + + component.doUpload([uf]); + + expect(streamSpy).not.toHaveBeenCalled(); + expect(uf.upload.status).toBe(UploadStatus.Done); + }); + }); + + describe("name editing edge cases", () => { + it("leaves edit mode without scheduling a focus when toggled off", () => { + component.uploadedFiles = [ + { name: "a.pdf", size: 10, postedDate: "", icon: {} }, + ]; + component.setUploadedFiles(component.uploadedFiles); + component.fileCtrlConfig[0].isNameEditMode = true; + + component.onNameEditSwitch(0, { preventDefault: () => undefined }); + + expect(component.fileCtrlConfig[0].isNameEditMode).toBe(false); + }); + + it("overwrites the name by default when no overwrite flag is passed", () => { + component.uploadedFiles = [ + { name: "a.pdf", size: 10, postedDate: "", icon: {} }, + ]; + component.setUploadedFiles(component.uploadedFiles); + component.fileCtrlConfig[0].shadowFileName = "renamed.pdf"; + + component.onNameEditComplete(0); + + expect(component.fileCtrlConfig[0].fileName).toBe("renamed.pdf"); + }); + }); + + describe("remove flow edge cases", () => { + it("removes the row even when no model entry matches the file name", () => { + component.uploadedFiles = [ + { name: "a.pdf", size: 10, postedDate: "", icon: {} }, + ]; + component.setUploadedFiles(component.uploadedFiles); + component._model = []; + component.removeModal = { closeModal: vi.fn() }; + + component.onRemoveModalSubmit(0); + + expect(component.fileCtrlConfig.length).toBe(0); + }); + + it("removeFileFromList keeps the input value when files remain", () => { + const keep = fakeUploadFile(UploadStatus.Done, "keep.txt"); + const drop = fakeUploadFile(UploadStatus.Done, "drop.txt"); + component._model = [keep, drop]; + const clearSpy = vi.spyOn(component, "_clearInput"); + + component.removeFileFromList(drop); + + expect(component._model).toEqual([keep]); + expect(clearSpy).not.toHaveBeenCalled(); + }); + }); + + describe("delete request resolution", () => { + it("resolves a plain HttpRequest-returning deleteRequest", () => { + const uf = fakeUploadFile(UploadStatus.Done); + component.deleteRequest = () => new HttpRequest("DELETE", "files/1"); + expect(() => component._getDeleteRequestForFile(uf)).not.toThrow(); + }); + }); + + describe("element id prefixing edge cases", () => { + it("leaves ids untouched for a property that is not in the id map", () => { + const before = { ...component.uploadElIds }; + component.id = "prefix"; + + component["setElementId"]("notARealProperty"); + + expect(component.uploadElIds).toEqual(before); + }); + }); }); diff --git a/src/ui-kit/form-templates/international-phone/international.spec.ts b/src/ui-kit/form-templates/international-phone/international.spec.ts index 8d7205dc0..2023824ab 100755 --- a/src/ui-kit/form-templates/international-phone/international.spec.ts +++ b/src/ui-kit/form-templates/international-phone/international.spec.ts @@ -144,5 +144,73 @@ describe("The Sam International Phone Group", () => { expect(expected).toEqual(countryCode); expect(component.phoneControl.valid).toBe(false); }); + + it("should throw when hasExtension is true but no extensionName is provided", () => { + component.phoneName = "a"; + component.prefixName = "a"; + component.extensionName = ""; + component.hasExtension = true; + + expect(() => fixture.detectChanges()).toThrow(); + + component.extensionName = "a"; + component.hasExtension = false; + }); + + it("should reset the prefix to '1' when the group emits a value with no prefix", () => { + component.phoneName = "a"; + component.prefixName = "a"; + fixture.detectChanges(); + + component.group.patchValue({ prefix: "" }); + + expect(component.group.controls.prefix.value).toBe("1"); + }); + + it("should format wrapper errors on SamFormService submit events when useFormService is true", () => { + component.phoneName = "a"; + component.prefixName = "a"; + component.useFormService = true; + fixture.detectChanges(); + + const formatSpy = vi.spyOn(component.wrapper, "formatErrors"); + const samFormService = TestBed.inject(SamFormService); + samFormService.fireSubmit(component.group.root); + + expect(formatSpy).toHaveBeenCalledWith( + component.group.controls.prefix, + component.group.controls.phone, + component.group.controls.extension + ); + }); + + it("should clear wrapper errors on SamFormService reset events when useFormService is true", () => { + component.phoneName = "a"; + component.prefixName = "a"; + component.useFormService = true; + fixture.detectChanges(); + + const clearSpy = vi.spyOn(component.wrapper, "clearError"); + const samFormService = TestBed.inject(SamFormService); + samFormService.fireReset(component.group.root); + + expect(clearSpy).toHaveBeenCalled(); + }); + + it("should format wrapper errors on group value changes when useFormService is false", () => { + component.phoneName = "a"; + component.prefixName = "a"; + component.useFormService = false; + fixture.detectChanges(); + + const formatSpy = vi.spyOn(component.wrapper, "formatErrors"); + component.group.controls.phone.setValue("5551234"); + + expect(formatSpy).toHaveBeenCalledWith( + component.group.controls.prefix, + component.group.controls.phone, + component.group.controls.extension + ); + }); }); }); diff --git a/src/ui-kit/form-templates/international-phone/sam-telephone/telephone.spec.ts b/src/ui-kit/form-templates/international-phone/sam-telephone/telephone.spec.ts index 1624fa881..ee4a75070 100755 --- a/src/ui-kit/form-templates/international-phone/sam-telephone/telephone.spec.ts +++ b/src/ui-kit/form-templates/international-phone/sam-telephone/telephone.spec.ts @@ -79,4 +79,96 @@ describe("Sam Telephone Component", () => { expect(component.inputValue).toEqual(expected); }); + + it("ignores changes that do not include countryCode", () => { + const before = component.template; + + component.ngOnChanges({ + placeholder: new SimpleChange(undefined, "other", false), + }); + + expect(component.template).toEqual(before); + }); + + it("treats a missing country code as North American", () => { + component.ngOnChanges({ + countryCode: new SimpleChange(undefined, undefined, true), + }); + + expect(component.template).toEqual("(___)___-____"); + }); + + it("validate() returns the first default validator error", () => { + component.ngOnChanges({ + countryCode: new SimpleChange(undefined, 1, true), + }); + + const result = component.validate({ value: "12345" } as never); + + expect(result.usaPhone.message).toBe( + "North American phone numbers must be 10 digits" + ); + }); + + it("validate() returns null for a valid North American number", () => { + component.ngOnChanges({ + countryCode: new SimpleChange(undefined, 1, true), + }); + + expect(component.validate({ value: "1234567890" } as never)).toBeNull(); + }); + + it("validate() returns null when the control has no value", () => { + component.ngOnChanges({ + countryCode: new SimpleChange(undefined, 1, true), + }); + + expect(component.validate({ value: "" } as never)).toBeNull(); + }); + + it("validate() flags an international number outside the 4-15 digit range", () => { + component.ngOnChanges({ + countryCode: new SimpleChange(undefined, 44, false), + }); + + const result = component.validate({ value: "123" } as never); + + expect(result.intlPhone.message).toBe( + "International phone numbers must be between 4 and 15 digits" + ); + }); + + it("validate() accepts an international number within range", () => { + component.ngOnChanges({ + countryCode: new SimpleChange(undefined, 44, false), + }); + + expect(component.validate({ value: "123456" } as never)).toBeNull(); + }); + + it("onKeyInput blocks a disallowed key", () => { + const preventDefault = vi.fn(); + + component.onKeyInput({ key: "a", preventDefault }); + + expect(preventDefault).toHaveBeenCalled(); + }); + + it("onKeyInput allows a numeric key through", () => { + const preventDefault = vi.fn(); + + // KeyHelper compares `event.key` against numeric literals, so a string + // "1" does not match; a real digit keypress is identified by `code`. + component.onKeyInput({ code: "Digit1", preventDefault }); + + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("input/focus/blur handlers are no-ops without an event target", () => { + const noTarget = { currentTarget: null }; + + expect(() => component.inputChange(noTarget)).not.toThrow(); + expect(() => component.handleFocus(noTarget)).not.toThrow(); + expect(() => component.handleBlur(noTarget)).not.toThrow(); + }); }); diff --git a/src/ui-kit/form-templates/phone-entry/phone-entry.spec.ts b/src/ui-kit/form-templates/phone-entry/phone-entry.spec.ts index e96ddc60c..13e74959f 100755 --- a/src/ui-kit/form-templates/phone-entry/phone-entry.spec.ts +++ b/src/ui-kit/form-templates/phone-entry/phone-entry.spec.ts @@ -206,5 +206,83 @@ describe("The Sam Phone Entry component", () => { expect(result).toBeUndefined(); }); + + it("writeValue() clears the model when a numbersOnly value is empty", () => { + component.numbersOnly = true; + component.writeValue(""); + expect(component.model).toBe(""); + }); + + it("writeValue() falls back to the template when a non-numbersOnly value is empty", () => { + component.numbersOnly = false; + component.writeValue(""); + expect(component.model).toBe(component.phoneNumberTemplate); + }); + + it("preserves an existing control validator alongside the default one", () => { + const customValidator = vi.fn().mockReturnValue(null); + component.control = new FormControl("", customValidator); + component.useDefaultValidations = true; + component.ngOnInit(); + + component.control.updateValueAndValidity(); + + expect(customValidator).toHaveBeenCalled(); + }); + + it("skips the default phone validator when useDefaultValidations is false", () => { + component.control = new FormControl("1+(111)___-____"); + component.useDefaultValidations = false; + component.ngOnInit(); + + expect(component.control.errors).toBeNull(); + }); + + it("formats an already-populated model with numbersOnly during ngOnInit", () => { + component.numbersOnly = true; + component.model = "5551234"; + component.ngOnInit(); + expect(component.phoneNumber).toContain("5551234".slice(0, 1)); + }); + + it("process() moves the caret without altering the value on left/right arrow keys", () => { + el.nativeElement.focus(); + el.nativeElement.setSelectionRange(2, 2); + + el.triggerEventHandler("keydown", { + keyCode: 39, + preventDefault: () => undefined, + }); + + // process() always calls updateModel() at the end (even for the + // caret-move branches), syncing model to the still-unedited template. + expect(component.model).toBe(component.phoneNumberTemplate); + }); + + it("process() restores the phone number value on an unrecognized key", () => { + component.writeValue("1+(111)111-1111"); + fixture.detectChanges(); + el.nativeElement.focus(); + el.nativeElement.setSelectionRange(2, 2); + + el.triggerEventHandler("keydown", { + keyCode: 90, + key: "z", + preventDefault: () => undefined, + }); + + expect(el.nativeElement.value).toBe(component.phoneNumber); + }); + + it("getPositionIncrement() wraps to pos+1 when there is no further blank slot", () => { + const lastIndex = component.phoneNumberTemplate.length - 1; + expect(component.getPositionIncrement(lastIndex)).toBe(lastIndex + 1); + }); + + it("getPositionDecrement() wraps to the first blank slot when there is no earlier one", () => { + expect(component.getPositionDecrement(0)).toBe( + component.phoneNumberTemplate.indexOf("_") + ); + }); }); }); diff --git a/src/ui-kit/layout-deprecated/list-results-message.spec.ts b/src/ui-kit/layout-deprecated/list-results-message.spec.ts index f41ee00b7..e356e64c7 100755 --- a/src/ui-kit/layout-deprecated/list-results-message.spec.ts +++ b/src/ui-kit/layout-deprecated/list-results-message.spec.ts @@ -43,5 +43,14 @@ describe("ListResultsMessage component", () => { const text = fixture.debugElement.nativeElement.textContent; expect(text).toBe("Showing 1 - 7 of 7 results"); }); + it("should clamp the upper bound to the total on a partial last page", function () { + component.total = 23; + component.currentPage = 3; + component.showing = 10; + component.ngOnChanges(); + fixture.detectChanges(); + const text = fixture.debugElement.nativeElement.textContent; + expect(text).toBe("Showing 21 - 23 of 23 results"); + }); }); }); diff --git a/src/ui-kit/layout-deprecated/page.service.spec.ts b/src/ui-kit/layout-deprecated/page.service.spec.ts index 425aa8e65..0dc4e58fc 100755 --- a/src/ui-kit/layout-deprecated/page.service.spec.ts +++ b/src/ui-kit/layout-deprecated/page.service.spec.ts @@ -29,5 +29,20 @@ describe("PageService", () => { expect(service.sidebarColumns).toBe(""); expect(service.mainContentColumns).toBe("12"); }); + + it("applies wide columns when the sidebar is enabled after wideSidebar", () => { + service.wideSidebar = true; + service.sidebar = true; + + expect(service.sidebarColumns).toBe("4"); + expect(service.mainContentColumns).toBe("8"); + }); + + it("does not size columns when wideSidebar is set without a sidebar", () => { + service.wideSidebar = true; + + expect(service.sidebarColumns).toBeUndefined(); + expect(service.mainContentColumns).toBeUndefined(); + }); }); }); diff --git a/src/ui-kit/layout/pagination/pagination.component.spec.ts b/src/ui-kit/layout/pagination/pagination.component.spec.ts index 1bec9167a..dfdde9329 100755 --- a/src/ui-kit/layout/pagination/pagination.component.spec.ts +++ b/src/ui-kit/layout/pagination/pagination.component.spec.ts @@ -76,4 +76,72 @@ describe("SamPaginationNextComponent", () => { component.paginator = paginator; expect(component.paginator.getTotalPages()).toBe(10); }); + + it("sets pageSize via a numeric string input", () => { + const paginator = new Paginator("Test Unit", 10, 100); + component.paginator = paginator; + component.pageSize = "20"; + expect(component.pageSize).toBe(20); + }); + + it("emits unitsChange with the updated units per page when pageSize is set", () => { + const paginator = new Paginator("Test Unit", 10, 100); + component.paginator = paginator; + const spy = vi.fn(); + component.unitsChange.subscribe(spy); + component.pageSize = 25; + expect(spy).toHaveBeenCalledWith(25); + }); + + it("ngOnChanges applies each changed input to the paginator", () => { + const paginator = new Paginator("Test Unit", 10, 100); + component.paginator = paginator; + component.defaultSize = 20; + component.unit = "Results"; + component.totalUnits = 200; + component.currentPage = 3; + + component.ngOnChanges({ + defaultSize: {} as never, + unit: {} as never, + totalUnits: {} as never, + currentPage: {} as never, + }); + + expect(component.paginator.unit).toBe("Results"); + expect(component.paginator.getTotalUnits()).toBe(200); + expect(component.currentPage).toBe(3); + }); + + it("ngOnChanges does nothing when no watched input changed", () => { + const paginator = new Paginator("Test Unit", 10, 100); + component.paginator = paginator; + const priorPage = component.currentPage; + + expect(() => component.ngOnChanges({})).not.toThrow(); + expect(component.currentPage).toBe(priorPage); + }); + + it("emits pageChange and unitsChange with initial paginator values on ngOnInit", () => { + const paginator = new Paginator("Test Unit", 10, 100); + component.paginator = paginator; + const pageSpy = vi.fn(); + const unitsSpy = vi.fn(); + component.pageChange.subscribe(pageSpy); + component.unitsChange.subscribe(unitsSpy); + + component.ngOnInit(); + + expect(pageSpy).toHaveBeenCalledWith(paginator.getCurrentPage()); + expect(unitsSpy).toHaveBeenCalledWith(paginator.getUnitsPerPage()); + }); + + it("emits pageChange with the updated page after clicking next", () => { + const paginator = new Paginator("Test Unit", 10, 100); + component.paginator = paginator; + const spy = vi.fn(); + component.pageChange.subscribe(spy); + component.onNextClick(); + expect(spy).toHaveBeenCalledWith(2); + }); }); diff --git a/src/ui-kit/layout/pagination/paginator.spec.ts b/src/ui-kit/layout/pagination/paginator.spec.ts new file mode 100644 index 000000000..93f712ee2 --- /dev/null +++ b/src/ui-kit/layout/pagination/paginator.spec.ts @@ -0,0 +1,128 @@ +import { Paginator } from "./paginator"; + +describe("Paginator", () => { + it("defaults units per page and total units when not provided", () => { + const paginator = new Paginator( + "Item", + undefined as never, + undefined as never + ); + expect(paginator.getUnitsPerPage()).toBe(10); + expect(paginator.getTotalUnits()).toBe(0); + expect(paginator.getCurrentPage()).toBe(1); + }); + + it("defaults current page to 1 when not provided", () => { + const paginator = new Paginator("Item", 10, 100); + expect(paginator.getCurrentPage()).toBe(1); + }); + + it("uses the provided current page when given", () => { + const paginator = new Paginator("Item", 10, 100, 3); + expect(paginator.getCurrentPage()).toBe(3); + }); + + it("computes total pages by dividing total units by units per page", () => { + const paginator = new Paginator("Item", 10, 95); + expect(paginator.getTotalPages()).toBe(10); + }); + + it("does not recalculate pagination when units per page is unchanged", () => { + const paginator = new Paginator("Item", 10, 100); + paginator.setCurrentPage(3); + paginator.setUnitsPerPage(10); + expect(paginator.getCurrentPage()).toBe(3); + }); + + it("resets to page 1 when units per page changes", () => { + const paginator = new Paginator("Item", 10, 100); + paginator.setCurrentPage(3); + paginator.setUnitsPerPage(20); + expect(paginator.getCurrentPage()).toBe(1); + }); + + it("resets to page 1 when total units changes", () => { + const paginator = new Paginator("Item", 10, 100); + paginator.setCurrentPage(3); + paginator.setTotalUnits(200); + expect(paginator.getCurrentPage()).toBe(1); + }); + + it("rejects a page number below 1", () => { + const paginator = new Paginator("Item", 10, 100); + paginator.setCurrentPage(0); + expect(paginator.getCurrentPage()).toBe(1); + }); + + it("rejects a page number that exceeds the total", () => { + const paginator = new Paginator("Item", 10, 100); + paginator.setCurrentPage(50); + expect(paginator.getCurrentPage()).toBe(1); + }); + + it("accepts a page number that lands exactly on the last partial page", () => { + const paginator = new Paginator("Item", 10, 95); + paginator.setCurrentPage(10); + expect(paginator.getCurrentPage()).toBe(10); + }); + + it("advances to the next page via nextPage()", () => { + const paginator = new Paginator("Item", 10, 100); + paginator.nextPage(); + expect(paginator.getCurrentPage()).toBe(2); + }); + + it("does not advance past the last valid page via nextPage()", () => { + // 95 units at 10 per page: page 10 covers units 91-95 and page 11 is empty. + const paginator = new Paginator("Item", 10, 95, 10); + paginator.nextPage(); + expect(paginator.getCurrentPage()).toBe(10); + }); + + it("does not advance past the last page when the total is an exact multiple of the page size", () => { + // 100 units at 10 per page is the common boundary: page 10 is exactly the + // last page, so page 11 holds nothing. _exceedsTotal() used to accept it + // because the remainder (10) was not strictly greater than the page size. + const paginator = new Paginator("Item", 10, 100, 10); + paginator.nextPage(); + expect(paginator.getCurrentPage()).toBe(10); + }); + + it("keeps the first page valid for an empty data set", () => { + const paginator = new Paginator("Item", 10, 0); + expect(paginator.getCurrentPage()).toBe(1); + }); + + it("rejects a page beyond the only page of a partially-filled first page", () => { + const paginator = new Paginator("Item", 10, 7); + paginator.nextPage(); + expect(paginator.getCurrentPage()).toBe(1); + }); + + it("goes back a page via previousPage()", () => { + const paginator = new Paginator("Item", 10, 100, 5); + paginator.previousPage(); + expect(paginator.getCurrentPage()).toBe(4); + }); + + it("does not go below the first page via previousPage()", () => { + const paginator = new Paginator("Item", 10, 100, 1); + paginator.previousPage(); + expect(paginator.getCurrentPage()).toBe(1); + }); + + it("prints the displaying string for a full-page range", () => { + const paginator = new Paginator("Item", 10, 100, 2); + expect(paginator.printDisplayingString()).toBe("11 – 20 of 100"); + }); + + it("clamps the max displayed unit to the total on the last partial page", () => { + const paginator = new Paginator("Item", 10, 95, 10); + expect(paginator.printDisplayingString()).toBe("91 – 95 of 95"); + }); + + it("prints the per-page unit string", () => { + const paginator = new Paginator("Widget", 10, 100); + expect(paginator.printPerPageString()).toBe("Widget per page"); + }); +}); diff --git a/src/ui-kit/layout/pagination/paginator.ts b/src/ui-kit/layout/pagination/paginator.ts index b08180d51..3070afd61 100755 --- a/src/ui-kit/layout/pagination/paginator.ts +++ b/src/ui-kit/layout/pagination/paginator.ts @@ -80,9 +80,17 @@ export class Paginator { } private _exceedsTotal(pageNum): boolean { - const r = this._calculateRemainder(pageNum); + // A page is out of range once its first unit is past the end of the data, + // i.e. (pageNum - 1) * unitsPerPage >= totalUnits, which is the same as + // remainder >= unitsPerPage. The previous test was `r > 0 && r > upp`, + // which let the first empty page through whenever the total was an exact + // multiple of the page size (100 units at 10 per page accepted page 11). + // Page 1 stays valid for an empty data set. + if (pageNum === 1) { + return false; + } - return r > 0 && r > this.getUnitsPerPage(); + return this._calculateRemainder(pageNum) >= this.getUnitsPerPage(); } private _calculateRemainder(pageNum): number { diff --git a/src/ui-kit/pipes/date-time-display/date-time-display.pipe.spec.ts b/src/ui-kit/pipes/date-time-display/date-time-display.pipe.spec.ts index 78e3f9565..4c1ffc8f0 100755 --- a/src/ui-kit/pipes/date-time-display/date-time-display.pipe.spec.ts +++ b/src/ui-kit/pipes/date-time-display/date-time-display.pipe.spec.ts @@ -11,14 +11,13 @@ describe("DateTimeDisplayPipe test", () => { ); }); - it.skip("FilterMultiArrayObjectPipe Test: Not nested: Single array", () => { - // This test is broken. Needs to be fixed, but I can't tell from the - // file what the business rules should be and, subsequently, the correct - // way to fix the test. - const datetime = moment().subtract(1, "month"); + it("formats a date a day or more old but still within this year as MMM DD", () => { + const datetime = moment("2024-01-01T12:00:00Z"); + vi.setSystemTime(new Date("2024-01-05T12:00:00Z")); expect(pipe.transform(datetime.format("YYYY-MM-DD HH:ss"))).toEqual( datetime.format("MMM DD") ); + vi.useRealTimers(); }); it("FilterMultiArrayObjectPipe Test: Nested array", () => { @@ -27,4 +26,15 @@ describe("DateTimeDisplayPipe test", () => { datetime.format("MMM DD, YYYY") ); // second level }); + + it("warns and returns undefined when the input is undefined", () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + expect(pipe.transform(undefined)).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + "Invalid value passed into DateTimeDisplayPipe" + ); + warnSpy.mockRestore(); + }); }); diff --git a/src/ui-kit/pipes/filesize/filesize.pipe.spec.ts b/src/ui-kit/pipes/filesize/filesize.pipe.spec.ts index 1a11b513d..1b88f2422 100755 --- a/src/ui-kit/pipes/filesize/filesize.pipe.spec.ts +++ b/src/ui-kit/pipes/filesize/filesize.pipe.spec.ts @@ -27,4 +27,18 @@ describe("src/app/opportunity/pipes/filesize.pipe.spec.ts", () => { expect(pipe.transform(1.5 * kb + 1)).toBe("2 KB"); expect(pipe.transform(5.5 * mb - 1)).toBe("5 MB"); }); + + it("FilesizePipe: returns '0' for non-numeric input", () => { + expect(pipe.transform("1024" as never)).toBe("0"); + }); + + it("FilesizePipe: warns and falls back to the byte symbol above the largest supported size", () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + const petabyte = 2 ** 50 + 1; + expect(pipe.transform(petabyte)).toContain("B"); + expect(warnSpy).toHaveBeenCalledWith("file size symbol not supported"); + warnSpy.mockRestore(); + }); }); diff --git a/src/ui-kit/pipes/short-date/short-date.pipe.spec.ts b/src/ui-kit/pipes/short-date/short-date.pipe.spec.ts index f8fc09380..366d955ac 100755 --- a/src/ui-kit/pipes/short-date/short-date.pipe.spec.ts +++ b/src/ui-kit/pipes/short-date/short-date.pipe.spec.ts @@ -17,17 +17,25 @@ describe("A pipe for shorter time formats", () => { * our filters broke. Most of the tests through the app need to be fixed. */ - it.skip("should show month day time if date is same year as now", () => { - const jan1 = moment().set("month", 1).set("day", 1); - const jan2 = moment().set("month", 1).set("day", 2); - const display = pipe.transform(jan1, jan2); - expect(display.length).toEqual(pipe.sameYearFormat.length + 2); + it("should show month day time if date is a different day within the same year as now", () => { + const now = moment("2024-06-15T12:00:00Z"); + const earlierThisYear = moment("2024-03-01T09:00:00Z"); + const display = pipe.transform(earlierThisYear, now); + expect(display).toBe(earlierThisYear.format(pipe.sameYearFormat)); }); - it.skip("should show month day year if date is not this year", () => { - const thisYear = moment(); - const lastYear = moment().subtract("year", 1); - const display = pipe.transform(lastYear, thisYear); - expect(display.length).toEqual(pipe.differentYearFormat.length + 2); + it("should show month day year if the date is in a different year than now", () => { + const now = moment("2024-06-15T12:00:00Z"); + const lastYear = moment("2023-06-15T12:00:00Z"); + const display = pipe.transform(lastYear, now); + expect(display).toBe(lastYear.format(pipe.differentYearFormat)); + }); + + it("should default to the current time when no fakeNow is provided", () => { + const display = pipe.transform(moment()); + // sameDayFormat is h:mmA; h is unpadded (1-2 digits) depending on the + // hour, so assert the pattern rather than a fixed length that would be + // flaky across different times of day. + expect(display).toMatch(/^\d{1,2}:\d{2}(AM|PM)$/); }); }); diff --git a/src/ui-kit/utilities/key-helper/key-helper.spec.ts b/src/ui-kit/utilities/key-helper/key-helper.spec.ts index a5b6fb0a4..16188d256 100755 --- a/src/ui-kit/utilities/key-helper/key-helper.spec.ts +++ b/src/ui-kit/utilities/key-helper/key-helper.spec.ts @@ -123,39 +123,53 @@ describe("Sam KeyEvent Class", () => { }); describe("KeyHelper getKeyCode method", () => { - const mock = { - code: undefined, - key: undefined, - keyIdentifier: undefined, - }; + // Each case builds its own event stub. A single shared, mutated `mock` + // object made these tests pass vacuously: every case assigned the same + // string "asdf", so once test one had set `key`, the later cases asserted + // `getKeyCode() === "asdf"` while still returning `key` rather than the + // `code`/`keyIdentifier` fallback they claim to exercise. That left the + // fallback branches covered only by whichever *other* spec file happened + // to run first, making total branch coverage order-dependent. it("should return key if present", () => { - const expected = (mock.key = "asdf"); - // Dummy data for testing - mock.code = "jkl;"; - mock.keyIdentifier = "jkl;"; + const actual = KeyHelper.getKeyCode({ + key: "the-key", + code: "the-code", + keyIdentifier: "the-identifier", + }); - const actual = KeyHelper.getKeyCode(mock); - - expect(expected).toEqual(actual); + expect(actual).toEqual("the-key"); }); it("should return code when key not present", () => { - const expected = (mock.code = "asdf"); - // Dummy data for testing - mock.keyIdentifier = "jkl;"; + const actual = KeyHelper.getKeyCode({ + key: undefined, + code: "the-code", + keyIdentifier: "the-identifier", + }); - const actual = KeyHelper.getKeyCode(mock); - - expect(expected).toEqual(actual); + expect(actual).toEqual("the-code"); }); it("should return keyIdentifier if present and key and\ code are missing", () => { - const expected = (mock.keyIdentifier = "asdf"); - const actual = KeyHelper.getKeyCode(mock); + const actual = KeyHelper.getKeyCode({ + key: undefined, + code: undefined, + keyIdentifier: "the-identifier", + }); - expect(expected).toEqual(actual); + expect(actual).toEqual("the-identifier"); + }); + + it("should return undefined when key, code and keyIdentifier are all missing", () => { + const actual = KeyHelper.getKeyCode({ + key: undefined, + code: undefined, + keyIdentifier: undefined, + }); + + expect(actual).toBeUndefined(); }); it("should return undefined if event is undefined", () => { diff --git a/src/ui-kit/wrappers/fieldset-wrapper/fieldset-wrapper.spec.ts b/src/ui-kit/wrappers/fieldset-wrapper/fieldset-wrapper.spec.ts index c69884c14..606726ef6 100755 --- a/src/ui-kit/wrappers/fieldset-wrapper/fieldset-wrapper.spec.ts +++ b/src/ui-kit/wrappers/fieldset-wrapper/fieldset-wrapper.spec.ts @@ -131,6 +131,115 @@ describe("The Sam Fieldset Wrapper component", () => { component.clearError(); expect(component.errorMessage).toBe(undefined); }); + + it("keeps accumulated messages when clearing across multiple controls", () => { + // The errorMessage setter only resets the list for a single control; + // with multiple controls a later pristine control must not wipe the + // messages an earlier invalid one contributed. + const group = new FormGroup({ + a: new FormControl(""), + b: new FormControl(""), + }); + group.controls.a.markAsDirty(); + group.controls.a.setErrors({ required: true }); + + component.formatErrors(group.controls.a, group.controls.b); + + expect(component.errorMessages).toEqual(["This field is required"]); + }); + + it("clears a single control's message when it becomes valid", () => { + const control = new FormControl(""); + control.markAsDirty(); + control.setErrors({ required: true }); + component.formatErrors(control); + expect(component.errorMessages.length).toBe(1); + + control.setErrors(null); + component.formatErrors(control); + + expect(component.errorMessages.length).toBe(0); + }); + + it("reports whether errors should be displayed and listed", () => { + expect(component.displayErrors()).toBe(false); + expect(component.displayErrorList()).toBe(false); + + component.errorMessages = ["one"]; + expect(component.displayErrors()).toBe(true); + expect(component.displayErrorList()).toBe(false); + + component.errorMessages = ["one", "two"]; + expect(component.displayErrorList()).toBe(true); + }); + }); + + describe("hint overflow styling", () => { + let component: FieldsetWrapper; + const cdr = { detectChanges: () => undefined } as ChangeDetectorRef; + + beforeEach(() => { + component = new FieldsetWrapper(cdr); + }); + + it("clamps a long hint while the toggle is showing and closed", () => { + component.showToggle = true; + + expect(component.setOverflow()).toBe("hidden"); + expect(component.setHeight()).toBe("2.88em"); + }); + + it("stops clamping once the hint is toggled open", () => { + component.showToggle = true; + component.toggleHint(false); + + expect(component.setOverflow()).toBe(""); + expect(component.setHeight()).toBe(""); + }); + + it("never clamps when there is no toggle to show", () => { + component.showToggle = false; + + expect(component.setOverflow()).toBe(""); + expect(component.setHeight()).toBe(""); + }); + + it("calcToggle is a no-op when there is no hint container", () => { + component.calcToggle(); + expect(component.showToggle).toBe(false); + }); + + it("calcToggle turns the toggle on when the hint exceeds the line limit", () => { + // jsdom reports offsetHeight as 0, so the layout measurement is stubbed + // at calculateNumberOfLines — the seam between layout and the decision. + vi.spyOn(component, "calculateNumberOfLines").mockReturnValue(5); + component.hintContainer = { + nativeElement: document.createElement("div"), + }; + + component.calcToggle(); + + expect(component.showToggle).toBe(true); + }); + + it("calcToggle leaves the toggle off when the hint fits", () => { + vi.spyOn(component, "calculateNumberOfLines").mockReturnValue(1); + component.hintContainer = { + nativeElement: document.createElement("div"), + }; + + component.calcToggle(); + + expect(component.showToggle).toBe(false); + }); + + it("onResize resets the toggle state so it is recalculated", () => { + component.showToggle = true; + + component.onResize(undefined); + + expect(component.showToggle).toBe(false); + }); }); describe("integration tests", () => { diff --git a/src/ui-kit/wrappers/label-wrapper/label-wrapper.spec.ts b/src/ui-kit/wrappers/label-wrapper/label-wrapper.spec.ts index 7971ac889..9fb8f5197 100755 --- a/src/ui-kit/wrappers/label-wrapper/label-wrapper.spec.ts +++ b/src/ui-kit/wrappers/label-wrapper/label-wrapper.spec.ts @@ -75,6 +75,129 @@ describe("The Sam Label Wrapper component", () => { component.clearError(); expect(component.errorMessage).toBe(""); }); + + it("falls through to setInvalidErrors when a message is not a string", () => { + const control = new FormControl(""); + control.markAsDirty(); + control.setErrors({ required: { message: { notAString: true } } }); + + component.formatErrors(control); + + expect(component.errorMessage).toBe("This field is required"); + }); + }); + + describe("hint overflow styling", () => { + let component: LabelWrapper; + const cdr = { detectChanges: () => undefined } as ChangeDetectorRef; + const renderer = { + setAttribute: () => undefined, + removeAttribute: () => undefined, + } as unknown as Renderer2; + + beforeEach(() => { + component = new LabelWrapper(cdr, renderer); + }); + + it("clamps a long hint when the toggle is showing and closed", () => { + component.showToggle = true; + component.showFullHint = false; + + expect(component.setOverflow()).toBe("hidden"); + expect(component.setHeight()).toBe("2.88em"); + }); + + it("stops clamping once the hint is toggled open", () => { + component.showToggle = true; + component.showFullHint = false; + component.toggleHint(false); + + expect(component.setOverflow()).toBe(""); + expect(component.setHeight()).toBe(""); + }); + + it("never clamps when showFullHint is set", () => { + component.showToggle = true; + component.showFullHint = true; + + expect(component.setOverflow()).toBe(""); + expect(component.setHeight()).toBe(""); + }); + + it("never clamps when there is no toggle to show", () => { + component.showToggle = false; + + expect(component.setOverflow()).toBe(""); + expect(component.setHeight()).toBe(""); + }); + + it("calcToggle is a no-op when there is no hint container", () => { + component.showToggle = false; + + component.calcToggle(); + + expect(component.showToggle).toBe(false); + }); + + it("calcToggle turns the toggle on when the hint exceeds the line limit", () => { + // jsdom reports offsetHeight as 0, so the line measurement is stubbed: + // calculateNumberOfLines is the seam between layout and the decision. + vi.spyOn(component, "calculateNumberOfLines").mockReturnValue(5); + component.hintContainer = { + nativeElement: document.createElement("div"), + } as never; + + component.calcToggle(); + + expect(component.showToggle).toBe(true); + }); + + it("calcToggle leaves the toggle off when the hint fits", () => { + vi.spyOn(component, "calculateNumberOfLines").mockReturnValue(1); + component.hintContainer = { + nativeElement: document.createElement("div"), + } as never; + + component.calcToggle(); + + expect(component.showToggle).toBe(false); + }); + + it("onResize resets the toggle state so it is recalculated", () => { + component.showToggle = true; + + component.onResize(undefined); + + expect(component.showToggle).toBe(false); + }); + + it("setInputLabelElement is a no-op when no input has been located", () => { + const setAttribute = vi.fn(); + const localComponent = new LabelWrapper(cdr, { + setAttribute, + removeAttribute: vi.fn(), + } as unknown as Renderer2); + + localComponent.setInputLabelElement("some-id"); + + expect(setAttribute).not.toHaveBeenCalled(); + }); + + it("setInputLabelElement removes aria-describedby when given no id", () => { + const removeAttribute = vi.fn(); + const localComponent = new LabelWrapper(cdr, { + setAttribute: vi.fn(), + removeAttribute, + } as unknown as Renderer2); + localComponent.input = document.createElement("input"); + + localComponent.setInputLabelElement(""); + + expect(removeAttribute).toHaveBeenCalledWith( + localComponent.input, + "aria-describedby" + ); + }); }); describe("integration tests", () => {