From 5fe8c6a5ca1ee88933dad39fd95c05fce711fd4d Mon Sep 17 00:00:00 2001 From: "Frank Pigeon Jr." <4629398+fpigeonjr@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:44:49 -0500 Subject: [PATCH 1/2] Add specs for experimental overlay internals Add unit specs for the CDK-style overlay positioning and scroll-strategy internals under experimental/patterns/layout/components/core/overlay/, raising coverage on files that were previously at or near 0%: - connected-position-strategy.ts: 0% -> 95.45% stmts - overlay-ref.ts: 0% -> 96.42% stmts - global-position-strategy.ts: 0% -> 100% stmts - overlay-directives.ts: 3.30% -> 93.47% stmts - block-scroll-strategy.ts: 0% -> 100% stmts - close-scroll-strategy.ts: 0% -> 100% stmts - reposition-scroll-strategy.ts: 0% -> 100% stmts - scroll-dispatcher.ts: 6.82% -> 97.72% stmts Also fixes a latent bug in ScrollDispatcher.scrolled(): the throttled branch used the RxJS 5-style auditTime.call(observable, ms) form, which under RxJS 7 returns an operator function rather than an Observable and throws on subscribe(). Every current caller passes a 0ms delay, so the branch was effectively dead code and untested. Switched to observable.pipe(auditTime(ms)) so the throttled path actually works and is now covered by a spec. Specs construct the units directly (fake collaborators, jsdom fixtures) rather than through TestBed, matching these files' narrow public surface (apply()/dispose()/enable()/disable() etc.) per the issue's guidance. Closes #634 --- .../core/overlay/overlay-directives.spec.ts | 250 +++++++++++++++++ .../core/overlay/overlay-ref.spec.ts | 239 ++++++++++++++++ .../connected-position-strategy.spec.ts | 261 ++++++++++++++++++ .../position/global-position-strategy.spec.ts | 171 ++++++++++++ .../scroll/block-scroll-strategy.spec.ts | 116 ++++++++ .../scroll/close-scroll-strategy.spec.ts | 88 ++++++ .../scroll/reposition-scroll-strategy.spec.ts | 114 ++++++++ .../overlay/scroll/scroll-dispatcher.spec.ts | 178 ++++++++++++ .../core/overlay/scroll/scroll-dispatcher.ts | 2 +- 9 files changed, 1418 insertions(+), 1 deletion(-) create mode 100644 src/ui-kit/experimental/patterns/layout/components/core/overlay/overlay-directives.spec.ts create mode 100644 src/ui-kit/experimental/patterns/layout/components/core/overlay/overlay-ref.spec.ts create mode 100644 src/ui-kit/experimental/patterns/layout/components/core/overlay/position/connected-position-strategy.spec.ts create mode 100644 src/ui-kit/experimental/patterns/layout/components/core/overlay/position/global-position-strategy.spec.ts create mode 100644 src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/block-scroll-strategy.spec.ts create mode 100644 src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/close-scroll-strategy.spec.ts create mode 100644 src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/reposition-scroll-strategy.spec.ts create mode 100644 src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/scroll-dispatcher.spec.ts diff --git a/src/ui-kit/experimental/patterns/layout/components/core/overlay/overlay-directives.spec.ts b/src/ui-kit/experimental/patterns/layout/components/core/overlay/overlay-directives.spec.ts new file mode 100644 index 000000000..220aa4783 --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/components/core/overlay/overlay-directives.spec.ts @@ -0,0 +1,250 @@ +import { ConnectedOverlayDirective, OverlayOrigin } from "./overlay-directives"; +import { ESCAPE } from "@angular/cdk/keycodes"; + +function createFakeOverlayRef() { + const positionChanges: any[] = []; + return { + attach: vi.fn(), + detach: vi.fn(), + dispose: vi.fn(), + hasAttached: vi.fn().mockReturnValue(false), + getState: vi.fn().mockReturnValue({ direction: "ltr" }), + backdropClick: vi.fn(() => ({ subscribe: vi.fn() })), + positionChanges, + }; +} + +function createFakeOverlay( + overlayRef: ReturnType +) { + const positionStrategy = { + withOffsetX: vi.fn().mockReturnThis(), + withOffsetY: vi.fn().mockReturnThis(), + withDirection: vi.fn().mockReturnThis(), + withFallbackPosition: vi.fn().mockReturnThis(), + onPositionChange: { subscribe: vi.fn() }, + }; + return { + scrollStrategies: { reposition: vi.fn().mockReturnValue({}) }, + create: vi.fn().mockReturnValue(overlayRef), + position: vi.fn().mockReturnValue({ + connectedTo: vi.fn().mockReturnValue(positionStrategy), + }), + positionStrategy, + }; +} + +function createFakeRenderer() { + const listeners: Record void> = {}; + return { + listen: vi.fn( + (_target: string, eventName: string, cb: (e: any) => void) => { + listeners[eventName] = cb; + return vi.fn(); + } + ), + trigger: (eventName: string, event: any) => listeners[eventName]?.(event), + }; +} + +describe("OverlayOrigin", () => { + it("exposes the injected elementRef", () => { + const elementRef = { nativeElement: document.createElement("div") }; + const origin = new OverlayOrigin(elementRef as any); + + expect(origin.elementRef).toBe(elementRef); + }); +}); + +describe("ConnectedOverlayDirective", () => { + let overlayRef: ReturnType; + let overlay: ReturnType; + let renderer: ReturnType; + let directive: ConnectedOverlayDirective; + + beforeEach(() => { + overlayRef = createFakeOverlayRef(); + overlay = createFakeOverlay(overlayRef); + renderer = createFakeRenderer(); + + directive = new ConnectedOverlayDirective( + overlay as any, + renderer as any, + {} as any, + {} as any, + null as any + ); + directive.origin = new OverlayOrigin({ + nativeElement: document.createElement("div"), + } as any); + }); + + it("defaults dir to ltr when no Directionality is injected", () => { + expect(directive.dir).toBe("ltr"); + }); + + it("offsetX setter forwards to an existing position strategy", () => { + directive.open = true; + directive.ngOnChanges({ open: {} as any }); + directive.offsetX = 10; + + expect(overlay.positionStrategy.withOffsetX).toHaveBeenCalledWith(10); + }); + + it("offsetY setter forwards to an existing position strategy", () => { + directive.open = true; + directive.ngOnChanges({ open: {} as any }); + directive.offsetY = 5; + + expect(overlay.positionStrategy.withOffsetY).toHaveBeenCalledWith(5); + }); + + it("hasBackdrop setter coerces its value to a boolean", () => { + directive.hasBackdrop = "true" as any; + expect(directive.hasBackdrop).toBe(true); + + directive.hasBackdrop = "false"; + expect(directive.hasBackdrop).toBe(false); + }); + + describe("ngOnChanges()", () => { + it("attaches the overlay when open becomes true", () => { + directive.open = true; + directive.ngOnChanges({ open: {} as any }); + + expect(overlay.create).toHaveBeenCalled(); + expect(overlayRef.attach).toHaveBeenCalled(); + expect(directive.overlayRef).toBe(overlayRef); + }); + + it("detaches the overlay when open becomes false", () => { + directive.open = true; + directive.ngOnChanges({ open: {} as any }); + + directive.open = false; + directive.ngOnChanges({ open: {} as any }); + + expect(overlayRef.detach).toHaveBeenCalled(); + }); + + it("ignores changes unrelated to open", () => { + directive.ngOnChanges({ width: {} as any }); + + expect(overlay.create).not.toHaveBeenCalled(); + }); + }); + + describe("_createOverlay() / _buildConfig()", () => { + it("defaults to the standard dropdown position list when none is provided", () => { + directive.open = true; + directive.ngOnChanges({ open: {} as any }); + + expect(directive.positions.length).toBe(2); + }); + + it("uses the caller-supplied positions when provided", () => { + directive.positions = [ + { originX: "end", originY: "top", overlayX: "end", overlayY: "bottom" }, + ] as any; + directive.open = true; + directive.ngOnChanges({ open: {} as any }); + + expect(directive.positions.length).toBe(1); + expect( + overlay.positionStrategy.withFallbackPosition + ).not.toHaveBeenCalled(); + }); + + it("registers additional fallback positions beyond the first", () => { + directive.positions = [ + { + originX: "start", + originY: "bottom", + overlayX: "start", + overlayY: "top", + }, + { + originX: "start", + originY: "top", + overlayX: "start", + overlayY: "bottom", + }, + ] as any; + directive.open = true; + directive.ngOnChanges({ open: {} as any }); + + expect( + overlay.positionStrategy.withFallbackPosition + ).toHaveBeenCalledWith( + { originX: "start", originY: "top" }, + { overlayX: "start", overlayY: "bottom" } + ); + }); + + it("carries width/height/minWidth/minHeight/backdropClass onto the overlay config", () => { + directive.width = 100; + directive.height = 0; + directive.minWidth = 10; + directive.minHeight = 0; + directive.backdropClass = "my-backdrop"; + directive.open = true; + + directive.ngOnChanges({ open: {} as any }); + + expect(overlay.create).toHaveBeenCalledWith( + expect.objectContaining({ + width: 100, + height: 0, + minWidth: 10, + minHeight: 0, + backdropClass: "my-backdrop", + }) + ); + }); + }); + + describe("_attachOverlay()", () => { + it("subscribes to backdropClick and forwards it to the output when hasBackdrop is set", () => { + directive.hasBackdrop = true; + const emitSpy = vi.fn(); + directive.backdropClick.subscribe(emitSpy); + + directive.open = true; + directive.ngOnChanges({ open: {} as any }); + + expect(overlayRef.backdropClick).toHaveBeenCalled(); + }); + + it("does not re-create the overlay ref if one is already attached", () => { + overlayRef.hasAttached.mockReturnValue(true); + directive.open = true; + directive.ngOnChanges({ open: {} as any }); + + expect(overlayRef.attach).not.toHaveBeenCalled(); + }); + + it("registers an escape-key listener that detaches the overlay", () => { + directive.open = true; + directive.ngOnChanges({ open: {} as any }); + + renderer.trigger("keydown", { keyCode: ESCAPE }); + + expect(overlayRef.detach).toHaveBeenCalled(); + }); + }); + + describe("ngOnDestroy()", () => { + it("disposes the overlay ref that was created", () => { + directive.open = true; + directive.ngOnChanges({ open: {} as any }); + + directive.ngOnDestroy(); + + expect(overlayRef.dispose).toHaveBeenCalled(); + }); + + it("is a no-op when no overlay was ever created", () => { + expect(() => directive.ngOnDestroy()).not.toThrow(); + }); + }); +}); 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 new file mode 100644 index 000000000..35f3b0efa --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/components/core/overlay/overlay-ref.spec.ts @@ -0,0 +1,239 @@ +import { NgZone } from "@angular/core"; +import { OverlayRef } from "./overlay-ref"; +import { OverlayState } from "./overlay-state"; + +function createFakePortalHost() { + return { + attach: vi.fn().mockReturnValue("attach-result"), + detach: vi.fn().mockResolvedValue("detach-result"), + dispose: vi.fn(), + hasAttached: vi.fn().mockReturnValue(false), + }; +} + +function createFakeScrollStrategy() { + return { + attach: vi.fn(), + enable: vi.fn(), + disable: vi.fn(), + }; +} + +describe("OverlayRef", () => { + let portalHost: ReturnType; + let pane: HTMLElement; + let state: OverlayState; + let scrollStrategy: ReturnType; + let ngZone: NgZone; + let overlayRef: OverlayRef; + + beforeEach(() => { + portalHost = createFakePortalHost(); + pane = document.createElement("div"); + document.body.appendChild(pane); + state = new OverlayState(); + scrollStrategy = createFakeScrollStrategy(); + ngZone = new NgZone({ enableLongStackTrace: false }); + overlayRef = new OverlayRef( + portalHost as any, + pane, + state, + scrollStrategy as any, + ngZone + ); + }); + + afterEach(() => { + pane.remove(); + document.body + .querySelectorAll(".cdk-overlay-backdrop") + .forEach((backdrop) => backdrop.remove()); + }); + + it("attaches the scroll strategy to itself on construction", () => { + expect(scrollStrategy.attach).toHaveBeenCalledWith(overlayRef); + }); + + it("exposes the pane as overlayElement", () => { + expect(overlayRef.overlayElement).toBe(pane); + }); + + describe("attach()", () => { + it("delegates to the portal host and returns its result", () => { + const portal = { templateRef: {} }; + const result = overlayRef.attach(portal as any); + + expect(portalHost.attach).toHaveBeenCalledWith(portal); + expect(result).toBe("attach-result"); + }); + + it("enables pointer events and the scroll strategy", () => { + overlayRef.attach({} as any); + + expect(pane.style.pointerEvents).toBe("auto"); + expect(scrollStrategy.enable).toHaveBeenCalled(); + }); + + it("adds a backdrop when the state requests one", () => { + state.hasBackdrop = true; + overlayRef.attach({} as any); + + const backdrop = document.body.querySelector(".cdk-overlay-backdrop"); + expect(backdrop).not.toBeNull(); + }); + + it("does not add a backdrop by default", () => { + overlayRef.attach({} as any); + + expect(document.body.querySelector(".cdk-overlay-backdrop")).toBeNull(); + }); + + it("adds the configured panel class to the pane", () => { + state.panelClass = "my-panel"; + overlayRef.attach({} as any); + + expect(pane.classList.contains("my-panel")).toBe(true); + }); + + it("emits on attachments() once setup is complete", () => { + const spy = vi.fn(); + overlayRef.attachments().subscribe(spy); + + overlayRef.attach({} as any); + + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe("detach()", () => { + it("disables pointer events and the scroll strategy, then delegates to the portal host", async () => { + overlayRef.attach({} as any); + const result = await overlayRef.detach(); + + expect(pane.style.pointerEvents).toBe("none"); + expect(scrollStrategy.disable).toHaveBeenCalled(); + expect(portalHost.detach).toHaveBeenCalled(); + expect(result).toBe("detach-result"); + }); + + it("emits on detachments() after everything is detached", async () => { + const spy = vi.fn(); + overlayRef.detachments().subscribe(spy); + + await overlayRef.detach(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe("dispose()", () => { + it("disposes the position strategy if one is set", () => { + const positionStrategy = { apply: vi.fn(), dispose: vi.fn() }; + state.positionStrategy = positionStrategy as any; + + overlayRef.dispose(); + + expect(positionStrategy.dispose).toHaveBeenCalled(); + }); + + it("disables the scroll strategy and disposes the portal host", () => { + overlayRef.dispose(); + + expect(scrollStrategy.disable).toHaveBeenCalled(); + expect(portalHost.dispose).toHaveBeenCalled(); + }); + + it("completes the attachments and backdropClick subjects", () => { + const attachSpy = vi.fn(); + const detachSpy = vi.fn(); + overlayRef.attachments().subscribe({ complete: attachSpy }); + overlayRef.detachments().subscribe({ complete: detachSpy }); + + overlayRef.dispose(); + + expect(attachSpy).toHaveBeenCalled(); + expect(detachSpy).toHaveBeenCalled(); + }); + }); + + describe("hasAttached()", () => { + it("delegates to the portal host", () => { + portalHost.hasAttached.mockReturnValue(true); + + expect(overlayRef.hasAttached()).toBe(true); + expect(portalHost.hasAttached).toHaveBeenCalled(); + }); + }); + + describe("backdrop click forwarding", () => { + it("emits on backdropClick() when the backdrop element is clicked", () => { + state.hasBackdrop = true; + overlayRef.attach({} as any); + const spy = vi.fn(); + overlayRef.backdropClick().subscribe(spy); + + const backdrop = document.body.querySelector( + ".cdk-overlay-backdrop" + ) as HTMLElement; + backdrop.dispatchEvent(new Event("click")); + + expect(spy).toHaveBeenCalled(); + }); + }); + + describe("updatePosition()", () => { + it("applies the position strategy to the pane when one is configured", () => { + const positionStrategy = { apply: vi.fn(), dispose: vi.fn() }; + state.positionStrategy = positionStrategy as any; + + overlayRef.updatePosition(); + + expect(positionStrategy.apply).toHaveBeenCalledWith(pane); + }); + + it("does nothing when no position strategy is configured", () => { + expect(() => overlayRef.updatePosition()).not.toThrow(); + }); + }); + + describe("updateSize()", () => { + it("applies width, height, minWidth and minHeight to the pane", () => { + state.width = 100; + state.height = "50%"; + state.minWidth = 10; + state.minHeight = 0; + + overlayRef.updateSize(); + + expect(pane.style.width).toBe("100px"); + expect(pane.style.height).toBe("50%"); + expect(pane.style.minWidth).toBe("10px"); + expect(pane.style.minHeight).toBe("0px"); + }); + }); + + describe("getState()", () => { + it("returns the overlay's state config", () => { + expect(overlayRef.getState()).toBe(state); + }); + }); + + describe("detachBackdrop()", () => { + it("is a no-op when there is no backdrop attached", () => { + expect(() => overlayRef.detachBackdrop()).not.toThrow(); + }); + + it("removes the backdrop from the DOM once the transition finishes", () => { + state.hasBackdrop = true; + overlayRef.attach({} as any); + const backdrop = document.body.querySelector( + ".cdk-overlay-backdrop" + ) as HTMLElement; + + overlayRef.detachBackdrop(); + backdrop.dispatchEvent(new Event("transitionend")); + + expect(document.body.querySelector(".cdk-overlay-backdrop")).toBeNull(); + }); + }); +}); diff --git a/src/ui-kit/experimental/patterns/layout/components/core/overlay/position/connected-position-strategy.spec.ts b/src/ui-kit/experimental/patterns/layout/components/core/overlay/position/connected-position-strategy.spec.ts new file mode 100644 index 000000000..8d5c9ba27 --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/components/core/overlay/position/connected-position-strategy.spec.ts @@ -0,0 +1,261 @@ +import { ElementRef } from "@angular/core"; +import { ConnectedPositionStrategy } from "./connected-position-strategy"; +import { FakeViewportRuler } from "./fake-viewport-ruler"; + +function rect(overrides: Partial = {}): ClientRect { + return { + top: 0, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + ...overrides, + } as ClientRect; +} + +describe("ConnectedPositionStrategy", () => { + let origin: HTMLElement; + let overlay: HTMLElement; + let viewportRuler: FakeViewportRuler; + + beforeEach(() => { + origin = document.createElement("div"); + overlay = document.createElement("div"); + document.body.appendChild(origin); + document.body.appendChild(overlay); + viewportRuler = new FakeViewportRuler(); + }); + + afterEach(() => { + origin.remove(); + overlay.remove(); + }); + + function createStrategy() { + return new ConnectedPositionStrategy( + new ElementRef(origin), + { originX: "start", originY: "bottom" }, + { overlayX: "start", overlayY: "top" }, + viewportRuler as any + ); + } + + it("applies the first preferred position that fits in the viewport", () => { + const strategy = createStrategy(); + + vi.spyOn(origin, "getBoundingClientRect").mockReturnValue( + rect({ + top: 100, + left: 50, + bottom: 130, + right: 150, + width: 100, + height: 30, + }) + ); + vi.spyOn(overlay, "getBoundingClientRect").mockReturnValue( + rect({ width: 200, height: 40 }) + ); + + strategy.apply(overlay); + + expect(overlay.style.top).toBe("130px"); + expect(overlay.style.left).toBe("50px"); + }); + + it("falls back to the fallback position with the largest visible area when nothing fits", () => { + const strategy = createStrategy(); + strategy.withFallbackPosition( + { originX: "end", originY: "top" }, + { overlayX: "end", overlayY: "bottom" } + ); + + vi.spyOn(origin, "getBoundingClientRect").mockReturnValue( + rect({ top: 0, left: 0, bottom: 10, right: 10, width: 10, height: 10 }) + ); + // Overlay is larger than the viewport in both preferred positions, so + // neither fits; the strategy should still apply *a* position rather + // than throwing. + vi.spyOn(overlay, "getBoundingClientRect").mockReturnValue( + rect({ width: 2000, height: 2000 }) + ); + + expect(() => strategy.apply(overlay)).not.toThrow(); + expect(overlay.style.top || overlay.style.bottom).toBeTruthy(); + }); + + it("emits onPositionChange with the connection pair that was used", () => { + const strategy = createStrategy(); + const changes: any[] = []; + strategy.onPositionChange.subscribe((change) => changes.push(change)); + + vi.spyOn(origin, "getBoundingClientRect").mockReturnValue( + rect({ + top: 100, + left: 50, + bottom: 130, + right: 150, + width: 100, + height: 30, + }) + ); + vi.spyOn(overlay, "getBoundingClientRect").mockReturnValue( + rect({ width: 200, height: 40 }) + ); + + strategy.apply(overlay); + + expect(changes.length).toBe(1); + expect(changes[0].connectionPair.originX).toBe("start"); + expect(changes[0].connectionPair.originY).toBe("bottom"); + }); + + it("recalculateLastPosition() re-applies the last connected position", () => { + const strategy = createStrategy(); + + vi.spyOn(origin, "getBoundingClientRect").mockReturnValue( + rect({ + top: 100, + left: 50, + bottom: 130, + right: 150, + width: 100, + height: 30, + }) + ); + vi.spyOn(overlay, "getBoundingClientRect").mockReturnValue( + rect({ width: 200, height: 40 }) + ); + + strategy.apply(overlay); + overlay.style.top = ""; + overlay.style.left = ""; + + strategy.recalculateLastPosition(); + + expect(overlay.style.top).toBe("130px"); + expect(overlay.style.left).toBe("50px"); + }); + + it("recalculateLastPosition() uses the first preferred position when nothing has been applied yet", () => { + const strategy = createStrategy(); + + vi.spyOn(origin, "getBoundingClientRect").mockReturnValue( + rect({ + top: 100, + left: 50, + bottom: 130, + right: 150, + width: 100, + height: 30, + }) + ); + vi.spyOn(overlay, "getBoundingClientRect").mockReturnValue( + rect({ width: 200, height: 40 }) + ); + // `apply` must be called once so `_pane` is set before recalculation, + // mirroring how OverlayRef always calls `apply` before any reposition. + strategy.apply(overlay); + + expect(() => strategy.recalculateLastPosition()).not.toThrow(); + }); + + it("withOffsetX/withOffsetY shift the applied position", () => { + const strategy = createStrategy().withOffsetX(10).withOffsetY(5); + + vi.spyOn(origin, "getBoundingClientRect").mockReturnValue( + rect({ + top: 100, + left: 50, + bottom: 130, + right: 150, + width: 100, + height: 30, + }) + ); + vi.spyOn(overlay, "getBoundingClientRect").mockReturnValue( + rect({ width: 200, height: 40 }) + ); + + strategy.apply(overlay); + + expect(overlay.style.top).toBe("135px"); + expect(overlay.style.left).toBe("60px"); + }); + + it("withDirection('rtl') flips which horizontal edge is used for positioning", () => { + const strategy = createStrategy().withDirection("rtl"); + + vi.spyOn(origin, "getBoundingClientRect").mockReturnValue( + rect({ + top: 100, + left: 50, + bottom: 130, + right: 150, + width: 100, + height: 30, + }) + ); + vi.spyOn(overlay, "getBoundingClientRect").mockReturnValue( + rect({ width: 200, height: 40 }) + ); + + strategy.apply(overlay); + + // In RTL, overlayX: "start" resolves to the origin's right edge and the + // overlay is positioned via `right` rather than `left`. + expect(overlay.style.top).toBe("130px"); + expect(overlay.style.right).not.toBe(""); + expect(overlay.style.left).toBe(""); + }); + + it("withScrollableContainers records scrollables used for view-property calculations", () => { + const strategy = createStrategy(); + const scrollableElement = document.createElement("div"); + document.body.appendChild(scrollableElement); + // A scrollable container that does not overlap with the origin at all, + // so the origin is reported as outside its view even though the overlay + // still fits comfortably in the (much larger) fake viewport. + vi.spyOn(scrollableElement, "getBoundingClientRect").mockReturnValue( + rect({ + top: 600, + left: 600, + bottom: 900, + right: 900, + width: 300, + height: 300, + }) + ); + const scrollable = { + getElementRef: () => new ElementRef(scrollableElement), + }; + strategy.withScrollableContainers([scrollable as any]); + + vi.spyOn(origin, "getBoundingClientRect").mockReturnValue( + rect({ + top: 100, + left: 50, + bottom: 130, + right: 150, + width: 100, + height: 30, + }) + ); + vi.spyOn(overlay, "getBoundingClientRect").mockReturnValue( + rect({ width: 200, height: 40 }) + ); + + const changes: any[] = []; + strategy.onPositionChange.subscribe((change) => changes.push(change)); + strategy.apply(overlay); + + expect(changes[0].scrollableViewProperties.isOriginOutsideView).toBe(true); + scrollableElement.remove(); + }); + + it("dispose() does not throw and performs no DOM cleanup", () => { + const strategy = createStrategy(); + expect(() => strategy.dispose()).not.toThrow(); + }); +}); diff --git a/src/ui-kit/experimental/patterns/layout/components/core/overlay/position/global-position-strategy.spec.ts b/src/ui-kit/experimental/patterns/layout/components/core/overlay/position/global-position-strategy.spec.ts new file mode 100644 index 000000000..794e49363 --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/components/core/overlay/position/global-position-strategy.spec.ts @@ -0,0 +1,171 @@ +import { GlobalPositionStrategy } from "./global-position-strategy"; + +describe("GlobalPositionStrategy", () => { + let strategy: GlobalPositionStrategy; + let element: HTMLElement; + let parent: HTMLElement; + + beforeEach(() => { + strategy = new GlobalPositionStrategy(); + parent = document.createElement("div"); + element = document.createElement("div"); + parent.appendChild(element); + document.body.appendChild(parent); + }); + + afterEach(() => { + strategy.dispose(); + if (parent.parentNode) { + parent.parentNode.removeChild(parent); + } + }); + + describe("fluent setters", () => { + it("top() sets alignItems and clears bottom offset", () => { + strategy.bottom("10px"); + strategy.top("5px"); + strategy.apply(element); + + expect(element.style.marginTop).toBe("5px"); + expect(element.style.marginBottom).toBe(""); + expect((element.parentNode as HTMLElement).style.alignItems).toBe( + "flex-start" + ); + }); + + it("bottom() sets alignItems and clears top offset", () => { + strategy.top("5px"); + strategy.bottom("10px"); + strategy.apply(element); + + expect(element.style.marginBottom).toBe("10px"); + expect(element.style.marginTop).toBe(""); + expect((element.parentNode as HTMLElement).style.alignItems).toBe( + "flex-end" + ); + }); + + it("left() sets justifyContent and clears right offset", () => { + strategy.right("10px"); + strategy.left("5px"); + strategy.apply(element); + + expect(element.style.marginLeft).toBe("5px"); + expect(element.style.marginRight).toBe(""); + expect((element.parentNode as HTMLElement).style.justifyContent).toBe( + "flex-start" + ); + }); + + it("right() sets justifyContent and clears left offset", () => { + strategy.left("5px"); + strategy.right("10px"); + strategy.apply(element); + + expect(element.style.marginRight).toBe("10px"); + expect(element.style.marginLeft).toBe(""); + expect((element.parentNode as HTMLElement).style.justifyContent).toBe( + "flex-end" + ); + }); + + it("width() sets the width", () => { + strategy.width("200px"); + strategy.apply(element); + + expect(element.style.width).toBe("200px"); + }); + + it("width('100%') resets left to flush against the viewport edge", () => { + strategy.right("20px"); + strategy.width("100%"); + strategy.apply(element); + + expect(element.style.width).toBe("100%"); + expect(element.style.marginLeft).toBe("0px"); + expect(element.style.marginRight).toBe(""); + }); + + it("height() sets the height", () => { + strategy.height("300px"); + strategy.apply(element); + + expect(element.style.height).toBe("300px"); + }); + + it("height('100%') resets top to flush against the viewport edge", () => { + strategy.bottom("20px"); + strategy.height("100%"); + strategy.apply(element); + + expect(element.style.height).toBe("100%"); + expect(element.style.marginTop).toBe("0px"); + expect(element.style.marginBottom).toBe(""); + }); + + it("centerHorizontally() centers with an optional offset", () => { + strategy.centerHorizontally("5px"); + strategy.apply(element); + + expect(element.style.marginLeft).toBe("5px"); + expect((element.parentNode as HTMLElement).style.justifyContent).toBe( + "center" + ); + }); + + it("centerVertically() centers with an optional offset", () => { + strategy.centerVertically("5px"); + strategy.apply(element); + + expect(element.style.marginTop).toBe("5px"); + expect((element.parentNode as HTMLElement).style.alignItems).toBe( + "center" + ); + }); + }); + + describe("apply()", () => { + it("wraps the element in a cdk-global-overlay-wrapper div", () => { + strategy.apply(element); + + const wrapper = element.parentNode as HTMLElement; + expect(wrapper.classList.contains("cdk-global-overlay-wrapper")).toBe( + true + ); + expect(wrapper.parentNode).toBe(parent); + }); + + it("re-uses the same wrapper on subsequent calls", () => { + strategy.apply(element); + const wrapperFirst = element.parentNode; + + strategy.top("1px"); + strategy.apply(element); + const wrapperSecond = element.parentNode; + + expect(wrapperSecond).toBe(wrapperFirst); + }); + }); + + describe("dispose()", () => { + it("removes the wrapper element from the DOM", () => { + strategy.apply(element); + const wrapper = element.parentNode as HTMLElement; + + strategy.dispose(); + + expect(wrapper.parentNode).toBeNull(); + }); + + it("is a no-op when apply() has not been called", () => { + expect(() => strategy.dispose()).not.toThrow(); + }); + + it("is a no-op when called twice", () => { + strategy.apply(element); + strategy.dispose(); + + expect(() => strategy.dispose()).not.toThrow(); + }); + }); +}); diff --git a/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/block-scroll-strategy.spec.ts b/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/block-scroll-strategy.spec.ts new file mode 100644 index 000000000..2b56a0b5d --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/block-scroll-strategy.spec.ts @@ -0,0 +1,116 @@ +import { BlockScrollStrategy } from "./block-scroll-strategy"; +import { FakeViewportRuler } from "../position/fake-viewport-ruler"; + +describe("BlockScrollStrategy", () => { + let viewportRuler: FakeViewportRuler; + let strategy: BlockScrollStrategy; + let originalScrollHeight: PropertyDescriptor | undefined; + let originalScrollWidth: PropertyDescriptor | undefined; + + beforeEach(() => { + viewportRuler = new FakeViewportRuler(); + strategy = new BlockScrollStrategy(viewportRuler as any); + document.documentElement.classList.remove("cdk-global-scrollblock"); + document.documentElement.style.top = ""; + document.documentElement.style.left = ""; + + originalScrollHeight = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "scrollHeight" + ); + originalScrollWidth = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "scrollWidth" + ); + }); + + afterEach(() => { + document.documentElement.classList.remove("cdk-global-scrollblock"); + document.documentElement.style.top = ""; + document.documentElement.style.left = ""; + + if (originalScrollHeight) { + Object.defineProperty( + HTMLElement.prototype, + "scrollHeight", + originalScrollHeight + ); + } + if (originalScrollWidth) { + Object.defineProperty( + HTMLElement.prototype, + "scrollWidth", + originalScrollWidth + ); + } + }); + + function mockBodyOverflow(scrollHeight: number, scrollWidth: number) { + Object.defineProperty(document.body, "scrollHeight", { + value: scrollHeight, + configurable: true, + }); + Object.defineProperty(document.body, "scrollWidth", { + value: scrollWidth, + configurable: true, + }); + } + + describe("enable()", () => { + it("blocks scrolling and offsets the html element when the body overflows the viewport", () => { + mockBodyOverflow(2000, 500); + + strategy.enable(); + + expect( + document.documentElement.classList.contains("cdk-global-scrollblock") + ).toBe(true); + expect(document.documentElement.style.top).toBe("0px"); + expect(document.documentElement.style.left).toBe("0px"); + }); + + it("does nothing when the body does not overflow the viewport", () => { + mockBodyOverflow(100, 100); + + strategy.enable(); + + expect( + document.documentElement.classList.contains("cdk-global-scrollblock") + ).toBe(false); + }); + + it("does not enable twice in a row", () => { + mockBodyOverflow(2000, 500); + vi.spyOn(viewportRuler, "getViewportScrollPosition"); + + strategy.enable(); + strategy.enable(); + + expect(viewportRuler.getViewportScrollPosition).toHaveBeenCalledTimes(1); + }); + }); + + describe("disable()", () => { + it("restores the previous inline styles and removes the scrollblock class", () => { + document.documentElement.style.top = "-25px"; + document.documentElement.style.left = "-10px"; + mockBodyOverflow(2000, 500); + + strategy.enable(); + strategy.disable(); + + expect(document.documentElement.style.top).toBe("-25px"); + expect(document.documentElement.style.left).toBe("-10px"); + expect( + document.documentElement.classList.contains("cdk-global-scrollblock") + ).toBe(false); + }); + + it("is a no-op when never enabled", () => { + expect(() => strategy.disable()).not.toThrow(); + expect( + document.documentElement.classList.contains("cdk-global-scrollblock") + ).toBe(false); + }); + }); +}); diff --git a/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/close-scroll-strategy.spec.ts b/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/close-scroll-strategy.spec.ts new file mode 100644 index 000000000..714abd3b5 --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/close-scroll-strategy.spec.ts @@ -0,0 +1,88 @@ +import { OverlayRef } from "../overlay-ref"; +import { CloseScrollStrategy } from "./close-scroll-strategy"; +import { ScrollDispatcher } from "./scroll-dispatcher"; + +function createFakeOverlayRef() { + return { + hasAttached: vi.fn().mockReturnValue(true), + detach: vi.fn(), + }; +} + +function createFakeScrollDispatcher() { + let callback: (() => void) | undefined; + const unsubscribe = vi.fn(); + return { + scrolled: vi.fn((_auditTime: number, cb: () => void) => { + callback = cb; + return { unsubscribe }; + }), + triggerScroll: () => callback && callback(), + unsubscribe, + }; +} + +describe("CloseScrollStrategy", () => { + let overlayRef: ReturnType; + let scrollDispatcher: ReturnType; + let strategy: CloseScrollStrategy; + + beforeEach(() => { + overlayRef = createFakeOverlayRef(); + scrollDispatcher = createFakeScrollDispatcher(); + strategy = new CloseScrollStrategy( + scrollDispatcher as unknown as ScrollDispatcher + ); + }); + + it("throws when attaching a second overlay ref", () => { + strategy.attach(overlayRef as unknown as OverlayRef); + + expect(() => + strategy.attach(overlayRef as unknown as OverlayRef) + ).toThrowError("Scroll strategy has already been attached."); + }); + + it("detaches the overlay and disables itself when a scroll occurs", () => { + strategy.attach(overlayRef as unknown as OverlayRef); + strategy.enable(); + + scrollDispatcher.triggerScroll(); + + expect(overlayRef.detach).toHaveBeenCalled(); + expect(scrollDispatcher.unsubscribe).toHaveBeenCalled(); + }); + + it("does not detach an overlay that is not currently attached", () => { + overlayRef.hasAttached.mockReturnValue(false); + strategy.attach(overlayRef as unknown as OverlayRef); + strategy.enable(); + + scrollDispatcher.triggerScroll(); + + expect(overlayRef.detach).not.toHaveBeenCalled(); + }); + + it("enable() only subscribes once across multiple calls", () => { + strategy.attach(overlayRef as unknown as OverlayRef); + strategy.enable(); + strategy.enable(); + + expect(scrollDispatcher.scrolled).toHaveBeenCalledTimes(1); + }); + + it("disable() unsubscribes and allows enable() to subscribe again", () => { + strategy.attach(overlayRef as unknown as OverlayRef); + strategy.enable(); + strategy.disable(); + + expect(scrollDispatcher.unsubscribe).toHaveBeenCalledTimes(1); + + strategy.enable(); + expect(scrollDispatcher.scrolled).toHaveBeenCalledTimes(2); + }); + + it("disable() is a no-op when never enabled", () => { + expect(() => strategy.disable()).not.toThrow(); + }); +}); diff --git a/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/reposition-scroll-strategy.spec.ts b/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/reposition-scroll-strategy.spec.ts new file mode 100644 index 000000000..310a6c163 --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/reposition-scroll-strategy.spec.ts @@ -0,0 +1,114 @@ +import { OverlayRef } from "../overlay-ref"; +import { RepositionScrollStrategy } from "./reposition-scroll-strategy"; +import { ScrollDispatcher } from "./scroll-dispatcher"; + +function createFakeOverlayRef() { + return { + updatePosition: vi.fn(), + }; +} + +function createFakeScrollDispatcher() { + let callback: (() => void) | undefined; + const unsubscribe = vi.fn(); + return { + scrolled: vi.fn((_auditTime: number, cb: () => void) => { + callback = cb; + return { unsubscribe }; + }), + triggerScroll: () => callback && callback(), + unsubscribe, + }; +} + +describe("RepositionScrollStrategy", () => { + let overlayRef: ReturnType; + let scrollDispatcher: ReturnType; + + beforeEach(() => { + overlayRef = createFakeOverlayRef(); + scrollDispatcher = createFakeScrollDispatcher(); + }); + + it("throws when attaching a second overlay ref", () => { + const strategy = new RepositionScrollStrategy( + scrollDispatcher as unknown as ScrollDispatcher + ); + strategy.attach(overlayRef as unknown as OverlayRef); + + expect(() => + strategy.attach(overlayRef as unknown as OverlayRef) + ).toThrowError("Scroll strategy has already been attached."); + }); + + it("calls overlayRef.updatePosition() when a scroll occurs", () => { + const strategy = new RepositionScrollStrategy( + scrollDispatcher as unknown as ScrollDispatcher + ); + strategy.attach(overlayRef as unknown as OverlayRef); + strategy.enable(); + + scrollDispatcher.triggerScroll(); + + expect(overlayRef.updatePosition).toHaveBeenCalled(); + }); + + it("passes the configured scrollThrottle through to the dispatcher", () => { + const strategy = new RepositionScrollStrategy( + scrollDispatcher as unknown as ScrollDispatcher, + { scrollThrottle: 50 } + ); + strategy.attach(overlayRef as unknown as OverlayRef); + strategy.enable(); + + expect(scrollDispatcher.scrolled).toHaveBeenCalledWith( + 50, + expect.any(Function) + ); + }); + + it("defaults to a 0ms throttle when no config is provided", () => { + const strategy = new RepositionScrollStrategy( + scrollDispatcher as unknown as ScrollDispatcher + ); + strategy.attach(overlayRef as unknown as OverlayRef); + strategy.enable(); + + expect(scrollDispatcher.scrolled).toHaveBeenCalledWith( + 0, + expect.any(Function) + ); + }); + + it("enable() only subscribes once across multiple calls", () => { + const strategy = new RepositionScrollStrategy( + scrollDispatcher as unknown as ScrollDispatcher + ); + strategy.attach(overlayRef as unknown as OverlayRef); + strategy.enable(); + strategy.enable(); + + expect(scrollDispatcher.scrolled).toHaveBeenCalledTimes(1); + }); + + it("disable() unsubscribes and allows enable() to subscribe again", () => { + const strategy = new RepositionScrollStrategy( + scrollDispatcher as unknown as ScrollDispatcher + ); + strategy.attach(overlayRef as unknown as OverlayRef); + strategy.enable(); + strategy.disable(); + + expect(scrollDispatcher.unsubscribe).toHaveBeenCalledTimes(1); + + strategy.enable(); + expect(scrollDispatcher.scrolled).toHaveBeenCalledTimes(2); + }); + + it("disable() is a no-op when never enabled", () => { + const strategy = new RepositionScrollStrategy( + scrollDispatcher as unknown as ScrollDispatcher + ); + expect(() => strategy.disable()).not.toThrow(); + }); +}); diff --git a/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/scroll-dispatcher.spec.ts b/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/scroll-dispatcher.spec.ts new file mode 100644 index 000000000..d2d9de174 --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/scroll-dispatcher.spec.ts @@ -0,0 +1,178 @@ +import { NgZone } from "@angular/core"; +import { ScrollDispatcher } from "./scroll-dispatcher"; +import { Scrollable } from "./scrollable"; + +function createFakePlatform(isBrowser = true) { + return { isBrowser } as any; +} + +function createFakeScrollable() { + const subject = { subscribe: vi.fn() }; + let listener: (() => void) | undefined; + return { + elementScrolled: vi.fn(() => ({ + subscribe: (cb: () => void) => { + listener = cb; + return { unsubscribe: vi.fn() }; + }, + })), + triggerScroll: () => listener && listener(), + getElementRef: vi.fn(), + }; +} + +describe("ScrollDispatcher", () => { + let ngZone: NgZone; + + beforeEach(() => { + ngZone = new NgZone({ enableLongStackTrace: false }); + }); + + describe("register() / deregister()", () => { + it("notifies subscribers when a registered scrollable fires a scroll event", () => { + const dispatcher = new ScrollDispatcher(ngZone, createFakePlatform()); + const scrollable = createFakeScrollable(); + dispatcher.register(scrollable as unknown as Scrollable); + + const callback = vi.fn(); + dispatcher.scrolled(0, callback); + scrollable.triggerScroll(); + + expect(callback).toHaveBeenCalled(); + }); + + it("stops notifying after deregister()", () => { + const dispatcher = new ScrollDispatcher(ngZone, createFakePlatform()); + const scrollable = createFakeScrollable(); + dispatcher.register(scrollable as unknown as Scrollable); + dispatcher.deregister(scrollable as unknown as Scrollable); + + expect(dispatcher.scrollableReferences.has(scrollable as any)).toBe( + false + ); + }); + + it("deregister() is a no-op for a scrollable that was never registered", () => { + const dispatcher = new ScrollDispatcher(ngZone, createFakePlatform()); + const scrollable = createFakeScrollable(); + + expect(() => + dispatcher.deregister(scrollable as unknown as Scrollable) + ).not.toThrow(); + }); + }); + + describe("scrolled()", () => { + it("returns Subscription.EMPTY when not running in a browser", () => { + const dispatcher = new ScrollDispatcher( + ngZone, + createFakePlatform(false) + ); + const callback = vi.fn(); + + const subscription = dispatcher.scrolled(0, callback); + + expect(subscription.closed).toBe(true); + }); + + it("notifies subscribers on window scroll and resize events", () => { + const dispatcher = new ScrollDispatcher(ngZone, createFakePlatform()); + const callback = vi.fn(); + dispatcher.scrolled(0, callback); + + window.document.dispatchEvent(new Event("scroll")); + + expect(callback).toHaveBeenCalled(); + }); + + it("shares a single global listener across multiple subscriptions", () => { + const dispatcher = new ScrollDispatcher(ngZone, createFakePlatform()); + dispatcher.scrolled(0, vi.fn()); + dispatcher.scrolled(0, vi.fn()); + + expect(dispatcher._globalSubscription).not.toBeNull(); + + const globalSubscription = dispatcher._globalSubscription; + window.document.dispatchEvent(new Event("scroll")); + + expect(dispatcher._globalSubscription).toBe(globalSubscription); + }); + + it("tears down the global listener once every subscription unsubscribes", () => { + const dispatcher = new ScrollDispatcher(ngZone, createFakePlatform()); + const subscriptionA = dispatcher.scrolled(0, vi.fn()); + const subscriptionB = dispatcher.scrolled(0, vi.fn()); + + subscriptionA.unsubscribe(); + expect(dispatcher._globalSubscription).not.toBeNull(); + + subscriptionB.unsubscribe(); + expect(dispatcher._globalSubscription).toBeNull(); + }); + + it("keeps the global listener alive while a Scrollable is still registered", () => { + const dispatcher = new ScrollDispatcher(ngZone, createFakePlatform()); + const scrollable = createFakeScrollable(); + dispatcher.register(scrollable as unknown as Scrollable); + + const subscription = dispatcher.scrolled(0, vi.fn()); + subscription.unsubscribe(); + + expect(dispatcher._globalSubscription).not.toBeNull(); + }); + + it("applies auditTime debouncing when a positive delay is provided", async () => { + const dispatcher = new ScrollDispatcher(ngZone, createFakePlatform()); + const callback = vi.fn(); + dispatcher.scrolled(20, callback); + + dispatcher._notify(); + dispatcher._notify(); + + expect(callback).not.toHaveBeenCalled(); + + await new Promise((resolve) => setTimeout(resolve, 30)); + + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + describe("getScrollContainers() / scrollableContainsElement()", () => { + it("returns registered scrollables that contain the given element", () => { + const dispatcher = new ScrollDispatcher(ngZone, createFakePlatform()); + const scrollableElement = document.createElement("div"); + const childElement = document.createElement("span"); + scrollableElement.appendChild(childElement); + + const scrollable = createFakeScrollable(); + scrollable.getElementRef.mockReturnValue({ + nativeElement: scrollableElement, + }); + dispatcher.register(scrollable as unknown as Scrollable); + + const containers = dispatcher.getScrollContainers({ + nativeElement: childElement, + } as any); + + expect(containers).toContain(scrollable); + }); + + it("excludes scrollables that do not contain the given element", () => { + const dispatcher = new ScrollDispatcher(ngZone, createFakePlatform()); + const scrollableElement = document.createElement("div"); + const unrelatedElement = document.createElement("span"); + + const scrollable = createFakeScrollable(); + scrollable.getElementRef.mockReturnValue({ + nativeElement: scrollableElement, + }); + dispatcher.register(scrollable as unknown as Scrollable); + + const containers = dispatcher.getScrollContainers({ + nativeElement: unrelatedElement, + } as any); + + expect(containers).not.toContain(scrollable); + }); + }); +}); diff --git a/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/scroll-dispatcher.ts b/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/scroll-dispatcher.ts index 9cd91b4e9..99ba7a659 100755 --- a/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/scroll-dispatcher.ts +++ b/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/scroll-dispatcher.ts @@ -93,7 +93,7 @@ export class ScrollDispatcher { // since it does add a perceptible delay in processing overhead. let observable = auditTimeInMs > 0 - ? auditTime.call(this._scrolled.asObservable(), auditTimeInMs) + ? this._scrolled.asObservable().pipe(auditTime(auditTimeInMs)) : this._scrolled.asObservable(); this._scrolledCount++; From 52b0c3b8376b52fd7a5fd1331de294aaa6e245de Mon Sep 17 00:00:00 2001 From: "Frank Pigeon Jr." <4629398+fpigeonjr@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:08:36 -0500 Subject: [PATCH 2/2] Address PR review feedback - block-scroll-strategy.spec.ts: delete the document.body scrollHeight/ scrollWidth own-property overrides in afterEach so they don't leak into later tests (afterEach only restored HTMLElement.prototype descriptors). - scroll-dispatcher.spec.ts: capture and unsubscribe every Subscription returned by scrolled() so global scroll/resize listeners don't leak across tests; replace the real setTimeout wait in the auditTime debouncing test with Vitest fake timers for determinism. - overlay-ref.spec.ts: wrap the detachBackdrop() transitionend test in fake timers and flush the pending 500ms fallback setTimeout so no timer is left running after the test. --- .../core/overlay/overlay-ref.spec.ts | 13 +++++-- .../scroll/block-scroll-strategy.spec.ts | 3 ++ .../overlay/scroll/scroll-dispatcher.spec.ts | 38 +++++++++++++------ 3 files changed, 39 insertions(+), 15 deletions(-) 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 35f3b0efa..6941b22df 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 @@ -230,10 +230,17 @@ describe("OverlayRef", () => { ".cdk-overlay-backdrop" ) as HTMLElement; - overlayRef.detachBackdrop(); - backdrop.dispatchEvent(new Event("transitionend")); + vi.useFakeTimers(); + try { + overlayRef.detachBackdrop(); + backdrop.dispatchEvent(new Event("transitionend")); - expect(document.body.querySelector(".cdk-overlay-backdrop")).toBeNull(); + expect(document.body.querySelector(".cdk-overlay-backdrop")).toBeNull(); + + vi.runOnlyPendingTimers(); + } finally { + vi.useRealTimers(); + } }); }); }); diff --git a/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/block-scroll-strategy.spec.ts b/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/block-scroll-strategy.spec.ts index 2b56a0b5d..744c90ccf 100644 --- a/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/block-scroll-strategy.spec.ts +++ b/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/block-scroll-strategy.spec.ts @@ -43,6 +43,9 @@ describe("BlockScrollStrategy", () => { originalScrollWidth ); } + + delete (document.body as any).scrollHeight; + delete (document.body as any).scrollWidth; }); function mockBodyOverflow(scrollHeight: number, scrollWidth: number) { diff --git a/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/scroll-dispatcher.spec.ts b/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/scroll-dispatcher.spec.ts index d2d9de174..8fc9c0ec3 100644 --- a/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/scroll-dispatcher.spec.ts +++ b/src/ui-kit/experimental/patterns/layout/components/core/overlay/scroll/scroll-dispatcher.spec.ts @@ -78,17 +78,19 @@ describe("ScrollDispatcher", () => { it("notifies subscribers on window scroll and resize events", () => { const dispatcher = new ScrollDispatcher(ngZone, createFakePlatform()); const callback = vi.fn(); - dispatcher.scrolled(0, callback); + const subscription = dispatcher.scrolled(0, callback); window.document.dispatchEvent(new Event("scroll")); expect(callback).toHaveBeenCalled(); + + subscription.unsubscribe(); }); it("shares a single global listener across multiple subscriptions", () => { const dispatcher = new ScrollDispatcher(ngZone, createFakePlatform()); - dispatcher.scrolled(0, vi.fn()); - dispatcher.scrolled(0, vi.fn()); + const subscriptionA = dispatcher.scrolled(0, vi.fn()); + const subscriptionB = dispatcher.scrolled(0, vi.fn()); expect(dispatcher._globalSubscription).not.toBeNull(); @@ -96,6 +98,9 @@ describe("ScrollDispatcher", () => { window.document.dispatchEvent(new Event("scroll")); expect(dispatcher._globalSubscription).toBe(globalSubscription); + + subscriptionA.unsubscribe(); + subscriptionB.unsubscribe(); }); it("tears down the global listener once every subscription unsubscribes", () => { @@ -119,21 +124,30 @@ describe("ScrollDispatcher", () => { subscription.unsubscribe(); expect(dispatcher._globalSubscription).not.toBeNull(); + + dispatcher.deregister(scrollable as unknown as Scrollable); }); - it("applies auditTime debouncing when a positive delay is provided", async () => { - const dispatcher = new ScrollDispatcher(ngZone, createFakePlatform()); - const callback = vi.fn(); - dispatcher.scrolled(20, callback); + it("applies auditTime debouncing when a positive delay is provided", () => { + vi.useFakeTimers(); + try { + const dispatcher = new ScrollDispatcher(ngZone, createFakePlatform()); + const callback = vi.fn(); + const subscription = dispatcher.scrolled(20, callback); + + dispatcher._notify(); + dispatcher._notify(); - dispatcher._notify(); - dispatcher._notify(); + expect(callback).not.toHaveBeenCalled(); - expect(callback).not.toHaveBeenCalled(); + vi.advanceTimersByTime(30); - await new Promise((resolve) => setTimeout(resolve, 30)); + expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledTimes(1); + subscription.unsubscribe(); + } finally { + vi.useRealTimers(); + } }); });