初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -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, /两个演示案例固定在最前/);
|
||||
});
|
||||
Reference in New Issue
Block a user