初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
@@ -0,0 +1,281 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
import {
animationEditContextName,
animationPreviewFrameAtElapsed,
effectiveFrameName,
formatFrameDuration,
reorderSelectedFrameIds,
validateFrameDuration,
} from "../app/static/animation-ui-model.js";
import {
DRAG_START_GUARD_PX,
pointInsideExpandedRect,
protectCardDragStart,
reorderByKey,
} from "../app/static/sortable-card.js";
import {
ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT,
ANIMATION_PREVIEW_MAX_MAX_CONCURRENT,
ANIMATION_PREVIEW_MIN_MAX_CONCURRENT,
ANIMATION_PREVIEW_PAGE_SIZE,
ANIMATION_PREVIEW_PRELOAD_COUNT,
loadCompleteAnimationPreview,
normalizeAnimationPreviewMaxConcurrent,
} from "../app/static/animation-thumbnail-preview.js";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
async function staticSource(relativePath) {
return readFile(path.join(root, "app", "static", relativePath), "utf8");
}
test("derives automatic frame names from current animation name and position", () => {
const frame = { name: null, position: 2 };
assert.equal(effectiveFrameName("眨眼", frame), "眨眼-第2帧");
assert.equal(effectiveFrameName("新名字", frame, 1), "新名字-第1帧");
});
test("keeps custom frame names and restores automatic names when cleared", () => {
assert.equal(effectiveFrameName("眨眼", { name: "闭眼", position: 2 }), "闭眼");
assert.equal(effectiveFrameName("眨眼", { name: " ", position: 2 }), "眨眼-第2帧");
});
test("builds non-repeating animation edit context names", () => {
assert.equal(animationEditContextName("眨眼", { name: null }, 2), "眨眼-第2帧");
assert.equal(animationEditContextName("眨眼", { name: "闭眼" }, 2), "眨眼-闭眼");
assert.equal(animationEditContextName("眨眼", { name: " " }, 1), "眨眼-第1帧");
});
test("selects saved animation preview frames by ordered durations", () => {
const frames = [
{ id: "a", duration_ms: 100 },
{ id: "b", duration_ms: 200 },
{ id: "c", duration_ms: 50 },
];
assert.deepEqual(animationPreviewFrameAtElapsed(frames, 0), {
frame: frames[0], index: 0, remainingMs: 100, totalDurationMs: 350,
});
assert.equal(animationPreviewFrameAtElapsed(frames, 99).index, 0);
assert.equal(animationPreviewFrameAtElapsed(frames, 100).index, 1);
assert.equal(animationPreviewFrameAtElapsed(frames, 299).remainingMs, 1);
assert.equal(animationPreviewFrameAtElapsed(frames, 300).index, 2);
assert.equal(animationPreviewFrameAtElapsed(frames, 350).index, 0);
assert.equal(animationPreviewFrameAtElapsed(frames, -1).index, 2);
assert.equal(animationPreviewFrameAtElapsed([], 0), null);
assert.equal(animationPreviewFrameAtElapsed([{ duration_ms: 0 }], 0), null);
});
test("loads every preview frame through revision-checked pagination", async () => {
const animation = { id: "animation-a", revision: "revision-a", frame_count: 5 };
const calls = [];
const frames = await loadCompleteAnimationPreview(animation, {
pageSize: 2,
fetchJson: async (url, options) => {
calls.push({ url, signal: options.signal });
const parsed = new URL(url, "http://localhost");
const offset = Number(parsed.searchParams.get("frame_offset"));
const pageFrames = Array.from({ length: Math.min(2, 5 - offset) }, (_, index) => ({
id: `frame-${offset + index}`,
duration_ms: 50 + offset + index,
thumbnail_url: `/thumbnail-${offset + index}.png`,
}));
return {
revision: "revision-a",
frame_count: 5,
frame_total: 5,
frames: pageFrames,
};
},
});
assert.deepEqual(frames.map((frame) => frame.id), [
"frame-0", "frame-1", "frame-2", "frame-3", "frame-4",
]);
assert.deepEqual(calls.map(({ url }) => new URL(url, "http://localhost").searchParams.get("frame_offset")), ["0", "2", "4"]);
assert.equal(ANIMATION_PREVIEW_PAGE_SIZE, 500);
assert.equal(ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT, 2);
assert.equal(ANIMATION_PREVIEW_MIN_MAX_CONCURRENT, 1);
assert.equal(ANIMATION_PREVIEW_MAX_MAX_CONCURRENT, 50);
assert.equal(ANIMATION_PREVIEW_PRELOAD_COUNT, 3);
});
test("validates configurable animation preview concurrency", () => {
for (const value of [1, 2, 50]) {
assert.equal(normalizeAnimationPreviewMaxConcurrent(value), value);
}
for (const value of [0, 51, true, 2.5, "2", null, undefined]) {
assert.throws(() => normalizeAnimationPreviewMaxConcurrent(value));
}
});
test("rejects mixed revisions instead of showing a partial animation", async () => {
const animation = { id: "animation-a", revision: "revision-a", frame_count: 3 };
let call = 0;
await assert.rejects(
loadCompleteAnimationPreview(animation, {
pageSize: 2,
fetchJson: async () => {
call += 1;
return {
revision: call === 1 ? "revision-a" : "revision-b",
frame_count: 3,
frame_total: 3,
frames: call === 1
? [{ id: "a", duration_ms: 50 }, { id: "b", duration_ms: 50 }]
: [{ id: "c", duration_ms: 50 }],
};
},
}),
(error) => error.code === "ANIMATION_PREVIEW_STALE",
);
});
test("validates frame duration only when explicitly called", () => {
assert.deepEqual(validateFrameDuration("500"), { valid: true, duration: 500, error: "" });
assert.deepEqual(validateFrameDuration("0.050", "s"), { valid: true, duration: 50, error: "" });
assert.deepEqual(validateFrameDuration("604800", "s"), { valid: true, duration: 604800000, error: "" });
assert.equal(formatFrameDuration(50, "s"), "0.05");
assert.equal(formatFrameDuration(1230, "s"), "1.23");
assert.equal(formatFrameDuration(604800000, "ms"), "604800000");
for (const value of ["", "49", "604800001", "50.5", "abc"]) {
const result = validateFrameDuration(value);
assert.equal(result.valid, false);
assert.equal(result.duration, null);
}
for (const value of ["0.049", "0.1234", "604800.001", "1e2", ".5"]) {
assert.equal(validateFrameDuration(value, "s").valid, false);
}
});
test("moves selected frames as one ordered group", () => {
const ids = ["a", "b", "c", "d", "e"];
assert.deepEqual(reorderSelectedFrameIds(ids, ["b", "d"], null), ["b", "d", "a", "c", "e"]);
assert.deepEqual(reorderSelectedFrameIds(ids, ["d", "b"], "c"), ["a", "c", "b", "d", "e"]);
assert.equal(reorderSelectedFrameIds(ids, ["b", "b"], null), null);
assert.equal(reorderSelectedFrameIds(ids, ["b"], "b"), null);
assert.equal(reorderSelectedFrameIds(ids, ["missing"], null), null);
});
test("protects an eight pixel ring around interactive drag controls", () => {
const rect = { left: 20, top: 30, right: 80, bottom: 60 };
assert.equal(DRAG_START_GUARD_PX, 8);
assert.equal(pointInsideExpandedRect(12, 22, rect), true);
assert.equal(pointInsideExpandedRect(88, 68, rect), true);
assert.equal(pointInsideExpandedRect(11.99, 40, rect), false);
assert.equal(pointInsideExpandedRect(40, 68.01, rect), false);
});
test("reorders mixed library keys without mutating the source list", () => {
const keys = ["template:a", "animation:b", "template:c"];
assert.deepEqual(reorderByKey(keys, "template:c", "template:a"), [
"template:c", "template:a", "animation:b",
]);
assert.deepEqual(keys, ["template:a", "animation:b", "template:c"]);
assert.equal(reorderByKey(keys, "missing", "template:a"), null);
assert.equal(reorderByKey(keys, "template:a", "template:a"), null);
});
test("restores drag eligibility after protected pointer cancellation and drag end", async () => {
const originalWindow = globalThis.window;
const windowListeners = new Map();
globalThis.window = {
addEventListener(type, listener) { windowListeners.set(type, listener); },
removeEventListener(type, listener) {
if (windowListeners.get(type) === listener) windowListeners.delete(type);
},
};
const listeners = new Map();
const classes = new Set();
const control = { getBoundingClientRect: () => ({ left: 20, top: 20, right: 60, bottom: 50 }) };
const card = {
draggable: false,
querySelectorAll: () => [control],
addEventListener(type, listener) { listeners.set(type, listener); },
classList: {
add(value) { classes.add(value); },
remove(value) { classes.delete(value); },
},
};
try {
protectCardDragStart(card);
let prevented = false;
listeners.get("pointerdown")({ pointerId: 7, clientX: 64, clientY: 40, target: null, preventDefault() { prevented = true; } });
assert.equal(card.draggable, false);
assert.equal(prevented, false);
windowListeners.get("pointercancel")({ pointerId: 7 });
await Promise.resolve();
assert.equal(card.draggable, true);
listeners.get("pointerdown")({ pointerId: 8, clientX: 100, clientY: 100, target: null });
listeners.get("dragstart")({ clientX: 100, clientY: 100, target: null, preventDefault() { prevented = true; } });
assert.equal(classes.has("is-dragging"), true);
listeners.get("dragend")({});
assert.equal(classes.has("is-dragging"), false);
assert.equal(card.draggable, true);
} finally {
globalThis.window = originalWindow;
}
});
test("animation workspace exposes two previews and the simplified control order", async () => {
const [index, animations, templates, preview, feedback, style] = await Promise.all([
staticSource("index.html"),
staticSource("views/animations.js"),
staticSource("views/templates.js"),
staticSource("animation-thumbnail-preview.js"),
staticSource("interaction-feedback.js"),
staticSource("style.css"),
]);
assert.match(index, /id="animation-preview-image"/);
assert.match(index, /id="animation-board-preview"/);
assert.match(index, /id="template-dialog-note"/);
assert.match(index, /id="animation-batch-copy"/);
assert.match(index, /id="animation-position-dialog"/);
assert.match(index, /id="animation-frame-pagination"/);
assert.match(index, /id="animation-page-prev"/);
assert.match(index, /id="animation-page-next"/);
assert.match(index, />新建动图<\/button>/);
assert.doesNotMatch(index, /id="animation-apply-all"/);
assert.ok(index.indexOf('class="animation-toolbar panel"') < index.indexOf('id="animation-frame-panel"'));
assert.ok(index.indexOf('id="animation-frame-panel"') < index.indexOf('class="animation-existing"'));
assert.match(animations, /\/frame-durations/);
assert.match(animations, /frame_limit:\s*String\(limit\)/);
assert.match(animations, /\/frame-move/);
assert.match(animations, /frame\.position/);
assert.match(animations, /frames\/copy/);
assert.match(animations, /frames\/batch-delete/);
assert.match(animations, /复制到已选帧/);
assert.match(animations, /reorderSelectedFrameIds/);
assert.match(animations, /protectCardDragStart\(card\)/);
assert.match(animations, /createAnimationThumbnailBox/);
assert.match(animations, /animation-folder-name/);
assert.match(animations, /dataset\.hoverTooltip = item\.name/);
assert.match(templates, /createAnimationThumbnailBox/);
assert.match(preview, /IntersectionObserver/);
assert.match(preview, /rootMargin:\s*"0px"/);
assert.match(preview, /visibilitychange/);
assert.match(preview, /document\.visibilityState === "visible"/);
assert.match(preview, /prefers-reduced-motion: reduce/);
assert.match(preview, /shouldRun\(record\)/);
assert.match(preview, /selected < this\.maxConcurrent/);
assert.match(preview, /activeInitialLoads < this\.maxConcurrent/);
assert.match(preview, /activeLoads < this\.maxConcurrent/);
assert.match(preview, /setAnimationPreviewMaxConcurrent/);
assert.match(preview, /record\.preloads\.clear\(\)[\s\S]*record\.schedule = null/);
assert.match(preview, /initialUrl: animation\.thumbnail_url/);
assert.doesNotMatch(preview, /image\.src = animation\.thumbnail_url/);
assert.match(preview, /isRecordVisible\(record\)/);
assert.match(preview, /getBoundingClientRect\(\)/);
assert.match(feedback, /event\.pointerType === "mouse"/);
assert.match(feedback, /\(hover: hover\) and \(pointer: fine\)/);
assert.match(style, /\.animation-folder-name\s*\{[\s\S]*text-overflow: ellipsis;[\s\S]*white-space: nowrap;/);
assert.match(templates, /当前帧将被保存为一个静态图模板/);
assert.match(templates, /animationEditState\(\)/);
assert.match(templates, /api\/library\/order/);
assert.match(templates, /prepareDemoCard/);
assert.match(templates, /prepareSortableLibraryCard/);
assert.match(templates, /两个演示案例固定在最前/);
});