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

168 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, parseLayoutReplaceProductAnalysis, layoutReplaceProductCrop } from "@/lib/canvas/layout-replace-product-crop";
import { parseLayoutReplaceTextBoxes, parseLayoutReplaceTextCheck } from "@/lib/canvas/layout-replace-text-lock";
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), ["nano-banana-2", "gpt-image-2-vip"]);
});
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("observed product structure is required and carried into the generation constraints", () => {
const identity = "木框绿色柜,贴地底框,无柜脚,左右有开放格";
const analysis = parseLayoutReplaceProductAnalysis(JSON.stringify({ box: [0,0,1000,1000], confidence: .95, identity }));
assert.equal(analysis.identity, identity);
assert.match(buildCanvasLayoutReplacePrompt({ sourceProductIdentity: identity }), /贴地底框,无柜脚/);
assert.match(buildCanvasLayoutReplacePrompt({ sourceProductIdentity: identity }), /不能继承图1的腿/);
assert.throws(() => parseLayoutReplaceProductAnalysis('{"box":[0,0,1000,1000],"confidence":0.95}'), /结构识别不完整/);
});
test("template text regions reject missing, invalid and whole-poster replacement masks", () => {
assert.deepEqual(parseLayoutReplaceTextBoxes('{"containsText":false,"boxes":[]}'), []);
assert.deepEqual(parseLayoutReplaceTextBoxes('{"containsText":true,"boxes":[[0,0,200,100]]}'), [[0,0,200,100]]);
for (const value of ["null", "{}", '{"containsText":true,"boxes":[]}',
'{"containsText":false,"boxes":[[0,0,200,100]]}', '{"containsText":true,"boxes":[[0,0,1000,1000]]}',
'{"containsText":true,"boxes":[[100,0,20,100]]}']) assert.throws(() => parseLayoutReplaceTextBoxes(value));
});
test("text verification refuses discount/price changes, uncertain reading and incomplete conclusions", () => {
const good = { identical: true, uncertain: false, confidence: .95, differences: [] };
assert.equal(parseLayoutReplaceTextCheck(JSON.stringify(good)).passed, true);
assert.equal(parseLayoutReplaceTextCheck(JSON.stringify({ ...good, passed: true, differences: ["8.7折变成6.7折"] })).passed, false);
assert.equal(parseLayoutReplaceTextCheck(JSON.stringify({ ...good, uncertain: true })).passed, false);
assert.equal(parseLayoutReplaceTextCheck(JSON.stringify({ ...good, identical: false })).passed, false);
for (const value of ["null", "{}", JSON.stringify({ ...good, confidence: "0.95" }), JSON.stringify({ ...good, differences: [1] })]) {
assert.throws(() => parseLayoutReplaceTextCheck(value));
}
});
test("structured OCR differences preserve numeric changes and discard unchanged rows", () => {
const changed = parseLayoutReplaceTextCheck(JSON.stringify({ identical: true, uncertain: false,
confidence: .95, differences: [{ original: "消费券8.7折起", result: "消费券6.7折起" },
{ original: "满8000减1050元", result: "满8000减1050元" }] }));
assert.equal(changed.passed, false);
assert.equal(changed.differences.length, 1);
assert.match(changed.differences[0], /8.7.*6.7/);
const equivalent = parseLayoutReplaceTextCheck(JSON.stringify({ identical: true, uncertain: false,
confidence: .95, differences: [{ original: "价格 1188", result: "价格1188" }] }));
assert.equal(equivalent.passed, true);
});
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);
});