import { describe, expect, it } from "vitest"; import type { CanvasState } from "@dada/shared-contracts"; import { P0A_TEXT_TEMPLATES, P0A_FONT_OPTIONS, TextEditSession, createTextTemplateElement, effectiveFontSize, fontOption, 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 complete 332-template catalog order and searches display names only", () => { expect(P0A_TEXT_TEMPLATES).toHaveLength(332); expect(P0A_TEXT_TEMPLATES[0]?.templateId).toBe("FLOWER001"); expect(P0A_TEXT_TEMPLATES.at(-1)?.templateId).toBe("SIMPLE017"); expect(P0A_TEXT_TEMPLATES.reduce>((counts, template) => { counts[template.category] = (counts[template.category] ?? 0) + 1; return counts; }, {})).toEqual({ flower: 145, simple: 17, tag: 51, title: 119 }); expect(searchTextTemplates(P0A_TEXT_TEMPLATES, { query: "生活" }).map((item) => item.templateId)).toEqual(expect.arrayContaining(["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: "#FFFFFF", letter_spacing: 0, line_height: 1, 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(P0A_TEXT_TEMPLATES[0]!.defaultFontSize * 1.5); const edit = new TextEditSession(element, P0A_TEXT_TEMPLATES); edit.setEffectiveFontSize(96); const resized = edit.complete(); expect(resized.font_size).toBe(P0A_TEXT_TEMPLATES[0]!.defaultFontSize); expect(resized.scale).toEqual({ x: 96 / P0A_TEXT_TEMPLATES[0]!.defaultFontSize, y: 96 / P0A_TEXT_TEMPLATES[0]!.defaultFontSize }); 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("adds every template with its captured default font", () => { expect(P0A_TEXT_TEMPLATES.every((template) => template.available && template.fontUrl)).toBe(true); expect(P0A_TEXT_TEMPLATES.every((template) => fontOption(template.defaultFontId)?.url === template.fontUrl)).toBe(true); expect(P0A_TEXT_TEMPLATES.filter((template) => template.defaultFontId.startsWith("TEXT-FONT-"))).toHaveLength(332); expect(P0A_FONT_OPTIONS).toHaveLength(86); const available = P0A_TEXT_TEMPLATES[0]!; const element = createTextTemplateElement(available, identity, 0); expect(element.font_override).toBeUndefined(); expect((element.style_parameters as Record).default_font_id).toBe(available.defaultFontId); }); });