Files
behind_glass/scripts/test-car-journey.mjs
horoli e358e24436 Replace project with current server release and Windows setup
Import the 2026-10-09 source release, Fastify/SQLite backend, restored source
assets, and analytics-enabled server build. Replace files from the previous
release while retaining the existing repository history.

Make builds portable across Windows and Linux, configure local startup,
normalize Windows static asset paths, and fix test fixture cleanup and file
URL handling. Exclude local environment settings, databases, dependencies,
validation output, and TypeScript build cache from version control.

Validation: npm ci, static and server builds, 106 arrival tests, and actual
local HTTP checks passed. The full suite passed 467 of 470 tests; three tests
still require QA fixtures omitted from the supplied release, documented in
README-서버실행.md.
2026-10-09 16:43:35 +09:00

299 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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|<progress|role="progressbar"/);
assert.match(css, /\.journey-passenger[\s\S]*?object-fit:contain/);
assert.match(css, /data-journey=driving/); assert.match(css, /data-motion=paused/);
assert.match(css, /prefers-reduced-motion:reduce/);
});
test('short wide layouts keep the full photo passenger above the road without clipping', async () => {
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);
});