ai-chatbot/tests/routes/psd-dimension-export.test.ts

155 lines
7.6 KiB
TypeScript

import { createRequire } from "node:module";
import { expect, test } from "@playwright/test";
import sharp from "sharp";
// tsx supplies the bundler; exercise the real component without an app server,
// authentication, storage, or a paid PSD render.
const { build } = createRequire(require.resolve("tsx"))("esbuild");
let bundle: string;
test.beforeAll(async () => {
const result = await build({
stdin: {
contents: `
import { createRoot } from "react-dom/client";
import { AgentPsdTemplateExportPreview } from "./components/agents/agent-psd-template-export-preview";
createRoot(document.getElementById("root")).render(
<AgentPsdTemplateExportPreview imageUrl="https://assets.example/result.png"
enableDimensionAnnotations downloadName="dimensions.png" />
);`,
resolveDir: process.cwd(),
loader: "tsx",
},
bundle: true,
write: false,
platform: "browser",
jsx: "automatic",
define: { "process.env.NODE_ENV": '"test"' },
plugins: [{
name: "next-image-in-browser",
setup(plugin: {
onResolve: (options: { filter: RegExp }, callback: () => object) => void;
onLoad: (options: { filter: RegExp; namespace: string }, callback: () => object) => void;
}) {
plugin.onResolve({ filter: /^next\/image$/ }, () => ({ path: "image", namespace: "test" }));
plugin.onLoad({ filter: /.*/, namespace: "test" }, () => ({
contents: `import React from "react";
export default function Image({ fill, unoptimized, sizes, style, ...props }) {
return <img {...props} style={{ ...(fill ? { position: "absolute", inset: 0, width: "100%", height: "100%" } : {}), ...style }} />;
}`,
loader: "jsx",
resolveDir: process.cwd(),
}));
},
}],
});
bundle = result.outputFiles[0].text;
});
for (const scenario of [
{ name: "a smaller square preview", preview: [800, 800], output: [1600, 1600] },
{ name: "matching resolutions", preview: [800, 800], output: [800, 800] },
{ name: "a portrait preview with side padding", preview: [400, 800], output: [800, 1600] },
{ name: "a landscape preview with top padding", preview: [800, 400], output: [1600, 800] },
]) {
test(`export preserves dimension positions and text from ${scenario.name}`, async ({ page }, testInfo) => {
const makeImage = (width: number, height: number) =>
sharp({ create: { width, height, channels: 3, background: "white" } }).png().toBuffer();
const preview = await makeImage(scenario.preview[0], scenario.preview[1]);
const original = await makeImage(scenario.output[0], scenario.output[1]);
await page.route("https://dimensions.test/**", (route) => {
const url = new URL(route.request().url());
return route.fulfill(url.pathname === "/api/image-proxy"
? { contentType: "image/png", body: original }
: { contentType: "text/html", body: `<!doctype html><style>
#root { width: 360px; } .relative { position: relative; }
.absolute { position: absolute; } .inset-0 { inset: 0; }
.h-full { height: 100%; } .w-full { width: 100%; }
button svg { width: 16px; height: 16px; }
</style><div id="root"></div>` });
});
await page.route("https://assets.example/result.png", (route) =>
route.fulfill({ contentType: "image/png", body: preview }));
await page.goto("https://dimensions.test/");
await page.addScriptTag({ content: bundle });
const overlay = page.locator("svg.absolute");
await expect(overlay).toBeVisible();
const image = page.getByAltText("PSD 模板导出结果");
const imageRect = await image.boundingBox();
expect(imageRect).not.toBeNull();
if (!imageRect) throw new Error("Missing preview image");
for (const points of [
[0.12, 0.2, 0.12, 0.75],
[0.25, 0.76, 0.8, 0.9],
]) {
await page.getByRole("button", { name: "新增尺寸线" }).click();
await page.mouse.move(imageRect.x + points[0] * imageRect.width, imageRect.y + points[1] * imageRect.height);
await page.mouse.down();
await page.mouse.move(imageRect.x + points[2] * imageRect.width, imageRect.y + points[3] * imageRect.height, { steps: 5 });
await page.mouse.up();
await page.getByRole("textbox").first().fill(points[0] === 0.12 ? "40" : "80");
}
// Read the visible SVG, independently of the component's coordinate helpers.
const expected = await overlay.locator(":scope > g").evaluateAll((groups) => groups.map((group) => {
const line = group.querySelector("line");
const text = group.querySelector("text");
if (!line || !text) throw new Error("Missing annotation");
const textTransform = text.getCTM();
if (!textTransform) throw new Error("Missing text transform");
const textPosition = new DOMPoint(
Number(text.getAttribute("x")), Number(text.getAttribute("y"))
).matrixTransform(textTransform);
return {
x1: Number(line.getAttribute("x1")), y1: Number(line.getAttribute("y1")),
x2: Number(line.getAttribute("x2")), y2: Number(line.getAttribute("y2")),
textX: textPosition.x, textY: textPosition.y,
fontSize: Number(text.getAttribute("font-size")), label: text.textContent,
};
}));
expect(expected).toHaveLength(2);
const overlayRect = await overlay.boundingBox();
if (!overlayRect) throw new Error("Missing annotation overlay");
await page.evaluate(() => {
const recorded: { x: number; y: number; size: number; label: string }[] = [];
Object.assign(window, { exportedDimensionText: recorded });
const fillText = CanvasRenderingContext2D.prototype.fillText;
CanvasRenderingContext2D.prototype.fillText = function (label, x, y) {
const transform = this.getTransform();
const point = transform.transformPoint({ x, y });
recorded.push({ x: point.x, y: point.y, size: Number.parseFloat(this.font.split(" ")[1]) * Math.hypot(transform.a, transform.b), label });
fillText.call(this, label, x, y);
};
});
const downloadReady = page.waitForEvent("download");
await page.getByRole("button", { name: "下载结果图" }).click();
const download = await downloadReady;
const path = testInfo.outputPath("dimensions.png");
await download.saveAs(path);
const { data, info } = await sharp(path).removeAlpha().raw().toBuffer({ resolveWithObject: true });
expect([info.width, info.height]).toEqual(scenario.output);
const scale = info.width / imageRect.width;
const left = imageRect.x - overlayRect.x;
const top = imageRect.y - overlayRect.y;
const texts = await page.evaluate(() => (window as unknown as {
exportedDimensionText: { x: number; y: number; size: number; label: string }[];
}).exportedDimensionText);
expect(texts).toHaveLength(2);
for (const [index, annotation] of expected.entries()) {
expect(texts[index].label).toBe(annotation.label);
expect(texts[index].x).toBeCloseTo((annotation.textX - left) * scale, 3);
expect(texts[index].y).toBeCloseTo((annotation.textY - top) * scale, 3);
expect(texts[index].size).toBeCloseTo(annotation.fontSize * scale, 4);
for (const fraction of [0.1, 0.5, 0.9]) {
const x = Math.round((annotation.x1 + (annotation.x2 - annotation.x1) * fraction - left) * scale);
const y = Math.round((annotation.y1 + (annotation.y2 - annotation.y1) * fraction - top) * scale);
const offset = (y * info.width + x) * info.channels;
expect(data[offset], `dimension ${index + 1} at ${fraction}`).toBeLessThan(180);
}
}
await testInfo.attach("exported dimensions", { path, contentType: "image/png" });
});
}