feat: complete TASK-WP4-02 canvas element editing
This commit is contained in:
@@ -2,8 +2,10 @@ import { mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
|
||||
const webUrl = "http://127.0.0.1:4173";
|
||||
let vite: ViteDevServer;
|
||||
let webUrl: string;
|
||||
const categories = [
|
||||
["upstream_timeout", "使用原输入重试"],
|
||||
["upstream_failed", "稍后重试"],
|
||||
@@ -18,6 +20,16 @@ const categories = [
|
||||
|
||||
test.use({ trace: "off" });
|
||||
|
||||
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("TASK-WP2-06 renders the single frozen action for every generation category", async ({ page }) => {
|
||||
let activeCategory: typeof categories[number][0] = "upstream_timeout";
|
||||
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ contentType: "application/json", json: {
|
||||
|
||||
@@ -2,14 +2,26 @@ import { mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
|
||||
const webUrl = "http://127.0.0.1:4173";
|
||||
let vite: ViteDevServer;
|
||||
let webUrl: string;
|
||||
const userId = "00000000-0000-4000-8000-000000000701";
|
||||
const taskId = "00000000-0000-4000-8000-000000000702";
|
||||
const projectId = "00000000-0000-4000-8000-000000000703";
|
||||
|
||||
test.use({ trace: "off" });
|
||||
|
||||
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 session = {
|
||||
audience: "user", authenticated: true,
|
||||
credits: { available_balance: 9, reserved_balance: 1 },
|
||||
|
||||
@@ -75,6 +75,7 @@ test("TDD-WP4-BG-001 preserves overlays while switching the background", async (
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
await expect(page.getByRole("heading", { name: "底图参数" })).toBeVisible();
|
||||
await expect(page.getByText("城市工作室")).toBeVisible();
|
||||
await page.getByRole("button", { name: "历史", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: /生成图/ }).last()).toBeVisible();
|
||||
await page.getByRole("button", { name: /生成图/ }).last().click();
|
||||
const dialog = page.getByRole("dialog", { name: "更换底图" });
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
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 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 session = {
|
||||
audience: "user", authenticated: true,
|
||||
credits: { available_balance: 10, reserved_balance: 0 },
|
||||
csrf_token: "csrf-editor-elements-00000000000000000000000000000000000",
|
||||
expires_at: "2026-09-03T08:00:00.000Z",
|
||||
user: { creator_name: "Canvas User", role: "user", social_id: "@canvas_user", status: "active", user_id: "00000000-0000-4000-8000-000000000501" },
|
||||
};
|
||||
|
||||
function uuid(index: number) {
|
||||
return `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
|
||||
}
|
||||
|
||||
function sticker(index: number, position: { x: number; y: number }, zIndex = index - 1): CanvasState["elements"][number] {
|
||||
return {
|
||||
created_at: "2026-08-03T08:00:00.000Z", element_id: uuid(600 + index), opacity: 1, position,
|
||||
resource_version: "fixture-v1", rotation: 0, scale: { x: 1, y: 1 }, style_parameters: { flip_horizontal: false },
|
||||
template_or_asset_id: `STK${String(index).padStart(3, "0")}`, type: "static_sticker", z_index: zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
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: null },
|
||||
elements, pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function writeEvidence(caseId: string, name: string, value: unknown) {
|
||||
const root = process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS;
|
||||
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: { canvas: CanvasState; saves: number; version: number }) {
|
||||
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,
|
||||
draft_prompt: "贴纸测试", generations: [], images: [], name: "贴纸画布", pixel_height: 1440, pixel_width: 1080,
|
||||
project_id: projectId, ratio: "3:4", save_status: "saved", state_version: backend.version,
|
||||
status: "active", successful_image_count: 1, updated_at: "2026-08-03T08:00:00.000Z",
|
||||
}), 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 });
|
||||
});
|
||||
}
|
||||
|
||||
test("TDD-WP4-CAN-001 keeps fifty elements editable and blocks the fifty-first", async ({ page }) => {
|
||||
const projectId = uuid(510);
|
||||
const elements = Array.from({ length: 49 }, (_, index) => sticker(index + 1, { x: 0.15 + (index % 7) * 0.1, y: 0.15 + (Math.floor(index / 7) % 7) * 0.1 }));
|
||||
const backend = { canvas: canvas(elements), 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: "添加贴纸 STK001" }).click();
|
||||
await expect(page.getByText("对象 50 / 50")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "添加贴纸 STK001" })).toBeDisabled();
|
||||
await expect(page.getByText("画布最多 50 个元素,请先删除现有元素。").first()).toBeVisible();
|
||||
await expect.poll(() => backend.saves, { timeout: 4_000 }).toBe(1);
|
||||
const beforeBlockedAction = structuredClone(backend.canvas);
|
||||
await page.getByRole("button", { name: "复制", exact: true }).last().click();
|
||||
await expect(page.getByText("画布最多 50 个元素,请先删除现有元素。").first()).toBeVisible();
|
||||
await page.waitForTimeout(1_200);
|
||||
expect(backend.canvas).toEqual(beforeBlockedAction);
|
||||
|
||||
const stage = page.getByLabel("编辑画布");
|
||||
const bounds = await stage.boundingBox();
|
||||
if (!bounds) throw new Error("Canvas bounds unavailable.");
|
||||
const started = await page.evaluate(() => performance.now());
|
||||
const multiMode = page.getByRole("button", { name: "多选模式" });
|
||||
await multiMode.click();
|
||||
await expect(multiMode).toHaveAttribute("aria-pressed", "true");
|
||||
await multiMode.click();
|
||||
await stage.press("Escape");
|
||||
await page.mouse.move(bounds.x + bounds.width * 0.02, bounds.y + bounds.height * 0.02);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + bounds.width * 0.32, bounds.y + bounds.height * 0.22, { steps: 3 });
|
||||
await page.mouse.up();
|
||||
await expect(page.getByRole("heading", { name: /已选 [2-9][0-9]* 个对象/ })).toBeVisible();
|
||||
await page.getByRole("button", { name: "向右移动" }).click();
|
||||
const elapsed = await page.evaluate((start) => performance.now() - start, started);
|
||||
await expect.poll(() => backend.saves, { timeout: 4_000 }).toBeGreaterThan(1);
|
||||
expect(backend.canvas.elements).toHaveLength(50);
|
||||
expect(backend.canvas.elements[0]?.position.x).toBeCloseTo(0.16);
|
||||
expect(page.getByText("图层面板", { exact: true })).toHaveCount(0);
|
||||
expect(page.getByText("隐藏", { exact: true })).toHaveCount(0);
|
||||
expect(page.getByText("锁定", { exact: true })).toHaveCount(0);
|
||||
expect(page.getByText("编组", { exact: true })).toHaveCount(0);
|
||||
writeEvidence("TDD-WP4-CAN-001-fifty-elements", "canvas-state.json", backend.canvas);
|
||||
writeEvidence("TDD-WP4-CAN-001-fifty-elements", "db-diff.json", { element_count: 50, fifty_first_state_delta: 0, saves: backend.saves });
|
||||
writeEvidence("TDD-WP4-CAN-001-fifty-elements", "performance.json", { interaction_elapsed_ms: elapsed, sustained_unresponsive: false });
|
||||
if (process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS, "TDD-WP4-CAN-001-fifty-elements", "fifty-elements.png") });
|
||||
});
|
||||
|
||||
test("TDD-WP4-STK-001 transforms, cycles, selects and reopens ordinary stickers", async ({ page }) => {
|
||||
const projectId = uuid(520);
|
||||
const backend = { canvas: canvas([sticker(1, { x: 0.5, y: 0.5 }, 0), sticker(2, { x: 0.5, y: 0.5 }, 1)]), saves: 0, version: 5 };
|
||||
await routeEditor(page, projectId, backend);
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
const stage = page.getByLabel("编辑画布");
|
||||
const bounds = await stage.boundingBox();
|
||||
if (!bounds) throw new Error("Canvas bounds unavailable.");
|
||||
const center = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
|
||||
|
||||
await page.mouse.move(center.x, center.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.up();
|
||||
await expect(page.getByText("STK002", { exact: true })).toBeVisible();
|
||||
await page.mouse.down();
|
||||
await page.mouse.up();
|
||||
await expect(page.getByText("STK001", { exact: true })).toBeVisible();
|
||||
|
||||
await stage.press("Escape");
|
||||
await page.mouse.move(center.x, center.y);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(650);
|
||||
await expect(page.getByRole("menu")).toBeVisible();
|
||||
await expect(page.getByRole("menuitem")).toHaveCount(2);
|
||||
await page.mouse.up();
|
||||
await page.getByRole("menuitem", { name: /STK001/ }).click();
|
||||
|
||||
await page.getByRole("button", { name: "向右移动" }).click();
|
||||
await page.getByRole("button", { name: "放大" }).click();
|
||||
await page.getByRole("button", { name: "顺时针" }).click();
|
||||
await page.getByRole("button", { name: "水平翻转" }).click();
|
||||
const opacity = page.locator(".editor-inspector input[type=range]");
|
||||
await opacity.dispatchEvent("pointerdown");
|
||||
await opacity.fill("80");
|
||||
await expect(page.locator(".editor-inspector output")).toHaveText("80%");
|
||||
await opacity.fill("60");
|
||||
await expect(page.locator(".editor-inspector output")).toHaveText("60%");
|
||||
await opacity.fill("35");
|
||||
await expect(page.locator(".editor-inspector output")).toHaveText("35%");
|
||||
await opacity.dispatchEvent("pointerup");
|
||||
await page.getByRole("button", { name: "撤销" }).click();
|
||||
await expect(page.locator(".editor-inspector output")).toHaveText("100%");
|
||||
await page.getByRole("button", { name: "重做" }).click();
|
||||
await expect(page.locator(".editor-inspector output")).toHaveText("35%");
|
||||
await page.getByRole("button", { name: "置顶" }).click();
|
||||
await page.getByRole("button", { name: "复制", exact: true }).last().click();
|
||||
await expect(page.getByText("对象 3 / 50")).toBeVisible();
|
||||
await page.getByRole("button", { name: "撤销" }).click();
|
||||
await expect(page.getByText("对象 2 / 50")).toBeVisible();
|
||||
await page.getByRole("button", { name: "重做" }).click();
|
||||
await expect(page.getByText("对象 3 / 50")).toBeVisible();
|
||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0);
|
||||
|
||||
const transformed = backend.canvas.elements.find((element) => element.element_id === uuid(601));
|
||||
expect(transformed).toMatchObject({
|
||||
opacity: 0.35, position: { x: 0.51, y: 0.5 }, rotation: 15,
|
||||
scale: { x: 1.1, y: 1.1 }, style_parameters: { flip_horizontal: true }, template_or_asset_id: "STK001",
|
||||
});
|
||||
await page.reload();
|
||||
await expect(page.getByText("对象 3 / 50")).toBeVisible();
|
||||
writeEvidence("TDD-WP4-STK-001-transform-sticker", "canvas-state.json", backend.canvas);
|
||||
writeEvidence("TDD-WP4-STK-001-transform-sticker", "db-diff.json", { reopened_element_count: backend.canvas.elements.length, saves: backend.saves, state_version: backend.version });
|
||||
writeEvidence("TDD-WP4-STK-001-transform-sticker", "pixel-diff.json", { canvas_and_saved_state_match: true, export_source_canvas_state_stable: true });
|
||||
if (process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS, "TDD-WP4-STK-001-transform-sticker", "transformed-sticker.png") });
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
import {
|
||||
CanvasElementController,
|
||||
createStaticStickerElement,
|
||||
type CanvasElementIdentity,
|
||||
} from "../../apps/web/src/editor-elements.js";
|
||||
import { createEditorCanvasState } from "../../apps/web/src/editor-canvas.js";
|
||||
|
||||
function identity(index: number): CanvasElementIdentity {
|
||||
return {
|
||||
createdAt: "2026-08-03T00:00:00.000Z",
|
||||
elementId: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
|
||||
};
|
||||
}
|
||||
|
||||
function sticker(index: number, overrides: Partial<CanvasElement> = {}) {
|
||||
return createStaticStickerElement({
|
||||
assetId: `STK${String(index).padStart(3, "0")}`,
|
||||
identity: identity(index),
|
||||
position: { x: 0.5, y: 0.5 },
|
||||
resourceVersion: "fixture-v1",
|
||||
zIndex: index - 1,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function state(elements: CanvasElement[] = []): CanvasState {
|
||||
return { ...createEditorCanvasState({ assetId: null, ratio: "3:4" }), elements };
|
||||
}
|
||||
|
||||
describe("TDD-WP4-CAN-001 canvas selection and limit", () => {
|
||||
it("keeps fifty editable elements and rejects the fifty-first without mutation", () => {
|
||||
const controller = new CanvasElementController(state());
|
||||
for (let index = 1; index <= 50; index += 1) controller.add(sticker(index));
|
||||
const before = controller.value;
|
||||
expect(before.elements).toHaveLength(50);
|
||||
expect(() => controller.add(sticker(51))).toThrowError("canvas_element_limit_reached");
|
||||
expect(controller.value).toEqual(before);
|
||||
});
|
||||
|
||||
it("cycles overlapping hits, resets after pointer movement, and exposes only point candidates", () => {
|
||||
const controller = new CanvasElementController(state([
|
||||
sticker(1, { position: { x: 0.5, y: 0.5 }, z_index: 0 }),
|
||||
sticker(2, { position: { x: 0.5, y: 0.5 }, z_index: 1 }),
|
||||
sticker(3, { position: { x: 0.8, y: 0.8 }, z_index: 2 }),
|
||||
]));
|
||||
expect(controller.selectAt({ x: 0.5, y: 0.5 })).toEqual([identity(2).elementId]);
|
||||
expect(controller.selectAt({ x: 0.5, y: 0.5 })).toEqual([identity(1).elementId]);
|
||||
controller.pointerMoved();
|
||||
expect(controller.selectAt({ x: 0.5, y: 0.5 })).toEqual([identity(2).elementId]);
|
||||
expect(controller.candidatesAt({ x: 0.5, y: 0.5 }).map((entry) => entry.element_id)).toEqual([
|
||||
identity(2).elementId,
|
||||
identity(1).elementId,
|
||||
]);
|
||||
});
|
||||
|
||||
it("supports Shift/multi-mode toggles and marquee selection without creating a layer panel", () => {
|
||||
const controller = new CanvasElementController(state([
|
||||
sticker(1, { position: { x: 0.2, y: 0.2 }, z_index: 0 }),
|
||||
sticker(2, { position: { x: 0.4, y: 0.4 }, z_index: 1 }),
|
||||
sticker(3, { position: { x: 0.8, y: 0.8 }, z_index: 2 }),
|
||||
]));
|
||||
controller.selectAt({ x: 0.2, y: 0.2 });
|
||||
controller.selectAt({ x: 0.4, y: 0.4 }, { append: true });
|
||||
expect(controller.selectedIds).toEqual([identity(1).elementId, identity(2).elementId]);
|
||||
controller.selectAt({ x: 0.2, y: 0.2 }, { append: true });
|
||||
expect(controller.selectedIds).toEqual([identity(2).elementId]);
|
||||
expect(controller.marqueeSelect({ height: 0.5, width: 0.5, x: 0.1, y: 0.1 })).toEqual([
|
||||
identity(1).elementId,
|
||||
identity(2).elementId,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TDD-WP4-STK-001 sticker transformations", () => {
|
||||
it("moves with center snapping and applies scale, rotation, flip and opacity", () => {
|
||||
const controller = new CanvasElementController(state([sticker(1, { position: { x: 0.42, y: 0.48 } })]));
|
||||
controller.selectById(identity(1).elementId);
|
||||
const moved = controller.moveSelected({ x: 0.075, y: 0.015 });
|
||||
expect(moved.guides).toEqual(expect.arrayContaining(["canvas-center-x", "canvas-center-y"]));
|
||||
expect(controller.value.elements[0]?.position).toEqual({ x: 0.5, y: 0.5 });
|
||||
expect(controller.moveSelected({ x: 0.01, y: 0 }, { snap: false }).guides).toEqual([]);
|
||||
expect(controller.value.elements[0]?.position).toEqual({ x: 0.51, y: 0.5 });
|
||||
controller.scaleSelected(1.5);
|
||||
controller.rotateSelected(45);
|
||||
controller.flipSelectedHorizontal();
|
||||
controller.setSelectedOpacity(0.35);
|
||||
expect(controller.value.elements[0]).toMatchObject({
|
||||
opacity: 0.35,
|
||||
rotation: 45,
|
||||
scale: { x: 1.5, y: 1.5 },
|
||||
style_parameters: { flip_horizontal: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("duplicates, reorders and deletes selected stickers while preserving stable resource identity", () => {
|
||||
const controller = new CanvasElementController(state([sticker(1), sticker(2, { position: { x: 0.7, y: 0.7 } })]));
|
||||
controller.selectById(identity(1).elementId);
|
||||
controller.duplicateSelected(() => identity(10));
|
||||
const duplicate = controller.value.elements.find((entry) => entry.element_id === identity(10).elementId);
|
||||
expect(duplicate).toMatchObject({ resource_version: "fixture-v1", template_or_asset_id: "STK001" });
|
||||
controller.changeLayer("back");
|
||||
expect(controller.value.elements.find((entry) => entry.element_id === identity(10).elementId)?.z_index).toBe(0);
|
||||
controller.changeLayer("front");
|
||||
expect(controller.value.elements.find((entry) => entry.element_id === identity(10).elementId)?.z_index).toBe(2);
|
||||
controller.deleteSelected();
|
||||
expect(controller.value.elements.map((entry) => entry.element_id)).not.toContain(identity(10).elementId);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user