ai-chatbot/tests/integration/layout-replace-validation.test.ts

127 lines
8.1 KiB
TypeScript

import assert from "node:assert/strict";
import { test } from "node:test";
import { selectCanvasReferenceImages } from "@/lib/canvas/reference-image-policy";
import { parseLayoutReplaceQuality } from "@/lib/canvas/layout-replace-quality";
import { runValidatedLayoutReplacement } from "@/lib/canvas/layout-replace-validation";
import { buildCanvasLayoutReplacePrompt, rankCanvasLayoutReplaceModels } from "@/lib/canvas/layout-replace-model-selection";
import { parseLayoutReplaceProductBox, layoutReplaceProductCrop } from "@/lib/canvas/layout-replace-product-crop";
const verdict = { sourceProductPreserved: true, templateLayoutPreserved: true,
templateTextPreserved: true, wrongDirection: false, collage: false, confidence: .95, reasons: [] };
const quality = (changes: Record<string, unknown> = {}) => parseLayoutReplaceQuality(JSON.stringify({ ...verdict, ...changes }));
const image = (name: string) => ({ images: [{ originalUrl: `https://test.invalid/${name}.png`, thumbnailUrl: `https://test.invalid/${name}.png` }], model: "test-edit", modelRef: "test-edit", prompt: "test" });
test("a partial or entirely failed reachability probe never removes/reorders required image roles", () => {
const requested = ["https://test.invalid/template.png", "https://test.invalid/product.png"];
for (const reachable of [[], [requested[0]], [requested[1]], [...requested].reverse()]) {
assert.deepEqual(selectCanvasReferenceImages({ requested, reachable, requiredCount: 2 }), requested);
}
assert.deepEqual(selectCanvasReferenceImages({ requested, reachable: [requested[1]] }), [requested[1]]);
assert.throws(() => selectCanvasReferenceImages({ requested: [requested[0]], reachable: [], requiredCount: 2 }), /必须提供 2 张/);
});
test("template is the editing base and product identity is independent of furniture-specific facts", () => {
const prompt = buildCanvasLayoutReplacePrompt({ referenceWidth: 750, referenceHeight: 1000, supplementalPrompt: "保持绿色柜门" });
assert.match(prompt, /图1是目标模版底图/);
assert.match(prompt, /图2是新商品身份图/);
assert.match(prompt, /不能把图1的旧商品搬入图2的背景/);
assert.match(prompt, /750x1000/);
assert.match(prompt, /保持绿色柜门/);
assert.match(prompt, /不能把电商海报变成无字场景照片/);
assert.doesNotMatch(prompt, /左侧带圆形拉手|四条桌腿|76%|缩短商品整体高度|扩大整体宽度/);
});
test("the deployed GPT image alias is eligible, while single-image and unverified limits are excluded", () => {
const candidate = (id: string, maxReferenceImages?: number) => ({ model: {
id, modelId: id, supportsImageGen: true, supportsVision: true, healthStatus: "healthy", capabilities: { maxReferenceImages },
}, provider: { type: "vip98k" } });
const ranked = rankCanvasLayoutReplaceModels([candidate("nano-banana-2", 8), candidate("unknown"),
candidate("single-edit", 1), candidate("gpt-image-2-vip", 8)]);
assert.deepEqual(ranked.map(item => item.model.id), ["gpt-image-2-vip", "nano-banana-2"]);
});
test("a healthy multi-image editor outranks a degraded preferred alias with an exhausted reseller account", () => {
const ranked = rankCanvasLayoutReplaceModels([
{ model: { id: "bad-account", modelId: "gpt-image-2", supportsImageGen: true, supportsVision: true,
healthStatus: "degraded", capabilities: { maxReferenceImages: 8 } }, provider: { type: "bananaimage" } },
{ model: { id: "healthy-editor", modelId: "nano-banana-2", supportsImageGen: true, supportsVision: true,
healthStatus: "healthy", capabilities: { maxReferenceImages: 8 } }, provider: { type: "vip98k" } },
]);
assert.equal(ranked[0].model.id, "healthy-editor");
});
test("product focus crops preserve complete edges with outward padding and reject unreliable coordinates", () => {
const box = parseLayoutReplaceProductBox('{"box":[100,500,900,850],"confidence":0.95}');
assert.deepEqual(layoutReplaceProductCrop(box, 1000, 1000), { left: 85, top: 485, width: 830, height: 380 });
assert.deepEqual(layoutReplaceProductCrop([0,0,1000,1000], 750, 1000), { left: 0, top: 0, width: 750, height: 1000 });
for (const value of ["null", "{}", '{"box":[100,500,900,850],"confidence":0.5}',
'{"box":[100,500,90,850],"confidence":0.9}', '{"box":[-1,0,1000,1000],"confidence":0.9}',
'{"box":[0,0,"1000",1000],"confidence":0.9}']) assert.throws(() => parseLayoutReplaceProductBox(value));
});
test("quality rejection covers identity, background direction, copy loss, collage and low confidence", () => {
assert.equal(quality().passed, true);
for (const changes of [{ sourceProductPreserved: false }, { templateLayoutPreserved: false },
{ templateTextPreserved: false }, { wrongDirection: true }, { collage: true }, { confidence: .3 }]) {
assert.equal(quality({ ...changes, passed: true }).passed, false);
}
});
test("incomplete or loosely typed verdicts never silently pass", () => {
for (const value of ["null", "{}", "[]", "looks good", JSON.stringify({ ...verdict, confidence: "0.95" }),
JSON.stringify({ ...verdict, confidence: 2 }), JSON.stringify({ ...verdict, sourceProductPreserved: "true" }),
JSON.stringify({ ...verdict, reasons: [1] })]) {
assert.throws(() => parseLayoutReplaceQuality(value), /质量检查/);
}
assert.equal(parseLayoutReplaceQuality("```json\n" + JSON.stringify(verdict) + "\n```").passed, true);
});
test("a rejected first result generates one corrected candidate and only accepted output is persisted", async () => {
const generated: string[] = []; const persisted: string[] = []; const verified: string[] = [];
const result = await runValidatedLayoutReplacement({
generate: async (attempt, feedback) => {
if (attempt) assert.match(feedback, /方向做反/);
generated.push(`candidate-${attempt}`); return image(`candidate-${attempt}`);
},
normalize: async result => ({ ...result, images: [{ originalUrl: result.images[0].originalUrl.replace(".png", "-normalized.png"), thumbnailUrl: "" }] }),
verify: async result => { verified.push(result.images[0].originalUrl); return verified.length === 1 ? quality({ wrongDirection: true }) : quality(); },
persist: async result => { persisted.push(result.images[0].originalUrl); },
});
assert.equal(generated.length, 2);
assert.equal(verified.length, 2);
assert.ok(verified.every(url => url.endsWith("-normalized.png")));
assert.deepEqual(persisted, ["https://test.invalid/candidate-1-normalized.png"]);
assert.equal(result.validation.attempts, 2);
assert.equal(result.validation.status, "passed");
});
test("two rejected outputs finish as failure without any gallery/canvas success", async () => {
let generations = 0; let saves = 0;
await assert.rejects(runValidatedLayoutReplacement({
generate: async () => { generations++; return image("bad"); }, normalize: async result => result,
verify: async () => quality({ sourceProductPreserved: false }), persist: async () => { saves++; },
}), /未通过质量检查/);
assert.equal(generations, 2); assert.equal(saves, 0);
});
test("unknown generation failure is not replayed and a verifier outage cannot trigger paid regeneration", async () => {
for (const failure of ["generation", "verifier"]) {
let generations = 0; let saves = 0;
await assert.rejects(runValidatedLayoutReplacement({
generate: async () => { generations++; if (failure === "generation") throw new Error("outcome-unknown timeout"); return image("unverified"); },
normalize: async result => result, verify: async () => { throw new Error("quality service unavailable"); },
persist: async () => { saves++; },
}));
assert.equal(generations, 1); assert.equal(saves, 0);
}
});
test("a passing first result is saved once without an unnecessary second generation", async () => {
let generations = 0; let saves = 0;
const result = await runValidatedLayoutReplacement({
generate: async () => { generations++; return image("good"); }, normalize: async result => result,
verify: async () => quality(), persist: async () => { saves++; },
});
assert.equal(generations, 1); assert.equal(saves, 1); assert.equal(result.validation.attempts, 1);
});