返回 CodeWhale
pet-browser-persistence.test.mjs
根目录 / pet / tests / pet-browser-persistence.test.mjs
1 import test from 'node:test';
2 import assert from 'node:assert/strict';
3 import { readFile } from 'node:fs/promises';
4 import vm from 'node:vm';
5 import * as world from '../dist/core/pet-world.js';
6 import * as telemetry from '../dist/core/pet-telemetry.js';
7 import * as demo from '../dist/core/pet-demo.js';
8 import * as ingest from '../dist/core/ingest.js';
9 import * as sim from '../dist/core/pet-sim.js';
10 import * as audio from '../dist/core/pet-audio.js';
11
12 /** Run the compiled browser controller and real world with minimal DOM sinks.
13 * Storage and file-read completion are controlled: this tests async caller
14 * contracts, not IndexedDB transactions, file-picker grants or rendering. */
15 async function browser({ deferFirstSave = true, handle } = {}) {
16 const nodes = new Map(), saves = [], intervals = [], timers = [], listeners = new Map();
17 let complete, fail, confirmations = 0;
18 const context = new Proxy({}, { get: (object, key) => object[key] ?? (() => {}) });
19 const node = id => {
20 if (!nodes.has(id)) nodes.set(id, { value: id === 'mode' ? 'wild' : '', textContent: '', checked: false,
21 clientWidth: 800, clientHeight: 400, getContext: () => context,
22 setAttribute() {}, addEventListener() {}, replaceChildren() {}, add() {},
23 querySelector: () => node('replay-option'), getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 400 }) });
24 return nodes.get(id);
25 };
26 class TraceLibrary {
27 async getHabitat() { return undefined; }
28 async petArchives() { return []; }
29 saveHabitat(habitat, revision, archive) {
30 saves.push(structuredClone({ habitat, revision, archive }));
31 if (deferFirstSave && saves.length === 1) return new Promise((resolve, reject) => { complete = resolve; fail = reject; });
32 return Promise.resolve(saves.length);
33 }
34 }
35 // Windows checkouts can use CRLF; exercise that asset in the actual controller.
36 const points = (await readFile(new URL('../public/whale-points.tsv', import.meta.url), 'utf8')).replace(/\r?\n/g, '\r\n');
37 // Imports bind to the real core above, while the controller body stays intact.
38 const controller = (await readFile(new URL('../dist/ui/pet.js', import.meta.url), 'utf8')).replace(/^import .*;\r?\n/gm, '');
39 const environment = { ...world, ...telemetry, ...demo, ...ingest, ...sim, ...audio, TraceLibrary,
40 document: { hidden: false, getElementById: node, addEventListener: (name, callback) => listeners.set(name, callback) },
41 window: { addEventListener() {}, confirm() { confirmations++; return false; },
42 showOpenFilePicker: handle ? async () => [handle] : undefined,
43 setTimeout: callback => timers.push(callback) },
44 matchMedia: () => ({ matches: false, addEventListener() {} }),
45 fetch: async () => ({ ok: true, text: async () => points }),
46 Option: class {}, requestAnimationFrame() {}, setInterval: callback => intervals.push(callback),
47 setTimeout, clearTimeout, structuredClone, TextEncoder, devicePixelRatio: 1 };
48 const inspect = await vm.runInNewContext(`(async () => { ${controller}\n return () => world.recording(true); })()`, environment);
49 assert.equal(intervals.length, 1, 'Controller starts its autosave after loading');
50 return { node, saves, autosave: intervals[0], complete: () => complete(1), fail: () => fail(new Error('Disk unavailable')),
51 confirmations: () => confirmations, recording: () => structuredClone(inspect()),
52 poll: () => { assert.ok(timers.length); return timers.shift()(); },
53 visibility: hidden => { environment.document.hidden = hidden; listeners.get('visibilitychange')(); } };
54 }
55
56 test('browser source change waits for autosave and archives inputs accepted during that save', async () => {
57 const page = await browser();
58 page.autosave();
59 assert.equal(page.saves.length, 1);
60 page.node('feed').onclick();
61 page.node('mode').value = 'demo';
62 let finished = false;
63 const changing = page.node('mode').onchange().then(() => { finished = true; });
64 await new Promise(resolve => setImmediate(resolve));
65 assert.equal(page.confirmations(), 0, 'An in-flight save is not a failed save');
66 assert.equal(finished, false);
67 assert.equal(page.saves.length, 1, 'Wait for the current revision before archiving');
68 page.complete(); await changing;
69 assert.equal(page.saves.length, 2);
70 assert.equal(page.saves[1].revision, 1);
71 assert.equal(page.saves[1].archive.interactions.length, 1);
72 assert.equal(page.saves[1].archive.interactions[0].kind, 'food');
73 assert.equal(page.saves[1].archive.source, 'wild');
74 assert.equal(page.node('mode').value, 'demo');
75 });
76
77 test('browser failed autosave asks before leaving and cancel preserves the current source', async () => {
78 const page = await browser();
79 page.autosave(); page.node('mode').value = 'demo';
80 const changing = page.node('mode').onchange();
81 page.fail(); await changing;
82 assert.equal(page.confirmations(), 1);
83 assert.equal(page.node('mode').value, 'wild');
84 assert.equal(page.saves.length, 1, 'No archive or replacement after the failed write');
85 });
86
87 test('browser live follow discards a read crossing suspension and primes the resumed file', async () => {
88 const human = telemetry.compilePetTelemetry(demo.petDemoEvents()).find(b => b.waiting);
89 const file = sequence => {
90 const text = telemetry.encodePetJSONL([{ ...human, sequence, simTimeMs: sequence * 400 }]);
91 return { name: 'local.jsonl', size: text.length, slice: () => ({ text: async () => text }) };
92 };
93 let current = file(10), pending;
94 const handle = { getFile: async () => current };
95 const page = await browser({ deferFirstSave: false, handle });
96 await page.node('follow').onclick();
97 assert.equal(page.recording().tape.length, 1, 'Existing file is not a live observation');
98 handle.getFile = () => new Promise(resolve => { pending = resolve; });
99 const reading = page.poll();
100 page.visibility(true); page.visibility(false);
101 pending(file(11)); await reading;
102 assert.equal(page.recording().tape.length, 1, 'Do not prime from a read started before suspension');
103 handle.getFile = async () => current;
104 current = file(12); await page.poll();
105 assert.equal(page.recording().tape.length, 1, 'The first resumed read establishes the new baseline');
106 current = file(13); await page.poll();
107 assert.equal(page.recording().tape.at(-1).channel, 'human');
108 assert.equal(page.recording().tape.length, 2);
109 await page.poll(); assert.equal(page.recording().tape.length, 2, 'Duplicate reads add no onsets');
110 });
111
111 lines Plain Text