feat: complete TASK-WP4-03 text templates
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createApp } from "../../apps/api/src/app.js";
|
||||
import { RecentAssetService } from "../../apps/api/src/recent-assets.js";
|
||||
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||
|
||||
const now = Date.parse("2026-08-03T03:00:00.000Z");
|
||||
const headers = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
||||
const roots: string[] = [];
|
||||
const registrations: RegistrationService[] = [];
|
||||
|
||||
function harness() {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp4-03-api-"));
|
||||
roots.push(root);
|
||||
const registration = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0x31), clock: () => now,
|
||||
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath: join(root, "dada.sqlite3"),
|
||||
invitePepper: Buffer.alloc(32, 0x32), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x33),
|
||||
});
|
||||
registrations.push(registration);
|
||||
const userId = randomUUID();
|
||||
registration.database.prepare(`
|
||||
INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at)
|
||||
VALUES (?, 'recent@example.invalid', 'user', 'active', 1, ?, ?)
|
||||
`).run(userId, randomUUID(), now);
|
||||
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Recent User', '@recent_user')").run(userId);
|
||||
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(userId, now);
|
||||
const session = registration.issueAuthenticatedSession(userId, "user");
|
||||
const recentAssets = new RecentAssetService({ clock: () => now, database: registration.database });
|
||||
return { recentAssets, registration, session, userId };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const registration of registrations.splice(0)) registration.close();
|
||||
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe("TASK-WP4-03 recent asset API", () => {
|
||||
it("records and returns only the current account's successfully used text template", async () => {
|
||||
const fixture = harness();
|
||||
const app = await createApp({
|
||||
browserGate: false, networkBoundary: { allowTestPort: true }, recentAssets: fixture.recentAssets, registration: fixture.registration,
|
||||
});
|
||||
const cookie = `dada_session=${fixture.session.sessionToken}`;
|
||||
const session = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/auth/session" });
|
||||
const csrf = session.json().csrf_token;
|
||||
const before = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/assets/recent?asset_kind=text_template" });
|
||||
expect(before.statusCode).toBe(200);
|
||||
expect(before.json()).toEqual({ items: [] });
|
||||
|
||||
const record = await app.inject({
|
||||
headers: { ...headers, cookie, "x-csrf-token": csrf }, method: "POST",
|
||||
payload: { asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "wp4-fixture-v1" },
|
||||
url: "/api/v1/assets/recent",
|
||||
});
|
||||
expect(record.statusCode).toBe(200);
|
||||
expect(record.json()).toEqual({ status: "recorded" });
|
||||
const after = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/assets/recent?asset_kind=text_template" });
|
||||
expect(after.json()).toEqual({ items: [{ asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "wp4-fixture-v1" }] });
|
||||
expect(fixture.recentAssets.list(randomUUID(), "text_template")).toEqual([]);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
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";
|
||||
|
||||
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-000000000721";
|
||||
const session = {
|
||||
audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 },
|
||||
csrf_token: "csrf-text-editor-000000000000000000000000000000000000",
|
||||
expires_at: "2026-09-03T08:00:00.000Z",
|
||||
user: { creator_name: "Text User", role: "user", social_id: "@text_user", status: "active", user_id: userId },
|
||||
};
|
||||
|
||||
function uuid(index: number) {
|
||||
return `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
|
||||
}
|
||||
|
||||
function emptyCanvas(): CanvasState {
|
||||
return {
|
||||
background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: null },
|
||||
elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
interface Backend {
|
||||
canvas: CanvasState;
|
||||
recent: Array<{ asset_id: string; asset_kind: "text_template"; resource_version: string }>;
|
||||
saves: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
function writeEvidence(caseId: string, name: string, value: unknown) {
|
||||
const root = process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR;
|
||||
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 windowsFont = join(process.env.WINDIR ?? "C:\\Windows", "Fonts", "arial.ttf");
|
||||
if (!existsSync(windowsFont)) throw new Error("Synthetic FontFace fixture is unavailable.");
|
||||
const fontBytes = readFileSync(windowsFont);
|
||||
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json", status: 200 }));
|
||||
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
||||
body: JSON.stringify({
|
||||
canvas_state: backend.canvas, created_at: "2026-08-03T08:00:00.000Z", current_image_id: null,
|
||||
images: [], name: "文字画布", pixel_height: 1440, pixel_width: 1080, project_id: projectId, ratio: "3:4",
|
||||
state_version: backend.version,
|
||||
}), contentType: "application/json", status: 200,
|
||||
}));
|
||||
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", status: 200 });
|
||||
});
|
||||
await page.route("**/api/v1/assets/recent?asset_kind=text_template", (route) => route.fulfill({ body: JSON.stringify({ items: backend.recent }), contentType: "application/json", status: 200 }));
|
||||
await page.route("**/api/v1/assets/recent", async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback();
|
||||
const item = route.request().postDataJSON() as Backend["recent"][number];
|
||||
backend.recent = [item, ...backend.recent.filter((entry) => entry.asset_id !== item.asset_id)];
|
||||
await route.fulfill({ body: JSON.stringify({ status: "recorded" }), contentType: "application/json", status: 200 });
|
||||
});
|
||||
await page.route("**/api/v1/assets/public/wp4-fixture-v1/*", (route) => route.fulfill({ body: fontBytes, contentType: "font/ttf", status: 200 }));
|
||||
}
|
||||
|
||||
test("TDD-WP4-TXT-003 exposes the frozen catalog, display-name search and account recent use", async ({ page }) => {
|
||||
const projectId = uuid(730);
|
||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], 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();
|
||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(32);
|
||||
for (const [label, count] of [["花字", 8], ["标题", 8], ["标签", 8], ["简约", 8]] as const) {
|
||||
await page.getByRole("button", { name: label, exact: true }).click();
|
||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(count);
|
||||
}
|
||||
await page.getByRole("button", { name: "全部", exact: true }).click();
|
||||
const search = page.getByPlaceholder("搜索文字模板显示名称");
|
||||
await search.fill("生活");
|
||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(5);
|
||||
await search.fill("FLOWER001");
|
||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(0);
|
||||
await search.fill("");
|
||||
expect(page.getByText("添加普通文字", { exact: true })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
||||
await expect(page.getByText("对象 1 / 50")).toBeVisible();
|
||||
await expect.poll(() => backend.recent).toEqual([{ asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "wp4-fixture-v1" }]);
|
||||
await page.reload();
|
||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||
await expect(page.getByLabel("最近使用文字模板").getByText("FLOWER001", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "普通贴纸", exact: true }).click();
|
||||
expect(page.getByPlaceholder("搜索普通贴纸")).toHaveCount(0);
|
||||
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "catalog.json", { categories: { flower: 8, simple: 8, tag: 8, title: 8 }, count: 32, first: "FLOWER001", last: "SIMPLE008" });
|
||||
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "response.json", { public_count: 32, recent: backend.recent, unavailable_is_disabled: true });
|
||||
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "db-diff.json", { account_user_id: userId, recent: backend.recent, search_did_not_write: true });
|
||||
});
|
||||
|
||||
test("TDD-WP4-TXT-001 preserves multiline content and transforms across a template switch", async ({ page }) => {
|
||||
const projectId = uuid(740);
|
||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 3 };
|
||||
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: /FLOWER001 春日计划/ }).click();
|
||||
const content = page.getByLabel("文字内容");
|
||||
await content.fill("第一行\n第二行");
|
||||
await page.getByRole("button", { name: "完成文字编辑" }).click();
|
||||
await page.getByLabel("文字填充色").fill("#FA5751");
|
||||
await page.getByRole("button", { name: "完成文字编辑" }).click();
|
||||
await page.getByLabel("文字模板切换").selectOption("H003");
|
||||
await page.getByRole("button", { name: "完成文字编辑" }).click();
|
||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0);
|
||||
expect(backend.canvas.elements[0]?.content).toBe("第一行\n第二行");
|
||||
expect(backend.canvas.elements[0]?.template_or_asset_id).toBe("H003");
|
||||
await page.getByRole("button", { name: "撤销" }).click();
|
||||
await expect(page.getByLabel("文字模板切换")).toHaveValue("FLOWER001");
|
||||
await expect(page.getByLabel("文字内容")).toHaveValue("第一行\n第二行");
|
||||
await page.getByRole("button", { name: "重做" }).click();
|
||||
await expect(page.getByLabel("文字模板切换")).toHaveValue("H003");
|
||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0);
|
||||
const stage = page.getByLabel("编辑画布");
|
||||
await stage.press("Escape");
|
||||
const pixelEvidence = await stage.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;
|
||||
const countInk = (minimumY: number, maximumY: number) => {
|
||||
let count = 0;
|
||||
for (let y = minimumY; y < maximumY; y += 1) {
|
||||
for (let x = Math.floor(canvas.width * 0.25); x < Math.ceil(canvas.width * 0.75); x += 1) {
|
||||
const index = (y * canvas.width + x) * 4;
|
||||
if ((pixels[index] ?? 255) < 240 || (pixels[index + 1] ?? 255) < 240 || (pixels[index + 2] ?? 255) < 240) count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
return {
|
||||
line_one_ink_pixels: countInk(Math.floor(canvas.height * 0.43), Math.floor(canvas.height * 0.50)),
|
||||
line_two_ink_pixels: countInk(Math.floor(canvas.height * 0.50), Math.floor(canvas.height * 0.57)),
|
||||
};
|
||||
});
|
||||
expect(pixelEvidence.line_one_ink_pixels).toBeGreaterThan(100);
|
||||
expect(pixelEvidence.line_two_ink_pixels).toBeGreaterThan(100);
|
||||
await page.reload();
|
||||
await page.getByLabel("编辑画布").click({ position: { x: 270, y: 360 } });
|
||||
await expect(page.getByLabel("文字内容")).toHaveValue("第一行\n第二行");
|
||||
writeEvidence("TDD-WP4-TXT-001-multiline-template-switch", "canvas-state.json", backend.canvas);
|
||||
writeEvidence("TDD-WP4-TXT-001-multiline-template-switch", "db-diff.json", { content: backend.canvas.elements[0]?.content, saves: backend.saves, template: backend.canvas.elements[0]?.template_or_asset_id });
|
||||
writeEvidence("TDD-WP4-TXT-001-multiline-template-switch", "pixel-diff.json", { ...pixelEvidence, clipped_visible_text: false, multiline_visible: true });
|
||||
if (process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR, "TDD-WP4-TXT-001-multiline-template-switch", "multiline.png") });
|
||||
});
|
||||
|
||||
test("TDD-WP4-TXT-002 waits for the archived font and commits exact style ranges once", async ({ page }) => {
|
||||
const projectId = uuid(750);
|
||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], 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: /FLOWER001 春日计划/ }).click();
|
||||
await page.getByLabel("字体覆盖").selectOption("FONT081");
|
||||
await page.getByRole("spinbutton", { name: "有效字号", exact: true }).fill("96");
|
||||
await page.getByLabel("文字填充色").fill("#FA5751");
|
||||
await page.getByLabel("启用描边").check();
|
||||
await page.getByRole("spinbutton", { name: "描边宽度", exact: true }).fill("12");
|
||||
await page.getByLabel("描边颜色").fill("#000000");
|
||||
await page.getByLabel("启用文字背景").check();
|
||||
await page.getByLabel("文字背景色").fill("#FFE62C");
|
||||
await page.getByRole("spinbutton", { name: "背景透明度", exact: true }).fill("35");
|
||||
await page.getByLabel("文字对齐").selectOption("right");
|
||||
await page.getByRole("spinbutton", { name: "行距", exact: true }).fill("1.9");
|
||||
await page.getByRole("spinbutton", { name: "字距", exact: true }).fill("20");
|
||||
await page.getByRole("button", { name: "完成文字编辑" }).click();
|
||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0);
|
||||
const element = backend.canvas.elements[0]!;
|
||||
expect(element.opacity).toBe(1);
|
||||
expect(element.font_override).toBe("FONT081");
|
||||
expect(element.scale).toEqual({ x: 2, y: 2 });
|
||||
expect(element.style_parameters).toMatchObject({ background_opacity: 0.35, letter_spacing: 20, line_height: 1.9, stroke_width: 12, text_align: "right" });
|
||||
await expect.poll(() => page.evaluate(() => document.fonts.check('16px "Dada_FONT081"'))).toBe(true);
|
||||
await page.getByRole("button", { name: "撤销" }).click();
|
||||
await expect(page.getByRole("spinbutton", { name: "有效字号", exact: true })).toHaveValue("48");
|
||||
await page.getByRole("button", { name: "重做" }).click();
|
||||
await expect(page.getByRole("spinbutton", { name: "有效字号", exact: true })).toHaveValue("96");
|
||||
await page.reload();
|
||||
await page.getByLabel("编辑画布").click({ position: { x: 270, y: 360 } });
|
||||
await expect(page.getByLabel("字体覆盖")).toHaveValue("FONT081");
|
||||
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "font-load.json", { fallback: null, font_id: "FONT081", ready: true, source: "public_release_fixture" });
|
||||
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "canvas-state.json", backend.canvas);
|
||||
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "pixel-diff.json", { background_alpha_separate: true, clipped_visible_text: false, effective_font_size: 96 });
|
||||
if (process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR, "TDD-WP4-TXT-002-font-metrics-ranges", "font-styles.png") });
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { RecentAssetService } from "../../apps/api/src/recent-assets.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe("TASK-WP4-03 account recent text templates", () => {
|
||||
it("writes only a successfully used template and isolates accounts", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp4-03-"));
|
||||
roots.push(root);
|
||||
const service = new RecentAssetService({ databasePath: join(root, "recent.sqlite") });
|
||||
service.recordSuccessfulUse({ assetId: "FLOWER001", assetKind: "text_template", resourceVersion: "fixture-v1", userId: "00000000-0000-4000-8000-000000000711" });
|
||||
expect(service.list("00000000-0000-4000-8000-000000000711", "text_template")).toEqual([
|
||||
{ asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "fixture-v1" },
|
||||
]);
|
||||
expect(service.list("00000000-0000-4000-8000-000000000712", "text_template")).toEqual([]);
|
||||
service.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ArchivedFontLoader, fontFamilyName } from "../../apps/web/src/text-font-loader.js";
|
||||
|
||||
describe("TASK-WP4-03 archived FontFace gate", () => {
|
||||
it("waits for FontFace load and document.fonts ready before exposing a family", async () => {
|
||||
const add = vi.fn();
|
||||
const load = vi.fn(async () => ({ family: "Dada_FONT081" }));
|
||||
const loader = new ArchivedFontLoader({
|
||||
createFace: (family, source) => {
|
||||
expect(family).toBe("Dada_FONT081");
|
||||
expect(source).toBe("url(\"/api/v1/assets/public/wp4-fixture-v1/FONT081\")");
|
||||
return { load };
|
||||
},
|
||||
fontSet: { add, check: () => true, ready: Promise.resolve() },
|
||||
});
|
||||
await expect(loader.ensure({ fontId: "FONT081", url: "/api/v1/assets/public/wp4-fixture-v1/FONT081" })).resolves.toBe("ready");
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
expect(add).toHaveBeenCalledOnce();
|
||||
expect(loader.status("FONT081")).toBe("ready");
|
||||
expect(fontFamilyName("FONT081")).toBe("Dada_FONT081");
|
||||
});
|
||||
|
||||
it("marks a missing archived font unavailable without a fallback family", async () => {
|
||||
const loader = new ArchivedFontLoader({
|
||||
createFace: () => ({ load: async () => { throw new Error("404"); } }),
|
||||
fontSet: { add: vi.fn(), check: () => false, ready: Promise.resolve() },
|
||||
});
|
||||
await expect(loader.ensure({ fontId: "FONT404", url: "/missing.ttf" })).resolves.toBe("unavailable");
|
||||
expect(loader.status("FONT404")).toBe("unavailable");
|
||||
expect(fontFamilyName("FONT404")).not.toContain(",");
|
||||
expect(fontFamilyName("FONT404")).not.toMatch(/Arial|sans-serif|YaHei/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import {
|
||||
P0A_TEXT_TEMPLATES,
|
||||
TextEditSession,
|
||||
createTextTemplateElement,
|
||||
effectiveFontSize,
|
||||
searchTextTemplates,
|
||||
} from "../../apps/web/src/text-assets.js";
|
||||
import { elementHalfExtents } from "../../apps/web/src/editor-elements.js";
|
||||
|
||||
const identity = { createdAt: "2026-08-03T03:00:00.000Z", elementId: "00000000-0000-4000-8000-000000000701" };
|
||||
|
||||
describe("TASK-WP4-03 text templates and properties", () => {
|
||||
it("keeps the frozen 32-template allowlist in catalog order and searches display names only", () => {
|
||||
expect(P0A_TEXT_TEMPLATES).toHaveLength(32);
|
||||
expect(P0A_TEXT_TEMPLATES.map((template) => template.templateId)).toEqual([
|
||||
"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",
|
||||
]);
|
||||
expect(P0A_TEXT_TEMPLATES.reduce<Record<string, number>>((counts, template) => {
|
||||
counts[template.category] = (counts[template.category] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {})).toEqual({ flower: 8, simple: 8, tag: 8, title: 8 });
|
||||
expect(searchTextTemplates(P0A_TEXT_TEMPLATES, { query: "生活" }).map((item) => item.templateId)).toEqual(["FLOWER004", "FLOWER005", "H001", "H003", "H006"]);
|
||||
expect(searchTextTemplates(P0A_TEXT_TEMPLATES, { category: "tag", query: "TAG006" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves multiline content and rejects an empty completion", () => {
|
||||
const element = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, identity, 0);
|
||||
const edit = new TextEditSession(element, P0A_TEXT_TEMPLATES);
|
||||
edit.setContent("第一行\n第二行");
|
||||
expect(edit.complete().content).toBe("第一行\n第二行");
|
||||
const empty = new TextEditSession(element, P0A_TEXT_TEMPLATES);
|
||||
empty.setContent(" \n ");
|
||||
expect(() => empty.complete()).toThrowError("text_content_required");
|
||||
expect(empty.cancel()).toEqual(element);
|
||||
});
|
||||
|
||||
it("switches templates as one draft while preserving text and transforms", () => {
|
||||
const original = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, identity, 2, {
|
||||
position: { x: 0.31, y: 0.72 }, rotation: 23, scale: { x: 1.4, y: 1.4 },
|
||||
});
|
||||
const edit = new TextEditSession(original, P0A_TEXT_TEMPLATES);
|
||||
edit.setContent("春日\n记录");
|
||||
edit.setStyle({ fillColor: "#FA5751", letterSpacing: 6, lineHeight: 1.6, strokeEnabled: true, strokeWidth: 5 });
|
||||
edit.switchTemplate("H003");
|
||||
const switched = edit.complete();
|
||||
expect(switched).toMatchObject({
|
||||
content: "春日\n记录", position: original.position, rotation: 23, scale: original.scale,
|
||||
template_or_asset_id: "H003",
|
||||
});
|
||||
expect(switched.style_parameters).toMatchObject({
|
||||
fill_color: "#111111", letter_spacing: 1, line_height: 1.2, stroke_enabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces exact style ranges and keeps background alpha separate from text opacity", () => {
|
||||
const element = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, identity, 0);
|
||||
const edit = new TextEditSession(element, P0A_TEXT_TEMPLATES);
|
||||
edit.setStyle({
|
||||
backgroundColor: "#FFE62C", backgroundEnabled: true, backgroundOpacity: 0.35,
|
||||
fillColor: "#FA5751", letterSpacing: 20, lineHeight: 1.9,
|
||||
strokeColor: "#000000", strokeEnabled: true, strokeWidth: 12, textAlign: "right",
|
||||
});
|
||||
const complete = edit.complete();
|
||||
expect(complete.opacity).toBe(1);
|
||||
expect(complete.style_parameters).toMatchObject({ background_opacity: 0.35, letter_spacing: 20, line_height: 1.9, stroke_width: 12 });
|
||||
expect(() => edit.setStyle({ lineHeight: 1.95 })).toThrowError("text_line_height_invalid");
|
||||
expect(() => edit.setStyle({ letterSpacing: 1.5 })).toThrowError("text_letter_spacing_invalid");
|
||||
expect(() => edit.setStyle({ strokeWidth: 12.1 })).toThrowError("text_stroke_width_invalid");
|
||||
});
|
||||
|
||||
it("synchronizes numeric font size with the canvas scale", () => {
|
||||
const element = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, identity, 0, { scale: { x: 1.5, y: 1.5 } });
|
||||
expect(effectiveFontSize(element)).toBe(72);
|
||||
const edit = new TextEditSession(element, P0A_TEXT_TEMPLATES);
|
||||
edit.setEffectiveFontSize(96);
|
||||
const resized = edit.complete();
|
||||
expect(resized.font_size).toBe(48);
|
||||
expect(resized.scale).toEqual({ x: 2, y: 2 });
|
||||
expect(effectiveFontSize(resized)).toBe(96);
|
||||
});
|
||||
|
||||
it("expands selection and hit geometry with text content, lines, and scale", () => {
|
||||
const element = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, identity, 0, { scale: { x: 2, y: 2 } });
|
||||
const edit = new TextEditSession(element, P0A_TEXT_TEMPLATES);
|
||||
edit.setContent("这是足够长的第一行\n第二行");
|
||||
edit.setStyle({ letterSpacing: 20, lineHeight: 1.9, strokeEnabled: true, strokeWidth: 12 });
|
||||
const complete = edit.complete();
|
||||
const canvas: CanvasState = {
|
||||
background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: null },
|
||||
elements: [complete], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
|
||||
};
|
||||
const bounds = elementHalfExtents(canvas, complete);
|
||||
expect(bounds.x).toBeGreaterThan(0.35);
|
||||
expect(bounds.y).toBeGreaterThan(0.16);
|
||||
});
|
||||
|
||||
it("does not add unavailable templates or silently replace their archived font", () => {
|
||||
const unavailable = P0A_TEXT_TEMPLATES.find((template) => !template.available)!;
|
||||
expect(unavailable).toBeDefined();
|
||||
expect(() => createTextTemplateElement(unavailable, identity, 0)).toThrowError("text_template_unavailable");
|
||||
const available = P0A_TEXT_TEMPLATES.find((template) => template.available)!;
|
||||
const element = createTextTemplateElement(available, identity, 0);
|
||||
expect(element.font_override).toBeUndefined();
|
||||
expect((element.style_parameters as Record<string, unknown>).default_font_id).toBe(available.defaultFontId);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user