feat(P0-A): 整合第一版并冻结最终发布 #1

Open
tuyixuan wants to merge 115 commits from codex/wp7-07 into codex/wp0-09
17 changed files with 758 additions and 19 deletions
Showing only changes of commit 609c71a3f7 - Show all commits
+1
View File
@@ -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",
+10
View File
@@ -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;
+13 -9
View File
@@ -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<string>();
const [notice, setNotice] = useState("");
const [activePanel, setActivePanel] = useState<EditorAssetPanel>("background");
const [stickerScrollTop, setStickerScrollTop] = useState(0);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [guides, setGuides] = useState<string[]>([]);
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) => <button aria-current={item.panel === activePanel ? "page" : undefined} key={item.label} onClick={() => setActivePanel(item.panel)} type="button">{item.label}</button>)}
]).map((item) => <button aria-current={item.panel === activePanel ? "page" : undefined} key={item.label} onClick={() => { setActivePanel(item.panel); if (item.panel === "stickers") setStickerScrollTop(0); }} type="button">{item.label}</button>)}
</nav>
{activePanel === "background" ? <section><h2></h2><button className="editor-source active" type="button"><span className="editor-thumb" style={{ backgroundImage: `url(${imageUrl})` }} /><span></span></button></section> : null}
{activePanel === "history" ? <section><h2></h2><div className="editor-history-list">
@@ -834,9 +831,16 @@ export function EditorPage({ projectId }: { projectId: string }) {
templates={P0A_TEXT_TEMPLATES}
{...(templateCategory ? { category: templateCategory } : {})}
/> : null}
{activePanel === "stickers" ? <section><h2></h2><div className="editor-sticker-grid">
{stickerFixtures.map((sticker) => <button aria-label={`添加贴纸 ${sticker.assetId}`} disabled={!canEdit || canvasState.elements.length >= 50} key={sticker.assetId} onClick={() => addSticker(sticker.assetId)} type="button"><img alt="" className="editor-sticker-preview" src={`/api/v1/assets/public/fixture-v1/${sticker.assetId}`} /><strong>{sticker.assetId}</strong><span>{sticker.label}</span></button>)}
</div>{canvasState.elements.length >= 50 ? <p className="editor-limit" role="status"> 50 </p> : null}</section> : null}
{activePanel === "stickers" ? (() => {
const visibleStickers = stickerWindow(P0A_STATIC_STICKER_CATALOG, stickerScrollTop, 280);
return <section><h2></h2><p aria-live="polite" className="editor-sticker-count"> {P0A_STATIC_STICKER_COUNT.toLocaleString("zh-CN")} </p><div className="editor-sticker-virtual-list" data-testid="static-sticker-list" onScroll={(event) => setStickerScrollTop(event.currentTarget.scrollTop)} role="list">
<div style={{ paddingTop: visibleStickers.top_spacer_px, paddingBottom: visibleStickers.bottom_spacer_px }}>
<div className="editor-sticker-grid">
{visibleStickers.items.map((sticker) => <button aria-label={`添加贴纸 ${sticker.stable_id}`} data-sticker-id={sticker.stable_id} disabled={!canEdit || canvasState.elements.length >= 50} key={sticker.stable_id} onClick={() => addSticker(sticker.stable_id)} type="button"><img alt="" className="editor-sticker-preview" decoding="async" loading="lazy" src={sticker.thumbnail_reference.url} /><strong>{sticker.stable_id}</strong><span>part{sticker.part} · {sticker.order}</span></button>)}
</div>
</div>
</div>{canvasState.elements.length >= 50 ? <p className="editor-limit" role="status"> 50 </p> : null}</section>;
})() : null}
{activePanel === "color" ? <ColorCardPanel canAdd={canEdit && canvasState.elements.length < 50} hasBackground={Boolean(canvasState.background.asset_id)} onAdd={(definition) => { void addColorCard(definition); }} /> : null}
{activePanel === "dynamic" ? <DynamicStickerPanel canAdd={canEdit && canvasState.elements.length < 50} fontStatuses={fontStatuses} onAdd={chooseDynamicSticker} /> : null}
</aside>
+47
View File
@@ -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<StaticStickerCatalogItem> {
return getVirtualStickerWindow(items, { columns: 2, itemHeight: 144, overscanScreens: 2, scrollTop, viewportHeight });
}
export { staticStickerOriginalUrl, staticStickerThumbnailUrl };
+6 -3
View File
@@ -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",
+1
View File
@@ -13,6 +13,7 @@
"compile": "node dist/cli.js"
},
"dependencies": {
"@dada/static-sticker-catalog": "workspace:*",
"csv-parse": "7.0.2"
},
"devDependencies": {
+14 -6
View File
@@ -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`);
+179 -1
View File
@@ -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<string, unknown>;
type CsvRow = Record<string, string>;
@@ -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<string, number> = {};
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<string, string[]>())).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);
@@ -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"
}
}
@@ -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<string, number>;
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<T extends { stable_id: string }> {
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<T extends { stable_id: string }>(items: readonly T[], options: VirtualStickerWindowOptions): VirtualStickerWindow<T> {
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<StaticStickerCatalogItem, "resource_version" | "stable_id">): string {
return `/api/v1/assets/public/${encodeURIComponent(item.resource_version)}/${encodeURIComponent(item.stable_id)}?variant=thumbnail`;
}
export function staticStickerOriginalUrl(item: Pick<StaticStickerCatalogItem, "resource_version" | "stable_id">): string {
return `/api/v1/assets/public/${encodeURIComponent(item.resource_version)}/${encodeURIComponent(item.stable_id)}`;
}
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2024"],
"declaration": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
+12
View File
@@ -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':
+6
View File
@@ -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 = {
+83
View File
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1440"><rect width="1080" height="1440" fill="#d8e8f2"/><rect x="0" y="0" width="1080" height="720" fill="#b3d8ea"/><rect x="0" y="720" width="1080" height="720" fill="#f4e7cf"/></svg>`;
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)); });
+79
View File
@@ -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);
@@ -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);
});
});