From f15cf932666d46e624419e53714787e6381500d5 Mon Sep 17 00:00:00 2001 From: "Frank Pigeon Jr." <4629398+fpigeonjr@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:02:05 -0500 Subject: [PATCH 1/2] Add specs for remaining ui-kit coverage gaps Adds/extends unit specs across layout, directives, elements, filters, form-templates, wrappers, pipes, and standalone utility/service files that were under-covered per #633: - New specs: type-check-helpers, dom-helpers (ScrollHelpers), form-service, utilities/deprecator, pipes/time-ago, and the previously-untested layout/filter-drawer tree (filter-drawer, filter-drawer-chip, filter-drawer-item, chip-host, dynamic-chips). - Extended specs: directives/drag-drop (element drop/dragover/dragend branches), directives/sticky (adjustStickyPos/isTallestAmongSiblings branches), elements/button (input fallbacks, theme, disabled click, debug() deprecation path), filters (ngOnChanges service branch), layout/toolbar/aside-toggle (sidenav + SamPageNextService branches), layout/page (SamPageSidebarComponent), layout/filters-wrapper (run/reset report with and without SamPageNextService), and form-templates/phone-entry (validatePhoneNumber, form-service submit/reset paths). Coverage: statements 53.56% -> 56.42%, branches 39.77% -> 42.23%, functions 50.31% -> 54.22%, lines 52.84% -> 55.75% (coverage-floor.json left untouched per repo convention; raising the floor is a separate coverage:bump commit). Closes #633 --- .../directives/drag-drop/drag-drop.spec.ts | 135 +++++++++++++++++- src/ui-kit/directives/sticky/sticky.spec.ts | 93 ++++++++++++ src/ui-kit/dom-helpers.spec.ts | 53 +++++++ src/ui-kit/elements/button/button.spec.ts | 71 +++++++++ src/ui-kit/filters/filters.spec.ts | 25 ++++ src/ui-kit/form-service.spec.ts | 48 +++++++ .../phone-entry/phone-entry.spec.ts | 71 ++++++++- .../chip-host/chip-host.directive.spec.ts | 28 ++++ .../dynamic-chips.directive.spec.ts | 107 ++++++++++++++ .../filter-drawer-chip.component.spec.ts | 57 ++++++++ .../filter-drawer-item.component.spec.ts | 41 ++++++ .../filter-drawer/filter-drawer.spec.ts | 75 ++++++++++ .../filters-wrapper/filter-wrapper.spec.ts | 70 ++++++++- .../layout/page/pagination.component.spec.ts | 17 ++- .../layout/toolbar/aside-toggle.spec.ts | 95 ++++++++++++ .../pipes/time-ago/time-ago.pipe.spec.ts | 18 +++ src/ui-kit/type-check-helpers.spec.ts | 56 ++++++++ .../utilities/deprecator/deprecator.spec.ts | 107 ++++++++++++++ 18 files changed, 1153 insertions(+), 14 deletions(-) create mode 100644 src/ui-kit/dom-helpers.spec.ts create mode 100644 src/ui-kit/form-service.spec.ts create mode 100644 src/ui-kit/layout/filter-drawer/chip-host/chip-host.directive.spec.ts create mode 100644 src/ui-kit/layout/filter-drawer/dynamic-chips/dynamic-chips.directive.spec.ts create mode 100644 src/ui-kit/layout/filter-drawer/filter-drawer-chip/filter-drawer-chip.component.spec.ts create mode 100644 src/ui-kit/layout/filter-drawer/filter-drawer-item/filter-drawer-item.component.spec.ts create mode 100644 src/ui-kit/layout/filter-drawer/filter-drawer.spec.ts create mode 100644 src/ui-kit/pipes/time-ago/time-ago.pipe.spec.ts create mode 100644 src/ui-kit/type-check-helpers.spec.ts create mode 100644 src/ui-kit/utilities/deprecator/deprecator.spec.ts diff --git a/src/ui-kit/directives/drag-drop/drag-drop.spec.ts b/src/ui-kit/directives/drag-drop/drag-drop.spec.ts index d1ce1a081..d22fa445b 100755 --- a/src/ui-kit/directives/drag-drop/drag-drop.spec.ts +++ b/src/ui-kit/directives/drag-drop/drag-drop.spec.ts @@ -1,10 +1,17 @@ -import { TestBed, waitForAsync, fakeAsync, tick } from "@angular/core/testing"; +import { TestBed } from "@angular/core/testing"; import { Component, Output, ViewChild, EventEmitter } from "@angular/core"; import { By } from "@angular/platform-browser"; // Load the implementations that should be tested -import { SamDragDropDirective } from "./drag-drop.directive"; +import { SamDragDropDirective, DragState } from "./drag-drop.directive"; + +interface FakeDragEvent { + preventDefault: () => void; + stopPropagation: () => void; + dataTransfer?: { dropEffect?: string; files?: unknown[] }; + target?: EventTarget; +} @Component({ selector: "test-cmp", @@ -21,7 +28,7 @@ import { SamDragDropDirective } from "./drag-drop.directive"; standalone: false, }) class TestComponent { - @Output() action: EventEmitter = new EventEmitter(); + @Output() action: EventEmitter = new EventEmitter(); @ViewChild("var", { static: true }) var; @ViewChild("dummydrop", { static: true }) dummydrop; dropHandler() { @@ -57,7 +64,7 @@ describe("The Sam Focus directive", () => { component.action.subscribe((val) => { expect(val).toBe(true); }); - directive.onWindowDrop({ + directive.onWindowDrop({ preventDefault: function () {}, stopPropagation: function () {}, dataTransfer: { @@ -71,7 +78,7 @@ describe("The Sam Focus directive", () => { expect(val).toBe(true); }); - directive.onWindowDragover({ + directive.onWindowDragover({ preventDefault: function () {}, stopPropagation: function () {}, dataTransfer: { @@ -80,7 +87,7 @@ describe("The Sam Focus directive", () => { target: component.dummydrop.nativeElement, }); - directive.onElementDragend({ + directive.onElementDragend({ preventDefault: function () {}, stopPropagation: function () {}, dataTransfer: { @@ -89,4 +96,120 @@ describe("The Sam Focus directive", () => { target: component.dummydrop.nativeElement, }); }); + + it("should emit window drop event", () => { + const preventDefault = vi.fn(); + const stopPropagation = vi.fn(); + directive.onWindowDrop({ + preventDefault, + stopPropagation, + }); + expect(preventDefault).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + }); + + it("should set drag state to NotDragging on element drag end", () => { + directive.dragState = DragState.DraggingInTarget; + directive.onElementDragend({ + preventDefault: () => undefined, + stopPropagation: () => undefined, + }); + expect(directive.dragState).toBe(DragState.NotDragging); + }); + + describe("onElementDrop", () => { + it("does nothing but sets dropEffect to none when disabled", () => { + directive.disabled = true; + const event: FakeDragEvent = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { dropEffect: "", files: ["test.jpg"] }, + target: component.dummydrop.nativeElement, + }; + const dropSpy = vi.fn(); + directive.dropEvent.subscribe(dropSpy); + + directive.onElementDrop(event); + + expect(event.dataTransfer.dropEffect).toBe("none"); + expect(dropSpy).not.toHaveBeenCalled(); + }); + + it("emits dropEvent with files when the drop is inside the target with files", () => { + const files = ["test.jpg"]; + const event: FakeDragEvent = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { dropEffect: "", files }, + target: component.dummydrop.nativeElement, + }; + const dropSpy = vi.fn(); + directive.dropEvent.subscribe(dropSpy); + + directive.onElementDrop(event); + + expect(dropSpy).toHaveBeenCalledWith(files); + expect(directive.dragState).toBe(DragState.NotDragging); + }); + + it("does not emit dropEvent when the drop target has no files", () => { + const event: FakeDragEvent = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { dropEffect: "", files: [] }, + target: component.dummydrop.nativeElement, + }; + const dropSpy = vi.fn(); + directive.dropEvent.subscribe(dropSpy); + + directive.onElementDrop(event); + + expect(dropSpy).not.toHaveBeenCalled(); + }); + }); + + describe("onElementDragOver", () => { + it("sets dropEffect to none and skips processing when disabled", () => { + directive.disabled = true; + const event: FakeDragEvent = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { dropEffect: "" }, + target: component.dummydrop.nativeElement, + }; + + directive.onElementDragOver(event); + + expect(event.dataTransfer.dropEffect).toBe("none"); + }); + + it("sets DraggingInTarget state and copy dropEffect when dragging inside the target", () => { + const event: FakeDragEvent = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { dropEffect: "" }, + target: component.dummydrop.nativeElement, + }; + + directive.onElementDragOver(event); + + expect(directive.dragState).toBe(DragState.DraggingInTarget); + expect(event.dataTransfer.dropEffect).toBe("copy"); + }); + + it("sets DraggingOutsideTarget state and none dropEffect when dragging outside the target", () => { + const outsideElement = document.createElement("div"); + const event: FakeDragEvent = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { dropEffect: "" }, + target: outsideElement, + }; + + directive.onElementDragOver(event); + + expect(directive.dragState).toBe(DragState.DraggingOutsideTarget); + expect(event.dataTransfer.dropEffect).toBe("none"); + }); + }); }); diff --git a/src/ui-kit/directives/sticky/sticky.spec.ts b/src/ui-kit/directives/sticky/sticky.spec.ts index 70d0eeb87..668123d71 100755 --- a/src/ui-kit/directives/sticky/sticky.spec.ts +++ b/src/ui-kit/directives/sticky/sticky.spec.ts @@ -77,4 +77,97 @@ describe("The Sam Sticky directive", () => { directive.limit = expectedLimit; directive.makeSticky(); }); + + it("resize() recalculates elemWidth and calls makeSticky", () => { + const makeStickySpy = vi.spyOn(directive, "makeSticky"); + directive.resize({}); + expect(makeStickySpy).toHaveBeenCalled(); + + const comp = fixture.debugElement.query(By.css(".test-comp")); + expect(comp.nativeElement.style.position).toBe("static"); + }); + + it("ngOnInit and ngAfterViewChecked update elemWidth when it changes", () => { + const nativeElement = directive["el"].nativeElement; + Object.defineProperty(nativeElement, "offsetWidth", { + value: 250, + configurable: true, + }); + + directive.ngOnInit(); + directive.ngAfterViewChecked(); + + expect(directive["elemWidth"]).toBe(250); + }); + + it("getDocHeight returns the largest of body/documentElement height metrics", () => { + expect(typeof directive.getDocHeight()).toBe("number"); + }); + + it("getScrollTop returns a number derived from pageYOffset and clientTop", () => { + expect(typeof directive.getScrollTop()).toBe("number"); + }); + + it("getElemDistanceToTop walks offsetParent chain and sums offsetTop", () => { + const nativeElement = directive["el"].nativeElement; + expect(directive.getElemDistanceToTop(nativeElement)).toBe(0); + + const fakeParent = { offsetTop: 40, offsetParent: null }; + const fakeElem = { offsetTop: 10, offsetParent: fakeParent }; + expect(directive.getElemDistanceToTop(fakeElem)).toBe(50); + }); + + describe("isTallestAmongSiblings / adjustStickyPos", () => { + it("stays static when the directive's direct child is the tallest sibling", () => { + directive.adjustStickyPos(); + const comp = fixture.debugElement.query(By.css(".test-comp")); + expect(comp.nativeElement.style.position).toBe("static"); + }); + + it("goes fixed and sets top/width when scrolled past a shorter sibling", () => { + const containerEl = fixture.debugElement.query( + By.css(".test-container") + ).nativeElement; + const compEl = fixture.debugElement.query( + By.css(".test-comp") + ).nativeElement; + const siblingEl = containerEl.children[1]; + + Object.defineProperty(siblingEl, "offsetHeight", { + value: 5000, + configurable: true, + }); + Object.defineProperty(compEl, "offsetHeight", { + value: 50, + configurable: true, + }); + Object.defineProperty(compEl, "offsetTop", { + value: 0, + configurable: true, + }); + Object.defineProperty(containerEl, "offsetHeight", { + value: 5000, + configurable: true, + }); + Object.defineProperty(containerEl, "offsetTop", { + value: 0, + configurable: true, + }); + Object.defineProperty(window, "pageYOffset", { + value: 200, + configurable: true, + }); + + directive.adjustStickyPos(); + + expect(compEl.style.position).toBe("fixed"); + expect(compEl.style.width).toContain("px"); + expect(compEl.style.top).toContain("px"); + + Object.defineProperty(window, "pageYOffset", { + value: 0, + configurable: true, + }); + }); + }); }); diff --git a/src/ui-kit/dom-helpers.spec.ts b/src/ui-kit/dom-helpers.spec.ts new file mode 100644 index 000000000..4d2a9b9ac --- /dev/null +++ b/src/ui-kit/dom-helpers.spec.ts @@ -0,0 +1,53 @@ +import { ScrollHelpers } from "./dom-helpers"; + +describe("ScrollHelpers", () => { + it("returns undefined when window is falsy", () => { + expect(ScrollHelpers(undefined)).toBeUndefined(); + }); + + it("returns disableScroll/enableScroll functions for a window object", () => { + const helpers = ScrollHelpers(window); + expect(typeof helpers.disableScroll).toBe("function"); + expect(typeof helpers.enableScroll).toBe("function"); + }); + + it("disableScroll wires up scroll-blocking handlers and enableScroll clears them", () => { + const helpers = ScrollHelpers(window); + + helpers.disableScroll(); + expect(typeof window.onwheel).toBe("function"); + expect(typeof document.onkeydown).toBe("function"); + + helpers.enableScroll(); + expect(window.onwheel).toBeFalsy(); + expect(document.onkeydown).toBeFalsy(); + }); + + it("preventDefaultForScrollKeys prevents default for arrow/page/space/home/end keys", () => { + const helpers = ScrollHelpers(window); + helpers.disableScroll(); + + const preventDefault = vi.fn(); + (document.onkeydown as (e: KeyboardEvent) => void)({ + keyCode: 37, + preventDefault, + } as unknown as KeyboardEvent); + expect(preventDefault).toHaveBeenCalled(); + + helpers.enableScroll(); + }); + + it("does nothing for keys that are not scroll keys", () => { + const helpers = ScrollHelpers(window); + helpers.disableScroll(); + + const preventDefault = vi.fn(); + const result = ( + document.onkeydown as (e: KeyboardEvent) => boolean | undefined + )({ keyCode: 65, preventDefault } as unknown as KeyboardEvent); + expect(preventDefault).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + + helpers.enableScroll(); + }); +}); diff --git a/src/ui-kit/elements/button/button.spec.ts b/src/ui-kit/elements/button/button.spec.ts index 28d52a5b7..3ff785f10 100755 --- a/src/ui-kit/elements/button/button.spec.ts +++ b/src/ui-kit/elements/button/button.spec.ts @@ -120,4 +120,75 @@ describe("The Sam Button component", () => { const btnElement = fixture.debugElement.query(By.css("#nextBtn")); expect(btnElement.nativeElement.innerHTML.trim()).toBe("Next"); }); + + it("falls back to the deprecated inputs when the new-style inputs are unset", () => { + component.buttonId = "legacyId"; + component.buttonType = "secondary"; + component.buttonSize = "large"; + + expect(component.id).toBe("legacyId"); + expect(component.action).toBe("secondary"); + expect(component.size).toBe("large"); + }); + + it("prefers the new-style inputs over the deprecated ones", () => { + component.buttonId = "legacyId"; + component.id = "newId"; + component.buttonType = "secondary"; + component.action = "primary"; + component.buttonSize = "large"; + component.size = "small"; + + expect(component.id).toBe("newId"); + expect(component.action).toBe("primary"); + expect(component.size).toBe("small"); + }); + + it("applies a theme class when the theme matches a known entry", () => { + component.theme = "dark"; + fixture.detectChanges(); + + expect(component.btnClass).toContain("inverted"); + }); + + it("applies the disabled class and disables the click emitter when isDisabled", () => { + component.isDisabled = true; + fixture.detectChanges(); + + expect(component.btnClass).toContain("disabled"); + + const clickSpy = vi.fn(); + component.onClick.subscribe(clickSpy); + component.click({}); + + expect(clickSpy).not.toHaveBeenCalled(); + }); + + it("emits onClick with the event when not disabled", () => { + const clickSpy = vi.fn(); + component.onClick.subscribe(clickSpy); + const event = { type: "click" }; + + component.click(event); + + expect(clickSpy).toHaveBeenCalledWith(event); + }); + + it("debug() reports deprecated members that are set on the component", () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + const tableSpy = vi + .spyOn(console, "table") + .mockImplementation(() => undefined); + + component.buttonId = "legacyId"; + component.debug(); + + expect(warnSpy).toHaveBeenCalled(); + expect(tableSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + tableSpy.mockRestore(); + }); }); diff --git a/src/ui-kit/filters/filters.spec.ts b/src/ui-kit/filters/filters.spec.ts index eb53155c6..5afb78212 100755 --- a/src/ui-kit/filters/filters.spec.ts +++ b/src/ui-kit/filters/filters.spec.ts @@ -163,5 +163,30 @@ describe("The Sam Filters Component", () => { const radio = fixture.debugElement.query(By.css("sam-radio-button")); expect(radio).not.toBeNull(); }); + + it("pushes fields to the page service's filterFields property when provided", () => { + const service = TestBed.inject(SamPageNextService); + const setValueSpy = vi.spyOn(service.get("filterFields"), "setValue"); + + component.ngOnChanges({ + fields: { + previousValue: undefined, + currentValue: component.fields, + firstChange: true, + isFirstChange: () => true, + }, + }); + + expect(setValueSpy).toHaveBeenCalledWith(component.fields); + }); + + it("does nothing when ngOnChanges fires without a fields change", () => { + const service = TestBed.inject(SamPageNextService); + const setValueSpy = vi.spyOn(service.get("filterFields"), "setValue"); + + component.ngOnChanges({}); + + expect(setValueSpy).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/ui-kit/form-service.spec.ts b/src/ui-kit/form-service.spec.ts new file mode 100644 index 000000000..e513c9632 --- /dev/null +++ b/src/ui-kit/form-service.spec.ts @@ -0,0 +1,48 @@ +import { SamFormService } from "./form-service"; +import { AbstractControl } from "@angular/forms"; + +describe("SamFormService", () => { + let service: SamFormService; + + beforeEach(() => { + service = new SamFormService(); + }); + + it("fireSubmit emits a submit event with the given root control", () => { + const received: unknown[] = []; + service.formEventsUpdated$.subscribe((event) => received.push(event)); + + const root = { name: "root" } as unknown as AbstractControl; + service.fireSubmit(root); + + expect(received).toEqual([{ root, eventType: "submit" }]); + }); + + it("fireSubmit defaults root to undefined", () => { + const received: unknown[] = []; + service.formEventsUpdated$.subscribe((event) => received.push(event)); + + service.fireSubmit(); + + expect(received).toEqual([{ root: undefined, eventType: "submit" }]); + }); + + it("fireReset emits a reset event with the given root control", () => { + const received: unknown[] = []; + service.formEventsUpdated$.subscribe((event) => received.push(event)); + + const root = { name: "root" } as unknown as AbstractControl; + service.fireReset(root); + + expect(received).toEqual([{ root, eventType: "reset" }]); + }); + + it("fireReset defaults root to undefined", () => { + const received: unknown[] = []; + service.formEventsUpdated$.subscribe((event) => received.push(event)); + + service.fireReset(); + + expect(received).toEqual([{ root: undefined, eventType: "reset" }]); + }); +}); 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 06c377eb3..e96ddc60c 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 @@ -1,13 +1,17 @@ -import { TestBed, waitForAsync } from "@angular/core/testing"; +import { TestBed } from "@angular/core/testing"; import { ChangeDetectorRef } from "@angular/core"; import { By } from "@angular/platform-browser"; -import { FormsModule, FormControl } from "@angular/forms"; +import { + FormsModule, + FormControl, + AbstractControl, + ValidationErrors, +} from "@angular/forms"; // Load the implementations that should be tested import { SamPhoneEntryComponent } from "./phone-entry.component"; import { SamFormControlsModule } from "../../form-controls"; import { SamWrapperModule } from "../../wrappers"; -import { SamUIKitModule } from "../../index"; import { SamFormService } from "../../form-service"; describe("The Sam Phone Entry component", () => { @@ -27,7 +31,7 @@ describe("The Sam Phone Entry component", () => { it("should implement controlvalueaccessor", () => { component.onChange(); component.onTouched(); - component.registerOnChange((_) => undefined); + component.registerOnChange(() => undefined); component.registerOnTouched(() => undefined); component.setDisabledState(false); // writevalue used in rendered tests @@ -44,7 +48,6 @@ describe("The Sam Phone Entry component", () => { let component: SamPhoneEntryComponent; let fixture: any; let el; - const model = ""; // provide our implementations or mocks to the dependency injector beforeEach(() => { @@ -145,5 +148,63 @@ describe("The Sam Phone Entry component", () => { } expect(component.model).toBe("1+(111)111-1111"); }); + + it("formats errors and calls detectChanges on control status changes when not using the form service", () => { + component.control = new FormControl(""); + component.useFormService = false; + component.ngOnInit(); + + const formatErrorsSpy = vi.spyOn(component.wrapper, "formatErrors"); + + component.control.setErrors({ required: true }); + component.control.updateValueAndValidity(); + + expect(formatErrorsSpy).toHaveBeenCalledWith(component.control); + }); + + it("formats errors on submit and clears them on reset when using the form service", () => { + const formService = TestBed.inject(SamFormService); + component.control = new FormControl(""); + component.useFormService = true; + component.ngOnInit(); + + const formatErrorsSpy = vi.spyOn(component.wrapper, "formatErrors"); + const clearErrorSpy = vi.spyOn(component.wrapper, "clearError"); + + formService.fireSubmit(component.control.root); + expect(formatErrorsSpy).toHaveBeenCalledWith(component.control); + + formService.fireReset(component.control.root); + expect(clearErrorSpy).toHaveBeenCalled(); + }); + + it("validatePhoneNumber flags an incomplete phone number as invalid", () => { + const validator = component.validatePhoneNumber( + component.phoneNumberTemplate + ); + const control = new FormControl("1+(111)___-____"); + + const result: ValidationErrors | undefined = validator( + control as AbstractControl + ); + + expect(result).toEqual({ + phoneError: { message: "Invalid phone number" }, + }); + }); + + it("validatePhoneNumber returns undefined for a complete phone number", () => { + component.model = "1+(111)111-1111"; + const validator = component.validatePhoneNumber( + component.phoneNumberTemplate + ); + const control = new FormControl("1+(111)111-1111"); + + const result: ValidationErrors | undefined = validator( + control as AbstractControl + ); + + expect(result).toBeUndefined(); + }); }); }); diff --git a/src/ui-kit/layout/filter-drawer/chip-host/chip-host.directive.spec.ts b/src/ui-kit/layout/filter-drawer/chip-host/chip-host.directive.spec.ts new file mode 100644 index 000000000..0fbc3f0b6 --- /dev/null +++ b/src/ui-kit/layout/filter-drawer/chip-host/chip-host.directive.spec.ts @@ -0,0 +1,28 @@ +import { TestBed } from "@angular/core/testing"; +import { Component, ViewChild } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { ChipHostDirective } from "./chip-host.directive"; + +@Component({ + selector: "test-chip-host", + template: ``, + standalone: false, +}) +class TestHostComponent { + @ViewChild(ChipHostDirective, { static: true }) chipHost: ChipHostDirective; +} + +describe("ChipHostDirective", () => { + it("exposes the host template's ViewContainerRef", () => { + TestBed.configureTestingModule({ + imports: [CommonModule], + declarations: [ChipHostDirective, TestHostComponent], + }); + + const fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + + expect(fixture.componentInstance.chipHost).toBeTruthy(); + expect(fixture.componentInstance.chipHost.viewContainerRef).toBeTruthy(); + }); +}); diff --git a/src/ui-kit/layout/filter-drawer/dynamic-chips/dynamic-chips.directive.spec.ts b/src/ui-kit/layout/filter-drawer/dynamic-chips/dynamic-chips.directive.spec.ts new file mode 100644 index 000000000..86cc9bbd6 --- /dev/null +++ b/src/ui-kit/layout/filter-drawer/dynamic-chips/dynamic-chips.directive.spec.ts @@ -0,0 +1,107 @@ +import { TestBed } from "@angular/core/testing"; +import { DynamicChipsDirective } from "./dynamic-chips.directive"; +import { SamFilterDrawerComponent } from "../filter-drawer.component"; +import { SamFilterDrawerModule } from "../filter-drawer.module"; +import { + SamPageNextService, + DataStore, + layoutReducer, + model, +} from "../../../experimental"; + +describe("DynamicChipsDirective", () => { + let host: SamFilterDrawerComponent; + let directive: DynamicChipsDirective; + let service: SamPageNextService; + + beforeEach(() => { + // Use a fresh DataStore per test rather than the shared `layoutStore` + // singleton: DynamicChipsDirective never unsubscribes from the + // service's filters observable, so subscriptions from a prior test + // would otherwise remain alive and fire against an already-destroyed + // TestBed fixture/injector on the next test's setValue call (NG0205). + const store = new DataStore(layoutReducer, model); + + TestBed.configureTestingModule({ + imports: [SamFilterDrawerModule], + providers: [{ provide: DataStore, useValue: store }, SamPageNextService], + }); + + const fixture = TestBed.createComponent(SamFilterDrawerComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + + service = TestBed.inject(SamPageNextService); + service.get("filterFields").setValue([ + { + key: "status", + templateOptions: { label: "Status" }, + }, + ]); + + directive = new DynamicChipsDirective(host, service); + directive.map = (obj: Record) => { + const value = obj.status; + if (value === undefined || value === null) { + return []; + } + return Array.isArray(value) ? value : [value]; + }; + }); + + it("marks the host as using the directive and renders chips for non-empty filters on init", () => { + const clearContainerSpy = vi.spyOn(host.chips.viewContainerRef, "clear"); + const createSpy = vi.spyOn(host.chips.viewContainerRef, "createComponent"); + + directive.ngOnInit(); + service.get("filters").setValue({ status: "active" }); + + expect(host.usingDirective).toBe(true); + expect(clearContainerSpy).toHaveBeenCalled(); + expect(createSpy).toHaveBeenCalled(); + expect(host.showClear).toBe(true); + }); + + it("does not show clear when the mapped filters are all empty", () => { + directive.ngOnInit(); + service.get("filters").setValue({ status: [] }); + + expect(host.showClear).toBe(false); + }); + + it("propagates remove events from rendered chips to its own remove emitter", () => { + const createSpy = vi.spyOn(host.chips.viewContainerRef, "createComponent"); + + directive.ngOnInit(); + service.get("filters").setValue({ status: "active" }); + + const removeSpy = vi.fn(); + directive.remove.subscribe(removeSpy); + + const chipRef = createSpy.mock.results[0].value; + chipRef.instance.remove.emit({ status: "active" }); + + expect(removeSpy).toHaveBeenCalledWith({ status: "active" }); + }); + + it("disables rendered chips when the directive is disabled", () => { + const createSpy = vi.spyOn(host.chips.viewContainerRef, "createComponent"); + directive.disabled = true; + + directive.ngOnInit(); + service.get("filters").setValue({ status: "active" }); + + const chipRef = createSpy.mock.results[0].value; + expect(chipRef.instance.disabled).toBe(true); + }); + + it("clearContainer clears the host's view container", () => { + directive.ngOnInit(); + service.get("filters").setValue({ status: "active" }); + + const clearSpy = vi.spyOn(host.chips.viewContainerRef, "clear"); + directive.clearContainer(); + + expect(clearSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/ui-kit/layout/filter-drawer/filter-drawer-chip/filter-drawer-chip.component.spec.ts b/src/ui-kit/layout/filter-drawer/filter-drawer-chip/filter-drawer-chip.component.spec.ts new file mode 100644 index 000000000..7790d5702 --- /dev/null +++ b/src/ui-kit/layout/filter-drawer/filter-drawer-chip/filter-drawer-chip.component.spec.ts @@ -0,0 +1,57 @@ +import { TestBed, ComponentFixture } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { CommonModule } from "@angular/common"; +import { SamFilterDrawerChip } from "./filter-drawer-chip.component"; + +describe("SamFilterDrawerChip", () => { + let component: SamFilterDrawerChip; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CommonModule], + declarations: [SamFilterDrawerChip], + }); + + fixture = TestBed.createComponent(SamFilterDrawerChip); + component = fixture.componentInstance; + }); + + it("renders the label text", () => { + component.label = "My Filter"; + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain("My Filter"); + }); + + it("renders a remove button when not disabled", () => { + component.label = "My Filter"; + component.disabled = false; + fixture.detectChanges(); + + const button = fixture.debugElement.query(By.css("button")); + expect(button).not.toBeNull(); + }); + + it("hides the remove button when disabled", () => { + component.label = "My Filter"; + component.disabled = true; + fixture.detectChanges(); + + const button = fixture.debugElement.query(By.css("button")); + expect(button).toBeNull(); + }); + + it("emits the remove event when the remove button is clicked", () => { + component.label = "My Filter"; + fixture.detectChanges(); + + const removeSpy = vi.fn(); + component.remove.subscribe(removeSpy); + + const button = fixture.debugElement.query(By.css("button")); + button.triggerEventHandler("click", {}); + + expect(removeSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/ui-kit/layout/filter-drawer/filter-drawer-item/filter-drawer-item.component.spec.ts b/src/ui-kit/layout/filter-drawer/filter-drawer-item/filter-drawer-item.component.spec.ts new file mode 100644 index 000000000..e3414f121 --- /dev/null +++ b/src/ui-kit/layout/filter-drawer/filter-drawer-item/filter-drawer-item.component.spec.ts @@ -0,0 +1,41 @@ +import { TestBed, ComponentFixture } from "@angular/core/testing"; +import { CommonModule } from "@angular/common"; +import { SamFilterDrawerItemComponent } from "./filter-drawer-item.component"; +import { SamFilterDrawerChip } from "../filter-drawer-chip"; + +describe("SamFilterDrawerItemComponent", () => { + let component: SamFilterDrawerItemComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CommonModule], + declarations: [SamFilterDrawerItemComponent, SamFilterDrawerChip], + }); + + fixture = TestBed.createComponent(SamFilterDrawerItemComponent); + component = fixture.componentInstance; + }); + + it("renders the label and a chip per value", () => { + component.label = "Type"; + component.values = ["A", "B"]; + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain("Type"); + expect(fixture.nativeElement.textContent).toContain("A"); + expect(fixture.nativeElement.textContent).toContain("B"); + }); + + it("removeFilter emits an object keyed by the item's label with the removed value", () => { + component.label = "Type"; + component.values = ["A", "B"]; + + const removeSpy = vi.fn(); + component.remove.subscribe(removeSpy); + + component.removeFilter("A"); + + expect(removeSpy).toHaveBeenCalledWith({ Type: "A" }); + }); +}); diff --git a/src/ui-kit/layout/filter-drawer/filter-drawer.spec.ts b/src/ui-kit/layout/filter-drawer/filter-drawer.spec.ts new file mode 100644 index 000000000..7820f03e6 --- /dev/null +++ b/src/ui-kit/layout/filter-drawer/filter-drawer.spec.ts @@ -0,0 +1,75 @@ +import { TestBed, ComponentFixture } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { SamFilterDrawerModule } from "./filter-drawer.module"; +import { SamFilterDrawerComponent } from "./filter-drawer.component"; +import { QueryList } from "@angular/core"; + +describe("SamFilterDrawerComponent", () => { + let component: SamFilterDrawerComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [SamFilterDrawerModule], + }); + + fixture = TestBed.createComponent(SamFilterDrawerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("showClear when not using the dynamicChips directive", () => { + it("is false when there are no content-child items", () => { + expect(component.showClear).toBe(false); + }); + + it("is true when there is at least one content-child item", () => { + component.items = { + length: 1, + } as QueryList; + + expect(component.showClear).toBe(true); + }); + }); + + describe("showClear when using the dynamicChips directive", () => { + beforeEach(() => { + component.usingDirective = true; + }); + + it("reflects the value set via the showClear setter", () => { + component.showClear = true; + expect(component.showClear).toBe(true); + + component.showClear = false; + expect(component.showClear).toBe(false); + }); + }); + + it("emits the clear event when the Clear All button is clicked", () => { + component.showClear = true; + component.usingDirective = true; + fixture.detectChanges(); + + const clearSpy = vi.fn(); + component.clear.subscribe(clearSpy); + + const button = fixture.debugElement.query(By.css("sam-button-next")); + button.triggerEventHandler("click", {}); + + expect(clearSpy).toHaveBeenCalled(); + }); + + it("does not render the Clear All button when showClear is false", () => { + component.usingDirective = true; + component.showClear = false; + fixture.detectChanges(); + + const button = fixture.debugElement.query(By.css("sam-button-next")); + expect(button).toBeNull(); + }); +}); diff --git a/src/ui-kit/layout/filters-wrapper/filter-wrapper.spec.ts b/src/ui-kit/layout/filters-wrapper/filter-wrapper.spec.ts index 93b7e32ed..d15f9fcbe 100755 --- a/src/ui-kit/layout/filters-wrapper/filter-wrapper.spec.ts +++ b/src/ui-kit/layout/filters-wrapper/filter-wrapper.spec.ts @@ -1,6 +1,7 @@ -import { TestBed } from "@angular/core/testing"; +import { TestBed, ComponentFixture } from "@angular/core/testing"; import { SamFiltersWrapperModule, SamFiltersWrapperComponent } from "./"; import { forwardRef } from "@angular/core"; +import { FormControl, FormGroup } from "@angular/forms"; import { SamPageNextService } from "../../experimental/patterns/layout/architecture/service/page.service"; import { DataStore } from "../../experimental/patterns/layout/architecture/store/datastore"; import { layoutStore } from "../../experimental/patterns/layout/architecture/update/layout-store"; @@ -9,7 +10,7 @@ import { SamButtonNextModule } from "../../experimental/button-next/button.modul describe("The Sam Filter Wrapper component", () => { describe("rendered tests", () => { let component: SamFiltersWrapperComponent; - let fixture: any; + let fixture: ComponentFixture; beforeEach(() => { TestBed.configureTestingModule({ @@ -31,5 +32,70 @@ describe("The Sam Filter Wrapper component", () => { it("should initialize", () => { expect(true).toBe(true); }); + + it("runs and resets the report without a page service", () => { + component.group = new FormGroup({ + name: new FormControl("test"), + }); + fixture.detectChanges(); + + // Should not throw when no SamPageNextService is injected. + component.runReportEvent.next({}); + component.resetReportEvent.next({}); + }); + + it("unsubscribes cleanly on destroy", () => { + component.group = new FormGroup({ + name: new FormControl("test"), + }); + fixture.detectChanges(); + + expect(() => fixture.destroy()).not.toThrow(); + }); + }); + + describe("with a page service", () => { + let component: SamFiltersWrapperComponent; + let fixture: ComponentFixture; + let service: SamPageNextService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [SamButtonNextModule, SamFiltersWrapperModule], + providers: [ + { provide: DataStore, useValue: layoutStore }, + SamPageNextService, + ], + }); + + fixture = TestBed.createComponent(SamFiltersWrapperComponent); + component = fixture.componentInstance; + component.group = new FormGroup({ + name: new FormControl("initial"), + }); + service = TestBed.inject(SamPageNextService); + fixture.detectChanges(); + }); + + it("pushes the group value to the filters property when the report runs", () => { + component.group.setValue({ name: "updated" }); + + component.runReportEvent.next({}); + + expect(service.get("filters").value).toEqual({ name: "updated" }); + }); + + it("clears the filters property to null values when the report resets", () => { + component.group.setValue({ name: "updated" }); + component.runReportEvent.next({}); + + component.resetReportEvent.next({}); + + expect(service.get("filters").value).toEqual({ name: null }); + }); + + it("unsubscribes from the service-backed subscriptions on destroy", () => { + expect(() => fixture.destroy()).not.toThrow(); + }); }); }); diff --git a/src/ui-kit/layout/page/pagination.component.spec.ts b/src/ui-kit/layout/page/pagination.component.spec.ts index c640f91b8..f82ac5c4e 100755 --- a/src/ui-kit/layout/page/pagination.component.spec.ts +++ b/src/ui-kit/layout/page/pagination.component.spec.ts @@ -4,7 +4,11 @@ import { FormsModule } from "@angular/forms"; import { RouterTestingModule } from "@angular/router/testing"; import { By } from "@angular/platform-browser"; import {} from "../../"; -import { SamPageComponent } from "./page.component"; +import { + SamPageComponent, + SamPageSidebarComponent, + SamPageService, +} from "./page.component"; import { SamExperimentalModule } from "../../../ui-kit/experimental/experimental.module"; describe("SamPageComponent", () => { @@ -37,3 +41,14 @@ describe("SamPageComponent", () => { expect(el.nativeElement.innerHTML).toContain("test into"); }); }); + +describe("SamPageSidebarComponent", () => { + it("marks the page service's sidebar flag true on init", () => { + const pageService = new SamPageService(); + const sidebarComponent = new SamPageSidebarComponent(pageService); + + sidebarComponent.ngOnInit(); + + expect(pageService.sidebar).toBe(true); + }); +}); diff --git a/src/ui-kit/layout/toolbar/aside-toggle.spec.ts b/src/ui-kit/layout/toolbar/aside-toggle.spec.ts index d1c2ebe94..91ead5a23 100755 --- a/src/ui-kit/layout/toolbar/aside-toggle.spec.ts +++ b/src/ui-kit/layout/toolbar/aside-toggle.spec.ts @@ -1,5 +1,15 @@ import { TestBed } from "@angular/core/testing"; import { SamAsideToggleComponent } from "./"; +import { SamPageNextService, DataStore, layoutStore } from "../../experimental"; +import { MdSidenav } from "../../experimental/patterns/layout/components/sidenav"; + +interface FakeSidenav { + toggle: (open?: boolean) => void; +} + +function asSidenav(sidenav: FakeSidenav): MdSidenav { + return sidenav as unknown as MdSidenav; +} describe("The Sam Aside Toggle component", () => { describe("rendered tests", () => { @@ -27,5 +37,90 @@ describe("The Sam Aside Toggle component", () => { }); component.handleClick(); }); + + it("toggles the provided sidenav on click", () => { + const sidenav: FakeSidenav = { toggle: vi.fn() }; + component.sidenav = asSidenav(sidenav); + + component.handleClick(); + + expect(sidenav.toggle).toHaveBeenCalled(); + }); + + it("opens the sidenav when the toggle input becomes true and a sidenav is set", () => { + const sidenav: FakeSidenav = { toggle: vi.fn() }; + component.sidenav = asSidenav(sidenav); + component.showToggle = true; + + component.ngOnChanges({ + showToggle: { + previousValue: false, + currentValue: true, + firstChange: false, + isFirstChange: () => false, + }, + }); + + expect(sidenav.toggle).toHaveBeenCalledWith(true); + }); + + it("does not toggle the sidenav when showToggle changes to false", () => { + const sidenav: FakeSidenav = { toggle: vi.fn() }; + component.sidenav = asSidenav(sidenav); + component.showToggle = false; + + component.ngOnChanges({ + showToggle: { + previousValue: true, + currentValue: false, + firstChange: false, + isFirstChange: () => false, + }, + }); + + expect(sidenav.toggle).not.toHaveBeenCalled(); + }); + }); + + describe("with SamPageNextService", () => { + let component: SamAsideToggleComponent; + let fixture: any; + let service: SamPageNextService; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [SamAsideToggleComponent], + providers: [ + { provide: DataStore, useValue: layoutStore }, + SamPageNextService, + ], + }); + + fixture = TestBed.createComponent(SamAsideToggleComponent); + component = fixture.componentInstance; + service = TestBed.inject(SamPageNextService); + }); + + it("shows the toggle when the page service sends an open-sidebar message with a sidenav set", () => { + const sidenav: FakeSidenav = { toggle: vi.fn() }; + component.sidenav = asSidenav(sidenav); + component.showToggle = false; + + component.ngOnInit(); + service.sendPageMessage("open sidebar"); + + expect(component.showToggle).toBe(true); + }); + + it("leaves showToggle unchanged for messages other than open-sidebar", () => { + const sidenav: FakeSidenav = { toggle: vi.fn() }; + component.sidenav = asSidenav(sidenav); + component.showToggle = false; + + component.ngOnInit(); + service.sendPageMessage("close sidebar"); + + expect(component.showToggle).toBe(false); + }); }); }); diff --git a/src/ui-kit/pipes/time-ago/time-ago.pipe.spec.ts b/src/ui-kit/pipes/time-ago/time-ago.pipe.spec.ts new file mode 100644 index 000000000..2b3401cd2 --- /dev/null +++ b/src/ui-kit/pipes/time-ago/time-ago.pipe.spec.ts @@ -0,0 +1,18 @@ +import { TimeAgoPipe } from "./time-ago.pipe"; +import moment from "moment"; + +describe("TimeAgoPipe", () => { + const pipe = new TimeAgoPipe(); + + it("transforms a datetime into a relative time-ago string", () => { + const fiveMinutesAgo = moment().subtract(5, "minutes").valueOf(); + expect(pipe.transform(fiveMinutesAgo)).toBe( + moment(fiveMinutesAgo).fromNow() + ); + }); + + it("transforms the current time to 'a few seconds ago'", () => { + const now = moment().valueOf(); + expect(pipe.transform(now)).toBe(moment(now).fromNow()); + }); +}); diff --git a/src/ui-kit/type-check-helpers.spec.ts b/src/ui-kit/type-check-helpers.spec.ts new file mode 100644 index 000000000..378424f31 --- /dev/null +++ b/src/ui-kit/type-check-helpers.spec.ts @@ -0,0 +1,56 @@ +import { isString, isObject, isArray, safeTypeOf } from "./type-check-helpers"; + +describe("type-check-helpers", () => { + describe("isString", () => { + it("returns true for strings", () => { + expect(isString("hello")).toBe(true); + }); + + it("returns false for non-strings", () => { + expect(isString(123)).toBe(false); + expect(isString({})).toBe(false); + expect(isString([])).toBe(false); + expect(isString(null)).toBe(false); + expect(isString(undefined)).toBe(false); + }); + }); + + describe("isObject", () => { + it("returns true for plain objects", () => { + expect(isObject({})).toBe(true); + expect(isObject({ a: 1 })).toBe(true); + }); + + it("returns false for non-objects", () => { + expect(isObject("hello")).toBe(false); + expect(isObject([])).toBe(false); + expect(isObject(123)).toBe(false); + expect(isObject(null)).toBe(false); + }); + }); + + describe("isArray", () => { + it("returns true for arrays", () => { + expect(isArray([])).toBe(true); + expect(isArray([1, 2, 3])).toBe(true); + }); + + it("returns false for non-arrays", () => { + expect(isArray({})).toBe(false); + expect(isArray("hello")).toBe(false); + expect(isArray(123)).toBe(false); + expect(isArray(null)).toBe(false); + }); + }); + + describe("safeTypeOf", () => { + it("returns the object type string", () => { + expect(safeTypeOf("hello")).toBe("[object String]"); + expect(safeTypeOf({})).toBe("[object Object]"); + expect(safeTypeOf([])).toBe("[object Array]"); + expect(safeTypeOf(123)).toBe("[object Number]"); + expect(safeTypeOf(null)).toBe("[object Null]"); + expect(safeTypeOf(undefined)).toBe("[object Undefined]"); + }); + }); +}); diff --git a/src/ui-kit/utilities/deprecator/deprecator.spec.ts b/src/ui-kit/utilities/deprecator/deprecator.spec.ts new file mode 100644 index 000000000..87ae4a8df --- /dev/null +++ b/src/ui-kit/utilities/deprecator/deprecator.spec.ts @@ -0,0 +1,107 @@ +import { Deprecator } from "./deprecator"; + +interface FakeParent { + constructor: { name: string }; + [key: string]: unknown; +} + +describe("Deprecator", () => { + let warnSpy: ReturnType; + let tableSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + tableSpy = vi.spyOn(console, "table").mockImplementation(() => undefined); + }); + + afterEach(() => { + warnSpy.mockRestore(); + tableSpy.mockRestore(); + }); + + it("does not warn when render is called with no deprecated members registered", () => { + const parent: FakeParent = { constructor: { name: "TestParent" } }; + const deprecator = new Deprecator(parent); + + deprecator.render(parent); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(tableSpy).not.toHaveBeenCalled(); + }); + + it("warns and renders a table when a deprecated member is set on the parent", () => { + const parent: FakeParent = { + constructor: { name: "TestParent" }, + oldProp: "someValue", + }; + const deprecator = new Deprecator(parent); + deprecator.deprecate("oldProp", "newProp", "PI 1", "someValue"); + + deprecator.render(parent); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("TestParent")); + expect(tableSpy).toHaveBeenCalledWith([ + { + deprecated: "oldProp", + deprecatedValue: "someValue", + use: "newProp", + updateBy: "PI 1", + }, + ]); + }); + + it("defaults deprecatedValue to n/a when not provided", () => { + const parent: FakeParent = { + constructor: { name: "TestParent" }, + oldProp: true, + }; + const deprecator = new Deprecator(parent); + deprecator.deprecate("oldProp", "newProp", "PI 1"); + + deprecator.render(parent); + + expect(tableSpy).toHaveBeenCalledWith([ + { + deprecated: "oldProp", + deprecatedValue: "n/a", + use: "newProp", + updateBy: "PI 1", + }, + ]); + }); + + it("filters out deprecated members that are falsy on the parent", () => { + const parent: FakeParent = { + constructor: { name: "TestParent" }, + oldProp: undefined, + otherProp: "set", + }; + const deprecator = new Deprecator(parent); + deprecator.deprecate("oldProp", "newProp", "PI 1"); + deprecator.deprecate("otherProp", "newOtherProp", "PI 1"); + + deprecator.render(parent); + + expect(tableSpy).toHaveBeenCalledWith([ + { + deprecated: "otherProp", + deprecatedValue: "n/a", + use: "newOtherProp", + updateBy: "PI 1", + }, + ]); + }); + + it("logs the global message in addition to the warning when provided", () => { + const parent: FakeParent = { + constructor: { name: "TestParent" }, + oldProp: "set", + }; + const deprecator = new Deprecator(parent, "Global deprecation notice"); + deprecator.deprecate("oldProp", "newProp", "PI 1"); + + deprecator.render(parent); + + expect(warnSpy).toHaveBeenCalledWith("Global deprecation notice"); + }); +}); From 9650a646a0d2c029e5d3d847da651b6ec6b5ee5b Mon Sep 17 00:00:00 2001 From: "Frank Pigeon Jr." <4629398+fpigeonjr@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:09:04 -0500 Subject: [PATCH 2/2] Address PR review feedback - dom-helpers.spec.ts: restore original window.onwheel/document.onkeydown in afterEach so ScrollHelpers specs don't leak global handler state into other specs; rename the mismatched arrow-key test. - sticky.spec.ts: replace private-field access (directive["el"], directive["elemWidth"]) with fixture-based DOM lookups and observable style assertions, keeping the specs on the public API. --- src/ui-kit/directives/sticky/sticky.spec.ts | 53 +++++++++++++++++++-- src/ui-kit/dom-helpers.spec.ts | 15 +++++- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/ui-kit/directives/sticky/sticky.spec.ts b/src/ui-kit/directives/sticky/sticky.spec.ts index 668123d71..970c54de5 100755 --- a/src/ui-kit/directives/sticky/sticky.spec.ts +++ b/src/ui-kit/directives/sticky/sticky.spec.ts @@ -87,17 +87,58 @@ describe("The Sam Sticky directive", () => { expect(comp.nativeElement.style.position).toBe("static"); }); - it("ngOnInit and ngAfterViewChecked update elemWidth when it changes", () => { - const nativeElement = directive["el"].nativeElement; - Object.defineProperty(nativeElement, "offsetWidth", { + it("ngOnInit and ngAfterViewChecked pick up the element's current offsetWidth for later sticky positioning", () => { + const containerEl = fixture.debugElement.query( + By.css(".test-container") + ).nativeElement; + const compEl = fixture.debugElement.query( + By.css(".test-comp") + ).nativeElement; + const siblingEl = containerEl.children[1]; + + Object.defineProperty(compEl, "offsetWidth", { value: 250, configurable: true, }); + // Pick up the new offsetWidth via the public lifecycle hooks. directive.ngOnInit(); directive.ngAfterViewChecked(); - expect(directive["elemWidth"]).toBe(250); + // Force the sticky-fixed branch so the picked-up width is applied. + Object.defineProperty(siblingEl, "offsetHeight", { + value: 5000, + configurable: true, + }); + Object.defineProperty(compEl, "offsetHeight", { + value: 50, + configurable: true, + }); + Object.defineProperty(compEl, "offsetTop", { + value: 0, + configurable: true, + }); + Object.defineProperty(containerEl, "offsetHeight", { + value: 5000, + configurable: true, + }); + Object.defineProperty(containerEl, "offsetTop", { + value: 0, + configurable: true, + }); + Object.defineProperty(window, "pageYOffset", { + value: 200, + configurable: true, + }); + + directive.adjustStickyPos(); + + expect(compEl.style.width).toBe("250px"); + + Object.defineProperty(window, "pageYOffset", { + value: 0, + configurable: true, + }); }); it("getDocHeight returns the largest of body/documentElement height metrics", () => { @@ -109,7 +150,9 @@ describe("The Sam Sticky directive", () => { }); it("getElemDistanceToTop walks offsetParent chain and sums offsetTop", () => { - const nativeElement = directive["el"].nativeElement; + const nativeElement = fixture.debugElement.query( + By.css(".test-comp") + ).nativeElement; expect(directive.getElemDistanceToTop(nativeElement)).toBe(0); const fakeParent = { offsetTop: 40, offsetParent: null }; diff --git a/src/ui-kit/dom-helpers.spec.ts b/src/ui-kit/dom-helpers.spec.ts index 4d2a9b9ac..85497c667 100644 --- a/src/ui-kit/dom-helpers.spec.ts +++ b/src/ui-kit/dom-helpers.spec.ts @@ -1,6 +1,19 @@ import { ScrollHelpers } from "./dom-helpers"; describe("ScrollHelpers", () => { + let originalOnWheel: typeof window.onwheel; + let originalOnKeyDown: typeof document.onkeydown; + + beforeEach(() => { + originalOnWheel = window.onwheel; + originalOnKeyDown = document.onkeydown; + }); + + afterEach(() => { + window.onwheel = originalOnWheel; + document.onkeydown = originalOnKeyDown; + }); + it("returns undefined when window is falsy", () => { expect(ScrollHelpers(undefined)).toBeUndefined(); }); @@ -23,7 +36,7 @@ describe("ScrollHelpers", () => { expect(document.onkeydown).toBeFalsy(); }); - it("preventDefaultForScrollKeys prevents default for arrow/page/space/home/end keys", () => { + it("preventDefaultForScrollKeys prevents default for arrow keys", () => { const helpers = ScrollHelpers(window); helpers.disableScroll();