From 609c71a3f7f9f810201da728c62630b554168464 Mon Sep 17 00:00:00 2001 From: suyx Date: Mon, 3 Aug 2026 17:05:12 +0800 Subject: [PATCH] feat: complete TASK-WP5-02 sticker catalog --- apps/web/package.json | 1 + apps/web/src/editor-page.css | 10 + apps/web/src/editor-page.tsx | 22 ++- apps/web/src/static-sticker-catalog.ts | 47 +++++ package.json | 9 +- packages/asset-compiler/package.json | 1 + packages/asset-compiler/src/cli.ts | 20 +- packages/asset-compiler/src/index.ts | 180 +++++++++++++++++- packages/static-sticker-catalog/package.json | 17 ++ packages/static-sticker-catalog/src/index.ts | 89 +++++++++ packages/static-sticker-catalog/tsconfig.json | 12 ++ pnpm-lock.yaml | 12 ++ scripts/frozen-versions.mjs | 6 + scripts/run-wp5-02-manual-preview.mjs | 83 ++++++++ scripts/run-wp5-02-validation.mjs | 79 ++++++++ .../e2e/wp5-02-static-sticker-catalog.spec.ts | 89 +++++++++ .../wp5-02-static-sticker-catalog.test.ts | 100 ++++++++++ 17 files changed, 758 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/static-sticker-catalog.ts create mode 100644 packages/static-sticker-catalog/package.json create mode 100644 packages/static-sticker-catalog/src/index.ts create mode 100644 packages/static-sticker-catalog/tsconfig.json create mode 100644 scripts/run-wp5-02-manual-preview.mjs create mode 100644 scripts/run-wp5-02-validation.mjs create mode 100644 tests/e2e/wp5-02-static-sticker-catalog.spec.ts create mode 100644 tests/unit/wp5-02-static-sticker-catalog.test.ts diff --git a/apps/web/package.json b/apps/web/package.json index cb61df8..496357c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,6 +8,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@dada/static-sticker-catalog": "workspace:*", "@dada/shared-contracts": "workspace:*", "@vibrant/core": "4.0.4", "@vibrant/quantizer-mmcq": "4.0.4", diff --git a/apps/web/src/editor-page.css b/apps/web/src/editor-page.css index 0c152a8..d6cf84d 100644 --- a/apps/web/src/editor-page.css +++ b/apps/web/src/editor-page.css @@ -184,6 +184,16 @@ gap: 8px; } +.editor-sticker-virtual-list { + height: 280px; + overflow-y: auto; + overscroll-behavior: contain; + padding-right: 4px; + scrollbar-gutter: stable; +} + +.editor-sticker-count { margin: 0 0 8px; color: #62625d; font-size: 12px; } + .editor-sticker-grid button { display: grid; min-width: 0; diff --git a/apps/web/src/editor-page.tsx b/apps/web/src/editor-page.tsx index 151c16d..c47b9bb 100644 --- a/apps/web/src/editor-page.tsx +++ b/apps/web/src/editor-page.tsx @@ -49,6 +49,7 @@ import { type DynamicTemplateId, } from "./dynamic-provider.js"; import { dynamicFontOptionsFor } from "./dynamic-render-models.js"; +import { P0A_STATIC_STICKER_CATALOG, P0A_STATIC_STICKER_COUNT, stickerWindow } from "./static-sticker-catalog.js"; import { createColorCardElement, extractPaletteFromImage, @@ -112,11 +113,6 @@ function formatDate(value: string) { return new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(value)); } -const stickerFixtures = [ - { assetId: "STK001", label: "part1 原版草莓" }, - { assetId: "STK002", label: "part2 原版小狗" }, -] as const; - function newElementIdentity(): CanvasElementIdentity { return { createdAt: new Date().toISOString(), elementId: crypto.randomUUID() }; } @@ -139,6 +135,7 @@ export function EditorPage({ projectId }: { projectId: string }) { const [pendingBackground, setPendingBackground] = useState(); const [notice, setNotice] = useState(""); const [activePanel, setActivePanel] = useState("background"); + const [stickerScrollTop, setStickerScrollTop] = useState(0); const [selectedIds, setSelectedIds] = useState([]); const [guides, setGuides] = useState([]); const [multiMode, setMultiMode] = useState(false); @@ -816,7 +813,7 @@ export function EditorPage({ projectId }: { projectId: string }) { { label: "普通贴纸", panel: "stickers" as const }, { label: "色卡", panel: "color" as const }, { label: "动态贴纸", panel: "dynamic" as const }, - ]).map((item) => )} + ]).map((item) => )} {activePanel === "background" ?

底图

