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\)/); });