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, /两个演示案例固定在最前/); });