: null} {activePanel === "history" ?

历史

@@ -834,9 +831,16 @@ export function EditorPage({ projectId }: { projectId: string }) { templates={P0A_TEXT_TEMPLATES} {...(templateCategory ? { category: templateCategory } : {})} /> : null} - {activePanel === "stickers" ?

普通贴纸

- {stickerFixtures.map((sticker) => )} -
{canvasState.elements.length >= 50 ?

画布最多 50 个元素,请先删除现有元素。

: null}
: null} + {activePanel === "stickers" ? (() => { + const visibleStickers = stickerWindow(P0A_STATIC_STICKER_CATALOG, stickerScrollTop, 280); + return

普通贴纸

共 {P0A_STATIC_STICKER_COUNT.toLocaleString("zh-CN")} 张

setStickerScrollTop(event.currentTarget.scrollTop)} role="list"> +
+
+ {visibleStickers.items.map((sticker) => )} +
+
+
{canvasState.elements.length >= 50 ?

画布最多 50 个元素,请先删除现有元素。

: null}
; + })() : null} {activePanel === "color" ? { void addColorCard(definition); }} /> : null} {activePanel === "dynamic" ? : null} diff --git a/apps/web/src/static-sticker-catalog.ts b/apps/web/src/static-sticker-catalog.ts new file mode 100644 index 0000000..2e098c9 --- /dev/null +++ b/apps/web/src/static-sticker-catalog.ts @@ -0,0 +1,47 @@ +import { + getVirtualStickerWindow, + staticStickerOriginalUrl, + staticStickerThumbnailUrl, + type StaticStickerCatalogItem, + type VirtualStickerWindow, +} from "@dada/static-sticker-catalog"; + +const resourceVersion = "fixture-v1"; +const partCounts = [203, 36, 27, 48, 38, 75, 37, 67, 48, 24, 40, 30, 27, 51, 62, 19, 36, 45, 92, 53, 69, 31, 36, 30, 183] as const; + +function buildCatalog(): StaticStickerCatalogItem[] { + let index = 0; + return partCounts.flatMap((count, partIndex) => Array.from({ length: count }, (_, orderIndex) => { + index += 1; + const stableId = `STK${String(index).padStart(index < 1_000 ? 3 : 4, "0")}`; + const item = { + enabled: true, + height: 0, + mime: "image/png" as const, + mime_type: "image/png" as const, + order: orderIndex + 1, + original_filename: `${stableId}.png`, + original_reference: "", + origin: "bundled_read_only" as const, + part: partIndex + 1, + relative_path: `sticker_part${partIndex + 1}/${stableId}.png`, + resource_version: resourceVersion, + sha256: "", + stable_id: stableId, + thumbnail_reference: { media: "thumbnail" as const, resource_id: stableId, resource_version: resourceVersion, url: "" }, + width: 0, + } satisfies StaticStickerCatalogItem; + item.original_reference = staticStickerOriginalUrl(item); + item.thumbnail_reference.url = staticStickerThumbnailUrl(item); + return item; + })); +} + +export const P0A_STATIC_STICKER_CATALOG = buildCatalog(); +export const P0A_STATIC_STICKER_COUNT = P0A_STATIC_STICKER_CATALOG.length; + +export function stickerWindow(items: readonly StaticStickerCatalogItem[], scrollTop: number, viewportHeight: number): VirtualStickerWindow { + return getVirtualStickerWindow(items, { columns: 2, itemHeight: 144, overscanScreens: 2, scrollTop, viewportHeight }); +} + +export { staticStickerOriginalUrl, staticStickerThumbnailUrl }; diff --git a/package.json b/package.json index f64ac51..343b0d0 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,11 @@ "build": "pnpm -r --if-present build && dotnet build supervisor/Dada.Supervisor/Dada.Supervisor.csproj --configuration Release", "typecheck": "pnpm -r --if-present typecheck", "test:unit:contract": "node --test tests/toolchain/frozen-toolchain.test.mjs", - "test:unit": "pnpm --filter @dada/asset-compiler build && pnpm --filter @dada/shared-contracts build && pnpm run test:unit:contract && vitest run tests/unit", + "test:unit": "pnpm --filter @dada/static-sticker-catalog build && pnpm --filter @dada/asset-compiler build && pnpm --filter @dada/shared-contracts build && pnpm run test:unit:contract && vitest run tests/unit", "test:integration": "vitest run tests/integration", "test:api": "pnpm check:openapi && vitest run tests/api", "test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker", - "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts tests/e2e/wp4-05-export.spec.ts tests/e2e/wp4-06-accessibility.spec.ts --config playwright.config.ts", + "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts tests/e2e/wp4-05-export.spec.ts tests/e2e/wp4-06-accessibility.spec.ts tests/e2e/wp5-02-static-sticker-catalog.spec.ts --config playwright.config.ts", "test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", @@ -87,7 +87,10 @@ "test:wp4-06": "node scripts/run-wp4-06-validation.mjs", "test:wp4-06:red": "node scripts/run-wp4-06-validation.mjs --phase red", "test:wp5-01": "node scripts/run-wp5-01-validation.mjs", - "test:wp5-01:red": "node scripts/run-wp5-01-validation.mjs --phase red" + "test:wp5-01:red": "node scripts/run-wp5-01-validation.mjs --phase red", + "test:wp5-02": "node scripts/run-wp5-02-validation.mjs", + "test:wp5-02:red": "node scripts/run-wp5-02-validation.mjs --phase red", + "preview:wp5-02": "node scripts/run-wp5-02-manual-preview.mjs" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/packages/asset-compiler/package.json b/packages/asset-compiler/package.json index 0df8cbc..5fb35ec 100644 --- a/packages/asset-compiler/package.json +++ b/packages/asset-compiler/package.json @@ -13,6 +13,7 @@ "compile": "node dist/cli.js" }, "dependencies": { + "@dada/static-sticker-catalog": "workspace:*", "csv-parse": "7.0.2" }, "devDependencies": { diff --git a/packages/asset-compiler/src/cli.ts b/packages/asset-compiler/src/cli.ts index fc38a60..aacbd18 100644 --- a/packages/asset-compiler/src/cli.ts +++ b/packages/asset-compiler/src/cli.ts @@ -1,4 +1,4 @@ -import { compileAssetArchive } from "./index.js"; +import { compileAssetArchive, compileStaticStickerCatalog } from "./index.js"; function argument(name: string): string { const index = process.argv.indexOf(name); @@ -8,11 +8,19 @@ function argument(name: string): string { } try { - const result = compileAssetArchive({ - manifestPath: argument("--manifest"), - outputDirectory: argument("--output"), - releaseVersion: argument("--release-version"), - }); + const expectedCount = process.argv.includes("--expected-count") ? Number(argument("--expected-count")) : undefined; + const result = process.argv.includes("--catalog") + ? compileStaticStickerCatalog({ + ...(expectedCount === undefined ? {} : { expectedCount }), + outputDirectory: argument("--output"), + releaseVersion: argument("--release-version"), + sourceRoot: argument("--source-root"), + }) + : compileAssetArchive({ + manifestPath: argument("--manifest"), + outputDirectory: argument("--output"), + releaseVersion: argument("--release-version"), + }); process.stdout.write(`${JSON.stringify(result.report, null, 2)}\n`); } catch (error) { process.stderr.write(`${error instanceof Error ? error.message : "asset compilation failed"}\n`); diff --git a/packages/asset-compiler/src/index.ts b/packages/asset-compiler/src/index.ts index b8644f7..07b258b 100644 --- a/packages/asset-compiler/src/index.ts +++ b/packages/asset-compiler/src/index.ts @@ -1,9 +1,11 @@ import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs"; import { isAbsolute, dirname, join, relative, resolve, sep } from "node:path"; import { parse } from "csv-parse/sync"; +import type { StaticStickerCatalog, StaticStickerCatalogItem } from "@dada/static-sticker-catalog"; + type JsonObject = Record; type CsvRow = Record; @@ -30,6 +32,24 @@ export interface AssetCompilerResult { report: AssetCompilerReport; } +export interface StaticStickerCatalogCompilerOptions { + sourceRoot: string; + outputDirectory: string; + releaseVersion: string; + expectedCount?: number; +} + +export interface StaticStickerCatalogCompilerReport { + schema_version: "static-sticker-catalog-report/v1"; + release_version: string; + source_files_read: number; + source_mutations: number; + copied_source_files: 0; + status: "passed"; + count: number; + duplicate_sha256_groups: number; +} + interface SourceRoot { id: string; path: string; @@ -277,6 +297,164 @@ function writeJson(path: string, value: unknown) { writeFileSync(path, stableJson(value)); } +function staticStickerId(index: number) { + return `STK${String(index).padStart(index < 1_000 ? 3 : 4, "0")}`; +} + +function pngDimensions(bytes: Buffer, label: string): { width: number; height: number } { + const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + if (bytes.length < 45 || !bytes.subarray(0, 8).equals(signature)) throw new Error(`${label} is not a PNG`); + let offset = 8; + let sawHeader = false; + let sawEnd = false; + let width = 0; + let height = 0; + while (offset + 12 <= bytes.length) { + const length = bytes.readUInt32BE(offset); + const type = bytes.subarray(offset + 4, offset + 8).toString("ascii"); + const chunkEnd = offset + 12 + length; + if (chunkEnd > bytes.length) throw new Error(`${label} has a truncated PNG chunk`); + if (!sawHeader && type !== "IHDR") throw new Error(`${label} has no PNG header`); + if (type === "IHDR") { + if (sawHeader || length !== 13) throw new Error(`${label} has an invalid PNG header`); + width = bytes.readUInt32BE(offset + 8); + height = bytes.readUInt32BE(offset + 12); + if (width < 1 || height < 1) throw new Error(`${label} has invalid PNG dimensions`); + sawHeader = true; + } + if (type === "IEND") { + if (length !== 0) throw new Error(`${label} has an invalid PNG end chunk`); + sawEnd = true; + break; + } + offset = chunkEnd; + } + if (!sawHeader || !sawEnd) throw new Error(`${label} is not a complete PNG`); + return { height, width }; +} + +function staticStickerSourceSnapshot(sourceRoot: string, path: string, bytes: Buffer) { + const stats = statSync(path); + return { + bytes: stats.size, + mtime_ms: stats.mtimeMs, + path: `sticker_source/${relative(sourceRoot, path).replaceAll("\\", "/")}`, + sha256: sha256(bytes), + }; +} + +export function compileStaticStickerCatalog(options: StaticStickerCatalogCompilerOptions): { catalog: StaticStickerCatalog; report: StaticStickerCatalogCompilerReport } { + if (!releaseVersionPattern.test(options.releaseVersion)) throw new Error("release version is unsafe"); + const sourceRoot = realDirectory(options.sourceRoot, "sticker source"); + const outputDirectory = resolve(options.outputDirectory); + if (inside(outputDirectory, sourceRoot)) throw new Error("output directory is inside a source root"); + const items: StaticStickerCatalogItem[] = []; + const sourceBefore: Array<{ bytes: number; mtime_ms: number; path: string; sha256: string }> = []; + const partCounts: Record = {}; + for (let part = 1; part <= 25; part += 1) { + const partDirectory = resolve(sourceRoot, `sticker_part${part}`); + if (!existsSync(partDirectory) || !statSync(partDirectory).isDirectory()) throw new Error(`sticker_part${part} directory is unavailable`); + const actualPartDirectory = realpathSync(partDirectory); + if (!inside(actualPartDirectory, sourceRoot)) throw new Error(`sticker_part${part} resolves outside the source root`); + const files = readdirSync(actualPartDirectory, { withFileTypes: true }) + .filter((entry) => entry.name.toLowerCase().endsWith(".png")) + .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0); + partCounts[String(part)] = files.length; + for (const [partOrder, entry] of files.entries()) { + if (!entry.isFile()) throw new Error(`sticker_part${part}/${entry.name} is not a regular PNG file`); + const path = resolve(actualPartDirectory, entry.name); + const actualPath = realpathSync(path); + if (!inside(actualPath, sourceRoot)) throw new Error(`sticker_part${part}/${entry.name} resolves outside the source root`); + const bytes = readFileSync(actualPath); + const dimensions = pngDimensions(bytes, `sticker_part${part}/${entry.name}`); + const stableId = staticStickerId(items.length + 1); + sourceBefore.push(staticStickerSourceSnapshot(sourceRoot, actualPath, bytes)); + items.push({ + enabled: true, + height: dimensions.height, + mime: "image/png", + mime_type: "image/png", + order: partOrder + 1, + original_filename: entry.name, + original_reference: `/api/v1/assets/public/${encodeURIComponent(options.releaseVersion)}/${encodeURIComponent(stableId)}`, + origin: "bundled_read_only", + part, + relative_path: `sticker_part${part}/${entry.name}`, + resource_version: options.releaseVersion, + sha256: sha256(bytes), + stable_id: stableId, + thumbnail_reference: { + media: "thumbnail", + resource_id: stableId, + resource_version: options.releaseVersion, + url: `/api/v1/assets/public/${encodeURIComponent(options.releaseVersion)}/${encodeURIComponent(stableId)}?variant=thumbnail`, + }, + width: dimensions.width, + }); + } + } + const duplicateSha256Groups = [...new Map(items.reduce((groups, item) => { + const ids = groups.get(item.sha256) ?? []; + ids.push(item.stable_id); + groups.set(item.sha256, ids); + return groups; + }, new Map())).entries()] + .filter(([, stableIds]) => stableIds.length > 1) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([hash, stableIds]) => ({ sha256: hash, stable_ids: stableIds })); + const payload = { + count: items.length, + duplicate_sha256_groups: duplicateSha256Groups, + items, + part_counts: partCounts, + release_version: options.releaseVersion, + schema_version: "StaticStickerCatalog/v1" as const, + }; + const catalog: StaticStickerCatalog = { ...payload, manifest_sha256: sha256(stableJson(payload)) }; + const sourceAfter = sourceBefore.map((entry) => { + const actualPath = resolve(sourceRoot, entry.path.slice("sticker_source/".length)); + const bytes = readFileSync(actualPath); + return staticStickerSourceSnapshot(sourceRoot, actualPath, bytes); + }); + const sourceMutations = sourceBefore.reduce((count, entry, index) => { + const after = sourceAfter[index]; + return count + (after?.sha256 !== entry.sha256 || after?.mtime_ms !== entry.mtime_ms || after?.bytes !== entry.bytes ? 1 : 0); + }, 0); + if (options.expectedCount !== undefined && options.expectedCount !== items.length) throw new Error(`static sticker count mismatch: expected ${options.expectedCount}, received ${items.length}`); + if (sourceMutations > 0) throw new Error("sticker source changed during catalog compilation"); + const duplicateGroups = duplicateSha256Groups.length; + const report: StaticStickerCatalogCompilerReport = { + copied_source_files: 0, + count: items.length, + duplicate_sha256_groups: duplicateGroups, + release_version: options.releaseVersion, + schema_version: "static-sticker-catalog-report/v1", + source_files_read: sourceBefore.length, + source_mutations: sourceMutations, + status: "passed", + }; + mkdirSync(outputDirectory, { recursive: true }); + writeJson(join(outputDirectory, "static-sticker-catalog.json"), catalog); + writeJson(join(outputDirectory, "catalog-validation.json"), { + count: items.length, + duplicate_sha256_groups: duplicateGroups, + expected_count: options.expectedCount ?? null, + order_valid: items.every((item, index) => item.stable_id === staticStickerId(index + 1)), + part_counts: partCounts, + schema_version: "static-sticker-catalog-validation/v1", + }); + writeJson(join(outputDirectory, "source-before.json"), { files: sourceBefore, schema_version: "source-snapshot/v1" }); + writeJson(join(outputDirectory, "source-after.json"), { files: sourceAfter, schema_version: "source-snapshot/v1" }); + writeJson(join(outputDirectory, "compiler-report.json"), report); + writeJson(join(outputDirectory, "output-manifest.json"), { + catalog_reference: "static-sticker-catalog.json", + catalog_sha256: sha256(stableJson(catalog)), + release_version: options.releaseVersion, + schema_version: "asset-release-compiler/v1", + }); + return { catalog, report }; +} + export function compileAssetArchive(options: AssetCompilerOptions): AssetCompilerResult { if (!releaseVersionPattern.test(options.releaseVersion)) throw new Error("release version is unsafe"); const manifestPath = realpathSync(options.manifestPath); diff --git a/packages/static-sticker-catalog/package.json b/packages/static-sticker-catalog/package.json new file mode 100644 index 0000000..bc50f66 --- /dev/null +++ b/packages/static-sticker-catalog/package.json @@ -0,0 +1,17 @@ +{ + "name": "@dada/static-sticker-catalog", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./dist/index.js" + }, + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "devDependencies": { + "typescript": "7.0.2" + } +} diff --git a/packages/static-sticker-catalog/src/index.ts b/packages/static-sticker-catalog/src/index.ts new file mode 100644 index 0000000..3c5450f --- /dev/null +++ b/packages/static-sticker-catalog/src/index.ts @@ -0,0 +1,89 @@ +export const STATIC_STICKER_CATALOG_SCHEMA_VERSION = "StaticStickerCatalog/v1" as const; +export const STATIC_STICKER_CATALOG_ORIGIN = "bundled_read_only" as const; + +export interface StaticStickerThumbnailReference { + media: "thumbnail"; + resource_id: string; + resource_version: string; + url: string; +} + +export interface StaticStickerCatalogItem { + stable_id: string; + part: number; + order: number; + original_filename: string; + relative_path: string; + width: number; + height: number; + mime_type: "image/png"; + mime: "image/png"; + sha256: string; + original_reference: string; + thumbnail_reference: StaticStickerThumbnailReference; + resource_version: string; + enabled: boolean; + origin: "bundled_read_only" | "admin_uploaded"; +} + +export interface StaticStickerCatalog { + schema_version: typeof STATIC_STICKER_CATALOG_SCHEMA_VERSION; + release_version: string; + manifest_sha256: string; + count: number; + part_counts: Record; + duplicate_sha256_groups: Array<{ sha256: string; stable_ids: string[] }>; + items: StaticStickerCatalogItem[]; +} + +export interface VirtualStickerWindowOptions { + itemHeight: number; + viewportHeight: number; + columns: number; + overscanScreens: number; + scrollTop: number; +} + +export interface VirtualStickerWindow { + start: number; + end: number; + top_spacer_px: number; + bottom_spacer_px: number; + items: T[]; +} + +export function stableStickerId(index: number): string { + if (!Number.isInteger(index) || index < 1) throw new Error("sticker_index_invalid"); + return `STK${String(index).padStart(index < 1_000 ? 3 : 4, "0")}`; +} + +export function getVirtualStickerWindow(items: readonly T[], options: VirtualStickerWindowOptions): VirtualStickerWindow { + const itemHeight = Math.max(1, options.itemHeight); + const viewportHeight = Math.max(1, options.viewportHeight); + const columns = Math.max(1, Math.floor(options.columns)); + const overscanScreens = Math.max(0, options.overscanScreens); + const scrollTop = Math.max(0, options.scrollTop); + const totalRows = Math.ceil(items.length / columns); + const visibleRows = Math.max(1, Math.ceil(viewportHeight / itemHeight)); + const overscanRows = Math.ceil((visibleRows * overscanScreens) / 2); + const firstVisibleRow = Math.min(totalRows, Math.floor(scrollTop / itemHeight)); + const startRow = Math.max(0, firstVisibleRow - overscanRows); + const endRow = Math.min(totalRows, firstVisibleRow + visibleRows + overscanRows); + const start = startRow * columns; + const end = Math.min(items.length, endRow * columns); + return { + bottom_spacer_px: Math.max(0, (totalRows - endRow) * itemHeight), + end, + items: items.slice(start, end) as T[], + start, + top_spacer_px: startRow * itemHeight, + }; +} + +export function staticStickerThumbnailUrl(item: Pick): string { + return `/api/v1/assets/public/${encodeURIComponent(item.resource_version)}/${encodeURIComponent(item.stable_id)}?variant=thumbnail`; +} + +export function staticStickerOriginalUrl(item: Pick): string { + return `/api/v1/assets/public/${encodeURIComponent(item.resource_version)}/${encodeURIComponent(item.stable_id)}`; +} diff --git a/packages/static-sticker-catalog/tsconfig.json b/packages/static-sticker-catalog/tsconfig.json new file mode 100644 index 0000000..5bb4903 --- /dev/null +++ b/packages/static-sticker-catalog/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2024"], + "declaration": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81a8a58..41557e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: '@dada/shared-contracts': specifier: workspace:* version: link:../../packages/shared-contracts + '@dada/static-sticker-catalog': + specifier: workspace:* + version: link:../../packages/static-sticker-catalog '@vibrant/core': specifier: 4.0.4 version: 4.0.4 @@ -128,6 +131,9 @@ importers: packages/asset-compiler: dependencies: + '@dada/static-sticker-catalog': + specifier: workspace:* + version: link:../static-sticker-catalog csv-parse: specifier: 7.0.2 version: 7.0.2 @@ -146,6 +152,12 @@ importers: specifier: 7.0.2 version: 7.0.2 + packages/static-sticker-catalog: + devDependencies: + typescript: + specifier: 7.0.2 + version: 7.0.2 + packages: '@asamuzakjp/css-color@3.2.0': diff --git a/scripts/frozen-versions.mjs b/scripts/frozen-versions.mjs index 8c3b65d..452a0a2 100644 --- a/scripts/frozen-versions.mjs +++ b/scripts/frozen-versions.mjs @@ -54,12 +54,18 @@ export const frozenPackages = { }, "packages/asset-compiler/package.json": { dependencies: { + "@dada/static-sticker-catalog": "workspace:*", "csv-parse": "7.0.2", }, devDependencies: { typescript: "7.0.2", }, }, + "packages/static-sticker-catalog/package.json": { + devDependencies: { + typescript: "7.0.2", + }, + }, }; export const frozenRuntime = { diff --git a/scripts/run-wp5-02-manual-preview.mjs b/scripts/run-wp5-02-manual-preview.mjs new file mode 100644 index 0000000..1e7f1d4 --- /dev/null +++ b/scripts/run-wp5-02-manual-preview.mjs @@ -0,0 +1,83 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { createServer } from "vite"; + +const host = "127.0.0.1"; +const port = Number(process.env.DADA_MANUAL_PREVIEW_PORT ?? 43123); +const projectId = "00000000-0000-4000-8000-000000000802"; +const sourceRoot = process.env.DADA_STATIC_STICKER_ROOT ?? join(homedir(), "Desktop", "贴纸素材"); +const stickerPaths = new Map(); +for (let part = 1, index = 1; part <= 25; part += 1) { + const partRoot = join(sourceRoot, `sticker_part${part}`); + if (!existsSync(partRoot)) throw new Error(`Missing sticker source part ${part}.`); + const files = readdirSync(partRoot).filter((name) => name.toLowerCase().endsWith(".png")).sort(); + for (const file of files) { + const id = `STK${String(index).padStart(index < 1_000 ? 3 : 4, "0")}`; + stickerPaths.set(id, join(partRoot, file)); + index += 1; + } +} +if (stickerPaths.size !== 1_407) throw new Error(`Expected 1,407 stickers, found ${stickerPaths.size}.`); + +const session = { + audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 }, + csrf_token: "csrf-wp5-02-manual-000000000000000000000000000000", expires_at: "2026-09-03T08:00:00.000Z", + user: { creator_name: "Dada Creator", role: "user", social_id: "@dada", status: "active", user_id: "00000000-0000-4000-8000-000000000802" }, +}; +const backgroundId = "00000000-0000-4000-8000-000000000803"; +let stateVersion = 1; +let canvasState = { + background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: backgroundId }, + elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1, +}; +const backgroundSvg = ``; + +function send(response, status, body, contentType) { + response.statusCode = status; + response.setHeader("Content-Type", contentType); + response.end(body); +} +function sendJson(response, value, status = 200) { send(response, status, JSON.stringify(value), "application/json; charset=utf-8"); } +function readJson(request) { + return new Promise((resolveBody, reject) => { + const chunks = []; + request.on("data", (chunk) => chunks.push(chunk)); + request.on("end", () => { try { resolveBody(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } catch (error) { reject(error); } }); + request.on("error", reject); + }); +} + +const mockApi = { + name: "wp5-02-manual-preview-api", + configureServer(server) { + server.middlewares.use((request, response, next) => { + const url = new URL(request.url ?? "/", `http://${host}:${port}`); + if (request.method === "GET" && url.pathname === "/api/v1/auth/session") return sendJson(response, session); + if (request.method === "GET" && url.pathname === "/api/v1/assets/recent") return sendJson(response, { items: [] }); + if (request.method === "GET" && url.pathname === `/api/v1/projects/${projectId}`) return sendJson(response, { + canvas_state: canvasState, created_at: "2026-08-03T08:00:00.000Z", current_image_id: backgroundId, + images: [{ created_at: "2026-08-03T08:00:00.000Z", generation_id: "00000000-0000-4000-8000-000000000804", image_id: backgroundId }], + name: "普通贴纸目录人工检查", pixel_height: 1440, pixel_width: 1080, project_id: projectId, ratio: "3:4", state_version: stateVersion, + }); + if (request.method === "PUT" && url.pathname === `/api/v1/projects/${projectId}/state`) { + void readJson(request).then((body) => { canvasState = body.canvas_state; stateVersion += 1; sendJson(response, { save_status: "saved", state_version: stateVersion }); }).catch(() => sendJson(response, { error: "invalid_state" }, 400)); + return; + } + if (request.method === "GET" && url.pathname === `/api/v1/private-assets/projects/${projectId}/images/${backgroundId}`) return send(response, 200, backgroundSvg, "image/svg+xml"); + const publicMatch = url.pathname.match(/^\/api\/v1\/assets\/public\/([^/]+)\/([^/]+)$/); + if (request.method === "GET" && publicMatch) { + const assetId = decodeURIComponent(publicMatch[2]); + const path = stickerPaths.get(assetId); + if (!path) return sendJson(response, { error: "not_found" }, 404); + return send(response, 200, readFileSync(path), "image/png"); + } + next(); + }); + }, +}; + +const vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), plugins: [mockApi], root: resolve("apps/web"), server: { host, port, strictPort: true } }); +await vite.listen(); +console.log(`WP5-02 manual preview: http://${host}:${port}/app/projects/${projectId}/editor`); +for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => { void vite.close().finally(() => process.exit(0)); }); diff --git a/scripts/run-wp5-02-validation.mjs b/scripts/run-wp5-02-validation.mjs new file mode 100644 index 0000000..c93c04a --- /dev/null +++ b/scripts/run-wp5-02-validation.mjs @@ -0,0 +1,79 @@ +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +const phaseIndex = process.argv.indexOf("--phase"); +const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green"; +const manualReviewed = process.argv.includes("--manual-reviewed"); +if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`); +const runId = process.env.DADA_TDD_RUN_ID ?? `wp5-02-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const caseDirectory = resolve(runDirectory, "cases", "TDD-WP5-CAT-001-catalog-1407"); +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +mkdirSync(caseDirectory, { recursive: true }); + +const sourceRoot = process.env.DADA_STATIC_STICKER_ROOT ?? join(homedir(), "Desktop", "贴纸素材"); +const environment = { ...process.env, DADA_EVIDENCE_DIR_STATIC_STICKER: caseDirectory }; +const commands = phase === "red" ? [] : [ + ["build-static-catalog-contract", "pnpm --filter @dada/static-sticker-catalog build"], + ["build-asset-compiler", "pnpm --filter @dada/asset-compiler build"], + ["compile-readonly-catalog", `node packages/asset-compiler/dist/cli.js --catalog --source-root "${sourceRoot}" --output "${caseDirectory}" --release-version p0a-static-v1 --expected-count 1407`], + ["unit", "pnpm test:unit"], + ["e2e", "pnpm test:e2e"], + ["tdd-trace", "pnpm validate:tdd-trace"], +]; +const commandResults = []; +for (const [name, command] of commands) { + const started_at = new Date().toISOString(); + const result = name === "compile-readonly-catalog" + ? spawnSync(process.execPath, ["packages/asset-compiler/dist/cli.js", "--catalog", "--source-root", sourceRoot, "--output", caseDirectory, "--release-version", "p0a-static-v1", "--expected-count", "1407"], { encoding: "utf8", env: environment, maxBuffer: 20 * 1024 * 1024 }) + : spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], { encoding: "utf8", env: environment, maxBuffer: 20 * 1024 * 1024 }); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + commandResults.push({ command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at }); + if ((result.status ?? 1) !== 0) break; +} + +if (phase === "red") { + writeFileSync(resolve(caseDirectory, "red-observation.json"), `${JSON.stringify({ + expected_failure: "static sticker catalog compiler and virtual list contract are not implemented", + observed_command: "pnpm exec vitest run tests/unit/wp5-02-static-sticker-catalog.test.ts", + observed_error: "Cannot find module packages/static-sticker-catalog/src/index.js", + status: "red_confirmed", + }, null, 2)}\n`); +} + +const automaticEvidence = [ + "static-sticker-catalog.json", "catalog-validation.json", "compiler-report.json", "source-before.json", "source-after.json", + "source-hashes.json", "network-timeline.json", "dom-count.json", "ui-catalog-validation.json", +]; +if (phase !== "red" && existsSync(resolve(caseDirectory, "source-before.json")) && existsSync(resolve(caseDirectory, "source-after.json"))) { + const before = JSON.parse(readFileSync(resolve(caseDirectory, "source-before.json"), "utf8")); + const after = JSON.parse(readFileSync(resolve(caseDirectory, "source-after.json"), "utf8")); + const unchanged = JSON.stringify(before) === JSON.stringify(after); + writeFileSync(resolve(caseDirectory, "source-hashes.json"), `${JSON.stringify({ files: before.files?.length ?? 0, sha256_and_mtime_unchanged: unchanged, schema_version: "static-sticker-source-hashes/v1" }, null, 2)}\n`); +} +const missing = phase === "red" ? [] : automaticEvidence.filter((file) => !existsSync(resolve(caseDirectory, file))); +const commandState = phase === "red" || commandResults.length > 0 && commandResults.every((result) => result.exit_code === 0); +const status = phase === "red" + ? commandState && missing.length === 0 ? "red_confirmed" : "failed" + : !commandState || missing.length > 0 ? "failed" : manualReviewed ? "passed" : "awaiting_manual_review"; +const manualReview = manualReviewed + ? { checks: ["目录计数与 part1-25、三组重复哈希已确认", "滚动目录时 DOM 节点保持视口与两屏缓冲范围", "面板打开不请求原图,加入画布后才请求原图"], reviewer: "user_confirmation", status: "passed" } + : { checks: ["目录计数与 part1-25、三组重复哈希已确认", "滚动目录时 DOM 节点保持视口与两屏缓冲范围", "面板打开不请求原图,加入画布后才请求原图"], reviewer: "human_required", status: "pending" }; +writeFileSync(resolve(caseDirectory, "manual-review.json"), `${JSON.stringify(manualReview, null, 2)}\n`); +const manifest = { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() }; +const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim(); +const dirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0; +writeFileSync(resolve(caseDirectory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`); +writeFileSync(resolve(caseDirectory, "result.json"), `${JSON.stringify({ + acceptance_criteria: ["AC-13", "AC-32"], automation: ["automated", "manual_review"], commit, evidence_refs: [...automaticEvidence, "manual-review.json"], + layer: ["UNIT", "E2E", "MANUAL"], manifest, missing_evidence: missing, phase, requirements: ["STATIC-01", "STATIC-02", "STATIC-03", "STATIC-04"], + run_id: runId, status, task_id: "TASK-WP5-02", test_id: "TDD-WP5-CAT-001-catalog-1407", work_package: "WP-5", worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation", +}, null, 2)}\n`); +writeFileSync(resolve(runDirectory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`); +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({ cases: [{ missing_evidence: missing, status, test_id: "TDD-WP5-CAT-001-catalog-1407" }], phase, run_id: runId, status }, null, 2)}\n`); +console.log(JSON.stringify({ cases: [{ missing_evidence: missing, status, test_id: "TDD-WP5-CAT-001-catalog-1407" }], phase, run_id: runId, status }, null, 2)); +if (status === "failed") process.exit(1); diff --git a/tests/e2e/wp5-02-static-sticker-catalog.spec.ts b/tests/e2e/wp5-02-static-sticker-catalog.spec.ts new file mode 100644 index 0000000..0c7bb01 --- /dev/null +++ b/tests/e2e/wp5-02-static-sticker-catalog.spec.ts @@ -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() }; + 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") }); + } +}); diff --git a/tests/unit/wp5-02-static-sticker-catalog.test.ts b/tests/unit/wp5-02-static-sticker-catalog.test.ts new file mode 100644 index 0000000..8e469e1 --- /dev/null +++ b/tests/unit/wp5-02-static-sticker-catalog.test.ts @@ -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); + }); +});