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(), "nla-unit-")); function transpile(sourceName, outputName, replacements = []) { const sourcePath = path.join(root, "web/protocol", sourceName); 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, []); const output = replacements.reduce((source, [from, to]) => source.replaceAll(from, to), transpiled.outputText); fs.writeFileSync(path.join(temporary, outputName), output); } transpile("capability-gates.ts", "capability-gates.mjs"); transpile("nla.ts", "nla.mjs", [['from "./capability-gates"', 'from "./capability-gates.mjs"']]); const nla = await import(pathToFileURL(path.join(temporary, "nla.mjs"))); const actionId = "action:Move:object:Cube"; const context = { actionIds: new Set([actionId]), actionChannelPaths: new Map([[actionId, new Set(["location[0]"])]]), ownerId: "object:Cube", }; function tracks() { return [{ schemaVersion: 1, id: "track:Move", ownerId: "object:Cube", name: "Move", muted: false, solo: false, selected: true, strips: [ { id: "strip:A", actionId, frameStart: 20, frameEnd: 40, actionFrameStart: 1, actionFrameEnd: 11, scale: 2, repeat: 1, blendIn: 0, blendOut: 0, influence: 1, blendMode: "REPLACE", extrapolation: "NOTHING", muted: false, selected: true, stripType: "CLIP" }, { id: "strip:B", actionId, frameStart: 45, frameEnd: 65, actionFrameStart: 1, actionFrameEnd: 11, scale: 1, repeat: 2, blendIn: 0, blendOut: 0, influence: 1, blendMode: "REPLACE", extrapolation: "NOTHING", muted: false, selected: true, stripType: "CLIP" }, ], }]; } test("M10-12 moves one NLA strip without mutating the source stack", () => { const source = tracks(); const before = structuredClone(source); const moved = nla.moveNlaStrip(source, { type: "moveNLAStrip", objectId: "object:Cube", trackId: "track:Move", stripId: "strip:A", frameStart: 5, baseRevision: 7, }, context); assert.deepEqual(source, before); assert.deepEqual(moved[0].strips.map((strip) => [strip.id, strip.frameStart, strip.frameEnd]), [ ["strip:A", 5, 25], ["strip:B", 45, 65], ]); }); test("M10-12 rejects overlap, unknown identities and invalid operator budgets", () => { const command = { type: "moveNLAStrip", objectId: "object:Cube", trackId: "track:Move", stripId: "strip:A", frameStart: 30, baseRevision: 7 }; assert.throws(() => nla.moveNlaStrip(tracks(), command, context), { code: "NLA_INVALID_STACK" }); assert.throws(() => nla.moveNlaStrip(tracks(), { ...command, trackId: "track:missing" }, context), { code: "NLA_INVALID_STACK" }); assert.throws(() => nla.moveNlaStrip(tracks(), { ...command, frameStart: 1_000_001 }, context), { code: "NLA_INVALID_STACK" }); }); test("M10-15 rejects NLA topology that exceeds the browser memory budget", () => { const oversized = new Array(nla.NLA_STACK_BUDGET.maxTracks + 1).fill(tracks()[0]); assert.throws(() => nla.parseNlaTracks(oversized), { code: "NLA_BUDGET_EXCEEDED" }); const tooManyStrips = [{ ...tracks()[0], strips: new Array(nla.NLA_STACK_BUDGET.maxStripsPerTrack + 1).fill(tracks()[0].strips[0]), }]; assert.throws(() => nla.parseNlaTracks(tooManyStrips), { code: "NLA_BUDGET_EXCEEDED" }); }); test("M10-15 rejects undeclared NLA input and recovers on the next valid stack", () => { const malicious = tracks(); malicious[0].strips[0].proxySuccess = true; assert.throws(() => nla.parseNlaTracks(malicious), { code: "NLA_INVALID_STACK" }); assert.equal(nla.gateNlaTracks(tracks(), context).status, "READY"); });