feat: complete TASK-WP4-03 text templates
This commit is contained in:
@@ -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