import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; import { BRUSH_MODE_NORMAL, BRUSH_MODE_PIXEL, PixelUndoHistory, brushModeSpec, brushStampPoints, chooseCoordinateGuideStep, clampCanvasView, clampFloatingButton, coordinateGuideTicks, linePoints, normalizeBrushSize, restoreCanvasToolState, zoomCanvasView, } from "../app/static/canvas-tools-model.js"; test("normal and pixel modes expose independent ranges and presets", () => { assert.deepEqual(brushModeSpec(BRUSH_MODE_NORMAL).presets, [1, 2, 4, 8]); assert.deepEqual(brushModeSpec(BRUSH_MODE_PIXEL).presets, [1, 2, 3, 4]); assert.equal(normalizeBrushSize(99, BRUSH_MODE_NORMAL), 16); assert.equal(normalizeBrushSize(99, BRUSH_MODE_PIXEL), 4); assert.equal(normalizeBrushSize("bad", BRUSH_MODE_PIXEL), 1); }); test("pixel stamps are exact squares from 1x1 through 4x4", () => { for (let size = 1; size <= 4; size += 1) { const points = brushStampPoints(32, 32, size, BRUSH_MODE_PIXEL); assert.equal(points.length, size * size); assert.equal(new Set(points.map(({ x }) => x)).size, size); assert.equal(new Set(points.map(({ y }) => y)).size, size); } }); test("pixel stamps clip at every canvas edge", () => { const points = brushStampPoints(0, 0, 4, BRUSH_MODE_PIXEL); assert.equal(points.length, 9); assert.ok(points.every(({ x, y }) => x >= 0 && y >= 0 && x < 64 && y < 64)); const opposite = brushStampPoints(63, 63, 4, BRUSH_MODE_PIXEL); assert.equal(opposite.length, 4); assert.ok(opposite.every(({ x, y }) => x >= 0 && y >= 0 && x < 64 && y < 64)); assert.equal(brushStampPoints(0, 32, 4, BRUSH_MODE_PIXEL).length, 12); assert.equal(brushStampPoints(32, 0, 4, BRUSH_MODE_PIXEL).length, 12); }); test("normal circle geometry remains unchanged", () => { assert.equal(brushStampPoints(32, 32, 1, BRUSH_MODE_NORMAL).length, 1); assert.equal(brushStampPoints(32, 32, 2, BRUSH_MODE_NORMAL).length, 4); assert.equal(brushStampPoints(32, 32, 4, BRUSH_MODE_NORMAL).length, 12); }); test("line interpolation includes endpoints without coordinate gaps", () => { const points = linePoints({ x: 2, y: 3 }, { x: 19, y: 11 }); assert.deepEqual(points[0], { x: 2, y: 3 }); assert.deepEqual(points.at(-1), { x: 19, y: 11 }); for (let index = 1; index < points.length; index += 1) { assert.ok(Math.abs(points[index].x - points[index - 1].x) <= 1); assert.ok(Math.abs(points[index].y - points[index - 1].y) <= 1); } }); test("older tools migrate while v3 restores the coordinate guide choice", () => { assert.deepEqual(restoreCanvasToolState(null, { paintColor: "#123456", backgroundColor: "#654321", tool: "eraser", brushSize: 8, }), { paintColor: "#123456", backgroundColor: "#654321", tool: "eraser", pixelEditEnabled: false, normalBrushSize: 8, pixelBrushSize: 1, coordinateGuideEnabled: false, }); assert.deepEqual(restoreCanvasToolState({ tool: "brush", pixelEditEnabled: true, normalBrushSize: 12, pixelBrushSize: 4, coordinateGuideEnabled: true, }), { paintColor: null, backgroundColor: null, tool: "brush", pixelEditEnabled: true, normalBrushSize: 12, pixelBrushSize: 4, coordinateGuideEnabled: true, }); assert.equal(restoreCanvasToolState("damaged", { brushSize: "bad" }).normalBrushSize, 1); assert.equal(restoreCanvasToolState("damaged", { coordinateGuideEnabled: true }).coordinateGuideEnabled, false); assert.deepEqual(restoreCanvasToolState(null, { pixelEditEnabled: true, normalBrushSize: 9, pixelBrushSize: 3, }), { paintColor: null, backgroundColor: null, tool: "brush", pixelEditEnabled: true, normalBrushSize: 9, pixelBrushSize: 3, coordinateGuideEnabled: false, }); }); test("coordinate guide spacing adapts to the transformed logical pixel size", () => { assert.equal(chooseCoordinateGuideStep(4.625), 8); assert.equal(chooseCoordinateGuideStep(9.25), 4); assert.equal(chooseCoordinateGuideStep(18.5), 2); assert.equal(chooseCoordinateGuideStep(37), 1); assert.equal(chooseCoordinateGuideStep("damaged"), 1); }); test("coordinate guide ticks preserve logical values through zoom and pan", () => { assert.deepEqual(coordinateGuideTicks({ boardStart: 20, boardSize: 320, visibleStart: 0, visibleEnd: 400, step: 16, }), [ { value: 0, position: 20 }, { value: 16, position: 100 }, { value: 32, position: 180 }, { value: 48, position: 260 }, ]); assert.deepEqual(coordinateGuideTicks({ boardStart: -300, boardSize: 640, visibleStart: 0, visibleEnd: 320, step: 8, }), [ { value: 32, position: 20 }, { value: 40, position: 100 }, { value: 48, position: 180 }, { value: 56, position: 260 }, ]); }); test("pixel mode markup and grid stay accessible and non-interactive", async () => { const [index, style, view] = await Promise.all([ readFile(new URL("../app/static/index.html", import.meta.url), "utf8"), readFile(new URL("../app/static/style.css", import.meta.url), "utf8"), readFile(new URL("../app/static/views/canvas.js", import.meta.url), "utf8"), ]); assert.match(index, /id="pixel-edit-toggle"[^>]+aria-pressed="false"/); assert.match(index, /id="pixel-edit-status"[^>]+aria-live="polite"/); assert.match(index, /id="pixel-grid"[^>]+aria-hidden="true"[^>]+hidden/); assert.match(style, /\.pixel-grid\s*\{[^}]*pointer-events:\s*none/s); assert.match(style, /background-size:\s*calc\(100% \/ 64\) calc\(100% \/ 64\)/); assert.match(view, /pixelGrid\.hidden = !\(pixelEditEnabled && getInteractionMode\(\) === "pixels"\)/); }); test("pixel undo history stores changed snapshots, caps entries, and clears", () => { const history = new PixelUndoHistory(2); const first = Uint8Array.from([0, 0, 0]); assert.equal(history.record(first, first), false); assert.equal(history.size, 0); assert.equal(history.record(first, Uint8Array.from([1, 0, 0])), true); first[0] = 9; history.record(Uint8Array.from([1, 0, 0]), Uint8Array.from([2, 0, 0])); history.record(Uint8Array.from([2, 0, 0]), Uint8Array.from([3, 0, 0])); assert.equal(history.size, 2); assert.deepEqual(history.undo(), Uint8Array.from([2, 0, 0])); assert.deepEqual(history.undo(), Uint8Array.from([1, 0, 0])); assert.equal(history.undo(), null); history.record(Uint8Array.from([3]), Uint8Array.from([4])); history.clear(); assert.equal(history.size, 0); }); test("canvas view clamps scale and keeps a visible section in the viewport", () => { const bounds = { viewportWidth: 320, viewportHeight: 568, baseSize: 296, minVisible: 48 }; assert.deepEqual(clampCanvasView({ scale: 99, x: 9999, y: -9999 }, bounds), { scale: 8, x: 1296, y: -1420, }); assert.deepEqual(clampCanvasView({ scale: 0, x: 0, y: 0 }, bounds), { scale: 1, x: 0, y: 0 }); }); test("zooming around an anchor preserves its logical position", () => { const bounds = { viewportWidth: 400, viewportHeight: 600, baseSize: 360, minVisible: 48 }; const view = { scale: 1, x: 0, y: 0 }; const zoomed = zoomCanvasView(view, 2, { x: 300, y: 250 }, bounds); assert.deepEqual(zoomed, { scale: 2, x: -100, y: 50 }); const logicalBefore = { x: (300 - 200 - view.x) / view.scale, y: (250 - 300 - view.y) / view.scale, }; const logicalAfter = { x: (300 - 200 - zoomed.x) / zoomed.scale, y: (250 - 300 - zoomed.y) / zoomed.scale, }; assert.deepEqual(logicalAfter, logicalBefore); }); test("floating editor button stays inside its safe viewport bounds", () => { assert.deepEqual(clampFloatingButton({ x: -20, y: 999 }, { viewportWidth: 320, viewportHeight: 568, size: 56, inset: 8, }), { x: 36, y: 532 }); }); test("canvas editor markup, guarded input, and checkpoint events stay wired", async () => { const [index, style, canvasView, sceneBoard, templates, animations] = await Promise.all([ readFile(new URL("../app/static/index.html", import.meta.url), "utf8"), readFile(new URL("../app/static/style.css", import.meta.url), "utf8"), readFile(new URL("../app/static/views/canvas.js", import.meta.url), "utf8"), readFile(new URL("../app/static/scene-board.js", import.meta.url), "utf8"), readFile(new URL("../app/static/views/templates.js", import.meta.url), "utf8"), readFile(new URL("../app/static/views/animations.js", import.meta.url), "utf8"), ]); assert.match(index, /id="start-canvas-edit"[^>]*>开始编辑<\/button>/); assert.match(index, /id="canvas-assistive-toggle"[^>]+aria-expanded="false"/); assert.match(index, /id="canvas-coordinate-guide"[^>]+aria-hidden="true"[^>]+hidden/); assert.match(index, /id="toggle-canvas-coordinates"[^>]+aria-pressed="false"/); assert.match(index, /id="undo-canvas-edit"[^>]+aria-disabled="true"/); assert.match(index, /id="move-canvas-view"/); assert.match(index, /id="continue-canvas-edit"/); assert.match(style, /body\.canvas-edit-active #board-shell\s*\{[^}]*position:\s*fixed/s); assert.match(style, /\.canvas-coordinate-guide\s*\{[^}]*pointer-events:\s*none/s); assert.match(style, /body\.canvas-edit-active \.canvas-coordinate-guide:not\(\[hidden\]\)\s*\{[^}]*z-index:\s*1005/s); assert.match(style, /#scene-overlay\[data-interaction="pixels"\]:not\(\[data-canvas-editing="true"\]\)\s*\{[^}]*pointer-events:\s*none/s); assert.match(canvasView, /if \(!editorActive \|\| getInteractionMode\(\) !== "pixels"\) return/); assert.match(canvasView, /new PixelUndoHistory\(\)/); assert.match(canvasView, /createLocalState\("canvas-tools", 3\)/); assert.match(canvasView, /coordinateGuide\.hidden = true/); assert.match(sceneBoard, /matrix:scene-replaced/); assert.match(templates, /matrix:canvas-history-checkpoint/); assert.match(animations, /matrix:canvas-history-checkpoint/); });