import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; import { BOARD_SESSION_KEY, readBoardSession, writeBoardSession, loadDefaultBoard, initializeDefaultBoard } from "../app/static/board-startup-model.js"; import { createTemplateEditContext, sceneNeedsTemplateSave, templateEditSnapshot, } from "../app/static/template-edit-model.js"; const staticRoot = new URL("../app/static/", import.meta.url); test("template edit baseline distinguishes clean, dirty, and unbound scenes", () => { const context = createTemplateEditContext({ id: "template-1", name: "测试模板", revision: "revision-1", read_only: true, }, "scene-a"); assert.equal(sceneNeedsTemplateSave(null, "scene-a"), true); assert.equal(sceneNeedsTemplateSave(context, "scene-a"), false); assert.equal(sceneNeedsTemplateSave(context, "scene-b"), true); assert.deepEqual(templateEditSnapshot(context, "scene-a"), { type: "template", id: "template-1", name: "测试模板", revision: "revision-1", readOnly: true, dirty: false, }); assert.equal(templateEditSnapshot(context, "scene-b").dirty, true); }); test("same boot restores binding and original dirty baseline; new boot or mismatched draft does not", () => { const values = new Map(); const storage = { getItem: (key) => values.get(key), setItem: (key, value) => values.set(key, value) }; const context = createTemplateEditContext({ id: "saved", name: "默认画面", revision: "original" }, "clean-scene"); writeBoardSession(storage, "boot-a", "dirty-scene", context); const restored = readBoardSession(storage, "dirty-scene", "boot-a"); assert.deepEqual(restored.context, context); assert.equal(templateEditSnapshot(restored.context, "dirty-scene").dirty, true); assert.equal(restored.context.revision, "original"); assert.equal(readBoardSession(storage, "dirty-scene", "boot-b").context, null); assert.equal(readBoardSession(storage, "another-scene", "boot-a").context, null); assert.equal(readBoardSession(storage, "another-scene", "boot-a").bootId, "boot-a"); values.set(BOARD_SESSION_KEY, '{"version":99}'); assert.throws(() => readBoardSession(storage, "dirty-scene", "boot-a"), /原值已保留/); assert.equal(values.get(BOARD_SESSION_KEY), '{"version":99}'); }); test("default static loads its complete latest scene and both demos keep read-only ownership", async () => { const staticDetail = { id: "static", name: "默认静态", revision: "r2", scene: "complete-scene" }; const calls = []; const normal = await loadDefaultBoard(async (url) => { calls.push(url); return url.endsWith("default-content") ? { type: "template", id: "static" } : staticDetail; }); assert.deepEqual(normal, { ...staticDetail, type: "template" }); assert.deepEqual(calls, ["/api/display/default-content", "/api/templates/static"]); const staticDemo = createTemplateEditContext({ ...normal, read_only: true }, "baseline"); assert.equal(staticDemo.readOnly, true); const demo = await loadDefaultBoard(async (url) => { if (url.endsWith("default-content")) return { type: "animation", id: "demo", is_demo: true }; if (url.endsWith("/frames")) return { id: "demo", name: "演示动图", revision: "r1", frames: [{ id: "first" }, { id: "second" }] }; assert.equal(url, "/api/animations/demo/frames/first"); return { scene: "first-scene" }; }); assert.equal(demo.scene, "first-scene"); const context = createTemplateEditContext(demo, demo.scene); assert.equal(context.type, "animation"); assert.equal(context.readOnly, true); assert.equal(context.id, "demo"); assert.equal(await loadDefaultBoard(async () => ({ type: "animation", is_demo: false })), null); }); test("startup skips protected drafts and never replaces intervening edits or failed reads", async () => { let allowed = false, applied = 0, retained = 0, loaded = 0; const options = { canLoad: () => allowed, load: async () => { loaded++; return { scene: "default" }; }, apply: () => applied++, retain: () => retained++, }; assert.equal(await initializeDefaultBoard(options), false); assert.equal(loaded, 0); allowed = true; assert.equal(await initializeDefaultBoard(options), true); assert.equal(applied, 1); options.load = async () => { allowed = false; return { scene: "late-default" }; }; assert.equal(await initializeDefaultBoard(options), false); assert.equal(applied, 1); assert.equal(retained, 1); allowed = true; options.load = async () => { throw Error("deleted or unavailable"); }; await assert.rejects(initializeDefaultBoard(options), /deleted or unavailable/); assert.equal(applied, 1); assert.equal(retained, 1); }); test("template manager uses a monitor and unified four-row card actions", async () => { const [html, board, templates, style] = await Promise.all([ readFile(new URL("index.html", staticRoot), "utf8"), readFile(new URL("scene-board.js", staticRoot), "utf8"), readFile(new URL("views/templates.js", staticRoot), "utf8"), readFile(new URL("style.css", staticRoot), "utf8"), ]); assert.match(board, /MONITOR_WORKSPACES = new Set\(\["device", "settings", "templates"\]\)/); assert.match(templates, /actions\.append\(play, edit, copy, rename, remove\)/); assert.match(templates, /template-card-delete-action/); assert.match(templates, /api\/templates\/\$\{template\.id\}\/play/); assert.match(templates, /enterTemplateEdit\(full\)/); assert.match(templates, /当前内容将会丢失/); assert.match(templates, /actionButton\("上移"/); assert.match(templates, /actionButton\("下移"/); assert.match(templates, /name\.dataset\.hoverTooltip = animation\.name/); assert.match(templates, /template-card-name animation-folder-name/); assert.match(style, /\.template-card-content-actions,[\s\S]*grid-template-columns: repeat\(2/); assert.match(style, /\.template-card-delete-action\s*\{\s*grid-column: 1 \/ -1/); assert.match(html, /id="template-save-choice-dialog"/); assert.match(html, /value="current">保存到当前模板/); assert.match(html, /value="new">另存为新模板/); assert.match(style, /\.template-save-choice-actions \.demo-restricted-action\[aria-disabled="true"\][\s\S]*background: #050505/); }); test("animation editing backs up and restores template edit ownership", async () => { const board = await readFile(new URL("scene-board.js", staticRoot), "utf8"); assert.match(board, /templateEdit: templateEdit \? \{ \.\.\.templateEdit \} : null/); assert.match(board, /templateEditContext: backup\.templateEdit/); assert.match(board, /markTemplateSaved/); });