feat(P0-A): 整合第一版并冻结最终发布 #1
@@ -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,再开始修复,禁止在根因未明确时直接改代码。
|
||||
|
||||
+7
-1
@@ -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/<wp-task>` 分支;完成 Red、Green、所属门禁、回归和脱敏扫描后,必须形成只包含该任务及其必要文档同步的本地 Git 提交,并推送到项目指定的 Gitea 组织仓库同名远端分支。当前项目的目标组织命名空间固定为 `intelligrow`,个人命名空间中的仓库或分支不作为任务交付位置。
|
||||
|
||||
任务交付必须记录 `task_id`、完整提交 SHA、Gitea 仓库、远端分支和远端哈希核对结果;记录不得包含凭据。推送后必须通过远端引用确认 Gitea 分支哈希与本地提交完全一致。只有本地测试 Green、必要真实/人工门禁齐全且远端核对通过,任务才可从 `green` 或 `externally_blocked` 进入 `passed`。仅有工作区改动、本地提交、推送失败、推送到个人命名空间或远端哈希不一致时,均不得宣告任务完成、工作包完成,也不得开始下一个 `TASK-*`。
|
||||
|
||||
## 2. 技术架构与选型
|
||||
|
||||
### 2.1 固定技术基线
|
||||
|
||||
+3
-3
@@ -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 |
|
||||
|
||||
## 文档使用规则
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+39
-18
@@ -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<Record<typeof P0A_REQUIRED_FONT_PANEL_IDS[number], string>> = {
|
||||
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);
|
||||
|
||||
+5
-3
@@ -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",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@dada/static-sticker-catalog": "workspace:*",
|
||||
"@dada/template-registry": "workspace:*",
|
||||
"csv-parse": "7.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
type CsvRow = Record<string, string>;
|
||||
@@ -487,6 +488,24 @@ export function compileAssetArchive(options: AssetCompilerOptions): AssetCompile
|
||||
ids.add(entry.canonical_id);
|
||||
}
|
||||
|
||||
const fontPanelIdBySha256 = new Map<string, string>();
|
||||
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<string>(P0A_TEXT_TEMPLATE_IDS);
|
||||
const fontRegistrations = new Map<string, { panelIds: string[]; sha256s: string[] }>();
|
||||
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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2024"],
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
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<string, number>;
|
||||
}
|
||||
|
||||
const expectedFamilyCounts: Readonly<Record<RegisteredComplexFamily, number>> = {
|
||||
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<string>();
|
||||
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<string>();
|
||||
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 },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2024"],
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Generated
+29
@@ -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':
|
||||
|
||||
@@ -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));
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-9
@@ -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/",
|
||||
|
||||
@@ -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/<run_id>/` 证据;缺 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 仓库级测试接口前置
|
||||
|
||||
|
||||
@@ -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. 固定测试层级
|
||||
|
||||
| 层级 | 标识 | 基础设施 | 主要职责 |
|
||||
|
||||
@@ -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 `<svg xmlns="http://www.w3.org/2000/svg" width="250" height="250">${colors.map((color, index) => `<rect x="${index * 50}" width="50" height="250" fill="${color}"/>`).join("")}</svg>`;
|
||||
}
|
||||
|
||||
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<string, unknown>) {
|
||||
if (!path) return;
|
||||
mkdirSync(resolve(path, ".."), { recursive: true });
|
||||
const current = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown> : {};
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user