185 lines
12 KiB
TypeScript
185 lines
12 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, parseLayoutReplaceProductAnalysis, layoutReplaceProductCrop, layoutReplaceProductAspectRatio } from "@/lib/canvas/layout-replace-product-crop";
|
||
import { makeLayoutReplaceQualityBoard } from "@/lib/canvas/layout-replace-quality-board";
|
||
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), ["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.equal(layoutReplaceProductAspectRatio(box, 1000, 1000), 800 / 350);
|
||
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("verification board visibly separates old template, true source and final result without stretching", async () => {
|
||
const sharp = (await import("sharp")).default;
|
||
const make = (color: string) => sharp({ create: { width: 100, height: 100, channels: 3, background: color } }).png().toBuffer();
|
||
const board = await makeLayoutReplaceQualityBoard({ template: await make("red"), source: await make("blue"),
|
||
product: await make("green"), result: await make("yellow") });
|
||
const { data, info } = await sharp(board).raw().toBuffer({ resolveWithObject: true });
|
||
const pixel = (x: number, y: number) => [...data.subarray((y*info.width+x)*info.channels,(y*info.width+x)*info.channels+3)];
|
||
assert.deepEqual([info.width,info.height], [1390,1390]);
|
||
assert.deepEqual(pixel(190,282), [255,0,0]);
|
||
assert.deepEqual(pixel(190,60), [255,255,255]);
|
||
assert.deepEqual(pixel(190,752), [0,0,255]);
|
||
assert.deepEqual(pixel(885,217), [0,128,0]);
|
||
assert.deepEqual(pixel(885,912), [255,255,0]);
|
||
});
|
||
|
||
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);
|
||
});
|