import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { loadTypescript } from './support/load-typescript.mjs';
const html = await readFile(new URL('../index.html', import.meta.url),'utf8');
assert.match(html, /id="settings-dialog"/, 'all options must be in a dismissible dialog, never a persistent sidebar');
const themes=await loadTypescript(new URL('../src/ui/themes.ts',import.meta.url));
const {initExperienceUI}=await loadTypescript(new URL('../src/ui/ExperienceUI.ts',import.meta.url));
class Element {
dataset={};attributes={};listeners={};value='';hidden=false;open=false;inert=false;isConnected=true;
addEventListener(type,fn,{signal}={}){(this.listeners[type]??=[]).push(fn);signal?.addEventListener('abort',()=>this.listeners[type]=this.listeners[type].filter(f=>f!==fn));}
dispatch(type,extra={}){for(const fn of this.listeners[type]??[])fn({target:this,...extra});}
setAttribute(key,value){this.attributes[key]=value;}
focus(){this.owner.activeElement=this;}
showModal(){this.open=true;}
close(){this.open=false;this.dispatch('close');}
getBoundingClientRect(){return {left:20,right:300,top:20,bottom:500};}
}
function harness(value=null,{readError=false,writeError=false}={}){
const values=new Map(value===null?[]:[[themes.THEME_STORAGE_KEY,value]]);let writes=0;
const root=new Element();root.documentElement=new Element();root.activeElement=null;
const ids=['home-dialog','home-confirm','home-cancel','photo-adjust-open','photo-adjust-panel','photo-adjust-done','scene-menu','scene-menu-open','scene-menu-close','scene-guide-replay','scene-zoom-reset','comparison-exit','first-guide','export-open','export-dialog','credits-open','credits-close','credits-dialog','entry-notices','entry-pet-name','settings-pet-name','entry-name-notice','settings-name-notice','welcome-name','entry-photo-button','image','pet-stage','experience','welcome','flow-overlay','flow-title','flow-detail','flow-photo','journey','journey-motion','flow-actions','flow-retry','flow-flat','settings-dialog','settings-open','settings-close','entry-samples','sample-browser','sample-dialog','sample-close','sample-confirm','flow-samples','theme-select','canvas-wrap','scene-toolbar','scene-photo-change','sample-home','entry-name-step','entry-photo-step','entry-name-next','entry-name-skip','entry-name-edit'];
const els=Object.fromEntries(ids.map(id=>[id,new Element()]));Object.values(els).forEach(e=>e.owner=root);root.querySelector=s=>els[s.slice(1)]??null;
const choices=['dog5','dog1','dog2','dog3','dog4'].map(id=>{const e=new Element();e.owner=root;e.dataset.sampleChoice=id;return e;});root.querySelectorAll=()=>choices;
root.defaultView={get localStorage(){if(readError)throw new Error('blocked');return{getItem:key=>values.get(key)??null,setItem(key,value){if(writeError)throw new Error('full');values.set(key,value);writes++;}};}};
let homeCalls=0,arrivalEnds=0;const samples=[];const themesSeen=[];const abort=new AbortController();const ui=initExperienceUI(root,abort.signal,theme=>themesSeen.push(theme),()=>{arrivalEnds++;},sample=>samples.push(sample), undefined, ()=>homeCalls++);
return {root,els,choices,samples,ui,themesSeen,values,abort,get writes(){return writes;},get homeCalls(){return homeCalls;},get arrivalEnds(){return arrivalEnds;}};
}
for(const [value,expected] of [[null,'wood'],['wood','wood'],['booth','booth'],['invalid','wood'],['','wood']]){const h=harness(value);assert.equal(h.root.documentElement.dataset.theme,expected);assert.equal(h.els['theme-select'].value,expected);assert.equal(h.themesSeen.at(-1),expected);assert.equal(h.els['settings-dialog'].open,false);assert.equal(h.els.experience.dataset.flow,'entry');assert.equal(h.ui.isBlocked(),true);}
for(const errors of [{readError:true},{writeError:true},{}]){const h=harness(null,errors);h.els['theme-select'].value='booth';h.els['theme-select'].dispatch('change');assert.equal(h.root.documentElement.dataset.theme,'booth');assert.equal(h.themesSeen.at(-1),'booth');h.els['theme-select'].dispatch('change');assert.equal(h.themesSeen.length,2,'reselect causes no additional appearance work');}
const h=harness();h.els['theme-select'].value='booth';h.els['theme-select'].dispatch('change');assert.equal(h.values.get(themes.THEME_STORAGE_KEY),'booth');assert.equal(harness(h.values.get(themes.THEME_STORAGE_KEY)).els['theme-select'].value,'booth');
h.ui.setPhoto('blob:photo');h.ui.setState('loading');assert.equal(h.els['flow-photo'].src,'blob:photo');assert.equal(h.els.experience.dataset.flow,'loading');assert.equal(h.els['flow-retry'].hidden,true);assert.equal(h.ui.isBlocked(),true);
h.ui.setDetail('model download');assert.equal(h.els['flow-detail'].textContent,'model download');
h.ui.setState('error',true);assert.equal(h.els.experience.dataset.flow,'error');assert.equal(h.els['flow-retry'].hidden,false);assert.equal(h.els['flow-flat'].hidden,false);
h.ui.setState('ready');assert.equal(h.els['flow-overlay'].hidden,true);assert.equal(h.els.welcome.hidden,true);assert.equal(h.ui.isBlocked(),false);
h.ui.setRenderLost(true);assert.equal(h.ui.isBlocked(),true);assert.equal(h.els['canvas-wrap'].inert,true);
h.ui.setState('ready');assert.equal(h.ui.isBlocked(),true,'a late ready state cannot bypass render recovery');
h.ui.setRenderLost(false);assert.equal(h.ui.isBlocked(),false);assert.equal(h.els['canvas-wrap'].inert,false);
h.els['settings-open'].focus();h.els['settings-open'].dispatch('click');assert.equal(h.els['settings-dialog'].open,true);assert.equal(h.ui.isBlocked(),true);assert.equal(h.els['canvas-wrap'].inert,true);
h.els['settings-dialog'].dispatch('cancel',{preventDefault(){}});assert.equal(h.els['settings-dialog'].open,false);assert.equal(h.root.activeElement,h.els['scene-menu-open']);assert.equal(h.els['canvas-wrap'].inert,false);
h.root.activeElement=null;h.els['settings-open'].dispatch('click');h.els['settings-dialog'].dispatch('click',{clientX:10,clientY:10});assert.equal(h.els['settings-dialog'].open,false);assert.equal(h.root.activeElement,h.els['scene-menu-open'],'pointer browsers that do not focus buttons still restore to the opener');
h.els['entry-samples'].focus();h.els['entry-samples'].dispatch('click');assert.equal(h.els['sample-dialog'].open,true);assert.equal(h.els['settings-dialog'].open,false);h.els['sample-close'].dispatch('click');assert.equal(h.root.activeElement,h.els['entry-samples']);
h.els['settings-open'].dispatch('click');h.ui.setState('loading');assert.equal(h.els['settings-dialog'].open,false,'new media closes the options');assert.equal(h.ui.isBlocked(),true);
h.els['entry-samples'].dispatch('click');
assert.equal(h.choices[0].attributes['aria-pressed'],'true');
h.choices[2].dispatch('click');assert.equal(h.choices[2].attributes['aria-pressed'],'true');assert.equal(h.choices[0].attributes['aria-pressed'],'false');assert.equal(h.samples.length,0,'selection must never start the experience');
h.els['sample-confirm'].dispatch('click');assert.deepEqual(h.samples,['dog2']);assert.equal(h.els['sample-dialog'].open,false);
h.els['entry-samples'].dispatch('click');h.els['sample-confirm'].dispatch('click');assert.deepEqual(h.samples,['dog2','dog2'],'same photo can be reselected');
h.ui.setState('error');assert.equal(h.els['flow-samples'].hidden,false);h.els['flow-samples'].dispatch('click');assert.equal(h.els['sample-dialog'].open,true);h.els['sample-dialog'].dispatch('cancel',{preventDefault(){}});assert.equal(h.root.activeElement,h.els['flow-samples']);
h.els['flow-samples'].dispatch('click');h.ui.setRenderLost(true);assert.equal(h.els['sample-dialog'].open,false);h.els['flow-samples'].dispatch('click');assert.equal(h.els['sample-dialog'].open,false);h.ui.setRenderLost(false);
h.els['flow-samples'].dispatch('click');h.abort.abort();assert.equal(h.els['sample-dialog'].open,false);const count=h.themesSeen.length;h.els['theme-select'].value='wood';h.els['theme-select'].dispatch('change');assert.equal(h.themesSeen.length,count,'teardown removes theme handlers');
console.log('Experience UI: default/persisted/invalid/unavailable storage, no-op reselect, real-state flow, source preview, error recovery, modal dismiss/focus/inert, teardown passed (DOM boundary double, no visual QA)');
const named=harness();
const entryName=named.els['entry-pet-name'], settingsName=named.els['settings-pet-name'];
entryName.value='호롱';entryName.dispatch('input');
assert.equal(named.els['entry-photo-button'].textContent,'호롱이 사진 고르기','typing a name updates landing copy');
assert.equal(settingsName.value,'호롱','settings reflects the same app state');
assert.equal(named.els['welcome-name'].textContent,'호롱이가 왔어요');
named.ui.startJourney();assert.equal(named.els['flow-title'].textContent,'호롱이를 할머니 집으로 데려가는 중…');
settingsName.value='초코';settingsName.dispatch('input');
assert.equal(named.els['flow-title'].textContent,'초코를 할머니 집으로 데려가는 중…');
assert.equal(entryName.value,'초코');
settingsName.dispatch('compositionstart');settingsName.value='보';settingsName.dispatch('input',{isComposing:true});
assert.equal(named.els['entry-photo-button'].textContent,'초코 사진 고르기','IME intermediate input does not update state');
settingsName.value='보리';settingsName.dispatch('compositionend');
assert.equal(named.els['entry-photo-button'].textContent,'보리 사진 고르기');
settingsName.value=' ';settingsName.dispatch('change');
assert.equal(settingsName.value,'
');assert.equal(named.els['entry-photo-button'].textContent,'
사진 고르기');
named.ui.setMediaOrigin('sample');named.ui.startJourney();
assert.equal(named.els['flow-title'].textContent,'누렁이를 할머니 집으로 데려가는 중…');
assert.equal(settingsName.value,'
','sample never overwrites the saved name');
named.ui.setMediaOrigin('personal');named.ui.startJourney();
assert.equal(named.els['flow-title'].textContent,'
를 할머니 집으로 데려가는 중…');
settingsName.value=' ';settingsName.dispatch('change');assert.equal(named.els['entry-photo-button'].textContent,'멍멍이 사진 고르기');
const blockedName=harness(null,{writeError:true});blockedName.els['entry-pet-name'].value='호롱';blockedName.els['entry-pet-name'].dispatch('input');
assert.equal(blockedName.els['entry-name-notice'].hidden,false);assert.equal(blockedName.els['settings-name-notice'].hidden,false);
assert.equal(blockedName.els['entry-photo-button'].textContent,'호롱이 사진 고르기');
console.log('Pet name UI: live shared copy, Korean IME, literal HTML-like names, sample origin, clearing, storage warning passed.');
const longName = harness(); longName.root.activeElement = longName.els['entry-pet-name'];
longName.els['entry-pet-name'].value = '가'.repeat(30); longName.els['entry-pet-name'].dispatch('input');
assert.equal(longName.els['entry-pet-name'].value, '가'.repeat(20), 'focused field enforces the advertised name length after completed input');
for (const [root, unavailable] of [[named, false], [blockedName, true]]) {
for (const prefix of ['entry', 'settings']) {
const description = root.els[`${prefix}-pet-name`].attributes['aria-describedby'];
assert.equal(description, `${prefix}-name-help${unavailable ? ` ${prefix}-name-notice` : ''}`, 'screen-reader descriptions include storage failure only when true');
}
}
const sampleHome = harness();
sampleHome.ui.setMediaOrigin('sample'); sampleHome.ui.setPhoto('blob:sample'); sampleHome.ui.setState('ready');
assert.equal(sampleHome.els['sample-home'].hidden, false);
assert.equal(sampleHome.els['scene-photo-change'].hidden, true);
assert.equal(sampleHome.els['settings-open'].hidden, true);
sampleHome.els['settings-open'].dispatch('click');
assert.equal(sampleHome.els['settings-dialog'].open, false, 'sample settings cannot be opened by stale clicks');
sampleHome.els['sample-home'].dispatch('click');sampleHome.els['home-confirm'].dispatch('click');
assert.equal(sampleHome.homeCalls, 1, 'home invokes media cancellation callback once');
assert.equal(sampleHome.els.experience.dataset.flow, 'entry');
assert.equal(sampleHome.els.welcome.hidden, false);
assert.equal(sampleHome.els['sample-home'].hidden, true);
assert.equal(sampleHome.els['settings-open'].hidden, true);assert.equal(sampleHome.els['credits-open'].hidden,false);
assert.equal(sampleHome.els['flow-photo'].src, '');
assert.equal(sampleHome.root.activeElement, sampleHome.els['entry-photo-button']);
sampleHome.ui.setMediaOrigin('personal'); sampleHome.ui.setState('ready');
assert.equal(sampleHome.els['scene-photo-change'].hidden, false);
assert.equal(sampleHome.els['sample-home'].hidden, false);
assert.match(html, /placeholder="예시 : 장준"/);
assert.doesNotMatch(html, /placeholder="예: 호롱"|창문 열기/);
console.log('Sample return: own-photo controls restored, sample settings gated, landing focus and placeholder verified.');
for (const state of ['loading', 'error']) {
const h = harness(); h.ui.setMediaOrigin('sample'); h.ui.setState(state);
assert.equal(h.els['sample-home'].hidden, false);
assert.equal(h.els['settings-open'].hidden, true);
assert.equal(h.els['scene-photo-change'].hidden, true);
h.els['sample-home'].dispatch('click');h.els['home-confirm'].dispatch('click');
assert.equal(h.homeCalls, 1);
assert.equal(h.els.experience.dataset.flow, 'entry');
}
const pasteGate=harness();
assert.equal(pasteGate.ui.canAcceptPhotoPaste(),false,'name step keeps focus on naming');pasteGate.els['entry-name-skip'].dispatch('click');assert.equal(pasteGate.ui.canAcceptPhotoPaste(),true,'photo step accepts paste');
pasteGate.ui.setMediaOrigin('sample');pasteGate.ui.setState('ready');
assert.equal(pasteGate.ui.canAcceptPhotoPaste(),false,'sample presentation cannot bypass hidden replace action');
pasteGate.els['sample-home'].dispatch('click');pasteGate.els['home-confirm'].dispatch('click');assert.equal(pasteGate.ui.canAcceptPhotoPaste(),true);
pasteGate.els['credits-open'].dispatch('click');assert.equal(pasteGate.ui.canAcceptPhotoPaste(),false);
pasteGate.els['credits-close'].dispatch('click');assert.equal(pasteGate.ui.canAcceptPhotoPaste(),true);
pasteGate.ui.setRenderLost(true);assert.equal(pasteGate.ui.canAcceptPhotoPaste(),false);
pasteGate.ui.setRenderLost(false);pasteGate.abort.abort();assert.equal(pasteGate.ui.canAcceptPhotoPaste(),false);
const step=harness();
assert.equal(step.els['entry-name-step'].hidden,false);
assert.equal(step.els['entry-photo-step'].hidden,true);assert.equal(step.els['entry-notices'].hidden,true);
step.els['entry-name-skip'].dispatch('click');
assert.equal(step.els['entry-name-step'].hidden,true);
assert.equal(step.els['entry-photo-step'].hidden,false);assert.equal(step.els['entry-notices'].hidden,false);
step.ui.setMediaOrigin('sample');step.ui.setState('ready');step.els['sample-home'].dispatch('click');step.els['home-confirm'].dispatch('click');
assert.equal(step.els['entry-photo-step'].hidden,false,'sample return skips already-completed name step');
step.els['entry-name-edit'].dispatch('click');assert.equal(step.els['entry-name-step'].hidden,false);
step.els['entry-pet-name'].value='장준';step.els['entry-pet-name'].dispatch('input');step.els['entry-name-next'].dispatch('click');
assert.equal(step.els['entry-photo-button'].textContent,'장준이 사진 고르기');
assert.equal(step.els['entry-photo-step'].hidden,false);assert.equal(step.els['entry-notices'].hidden,false);
const imeStep=harness();imeStep.els['entry-pet-name'].dispatch('compositionstart');imeStep.els['entry-pet-name'].value='누';
imeStep.els['entry-pet-name'].dispatch('keydown',{key:'Enter',isComposing:false,keyCode:229,preventDefault(){}});
assert.equal(imeStep.els['entry-name-step'].hidden,false,'IME commit Enter never advances or steals focus');
imeStep.els['entry-pet-name'].value='누렁';imeStep.els['entry-pet-name'].dispatch('compositionend');
imeStep.els['entry-pet-name'].dispatch('keydown',{key:'Enter',isComposing:false,keyCode:13,preventDefault(){}});
assert.equal(imeStep.els['entry-photo-step'].hidden,false);
const personalHome=harness();personalHome.els['entry-name-skip'].dispatch('click');personalHome.ui.setMediaOrigin('personal');personalHome.ui.setPhoto('blob:own');personalHome.ui.setState('ready');
assert.equal(personalHome.els['sample-home'].hidden,false,'personal scene also offers home');
personalHome.els['sample-home'].dispatch('click');personalHome.els['home-confirm'].dispatch('click');assert.equal(personalHome.homeCalls,1);assert.equal(personalHome.els.experience.dataset.flow,'entry');assert.equal(personalHome.els['entry-photo-step'].hidden,false);
const skipVisibility=harness();const nameField=skipVisibility.els['entry-pet-name'];
assert.equal(skipVisibility.els['entry-name-skip'].hidden,false);
nameField.value='장준';nameField.dispatch('input');assert.equal(skipVisibility.els['entry-name-skip'].hidden,true);
nameField.value=' ';nameField.dispatch('input');assert.equal(skipVisibility.els['entry-name-skip'].hidden,false);
nameField.dispatch('compositionstart');nameField.value='장';nameField.dispatch('input',{isComposing:true});assert.equal(skipVisibility.els['entry-name-skip'].hidden,true,'visible composing text hides skip without mutating stored name');
nameField.value='';nameField.dispatch('compositionend');assert.equal(skipVisibility.els['entry-name-skip'].hidden,false);
// Main entry has maker details instead of settings, without starting a photo flow.
const credits=harness();
assert.equal(credits.els['credits-open'].hidden,false);
assert.equal(credits.els['settings-open'].hidden,true);assert.equal(credits.els['scene-photo-change'].hidden,true);
credits.els['entry-name-skip'].dispatch('click');
credits.els['credits-open'].dispatch('click');
assert.equal(credits.els['credits-dialog'].open,true);
assert.equal(credits.ui.canAcceptPhotoPaste(),false);
credits.els['credits-dialog'].dispatch('cancel',{preventDefault(){}});
assert.equal(credits.els['credits-dialog'].open,false);
assert.equal(credits.root.activeElement,credits.els['credits-open']);
credits.els['credits-open'].dispatch('click');credits.ui.setState('loading');
assert.equal(credits.els['credits-dialog'].open,false);
assert.equal(credits.els['credits-open'].hidden,true);
credits.ui.setState('ready');assert.equal(credits.els['settings-open'].hidden,false);
credits.els['credits-open'].dispatch('click');assert.equal(credits.els['credits-dialog'].open,false);
credits.els['sample-home'].dispatch('click');credits.els['home-confirm'].dispatch('click');assert.equal(credits.els['credits-open'].hidden,false);
credits.els['credits-open'].dispatch('click');credits.abort.abort();assert.equal(credits.els['credits-dialog'].open,false);
assert.match(html, /id="scene-photo-change"[^>]*\bhidden\b/, 'entry markup must not reserve an invisible photo action before JS starts');
const creditsDismiss=harness();
creditsDismiss.els['credits-open'].dispatch('click');assert.equal(creditsDismiss.els['credits-dialog'].open,true,'maker works from name entry');
creditsDismiss.els['credits-open'].dispatch('click');assert.equal(creditsDismiss.els['credits-dialog'].open,true,'repeated click is safe');
creditsDismiss.els['credits-dialog'].dispatch('click',{clientX:100,clientY:100});assert.equal(creditsDismiss.els['credits-dialog'].open,true,'inside click remains open');
creditsDismiss.els['credits-dialog'].dispatch('click',{clientX:0,clientY:0});assert.equal(creditsDismiss.els['credits-dialog'].open,false,'backdrop closes');
creditsDismiss.els['credits-open'].dispatch('click');creditsDismiss.ui.setRenderLost(true);assert.equal(creditsDismiss.els['credits-dialog'].open,false);
creditsDismiss.els['credits-open'].dispatch('click');assert.equal(creditsDismiss.els['credits-dialog'].open,false,'render-loss guards reopen');
assert.match(html,/href="mailto:sunha321@gmail.com"/);assert.match(html,/