242 lines
9.7 KiB
JavaScript
242 lines
9.7 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, /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/);
|
|
});
|