Files
behind_glass/scripts/test-peeking-ui.mjs
T
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

240 lines
15 KiB
JavaScript

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { readFile } from 'node:fs/promises';
import vm from 'node:vm';
import ts from 'typescript';
// DOM and segmentation are browser boundaries. Exercise the real UI controller;
// do not load a network model or claim this is rendered-browser verification.
class Element {
constructor(tag, owner) {
this.tagName = tag.toUpperCase(); this.ownerDocument = owner;
this.children = []; this.attributes = {}; this.listeners = {};
this.hidden = false; this.disabled = false; this.open = false;
this.value = ''; this.textContent = ''; this.files = []; this.dataset = {};
this.connected = false; this.clicks = 0;
}
get isConnected() { return this.connected || Boolean(this.parentElement?.isConnected); }
set isConnected(value) { this.connected = value; }
append(...children) { for (const child of children) { child.parentElement = this; child.isConnected = this.isConnected; this.children.push(child); } }
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return this.attributes[name] ?? null; }
removeAttribute(name) { delete this.attributes[name]; if (name === 'src') this.src = ''; }
addEventListener(type, fn, { signal } = {}) {
if (signal?.aborted) return;
(this.listeners[type] ??= []).push(fn);
signal?.addEventListener('abort', () => this.removeEventListener(type, fn), { once: true });
}
removeEventListener(type, fn) { this.listeners[type] = (this.listeners[type] ?? []).filter(listener => listener !== fn); }
dispatch(type, extra = {}) {
const event = { type, target: this, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; }, stopPropagation() { this.stopped = true; }, ...extra };
for (const fn of [...this.listeners[type] ?? []]) fn(event);
return event;
}
click() { if (!this.disabled) { this.clicks++; this.dispatch('click'); } }
focus() { this.ownerDocument.activeElement = this; }
showModal() { assert.equal(this.open, false); this.open = true; }
close() { this.open = false; this.dispatch('close'); }
remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter(child => child !== this); this.parentElement = null; this.connected = false; }
querySelector(selector) { return this.querySelectorAll(selector)[0] ?? null; }
querySelectorAll(selector) {
const matches = el => selector.startsWith('#') ? el.id === selector.slice(1) : el.tagName.toLowerCase() === selector;
return this.children.flatMap(child => [...(matches(child) ? [child] : []), ...child.querySelectorAll(selector)]);
}
}
const tick = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); };
function candidate(name = 'first', warnings = []) {
let disposed = false;
return { originalUrl: `blob:${name}-original`, cutoutUrl: `blob:${name}-cutout`, warnings, requiresReview: true,
get disposed() { return disposed; }, dispose() { disposed = true; } };
}
async function harness({ importFailure = false } = {}) {
const root = { activeElement: null };
root.createElement = tag => new Element(tag, root);
root.body = root.createElement('body'); root.body.isConnected = true;
root.querySelector = selector => root.body.querySelector(selector);
const settings = root.createElement('dialog'); settings.id = 'settings-dialog'; settings.open = true;
const panel = root.createElement('section'); panel.id = 'panel-photo';
const internal = root.createElement('div'); internal.id = 'internal-controls'; internal.hidden = true; internal.inert = true; root.body.append(internal);
const originalInput = root.createElement('input'); originalInput.id = 'image';
root.body.append(settings); settings.append(panel); panel.append(originalInput);
const preparations = [], requests = [], accepted = [], toggled = [];
let imports = 0;
class Preparation {
constructor() { this.results = new Set(); preparations.push(this); }
prepare(file, onProgress, signal) {
const request = { file, onProgress, signal, preparation: this };
requests.push(request);
return new Promise((resolve, reject) => {
request.resolve = result => { this.results.add(result); resolve(result); };
request.reject = reject;
});
}
cancel() { this.cancelled = true; }
dispose() { this.disposed = true; this.cancel(); for (const result of this.results) result.dispose(); }
}
const exports = {};
const source = await readFile(new URL('../src/ui/PeekingExperimentUI.ts', import.meta.url), 'utf8');
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, {
exports, AbortController, Error, DOMException,
require(name) { assert.match(name, /CutoutPreparation$/); imports++; if (importFailure) throw Error('module unavailable'); return { CutoutPreparation: Preparation }; },
});
const abort = new AbortController();
const ui = exports.initPeekingExperimentUI(root, abort.signal, {
onAccept(result) { assert.equal(root.querySelector('#peeking-preview-dialog').open, false, 'close preview before main changes settings/loading'); accepted.push(result); },
onToggle(enabled) { toggled.push(enabled); },
});
const get = id => { const element = root.querySelector(`#${id}`); assert.ok(element, id); return element; };
const select = async name => { const picker = get('peeking-photo'); picker.files = [{ name, type: 'image/png' }]; picker.value = name; picker.dispatch('change'); await tick(); };
return { root, settings, panel, internal, originalInput, ui, abort, get, select, preparations, requests, accepted, toggled, get imports() { return imports; } };
}
test('retired experiment stays outside user settings in hidden inert internal controls', async () => {
const h = await harness();
assert.equal(h.imports, 0); assert.equal(h.preparations.length, 0);
assert.equal(h.get('peeking-experiment').parentElement, h.internal);
assert.equal(h.internal.hidden,true);assert.equal(h.internal.inert,true);
assert.equal(h.panel.querySelector('#peeking-experiment'),null);
assert.equal(h.originalInput.listeners.change, undefined);
assert.equal(h.get('peeking-choose').textContent, '마당에서 빼꼼 · 실험');
assert.equal(h.get('peeking-toggle').hidden, true);
assert.equal(h.get('peeking-photo').type, 'file'); assert.equal(h.get('peeking-photo').hidden, true);
h.get('peeking-choose').click(); assert.equal(h.get('peeking-photo').clicks, 1); assert.equal(h.imports, 0);
assert.equal(h.settings.open, true);
assert.match(h.get('peeking-limitations').textContent, /털/);
assert.match(h.get('peeking-limitations').textContent, /배경/);
assert.match(h.get('peeking-limitations').textContent, /자세/);
assert.match(h.get('peeking-limitations').textContent, /옆/);
});
test('selection stays in settings, shows progress and review, and requires explicit acceptance', async () => {
const h = await harness(); await h.select('dog.png');
assert.equal(h.imports, 1); assert.equal(h.requests.length, 1);
assert.equal(h.settings.open, true); assert.equal(h.get('peeking-preview-dialog').open, true);
assert.equal(h.get('peeking-accept').disabled, true);
assert.equal(h.get('peeking-photo').value, '', 'same file can be selected again');
assert.equal(h.get('peeking-progress').getAttribute('role'), 'status');
h.requests[0].onProgress('누끼 모델을 준비하는 중…');
assert.equal(h.get('peeking-progress').textContent, '누끼 모델을 준비하는 중…');
h.requests[0].resolve(candidate()); await tick();
assert.equal(h.accepted.length, 0); assert.equal(h.get('peeking-accept').disabled, false);
assert.equal(h.get('peeking-original').src, 'blob:first-original');
assert.equal(h.get('peeking-cutout').src, 'blob:first-cutout');
assert.equal(h.get('peeking-preview-dialog').getAttribute('aria-busy'), 'false');
h.get('peeking-accept').click(); assert.equal(h.accepted.length, 1);
h.get('peeking-accept').click(); assert.equal(h.accepted.length, 1, 'duplicate accept is inert');
});
test('accepted ownership survives UI cancel, replacement and teardown until caller releases it', async () => {
const h = await harness(); await h.select('dog.png');
const result = candidate(); h.requests[0].resolve(result); await tick(); h.get('peeking-accept').click();
h.ui.cancel(); assert.equal(result.disposed, false);
await h.select('other.png'); h.ui.dispose();
assert.equal(result.disposed, false); assert.equal(h.preparations[0].disposed, undefined);
h.accepted[0].dispose(); assert.equal(result.disposed, true); assert.equal(h.preparations[0].disposed, true);
h.accepted[0].dispose();
});
test('Escape aborts pending work, keeps settings open, restores focus, and disposes late results', async () => {
const h = await harness(); h.get('peeking-choose').focus(); await h.select('dog.png');
const event = h.get('peeking-preview-dialog').dispatch('cancel');
assert.equal(event.defaultPrevented, true); assert.equal(h.requests[0].signal.aborted, true);
assert.equal(h.settings.open, true); assert.equal(h.get('peeking-preview-dialog').open, false);
assert.equal(h.root.activeElement?.id, 'peeking-choose');
const late = candidate('late'); h.requests[0].resolve(late); await tick();
assert.equal(late.disposed, true); assert.equal(h.accepted.length, 0);
});
test('replacement aborts old work and ignores stale progress and errors', async () => {
const h = await harness(); await h.select('first.png'); await h.select('second.png');
assert.equal(h.requests[0].signal.aborted, true);
const status = h.get('peeking-progress').textContent;
h.requests[0].onProgress('stale'); h.requests[0].reject(Error('old error')); await tick();
assert.equal(h.get('peeking-progress').textContent, status);
const result = candidate('second'); h.requests[1].resolve(result); await tick();
assert.equal(h.get('peeking-cutout').src, result.cutoutUrl);
h.ui.cancel(); assert.equal(result.disposed, true);
assert.equal(h.get('peeking-original').src, ''); assert.equal(h.get('peeking-cutout').src, '');
});
test('errors expose same-photo retry and another-photo selection without changing the scene', async () => {
const h = await harness(); await h.select('dog.png');
h.requests[0].reject(Error('모델을 불러오지 못했어요.')); await tick();
assert.match(h.get('peeking-error').textContent, /모델/); assert.equal(h.get('peeking-error').hidden, false);
assert.equal(h.get('peeking-error').getAttribute('role'), 'alert');
assert.equal(h.get('peeking-retry').hidden, false); assert.equal(h.get('peeking-accept').disabled, true);
h.get('peeking-retry').click(); await tick(); assert.equal(h.requests.length, 2); assert.equal(h.requests[1].file.name, 'dog.png');
h.requests[1].resolve(candidate('retry', ['multiple-foreground', 'tiny-foreground'])); await tick();
assert.match(h.get('peeking-warning').textContent, /여러/); assert.match(h.get('peeking-warning').textContent, /작/);
h.get('peeking-another').click(); assert.equal(h.get('peeking-photo').clicks, 1);
assert.equal(h.preparations[1].disposed, true); assert.equal(h.accepted.length, 0);
});
test('toggle only invokes explicit route changes while an accepted image is available and idle', async () => {
const h = await harness(); h.get('peeking-toggle').click(); assert.deepEqual(h.toggled, []);
h.ui.setState({ available: true, enabled: true, busy: false });
assert.equal(h.get('peeking-toggle').hidden, false); assert.equal(h.get('peeking-toggle').textContent, '원본 사진으로 돌아가기');
h.get('peeking-toggle').click(); assert.deepEqual(h.toggled, [false]);
h.ui.setState({ available: true, enabled: false, busy: false });
assert.equal(h.get('peeking-toggle').textContent, '마당 실험 다시 보기'); h.get('peeking-toggle').click();
assert.deepEqual(h.toggled, [false, true]);
h.ui.setState({ available: true, enabled: true, busy: true });
assert.equal(h.get('peeking-toggle').disabled, true); assert.equal(h.get('peeking-choose').disabled, true);
h.ui.setState({ available: false, enabled: false, busy: false }); assert.equal(h.get('peeking-toggle').hidden, true);
});
test('parent settings close, lifetime abort, and main cancellation release previews', async () => {
for (const action of ['settings', 'signal', 'main', 'native-close']) {
const h = await harness(); await h.select('dog.png'); const result = candidate(); h.requests[0].resolve(result); await tick();
if (action === 'settings') h.settings.close();
if (action === 'signal') h.abort.abort();
if (action === 'main') h.ui.cancel();
if (action === 'native-close') h.get('peeking-preview-dialog').close();
assert.equal(result.disposed, true, action); assert.equal(h.accepted.length, 0);
if (action === 'signal') { assert.equal(h.root.querySelector('#peeking-experiment'), null); assert.equal(h.root.querySelector('#peeking-preview-dialog'), null); }
else assert.equal(h.get('peeking-preview-dialog').open, false);
}
});
test('module load failure is recoverable and selecting after settings closes does not start work', async () => {
const h = await harness({ importFailure: true }); await h.select('dog.png');
assert.equal(h.get('peeking-error').hidden, false); assert.equal(h.get('peeking-retry').hidden, false);
h.ui.cancel(); h.settings.close(); await h.select('late-file.png');
assert.equal(h.imports, 1); assert.equal(h.get('peeking-preview-dialog').open, false);
});
test('cancelling before the lazy module settles creates no preparation or result', async () => {
const h = await harness();
const picker = h.get('peeking-photo');
picker.files = [{ name: 'dog.png', type: 'image/png' }];
picker.dispatch('change'); h.ui.cancel(); await tick();
assert.equal(h.preparations.length, 0); assert.equal(h.accepted.length, 0);
assert.equal(h.get('peeking-preview-dialog').open, false);
});
test('queued close events from an earlier review cannot cancel a newly opened preview', async () => {
const h = await harness(); await h.select('first.png'); await h.select('second.png');
h.get('peeking-preview-dialog').dispatch('close');
assert.equal(h.requests[1].signal.aborted, false);
h.requests[1].resolve(candidate('second')); await tick();
assert.equal(h.get('peeking-preview-dialog').open, true);
assert.equal(h.get('peeking-cutout').src, 'blob:second-cutout');
h.ui.cancel();
});
test('empty picker changes and retained controls after disposal cannot start work', async () => {
const h = await harness(); const picker = h.get('peeking-photo'); const choose = h.get('peeking-choose');
picker.files = []; picker.dispatch('change'); await tick();
assert.equal(h.imports, 0);
h.ui.dispose(); picker.files = [{ name: 'late.png' }]; picker.dispatch('change'); choose.click(); await tick();
assert.equal(h.imports, 0); assert.equal(h.root.querySelector('#peeking-experiment'), null);
});
test('dedicated CSS supplies checkerboard, responsive containment, keyboard focus and touch targets', async () => {
const css = await readFile(new URL('../src/ui/peeking-experiment.css', import.meta.url), 'utf8');
assert.match(css, /repeating-conic-gradient|linear-gradient/);
assert.match(css, /object-fit:\s*contain/);
assert.match(css, /:focus-visible/); assert.match(css, /min-height:\s*44px/);
assert.match(css, /@media\s*\(max-width:\s*480px\)/);
});