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.
70 lines
5.8 KiB
JavaScript
70 lines
5.8 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import vm from 'node:vm';
|
|
import ts from 'typescript';
|
|
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
|
|
// These requirements fail on the prior developer-oriented shell.
|
|
assert.match(html, /id="tool-tabs"[^>]*role="tablist"/, 'consumer tools use an accessible tab list');
|
|
assert.match(html, /<details\b[^>]*id="developer-tools"(?![^>]*\bopen)[^>]*>/, 'diagnostics are collapsed by default');
|
|
assert.ok(html.indexOf('id="reference-strip"') > html.indexOf('id="developer-tools"'), 'references live in developer tools');
|
|
for (const value of ['2','0','1']) assert.match(html, new RegExp(`data-pattern="${value}"`));
|
|
for (const id of ['image','test-photo','sample','ai-depth','depth-provenance','status','grid-size','curvature','photo-glass-distance','camera-offset','photo-zoom','photo-pan-x','photo-pan-y','reset','gyro','depth-image','video','effect','view-mode','depth-scale','light-x','light-y','sensitivity','depth-inspection']) {
|
|
assert.equal([...html.matchAll(new RegExp(`\\bid="${id}"`,'g'))].length, 1, `${id} stays available exactly once`);
|
|
}
|
|
for (const [,attrs,body] of html.matchAll(/<label\b([^>]*)>([\s\S]*?)<\/label>/g)) {
|
|
const controlId=body.match(/<(?:input|select)\b[^>]*\bid="([^"]+)"/)?.[1];
|
|
if (controlId && body.includes('<output')) assert.ok(attrs.includes(`for="${controlId}"`), `${controlId} has an explicit label instead of labeling its output`);
|
|
}
|
|
class Element {
|
|
constructor(data={}) {this.dataset=data;this.attributes={};this.listeners={};this.hidden=false;this.value='';this.tabIndex=0;}
|
|
setAttribute(key,value){this.attributes[key]=value;}
|
|
getAttribute(key){return this.attributes[key];}
|
|
addEventListener(key,fn){(this.listeners[key]??=[]).push(fn);}
|
|
dispatchEvent(event){for(const fn of this.listeners[event.type]??[])fn(event);}
|
|
focus(){this.focused=true;}
|
|
}
|
|
const tabs=['glass','photo','view'].map(name=>new Element({tool:name}));
|
|
const panels=tabs.map(tab=>new Element({panel:tab.dataset.tool}));
|
|
const cards=['2','0','1'].map(pattern=>new Element({pattern}));
|
|
const select=new Element();select.value='2';
|
|
const effect=new Element();effect.value='mosaic';
|
|
const cardGroup=new Element();
|
|
const document={querySelectorAll:query=>query==='[data-tool]'?tabs:query==='[data-panel]'?panels:cards,querySelector:query=>({'#mosaic-style':select,'#effect':effect,'#pattern-cards':cardGroup}[query])};
|
|
const source=await readFile(new URL('../src/ui/PhotoBoothUI.ts',import.meta.url),'utf8');
|
|
const module={exports:{}};
|
|
vm.runInNewContext(ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText,{exports:module.exports,Event});
|
|
module.exports.initPhotoBoothUI(document,new AbortController().signal);
|
|
const click=element=>element.dispatchEvent({type:'click'});
|
|
assert.equal(tabs[0].attributes['aria-selected'],'true');assert.deepEqual(panels.map(p=>p.hidden),[false,true,true]);
|
|
click(tabs[2]);assert.deepEqual(panels.map(p=>p.hidden),[true,true,false]);
|
|
assert.deepEqual(tabs.map(t=>t.tabIndex),[-1,-1,0]);
|
|
let prevented=false;tabs[2].dispatchEvent({type:'keydown',key:'ArrowRight',preventDefault(){prevented=true;}});
|
|
assert.equal(tabs[0].focused,true);assert.equal(prevented,true);
|
|
tabs[0].dispatchEvent({type:'keydown',key:'End',preventDefault(){}});assert.equal(tabs[2].attributes['aria-selected'],'true');
|
|
tabs[2].dispatchEvent({type:'keydown',key:'Home',preventDefault(){}});assert.equal(tabs[0].attributes['aria-selected'],'true');
|
|
let changes=0;select.addEventListener('change',()=>changes++);
|
|
click(cards[0]);assert.equal(changes,0,'reselecting the current pattern must not reset its custom grid');
|
|
for (const card of [cards[1],cards[2],cards[0],cards[1]]) {click(card);assert.equal(select.value,card.dataset.pattern);assert.equal(card.attributes['aria-pressed'],'true');assert.equal(cards.filter(c=>c.attributes['aria-pressed']==='true').length,1);}
|
|
assert.equal(changes,4);
|
|
select.value='1';select.dispatchEvent(new Event('change'));assert.equal(cards[2].attributes['aria-pressed'],'true');
|
|
effect.value='frosted';effect.dispatchEvent(new Event('change'));assert.equal(cardGroup.hidden,true);
|
|
effect.value='mosaic';effect.dispatchEvent(new Event('change'));assert.equal(cardGroup.hidden,false);
|
|
const css=await readFile(new URL('../src/style.css',import.meta.url),'utf8');
|
|
assert.match(css,/prefers-reduced-motion:\s*reduce/);
|
|
assert.match(css,/:focus-visible/);
|
|
assert.match(css,/min-height:\s*44px/);
|
|
// Source-level palette checks, not a claim about computed styles or rendered UI.
|
|
const ruleColor=(selector,property)=>{
|
|
const start=css.indexOf(`${selector} {`);assert.ok(start>=0,selector);
|
|
const rule=css.slice(start,css.indexOf('}',start));
|
|
const value=rule.match(new RegExp(`${property}:#([0-9a-f]{6})(?:;|\\b)`))?.[1];assert.ok(value,`${selector} ${property}`);return value;
|
|
};
|
|
const luminance=hex=>[0,2,4].map(i=>parseInt(hex.slice(i,i+2),16)/255).map(c=>c<=.04045?c/12.92:((c+.055)/1.055)**2.4).reduce((sum,c,i)=>sum+c*[.2126,.7152,.0722][i],0);
|
|
const contrast=(a,b)=>(Math.max(luminance(a),luminance(b))+.05)/(Math.min(luminance(a),luminance(b))+.05);
|
|
assert.ok(contrast(ruleColor('.pattern-detail','color'),ruleColor('.pattern-card[aria-pressed=true]','background'))>=4.5,'selected card detail contrast');
|
|
assert.ok(contrast(ruleColor('.app-footer','color'),ruleColor(':root','background'))>=4.5,'footer contrast');
|
|
assert.match(css,/#canvas-wrap:focus-visible\s*\{\s*outline-offset:-4px/,'preview focus ring stays inside the clipping edge');
|
|
assert.match(css,/@media\(max-width:850px\)/,'tablet/phone stack breakpoint');
|
|
assert.match(css,/@media\(max-width:480px\)/,'compact phone styling');
|
|
console.log('Photo booth UI passed: preserved controls, collapsed diagnostics, card synchronization, repeat selection, effect visibility, tab keyboard navigation, reduced-motion/focus/touch rules, palette contrast and responsive source rules (no layout engine)');
|