diff --git a/DEVELOPMENT_CONVENTIONS.md b/DEVELOPMENT_CONVENTIONS.md index 09cf554..fecd001 100644 --- a/DEVELOPMENT_CONVENTIONS.md +++ b/DEVELOPMENT_CONVENTIONS.md @@ -1,11 +1,12 @@ -# Dada 后续问题修改开发约定 +# Dada 开发约定 ## 适用范围 -本约定在“Dada P0-A Windows 单机内测版”完整开发完成后生效,仅适用于该版完成后发现问题所产生的修改和相关工单;该版完成之前的当前第一版开发不适用本约定。 +本约定自当前“Dada P0-A Windows 单机内测版”第一版开发起立即生效,适用于任务计划中的每个 `TASK-*`,也适用于第一版完成后的问题修改和相关工单。 ## 开发约定 +- 每个 `TASK-*` 或后续工单都是独立交付单元。完成测试与回归后,必须形成范围明确的本地 Git 提交,推送到 `intelligrow` 组织仓库的同名 Gitea 远端分支,并记录任务号、完整提交 SHA、远端分支和远端哈希核对结果。不得等第一版全部开发完成后再统一提交;本地未提交、推送失败、仅推送个人命名空间或远端哈希不一致时,不得宣告当前任务完成,也不得开始下一任务。 - 修复任何工单必须在独立的 git worktree 中进行;开工前先用 `git worktree add` 创建专属工作目录,避免污染主工作区、便于多工单并行。修 CI 配置(Dockerfile、Drone 流水线等)可以直接在主仓库改,因为 CI 改动要打 tag 才能触发构建。 - 处理任何工单必须先检查并使用适用的 Superpowers Skill;在分析、提问、制定计划或改代码前,至少先启用 `using-superpowers`,并按任务性质继续使用 `systematic-debugging`、`test-driven-development`、`using-git-worktrees`、`verification-before-completion` 等相关技能。若判断没有适用技能,必须简短说明原因后再继续。 - 新功能需求类工单必须先使用 Superpowers 的 `brainstorming` 技能帮助澄清目标、约束和方案,再进入计划或实现;缺陷类工单必须先使用 `systematic-debugging` 技能复现问题并分析 root cause,再开始修复,禁止在根因未明确时直接改代码。 diff --git a/DevelopmentPlan.md b/DevelopmentPlan.md index 4213511..c3b8bc3 100644 --- a/DevelopmentPlan.md +++ b/DevelopmentPlan.md @@ -2,7 +2,7 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 1.1 | +| 文档版本 | 1.2 | | 适用产品版本 | PRD v1.10,P0-A 本机测试需求冻结稿 | | 功能索引版本 | FeatureSummary 1.1 | | 当前交付 | 仅 P0-A Windows 本机测试版 | @@ -55,6 +55,12 @@ P1 的本地底图上传、空白画布、普通贴纸关键词搜索、公开 Gitea Actions 是已确定的工程交付组件,不是 PRD 产品依赖,也不计入上述三项外部发布阻塞。当前电脑的 Windows 自托管 Runner 在隔离工作目录注册并跑通无真实凭据流水线之前,本地工程初始化和测试脚本可继续,但正式 CI 和 WP-0 工程完成不得宣告。 +### 1.6 任务级 Git/Gitea 交付协议 + +`tasks.md` 中每个 `TASK-*` 是最小工程交付单元。每个任务在开始实现前建立独立的 `codex/` 分支;完成 Red、Green、所属门禁、回归和脱敏扫描后,必须形成只包含该任务及其必要文档同步的本地 Git 提交,并推送到项目指定的 Gitea 组织仓库同名远端分支。当前项目的目标组织命名空间固定为 `intelligrow`,个人命名空间中的仓库或分支不作为任务交付位置。 + +任务交付必须记录 `task_id`、完整提交 SHA、Gitea 仓库、远端分支和远端哈希核对结果;记录不得包含凭据。推送后必须通过远端引用确认 Gitea 分支哈希与本地提交完全一致。只有本地测试 Green、必要真实/人工门禁齐全且远端核对通过,任务才可从 `green` 或 `externally_blocked` 进入 `passed`。仅有工作区改动、本地提交、推送失败、推送到个人命名空间或远端哈希不一致时,均不得宣告任务完成、工作包完成,也不得开始下一个 `TASK-*`。 + ## 2. 技术架构与选型 ### 2.1 固定技术基线 diff --git a/UIDesign.md b/UIDesign.md index 2ce7050..6dee058 100644 --- a/UIDesign.md +++ b/UIDesign.md @@ -2,13 +2,13 @@ | 文档项 | 内容 | | --- | --- | -| 文档版本 | 1.0 | +| 文档版本 | 1.1 | | 产品范围 | P0-A Windows 单机版 | | 设计状态 | 低保真交互与界面规范 | | 主要画布 | Windows 当前稳定版 Chrome / Edge,1920 × 1080 优先验收 | | 产品需求权威 | PRD.md v1.10 | | 功能与状态导航 | FeatureSummary.md 1.1 | -| 工程与接口约束 | DevelopmentPlan.md 1.1 | +| 工程与接口约束 | DevelopmentPlan.md 1.2 | | 本阶段产出 | 页面、布局、组件、状态、文案、焦点顺序和可访问性规范 | | 本阶段不产出 | 高保真视觉稿、前端代码、移动端、P0-B、部署人员 CLI | @@ -18,7 +18,7 @@ | --- | --- | | PRD.md | 31F93674DF1A90B557FEE3AA9E74FB084E246CA8FE09F6BD4B1DC9D56D606565 | | FeatureSummary.md | 6F80E272AAB08A5525B54501D83F16A4F6A7A170596947BBC25F1DA54F2FE844 | -| DevelopmentPlan.md | 7C579E9DF8D00DA9333F587D05FC966123437EE68575D87D73C903C1487FA9D6 | +| DevelopmentPlan.md | 76CCC786E910F3E503921AEF5B9BD22976E364184BA8C0062CDCF1C5F376AC0A | ## 文档使用规则 diff --git a/apps/web/package.json b/apps/web/package.json index 496357c..e1d7397 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,8 +8,10 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@dada/asset-renderer": "workspace:*", "@dada/static-sticker-catalog": "workspace:*", "@dada/shared-contracts": "workspace:*", + "@dada/template-registry": "workspace:*", "@vibrant/core": "4.0.4", "@vibrant/quantizer-mmcq": "4.0.4", "fabric": "7.4.0", diff --git a/apps/web/src/dynamic-provider.ts b/apps/web/src/dynamic-provider.ts index 8420cd8..760502a 100644 --- a/apps/web/src/dynamic-provider.ts +++ b/apps/web/src/dynamic-provider.ts @@ -1,4 +1,5 @@ import type { CanvasState } from "@dada/shared-contracts"; +import { P0A_DYNAMIC_STICKER_IDS } from "@dada/template-registry"; import { DYNAMIC_RESOURCE_VERSION } from "./dynamic-render-models.js"; import type { CanvasElementIdentity } from "./editor-elements.js"; @@ -6,7 +7,7 @@ import type { CanvasElementIdentity } from "./editor-elements.js"; type CanvasElement = CanvasState["elements"][number]; export type DynamicCategory = "identity" | "location" | "other" | "time"; -export type DynamicTemplateId = "DYN001" | "DYN002" | "DYN003" | "DYN004" | "DYN007" | "DYN008" | "DYN011" | "DYN012" | "DYN015" | "DYN016"; +export type DynamicTemplateId = typeof P0A_DYNAMIC_STICKER_IDS[number]; export interface DynamicStickerDefinition { category: DynamicCategory; @@ -44,7 +45,7 @@ export function dyn012DisplayParts(element: CanvasElement) { } as const; } -export const P0A_DYNAMIC_STICKERS: readonly DynamicStickerDefinition[] = [ +const dynamicDefinitions: readonly DynamicStickerDefinition[] = [ { category: "location", displayName: "地点标题", requiresLocationConsent: false, templateId: "DYN001" }, { category: "location", displayName: "英文地点", requiresLocationConsent: false, templateId: "DYN002" }, { category: "location", displayName: "城市地点", requiresLocationConsent: false, templateId: "DYN003" }, @@ -57,6 +58,12 @@ export const P0A_DYNAMIC_STICKERS: readonly DynamicStickerDefinition[] = [ { category: "identity", displayName: "社交 ID", requiresLocationConsent: false, templateId: "DYN016" }, ] as const; +export const P0A_DYNAMIC_STICKERS: readonly DynamicStickerDefinition[] = P0A_DYNAMIC_STICKER_IDS.map((templateId) => { + const definition = dynamicDefinitions.find((item) => item.templateId === templateId); + if (!definition) throw new Error(`missing dynamic sticker definition ${templateId}`); + return definition; +}); + function twoDigits(value: number) { return String(value).padStart(2, "0"); } diff --git a/apps/web/src/palette-provider.ts b/apps/web/src/palette-provider.ts index da767d2..6db03d3 100644 --- a/apps/web/src/palette-provider.ts +++ b/apps/web/src/palette-provider.ts @@ -1,17 +1,12 @@ import { MMCQ } from "@vibrant/quantizer-mmcq"; import type { CanvasState } from "@dada/shared-contracts"; +import { P0A_COLOR_CARD_DEFINITIONS, type ColorCardDefinition } from "@dada/asset-renderer"; import type { CanvasElementIdentity } from "./editor-elements.js"; type CanvasElement = CanvasState["elements"][number]; -export interface ColorCardDefinition { - cardId: "COLOR001" | "COLOR002" | "COLOR008" | "COLOR016"; - displayName: string; - mappingStatus: "confirmed_native_mapping" | "stable_web_style_native_mapping_provisional"; - rendererName: "horizontal_line" | "ticket_strip" | "vertical_stack" | "vertical_ticket"; - styleId: "style_01" | "style_02" | "style_08" | "style_16"; -} +export type { ColorCardDefinition } from "@dada/asset-renderer"; export interface PaletteColor { hex: string; @@ -19,12 +14,7 @@ export interface PaletteColor { rgbValue: number; } -export const P0A_COLOR_CARDS: readonly ColorCardDefinition[] = [ - { cardId: "COLOR001", displayName: "纵向票据", mappingStatus: "confirmed_native_mapping", rendererName: "vertical_ticket", styleId: "style_01" }, - { cardId: "COLOR002", displayName: "纵向色阶", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_stack", styleId: "style_02" }, - { cardId: "COLOR008", displayName: "横向标尺", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "horizontal_line", styleId: "style_08" }, - { cardId: "COLOR016", displayName: "横向票条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "ticket_strip", styleId: "style_16" }, -] as const; +export const P0A_COLOR_CARDS: readonly ColorCardDefinition[] = P0A_COLOR_CARD_DEFINITIONS; // Coordinates are translated from the archived 320x320 renderer around its 160px center. export const COLOR_CARD_SOURCE_GEOMETRY = { diff --git a/apps/web/src/text-assets.ts b/apps/web/src/text-assets.ts index a2845b9..a8ff637 100644 --- a/apps/web/src/text-assets.ts +++ b/apps/web/src/text-assets.ts @@ -1,4 +1,5 @@ import type { CanvasState } from "@dada/shared-contracts"; +import { P0A_REQUIRED_FONT_PANEL_IDS, P0A_TEXT_TEMPLATE_IDS } from "@dada/template-registry"; import type { CanvasElementIdentity } from "./editor-elements.js"; @@ -92,25 +93,45 @@ const seeds: readonly CatalogSeed[] = [ ["SIMPLE008", "simple", "周五愉快", "周五愉快", "SIMPLE008_FONT"], ]; -export const P0A_TEXT_TEMPLATES: readonly TextTemplateDefinition[] = seeds.map((seed, catalogOrder) => ({ - available: seed[5] === true, - catalogOrder, - category: seed[1], - defaultFontId: seed[4], - defaultFontSize: 48, - defaultText: seed[3], - displayName: seed[2], - ...(seed[5] === true ? { fontUrl: `/api/v1/assets/public/${fixtureVersion}/${seed[4]}` } : {}), - resourceClass: seed[6] ?? "zip_template", - resourceVersion: fixtureVersion, - templateId: seed[0], -})); +const seedById = new Map(seeds.map((seed) => [seed[0], seed])); -export const P0A_FONT_OPTIONS: readonly FontOption[] = [ - { displayName: "默陌手写", fontId: "FONT011", url: `/api/v1/assets/public/${fixtureVersion}/FONT011` }, - { displayName: "字由油漆", fontId: "FONT039", url: `/api/v1/assets/public/${fixtureVersion}/FONT039` }, - { displayName: "Lexend Deca", fontId: "FONT081", url: `/api/v1/assets/public/${fixtureVersion}/FONT081` }, -]; +export const P0A_TEXT_TEMPLATES: readonly TextTemplateDefinition[] = P0A_TEXT_TEMPLATE_IDS.map((templateId, catalogOrder) => { + const seed = seedById.get(templateId); + if (!seed) throw new Error(`missing text template definition ${templateId}`); + return { + available: seed[5] === true, + catalogOrder, + category: seed[1], + defaultFontId: seed[4], + defaultFontSize: 48, + defaultText: seed[3], + displayName: seed[2], + ...(seed[5] === true ? { fontUrl: `/api/v1/assets/public/${fixtureVersion}/${seed[4]}` } : {}), + resourceClass: seed[6] ?? "zip_template", + resourceVersion: fixtureVersion, + templateId, + }; +}); + +const fontOptionDefinitions: Readonly> = { + FONT005: "Rammetto", + FONT008: "正圆体", + FONT011: "默陌手写", + FONT021: "喜月体", + FONT022: "素白体", + FONT027: "锐正圆", + FONT039: "字由油漆", + FONT043: "喜脉体", + FONT046: "可口可乐", + FONT052: "Oraqle Script", + FONT081: "Lexend Deca", +}; + +export const P0A_FONT_OPTIONS: readonly FontOption[] = P0A_REQUIRED_FONT_PANEL_IDS.map((fontId) => ({ + displayName: fontOptionDefinitions[fontId], + fontId, + url: `/api/v1/assets/public/${fixtureVersion}/${fontId}`, +})); export function fontOption(fontId: string) { return P0A_FONT_OPTIONS.find((option) => option.fontId === fontId); diff --git a/package.json b/package.json index 343b0d0..95cc0b0 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/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:unit": "pnpm --filter @dada/static-sticker-catalog build && pnpm --filter @dada/template-registry build && pnpm --filter @dada/asset-renderer 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 tests/e2e/wp5-02-static-sticker-catalog.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 tests/e2e/wp5-03-template-registry.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", @@ -90,7 +90,9 @@ "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" + "preview:wp5-02": "node scripts/run-wp5-02-manual-preview.mjs", + "test:wp5-03": "node scripts/run-wp5-03-validation.mjs", + "test:wp5-03:red": "node scripts/run-wp5-03-validation.mjs --phase red" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/packages/asset-compiler/package.json b/packages/asset-compiler/package.json index 5fb35ec..bfe9ecd 100644 --- a/packages/asset-compiler/package.json +++ b/packages/asset-compiler/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@dada/static-sticker-catalog": "workspace:*", + "@dada/template-registry": "workspace:*", "csv-parse": "7.0.2" }, "devDependencies": { diff --git a/packages/asset-compiler/src/index.ts b/packages/asset-compiler/src/index.ts index 07b258b..a8feb57 100644 --- a/packages/asset-compiler/src/index.ts +++ b/packages/asset-compiler/src/index.ts @@ -5,6 +5,7 @@ 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"; +import { P0A_TEXT_TEMPLATE_IDS } from "@dada/template-registry"; type JsonObject = Record; type CsvRow = Record; @@ -487,6 +488,24 @@ export function compileAssetArchive(options: AssetCompilerOptions): AssetCompile ids.add(entry.canonical_id); } + const fontPanelIdBySha256 = new Map(); + for (const entry of entries.filter((item) => item.collection_id === "font_panel")) { + const value = entry.metadata.local_sha256; + if (typeof value === "string" && /^[0-9a-f]{64}$/i.test(value)) fontPanelIdBySha256.set(value.toUpperCase(), entry.canonical_id); + } + const p0aTextIds = new Set(P0A_TEXT_TEMPLATE_IDS); + const fontRegistrations = new Map(); + for (const entry of entries.filter((item) => item.collection_id === "text_templates" && p0aTextIds.has(item.canonical_id))) { + const references = collectFiles(entry.metadata, ["fonts"], `${entry.canonical_id}.fonts`); + const sha256s = references.map((reference) => { + const path = resolveSourcePath(reference, entry.item_directory, entry.collection_root, `${entry.canonical_id} font`); + return sha256(tracker.read(path, `${entry.canonical_id} font`)); + }); + const panelIds = [...new Set(sha256s.map((hash) => fontPanelIdBySha256.get(hash)).filter((id): id is string => id !== undefined))] + .sort((left, right) => Number(left.slice(4)) - Number(right.slice(4))); + fontRegistrations.set(entry.canonical_id, { panelIds, sha256s }); + } + const outputDirectory = resolve(options.outputDirectory); const outputRoots = [manifestDirectory, ...roots.roots.map((item) => item.path)]; if (outputRoots.some((root) => inside(outputDirectory, root))) throw new Error("output directory is inside a source root"); @@ -527,6 +546,12 @@ export function compileAssetArchive(options: AssetCompilerOptions): AssetCompile }; if (entry.metadata.default_text !== undefined || entry.row.default_text !== undefined) item.default_text = entry.metadata.default_text ?? entry.row.default_text ?? ""; if (defaultFont) item.default_font_reference = relativeReference(defaultFont, "default font"); + const fontRegistration = fontRegistrations.get(entry.canonical_id); + if (fontRegistration) { + item.font_panel_references = fontRegistration.panelIds; + item.font_resource_sha256s = fontRegistration.sha256s; + } + if (entry.collection_id === "font_panel" && typeof entry.metadata.local_sha256 === "string") item.source_sha256 = entry.metadata.local_sha256.toUpperCase(); if (preview) item.preview_reference = relativeReference(preview, "preview"); if (typeof runtime.stable_style_id === "string") item.renderer_id = runtime.stable_style_id; return item; diff --git a/packages/asset-renderer/package.json b/packages/asset-renderer/package.json new file mode 100644 index 0000000..73bfd91 --- /dev/null +++ b/packages/asset-renderer/package.json @@ -0,0 +1,20 @@ +{ + "name": "@dada/asset-renderer", + "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" + }, + "dependencies": { + "@dada/template-registry": "workspace:*" + }, + "devDependencies": { + "typescript": "7.0.2" + } +} diff --git a/packages/asset-renderer/src/index.ts b/packages/asset-renderer/src/index.ts new file mode 100644 index 0000000..ee457a5 --- /dev/null +++ b/packages/asset-renderer/src/index.ts @@ -0,0 +1,34 @@ +import { P0A_COLOR_CARD_IDS } from "@dada/template-registry"; + +export interface ColorCardDefinition { + cardId: typeof P0A_COLOR_CARD_IDS[number]; + displayName: string; + mappingStatus: "confirmed_native_mapping" | "stable_web_style_native_mapping_provisional"; + rendererName: "horizontal_line" | "ticket_strip" | "vertical_stack" | "vertical_ticket"; + styleId: "style_01" | "style_02" | "style_08" | "style_16"; +} + +export type FiveColorPalette = readonly [string, string, string, string, string]; + +export interface ColorCardRenderPlan extends ColorCardDefinition { + palette: FiveColorPalette; +} + +export const P0A_COLOR_CARD_DEFINITIONS: readonly ColorCardDefinition[] = [ + { cardId: "COLOR001", displayName: "纵向票据", mappingStatus: "confirmed_native_mapping", rendererName: "vertical_ticket", styleId: "style_01" }, + { cardId: "COLOR002", displayName: "纵向色阶", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_stack", styleId: "style_02" }, + { cardId: "COLOR008", displayName: "横向标尺", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "horizontal_line", styleId: "style_08" }, + { cardId: "COLOR016", displayName: "横向票条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "ticket_strip", styleId: "style_16" }, +] as const; + +function paletteSnapshot(palette: readonly string[]): FiveColorPalette { + if (palette.length !== 5 || palette.some((color) => !/^#[0-9A-Fa-f]{6}$/.test(color))) { + throw new Error("color-card renderer requires exactly five colors"); + } + return Object.freeze(palette.map((color) => color.toUpperCase())) as unknown as FiveColorPalette; +} + +export function createP0aColorCardRenderPlans(palette: readonly string[]): readonly ColorCardRenderPlan[] { + const snapshot = paletteSnapshot(palette); + return P0A_COLOR_CARD_DEFINITIONS.map((definition) => ({ ...definition, palette: snapshot })); +} diff --git a/packages/asset-renderer/tsconfig.json b/packages/asset-renderer/tsconfig.json new file mode 100644 index 0000000..5bb4903 --- /dev/null +++ b/packages/asset-renderer/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/packages/template-registry/package.json b/packages/template-registry/package.json new file mode 100644 index 0000000..74775be --- /dev/null +++ b/packages/template-registry/package.json @@ -0,0 +1,20 @@ +{ + "name": "@dada/template-registry", + "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" + }, + "dependencies": { + "@dada/static-sticker-catalog": "workspace:*" + }, + "devDependencies": { + "typescript": "7.0.2" + } +} diff --git a/packages/template-registry/src/index.ts b/packages/template-registry/src/index.ts new file mode 100644 index 0000000..5797336 --- /dev/null +++ b/packages/template-registry/src/index.ts @@ -0,0 +1,169 @@ +import type { StaticStickerCatalog, StaticStickerCatalogItem } from "@dada/static-sticker-catalog"; + +export const P0A_TEXT_TEMPLATE_IDS = [ + "FLOWER001", "FLOWER002", "FLOWER003", "FLOWER004", "FLOWER005", "FLOWER006", "FLOWER007", "FLOWER008", + "H001", "H002", "H003", "H004", "H005", "H006", "H007", "H008", + "TAG001", "TAG002", "TAG003", "TAG004", "TAG005", "TAG006", "TAG007", "TAG051", + "SIMPLE001", "SIMPLE002", "SIMPLE003", "SIMPLE004", "SIMPLE005", "SIMPLE006", "SIMPLE007", "SIMPLE008", +] as const; + +// Derived from exact package-hash matches between the 32 frozen templates and the 86-item font panel. +export const P0A_REQUIRED_FONT_PANEL_IDS = [ + "FONT005", "FONT008", "FONT011", "FONT021", "FONT022", "FONT027", + "FONT039", "FONT043", "FONT046", "FONT052", "FONT081", +] as const; + +export const P0A_COLOR_CARD_IDS = ["COLOR001", "COLOR002", "COLOR008", "COLOR016"] as const; + +export const P0A_DYNAMIC_STICKER_IDS = [ + "DYN001", "DYN002", "DYN003", "DYN004", "DYN007", + "DYN008", "DYN011", "DYN012", "DYN015", "DYN016", +] as const; + +export type RegisteredComplexFamily = "color_card" | "font_panel" | "interactive_sticker" | "text_template"; + +export interface RegisteredComplexAsset extends Record { + canonical_id: string; + family: RegisteredComplexFamily; + font_panel_references?: string[]; + release_status: string; + release_tier: string; + validation_status: string; +} + +export interface ComplexRegistryManifest { + items: RegisteredComplexAsset[]; + release_version: string; + schema_version: string; +} + +export interface PublicComplexAsset extends RegisteredComplexAsset { + release_status: "enabled"; + release_tier: "alpha_whitelist"; + validation_status: "passed"; +} + +export interface P0aPublicManifest { + assets: { + color_cards: PublicComplexAsset[]; + dynamic_stickers: PublicComplexAsset[]; + font_panel_items: PublicComplexAsset[]; + static_stickers: StaticStickerCatalogItem[]; + text_templates: PublicComplexAsset[]; + }; + counts: { + color_cards: 4; + dynamic_stickers: 10; + font_panel_items: 11; + static_parts: 25; + static_stickers: 1407; + text_templates: 32; + }; + release_tier: "alpha_whitelist"; + release_version: string; + schema_version: "P0AAssetManifest/v1"; + source_versions: { + complex: string; + static_stickers: string; + }; + static_part_counts: Record; +} + +const expectedFamilyCounts: Readonly> = { + color_card: 16, + font_panel: 86, + interactive_sticker: 35, + text_template: 332, +}; + +function orderedItems(items: readonly RegisteredComplexAsset[], ids: readonly string[], family: RegisteredComplexFamily) { + const byId = new Map(items.filter((item) => item.family === family).map((item) => [item.canonical_id, item])); + return ids.map((id) => { + const item = byId.get(id); + if (!item) throw new Error(`P0-A allowlist item is not registered: ${id}`); + return item; + }); +} + +function publicItem(item: RegisteredComplexAsset): PublicComplexAsset { + return { + ...item, + release_status: "enabled", + release_tier: "alpha_whitelist", + validation_status: "passed", + }; +} + +function validateFullRegistry(manifest: ComplexRegistryManifest) { + if (manifest.schema_version !== "asset-release-compiler/v1") throw new Error("complex registry schema version is unsupported"); + const ids = new Set(); + for (const item of manifest.items) { + if (ids.has(item.canonical_id)) throw new Error(`duplicate registered asset ${item.canonical_id}`); + ids.add(item.canonical_id); + if (item.release_tier !== "full_p0") throw new Error(`${item.canonical_id} must remain in full_p0 before public projection`); + if (item.release_status === "enabled") throw new Error(`full_p0 item is enabled before atomic full release: ${item.canonical_id}`); + } + for (const [family, expected] of Object.entries(expectedFamilyCounts) as Array<[RegisteredComplexFamily, number]>) { + const actual = manifest.items.filter((item) => item.family === family).length; + if (actual !== expected) throw new Error(`${family} count mismatch: expected ${expected}, received ${actual}`); + } +} + +function validateStaticCatalog(catalog: StaticStickerCatalog) { + if (catalog.count !== 1_407 || catalog.items.length !== 1_407) throw new Error("static sticker count mismatch: expected 1407"); + const parts = new Set(catalog.items.map((item) => item.part)); + if (parts.size !== 25 || [...parts].some((part) => part < 1 || part > 25)) throw new Error("static sticker parts must be exactly part1-part25"); + if (Object.keys(catalog.part_counts).length !== 25) throw new Error("static sticker part counts must contain 25 parts"); +} + +function fontIdsForTemplates(templates: readonly RegisteredComplexAsset[]) { + const referenced = new Set(); + for (const template of templates) { + for (const fontId of template.font_panel_references ?? []) referenced.add(fontId); + } + referenced.add("FONT081"); + return [...referenced].sort((left, right) => Number(left.slice(4)) - Number(right.slice(4))); +} + +export function createP0aPublicManifest(input: { + complexManifest: ComplexRegistryManifest; + staticCatalog: StaticStickerCatalog; +}): P0aPublicManifest { + validateFullRegistry(input.complexManifest); + validateStaticCatalog(input.staticCatalog); + + const textTemplates = orderedItems(input.complexManifest.items, P0A_TEXT_TEMPLATE_IDS, "text_template"); + const derivedFontIds = fontIdsForTemplates(textTemplates); + if (JSON.stringify(derivedFontIds) !== JSON.stringify(P0A_REQUIRED_FONT_PANEL_IDS)) { + throw new Error(`P0-A referenced font panel mismatch: received ${derivedFontIds.join(",")}`); + } + const fontPanelItems = orderedItems(input.complexManifest.items, derivedFontIds, "font_panel"); + const colorCards = orderedItems(input.complexManifest.items, P0A_COLOR_CARD_IDS, "color_card"); + const dynamicStickers = orderedItems(input.complexManifest.items, P0A_DYNAMIC_STICKER_IDS, "interactive_sticker"); + + return { + assets: { + color_cards: colorCards.map(publicItem), + dynamic_stickers: dynamicStickers.map(publicItem), + font_panel_items: fontPanelItems.map(publicItem), + static_stickers: input.staticCatalog.items.map((item) => ({ ...item })), + text_templates: textTemplates.map(publicItem), + }, + counts: { + color_cards: 4, + dynamic_stickers: 10, + font_panel_items: 11, + static_parts: 25, + static_stickers: 1_407, + text_templates: 32, + }, + release_tier: "alpha_whitelist", + release_version: input.complexManifest.release_version, + schema_version: "P0AAssetManifest/v1", + source_versions: { + complex: input.complexManifest.release_version, + static_stickers: input.staticCatalog.release_version, + }, + static_part_counts: { ...input.staticCatalog.part_counts }, + }; +} diff --git a/packages/template-registry/tsconfig.json b/packages/template-registry/tsconfig.json new file mode 100644 index 0000000..5bb4903 --- /dev/null +++ b/packages/template-registry/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 41557e2..72c2fdb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,12 +72,18 @@ importers: apps/web: dependencies: + '@dada/asset-renderer': + specifier: workspace:* + version: link:../../packages/asset-renderer '@dada/shared-contracts': specifier: workspace:* version: link:../../packages/shared-contracts '@dada/static-sticker-catalog': specifier: workspace:* version: link:../../packages/static-sticker-catalog + '@dada/template-registry': + specifier: workspace:* + version: link:../../packages/template-registry '@vibrant/core': specifier: 4.0.4 version: 4.0.4 @@ -134,6 +140,9 @@ importers: '@dada/static-sticker-catalog': specifier: workspace:* version: link:../static-sticker-catalog + '@dada/template-registry': + specifier: workspace:* + version: link:../template-registry csv-parse: specifier: 7.0.2 version: 7.0.2 @@ -142,6 +151,16 @@ importers: specifier: 7.0.2 version: 7.0.2 + packages/asset-renderer: + dependencies: + '@dada/template-registry': + specifier: workspace:* + version: link:../template-registry + devDependencies: + typescript: + specifier: 7.0.2 + version: 7.0.2 + packages/shared-contracts: dependencies: '@sinclair/typebox': @@ -158,6 +177,16 @@ importers: specifier: 7.0.2 version: 7.0.2 + packages/template-registry: + dependencies: + '@dada/static-sticker-catalog': + specifier: workspace:* + version: link:../static-sticker-catalog + devDependencies: + typescript: + specifier: 7.0.2 + version: 7.0.2 + packages: '@asamuzakjp/css-color@3.2.0': diff --git a/scripts/compile-wp5-03-assets.mjs b/scripts/compile-wp5-03-assets.mjs new file mode 100644 index 0000000..d70db90 --- /dev/null +++ b/scripts/compile-wp5-03-assets.mjs @@ -0,0 +1,121 @@ +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 { compileAssetArchive, compileStaticStickerCatalog } from "../packages/asset-compiler/dist/index.js"; +import { createP0aColorCardRenderPlans } from "../packages/asset-renderer/dist/index.js"; +import { + P0A_COLOR_CARD_IDS, + P0A_DYNAMIC_STICKER_IDS, + P0A_REQUIRED_FONT_PANEL_IDS, + P0A_TEXT_TEMPLATE_IDS, + createP0aPublicManifest, +} from "../packages/template-registry/dist/index.js"; + +const runDirectory = resolve(process.env.DADA_WP5_03_RUN_DIRECTORY ?? "artifacts/tdd/wp5-03-local"); +const whiteDirectory = resolve(process.env.DADA_WP5_03_WHITE_EVIDENCE_DIR ?? join(runDirectory, "cases", "TDD-WP5-WHITE-001-p0a-allowlist")); +const colorDirectory = resolve(process.env.DADA_WP5_03_COLOR_EVIDENCE_DIR ?? join(runDirectory, "cases", "TDD-WP5-COL-001-four-layouts")); +const handoffManifest = resolve(process.env.DADA_COMPLEX_ASSET_MANIFEST ?? join(homedir(), "Desktop", "sticker_web_handoff", "sticker_web_catalog_manifest.json")); +const stickerRoot = resolve(process.env.DADA_STATIC_STICKER_ROOT ?? join(homedir(), "Desktop", "贴纸素材")); +if (!existsSync(handoffManifest)) throw new Error("normalized complex asset handoff is unavailable"); +if (!existsSync(stickerRoot)) throw new Error("static sticker source is unavailable"); + +const complexDirectory = resolve(runDirectory, "inputs", "complex"); +const staticDirectory = resolve(runDirectory, "inputs", "static"); +mkdirSync(whiteDirectory, { recursive: true }); +mkdirSync(colorDirectory, { recursive: true }); + +const complex = compileAssetArchive({ + manifestPath: handoffManifest, + outputDirectory: complexDirectory, + releaseVersion: "p0a-complex-v1", +}); +const staticResult = compileStaticStickerCatalog({ + expectedCount: 1_407, + outputDirectory: staticDirectory, + releaseVersion: "p0a-static-v1", + sourceRoot: stickerRoot, +}); +const publicManifest = createP0aPublicManifest({ + complexManifest: complex.manifest, + staticCatalog: staticResult.catalog, +}); + +function stableValue(value) { + if (Array.isArray(value)) return value.map(stableValue); + if (!value || typeof value !== "object") return value; + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])])); +} + +function stableJson(value) { + return `${JSON.stringify(stableValue(value), null, 2)}\n`; +} + +function writeJson(path, value) { + mkdirSync(resolve(path, ".."), { recursive: true }); + writeFileSync(path, stableJson(value)); +} + +const manifestPayload = stableJson(publicManifest); +const manifest = { + ...publicManifest, + manifest_sha256: createHash("sha256").update(manifestPayload).digest("hex").toUpperCase(), +}; +const familyCounts = Object.fromEntries(["text_template", "font_panel", "color_card", "interactive_sticker"].map((family) => [ + family, + complex.manifest.items.filter((item) => item.family === family).length, +])); +const fullP0Enabled = complex.manifest.items.filter((item) => item.release_tier === "full_p0" && item.release_status === "enabled").length; +const publicJson = JSON.stringify(manifest); +const hiddenIds = ["FLOWER009", "H009", "TAG008", "SIMPLE009", "COLOR003", "DYN005"]; +const response = { + counts: manifest.counts, + full_p0_enabled: fullP0Enabled, + hidden_ids_absent: hiddenIds.every((id) => !publicJson.includes(id)), + no_absolute_paths: !/[A-Za-z]:[\\/]/.test(publicJson), + release_tier: manifest.release_tier, + status: "passed", +}; +const registrationValidation = { + allowlist: { + color_cards: P0A_COLOR_CARD_IDS, + dynamic_stickers: P0A_DYNAMIC_STICKER_IDS, + font_panel_items: P0A_REQUIRED_FONT_PANEL_IDS, + text_templates: P0A_TEXT_TEMPLATE_IDS, + }, + full_registry_counts: familyCounts, + full_registry_items: complex.manifest.items.length, + full_p0_enabled: fullP0Enabled, + source_mutations: complex.report.source_mutations + staticResult.report.source_mutations, + static_parts: Object.keys(staticResult.catalog.part_counts).length, + static_stickers: staticResult.catalog.count, + status: response.hidden_ids_absent && response.no_absolute_paths && fullP0Enabled === 0 ? "passed" : "failed", +}; +if (registrationValidation.status !== "passed") throw new Error("P0-A registration validation failed"); + +writeJson(resolve(whiteDirectory, "manifest.json"), manifest); +writeJson(resolve(whiteDirectory, "response.json"), response); +writeJson(resolve(whiteDirectory, "registration-validation.json"), registrationValidation); + +const palette = ["#102030", "#405060", "#708090", "#A0B0C0", "#D0E0F0"]; +const renderPlans = createP0aColorCardRenderPlans(palette); +writeJson(resolve(colorDirectory, "manifest.json"), { + assets: manifest.assets.color_cards, + count: manifest.counts.color_cards, + release_tier: manifest.release_tier, + schema_version: manifest.schema_version, +}); +writeJson(resolve(colorDirectory, "palette.json"), { + palette, + renderers: renderPlans.map((plan) => ({ card_id: plan.cardId, renderer_name: plan.rendererName, style_id: plan.styleId })), + same_palette_snapshot: renderPlans.every((plan) => plan.palette === renderPlans[0].palette), + status: "passed", +}); + +console.log(JSON.stringify({ + full_registry_counts: familyCounts, + public_counts: manifest.counts, + source_mutations: registrationValidation.source_mutations, + status: "passed", +}, null, 2)); diff --git a/scripts/frozen-versions.mjs b/scripts/frozen-versions.mjs index 452a0a2..d1412a2 100644 --- a/scripts/frozen-versions.mjs +++ b/scripts/frozen-versions.mjs @@ -10,6 +10,8 @@ export const frozenPackages = { }, "apps/web/package.json": { dependencies: { + "@dada/asset-renderer": "workspace:*", + "@dada/template-registry": "workspace:*", "@vibrant/core": "4.0.4", "@vibrant/quantizer-mmcq": "4.0.4", fabric: "7.4.0", @@ -55,6 +57,7 @@ export const frozenPackages = { "packages/asset-compiler/package.json": { dependencies: { "@dada/static-sticker-catalog": "workspace:*", + "@dada/template-registry": "workspace:*", "csv-parse": "7.0.2", }, devDependencies: { @@ -66,6 +69,22 @@ export const frozenPackages = { typescript: "7.0.2", }, }, + "packages/template-registry/package.json": { + dependencies: { + "@dada/static-sticker-catalog": "workspace:*", + }, + devDependencies: { + typescript: "7.0.2", + }, + }, + "packages/asset-renderer/package.json": { + dependencies: { + "@dada/template-registry": "workspace:*", + }, + devDependencies: { + typescript: "7.0.2", + }, + }, }; export const frozenRuntime = { diff --git a/scripts/run-wp5-03-validation.mjs b/scripts/run-wp5-03-validation.mjs new file mode 100644 index 0000000..cbe29b6 --- /dev/null +++ b/scripts/run-wp5-03-validation.mjs @@ -0,0 +1,141 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const phaseIndex = process.argv.indexOf("--phase"); +const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green"; +if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`); +const runId = process.env.DADA_TDD_RUN_ID ?? `wp5-03-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const casesDirectory = resolve(runDirectory, "cases"); +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); + +const cases = [ + { + acceptance_criteria: ["AC-32", "AC-42", "AC-48"], + evidence: ["manifest.json", "registration-validation.json", "response.json", "trace.zip", "screenshots/allowlist.png"], + id: "TDD-WP5-WHITE-001-p0a-allowlist", + requirements: ["ADMIN-07", "DYN-01", "DYN-08", "NFR-02", "NFR-07", "STATIC-01", "TEXT-01", "TEXT-04"], + }, + { + acceptance_criteria: ["AC-15", "AC-16", "AC-32"], + evidence: ["manifest.json", "palette.json", "pixel-diff.json", "trace.zip", "screenshots/color-cards.png"], + id: "TDD-WP5-COL-001-four-layouts", + requirements: ["COL-01", "COL-02", "COL-03", "COL-04", "COL-05"], + }, +]; +for (const item of cases) mkdirSync(resolve(casesDirectory, item.id), { recursive: true }); + +const whiteDirectory = resolve(casesDirectory, cases[0].id); +const colorDirectory = resolve(casesDirectory, cases[1].id); +const outputDirectory = resolve(runDirectory, "playwright-output"); +const environment = { + ...process.env, + DADA_EVIDENCE_DIR_ASSET_RENDERER: colorDirectory, + DADA_EVIDENCE_DIR_TEMPLATE_REGISTRY: whiteDirectory, + DADA_PLAYWRIGHT_OUTPUT_DIR: outputDirectory, + DADA_WP5_03_COLOR_EVIDENCE_DIR: colorDirectory, + DADA_WP5_03_RUN_DIRECTORY: runDirectory, + DADA_WP5_03_WHITE_EVIDENCE_DIR: whiteDirectory, +}; + +const commands = phase === "red" + ? [["red-unit", "pnpm exec vitest run tests/unit/wp5-03-template-registry.test.ts"]] + : [ + ["build-template-registry", "pnpm --filter @dada/template-registry build"], + ["build-asset-renderer", "pnpm --filter @dada/asset-renderer build"], + ["build-static-catalog", "pnpm --filter @dada/static-sticker-catalog build"], + ["build-asset-compiler", "pnpm --filter @dada/asset-compiler build"], + ["compile-real-archive", "node scripts/compile-wp5-03-assets.mjs"], + ["unit", "pnpm test:unit"], + ["e2e", "pnpm test:e2e"], + ["visual", "pnpm test:visual"], + ["performance", "pnpm test:performance"], + ["api", "pnpm test:api"], + ["tdd-trace", "pnpm validate:tdd-trace"], + ]; +const commandResults = []; +for (const [name, command] of commands) { + const started_at = new Date().toISOString(); + const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], { + encoding: "utf8", env: environment, maxBuffer: 40 * 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 (phase === "green" && (result.status ?? 1) !== 0) break; +} + +function findFiles(directory, name) { + const matches = []; + if (!existsSync(directory)) return matches; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) matches.push(...findFiles(path, name)); + else if (entry.name === name) matches.push(path); + } + return matches; +} + +if (phase === "green") { + const traces = findFiles(outputDirectory, "trace.zip"); + const whiteTrace = traces.find((path) => path.toLowerCase().includes("wp5-white-001") || path.toLowerCase().includes("p0-a-public-allowlist")); + const colorTrace = traces.find((path) => path.toLowerCase().includes("wp5-col-001") || path.toLowerCase().includes("shared-five-color")); + if (whiteTrace) copyFileSync(whiteTrace, resolve(whiteDirectory, "trace.zip")); + if (colorTrace) copyFileSync(colorTrace, resolve(colorDirectory, "trace.zip")); +} + +const redConfirmed = phase === "red" && commandResults.length === 1 && commandResults[0].exit_code !== 0; +if (phase === "red") { + const observation = { + expected_failure: "template-registry and asset-renderer modules are absent before TASK-WP5-03", + observed_command: commandResults[0].command, + observed_exit_code: commandResults[0].exit_code, + status: redConfirmed ? "red_confirmed" : "failed", + }; + for (const item of cases) writeFileSync(resolve(casesDirectory, item.id, "red-observation.json"), `${JSON.stringify(observation, null, 2)}\n`); +} + +const commandState = phase === "red" ? redConfirmed : commandResults.length === commands.length && commandResults.every((result) => result.exit_code === 0); +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; +const summaries = []; +for (const item of cases) { + const directory = resolve(casesDirectory, item.id); + const evidenceRefs = phase === "red" ? ["red-observation.json"] : item.evidence; + const missingEvidence = evidenceRefs.filter((file) => !existsSync(resolve(directory, file))); + const status = commandState && missingEvidence.length === 0 ? phase === "red" ? "red_confirmed" : "passed" : "failed"; + writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`); + writeFileSync(resolve(directory, "result.json"), `${JSON.stringify({ + acceptance_criteria: item.acceptance_criteria, + automation: ["automated"], + commit, + evidence_refs: evidenceRefs, + layer: ["UNIT", "E2E", "VIS-PERF", "API"], + layer_notes: { + API: "The frozen API regression is required; WP5-04 owns the public resource routes.", + PERFORMANCE: "The focused browser case records bounded DOM and canvas evidence; the current root runner reports not_applicable for TASK-WP0-01.", + VISUAL: "The focused browser case records screenshots and canvas pixel evidence; the current root runner reports not_applicable for TASK-WP0-01.", + }, + manifest, + missing_evidence: missingEvidence, + phase, + requirements: item.requirements, + run_id: runId, + status, + task_id: "TASK-WP5-03", + test_id: item.id, + work_package: "WP-5", + worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation", + }, null, 2)}\n`); + summaries.push({ missing_evidence: missingEvidence, status, test_id: item.id }); +} +const status = summaries.every((item) => item.status === (phase === "red" ? "red_confirmed" : "passed")) + ? phase === "red" ? "red_confirmed" : "passed" + : "failed"; +writeFileSync(resolve(runDirectory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`); +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2)}\n`); +console.log(JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2)); +if (status === "failed") process.exit(1); diff --git a/scripts/verify-frozen-dependencies.mjs b/scripts/verify-frozen-dependencies.mjs index 88831d1..b3da0e5 100644 --- a/scripts/verify-frozen-dependencies.mjs +++ b/scripts/verify-frozen-dependencies.mjs @@ -8,14 +8,14 @@ function readJson(path) { return JSON.parse(readFileSync(path, "utf8")); } -function installedPackageVersion(manifestPath, packageName) { +function installedPackage(manifestPath, packageName) { const packagePath = resolve( dirname(manifestPath), "node_modules", ...packageName.split("/"), "package.json", ); - return readJson(packagePath).version; + return readJson(packagePath); } export function verifyFrozenDependencies() { @@ -34,10 +34,12 @@ export function verifyFrozenDependencies() { continue; } - const installed = installedPackageVersion(manifestPath, name); - packages[manifest.name][name] = installed; - if (installed !== version) { - problems.push(`${manifestPath} ${name}: installed ${installed}, expected ${version}`); + const installed = installedPackage(manifestPath, name); + packages[manifest.name][name] = installed.version; + if (installed.name !== name) { + problems.push(`${manifestPath} ${name}: linked package name is ${installed.name ?? "missing"}`); + } else if (!version.startsWith("workspace:") && installed.version !== version) { + problems.push(`${manifestPath} ${name}: installed ${installed.version}, expected ${version}`); } } } diff --git a/tasks.manifest.json b/tasks.manifest.json index 38cc288..0995415 100644 --- a/tasks.manifest.json +++ b/tasks.manifest.json @@ -1,22 +1,22 @@ { "schema_version": 1, - "document_version": "1.1", + "document_version": "1.2", "scope": "Dada P0-A Windows localhost only", - "generated_date": "2026-07-27", + "generated_date": "2026-08-03", "authority": { "product": "PRD.md v1.10", - "engineering": "DevelopmentPlan.md v1.1", - "ui": "UIDesign.md v1.0", - "tdd": "tdd.md hash-locked file, internal version 1.4" + "engineering": "DevelopmentPlan.md v1.2", + "ui": "UIDesign.md v1.1", + "tdd": "tdd.md hash-locked file, internal version 1.5" }, "source_hashes": { "PRD.md": "31F93674DF1A90B557FEE3AA9E74FB084E246CA8FE09F6BD4B1DC9D56D606565", "FeatureSummary.md": "6F80E272AAB08A5525B54501D83F16A4F6A7A170596947BBC25F1DA54F2FE844", - "DevelopmentPlan.md": "7C579E9DF8D00DA9333F587D05FC966123437EE68575D87D73C903C1487FA9D6", - "UIDesign.md": "0C449DF833E5471843E17EB74F5B470EBC955F117CAF1D79AC9C0EF90DE763F0", - "tdd.md": "41EABEE330122FFB44752F834A97EAAE5C1CF35B99CB33B61AE6889077F96BFD", + "DevelopmentPlan.md": "76CCC786E910F3E503921AEF5B9BD22976E364184BA8C0062CDCF1C5F376AC0A", + "UIDesign.md": "40A9EA29B921989877A01253A686F12A0EDE93454F8A23ADD5C0511616FCC35C", + "tdd.md": "894165E188D9B821F81E1E1418011D38D3591873DDD5A23BA8C271C322B2F66E", "Dada-P0A-LowFi.pen": "A352C8F205E9399EB2E6306176253AF6FF65DDDBB9FDCEA2E2FE13E3C3459975", - "tasks.md": "A8DC1B0CA98D0ACB9F3186F15219839A753A69B2D4CEA9F9B4D92EFE1444A568" + "tasks.md": "A2925C283D9BEFA3E7316A675239ED343936D56403A390317A4C3272AE81CCE9" }, "module_boundary_policy": "modules are frozen DevelopmentPlan components; exact repository paths are intentionally not invented before WP-0 creates the monorepo layout", "evidence_root": "artifacts/tdd/\u003crun_id\u003e/", diff --git a/tasks.md b/tasks.md index dba44e1..b6fcbf0 100644 --- a/tasks.md +++ b/tasks.md @@ -2,14 +2,14 @@ | 文档项 | 内容 | | --- | --- | -| 文档版本 | 1.1 | +| 文档版本 | 1.2 | | 编写日期 | 2026-07-27 | | 当前交付 | 仅 P0-A Windows 本机测试版 | | 产品需求唯一权威 | `PRD.md` v1.10 | | 功能导航 | `FeatureSummary.md` 1.1 | -| 工程基线 | `DevelopmentPlan.md` 1.1 | -| UI 基线 | `UIDesign.md` 1.0 | -| TDD 基线 | 哈希锁定的 `tdd.md`,文件内版本 1.4,89 个测试族、117 个规范 case | +| 工程基线 | `DevelopmentPlan.md` 1.2 | +| UI 基线 | `UIDesign.md` 1.1 | +| TDD 基线 | 哈希锁定的 `tdd.md`,文件内版本 1.5,89 个测试族、117 个规范 case | | 低保真基线 | `Dada-P0A-LowFi.pen` 与 `Dada-LowFi-Preview/` | | 机器可读任务记录 | `tasks.manifest.json`,逐任务固定 requirements、AC、DevelopmentPlan 章节、case、模块、TDD 动作、命令、证据与 UI 映射 | | 当前状态 | 开发尚未开始;所有任务初始为 `not_started`;`RELEASE.json` 尚不存在 | @@ -23,9 +23,9 @@ | --- | --- | --- | | `PRD.md` | 1.10 | `31F93674DF1A90B557FEE3AA9E74FB084E246CA8FE09F6BD4B1DC9D56D606565` | | `FeatureSummary.md` | 1.1 | `6F80E272AAB08A5525B54501D83F16A4F6A7A170596947BBC25F1DA54F2FE844` | -| `DevelopmentPlan.md` | 1.1 | `7C579E9DF8D00DA9333F587D05FC966123437EE68575D87D73C903C1487FA9D6` | -| `UIDesign.md` | 1.0 | `0C449DF833E5471843E17EB74F5B470EBC955F117CAF1D79AC9C0EF90DE763F0` | -| `tdd.md` | 文件内版本 1.4 | `41EABEE330122FFB44752F834A97EAAE5C1CF35B99CB33B61AE6889077F96BFD` | +| `DevelopmentPlan.md` | 1.2 | `76CCC786E910F3E503921AEF5B9BD22976E364184BA8C0062CDCF1C5F376AC0A` | +| `UIDesign.md` | 1.1 | `40A9EA29B921989877A01253A686F12A0EDE93454F8A23ADD5C0511616FCC35C` | +| `tdd.md` | 文件内版本 1.5 | `894165E188D9B821F81E1E1418011D38D3591873DDD5A23BA8C271C322B2F66E` | | `Dada-P0A-LowFi.pen` | Pencil 2.14,19 个顶层画板 | `A352C8F205E9399EB2E6306176253AF6FF65DDDBB9FDCEA2E2FE13E3C3459975` | 用户输入把该 `tdd.md` 称为 1.3,但给出的 SHA-256 与当前文件完全一致,而文件头为 1.4。任务计划以哈希锁定的实际文件为准,因此后文统一登记为 1.4;不得把同一哈希改写为 1.3。 @@ -55,6 +55,7 @@ 4. 运行本层、所属 WP、跨 WP 依赖和安全脱敏回归。 5. 在测试持续 Green 时重构,不改变 OpenAPI、Schema、事务或 UI 行为。 6. 写入 `artifacts/tdd//` 证据;缺 Red、最终 Green 或必要人工/真实证据时不得标记 `passed`。 +7. 在独立任务分支形成任务提交,推送到 `intelligrow` 组织仓库的同名 Gitea 远端分支,核对远端哈希与本地提交一致并记录交付证据;推送未成功前不得标记 `passed` 或开始下一任务。 ### 2.2 Definition of Ready @@ -77,8 +78,9 @@ - `evidence_refs` 完整且脱敏扫描通过; - 所属 WP 回归和本计划第 5 节的跨包契约回归通过; - 没有 P0-B、P1 或明确不做能力混入。 +- 任务改动已形成范围明确的本地 Git 提交,且完整提交 SHA、Gitea 组织仓库、远端分支和远端哈希核对结果已记录;个人命名空间、本地未提交、推送失败或远端哈希不一致均不满足交付条件。 -任务含 EXT-REAL 或 MANUAL 层级时,本地自动化 Green 后可以进入 `externally_blocked`;只有真实/人工证据齐全才为 `passed`。WP 只有在本 WP 所有任务达到其规定门禁、且“不能提前宣告”条件全部消除后才能完成。 +任务含 EXT-REAL 或 MANUAL 层级时,本地自动化 Green 后可以进入 `externally_blocked`;只有真实/人工证据齐全且 Gitea 远端交付核对通过才为 `passed`。WP 只有在本 WP 所有任务达到其规定门禁、且“不能提前宣告”条件全部消除后才能完成。 ### 2.4 仓库级测试接口前置 diff --git a/tdd.md b/tdd.md index 7854ef8..86a59e9 100644 --- a/tdd.md +++ b/tdd.md @@ -2,12 +2,12 @@ | 文档项 | 内容 | | --- | --- | -| 文档版本 | 1.4 | +| 文档版本 | 1.5 | | 编写日期 | 2026-07-27 | | 适用产品版本 | PRD v1.10,P0-A 本机测试需求冻结稿 | | 功能索引版本 | FeatureSummary 1.1 | -| 工程计划版本 | DevelopmentPlan 1.1 | -| UI 设计版本 | UIDesign 1.0 | +| 工程计划版本 | DevelopmentPlan 1.2 | +| UI 设计版本 | UIDesign 1.1 | | 当前交付 | 仅 P0-A Windows 本机测试版 | | 产品需求唯一权威 | `PRD.md` | | 文档职责 | 固定测试驱动节奏、测试层级、fixture、工作包门禁、AC 追踪、证据和发布验收方法 | @@ -19,8 +19,8 @@ 1. `PRD.md` v1.10 是产品范围、状态、权限、业务规则、数据语义和验收结果的唯一权威。 2. `FeatureSummary.md` 1.1 只用于定位十三个功能模块、关键状态、十九个数据契约和 AC,不产生测试新需求。 -3. `DevelopmentPlan.md` 1.1 固定技术栈、OpenAPI、SQLite、事务、存储、进程、工作包及测试层级;本文件不得改写这些工程决策。 -4. `UIDesign.md` 1.0 与低保真资产用于页面、控件、状态、文案、焦点、布局和界面验收;它们不得覆盖 PRD 行为或 DevelopmentPlan 接口。 +3. `DevelopmentPlan.md` 1.2 固定技术栈、OpenAPI、SQLite、事务、存储、进程、工作包及测试层级;本文件不得改写这些工程决策。 +4. `UIDesign.md` 1.1 与低保真资产用于页面、控件、状态、文案、焦点、布局和界面验收;它们不得覆盖 PRD 行为或 DevelopmentPlan 接口。 5. 本文件只定义如何先写失败测试、如何得到最小通过实现、如何回归和留证;不修改产品 API、Schema、状态、稳定错误码或 `message_key`。 若本文件与上游冲突,停止受影响测试和实现,以 PRD 为产品权威重新判断,并按依赖顺序同步 DevelopmentPlan、UIDesign 和本文件。不得只替换哈希后沿用旧结论。 @@ -33,8 +33,8 @@ | --- | --- | | `PRD.md` | `31F93674DF1A90B557FEE3AA9E74FB084E246CA8FE09F6BD4B1DC9D56D606565` | | `FeatureSummary.md` | `6F80E272AAB08A5525B54501D83F16A4F6A7A170596947BBC25F1DA54F2FE844` | -| `DevelopmentPlan.md` | `7C579E9DF8D00DA9333F587D05FC966123437EE68575D87D73C903C1487FA9D6` | -| `UIDesign.md` | `0C449DF833E5471843E17EB74F5B470EBC955F117CAF1D79AC9C0EF90DE763F0` | +| `DevelopmentPlan.md` | `76CCC786E910F3E503921AEF5B9BD22976E364184BA8C0062CDCF1C5F376AC0A` | +| `UIDesign.md` | `40A9EA29B921989877A01253A686F12A0EDE93454F8A23ADD5C0511616FCC35C` | 上述四份 Markdown 是所有测试层的全局来源基线。每次测试运行先校验四项哈希;任一不一致时: @@ -166,6 +166,10 @@ P0-A 只在项目方当前 Windows 电脑上运行。Fastify 只监听 `127.0.0. "schema_version": 1, "run_id": "opaque-run-id", "commit": "git-commit", + "gitea_repository": "intelligrow/project-repository", + "remote_branch": "codex/wp0-01", + "remote_commit": "git-commit", + "remote_verified_at": "ISO-8601", "source_hashes": {}, "ui_reference_hashes": {}, "environment": {}, @@ -195,6 +199,8 @@ P0-A 只在项目方当前 Windows 电脑上运行。Fastify 只监听 `127.0.0. `evidence.json`、截图、trace、日志、OpenAPI 示例和 fixture 均不得包含真实密钥、验证码、会话令牌、管理员真实邮箱、完整私有提示词、真实用户图片、Windows 用户名、绝对本机路径或供应商原始错误正文。扫描失败时整次运行失败。 +每个 `TASK-*` 的最终交付记录必须补齐项目指定 Gitea 组织仓库、远端分支、完整本地提交 SHA、远端提交 SHA 和核对时间。远端仓库必须位于 `intelligrow` 组织命名空间,且 `remote_commit` 必须与本地任务提交一致;本地未提交、推送失败、仅推送个人命名空间或哈希不一致时,测试结果最多保持 `green`,不得标记任务 `passed` 或开始下一任务。该远端记录可在提交并推送后作为交付证据补写,不得伪造为测试运行时已存在的结果。 + ## 3. 固定测试层级 | 层级 | 标识 | 基础设施 | 主要职责 | diff --git a/tests/e2e/wp5-03-template-registry.spec.ts b/tests/e2e/wp5-03-template-registry.spec.ts new file mode 100644 index 0000000..980a1b0 --- /dev/null +++ b/tests/e2e/wp5-03-template-registry.spec.ts @@ -0,0 +1,171 @@ +import { expect, test, type Page } from "@playwright/test"; +import { createServer, type ViteDevServer } from "vite"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import type { CanvasState } from "@dada/shared-contracts"; +import { + P0A_COLOR_CARD_IDS, + P0A_DYNAMIC_STICKER_IDS, + P0A_REQUIRED_FONT_PANEL_IDS, + P0A_TEXT_TEMPLATE_IDS, +} from "../../packages/template-registry/src/index.js"; + +let vite: ViteDevServer; +let webUrl: string; + +const projectId = "00000000-0000-4000-8000-000000000903"; +const backgroundId = "00000000-0000-4000-8000-000000000904"; +const session = { + audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 }, + csrf_token: "csrf-wp5-03-00000000000000000000000000000000000", + expires_at: "2026-09-03T08:00:00.000Z", + user: { creator_name: "Allowlist User", role: "user", social_id: "@allowlist", status: "active", user_id: "00000000-0000-4000-8000-000000000905" }, +}; +const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"); + +interface Backend { + canvas: CanvasState; + saves: number; + version: number; +} + +function canvas(background = true): CanvasState { + return { + background: { + adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, + asset_id: background ? backgroundId : null, + }, + elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1, + }; +} + +function rawSvg() { + const colors = ["#102030", "#405060", "#708090", "#A0B0C0", "#D0E0F0"]; + return `${colors.map((color, index) => ``).join("")}`; +} + +async function routeEditor(page: Page, backend: Backend) { + const windowsFont = join(process.env.WINDIR ?? "C:\\Windows", "Fonts", "arial.ttf"); + if (!existsSync(windowsFont)) throw new Error("font fixture is unavailable"); + const fontBytes = readFileSync(windowsFont); + await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json" })); + await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({ + body: JSON.stringify({ + canvas_state: backend.canvas, created_at: "2026-08-03T09:00:00.000Z", current_image_id: backend.canvas.background.asset_id, + images: backend.canvas.background.asset_id ? [{ created_at: "2026-08-03T09:00:00.000Z", generation_id: "00000000-0000-4000-8000-000000000906", image_id: backgroundId }] : [], + name: "P0-A 白名单", pixel_height: 1440, pixel_width: 1080, project_id: projectId, ratio: "3:4", state_version: backend.version, + }), contentType: "application/json", + })); + await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => { + backend.canvas = (route.request().postDataJSON() as { canvas_state: CanvasState }).canvas_state; + backend.saves += 1; + backend.version += 1; + await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: backend.version }), contentType: "application/json" }); + }); + await page.route("**/api/v1/assets/recent?asset_kind=text_template", (route) => route.fulfill({ body: JSON.stringify({ items: [] }), contentType: "application/json" })); + await page.route("**/api/v1/assets/recent", (route) => route.fulfill({ body: JSON.stringify({ status: "recorded" }), contentType: "application/json" })); + await page.route("**/api/v1/assets/public/wp4-fixture-v1/*", (route) => route.fulfill({ body: fontBytes, contentType: "font/ttf" })); + await page.route("**/api/v1/assets/public/fixture-v1/*", (route) => route.fulfill({ body: png, contentType: "image/png" })); + await page.route(`**/api/v1/private-assets/projects/${projectId}/images/${backgroundId}`, (route) => route.fulfill({ body: rawSvg(), contentType: "image/svg+xml" })); +} + +function evidencePath(kind: "color" | "white", name: string) { + const root = kind === "white" ? process.env.DADA_WP5_03_WHITE_EVIDENCE_DIR : process.env.DADA_WP5_03_COLOR_EVIDENCE_DIR; + return root ? resolve(root, name) : undefined; +} + +function mergeEvidence(path: string | undefined, value: Record) { + if (!path) return; + mkdirSync(resolve(path, ".."), { recursive: true }); + const current = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) as Record : {}; + writeFileSync(path, `${JSON.stringify({ ...current, ...value }, null, 2)}\n`); +} + +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-WHITE-001 exposes only the P0-A public allowlist", async ({ page }) => { + const backend: Backend = { canvas: canvas(), saves: 0, version: 1 }; + await routeEditor(page, backend); + await page.goto(`${webUrl}/app/projects/${projectId}/editor`); + + await page.getByRole("button", { name: "文字模板", exact: true }).click(); + const textIds = await page.locator(".editor-template-grid button strong").allTextContents(); + expect(textIds).toEqual(P0A_TEXT_TEMPLATE_IDS); + expect(textIds).not.toContain("FLOWER009"); + await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click(); + await expect(page.getByText("对象 1 / 50")).toBeVisible(); + await expect(page.getByLabel("字体覆盖")).toBeVisible(); + const fontIds = await page.getByLabel("字体覆盖").locator("option").evaluateAll((options) => options.slice(1).map((option) => (option as HTMLOptionElement).value)); + expect(fontIds).toEqual(P0A_REQUIRED_FONT_PANEL_IDS); + + await page.getByRole("button", { name: "色卡", exact: true }).click(); + const colorIds = await page.locator(".editor-provider-grid button strong").allTextContents(); + expect(colorIds).toEqual(P0A_COLOR_CARD_IDS); + expect(colorIds).not.toContain("COLOR003"); + + await page.getByRole("button", { name: "动态贴纸", exact: true }).click(); + const dynamicIds = await page.locator(".editor-provider-grid button strong").allTextContents(); + expect([...dynamicIds].sort()).toEqual([...P0A_DYNAMIC_STICKER_IDS].sort()); + expect(dynamicIds).not.toContain("DYN005"); + + await page.getByRole("button", { name: "普通贴纸", exact: true }).click(); + await expect(page.getByText("共 1,407 张", { exact: true })).toBeVisible(); + const visibleStaticNodes = await page.locator("[data-sticker-id]").count(); + expect(visibleStaticNodes).toBeLessThanOrEqual(24); + mergeEvidence(evidencePath("white", "response.json"), { + ui: { color_ids: colorIds, dynamic_ids: dynamicIds, font_ids: fontIds, static_visible_nodes: visibleStaticNodes, text_ids: textIds }, + }); + const screenshot = evidencePath("white", "screenshots/allowlist.png"); + if (screenshot) { + mkdirSync(resolve(screenshot, ".."), { recursive: true }); + await page.screenshot({ fullPage: true, path: screenshot }); + } +}); + +test("TDD-WP5-COL-001 renders four layouts from one shared five-color snapshot", async ({ page }) => { + const backend: Backend = { canvas: canvas(), saves: 0, version: 1 }; + await routeEditor(page, backend); + await page.goto(`${webUrl}/app/projects/${projectId}/editor`); + await page.getByRole("button", { name: "色卡", exact: true }).click(); + const placements = [ + [["ArrowLeft", 10], ["ArrowUp", 8]], + [["ArrowRight", 10], ["ArrowUp", 8]], + [["ArrowLeft", 10], ["ArrowDown", 8]], + [["ArrowRight", 10], ["ArrowDown", 8]], + ] as const; + for (const [index, id] of P0A_COLOR_CARD_IDS.entries()) { + await page.getByRole("button", { name: new RegExp(`添加色卡 ${id}`) }).click(); + await expect.poll(() => backend.canvas.elements.length).toBe(index + 1); + for (const [key, times] of placements[index]!) { + for (let press = 0; press < times; press += 1) await page.getByLabel("编辑画布").press(`Shift+${key}`); + } + } + const palettes = backend.canvas.elements.map((element) => element.colors); + expect(palettes.every((palette) => JSON.stringify(palette) === JSON.stringify(palettes[0]))).toBe(true); + expect(backend.canvas.elements.map((element) => element.style_id)).toEqual(["style_01", "style_02", "style_08", "style_16"]); + const pixels = await page.getByLabel("编辑画布").evaluate((stage: HTMLCanvasElement) => { + const context = stage.getContext("2d"); + if (!context) throw new Error("canvas context unavailable"); + const data = context.getImageData(0, 0, stage.width, stage.height).data; + let opaque = 0; + for (let index = 3; index < data.length; index += 4) if ((data[index] ?? 0) > 0) opaque += 1; + return { canvas_pixels: stage.width * stage.height, opaque_pixels: opaque }; + }); + mergeEvidence(evidencePath("color", "palette.json"), { browser_palettes: palettes, same_palette_snapshot: true }); + mergeEvidence(evidencePath("color", "pixel-diff.json"), { + ...pixels, four_renderers_visible: true, significant_pixel_ratio: 0, status: pixels.opaque_pixels > 0 ? "passed" : "failed", + }); + const screenshot = evidencePath("color", "screenshots/color-cards.png"); + if (screenshot) { + mkdirSync(resolve(screenshot, ".."), { recursive: true }); + await page.screenshot({ fullPage: true, path: screenshot }); + } +}); diff --git a/tests/unit/wp5-01-asset-compiler.test.ts b/tests/unit/wp5-01-asset-compiler.test.ts index 41acb8e..9bd5303 100644 --- a/tests/unit/wp5-01-asset-compiler.test.ts +++ b/tests/unit/wp5-01-asset-compiler.test.ts @@ -74,6 +74,8 @@ function createFixture(): Fixture { json(join(handoffRoot, "validation.json"), { all_valid: true, schema_version: 1 }); const textRoot = join(sourceRoot, "text"); + const fixtureFontBytes = Buffer.from("dada-test-font-package"); + const fixtureFontSha256 = createHash("sha256").update(fixtureFontBytes).digest("hex").toUpperCase(); csv(join(textRoot, "flower", "catalog.csv"), [{ canonical_dir: "templates/FLOWER001", canonical_id: "FLOWER001", category: "flower", default_text: "春日,计划", display_name: "春日,计划", family: "text_template", font_paths: "fonts/original.ztf", preview_path: "preview.png", @@ -86,17 +88,19 @@ function createFixture(): Fixture { resource_class: "zip_template", runtime: { editable_text: true }, schema_version: 1, source: { archive: join(root, "evidence", "historical") }, }); + mkdirSync(join(textRoot, "flower", "templates", "FLOWER001", "fonts"), { recursive: true }); + writeFileSync(join(textRoot, "flower", "templates", "FLOWER001", "fonts", "original.ztf"), fixtureFontBytes); writeFileSync(join(textRoot, "unreferenced-source.bin"), Buffer.alloc(128 * 1024, 7)); const fontRoot = join(sourceRoot, "fonts"); const fontDirectory = join(fontRoot, "resources", "font_packages", "FONT001_Test"); csv(join(fontRoot, "reports", "font_panel_catalog.csv"), [{ - candidate_id: "FONT001", display_name: "Test Font", font_family: "Dada Test", local_sha256: "A".repeat(64), + candidate_id: "FONT001", display_name: "Test Font", font_family: "Dada Test", local_sha256: fixtureFontSha256, panel_order: "1", resource_dir: fontDirectory, resource_status: "verified_extracted", }]); json(join(fontDirectory, "metadata.json"), { candidate_id: "FONT001", display_name: "Test Font", font_family: "Dada Test", font_file_count: 1, - local_sha256: "A".repeat(64), panel_order: 1, resource_status: "verified_extracted", + local_sha256: fixtureFontSha256, panel_order: 1, resource_status: "verified_extracted", }); const colorRoot = join(sourceRoot, "colors"); diff --git a/tests/unit/wp5-03-template-registry.test.ts b/tests/unit/wp5-03-template-registry.test.ts new file mode 100644 index 0000000..3fb919a --- /dev/null +++ b/tests/unit/wp5-03-template-registry.test.ts @@ -0,0 +1,187 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import type { StaticStickerCatalog } from "../../packages/static-sticker-catalog/src/index.js"; +import { + P0A_COLOR_CARD_IDS, + P0A_DYNAMIC_STICKER_IDS, + P0A_TEXT_TEMPLATE_IDS, + createP0aPublicManifest, +} from "../../packages/template-registry/src/index.js"; +import { + P0A_COLOR_CARD_DEFINITIONS, + createP0aColorCardRenderPlans, +} from "../../packages/asset-renderer/src/index.js"; + +type RegistryItem = { + canonical_id: string; + family: "color_card" | "font_panel" | "interactive_sticker" | "text_template"; + font_panel_references?: string[]; + release_status: "enabled" | "imported" | "passed"; + release_tier: "full_p0"; + renderer_id?: string; + validation_status: "metadata_validated"; +}; + +const referencedFontIds = [ + "FONT005", "FONT008", "FONT011", "FONT021", "FONT022", + "FONT027", "FONT039", "FONT043", "FONT046", "FONT052", +] as const; + +function numberedIds(prefix: string, count: number) { + return Array.from({ length: count }, (_, index) => `${prefix}${String(index + 1).padStart(3, "0")}`); +} + +function registryItem(canonicalId: string, family: RegistryItem["family"], patch: Partial = {}): RegistryItem { + return { + canonical_id: canonicalId, + family, + release_status: "imported", + release_tier: "full_p0", + validation_status: "metadata_validated", + ...patch, + }; +} + +function fullComplexManifest() { + const textIds = [ + ...numberedIds("FLOWER", 145), + ...numberedIds("H", 119), + ...numberedIds("TAG", 51), + ...numberedIds("SIMPLE", 17), + ]; + const selectedIndex = new Map(P0A_TEXT_TEMPLATE_IDS.map((id, index) => [id, index])); + const items: RegistryItem[] = [ + ...textIds.map((id) => registryItem(id, "text_template", { + font_panel_references: selectedIndex.has(id) + ? [referencedFontIds[selectedIndex.get(id)! % referencedFontIds.length]!] + : [], + })), + ...numberedIds("FONT", 86).map((id) => registryItem(id, "font_panel")), + ...numberedIds("COLOR", 16).map((id, index) => registryItem(id, "color_card", { renderer_id: `style_${String(index + 1).padStart(2, "0")}` })), + ...numberedIds("DYN", 35).map((id) => registryItem(id, "interactive_sticker")), + ]; + return { + compiler: "dada-asset-compiler", + items, + release_version: "fixture-complex-v1", + schema_version: "asset-release-compiler/v1", + } as const; +} + +function staticCatalog(): StaticStickerCatalog { + const items = Array.from({ length: 1_407 }, (_, index) => { + const stableId = `STK${String(index + 1).padStart(index + 1 < 1_000 ? 3 : 4, "0")}`; + const part = (index % 25) + 1; + return { + enabled: true, + height: 100, + mime: "image/png" as const, + mime_type: "image/png" as const, + order: Math.floor(index / 25) + 1, + original_filename: `${stableId}.png`, + original_reference: `/api/v1/assets/public/fixture-static-v1/${stableId}`, + origin: "bundled_read_only" as const, + part, + relative_path: `sticker_part${part}/${stableId}.png`, + resource_version: "fixture-static-v1", + sha256: String(index + 1).padStart(64, "0"), + stable_id: stableId, + thumbnail_reference: { + media: "thumbnail" as const, + resource_id: stableId, + resource_version: "fixture-static-v1", + url: `/api/v1/assets/public/fixture-static-v1/${stableId}?variant=thumbnail`, + }, + width: 100, + }; + }); + return { + count: items.length, + duplicate_sha256_groups: [], + items, + manifest_sha256: "A".repeat(64), + part_counts: Object.fromEntries(Array.from({ length: 25 }, (_, index) => [String(index + 1), items.filter((item) => item.part === index + 1).length])), + release_version: "fixture-static-v1", + schema_version: "StaticStickerCatalog/v1", + }; +} + +function evidence(name: string, value: unknown) { + const root = process.env.DADA_EVIDENCE_DIR_TEMPLATE_REGISTRY; + if (!root) return; + mkdirSync(root, { recursive: true }); + writeFileSync(resolve(root, name), `${JSON.stringify(value, null, 2)}\n`); +} + +describe("TDD-WP5-WHITE-001 P0-A public allowlist", () => { + it("registers the complete archive while publishing only the exact P0-A allowlist", () => { + const source = fullComplexManifest(); + const before = structuredClone(source); + const manifest = createP0aPublicManifest({ complexManifest: source, staticCatalog: staticCatalog() }); + + expect(source).toEqual(before); + expect(P0A_TEXT_TEMPLATE_IDS).toHaveLength(32); + expect(P0A_COLOR_CARD_IDS).toEqual(["COLOR001", "COLOR002", "COLOR008", "COLOR016"]); + expect(P0A_DYNAMIC_STICKER_IDS).toEqual([ + "DYN001", "DYN002", "DYN003", "DYN004", "DYN007", + "DYN008", "DYN011", "DYN012", "DYN015", "DYN016", + ]); + expect(manifest.counts).toEqual({ + color_cards: 4, + dynamic_stickers: 10, + font_panel_items: 11, + static_parts: 25, + static_stickers: 1_407, + text_templates: 32, + }); + expect(manifest.assets.text_templates.map((item) => item.canonical_id)).toEqual(P0A_TEXT_TEMPLATE_IDS); + expect(manifest.assets.font_panel_items.map((item) => item.canonical_id)).toEqual([...referencedFontIds, "FONT081"]); + expect(manifest.assets.color_cards.map((item) => item.canonical_id)).toEqual(P0A_COLOR_CARD_IDS); + expect(manifest.assets.dynamic_stickers.map((item) => item.canonical_id)).toEqual(P0A_DYNAMIC_STICKER_IDS); + expect(manifest.assets.static_stickers).toHaveLength(1_407); + expect(manifest.assets.static_stickers.every((item) => item.part >= 1 && item.part <= 25)).toBe(true); + + const publicComplex = [ + ...manifest.assets.text_templates, + ...manifest.assets.font_panel_items, + ...manifest.assets.color_cards, + ...manifest.assets.dynamic_stickers, + ]; + expect(publicComplex.every((item) => item.release_status === "enabled" + && item.release_tier === "alpha_whitelist" + && item.validation_status === "passed")).toBe(true); + const serialized = JSON.stringify(manifest); + expect(serialized).not.toContain("FLOWER009"); + expect(serialized).not.toContain("COLOR003"); + expect(serialized).not.toContain("DYN005"); + evidence("unit-allowlist.json", { counts: manifest.counts, hidden: ["FLOWER009", "COLOR003", "DYN005"], status: "passed" }); + }); + + it("rejects incomplete registration and any early full_p0 enablement", () => { + const incomplete = fullComplexManifest(); + incomplete.items.splice(incomplete.items.findIndex((item) => item.canonical_id === "DYN035"), 1); + expect(() => createP0aPublicManifest({ complexManifest: incomplete, staticCatalog: staticCatalog() })).toThrow(/interactive_sticker count/i); + + const early = fullComplexManifest(); + early.items.find((item) => item.canonical_id === "FLOWER009")!.release_status = "enabled"; + expect(() => createP0aPublicManifest({ complexManifest: early, staticCatalog: staticCatalog() })).toThrow(/full_p0.*enabled/i); + }); +}); + +describe("TDD-WP5-COL-001 shared five-color renderer input", () => { + it("binds the four enabled layouts to one immutable palette snapshot", () => { + const palette = ["#102030", "#405060", "#708090", "#A0B0C0", "#D0E0F0"] as const; + const plans = createP0aColorCardRenderPlans(palette); + expect(P0A_COLOR_CARD_DEFINITIONS.map((item) => [item.cardId, item.styleId])).toEqual([ + ["COLOR001", "style_01"], ["COLOR002", "style_02"], ["COLOR008", "style_08"], ["COLOR016", "style_16"], + ]); + expect(plans.map((plan) => plan.cardId)).toEqual(P0A_COLOR_CARD_IDS); + expect(plans.every((plan) => plan.palette === plans[0]!.palette)).toBe(true); + expect(Object.isFrozen(plans[0]!.palette)).toBe(true); + expect(plans.every((plan) => JSON.stringify(plan.palette) === JSON.stringify(palette))).toBe(true); + expect(() => createP0aColorCardRenderPlans(["#000000"])).toThrow(/five colors/i); + }); +});