| 1 | // Run: tsx src/__tests__/browser-surface-layout.test.ts |
| 2 | import assert from "node:assert/strict"; |
| 3 | import { JSDOM } from "jsdom"; |
| 4 | |
| 5 | import type { BrowserLayoutRect } from "../lib/browserHost"; |
| 6 | import { attachAppOverlayGate, attachBrowserSurfaceLayout } from "../lib/useBrowserSurfaceLayout"; |
| 7 | |
| 8 | const dom = new JSDOM("<!doctype html><html><body><main id='scroller'><div id='surface'></div></main></body></html>", { url: "http://localhost" }); |
| 9 | const view = dom.window as unknown as Window & typeof globalThis; |
| 10 | const frames: FrameRequestCallback[] = []; |
| 11 | let cancelled = 0; |
| 12 | view.requestAnimationFrame = (callback) => { frames.push(callback); return frames.length; }; |
| 13 | view.cancelAnimationFrame = (id) => { cancelled += 1; frames[id - 1] = () => {}; }; |
| 14 | const flush = () => { for (const callback of frames.splice(0)) callback(0); }; |
| 15 | |
| 16 | class FakeResizeObserver { |
| 17 | static instances: FakeResizeObserver[] = []; |
| 18 | observed: Element[] = []; |
| 19 | constructor(readonly callback: ResizeObserverCallback) { FakeResizeObserver.instances.push(this); } |
| 20 | observe(target: Element) { this.observed.push(target); } |
| 21 | unobserve() {} |
| 22 | disconnect() { this.observed = []; } |
| 23 | trigger() { this.callback([], this as unknown as ResizeObserver); } |
| 24 | } |
| 25 | (view as unknown as { ResizeObserver: unknown }).ResizeObserver = FakeResizeObserver; |
| 26 | |
| 27 | const surface = view.document.getElementById("surface")!; |
| 28 | const scroller = view.document.getElementById("scroller")!; |
| 29 | let box = { left: 10.4, top: 20.6, width: 300.2, height: 199.5 }; |
| 30 | surface.getBoundingClientRect = () => ({ ...box, right: box.left + box.width, bottom: box.top + box.height, x: box.left, y: box.top, toJSON: () => box }) as DOMRect; |
| 31 | |
| 32 | const layouts: (BrowserLayoutRect | null)[] = []; |
| 33 | const overlays: boolean[] = []; |
| 34 | const host = { setLayout: (rect: BrowserLayoutRect | null) => { layouts.push(rect); }, setOverlay: (active: boolean) => { overlays.push(active); } }; |
| 35 | const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); |
| 36 | |
| 37 | console.log("\nbrowser surface layout"); |
| 38 | |
| 39 | const detachLayout = attachBrowserSurfaceLayout(host, surface, view); |
| 40 | assert.deepEqual(layouts, [{ x: 10, y: 21, width: 300, height: 200 }], "the surface rect is reported immediately, rounded to integers"); |
| 41 | const observer = FakeResizeObserver.instances[0]; |
| 42 | assert.deepEqual(observer.observed, [surface], "the surface is observed for size changes"); |
| 43 | observer.trigger(); |
| 44 | observer.trigger(); |
| 45 | assert.equal(frames.length, 1, "notifications coalesce into one animation frame"); |
| 46 | flush(); |
| 47 | assert.equal(layouts.length, 1, "an unchanged rect is not resent"); |
| 48 | box = { ...box, top: 60 }; |
| 49 | scroller.dispatchEvent(new view.Event("scroll")); |
| 50 | flush(); |
| 51 | assert.deepEqual(layouts[layouts.length - 1], { x: 10, y: 60, width: 300, height: 200 }, "scrolling an ancestor re-measures"); |
| 52 | box = { ...box, width: 420 }; |
| 53 | view.dispatchEvent(new view.Event("resize")); |
| 54 | flush(); |
| 55 | assert.deepEqual(layouts[layouts.length - 1], { x: 10, y: 60, width: 420, height: 200 }, "window resize re-measures"); |
| 56 | box = { ...box, left: 5 }; |
| 57 | observer.trigger(); |
| 58 | detachLayout(); |
| 59 | assert.equal(cancelled, 1, "a pending frame is cancelled on detach"); |
| 60 | assert.equal(layouts[layouts.length - 1], null, "detach hides the native view"); |
| 61 | assert.deepEqual(observer.observed, [], "detach disconnects the observer"); |
| 62 | const count = layouts.length; |
| 63 | scroller.dispatchEvent(new view.Event("scroll")); |
| 64 | view.dispatchEvent(new view.Event("resize")); |
| 65 | flush(); |
| 66 | assert.equal(layouts.length, count, "listeners are removed on detach"); |
| 67 | console.log(" PASS rect math, dedupe, ancestor scroll, resize and detach"); |
| 68 | |
| 69 | const detachOverlay = attachAppOverlayGate(host, view); |
| 70 | assert.deepEqual(overlays, [false], "the initial overlay state is reported"); |
| 71 | const modal = view.document.createElement("div"); |
| 72 | modal.setAttribute("data-app-overlay", ""); |
| 73 | view.document.body.appendChild(modal); |
| 74 | await settle(); |
| 75 | flush(); |
| 76 | assert.deepEqual(overlays, [false, true], "a portaled overlay root hides the native views"); |
| 77 | const wrapper = view.document.createElement("div"); |
| 78 | wrapper.innerHTML = "<section><div data-app-overlay=''></div></section>"; |
| 79 | view.document.body.appendChild(wrapper); |
| 80 | await settle(); |
| 81 | flush(); |
| 82 | assert.deepEqual(overlays, [false, true], "a second overlay does not repeat the state"); |
| 83 | modal.remove(); |
| 84 | await settle(); |
| 85 | flush(); |
| 86 | assert.deepEqual(overlays, [false, true], "one remaining nested overlay keeps the state"); |
| 87 | wrapper.remove(); |
| 88 | await settle(); |
| 89 | flush(); |
| 90 | assert.deepEqual(overlays, [false, true, false], "removing the last overlay restores the views"); |
| 91 | const plain = view.document.createElement("div"); |
| 92 | view.document.body.appendChild(plain); |
| 93 | await settle(); |
| 94 | flush(); |
| 95 | assert.equal(overlays.length, 3, "unrelated mutations do not report"); |
| 96 | plain.setAttribute("data-app-overlay", ""); |
| 97 | await settle(); |
| 98 | flush(); |
| 99 | assert.deepEqual(overlays[overlays.length - 1], true, "toggling the attribute on an existing element counts"); |
| 100 | detachOverlay(); |
| 101 | assert.deepEqual(overlays[overlays.length - 1], false, "detach clears an active overlay state"); |
| 102 | plain.remove(); |
| 103 | await settle(); |
| 104 | flush(); |
| 105 | assert.equal(overlays.length, 5, "the observer is disconnected on detach"); |
| 106 | console.log(" PASS overlay gate follows [data-app-overlay] presence"); |
| 107 | console.log("browser surface layout: all checks passed"); |
| 108 |