import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import test from "node:test"; import ts from "typescript"; const root = path.resolve(import.meta.dirname, "../../.."); const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "simulation-cache-unit-")); const sourcePath = path.join(root, "web/protocol/simulation-cache.ts"); const outputPath = path.join(temporary, "simulation-cache.mjs"); const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, fileName: sourcePath, reportDiagnostics: true, }); assert.deepEqual(transpiled.diagnostics, []); fs.writeFileSync(outputPath, transpiled.outputText); const simulation = await import(pathToFileURL(outputPath)); const digest = async (value) => Array.from( new Uint8Array(await crypto.subtle.digest("SHA-256", value)), (byte) => byte.toString(16).padStart(2, "0"), ).join(""); async function manifest(overrides = {}) { const payload = Uint8Array.from([1, 2, 3, 4]).buffer; const binding = { graphId: "node-group:SimulationUnit", graphHash: await digest(Uint8Array.from([1]).buffer), sourceBlendSha256: await digest(Uint8Array.from([2]).buffer), sourceRevision: 7, inputHash: await digest(Uint8Array.from([3]).buffer), blenderVersion: "5.2.0", frameStart: 1, frameEnd: 1, }; return { schemaVersion: 2, ...binding, revisionHash: await simulation.computeSimulationCacheRevisionHash(binding), cacheSha256: await digest(payload), byteLength: payload.byteLength, frames: [{ frame: 1, byteOffset: 0, byteLength: payload.byteLength, sha256: await digest(payload) }], ...overrides, }; } test("M10-05 binds a cache key to the full graph, source, revision and input identity", async () => { const value = await manifest(); const verified = await simulation.verifySimulationCacheRevisionBinding(value); assert.equal(verified.sourceRevision, 7); assert.equal(simulation.simulationCacheKey(verified), `sim2-${value.revisionHash}`); assert.equal(simulation.simulationCacheKey(verified).length, 69); }); test("M10-05 rejects graph, source, revision, input and range drift", async () => { const value = await manifest(); for (const patch of [ { graphHash: "0".repeat(64) }, { sourceBlendSha256: "1".repeat(64) }, { sourceRevision: 8 }, { inputHash: "2".repeat(64) }, { frameStart: 2, frameEnd: 2, frames: [{ ...value.frames[0], frame: 2 }] }, ]) { await assert.rejects( simulation.verifySimulationCacheRevisionBinding({ ...value, ...patch }), { code: "SIMULATION_CACHE_REVISION_MISMATCH" }, ); } }); test("M10-05 rejects legacy schemas, undeclared fields and invalid revisions", async () => { const value = await manifest(); assert.throws(() => simulation.parseSimulationCacheManifest({ ...value, schemaVersion: 1 }), { code: "PROTOCOL_MISMATCH" }); assert.throws(() => simulation.parseSimulationCacheManifest({ ...value, sourceRevision: -1 }), { code: "SIMULATION_CACHE_INVALID" }); assert.throws(() => simulation.parseSimulationCacheManifest({ ...value, staleKey: "accepted" }), { code: "SIMULATION_CACHE_INVALID" }); assert.throws(() => simulation.parseSimulationCacheManifest({ ...value, frames: [{ ...value.frames[0], payload: [1, 2, 3, 4] }], }), { code: "SIMULATION_CACHE_INVALID" }); }); test("M10-15 reports cache byte and frame budgets before allocating payload bytes", async () => { const value = await manifest(); assert.throws(() => simulation.parseSimulationCacheManifest({ ...value, byteLength: simulation.SIMULATION_CACHE_BUDGET.maxCacheBytes + 1, }), { code: "SIMULATION_CACHE_BUDGET_EXCEEDED" }); assert.throws(() => simulation.parseSimulationCacheManifest({ ...value, frameEnd: value.frameStart + simulation.SIMULATION_CACHE_BUDGET.maxFrames, }), { code: "SIMULATION_CACHE_BUDGET_EXCEEDED" }); }); test("M10-06 plans deterministic LRU eviction while retaining protected playback caches", () => { const key = (digit) => `sim2-${digit.repeat(64)}`; const candidates = [ { cacheKey: key("a"), byteLength: 4, createdAt: "2026-08-16T12:00:00.000Z", lastAccessAt: "2026-08-16T12:00:00.000Z" }, { cacheKey: key("b"), byteLength: 4, createdAt: "2026-08-16T12:00:01.000Z", lastAccessAt: "2026-08-16T12:00:01.000Z" }, { cacheKey: key("c"), byteLength: 4, createdAt: "2026-08-16T12:00:02.000Z", lastAccessAt: "2026-08-16T12:00:02.000Z" }, ]; const plan = simulation.planSimulationCacheLRU(candidates, 4, [key("a")]); assert.deepEqual(plan.cacheKeys, [key("b"), key("c")]); assert.deepEqual(plan.protectedCacheKeys, [key("a")]); assert.equal(plan.beforeBytes, 12); assert.equal(plan.remainingBytes, 4); assert.equal(plan.removedBytes, 8); assert.equal(plan.budgetSatisfied, true); }); test("M10-06 reports an unsatisfied LRU budget instead of evicting an active cache", () => { const cacheKey = `sim2-${"d".repeat(64)}`; const plan = simulation.planSimulationCacheLRU([ { cacheKey, byteLength: 8, createdAt: "2026-08-16T12:00:00.000Z", lastAccessAt: "2026-08-16T12:00:00.000Z" }, ], 0, [cacheKey]); assert.deepEqual(plan.cacheKeys, []); assert.equal(plan.remainingBytes, 8); assert.equal(plan.budgetSatisfied, false); }); test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));