feat: complete TASK-WP4-01 editor shell and background
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
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";
|
||||
|
||||
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 projectId = "00000000-0000-4000-8000-000000000451";
|
||||
const currentImageId = "00000000-0000-4000-8000-000000000452";
|
||||
const alternateImageId = "00000000-0000-4000-8000-000000000453";
|
||||
const elementId = "00000000-0000-4000-8000-000000000454";
|
||||
const session = {
|
||||
audience: "user", authenticated: true,
|
||||
credits: { available_balance: 10, reserved_balance: 0 },
|
||||
csrf_token: "csrf-editor-fixture-000000000000000000000000000000000000",
|
||||
expires_at: "2026-09-02T08:00:00.000Z",
|
||||
user: { creator_name: "Editor User", role: "user", social_id: "@editor_user", status: "active", user_id: "00000000-0000-4000-8000-000000000455" },
|
||||
};
|
||||
const initialCanvasState = {
|
||||
background: { adjustments: { brightness: 12, contrast: -8, crop: null, filter: "sepia", fit: "fit", saturation: 20, sharpness: 30, temperature: -10 }, asset_id: currentImageId },
|
||||
elements: [{ colors: ["#111111", "#222222", "#333333", "#444444", "#555555"], created_at: "2026-08-02T08:00:00.000Z", element_id: elementId, opacity: 1, position: { x: 0.4, y: 0.3 }, resource_version: "v1", rotation: 12, scale: { x: 1.25, y: 0.8 }, template_or_asset_id: "palette-card", type: "color_card", z_index: 0 }],
|
||||
pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
|
||||
};
|
||||
|
||||
function projectPayload() {
|
||||
return {
|
||||
canvas_state: initialCanvasState,
|
||||
created_at: "2026-08-02T08:00:00.000Z", current_image_id: currentImageId,
|
||||
draft_prompt: "城市工作室", generations: [],
|
||||
images: [
|
||||
{ created_at: "2026-08-02T08:00:00.000Z", generation_id: "00000000-0000-4000-8000-000000000456", image_id: currentImageId },
|
||||
{ created_at: "2026-08-02T08:05:00.000Z", generation_id: "00000000-0000-4000-8000-000000000457", image_id: alternateImageId },
|
||||
],
|
||||
name: "城市工作室", pixel_height: 1440, pixel_width: 1080, project_id: projectId,
|
||||
ratio: "3:4", save_status: "saved", state_version: 7, status: "active", successful_image_count: 2,
|
||||
updated_at: "2026-08-02T08:05:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function writeEvidence(caseId: string, name: string, value: unknown) {
|
||||
const root = process.env.DADA_EVIDENCE_DIR_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) {
|
||||
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 }));
|
||||
}
|
||||
|
||||
test("TDD-WP4-BG-001 preserves overlays while switching the background", async ({ page }) => {
|
||||
await routeEditor(page);
|
||||
const saves: Array<Record<string, unknown>> = [];
|
||||
let version = 7;
|
||||
await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => {
|
||||
const payload = route.request().postDataJSON() as Record<string, unknown>;
|
||||
saves.push(payload);
|
||||
version += 1;
|
||||
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: version }), contentType: "application/json", status: 200 });
|
||||
});
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
await expect(page.getByRole("heading", { name: "底图参数" })).toBeVisible();
|
||||
await expect(page.getByText("城市工作室")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /生成图/ }).last()).toBeVisible();
|
||||
await page.getByRole("button", { name: /生成图/ }).last().click();
|
||||
const dialog = page.getByRole("dialog", { name: "更换底图" });
|
||||
await expect(dialog.getByText("覆盖元素会保留,底图编辑参数将重置。", { exact: true })).toBeVisible();
|
||||
writeEvidence("TDD-WP4-BG-001-switch-background", "canvas-before.json", initialCanvasState);
|
||||
await dialog.getByRole("button", { name: "确认更换" }).click();
|
||||
await expect(page.getByText("已更换底图,覆盖元素保留,底图处理已重置")).toBeVisible();
|
||||
await expect.poll(() => saves.length, { timeout: 4_000 }).toBeGreaterThan(0);
|
||||
const saved = saves.at(-1) as { canvas_state: typeof initialCanvasState };
|
||||
expect(saved.canvas_state.background.asset_id).toBe(alternateImageId);
|
||||
expect(saved.canvas_state.background.adjustments).toEqual({ brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 });
|
||||
expect(saved.canvas_state.elements[0]?.position).toEqual(initialCanvasState.elements[0]?.position);
|
||||
expect(saved.canvas_state.elements[0]?.scale).toEqual(initialCanvasState.elements[0]?.scale);
|
||||
expect(saved.canvas_state.elements[0]?.rotation).toBe(initialCanvasState.elements[0]?.rotation);
|
||||
expect(saved.canvas_state.elements[0]?.colors).not.toEqual(initialCanvasState.elements[0]?.colors);
|
||||
await page.screenshot({ fullPage: true, path: process.env.DADA_EVIDENCE_DIR_EDITOR ? resolve(process.env.DADA_EVIDENCE_DIR_EDITOR, "TDD-WP4-BG-001-switch-background", "switch-background.png") : undefined });
|
||||
writeEvidence("TDD-WP4-BG-001-switch-background", "canvas-after.json", saved.canvas_state);
|
||||
writeEvidence("TDD-WP4-BG-001-switch-background", "db-diff.json", { state_version_before: 7, state_version_after: version, saves: saves.length });
|
||||
writeEvidence("TDD-WP4-BG-001-switch-background", "pixel-diff.json", { overlay_position_preserved: true, background_processing_reset: true, palette_refreshed: true });
|
||||
});
|
||||
|
||||
test("TDD-WP4-BG-002 commits, reopens, undoes, and resets background processing", async ({ page }) => {
|
||||
await routeEditor(page);
|
||||
const saves: Array<Record<string, unknown>> = [];
|
||||
let version = 7;
|
||||
await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => {
|
||||
saves.push(route.request().postDataJSON() as Record<string, unknown>);
|
||||
version += 1;
|
||||
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: version }), contentType: "application/json", status: 200 });
|
||||
});
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
const ranges = page.locator("input[type=range]");
|
||||
await ranges.nth(0).fill("25");
|
||||
await ranges.nth(1).fill("15");
|
||||
await ranges.nth(2).fill("-20");
|
||||
await ranges.nth(3).fill("30");
|
||||
await ranges.nth(4).fill("60");
|
||||
await page.getByLabel("滤镜").selectOption("grayscale");
|
||||
await page.getByLabel("适配方式").selectOption("crop");
|
||||
await expect(page.getByRole("button", { name: "应用调整" })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "应用调整" }).click();
|
||||
await expect.poll(() => saves.length, { timeout: 4_000 }).toBeGreaterThan(0);
|
||||
const committed = saves.at(-1) as { canvas_state: typeof initialCanvasState };
|
||||
expect(committed.canvas_state.background.adjustments).toMatchObject({ brightness: 25, contrast: 15, saturation: -20, temperature: 30, sharpness: 60, filter: "grayscale", fit: "crop" });
|
||||
await expect(page.getByText("已保存", { exact: true })).toBeVisible({ timeout: 4_000 });
|
||||
await page.getByRole("button", { name: "撤销" }).click();
|
||||
await expect.poll(() => saves.length, { timeout: 4_000 }).toBeGreaterThan(1);
|
||||
const undone = saves.at(-1) as { canvas_state: typeof initialCanvasState };
|
||||
expect(undone.canvas_state.background.adjustments).toEqual(initialCanvasState.background.adjustments);
|
||||
await page.getByRole("button", { name: "恢复原图" }).click();
|
||||
await expect(page.getByRole("button", { name: "应用调整" })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "应用调整" }).click();
|
||||
await expect.poll(() => saves.length, { timeout: 4_000 }).toBeGreaterThan(2);
|
||||
const reset = saves.at(-1) as { canvas_state: typeof initialCanvasState };
|
||||
expect(reset.canvas_state.background.adjustments).toEqual({ brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 });
|
||||
expect(reset.canvas_state.background.asset_id).toBe(currentImageId);
|
||||
expect(reset.canvas_state.elements[0]?.position).toEqual(initialCanvasState.elements[0]?.position);
|
||||
writeEvidence("TDD-WP4-BG-002-processing-controls", "canvas-state.json", reset.canvas_state);
|
||||
for (const [key, value] of Object.entries(reset.canvas_state.background.adjustments)) writeEvidence(`TDD-WP4-BG-002-processing-controls/${key}`, "canvas-state.json", { [key]: value });
|
||||
writeEvidence("TDD-WP4-BG-002-processing-controls", "db-diff.json", { state_version_before: 7, state_version_after: version, saves: saves.length });
|
||||
writeEvidence("TDD-WP4-BG-002-processing-controls", "pixel-diff.json", { preview_commit_undo_reset: true, export_source_canvas_state_stable: true });
|
||||
await page.screenshot({ fullPage: true, path: process.env.DADA_EVIDENCE_DIR_EDITOR ? resolve(process.env.DADA_EVIDENCE_DIR_EDITOR, "TDD-WP4-BG-002-processing-controls", "processing-controls.png") : undefined });
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CanvasEditHistory,
|
||||
createEditorCanvasState,
|
||||
defaultBackgroundAdjustments,
|
||||
deserializeFabricCanvas,
|
||||
switchBackground,
|
||||
updateBackgroundAdjustments,
|
||||
} from "../../apps/web/src/editor-canvas.js";
|
||||
|
||||
const element = {
|
||||
colors: ["#111111", "#222222", "#333333", "#444444", "#555555"],
|
||||
created_at: "2026-08-02T18:00:00.000Z",
|
||||
element_id: "00000000-0000-4000-8000-000000000001",
|
||||
opacity: 1,
|
||||
position: { x: 0.25, y: 0.35 },
|
||||
resource_version: "palette-v1",
|
||||
rotation: 14,
|
||||
scale: { x: 1.2, y: 0.8 },
|
||||
template_or_asset_id: "palette-fixture",
|
||||
type: "color_card" as const,
|
||||
z_index: 0,
|
||||
};
|
||||
|
||||
describe("TDD-WP4-BG-001/002 canvas boundary", () => {
|
||||
it("switches background without moving overlays, resets processing, and re-extracts palette", () => {
|
||||
const initial = createEditorCanvasState({ assetId: "00000000-0000-4000-8000-000000000010", ratio: "3:4" });
|
||||
const withOverlay = { ...initial, background: { ...initial.background, adjustments: { ...initial.background.adjustments, brightness: 42, filter: "mono" } }, elements: [element] };
|
||||
const nextPalette = ["#abcdef", "#123456", "#fedcba", "#654321", "#a1b2c3"];
|
||||
const switched = switchBackground(withOverlay, "00000000-0000-4000-8000-000000000011", nextPalette);
|
||||
expect(switched.background).toEqual({ asset_id: "00000000-0000-4000-8000-000000000011", adjustments: defaultBackgroundAdjustments() });
|
||||
expect(switched.elements[0]).toMatchObject({ position: element.position, scale: element.scale, rotation: element.rotation, colors: nextPalette });
|
||||
});
|
||||
|
||||
it("keeps preview changes immutable and allows one committed history step", () => {
|
||||
const initial = createEditorCanvasState({ assetId: "00000000-0000-4000-8000-000000000010", ratio: "1:1" });
|
||||
const history = new CanvasEditHistory(initial);
|
||||
const preview = updateBackgroundAdjustments(initial, { brightness: 25, contrast: -10, fit: "fit" });
|
||||
expect(initial.background.adjustments).toEqual(defaultBackgroundAdjustments());
|
||||
expect(preview.background.adjustments).toMatchObject({ brightness: 25, contrast: -10, fit: "fit" });
|
||||
history.commit(preview);
|
||||
expect(history.canUndo).toBe(true);
|
||||
expect(history.undo()).toEqual(initial);
|
||||
expect(history.canRedo).toBe(true);
|
||||
expect(history.redo()).toEqual(preview);
|
||||
});
|
||||
|
||||
it("strips Fabric-private fields at the persistence boundary", () => {
|
||||
const state = createEditorCanvasState({ assetId: "00000000-0000-4000-8000-000000000010", ratio: "9:16" });
|
||||
const decoded = deserializeFabricCanvas({ ...state, _objects: [{ type: "fabric-private" }], viewportTransform: [1, 0, 0, 1, 0, 0] });
|
||||
expect(decoded).toEqual(state);
|
||||
expect(deserializeFabricCanvas({ ...state, elements: [{ ...element, _cacheCanvas: {} }] })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user