import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { test } from 'node:test'; import { loadTypescript } from './support/load-typescript.mjs'; const html = await readFile(new URL('../index.html', import.meta.url), 'utf8'); const source = await readFile(new URL('../src/ui/ExperienceUI.ts', import.meta.url), 'utf8'); const exports = await loadTypescript(new URL('../src/ui/ExperienceUI.ts', import.meta.url)); class Element { dataset = {}; attributes = {}; listeners = new Map(); hidden = false; open = false; inert = false; value = ''; isConnected = true; addEventListener(type, fn, { signal } = {}) { const list = this.listeners.get(type) ?? new Set(); list.add(fn); this.listeners.set(type, list); signal?.addEventListener('abort', () => list.delete(fn)); } dispatch(type, extra = {}) { for (const fn of [...(this.listeners.get(type) ?? [])]) fn({ target: this, ...extra }); } setAttribute(key, value) { this.attributes[key] = value; } focus() { if (!this.inert) this.root.activeElement = this; } showModal() { this.open = true; } close() { this.open = false; this.dispatch('close'); } getBoundingClientRect() { return { left: 0, top: 0, right: 400, bottom: 800 }; } } function harness(reduced = false) { const root = new Element(), media = new Element(), timers = new Map(); let timerId = 0; media.matches = reduced; const els = Object.fromEntries([...html.matchAll(/\bid="([^"]+)"/g)].map(([, id]) => [id, new Element()])); for (const element of Object.values(els)) element.root = root; root.querySelectorAll = () => []; root.documentElement = new Element(); root.querySelector = selector => els[selector.slice(1)] ?? null; root.defaultView = { localStorage: { getItem() { return null; }, setItem() {} }, matchMedia: () => media, setTimeout(fn, ms) { timers.set(++timerId, { fn, ms }); return timerId; }, clearTimeout(id) { timers.delete(id); } }; let reveals = 0; const abort = new AbortController(), ui = exports.initExperienceUI(root, abort.signal, () => {}, () => { reveals += 1; }); const tick = () => { const [id, timer] = timers.entries().next().value; timers.delete(id); timer.fn(); return timer.ms; }; return { root, els, ui, media, timers, abort, tick, get reveals() { return reveals; } }; } test('only an explicit real AI start drives the selected photo, without a completion timer', () => { const h = harness(); assert.equal(typeof h.ui.startJourney, 'function', 'AI work needs its own truthful journey start'); h.ui.setPhoto('blob:user-photo'); h.ui.setState('loading'); assert.notEqual(h.els['flow-overlay'].dataset.journey, 'driving', 'plain file loading is not AI travel'); h.ui.startJourney(); h.ui.setDetail('CPU/WASM에서 입체감을 계산하고 있어요'); assert.equal(h.els['flow-photo'].src, 'blob:user-photo'); assert.equal(h.els['flow-overlay'].dataset.journey, 'driving'); assert.equal(h.els['flow-overlay'].attributes['aria-busy'], 'true'); assert.equal(h.ui.isBlocked(), true); assert.equal(h.timers.size, 0, 'slow inference must not arrive on a timer'); assert.match(h.els['flow-detail'].textContent, /CPU\/WASM/); }); test('real success holds arrival for 1350ms, unpacks for 1500ms, then reveals the scene', () => { const h = harness(); h.ui.setPhoto('blob:photo'); h.ui.startJourney(); h.ui.setState('ready'); assert.equal(h.els.experience.dataset.flow, 'ready', 'inference has truthfully completed'); assert.equal(h.ui.isBlocked(), true, 'scene input waits for the short presentation'); assert.equal(h.els['canvas-wrap'].inert, true); assert.equal(h.els['flow-overlay'].hidden, false); assert.equal(h.els['flow-overlay'].dataset.journey, 'arrived'); assert.equal(h.els['flow-overlay'].attributes['aria-busy'], 'false'); assert.match(h.els['flow-title'].textContent, /할머니 집에 도착/); assert.equal(h.els['flow-detail'].hidden, true, 'technical success text stays out of the arrival moment'); assert.equal(h.els['flow-actions'].hidden, true); h.els['flow-overlay'].dispatch('animationend', { animationName: 'journey-arrival-fade' }); assert.equal(h.els['flow-overlay'].hidden, false, 'old animation events cannot skip the holds'); assert.equal(h.tick(), 1350); assert.equal(h.els['flow-overlay'].dataset.journey, 'unpacking'); assert.equal(h.els['flow-title'].textContent, '짐을 푸는 중…'); assert.equal(h.ui.isBlocked(), true); assert.equal(h.reveals, 0); assert.equal(h.tick(), 1500); assert.equal(h.els['flow-overlay'].hidden, true); assert.equal(h.timers.size, 0); assert.equal(h.ui.isBlocked(), false); assert.equal(h.els['canvas-wrap'].inert, false); assert.equal(h.root.activeElement, h.els['canvas-wrap']); assert.equal(h.reveals, 1); }); test('failure stops the trip and flat fallback cannot masquerade as arrival', () => { const h = harness(); h.ui.setPhoto('blob:photo'); h.ui.startJourney(); h.ui.setState('error', true); assert.equal(h.els['flow-overlay'].dataset.journey, 'idle'); assert.equal(h.els['flow-retry'].hidden, false); assert.equal(h.els['flow-flat'].hidden, false); assert.doesNotMatch(h.els['flow-title'].textContent, /도착/); assert.equal(h.timers.size, 0); h.ui.setState('ready'); assert.equal(h.els['flow-overlay'].hidden, true); }); test('motion pause and reduced motion never pause AI or fabricate progress', () => { const h = harness(); h.ui.setPhoto('blob:photo'); h.ui.startJourney(); assert.equal(h.els['flow-overlay'].dataset.motion, 'running'); assert.equal(h.els.experience.dataset.flow, 'loading'); assert.equal(h.els['flow-overlay'].attributes['aria-busy'], 'true'); h.media.matches = true; h.media.dispatch('change'); assert.equal(h.els['flow-overlay'].dataset.motion, 'paused'); h.ui.setState('ready'); assert.equal(h.els['flow-overlay'].hidden, true); assert.equal(h.timers.size, 0); assert.equal(h.ui.isBlocked(), false); assert.equal(h.root.activeElement, h.els['canvas-wrap'], 'reduced-motion reveal focuses the now-interactive scene'); const reduced = harness(true); reduced.ui.setPhoto('blob:photo'); reduced.ui.startJourney(); assert.equal(reduced.els['flow-overlay'].dataset.motion, 'paused'); }); test('replacement and retry cancel old arrival cleanup without hiding the newer operation', () => { const h = harness(); h.ui.setPhoto('blob:first'); h.ui.startJourney(); h.ui.setState('ready'); const oldCleanup = [...h.timers.values()][0].fn; h.ui.setPhoto(''); h.ui.setState('loading'); h.ui.setPhoto('blob:second'); h.ui.startJourney(); oldCleanup(); assert.equal(h.els['flow-photo'].src, 'blob:second'); assert.equal(h.els['flow-overlay'].hidden, false); assert.equal(h.els['flow-overlay'].dataset.journey, 'driving'); assert.equal(h.els.experience.dataset.flow, 'loading'); h.ui.setState('error', true); h.ui.startJourney(); assert.equal(h.els['flow-overlay'].dataset.journey, 'driving'); }); test('render loss blocks input and stops travel/arrival; teardown removes all pending animation work', () => { const h = harness(); h.ui.setPhoto('blob:photo'); h.ui.startJourney(); h.ui.setRenderLost(true); assert.equal(h.ui.isBlocked(), true); assert.notEqual(h.els['flow-overlay'].dataset.journey, 'driving'); h.ui.setState('ready'); assert.equal(h.els['flow-overlay'].hidden, true); assert.equal(h.ui.isBlocked(), true); h.ui.setRenderLost(false); assert.equal(h.ui.isBlocked(), false); h.ui.startJourney(); h.ui.setState('ready'); assert.equal(h.timers.size, 1); h.abort.abort(); assert.equal(h.timers.size, 0); h.media.matches=true;h.media.dispatch('change'); assert.notEqual(h.els['flow-overlay'].dataset.journey, 'driving'); }); test('both arrival stages cancel safely on replacement, recovery, reduced motion and disposal', () => { for (const stage of ['arrived', 'unpacking']) { for (const reason of ['photo', 'loading', 'error', 'render-loss', 'reduced-motion', 'dispose']) { const h = harness(); h.ui.setPhoto('blob:photo'); h.ui.startJourney(); h.ui.setState('ready'); if (stage === 'unpacking') h.tick(); const stale = [...h.timers.values()][0].fn; if (reason === 'photo') h.ui.setPhoto('blob:replacement'); if (reason === 'loading') h.ui.startJourney(); if (reason === 'error') h.ui.setState('error', true); if (reason === 'render-loss') h.ui.setRenderLost(true); if (reason === 'reduced-motion') { h.media.matches = true; h.media.dispatch('change'); } if (reason === 'dispose') h.abort.abort(); const title = h.els['flow-title'].textContent, hidden = h.els['flow-overlay'].hidden; stale(); assert.equal(h.timers.size, 0, `${stage}/${reason} leaves no pending timer`); assert.equal(h.els['flow-title'].textContent, title, `${stage}/${reason} ignores stale callback`); assert.equal(h.els['flow-overlay'].hidden, hidden); if (reason === 'photo' || reason === 'reduced-motion') { assert.equal(hidden, true); assert.equal(h.ui.isBlocked(), false); } } } }); test('opening settings dismisses the arrival without stealing dialog focus later', () => { const h = harness(); h.ui.setPhoto('blob:photo'); h.ui.startJourney(); h.ui.setState('ready'); h.tick(); const stale = [...h.timers.values()][0].fn; h.els['settings-open'].dispatch('click'); assert.equal(h.els['flow-overlay'].hidden, true); assert.equal(h.timers.size, 0); assert.equal(h.root.activeElement, h.els['settings-close']); stale(); assert.equal(h.root.activeElement, h.els['settings-close']); assert.equal(h.ui.isBlocked(), true); }); test('the illustration is original inline SVG with contained photo and no scrolling or progress meter', async () => { const css = await readFile(new URL('../src/style.css', import.meta.url), 'utf8'); assert.match(html, /id="journey"/); assert.match(html, /class="journey-car"/); assert.match(html, /class="journey-house"/); assert.doesNotMatch(html, /id="journey-motion"/); assert.doesNotMatch(html, /arrival-scroll|scroll-story| { const css = await readFile(new URL('../src/style.css', import.meta.url), 'utf8'); assert.match(css, /width:min\(var\(--journey-car-width\),\s*calc\(\(var\(--journey-height\)\s*-\s*38px\)\s*\*\s*5\s*\/\s*3\)\)/, 'car width must also be bounded by available illustration height, not only viewport width'); for (const [viewportWidth, viewportHeight] of [[1280,450],[500,400],[1024,600],[900,431],[375,667],[320,568],[1440,900]]) { const compact = viewportHeight <= 650; const illustrationHeight = compact ? Math.max(100, Math.min(.27 * viewportHeight, 160)) : Math.max(144, Math.min(.3 * viewportHeight, 236)); const illustrationWidth = Math.min(compact ? 380 : 460, viewportWidth * (compact ? .8 : .88)); const carWidth = Math.min(illustrationWidth * (compact ? .54 : .58), (illustrationHeight - 38) * 5 / 3, 267); // Same box geometry as CSS; reserve 8px for the photo rotation and gentle bob. assert.ok(illustrationHeight - 24 - carWidth * 3 / 5 - 8 >= 0, `${viewportWidth}×${viewportHeight} leaves the photo unclipped`); } }); test('unpacking hops the contained original photo before walking and fades only in the final 200ms', async () => { const css = await readFile(new URL('../src/style.css', import.meta.url), 'utf8'); assert.match(html, /class="journey-traveler"/); assert.match(css, /data-journey=unpacking\] \.journey-traveler[^}]*animation:journey-walk 1\.3s/); assert.match(css, /data-journey=unpacking\] \.journey-passenger[^}]*animation:journey-waddle 1\.3s/); assert.match(css, /data-journey=unpacking\] \{ animation:journey-arrival-fade \.2s ease-in 1\.3s both/); assert.match(css, /@keyframes journey-walk\s*\{\s*0%,28%\s*\{ left:16%; transform:translateX\(-10%\); \}/); assert.match(css, /@keyframes journey-waddle[^@]*28%\s*\{ top:35%;/); assert.match(css, /@keyframes journey-waddle[^@]*100%\s*\{ top:35%;/); assert.match(css, /@keyframes journey-waddle[^@]*rotate\(5deg\)/); assert.ok(css.indexOf('#flow-overlay[data-motion=paused] .journey *') > css.indexOf('animation:journey-waddle'), 'manual pause wins over animation shorthand reset'); const h = harness(); h.ui.setPhoto('blob:original-photo'); h.ui.startJourney(); h.ui.setState('ready'); h.tick(); assert.equal(h.els['flow-photo'].src, 'blob:original-photo'); assert.equal(h.els['flow-overlay'].dataset.motion, 'running'); assert.equal(h.tick(), 1500, 'walking completes on the existing reveal timer'); assert.equal(h.ui.isBlocked(), false); }); test('walking photo stays inside responsive journey bounds through its full route', () => { for (const [w, h] of [[460,236],[380,160],[256,153.36],[300,100],[170,170],[180,100]]) { const c=Math.min(w*(h<=160?.54:.58),(h-38)*5/3,267); for(let i=0;i<=100;i++) { const t=i/100; const walk=Math.max(0,(t-.28)/.72); const drop=Math.min(t/.24,1); const left=(.16+.715*walk)*w+(-.1-.29*walk)*c+.22*c; // Conservative rotated card envelope, including the 3px upward footstep. const rotationAllowance=Math.sin(5*Math.PI/180)*.65*.6*c; assert.ok(left-rotationAllowance>=0); assert.ok(left+.34*c+rotationAllowance<=w); const top=h-24-.6*c+.35*.6*c*drop; assert.ok(top-3-rotationAllowance>=0); assert.ok(top+.65*.6*c+rotationAllowance<=h); } } }); // Parse production keyframes so changing the CSS trajectory breaks these checks. async function hopFrames() { const css = await readFile(new URL('../src/style.css', import.meta.url), 'utf8'); const block = css.match(/@keyframes journey-waddle \{([\s\S]*?)\n\}/)[1]; return [...block.matchAll(/(\d+)%\s*\{([^}]+)\}/g)].map(([, percent, body]) => ({ time: +percent, top: +body.match(/top:([\d.]+)/)[1], y: +body.match(/translateY\((-?[\d.]+)(?:px)?\)/)[1], body, })); } test('photo rises before descending, softens its landing, and settles before X travel', async () => { const frames = await hopFrames(); const lift = frames.find(f => f.y < 0 && f.top === 0); assert.ok(lift, 'photo needs an upward takeoff before the descent'); const landing = frames.find(f => f.top === 35); const settled = frames.find(f => f.time > landing.time && f.y === 0 && /scaleY\(1\)/.test(f.body)); assert.ok(lift.time < landing.time && landing.time < settled.time); assert.match(landing.body, /scaleY\(\.97\)/, 'small foot-pivot landing compression'); assert.match(lift.body, /animation-timing-function:cubic-bezier\(\.4,0,\.65,1\)/, 'descent eases to a soft zero-speed landing'); assert.equal(settled.time, 28, 'X hold ends only after the landing settles'); assert.ok(frames.filter(f => f.time >= settled.time).every(f => f.top === 35), 'walking stays at ground baseline'); }); test('production hop keyframes fit short and narrow illustrations including takeoff headroom', async () => { const frames = await hopFrames(); for (const [w,h,ratio] of [[460,236,.58],[380,160,.54],[256,153.36,.54],[300,100,.54],[170,170,.54],[180,100,.54]]) { const c = Math.min(w*ratio,(h-38)*5/3,267); for (const f of frames) { const walk = Math.max(0,(f.time-28)/72); const x = (.16+.715*walk)*w+(-.1-.29*walk)*c+.22*c; const rotation = Math.sin(5*Math.PI/180)*.65*.6*c; const top = h-24-.6*c+f.top/100*.6*c+f.y; assert.ok(top-rotation>=0, `${w}x${h} at ${f.time}% retains takeoff headroom`); assert.ok(top+.65*.6*c+rotation<=h); assert.ok(x-rotation>=0 && x+.34*c+rotation<=w); } } }); test('editing the global name changes copy without restarting loading, arrival or unpacking timers', () => { const h = harness(); const edit = name => { h.els['entry-pet-name'].value = name; h.els['entry-pet-name'].dispatch('input'); }; edit('호롱'); h.ui.setPhoto('blob:photo'); h.ui.startJourney(); assert.equal(h.els['flow-title'].textContent, '호롱이를 할머니 집으로 데려가는 중…'); assert.equal(h.timers.size, 0); h.ui.setState('ready'); const arrivalTimer = [...h.timers.entries()][0]; edit('초코'); assert.equal(h.els['flow-title'].textContent, '초코가 할머니 집에 도착했어요'); assert.deepEqual([...h.timers.entries()][0], arrivalTimer, 'name edits preserve the exact arrival timer'); assert.equal(h.tick(), 1350); const unpackTimer = [...h.timers.entries()][0]; edit('보리'); assert.equal(h.els['flow-title'].textContent, '짐을 푸는 중…'); assert.deepEqual([...h.timers.entries()][0], unpackTimer, 'name edits preserve the exact unpacking timer'); assert.equal(h.tick(), 1500); assert.equal(h.reveals, 1); assert.equal(h.ui.isBlocked(), false); }); test('sample copy uses its nickname across both themes and never changes the stored name', () => { for (const theme of ['wood', 'booth']) { const h = harness(); h.els['entry-pet-name'].value = '호롱'; h.els['entry-pet-name'].dispatch('input'); h.els['theme-select'].value = theme; h.els['theme-select'].dispatch('change'); h.ui.setMediaOrigin('sample'); h.ui.setPhoto('blob:sample'); h.ui.startJourney(); assert.match(h.els['flow-title'].textContent, /누렁이/); assert.doesNotMatch(h.els['flow-title'].textContent, /호롱/); h.ui.setState('ready'); assert.match(h.els['flow-title'].textContent, /누렁이/); assert.equal(h.els['settings-pet-name'].value, '호롱'); h.ui.setMediaOrigin('personal'); h.ui.setPhoto('blob:own'); h.ui.startJourney(); assert.match(h.els['flow-title'].textContent, /호롱이/); } }); test('sample return cancels arrival timers and keeps landing focus even after stale callbacks', () => { const h = harness(); h.ui.setMediaOrigin('sample'); h.ui.setPhoto('blob:sample'); h.ui.startJourney(); h.ui.setState('ready'); const stale = [...h.timers.values()][0].fn; h.els['sample-home'].dispatch('click');h.els['home-confirm'].dispatch('click'); assert.equal(h.timers.size, 0); stale(); assert.equal(h.timers.size, 0); assert.equal(h.els.experience.dataset.flow, 'entry'); assert.equal(h.els['flow-overlay'].hidden, true); assert.equal(h.els['canvas-wrap'].inert, true); assert.equal(h.root.activeElement, h.els['entry-photo-button']); assert.equal(h.reveals, 0); }); test('sample return during unpacking cancels reveal and preserves the selected name', () => { const h = harness(); h.els['entry-pet-name'].value='장준'; h.els['entry-pet-name'].dispatch('input'); h.ui.setMediaOrigin('sample'); h.ui.setPhoto('blob:sample'); h.ui.startJourney(); h.ui.setState('ready'); h.tick(); const stale = [...h.timers.values()][0].fn; h.els['sample-home'].dispatch('click');h.els['home-confirm'].dispatch('click'); stale(); assert.equal(h.els.experience.dataset.flow, 'entry'); assert.equal(h.els['flow-overlay'].hidden, true); assert.equal(h.els['entry-pet-name'].value, '장준'); assert.equal(h.els['entry-photo-button'].textContent, '장준이 사진 고르기'); assert.equal(h.timers.size, 0); assert.equal(h.reveals, 0); }); test('personal and sample journeys share identical loading and arrival templates', () => { for (const theme of ['wood','booth']) { const h=harness();h.els['entry-pet-name'].value='누렁이';h.els['entry-pet-name'].dispatch('input');h.els['theme-select'].value=theme;h.els['theme-select'].dispatch('change'); h.ui.setMediaOrigin('personal');h.ui.setPhoto('blob:own');h.ui.startJourney();const loading=h.els['flow-title'].textContent;h.ui.setState('ready');const arrived=h.els['flow-title'].textContent; h.ui.setMediaOrigin('sample');h.ui.setSampleName('누렁이');h.ui.setPhoto('blob:sample');h.ui.startJourney();assert.equal(h.els['flow-title'].textContent,loading);h.ui.setState('ready');assert.equal(h.els['flow-title'].textContent,arrived); } }); test('menu interrupts arrival without consuming first tutorial or leaving timers alive',()=>{ const h=harness();h.ui.setPhoto('blob:dog');h.ui.startJourney();h.ui.setState('ready'); assert.ok(h.timers.size>0);h.els['scene-menu-open'].dispatch('click'); assert.equal(h.els['scene-menu'].open,true);assert.equal(h.timers.size,0);assert.equal(h.reveals,0); h.els['scene-menu-close'].dispatch('click');assert.equal(h.ui.isBlocked(),false); });