136 lines
8.8 KiB
TypeScript
136 lines
8.8 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 } 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("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("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);
|
|
});
|