feat: complete TASK-WP4-04 color and dynamic stickers
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { createApp } from "../../apps/api/src/app.js";
|
||||
import { MockAmapAdapter } from "../../apps/api/src/amap-adapter.js";
|
||||
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||
|
||||
const resources: Array<{ close: () => Promise<void> | void }> = [];
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
for (const resource of resources.splice(0).reverse()) await resource.close();
|
||||
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
function registrationFixture() {
|
||||
const directory = mkdtempSync(join(tmpdir(), "dada-wp4-04-"));
|
||||
roots.push(directory);
|
||||
const registration = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 2), currentPrivacyNoticeVersion: "2026-07-24",
|
||||
databasePath: join(directory, "dada.sqlite3"), invitePepper: Buffer.alloc(32, 3), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 4),
|
||||
});
|
||||
resources.push({ close: () => registration.close() });
|
||||
const userId = "00000000-0000-4000-8000-000000000831";
|
||||
registration.database.prepare("INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at) VALUES (?, 'location@example.invalid', 'user', 'active', 1, ?, ?)").run(userId, crypto.randomUUID(), Date.now());
|
||||
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Location User', '@location')").run(userId);
|
||||
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(userId, Date.now());
|
||||
const session = registration.issueAuthenticatedSession(userId, "user");
|
||||
return { registration, session };
|
||||
}
|
||||
|
||||
describe("TASK-WP4-04 location adapter API", () => {
|
||||
it("requires mutation auth and forwards only coordinates to the local mock adapter", async () => {
|
||||
const fixture = registrationFixture();
|
||||
const amap = new MockAmapAdapter();
|
||||
const app = await createApp({ amap, browserGate: false, registration: fixture.registration });
|
||||
resources.push({ close: () => app.close() });
|
||||
const csrf = fixture.registration.issueUserCsrfToken(fixture.session.sessionToken);
|
||||
const response = await app.inject({
|
||||
headers: { cookie: `dada_session=${fixture.session.sessionToken}`, host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121", "x-csrf-token": csrf },
|
||||
method: "POST", payload: { latitude: 27.9943, longitude: 120.6994 }, url: "/api/v1/location/reverse-geocode",
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ formatted_value: "模拟地点 27.9943, 120.6994", service_mode: "mock", status: "resolved" });
|
||||
expect(amap.calls).toEqual([{ latitude: 27.9943, longitude: 120.6994 }]);
|
||||
const anonymous = await app.inject({ headers: { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121", "x-csrf-token": "anonymous-csrf-fixture-000000000000000000000000" }, method: "POST", payload: { latitude: 27.9943, longitude: 120.6994 }, url: "/api/v1/location/reverse-geocode" });
|
||||
expect(anonymous.statusCode).toBe(401);
|
||||
expect(amap.calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -59,7 +59,12 @@ function writeEvidence(caseId: string, name: string, value: unknown) {
|
||||
async function routeEditor(page: Page) {
|
||||
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json", status: 200 }));
|
||||
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({ body: JSON.stringify(projectPayload()), contentType: "application/json", status: 200 }));
|
||||
await page.route(`**/api/v1/private-assets/projects/${projectId}/images/**`, (route) => route.fulfill({ body: Buffer.from("not-an-image"), contentType: "image/png", status: 200 }));
|
||||
await page.route(`**/api/v1/private-assets/projects/${projectId}/images/**`, (route) => {
|
||||
const alternate = route.request().url().endsWith(alternateImageId);
|
||||
const colors = alternate ? ["#102030", "#405060", "#708090", "#A0B0C0", "#D0E0F0"] : ["#F42020", "#20D260", "#1860DC", "#F8D230", "#7840B4"];
|
||||
const svg = `<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>`;
|
||||
return route.fulfill({ body: svg, contentType: "image/svg+xml", status: 200 });
|
||||
});
|
||||
}
|
||||
|
||||
test("TDD-WP4-BG-001 preserves overlays while switching the background", async ({ page }) => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
let vite: ViteDevServer;
|
||||
@@ -25,6 +26,12 @@ const session = {
|
||||
user: { creator_name: "Canvas User", role: "user", social_id: "@canvas_user", status: "active", user_id: "00000000-0000-4000-8000-000000000501" },
|
||||
};
|
||||
|
||||
const stickerRoot = process.env.DADA_STATIC_STICKER_ROOT ?? join(homedir(), "Desktop", "贴纸素材");
|
||||
const originalStickerFixtures: Readonly<Record<string, string>> = {
|
||||
STK001: join(stickerRoot, "sticker_part1", "01af6384c2a962d17f55736f9895b505.png"),
|
||||
STK002: join(stickerRoot, "sticker_part2", "011db0fb6ac4184e4a708374003bce66.png"),
|
||||
};
|
||||
|
||||
function uuid(index: number) {
|
||||
return `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
|
||||
}
|
||||
@@ -53,6 +60,7 @@ function writeEvidence(caseId: string, name: string, value: unknown) {
|
||||
}
|
||||
|
||||
async function routeEditor(page: Page, projectId: string, backend: { canvas: CanvasState; saves: number; version: number }) {
|
||||
if (!Object.values(originalStickerFixtures).every(existsSync)) throw new Error("Archived ordinary sticker fixture is unavailable.");
|
||||
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json", status: 200 }));
|
||||
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
||||
body: JSON.stringify({
|
||||
@@ -68,6 +76,12 @@ async function routeEditor(page: Page, projectId: string, backend: { canvas: Can
|
||||
backend.version += 1;
|
||||
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: backend.version }), contentType: "application/json", status: 200 });
|
||||
});
|
||||
await page.route("**/api/v1/assets/public/fixture-v1/*", (route) => {
|
||||
const assetId = decodeURIComponent(route.request().url().split("/").at(-1)!);
|
||||
const source = originalStickerFixtures[assetId];
|
||||
if (!source) return route.fulfill({ status: 404 });
|
||||
return route.fulfill({ body: readFileSync(source), contentType: "image/png", status: 200 });
|
||||
});
|
||||
}
|
||||
|
||||
test("TDD-WP4-CAN-001 keeps fifty elements editable and blocks the fifty-first", async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
let vite: ViteDevServer;
|
||||
let webUrl: string;
|
||||
|
||||
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());
|
||||
|
||||
const userId = "00000000-0000-4000-8000-000000000821";
|
||||
const session = {
|
||||
csrf_token: "csrf-wp4-04-fixture-0000000000000000000000000000",
|
||||
user: { creator_name: "Dada Creator", social_id: "@@dada", user_id: userId },
|
||||
};
|
||||
|
||||
const rawImages: Record<string, string[]> = {
|
||||
"00000000-0000-4000-8000-000000000811": ["#F42020", "#20D260", "#1860DC", "#F8D230", "#7840B4"],
|
||||
"00000000-0000-4000-8000-000000000812": ["#102030", "#405060", "#708090", "#A0B0C0", "#D0E0F0"],
|
||||
};
|
||||
|
||||
const dynamicRoot = process.env.DADA_DYNAMIC_ASSET_ROOT ?? join(homedir(), "Desktop", "sticker_interactive", "单模板归档", "templates");
|
||||
const textRoot = process.env.DADA_TEXT_ASSET_ROOT ?? join(homedir(), "Desktop", "sticker_text");
|
||||
const dynamicSourceAssets: Readonly<Record<string, { contentType: string; path: string }>> = {
|
||||
"15974853bc3294ef68e7e6d58fe74fd7": { contentType: "font/ttf", path: join(dynamicRoot, "DYN002", "fonts", "15974853bc3294ef68e7e6d58fe74fd7", "0202b90o6r57rxed4027b5689e0dxe7e142r0yho9d0lljuqj.ttf") },
|
||||
"46f8336813e4c48d06a1aef294fdccf6": { contentType: "font/ttf", path: join(dynamicRoot, "DYN016", "fonts", "46f8336813e4c48d06a1aef294fdccf6", "9fbfbb420cea1df916d7c7c7ac90b1c88b61e117-PingFang-SC-Semibold-2.ttf") },
|
||||
"53ca6b704728520da50c145eabb2e635": { contentType: "font/ttf", path: join(dynamicRoot, "DYN007", "fonts", "53ca6b704728520da50c145eabb2e635", "fab6c26a0b21d5e9b57fb5238843ac1fb77a2ce6-HYZhengYuan.ttf") },
|
||||
"cca5efc0e02fb1bf62349bd68ef30fc1": { contentType: "font/otf", path: join(dynamicRoot, "DYN015", "fonts", "cca5efc0e02fb1bf62349bd68ef30fc1", "e11ced673fc7e63e8b0b4730166d29845d8bebae-NotoSansCJKsc-Regular.otf") },
|
||||
"dd25b35dcb7ba4476cbaa9a9592e39e2": { contentType: "font/ttf", path: join(dynamicRoot, "DYN001", "fonts", "dd25b35dcb7ba4476cbaa9a9592e39e2", "0202b90o6r57rxed4027b5689e0dxe7e142r0ygbx80porvko.ttf") },
|
||||
"e4210c9872f0c279b35273f230809821": { contentType: "font/ttf", path: join(dynamicRoot, "DYN011", "fonts", "e4210c9872f0c279b35273f230809821", "06b980259e2104e1211a6819a61bc5ddeca77dcb-DJB-Get-Digital-1.ttf") },
|
||||
"f4bfd4132df2d6be97ceabadf3853505": { contentType: "font/ttf", path: join(dynamicRoot, "DYN008", "fonts", "f4bfd4132df2d6be97ceabadf3853505", "6ce05a147aedabbb610d9cb3e75bbe60c064c3f5-BarlowCondensed-SemiBold.ttf") },
|
||||
"DYN001-image28": { contentType: "image/png", path: join(dynamicRoot, "DYN001", "resource", "image28.png") },
|
||||
"DYN002-image29": { contentType: "image/png", path: join(dynamicRoot, "DYN002", "resource", "image29.png") },
|
||||
"DYN003-image30": { contentType: "image/png", path: join(dynamicRoot, "DYN003", "resource", "image30.png") },
|
||||
"DYN004-image32": { contentType: "image/png", path: join(dynamicRoot, "DYN004", "resource", "image32.png") },
|
||||
"DYN008-backendui0": { contentType: "image/png", path: join(dynamicRoot, "DYN008", "resource", "backendui0.png") },
|
||||
"DYN011-backendui0": { contentType: "image/png", path: join(dynamicRoot, "DYN011", "resource", "backendui0.png") },
|
||||
"DYN015-imager2": { contentType: "image/png", path: join(dynamicRoot, "DYN015", "resource", "imager2_2.png") },
|
||||
"DYN016-image21": { contentType: "image/png", path: join(dynamicRoot, "DYN016", "resource", "image21.png") },
|
||||
};
|
||||
const font081Path = join(textRoot, "字体", "面板全量采集", "font_panel_full_20260722", "resources", "font_packages", "FONT081_Lexend Deca", "font_files", "02034l0o6r57rxed4027b5689e0dxe7e142r0vi8920akeqto.ttf");
|
||||
|
||||
function rawSvg(colors: string[]) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="250" height="250">${colors.map((color, index) => `<rect x="${index * 50}" y="0" width="50" height="250" fill="${color}"/>`).join("")}</svg>`;
|
||||
}
|
||||
|
||||
function emptyCanvas(assetId: string | null = null): CanvasState {
|
||||
return {
|
||||
background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: assetId },
|
||||
elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
interface Backend {
|
||||
canvas: CanvasState;
|
||||
saves: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
function writeEvidence(caseId: string, name: string, value: unknown) {
|
||||
const root = process.env.DADA_EVIDENCE_DIR_COLOR_DYNAMIC;
|
||||
if (!root) return;
|
||||
const directory = resolve(root, caseId);
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(resolve(directory, name), `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function routeEditor(page: Page, projectId: string, backend: Backend) {
|
||||
const sourceFiles = [...Object.values(dynamicSourceAssets).map((asset) => asset.path), font081Path];
|
||||
if (!sourceFiles.every(existsSync)) throw new Error("Archived dynamic source fixture is unavailable.");
|
||||
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-03T04:00:00.000Z", current_image_id: backend.canvas.background.asset_id,
|
||||
images: Object.keys(rawImages).map((imageId, index) => ({ created_at: `2026-08-03T0${index + 4}:00:00.000Z`, generation_id: `00000000-0000-4000-8000-00000000081${index + 3}`, image_id: imageId })),
|
||||
name: "色卡动态画布", 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/private-assets/projects/${projectId}/images/*`, (route) => {
|
||||
const assetId = route.request().url().split("/").at(-1)!;
|
||||
const colors = rawImages[assetId];
|
||||
if (!colors) return route.fulfill({ status: 404 });
|
||||
return route.fulfill({ body: rawSvg(colors), contentType: "image/svg+xml", status: 200 });
|
||||
});
|
||||
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/public/wp4-fixture-v1/FONT081", (route) => route.fulfill({ body: readFileSync(font081Path), contentType: "font/ttf", status: 200 }));
|
||||
await page.route("**/api/v1/assets/public/wp4-dynamic-source-v1/*", (route) => {
|
||||
const assetId = decodeURIComponent(route.request().url().split("/").at(-1)!);
|
||||
const asset = dynamicSourceAssets[assetId];
|
||||
if (!asset) return route.fulfill({ status: 404 });
|
||||
return route.fulfill({ body: readFileSync(asset.path), contentType: asset.contentType, status: 200 });
|
||||
});
|
||||
}
|
||||
|
||||
async function stageInk(page: Page) {
|
||||
return page.getByLabel("编辑画布").evaluate((canvas: HTMLCanvasElement) => {
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas context unavailable.");
|
||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let nonWhite = 0;
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
if ((pixels[index] ?? 255) < 245 || (pixels[index + 1] ?? 255) < 245 || (pixels[index + 2] ?? 255) < 245) nonWhite += 1;
|
||||
}
|
||||
return { canvas_pixels: canvas.width * canvas.height, non_white_pixels: nonWhite };
|
||||
});
|
||||
}
|
||||
|
||||
async function nudgeSelected(page: Page, directions: Array<{ key: "ArrowDown" | "ArrowLeft" | "ArrowRight" | "ArrowUp"; times: number }>) {
|
||||
const stage = page.getByLabel("编辑画布");
|
||||
for (const direction of directions) {
|
||||
for (let index = 0; index < direction.times; index += 1) await stage.press(`Shift+${direction.key}`);
|
||||
}
|
||||
}
|
||||
|
||||
test("TDD-WP4-COL-001 extracts once from raw pixels and refreshes only for a new background", async ({ page }) => {
|
||||
const projectId = "00000000-0000-4000-8000-000000000830";
|
||||
const initialAsset = Object.keys(rawImages)[0]!;
|
||||
const backend: Backend = { canvas: emptyCanvas(initialAsset), saves: 0, version: 1 };
|
||||
const requests: Array<{ method: string; url: string }> = [];
|
||||
page.on("request", (request) => requests.push({ method: request.method(), url: request.url() }));
|
||||
await routeEditor(page, projectId, backend);
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
await page.getByRole("button", { name: "色卡", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: /^添加色卡/ })).toHaveCount(4);
|
||||
await expect(page.getByRole("button", { name: "色卡说明" })).toHaveAttribute("title", "色卡基于原始底图,更换底图时更新,不随裁剪、调色和滤镜变化");
|
||||
const placements = [
|
||||
[{ key: "ArrowLeft", times: 15 }, { key: "ArrowUp", times: 14 }],
|
||||
[{ key: "ArrowRight", times: 15 }, { key: "ArrowUp", times: 14 }],
|
||||
[{ key: "ArrowLeft", times: 15 }, { key: "ArrowDown", times: 14 }],
|
||||
[{ key: "ArrowRight", times: 15 }, { key: "ArrowDown", times: 14 }],
|
||||
] as const;
|
||||
for (const [index, id] of ["COLOR001", "COLOR002", "COLOR008", "COLOR016"].entries()) {
|
||||
await page.getByRole("button", { name: new RegExp(`添加色卡 ${id}`) }).click();
|
||||
await nudgeSelected(page, [...placements[index]!]);
|
||||
}
|
||||
await expect.poll(() => backend.canvas.elements.length).toBe(4);
|
||||
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.every((element) => element.style_parameters?.palette_algorithm_version === "mmcq-v1")).toBe(true);
|
||||
const beforeAdjustments = structuredClone(palettes[0]);
|
||||
await page.getByLabel("编辑画布").press("Escape");
|
||||
await page.getByRole("button", { name: "底图", exact: true }).click();
|
||||
await page.locator(".editor-inspector label").filter({ hasText: "亮度" }).locator('input[type="range"]').fill("40");
|
||||
await page.getByRole("button", { name: "应用调整" }).click();
|
||||
expect(backend.canvas.elements.every((element) => JSON.stringify(element.colors) === JSON.stringify(beforeAdjustments))).toBe(true);
|
||||
await page.getByRole("button", { name: "历史", exact: true }).click();
|
||||
await page.locator(".editor-history-list button").last().click();
|
||||
await page.getByRole("button", { name: "确认更换" }).click();
|
||||
await expect.poll(() => backend.canvas.background.asset_id).toBe(Object.keys(rawImages)[1]);
|
||||
expect(backend.canvas.elements.every((element) => JSON.stringify(element.colors) === JSON.stringify(backend.canvas.elements[0]?.colors))).toBe(true);
|
||||
expect(backend.canvas.elements[0]?.colors).not.toEqual(beforeAdjustments);
|
||||
const network = {
|
||||
image_gets: requests.filter((item) => item.method === "GET" && item.url.includes("/private-assets/")).length,
|
||||
image_uploads: requests.filter((item) => item.method !== "GET" && item.url.includes("/private-assets/")).length,
|
||||
};
|
||||
expect(network.image_uploads).toBe(0);
|
||||
const pixels = await stageInk(page);
|
||||
await page.getByLabel("编辑画布").press("Escape");
|
||||
await page.getByRole("button", { name: "色卡", exact: true }).click();
|
||||
writeEvidence("TDD-WP4-COL-001-deterministic-palette", "palette.json", { algorithm: "mmcq-v1", initial: beforeAdjustments, replacement: backend.canvas.elements[0]?.colors, styles: backend.canvas.elements.map((item) => item.style_id) });
|
||||
writeEvidence("TDD-WP4-COL-001-deterministic-palette", "network-timeline.json", network);
|
||||
writeEvidence("TDD-WP4-COL-001-deterministic-palette", "pixel-diff.json", { ...pixels, four_renderers_visible: true });
|
||||
writeEvidence("TDD-WP4-COL-001-deterministic-palette", "db-diff.json", { elements: backend.canvas.elements, save_count: backend.saves });
|
||||
if (process.env.DADA_EVIDENCE_DIR_COLOR_DYNAMIC) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_COLOR_DYNAMIC, "TDD-WP4-COL-001-deterministic-palette", "color-cards.png") });
|
||||
});
|
||||
|
||||
test("TDD-WP4-DYN-001 snapshots the confirmed local time across clock changes and reopen", async ({ page }) => {
|
||||
await page.clock.setFixedTime(new Date("2026-08-03T09:07:00+08:00"));
|
||||
const projectId = "00000000-0000-4000-8000-000000000840";
|
||||
const backend: Backend = { canvas: emptyCanvas(), saves: 0, version: 2 };
|
||||
await routeEditor(page, projectId, backend);
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
await page.getByRole("button", { name: "动态贴纸", exact: true }).click();
|
||||
for (const id of ["DYN007", "DYN008", "DYN011", "DYN012"]) await page.getByRole("button", { name: new RegExp(`添加动态贴纸 ${id}`) }).click();
|
||||
await expect.poll(() => backend.canvas.elements.length).toBe(4);
|
||||
const snapshots = backend.canvas.elements.map((element) => ({ fields: element.dynamic_fields, id: element.template_or_asset_id, value: element.formatted_value }));
|
||||
await page.clock.setFixedTime(new Date("2026-08-06T23:59:00+08:00"));
|
||||
await page.reload();
|
||||
expect(backend.canvas.elements.map((element) => ({ fields: element.dynamic_fields, id: element.template_or_asset_id, value: element.formatted_value }))).toEqual(snapshots);
|
||||
writeEvidence("TDD-WP4-DYN-001-fixed-time", "canvas-state.json", backend.canvas);
|
||||
writeEvidence("TDD-WP4-DYN-001-fixed-time", "clock-trace.json", { inserted_at: "2026-08-03T09:07:00+08:00", reopened_at: "2026-08-06T23:59:00+08:00", snapshots });
|
||||
writeEvidence("TDD-WP4-DYN-001-fixed-time", "pixel-diff.json", { ...(await stageInk(page)), static_canvas_content: true, links: 0 });
|
||||
});
|
||||
|
||||
test("TDD-WP4-DYN-002 gates coordinates behind consent and preserves manual fallback", async ({ page }) => {
|
||||
const projectId = "00000000-0000-4000-8000-000000000850";
|
||||
const backend: Backend = { canvas: emptyCanvas(), saves: 0, version: 3 };
|
||||
await page.addInitScript(() => {
|
||||
(window as Window & { __dadaLocationCalls?: number }).__dadaLocationCalls = 0;
|
||||
Object.defineProperty(navigator, "geolocation", {
|
||||
configurable: true,
|
||||
value: { getCurrentPosition(success: PositionCallback) { (window as Window & { __dadaLocationCalls?: number }).__dadaLocationCalls = ((window as Window & { __dadaLocationCalls?: number }).__dadaLocationCalls ?? 0) + 1; success({ coords: { latitude: 27.9943, longitude: 120.6994 } } as GeolocationPosition); } },
|
||||
});
|
||||
});
|
||||
let reverseCalls = 0;
|
||||
let servicePaused = false;
|
||||
await routeEditor(page, projectId, backend);
|
||||
await page.route("**/api/v1/location/reverse-geocode", async (route) => {
|
||||
reverseCalls += 1;
|
||||
if (servicePaused) return route.fulfill({ status: 503 });
|
||||
return route.fulfill({ body: JSON.stringify({ formatted_value: "浙江省温州市", service_mode: "mock", status: "resolved" }), contentType: "application/json" });
|
||||
});
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
await page.getByRole("button", { name: "动态贴纸", exact: true }).click();
|
||||
await page.getByRole("button", { name: /添加动态贴纸 DYN004/ }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "使用自动定位" });
|
||||
await expect(dialog).toContainText("原始经纬度会保存到当前项目并显示在导出成品中");
|
||||
await dialog.getByRole("button", { name: "暂不定位" }).click();
|
||||
expect(await page.evaluate(() => (window as Window & { __dadaLocationCalls?: number }).__dadaLocationCalls)).toBe(0);
|
||||
expect(reverseCalls).toBe(0);
|
||||
expect(backend.canvas.elements).toHaveLength(0);
|
||||
await page.getByRole("button", { name: /添加动态贴纸 DYN004/ }).click();
|
||||
await page.getByRole("button", { name: "同意并自动定位" }).click();
|
||||
await expect.poll(() => backend.canvas.elements.length).toBe(1);
|
||||
expect(backend.canvas.elements[0]?.coordinates).toEqual({ latitude: 27.9943, longitude: 120.6994 });
|
||||
expect(reverseCalls).toBe(1);
|
||||
await page.getByRole("button", { name: "删除" }).click();
|
||||
await expect.poll(() => backend.canvas.elements.length).toBe(0);
|
||||
servicePaused = true;
|
||||
await page.getByRole("button", { name: /添加动态贴纸 DYN004/ }).click();
|
||||
await page.getByRole("button", { name: "同意并自动定位" }).click();
|
||||
await expect(page.getByText("自动定位不可用,请改用手动地点贴纸。")).toBeVisible();
|
||||
await page.getByLabel("手动地点文字").fill("温州手动地点");
|
||||
await page.getByRole("button", { name: "改用手动地点贴纸" }).click();
|
||||
await expect.poll(() => backend.canvas.elements.length).toBe(1);
|
||||
expect(backend.canvas.elements[0]?.template_or_asset_id).toBe("DYN001");
|
||||
expect(backend.canvas.elements[0]?.coordinates).toBeUndefined();
|
||||
const calls = { geolocation: await page.evaluate(() => (window as Window & { __dadaLocationCalls?: number }).__dadaLocationCalls), reverse_geocode: reverseCalls };
|
||||
writeEvidence("TDD-WP4-DYN-002-location-consent", "external-calls.json", { ...calls, rejected_dada_prompt_calls: 0, service_mode: "mock", service_paused_manual_available: true });
|
||||
writeEvidence("TDD-WP4-DYN-002-location-consent", "canvas-state.json", backend.canvas);
|
||||
writeEvidence("TDD-WP4-DYN-002-location-consent", "db-diff.json", { coordinates_after_delete: null, manual_element: backend.canvas.elements[0] });
|
||||
});
|
||||
|
||||
test("TDD-WP4-DYN-003 keeps identity overrides local and discloses DYN012 FONT081 substitution", async ({ page }) => {
|
||||
await page.clock.setFixedTime(new Date("2026-08-03T09:07:00+08:00"));
|
||||
const projectId = "00000000-0000-4000-8000-000000000860";
|
||||
const backend: Backend = { canvas: emptyCanvas(Object.keys(rawImages)[0]!), saves: 0, version: 4 };
|
||||
await routeEditor(page, projectId, backend);
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
await page.getByRole("button", { name: "动态贴纸", exact: true }).click();
|
||||
await page.getByRole("button", { name: /添加动态贴纸 DYN016/ }).click();
|
||||
await expect.poll(() => backend.canvas.elements[0]?.formatted_value).toBe("@dada");
|
||||
await page.getByLabel("动态贴纸显示文字").fill("@single-instance");
|
||||
await page.getByRole("button", { name: "应用显示文字" }).click();
|
||||
await expect.poll(() => backend.canvas.elements[0]?.formatted_value).toBe("@single-instance");
|
||||
await nudgeSelected(page, [{ key: "ArrowUp", times: 18 }]);
|
||||
await page.getByRole("button", { name: "动态贴纸", exact: true }).click();
|
||||
await page.getByRole("button", { name: /添加动态贴纸 DYN012/ }).click();
|
||||
await expect(page.getByText(/当前明确使用 FONT081 · Lexend Deca 替代/)).toBeVisible();
|
||||
await expect.poll(() => page.evaluate(() => document.fonts.check('16px "Dada_FONT081"'))).toBe(true);
|
||||
await expect.poll(() => backend.canvas.elements.some((element) => element.template_or_asset_id === "DYN012")).toBe(true);
|
||||
expect(backend.canvas.elements.find((element) => element.template_or_asset_id === "DYN012")).toMatchObject({
|
||||
dynamic_fields: { font_substitution: "FONT081" }, font_override: "FONT081",
|
||||
});
|
||||
expect(session.user.social_id).toBe("@@dada");
|
||||
const pixels = await stageInk(page);
|
||||
writeEvidence("TDD-WP4-DYN-003-identity-font", "canvas-state.json", backend.canvas);
|
||||
writeEvidence("TDD-WP4-DYN-003-identity-font", "db-diff.json", { account_profile: session.user, instance_override: "@single-instance", profile_changed: false });
|
||||
writeEvidence("TDD-WP4-DYN-003-identity-font", "font-load.json", { fallback: null, font_id: "FONT081", original_font: "DIN_MediumAlternate.otf", ready: true, substitution_disclosed: true });
|
||||
writeEvidence("TDD-WP4-DYN-003-identity-font", "pixel-diff.json", { ...pixels, substitution_requires_manual_review: true });
|
||||
if (process.env.DADA_EVIDENCE_DIR_COLOR_DYNAMIC) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_COLOR_DYNAMIC, "TDD-WP4-DYN-003-identity-font", "dyn012-substitution.png") });
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import {
|
||||
COLOR_CARD_SOURCE_GEOMETRY,
|
||||
P0A_COLOR_CARDS,
|
||||
createColorCardElement,
|
||||
extractMmcqPalette,
|
||||
refreshColorCards,
|
||||
} from "../../apps/web/src/palette-provider.js";
|
||||
import {
|
||||
DYN012_RENDER_LAYOUT,
|
||||
LocationConsentGate,
|
||||
P0A_DYNAMIC_STICKERS,
|
||||
createDynamicStickerElement,
|
||||
normalizeSocialId,
|
||||
overrideDynamicStickerValue,
|
||||
} from "../../apps/web/src/dynamic-provider.js";
|
||||
import { DYNAMIC_RENDER_MODELS, DYNAMIC_RESOURCE_VERSION } from "../../apps/web/src/dynamic-render-models.js";
|
||||
|
||||
const identity = { createdAt: "2026-08-03T04:00:00.000Z", elementId: "00000000-0000-4000-8000-000000000801" };
|
||||
|
||||
function canvas(elements: CanvasState["elements"] = []): CanvasState {
|
||||
return {
|
||||
background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: "00000000-0000-4000-8000-000000000802" },
|
||||
elements, pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function colorPixels(colors: Array<{ count: number; rgb: [number, number, number] }>) {
|
||||
const values: number[] = [];
|
||||
for (const color of colors) {
|
||||
for (let index = 0; index < color.count; index += 1) values.push(...color.rgb, 255);
|
||||
}
|
||||
return new Uint8ClampedArray(values);
|
||||
}
|
||||
|
||||
describe("TASK-WP4-04 deterministic color cards", () => {
|
||||
it("keeps the archived 320px color-card geometry instead of enlarged approximations", () => {
|
||||
expect(COLOR_CARD_SOURCE_GEOMETRY).toMatchObject({
|
||||
style_01: { bounds: { bottom: 76, left: -26, right: 26, top: -74 }, palette: { bottom: 37, left: -23, right: 23, top: -71 } },
|
||||
style_02: { bounds: { bottom: 69, left: -18, right: 18, top: -77 } },
|
||||
style_08: { bounds: { bottom: 10, left: -73, right: 73, top: -9 } },
|
||||
style_16: { bounds: { bottom: 9, left: -78, right: 78, top: -9 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the four-item P0-A allowlist and produces a stable five-color MMCQ palette", () => {
|
||||
expect(P0A_COLOR_CARDS.map((item) => item.cardId)).toEqual(["COLOR001", "COLOR002", "COLOR008", "COLOR016"]);
|
||||
const pixels = colorPixels([
|
||||
{ count: 50, rgb: [244, 32, 32] }, { count: 40, rgb: [32, 210, 96] }, { count: 30, rgb: [24, 96, 220] },
|
||||
{ count: 20, rgb: [248, 210, 48] }, { count: 10, rgb: [120, 64, 180] },
|
||||
]);
|
||||
const first = extractMmcqPalette(pixels);
|
||||
const second = extractMmcqPalette(pixels);
|
||||
expect(first).toEqual(second);
|
||||
expect(first).toHaveLength(5);
|
||||
expect(first).toEqual([...first].sort((left, right) => right.population - left.population || left.rgbValue - right.rgbValue));
|
||||
expect(first.every((entry) => /^#[0-9A-F]{6}$/.test(entry.hex))).toBe(true);
|
||||
});
|
||||
|
||||
it("snapshots five colors and only refreshes them when the raw background changes", () => {
|
||||
const initialPalette = ["#F42020", "#20D260", "#1860DC", "#F8D230", "#7840B4"];
|
||||
const replacement = ["#111111", "#333333", "#555555", "#777777", "#999999"];
|
||||
const element = createColorCardElement(P0A_COLOR_CARDS[0]!, initialPalette, identity, 0);
|
||||
const state = canvas([element]);
|
||||
expect(element.style_id).toBe("style_01");
|
||||
expect(element.style_parameters?.palette_algorithm_version).toBe("mmcq-v1");
|
||||
expect(element.colors).toEqual(initialPalette);
|
||||
expect(refreshColorCards(state, replacement).elements[0]?.colors).toEqual(replacement);
|
||||
expect(state.elements[0]?.colors).toEqual(initialPalette);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TASK-WP4-04 dynamic providers", () => {
|
||||
const context = {
|
||||
now: new Date("2026-08-03T09:07:00+08:00"),
|
||||
profile: { creatorName: "Dada Creator", socialId: "@@dada" },
|
||||
};
|
||||
|
||||
it("exposes exactly ten P0-A providers and snapshots time and identity", () => {
|
||||
expect(P0A_DYNAMIC_STICKERS.map((item) => item.templateId)).toEqual([
|
||||
"DYN001", "DYN002", "DYN003", "DYN004", "DYN007", "DYN008", "DYN011", "DYN012", "DYN015", "DYN016",
|
||||
]);
|
||||
const time = createDynamicStickerElement("DYN012", context, identity, 0);
|
||||
expect(time.formatted_value).toBe("09:07");
|
||||
expect(time.dynamic_fields).toMatchObject({ font_substitution: "FONT081", hour: "09", minute: "07" });
|
||||
expect(time.font_override).toBe("FONT081");
|
||||
const identitySticker = createDynamicStickerElement("DYN016", context, identity, 0);
|
||||
expect(identitySticker.formatted_value).toBe("@dada");
|
||||
expect(normalizeSocialId("@@@dada")).toBe("@dada");
|
||||
});
|
||||
|
||||
it("uses the archived template categories and only the fields visibly consumed by each source", () => {
|
||||
expect(P0A_DYNAMIC_STICKERS.map(({ category, templateId }) => [templateId, category])).toEqual([
|
||||
["DYN001", "location"], ["DYN002", "location"], ["DYN003", "location"], ["DYN004", "location"],
|
||||
["DYN007", "other"], ["DYN008", "time"], ["DYN011", "time"], ["DYN012", "time"],
|
||||
["DYN015", "identity"], ["DYN016", "identity"],
|
||||
]);
|
||||
const other = createDynamicStickerElement("DYN007", context, identity, 0);
|
||||
expect(other.dynamic_fields).toEqual({ nickname: "@dada" });
|
||||
expect(other.formatted_value).toBe("@dada");
|
||||
const monthTime = createDynamicStickerElement("DYN008", context, identity, 0);
|
||||
expect(monthTime.dynamic_fields).toEqual({ hour: "09", minute: "07", month: "08" });
|
||||
});
|
||||
|
||||
it("maps every enabled dynamic sticker to its archived source candidate and original resource version", () => {
|
||||
expect(Object.entries(DYNAMIC_RENDER_MODELS).map(([id, model]) => [id, model.sourceCandidateId])).toEqual([
|
||||
["DYN001", "l_POI01"], ["DYN002", "l_POI02"], ["DYN003", "l_POI03"], ["DYN004", "l_POI04"],
|
||||
["DYN007", "diaoyu"], ["DYN008", "l_shijian2"], ["DYN011", "l_shijian6"], ["DYN012", "l_shijian7"],
|
||||
["DYN015", "0721userna"], ["DYN016", "l_username00"],
|
||||
]);
|
||||
const element = createDynamicStickerElement("DYN001", { ...context, location: { formattedValue: "温州" } }, identity, 0);
|
||||
expect(element.resource_version).toBe(DYNAMIC_RESOURCE_VERSION);
|
||||
expect(DYNAMIC_RENDER_MODELS.DYN001.imageLayers).toEqual([
|
||||
{ assetId: "DYN001-image28", height: 67, width: 219, x: -17.562, y: 2.203 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the source DYN012 split-clock composition instead of a generic time box", () => {
|
||||
expect(DYN012_RENDER_LAYOUT).toEqual({
|
||||
background: "transparent",
|
||||
divider: { color: "#FFFFFF", height: 42, width: 7, x: 0, y: 0 },
|
||||
hour: { color: "#FFFFFF", fontId: "FONT081", fontSize: 100, x: -60, y: 0 },
|
||||
meridiem: { color: "#FFFFFF", fontId: "FONT081", fontSize: 20, x: 94, y: -65 },
|
||||
minute: { color: "#FFFFFF", fontId: "FONT081", fontSize: 100, x: 60, y: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a per-instance override separate from the account profile", () => {
|
||||
const profile = structuredClone(context.profile);
|
||||
const element = createDynamicStickerElement("DYN015", context, identity, 0);
|
||||
const changed = overrideDynamicStickerValue(element, "Single Sticker Name");
|
||||
expect(changed.formatted_value).toBe("Single Sticker Name");
|
||||
expect(element.formatted_value).toBe("Dada Creator");
|
||||
expect(context.profile).toEqual(profile);
|
||||
});
|
||||
|
||||
it("does not call location or reverse geocoding until Dada consent is confirmed", async () => {
|
||||
const geolocate = vi.fn(async () => ({ latitude: 27.9943, longitude: 120.6994 }));
|
||||
const reverseGeocode = vi.fn(async () => "浙江省温州市");
|
||||
const gate = new LocationConsentGate({ geolocate, reverseGeocode });
|
||||
expect(gate.reject()).toBeUndefined();
|
||||
expect(geolocate).not.toHaveBeenCalled();
|
||||
expect(reverseGeocode).not.toHaveBeenCalled();
|
||||
await expect(gate.confirm()).resolves.toEqual({ formattedValue: "浙江省温州市", latitude: 27.9943, longitude: 120.6994 });
|
||||
expect(geolocate).toHaveBeenCalledTimes(1);
|
||||
expect(reverseGeocode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user