Files
matrix-screen-controller/核桃派软件源代码/tests/test_canvas_tools.mjs
T

242 lines
9.6 KiB
JavaScript

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, /getInteractionMode\(\) === "readonly"/);
assert.match(canvasView, /undoSceneEdit\(\)/);
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/);
});