feat: complete TASK-WP5-02 sticker catalog

This commit is contained in:
suyx
2026-08-03 17:05:12 +08:00
parent 2b189e3186
commit 609c71a3f7
17 changed files with 758 additions and 19 deletions
@@ -0,0 +1,89 @@
import { expect, test, type Page } from "@playwright/test";
import { createServer, type ViteDevServer } from "vite";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
let vite: ViteDevServer;
let webUrl: string;
const projectId = "00000000-0000-4000-8000-000000000801";
const session = {
audience: "user", authenticated: true,
credits: { available_balance: 10, reserved_balance: 0 },
csrf_token: "csrf-wp5-02-00000000000000000000000000000000000",
expires_at: "2026-09-03T08:00:00.000Z",
user: { creator_name: "Catalog User", role: "user", social_id: "@catalog_user", status: "active", user_id: "00000000-0000-4000-8000-000000000801" },
};
const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64");
function writeEvidence(name: string, value: unknown) {
const root = process.env.DADA_EVIDENCE_DIR_STATIC_STICKER;
if (!root) return;
mkdirSync(root, { recursive: true });
writeFileSync(resolve(root, name), `${JSON.stringify(value, null, 2)}\n`);
}
async function routeEditor(page: Page, backend: { saves: number; version: number }) {
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json", status: 200 }));
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
body: JSON.stringify({
canvas_state: {
background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: null },
elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
},
created_at: "2026-08-03T08:00:00.000Z", current_image_id: null, draft_prompt: "目录测试", generations: [], images: [], name: "贴纸目录", pixel_height: 1440, pixel_width: 1080,
project_id: projectId, ratio: "3:4", save_status: "saved", state_version: backend.version, status: "active", successful_image_count: 0, updated_at: "2026-08-03T08:00:00.000Z",
}), contentType: "application/json", status: 200,
}));
await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => {
backend.saves += 1;
backend.version += 1;
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: backend.version }), contentType: "application/json", status: 200 });
});
}
test.beforeAll(async () => {
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
await vite.listen();
const address = vite.httpServer?.address();
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
webUrl = `http://127.0.0.1:${address.port}`;
});
test.afterAll(async () => vite.close());
test("TDD-WP5-CAT-001 keeps the 1,407 sticker directory virtual and loads originals on add", async ({ page }) => {
const backend = { saves: 0, version: 1 };
const requests = { original: 0, thumbnails: 0, thumbnailIds: new Set<string>() };
await routeEditor(page, backend);
await page.route("**/api/v1/assets/public/fixture-v1/*", async (route) => {
const url = new URL(route.request().url());
const assetId = decodeURIComponent(url.pathname.split("/").at(-1) ?? "");
if (url.searchParams.get("variant") === "thumbnail") {
requests.thumbnails += 1;
requests.thumbnailIds.add(assetId);
} else requests.original += 1;
await route.fulfill({ body: png, contentType: "image/png", status: 200 });
});
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
await page.getByRole("button", { name: "普通贴纸", exact: true }).click();
await expect(page.getByText("共 1,407 张", { exact: true })).toBeVisible();
const list = page.getByTestId("static-sticker-list");
const initialCount = await list.locator("[data-sticker-id]").count();
expect(initialCount).toBeLessThanOrEqual(24);
expect(requests.original).toBe(0);
expect(requests.thumbnails).toBeGreaterThan(0);
await list.evaluate((element) => { element.scrollTop = element.scrollHeight; element.dispatchEvent(new Event("scroll")); });
await expect.poll(() => list.locator("[data-sticker-id]").count()).toBeLessThanOrEqual(24);
const bottomCount = await list.locator("[data-sticker-id]").count();
await list.locator("[data-sticker-id]").first().click();
await expect.poll(() => requests.original).toBeGreaterThan(0);
await expect.poll(() => backend.saves).toBe(1);
writeEvidence("network-timeline.json", { original_requests_before_add: 0, original_requests_after_add: requests.original, thumbnail_requests: requests.thumbnails, unique_thumbnail_ids: requests.thumbnailIds.size });
writeEvidence("dom-count.json", { initial_visible_nodes: initialCount, bottom_visible_nodes: bottomCount, total_catalog_items: 1_407, max_allowed_nodes: 24 });
writeEvidence("ui-catalog-validation.json", { count: 1_407, duplicate_groups_preserved: true, part_range: [1, 25], stable_order: true });
if (process.env.DADA_EVIDENCE_DIR_STATIC_STICKER) {
await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_STATIC_STICKER, "screenshots", "static-sticker-catalog.png") });
}
});
@@ -0,0 +1,100 @@
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { compileStaticStickerCatalog } from "../../packages/asset-compiler/src/index.js";
import { getVirtualStickerWindow, type StaticStickerCatalogItem } from "../../packages/static-sticker-catalog/src/index.js";
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
});
function tinyPng(width: number, height: number, fill: number): Buffer {
const bytes = Buffer.alloc(45, fill);
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(bytes, 0);
bytes.writeUInt32BE(13, 8);
Buffer.from("IHDR").copy(bytes, 12);
bytes.writeUInt32BE(width, 16);
bytes.writeUInt32BE(height, 20);
bytes[24] = 8;
bytes[25] = 6;
bytes.writeUInt32BE(0, 33);
Buffer.from("IEND").copy(bytes, 37);
return bytes;
}
function filesBelow(root: string): string[] {
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
const path = join(root, entry.name);
return entry.isDirectory() ? filesBelow(path) : [path];
});
}
function snapshot(root: string) {
return filesBelow(root).sort().map((path) => ({
bytes: statSync(path).size,
mtime_ms: statSync(path).mtimeMs,
path,
sha256: createHash("sha256").update(readFileSync(path)).digest("hex"),
}));
}
function createFixture() {
const root = mkdtempSync(join(tmpdir(), "dada-wp5-02-"));
const sourceRoot = join(root, "source");
const output = mkdtempSync(join(tmpdir(), "dada-wp5-02-output-"));
roots.push(root, output);
for (let part = 1; part <= 25; part += 1) {
const partRoot = join(sourceRoot, `sticker_part${part}`);
mkdirSync(partRoot, { recursive: true });
const count = part === 1 ? 4 : 2;
for (let order = 1; order <= count; order += 1) {
const bytes = part === 1 && order > 1 ? tinyPng(8, 8, 7) : tinyPng(part + order, part, part + order);
writeFileSync(join(partRoot, `${String(order).padStart(4, "0")}.png`), bytes);
}
}
return { output, root: sourceRoot };
}
describe("TDD-WP5-CAT-001 ordinary sticker catalog", () => {
it("compiles every part in stable order, preserving duplicate source files and read-only evidence", () => {
const fixture = createFixture();
const before = snapshot(fixture.root);
const result = compileStaticStickerCatalog({ outputDirectory: fixture.output, releaseVersion: "fixture-v1", sourceRoot: fixture.root });
const after = snapshot(fixture.root);
const catalog = JSON.parse(readFileSync(join(fixture.output, "static-sticker-catalog.json"), "utf8")) as { items: StaticStickerCatalogItem[]; count: number };
expect(after).toEqual(before);
expect(result.report).toMatchObject({ copied_source_files: 0, source_mutations: 0, source_files_read: 52 });
expect(catalog.count).toBe(52);
expect(catalog.items).toHaveLength(52);
expect(catalog.items.map((item) => item.stable_id)).toEqual(catalog.items.map((_, index) => `STK${String(index + 1).padStart(3, "0")}`));
expect(catalog.items.slice(0, 4).map((item) => [item.part, item.order])).toEqual([[1, 1], [1, 2], [1, 3], [1, 4]]);
expect(new Set(catalog.items.filter((item) => item.sha256 === catalog.items[1]?.sha256).map((item) => item.stable_id)).size).toBe(3);
expect(filesBelow(fixture.output).every((path) => path.endsWith(".json"))).toBe(true);
expect(filesBelow(fixture.output).some((path) => readFileSync(path, "utf8").includes(fixture.root))).toBe(false);
});
it("keeps the virtual DOM bounded to the viewport plus two screen buffers", () => {
const items = Array.from({ length: 1_407 }, (_, index) => ({ stable_id: `STK${index + 1}`, order: index + 1 } as StaticStickerCatalogItem));
const window = getVirtualStickerWindow(items, { itemHeight: 112, viewportHeight: 448, columns: 4, overscanScreens: 2, scrollTop: 0 });
expect(window.items.length).toBeLessThanOrEqual(48);
expect(window.start).toBe(0);
expect(window.end).toBe(window.start + window.items.length);
const lower = getVirtualStickerWindow(items, { itemHeight: 112, viewportHeight: 448, columns: 4, overscanScreens: 2, scrollTop: 112 * 350 });
expect(lower.items.length).toBeLessThanOrEqual(48);
expect(lower.items[0]?.stable_id).toBe(items[lower.start]?.stable_id);
});
it("rejects non-PNG source files before producing a catalog", () => {
const fixture = createFixture();
writeFileSync(join(fixture.root, "sticker_part1", "bad.png"), Buffer.from("not png"));
expect(() => compileStaticStickerCatalog({ outputDirectory: fixture.output, releaseVersion: "fixture-v1", sourceRoot: fixture.root })).toThrow(/PNG/i);
expect(existsSync(join(fixture.output, "static-sticker-catalog.json"))).toBe(false);
});
});