import test from "node:test"; import assert from "node:assert/strict"; import { initializeSplash } from "../src/scripts/splash-motion.js"; function target(extra = {}) { const listeners = new Map(); return { ...extra, addEventListener: (name, fn) => listeners.set(name, fn), removeEventListener: (name) => listeners.delete(name), emit: (name, event = {}) => listeners.get(name)?.(event), }; } function fixture(search = "", reduced = false, compact = false) { let intersection, resize; let serial = 0; const frames = new Map(); const rect = (x, y, width, height) => ({ x, y, left: x, top: y, width, height, }); const node = (bounds) => ({ bounds, style: {}, textContent: "", getBoundingClientRect() { return this.bounds; }, }); const board = { ...node(rect(100, 100, 1000, 600)), append(...children) { this.children = children; }, }; const slots = Array.from({ length: 3 }, (_, index) => node( rect( compact ? 112 : 120 + index * 300, compact ? 124 + index * 180 : 154, compact ? 290 : 250, 140, ), ), ); const statuses = Array.from({ length: 3 }, () => node()); const cards = statuses.map((status, index) => ({ ...node(rect(0, 0, 250, 140 + index * 5)), querySelector: () => status, })); const attributes = new Map(); const toggle = target({ disabled: false, setAttribute: (name, value) => attributes.set(name, value), }); const nodes = new Map([ [".kanban", board], [".splash-screen", node()], ["[data-splash-toggle]", toggle], ]); const root = { dataset: {}, querySelector: (selector) => nodes.get(selector), querySelectorAll: (selector) => selector === "[data-splash-card]" ? cards : slots, }; const media = target({ matches: reduced }); const document = target({ hidden: false }); const env = { location: { search }, innerHeight: 800, document, matchMedia: () => media, requestAnimationFrame: (callback) => { frames.set(++serial, callback); return serial; }, cancelAnimationFrame: (id) => frames.delete(id), IntersectionObserver: class { constructor(callback) { intersection = callback; } observe() {} disconnect() {} }, ResizeObserver: class { constructor(callback) { resize = callback; } observe() {} disconnect() {} }, }; const dispose = initializeSplash(root, env); return { root, cards, slots, statuses, toggle, attributes, media, document, dispose, frames: () => frames.size, visible(shown = 600) { intersection([ { isIntersecting: shown > 0, boundingClientRect: { height: 600 }, intersectionRect: { height: shown }, }, ]); }, tick(time) { const item = frames.entries().next().value; if (item) { frames.delete(item[0]); item[1](time); } }, resize: () => resize(), }; } test("all cards loop continuously, pause offscreen or hidden, and resume without skipped time", () => { const app = fixture(); assert.equal(app.root.dataset.splashPhase, "queued"); assert.equal(app.frames(), 0); assert.equal(app.cards[0].style.transform, "translate3d(20px, 54px, 0)"); assert.deepEqual( app.slots.map((slot) => slot.style.height), ["150px", "150px", "150px"], ); app.visible(); app.tick(0); app.tick(4500); assert.equal(app.root.dataset.splashPhase, "moving"); const pose = app.cards.map((card) => card.style.transform); app.visible(100); app.tick(10000); assert.equal(app.frames(), 0); assert.deepEqual( app.cards.map((card) => card.style.transform), pose, ); app.visible(); app.tick(11000); assert.deepEqual( app.cards.map((card) => card.style.transform), pose, ); app.document.hidden = true; app.document.emit("visibilitychange"); assert.equal(app.frames(), 0); app.document.hidden = false; app.document.emit("visibilitychange"); app.tick(50000); app.tick(50700); assert.equal(app.root.dataset.splashPhase, "review"); assert.equal(app.cards[0].style.transform, "translate3d(320px, 54px, 0)"); assert.deepEqual( app.statuses.map((status) => status.textContent), ["In progress", "To do", "For review"], ); assert.equal(app.frames(), 1); app.tick(81500); assert.equal(app.cards[0].style.transform, "translate3d(20px, 54px, 0)"); assert.equal(app.frames(), 1); app.dispose(); assert.equal(app.frames(), 0); }); test("phone cards move between real vertical anchors and remeasure after width changes", () => { const app = fixture("", false, true); assert.equal(app.root.dataset.splashCompact, "true"); assert.equal(app.cards[0].style.transform, "translate3d(12px, 24px, 0)"); app.visible(); app.tick(0); app.tick(4500); assert.equal(app.cards[0].style.transform, "translate3d(18px, 114px, 0)"); app.slots.forEach((slot) => { slot.bounds = { ...slot.bounds, width: 250 }; }); app.resize(); assert.equal(app.cards[0].style.width, "250px"); app.tick(5200); assert.equal(app.cards[0].style.transform, "translate3d(12px, 204px, 0)"); assert.equal(app.cards[1].style.transform, "translate3d(12px, 24px, 0)"); app.dispose(); }); test("capture and reduced motion lock playback, and switching motion preference back resumes", () => { for (const phase of ["queued", "moving", "review"]) { const app = fixture(`?capture=1§ion=hero&phase=${phase}`); app.visible(); app.toggle.emit("click"); app.tick(9999); assert.equal(app.root.dataset.splashPhase, phase); assert.equal(app.frames(), 0); assert.equal(app.toggle.disabled, true); app.media.emit("change", { matches: false }); assert.equal(app.frames(), 0); app.dispose(); } const app = fixture("", true); assert.equal(app.root.dataset.splashPhase, "review"); app.visible(); assert.equal(app.frames(), 0); app.media.emit("change", { matches: false }); assert.equal(app.frames(), 1); assert.equal(app.toggle.disabled, false); app.tick(0); app.tick(5000); app.media.emit("change", { matches: true }); assert.equal(app.root.dataset.splashPhase, "review"); assert.equal(app.frames(), 0); assert.equal(app.toggle.disabled, true); app.dispose(); }); test("accessible pause control freezes in place and preserves user pause across visibility changes", () => { const app = fixture(); app.visible(); app.tick(0); app.tick(4500); const pose = app.cards[0].style.transform; app.toggle.emit("click"); assert.equal(app.frames(), 0); assert.equal(app.attributes.get("aria-label"), "Resume task animation"); assert.equal(app.attributes.get("aria-pressed"), "true"); app.visible(0); app.visible(); assert.equal(app.frames(), 0); app.toggle.emit("click"); assert.equal(app.attributes.get("aria-label"), "Pause task animation"); app.tick(20000); assert.equal(app.cards[0].style.transform, pose); app.tick(20700); assert.notEqual(app.cards[0].style.transform, pose); app.dispose(); });