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.
308 lines
25 KiB
JavaScript
308 lines
25 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { createHash } from 'node:crypto';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { test } from 'node:test';
|
|
import vm from 'node:vm';
|
|
import ts from 'typescript';
|
|
import { loadTypescript } from './support/load-typescript.mjs';
|
|
|
|
const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8');
|
|
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
|
|
const css = await readFile(new URL('../src/style.css', import.meta.url), 'utf8');
|
|
const ast = ts.createSourceFile('main.ts', main, ts.ScriptTarget.Latest, true);
|
|
const statement = predicate => {
|
|
const found = ast.statements.find(node => predicate(node, node.getText(ast)));
|
|
assert.ok(found, 'required arrival lifecycle wiring is present');
|
|
return found.getText(ast);
|
|
};
|
|
const fn = name => statement(node => ts.isFunctionDeclaration(node) && node.name?.text === name);
|
|
const variable = name => statement(node => ts.isVariableStatement(node) && node.declarationList.declarations.some(declaration => declaration.name.getText(ast) === name));
|
|
const expression = prefix => statement((node, text) => ts.isExpressionStatement(node) && text.startsWith(prefix));
|
|
const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b; }); return { promise, resolve, reject }; };
|
|
const tick = async () => { for (let index = 0; index < 25; index++) await Promise.resolve(); };
|
|
|
|
// These source contracts supplement executed production handlers below. Removing
|
|
// the gate, moving arrival to generic ready, or awaiting telemetry breaks them.
|
|
test('collector construction is explicitly opt-in and is started once outside rendering', () => {
|
|
assert.match(variable('arrivalTracker'), /import\.meta\.env\.VITE_ARRIVAL_ANALYTICS === 'true'/);
|
|
assert.equal((main.match(/arrivalTracker\?\.start\(\)/g) ?? []).length, 1);
|
|
assert.doesNotMatch(fn('frame'), /arrivalTracker|\/api\/|makeDogId/);
|
|
assert.doesNotMatch(main, /await\s+arrivalTracker/);
|
|
});
|
|
test('AI success owns the only arrival call and captures a request-local token', () => {
|
|
const ai = fn('generateAIDepth');
|
|
assert.equal((main.match(/arrivalTracker\?\.arrived\(/g) ?? []).length, 1);
|
|
assert.match(ai, /const arrivalToken = arrivalJourneyFor\(sourceTexture\)/);
|
|
assert.match(ai, /!renderLost && !meshFailure && arrivalToken !== null && arrivalToken === photoArrivalToken/);
|
|
assert.ok(ai.indexOf('setDepth(texture)') < ai.indexOf('arrivalTracker?.arrived(arrivalToken)'));
|
|
assert.ok(ai.indexOf('arrivalTracker?.arrived(arrivalToken)') < ai.indexOf("experience.setState('ready')"));
|
|
assert.match(fn('teardown'), /arrivalTracker\?\.dispose\(\)/);
|
|
});
|
|
test('photo snapshot comes from the name store and survives retry without rereading the name', () => {
|
|
assert.match(fn('beginMediaLoad'), /petName\.name/);
|
|
assert.doesNotMatch(fn('arrivalJourneyFor'), /petName\.|querySelector|\.value/);
|
|
assert.match(fn('invalidateDepthRequests'), /cancelArrivalJourney\(\)/);
|
|
assert.match(fn('showMeshFailure'), /cancelArrivalJourney\(\)/);
|
|
assert.doesNotMatch(fn('generateAIDepth'), /beginMediaLoad|petNameForms/);
|
|
});
|
|
test('two initially hidden counts sit below entry controls and the settings heading', () => {
|
|
const nodes = [...html.matchAll(/<p\b[^>]*data-arrival-count[^>]*>/g)];
|
|
assert.equal(nodes.length, 2);
|
|
for (const [node] of nodes) { assert.match(node, /\bhidden\b/); assert.doesNotMatch(node, /aria-live|role=/); }
|
|
assert.match(html, /id="notice-status"[^>]*><\/span>\s*<\/div>\s*<p[^>]*data-arrival-count/);
|
|
assert.match(html, /id="settings-title"[\s\S]*?<\/div>\s*<p[^>]*data-arrival-count/);
|
|
assert.match(css, /\.arrival-count\s*\{[^}]*(?:font-size|line-height)/);
|
|
});
|
|
|
|
class Element {
|
|
dataset = {}; attributes = {}; listeners = new Map(); hidden = false; open = false; inert = false; value = ''; isConnected = true; style = {};
|
|
addEventListener(type, callback, { signal } = {}) { const set = this.listeners.get(type) ?? new Set(); set.add(callback); this.listeners.set(type, set); signal?.addEventListener('abort', () => set.delete(callback)); }
|
|
async dispatch(type, extra = {}) { for (const callback of [...(this.listeners.get(type) ?? [])]) await callback({ target: this, ...extra }); }
|
|
setAttribute(key, value) { this.attributes[key] = value; }
|
|
removeAttribute(key) { delete this.attributes[key]; }
|
|
focus() { if (!this.inert) this.root.activeElement = this; }
|
|
showModal() { this.open = true; }
|
|
close() { this.open = false; void this.dispatch('close'); }
|
|
getBoundingClientRect() { return { left: 0, top: 0, right: 400, bottom: 800 }; }
|
|
remove() {}
|
|
}
|
|
|
|
async function harness({ enabled = true, reduced = false, pendingHash = false, retryArrival = false, cryptoFailure = '', invalidInitialId = false, blockedStorage = false } = {}) {
|
|
// Assert the actual feature before importing its separate production modules.
|
|
const initialization = ['arrivalTracker', 'photoArrivalSnapshot', 'photoArrivalTexture', 'photoArrivalToken'].map(variable).join('\n');
|
|
const identity = await loadTypescript(new URL('../src/analytics/Identity.ts', import.meta.url));
|
|
const { createArrivalTracker } = await loadTypescript(new URL('../src/analytics/ArrivalTracker.ts', import.meta.url));
|
|
const { createTransport } = await loadTypescript(new URL('../src/analytics/Transport.ts', import.meta.url));
|
|
const { renderArrivalCount } = await loadTypescript(new URL('../src/ui/ArrivalCount.ts', import.meta.url));
|
|
const { createPetNameStore } = await loadTypescript(new URL('../src/state/PetName.ts', import.meta.url));
|
|
const { initExperienceUI } = await loadTypescript(new URL('../src/ui/ExperienceUI.ts', import.meta.url));
|
|
const root = new Element(), motion = new Element(), timers = new Map(); let timerId = 0;
|
|
motion.matches = reduced;
|
|
const elements = Object.fromEntries([...html.matchAll(/\bid="([^"]+)"/g)].map(([, id]) => [id, new Element()]));
|
|
const countNodes = [new Element(), new Element()]; countNodes.forEach(node => { node.hidden = true; });
|
|
for (const node of Object.values(elements)) node.root = root;
|
|
root.querySelector = selector => elements[selector.slice(1)] ?? null;
|
|
root.querySelectorAll = selector => selector === '[data-arrival-count]' ? countNodes : [];
|
|
root.documentElement = new Element();
|
|
root.defaultView = { localStorage: { getItem() { return null; }, setItem() {} }, matchMedia: () => motion,
|
|
setTimeout(callback, ms) { timers.set(++timerId, { callback, ms }); return timerId; }, clearTimeout(id) { timers.delete(id); } };
|
|
const listenerAbort = new AbortController();
|
|
const petName = createPetNameStore(() => root.defaultView.localStorage); petName.setName('호롱');
|
|
const experience = initExperienceUI(root, listenerAbort.signal, () => {}, () => {}, () => {}, petName);
|
|
const requests = [], hashes = [], sleeps = [], inferences = [], sampleFetches = [], mediaLoads = [];
|
|
let identityReads = 0, transportCreations = 0, collectorCreations = 0, uuidCalls = 0, storageReads = 0, storageWrites = 0, digestCalls = 0;
|
|
const noop = () => {};
|
|
const disposable = () => ({ dispose: noop });
|
|
class Photo { width = 400; height = 500; src = 'blob:photo'; async decode() {} }
|
|
const texture = () => ({ ...disposable(), image: new Photo() });
|
|
const emptyTex = texture();
|
|
const context = {
|
|
document: root, listenerAbort, petName, experience, HTMLImageElement: Photo, Image: Photo,
|
|
AbortController, Object, Array, Error, Promise, Number, Boolean, Math, performance,
|
|
THREE: { SRGBColorSpace: 'srgb', Texture: class { constructor(image) { this.image = image; } } },
|
|
AI_DEPTH: { modelName: 'Small', inputSize: 770 },
|
|
active: emptyTex, emptyTex, activeIsVideo: false, aspect: 0, mediaRequest: 0, depthRequest: 0,
|
|
mediaBusy: false, manualDepthBusy: false, aiDepthBusy: false, renderLost: false, disposed: false, meshFailure: false,
|
|
pendingCutout: null, cutout: null, cutoutTexture: null, cutoutEnabled: false,
|
|
pendingSampleFraming: false, pendingPairDepth: null, pendingSampleDebugImage: null, pendingTestPhotoHasNoDepth: false, activeTestPhotoHasNoDepth: false,
|
|
aiDepthButton: elements['ai-depth'], imageInput: elements.image, videoInput: elements.video,
|
|
depthFileInput: elements['depth-image'], depthProvenance: elements['depth-provenance'], sampleButton: elements.sample,
|
|
testPhotoInput: { value: '' }, retry: new Element(), debugOutput: new Element(), hintElement: { dataset: {} },
|
|
depthUniforms: { uTexture: {}, uDepthMap: { value: disposable() } }, depthMap: disposable(),
|
|
petMat: disposable(), petDepth: { geometry: disposable(), setVideo: noop }, empty: { style: {} },
|
|
depthDebug: { clear: noop, setSample: noop }, DepthProcessor: { flatTexture: texture, toTexture: texture },
|
|
setDepth(value) { context.appliedDepth = value; }, setMode: noop, setStatus: noop, showHint: noop,
|
|
resetViewCalls:0,resetNewPhotoView(){context.resetViewCalls++;},syncPhotoControls: noop, resize: noop, clearCutout: noop, renderPhotoTexture: () => context.active,
|
|
cancelDoorInteraction: noop, resetHintForMedia: noop, hideHintAfterDrag: noop, clearPendingTestState: noop, clearPendingPair: noop,
|
|
peekingUI: { cancel: noop, setState: noop, dispose: noop }, gyro: { setPaused: noop, dispose: noop },
|
|
aiDepthProvider: { cancel: noop, getDepthMap(image) { const pending = deferred(); inferences.push({ ...pending, image }); return pending.promise; } },
|
|
MediaLoader: class { constructor(ready, status) { this.ready = ready; this.status = status; context.loaderInstance = this; } load(file) { mediaLoads.push(file); } cancelPending() {} retry() {} dispose() {} },
|
|
RenderRecovery: class { constructor(_canvas, _dialog, _button, lost, restored) { context.loseGraphics = lost; context.restoreGraphics = restored; } dispose() {} },
|
|
fetch(path) { const pending = deferred(); sampleFetches.push({ ...pending, path }); return pending.promise; },
|
|
File: class { constructor(_, name, options) { this.name = name; this.type = options.type; } },
|
|
URL: { createObjectURL: () => 'blob:depth', revokeObjectURL: noop }, sampleDepthProvider: { getDepthMap: async () => texture() },
|
|
rendererSelectionGeneration: 0, comparisonGeneration: 0, requestedRenderer: 'b', selectedRenderer: 'b', comparisonRequested: false, comparison: null,
|
|
meshErrorDialog: elements['mesh-render-error'], endComparison: noop, originalRenderer: null,
|
|
meshRenderer: { restore: noop, dispose: noop }, renderer: { domElement: new Element(), dispose: noop },
|
|
rendererSelect: {}, rendererNotice: {}, courtyard: disposable(), peekingGrid: disposable(), doorMotion: { cancel: noop },
|
|
raf: 0, cancelAnimationFrame: noop, observer: { disconnect: noop }, effectInput: {}, sensitivityInput: {}, resetButton: {}, gyroButton: {}, pointer: disposable(),
|
|
blenderFrame: disposable(), houseSurround: disposable(), housePhotoDepth: disposable(), windowFinish: disposable(), windowReveal: disposable(), windowRevealMaterial: disposable(),
|
|
frameMaterials: [], petDepthMaterial: disposable(), geometry: disposable(), frameGeometry: disposable(),
|
|
getVisitorId(options) {
|
|
identityReads++;
|
|
return identity.getVisitorId({ ...options, getStorage() {
|
|
storageReads++;
|
|
if (blockedStorage) throw new Error('storage unavailable');
|
|
const storage = options.getStorage();
|
|
return { getItem(key) { return invalidInitialId ? 'invalid stored id' : storage?.getItem(key); }, setItem(key, value) { storageWrites++; storage?.setItem(key, value); } };
|
|
} });
|
|
},
|
|
makeDogId(visitor, name, digest) { const id = createHash('sha256').update(JSON.stringify([1, visitor, name.normalize('NFC').trim() || '멍멍이'])).digest('hex'); const pending = deferred(); hashes.push({ visitor, name, id, ...pending }); return pendingHash ? pending.promise : identity.makeDogId(visitor, name, digest); },
|
|
createTransport() { transportCreations++; return createTransport({
|
|
fetch: async (path, init = {}) => { requests.push({ path, body: init.body && JSON.parse(init.body), signal: init.signal }); return { ok: !(retryArrival && path === '/api/arrivals'), status: retryArrival && path === '/api/arrivals' ? 503 : init.body ? 204 : 200, json: async () => ({ count: 7 }) }; },
|
|
sleep: (ms, signal) => { const pending = deferred(); sleeps.push({ ...pending, ms, signal }); return pending.promise; }, retryDelays: [1, 2]
|
|
}); },
|
|
createArrivalTracker(options) { collectorCreations++; return createArrivalTracker(options); }, renderArrivalCount,
|
|
};
|
|
// Test doubles are limited to external media/GPU/browser facilities. Lifecycle
|
|
// logic, retry transport, token cancellation, count renderer and UI are real.
|
|
const crypto = {
|
|
get randomUUID() { if (cryptoFailure === 'randomUUID') throw new Error('crypto UUID unavailable'); return () => { uuidCalls++; return '86f755c0-921b-4d34-a09c-5fcc8e4a21c6'; }; },
|
|
get subtle() {
|
|
if (cryptoFailure === 'subtle') throw new Error('subtle unavailable');
|
|
return { get digest() { if (cryptoFailure === 'digest') throw new Error('digest unavailable'); return (algorithm, data) => { digestCalls++; assert.equal(algorithm, 'SHA-256'); return Promise.resolve(Uint8Array.from(createHash('sha256').update(Buffer.from(data)).digest()).buffer); }; } };
|
|
},
|
|
};
|
|
Object.defineProperty(context, 'crypto', { get() { if (cryptoFailure === 'crypto') throw new Error('crypto unavailable'); return crypto; } });
|
|
context.meshErrorDialog ??= new Element();
|
|
vm.createContext(context);
|
|
const functions = ['loadPersonalImage', 'cancelArrivalJourney', 'arrivalJourneyFor', 'beginMediaLoad', 'applyMedia', 'invalidateDepthRequests', 'syncAIDepthButton', 'generateAIDepth', 'loadSample', 'showMeshFailure', 'teardown'].map(fn);
|
|
const handlers = [expression('imageInput.onchange'), expression('videoInput.onchange'), expression("depthFileInput?.addEventListener('change'"), expression("document.querySelector('#flow-flat')"), expression("document.querySelector('#flow-retry')")];
|
|
const code = [initialization, ...functions, variable('loader'), variable('renderRecovery'), ...handlers, expression('arrivalTracker?.start')].join('\n').replaceAll('import.meta.env.VITE_ARRIVAL_ANALYTICS', JSON.stringify(enabled ? 'true' : 'false'));
|
|
vm.runInContext(ts.transpileModule(code, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText, context);
|
|
await tick();
|
|
const accept = (kind = 'personal') => {
|
|
const input = kind === 'video' ? context.videoInput : context.imageInput;
|
|
input.onchange({ target: { files: [{ type: kind === 'video' ? 'video/mp4' : 'image/png' }], value: 'selected' } });
|
|
const photo = texture(); context.loaderInstance.ready(photo, 0.8, kind === 'video' ? {} : null); return photo;
|
|
};
|
|
const finishAI = async (index = inferences.length - 1) => { inferences[index].resolve(texture()); await tick(); };
|
|
const respondSample = async () => { sampleFetches.at(-1).resolve({ ok: true, blob: async () => ({}), headers: { get: () => 'image/jpeg' } }); await tick(); };
|
|
return { context, elements, countNodes, timers, requests, hashes, sleeps, inferences, sampleFetches, mediaLoads, accept, finishAI, respondSample,
|
|
arrivals: () => requests.filter(request => request.path === '/api/arrivals'), counters: () => ({ identityReads, transportCreations, collectorCreations, uuidCalls, storageReads, storageWrites, digestCalls }) };
|
|
}
|
|
|
|
test('disabled static build creates no collector, identity, hash, storage or API work', async () => {
|
|
const h = await harness({ enabled: false }); h.accept(); await h.finishAI();
|
|
assert.deepEqual(h.counters(), { identityReads: 0, transportCreations: 0, collectorCreations: 0, uuidCalls: 0, storageReads: 0, storageWrites: 0, digestCalls: 0 });
|
|
assert.equal(h.requests.length, 0); assert.equal(h.hashes.length, 0); assert.ok(h.countNodes.every(node => node.hidden));
|
|
assert.equal(h.elements.experience.dataset.flow, 'ready'); h.context.teardown();
|
|
});
|
|
test('valid AI success counts immediately for normal and reduced motion, without waiting for presentation', async () => {
|
|
for (const reduced of [false, true]) {
|
|
const h = await harness({ reduced }); h.accept(); await h.finishAI();
|
|
assert.equal(h.arrivals().length, 1); assert.deepEqual(Object.keys(h.arrivals()[0].body), ['dog_id']);
|
|
assert.equal(h.elements.experience.dataset.flow, 'ready'); assert.equal(h.timers.size, reduced ? 0 : 1);
|
|
assert.equal(h.countNodes[0].textContent, h.countNodes[1].textContent); assert.ok(h.countNodes.every(node => !node.hidden));
|
|
assert.equal(h.requests.filter(request => request.path === '/api/visits').length, 1); h.context.teardown();
|
|
}
|
|
});
|
|
test('picker cancellation creates no journey, while retry retains the accepted photo name', async () => {
|
|
const h = await harness(); h.context.imageInput.onchange({ target: { files: [], value: '' } });
|
|
assert.equal(h.inferences.length, 0); h.accept(); h.context.petName.setName('초코');
|
|
h.inferences[0].reject(new Error('AI unavailable')); await tick(); assert.equal(h.arrivals().length, 0);
|
|
const retry = h.context.generateAIDepth(); await h.finishAI(); await retry;
|
|
assert.equal(h.hashes.at(-1).name, '호롱'); assert.equal(h.arrivals().length, 1);
|
|
h.accept(); await h.finishAI(); assert.equal(h.hashes.at(-1).name, '초코'); assert.equal(h.arrivals().length, 2); h.context.teardown();
|
|
});
|
|
test('all replacement samples stay excluded after successful AI', async () => {
|
|
for (const selected of ['dog1', 'dog2', 'dog3', 'dog4', 'dog5']) {
|
|
const h = await harness(), loading = h.context.loadSample(selected); await h.respondSample();
|
|
await loading; h.context.loaderInstance.ready({ image: new h.context.HTMLImageElement(), dispose() {} }, 0.8, null);
|
|
await h.finishAI();
|
|
assert.equal(h.elements.experience.dataset.flow, 'ready'); assert.equal(h.arrivals().length, 0); assert.equal(h.hashes.length, 0);
|
|
assert.equal(h.requests.filter(request => request.path === '/api/visits').length, 1); h.context.teardown();
|
|
}
|
|
});
|
|
test('video, manual-depth, flat-view and errors do not create arrivals', async () => {
|
|
const video = await harness(); video.accept('video'); assert.equal(video.inferences.length, 0); assert.equal(video.arrivals().length, 0); video.context.teardown();
|
|
for (const action of ['manual', 'flat', 'media-error', 'AI-error']) {
|
|
const h = await harness(); h.accept();
|
|
if (action === 'manual') { h.context.depthFileInput.files = [{}]; await h.context.depthFileInput.dispatch('change'); }
|
|
if (action === 'flat') await h.elements['flow-flat'].dispatch('click');
|
|
if (action === 'media-error') h.context.loaderInstance.status('cannot load');
|
|
if (action === 'AI-error') { h.inferences[0].reject(new Error('failed')); await tick(); }
|
|
else await h.finishAI();
|
|
assert.equal(h.arrivals().length, 0, action); h.context.teardown();
|
|
}
|
|
});
|
|
test('photo replacement and stale AI replies cannot switch to a newer identity', async () => {
|
|
const h = await harness(); h.accept(); h.context.petName.setName('초코'); h.accept();
|
|
await h.finishAI(0); assert.equal(h.arrivals().length, 0); await h.finishAI(1);
|
|
assert.equal(h.arrivals().length, 1); assert.equal(h.hashes.at(-1).name, '초코'); h.context.teardown();
|
|
});
|
|
test('failed replacement cannot rearm a new name for the old active texture', async () => {
|
|
const h = await harness(); h.accept(); h.inferences[0].reject(new Error('failed')); await tick();
|
|
h.context.petName.setName('초코'); h.context.imageInput.onchange({ target: { files: [{ type: 'image/png' }], value: 'new' } });
|
|
h.context.loaderInstance.status('new photo failed'); const retry = h.context.generateAIDepth(); await h.finishAI(); await retry;
|
|
assert.equal(h.arrivals().length, 0); h.context.teardown();
|
|
});
|
|
test('graphics loss, render failure and teardown invalidate in-flight AI eligibility', async () => {
|
|
for (const action of ['context-loss', 'mesh-failure', 'dispose']) {
|
|
const h = await harness(); h.accept();
|
|
if (action === 'context-loss') h.context.loseGraphics();
|
|
if (action === 'mesh-failure') h.context.showMeshFailure();
|
|
if (action === 'dispose') h.context.teardown();
|
|
await h.finishAI(); assert.equal(h.arrivals().length, 0, action);
|
|
if (action !== 'dispose') h.context.teardown();
|
|
}
|
|
});
|
|
test('photo replacement, flat view, manual depth, errors and losses cancel a pending hash', async () => {
|
|
for (const action of ['replacement', 'flat', 'manual', 'media-error', 'context-loss', 'mesh-failure', 'dispose']) {
|
|
const h = await harness({ pendingHash: true }); h.accept(); await h.finishAI(); assert.equal(h.hashes.length, 1);
|
|
if (action === 'replacement') h.accept();
|
|
if (action === 'flat') await h.elements['flow-flat'].dispatch('click');
|
|
if (action === 'manual') { h.context.depthFileInput.files = [{}]; await h.context.depthFileInput.dispatch('change'); }
|
|
if (action === 'media-error') h.context.loaderInstance.status('cannot load');
|
|
if (action === 'context-loss') h.context.loseGraphics();
|
|
if (action === 'mesh-failure') h.context.showMeshFailure();
|
|
if (action === 'dispose') h.context.teardown();
|
|
h.hashes[0].resolve(h.hashes[0].id); await tick(); assert.equal(h.arrivals().length, 0, action);
|
|
if (action !== 'dispose') h.context.teardown();
|
|
}
|
|
});
|
|
test('invalidation aborts a delayed network retry without delaying the ready UI', async () => {
|
|
for (const action of ['replacement', 'flat', 'manual', 'media-error', 'context-loss', 'mesh-failure', 'dispose']) {
|
|
const h = await harness({ retryArrival: true }); h.accept(); await h.finishAI();
|
|
assert.equal(h.elements.experience.dataset.flow, 'ready'); assert.equal(h.arrivals().length, 1); assert.equal(h.sleeps.length, 1);
|
|
if (action === 'replacement') h.accept();
|
|
if (action === 'flat') await h.elements['flow-flat'].dispatch('click');
|
|
if (action === 'manual') { h.context.depthFileInput.files = [{}]; await h.context.depthFileInput.dispatch('change'); }
|
|
if (action === 'media-error') h.context.loaderInstance.status('cannot load');
|
|
if (action === 'mesh-failure') h.context.showMeshFailure();
|
|
if (action === 'context-loss') h.context.loseGraphics();
|
|
if (action === 'dispose') h.context.teardown();
|
|
assert.equal(h.sleeps[0].signal.aborted, true, action); h.sleeps[0].resolve(); await tick(); assert.equal(h.arrivals().length, 1, action);
|
|
if (action !== 'dispose') h.context.teardown();
|
|
}
|
|
});
|
|
|
|
test('invalid stored identity and unavailable storage recover without affecting AI startup', async () => {
|
|
for (const options of [{ invalidInitialId: true }, { blockedStorage: true }]) {
|
|
const h = await harness(options); h.accept(); await h.finishAI();
|
|
assert.equal(h.elements.experience.dataset.flow, 'ready'); assert.equal(h.arrivals().length, 1);
|
|
assert.equal(h.counters().uuidCalls, 1); h.context.teardown();
|
|
}
|
|
});
|
|
test('throwing crypto, randomUUID, subtle and digest getters leave the experience usable', async () => {
|
|
for (const cryptoFailure of ['crypto', 'randomUUID', 'subtle', 'digest']) {
|
|
const h = await harness({ cryptoFailure }); h.accept(); await h.finishAI();
|
|
assert.equal(h.elements.experience.dataset.flow, 'ready', cryptoFailure); assert.equal(h.arrivals().length, 0, cryptoFailure);
|
|
h.context.teardown();
|
|
}
|
|
});
|
|
|
|
test('explicit AI retry after flat, manual depth or graphics recovery re-arms the original photo snapshot', async () => {
|
|
for (const reason of ['flat', 'manual', 'context-loss']) {
|
|
const h = await harness(); h.accept();
|
|
h.inferences[0].reject(new Error('initial inference failed')); await tick();
|
|
if (reason === 'flat') await h.elements['flow-flat'].dispatch('click');
|
|
if (reason === 'manual') { h.context.depthFileInput.files = [{}]; await h.context.depthFileInput.dispatch('change'); }
|
|
if (reason === 'context-loss') { h.context.loseGraphics(); h.context.restoreGraphics(); }
|
|
h.context.petName.setName('보리');
|
|
const retry = h.context.generateAIDepth(); await h.finishAI(); await retry;
|
|
assert.equal(h.arrivals().length, 1, reason); assert.equal(h.hashes.at(-1).name, '호롱', reason);
|
|
h.context.teardown();
|
|
}
|
|
});
|
|
|
|
test('new-photo reset is called once on acceptance, never by picker cancellation, failure or AI retry',async()=>{
|
|
const h=await harness({reduced:true});h.accept();await h.finishAI();assert.equal(h.context.resetViewCalls,1);
|
|
h.context.imageInput.onchange({target:{files:[],value:''}});assert.equal(h.context.resetViewCalls,1);
|
|
const retry=h.context.generateAIDepth();await h.finishAI();await retry;assert.equal(h.context.resetViewCalls,1);
|
|
h.context.imageInput.onchange({target:{files:[{type:'image/png'}],value:'new'}});h.context.loaderInstance.status('failed decode');assert.equal(h.context.resetViewCalls,1);
|
|
const sample=h.context.loadSample('dog5');await h.respondSample();await sample;assert.equal(h.context.resetViewCalls,1);
|
|
h.context.loaderInstance.ready({...h.context.active,image:new h.context.HTMLImageElement()},.8,null);await h.finishAI();assert.equal(h.context.resetViewCalls,2);
|
|
h.context.teardown();
|
|
});
|