Merge 45e6534: 引入 Vitest 测试骨架

This commit is contained in:
2026-06-16 13:53:40 +08:00
12 changed files with 3773 additions and 199 deletions
+3011
View File
File diff suppressed because it is too large Load Diff
+10 -4
View File
@@ -7,20 +7,26 @@
"dev": "vite --host 127.0.0.1",
"build": "vite build",
"preview": "vite preview --host 127.0.0.1",
"type-check": "tsc -p tsconfig.json --noEmit"
"type-check": "tsc -p tsconfig.json --noEmit",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@ant-design/icons": "5.3.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"scheduler": "0.23.0",
"zustand": "5.0.13"
},
"devDependencies": {
"@types/react": "18.2.0",
"@types/react-dom": "18.2.0",
"@types/react": "18.2.55",
"@types/react-dom": "18.2.18",
"@vitejs/plugin-react": "4.2.1",
"@vitest/coverage-v8": "^1.6.0",
"typescript": "5.3.3",
"vite": "5.1.0",
"vite-plugin-compression2": "2.5.3"
"vite-plugin-compression2": "2.5.3",
"vitest": "^1.6.0"
}
}
+3 -194
View File
@@ -40,6 +40,9 @@ import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel";
import EcommerceClonePanel from "./panels/EcommerceClonePanel";
import { ecommerceOssScopes, saveUnifiedEcommerceGenerationRecord, deleteEcommerceGenerationRecord } from "./ecommerceGenerationPersistence";
import { downloadResultAsset } from "../workbench/workbenchDownload";
import { clampNumber, normalizeHexColor, hexToRgb, rgbToHex, parseSmartCutoutAspect, parseSmartCutoutPercent, hsvToRgb, hexToHsv } from "./utils/colorUtils";
import { normalizeRatioToken, quickSetRatioOptions, getQuickSetRatioValue, formatRatioDisplayValue, getRatioDisplayParts, parseRatioToAspectCss, supportedImageApiRatios, toSupportedImageApiRatio, normalizeRatioForApi, greatestCommonDivisor, formatAspectRatio } from "./utils/ratioUtils";
import { aiGenerationClient } from "../../api/aiGenerationClient";
const smartCutoutColorPresets = [
"#ffffff",
@@ -166,92 +169,6 @@ const buildInspirationPrompt = (title: string, meta: string): string => {
return points.length ? `${base}。风格要点:${points.join("、")}` : `${base}`;
};
const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const normalizeHexColor = (value: string) => {
const clean = value.trim().replace(/^#/, "");
if (!/^[0-9a-fA-F]{6}$/.test(clean)) return null;
return `#${clean.toLowerCase()}`;
};
const hexToRgb = (value: string) => {
const normalized = normalizeHexColor(value);
if (!normalized) return null;
const numeric = Number.parseInt(normalized.slice(1), 16);
return {
r: (numeric >> 16) & 255,
g: (numeric >> 8) & 255,
b: numeric & 255,
};
};
const rgbToHex = (r: number, g: number, b: number) =>
`#${[r, g, b].map((item) => clampNumber(Math.round(item), 0, 255).toString(16).padStart(2, "0")).join("")}`;
const parseSmartCutoutAspect = (aspect: string) => {
const match = aspect.match(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/);
if (!match) return null;
const width = Number(match[1]);
const height = Number(match[2]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null;
return width / height;
};
const parseSmartCutoutPercent = (value: string, fallback: number) => {
const numeric = Number(value.replace("%", ""));
if (!Number.isFinite(numeric)) return fallback;
return clampNumber(numeric / 100, 0.05, 1);
};
const hsvToRgb = (h: number, s: number, v: number) => {
const hue = ((h % 360) + 360) % 360;
const saturation = clampNumber(s, 0, 100) / 100;
const value = clampNumber(v, 0, 100) / 100;
const chroma = value * saturation;
const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
const match = value - chroma;
const [red, green, blue] =
hue < 60
? [chroma, x, 0]
: hue < 120
? [x, chroma, 0]
: hue < 180
? [0, chroma, x]
: hue < 240
? [0, x, chroma]
: hue < 300
? [x, 0, chroma]
: [chroma, 0, x];
return {
r: (red + match) * 255,
g: (green + match) * 255,
b: (blue + match) * 255,
};
};
const hexToHsv = (value: string) => {
const rgb = hexToRgb(value) ?? { r: 255, g: 255, b: 255 };
const red = rgb.r / 255;
const green = rgb.g / 255;
const blue = rgb.b / 255;
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const delta = max - min;
const hue =
delta === 0
? 0
: max === red
? 60 * (((green - blue) / delta) % 6)
: max === green
? 60 * ((blue - red) / delta + 2)
: 60 * ((red - green) / delta + 4);
return {
h: Math.round((hue + 360) % 360),
s: max === 0 ? 0 : Math.round((delta / max) * 100),
v: Math.round(max * 100),
};
};
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { ServerRequestError } from "../../api/serverConnection";
import { waitForTask } from "../../api/taskSubscription";
import { toast } from "../../components/toast/toastStore";
@@ -838,15 +755,6 @@ const getPlatformRatioGroup = (value: string, mode?: PlatformRatioModeKey): Plat
const getPlatformRatioOptions = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).ratios;
const getPlatformDefaultRatio = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).defaultRatio;
const getUniqueRatioOptions = (ratios: string[]) => Array.from(new Set(ratios));
const normalizeRatioToken = (value: string) =>
value
.replaceAll("\u00a0", " ")
.replaceAll("脳", "×")
.replaceAll("*", "×")
.replaceAll("", ":")
.replace(/锛\?/g, ":")
.replace(/\s+/g, " ")
.trim();
const normalizeRatioForPlatform = (platformValue: string, ratioValue: string, mode?: PlatformRatioModeKey) => {
const platformRatios = getPlatformRatioOptions(platformValue, mode);
if (platformRatios.includes(ratioValue)) return ratioValue;
@@ -854,105 +762,6 @@ const normalizeRatioForPlatform = (platformValue: string, ratioValue: string, mo
const matchedRatio = platformRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio));
return matchedRatio ?? getPlatformDefaultRatio(platformValue, mode);
};
const quickSetRatioOptions = ["1:1", "3:4", "4:3", "9:16", "16:9"];
const getQuickSetRatioValue = (value: string) => {
const normalizedValue = normalizeRatioToken(value);
if (quickSetRatioOptions.includes(normalizedValue)) return normalizedValue;
const sizeMatch = normalizedValue.match(/(\d+)\s*[×xX]\s*(\d+)/u);
if (sizeMatch) {
const width = Number(sizeMatch[1]);
const height = Number(sizeMatch[2]);
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
const aspect = formatAspectRatio(width, height);
if (quickSetRatioOptions.includes(aspect)) return aspect;
}
}
const ratioMatch = normalizedValue.match(/(\d+)\s*[:]\s*(\d+)/u);
if (ratioMatch) {
const aspect = `${Number(ratioMatch[1])}:${Number(ratioMatch[2])}`;
if (quickSetRatioOptions.includes(aspect)) return aspect;
}
return quickSetRatioOptions[0]!;
};
const formatRatioDisplayValue = (value: string) => {
const normalizedValue = normalizeRatioToken(value);
const sizeMatch = normalizedValue.match(/(\d+)\s*[×xX]\s*(\d+)\s*px?/u);
if (sizeMatch) {
const width = Number(sizeMatch[1]);
const height = Number(sizeMatch[2]);
return `${width}×${height}px\u00a0\u00a0\u00a0${formatAspectRatio(width, height)}`;
}
return normalizedValue
.replace("淘宝主图 / SKU 图 ", "淘宝主图 / SKU 图 ")
.replace("京东主图 / SKU 图 ", "京东主图 / SKU 图 ")
.replace("详情页宽", "详情页宽")
.replace("短视频", "短视频")
.replace("主图", "主图")
.replace("商品主图", "商品主图")
.replace("鍟嗗搧鍥?", "商品图")
.replace(/\s+:/g, ":")
.replace(/:\s+/g, ":");
};
const getRatioDisplayParts = (value: string) => {
const display = formatRatioDisplayValue(value).replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim();
const aspectMatch = display.match(/(\d+\s*[:]\s*\d+)(?!.*\d+\s*[:]\s*\d+)/u);
const aspect = aspectMatch?.[1]?.replace(/\s+/g, "") ?? "自适应";
const size = aspectMatch ? display.replace(aspectMatch[0], "").trim() : display;
return {
size: size || "原图比例",
aspect,
};
};
/** Extract CSS aspect-ratio from a ratio string like "1000x1000px 1:1" -> "1 / 1" */
const parseRatioToAspectCss = (ratioStr: string): string => {
const match = ratioStr.match(/(\d+)\D+(\d+)/u);
if (!match) return "1 / 1";
return `${match[1]} / ${match[2]}`;
};
const supportedImageApiRatios = ["1:1", "3:4", "4:3", "9:16", "16:9"] as const;
type SupportedImageApiRatio = typeof supportedImageApiRatios[number];
const toSupportedImageApiRatio = (width: number, height: number): SupportedImageApiRatio => {
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return "1:1";
let bestRatio: SupportedImageApiRatio = "1:1";
let bestScore = Number.POSITIVE_INFINITY;
const target = Math.log(width / height);
for (const ratio of supportedImageApiRatios) {
const [left, right] = ratio.split(":").map(Number);
const score = Math.abs(target - Math.log(left / right));
if (score < bestScore) {
bestRatio = ratio;
bestScore = score;
}
}
return bestRatio;
};
/** Normalize ratio display string ("1000×1000px 1:1") to an image API aspect ratio ("1:1"). */
const normalizeRatioForApi = (ratioStr: string): string => {
const normalizedValue = normalizeRatioToken(ratioStr);
const explicitRatios = Array.from(normalizedValue.matchAll(/(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)/g));
const explicitRatio = explicitRatios.at(-1);
if (explicitRatio) {
return toSupportedImageApiRatio(Number(explicitRatio[1]), Number(explicitRatio[2]));
}
const sizeMatch = normalizedValue.match(/(\d+(?:\.\d+)?)\s*[×xX*]\s*(\d+(?:\.\d+)?)/u);
if (!sizeMatch) return "1:1";
return toSupportedImageApiRatio(Number(sizeMatch[1]), Number(sizeMatch[2]));
};
const greatestCommonDivisor = (left: number, right: number): number => {
let a = Math.abs(left);
let b = Math.abs(right);
while (b) {
[a, b] = [b, a % b];
}
return a || 1;
};
const formatAspectRatio = (width: number, height: number) => {
const divisor = greatestCommonDivisor(width, height);
return `${Math.round(width / divisor)}:${Math.round(height / divisor)}`;
};
const formatUploadedImageRatio = (image?: CloneImageItem) => {
if (!image) return null;
const format = image.format ? `\u00a0\u00a0\u00a0${image.format}` : "";
@@ -0,0 +1,90 @@
import { describe, it, expect } from "vitest";
import {
validateEcommerceImageFiles,
summarizeRejectedImages,
normalizeEcommerceImageMime,
ECOMMERCE_MAX_IMAGE_BYTES,
} from "./ecommerceImageValidation";
function makeFile(name: string, type: string, size: number): File {
return new File([new Uint8Array(size)], name, { type });
}
describe("validateEcommerceImageFiles", () => {
it("accepts supported types under the size limit", () => {
const result = validateEcommerceImageFiles([
makeFile("a.png", "image/png", 1024),
makeFile("b.jpg", "image/jpeg", 1024),
makeFile("c.webp", "image/webp", 1024),
makeFile("d.gif", "image/gif", 1024),
]);
expect(result.accepted).toHaveLength(4);
expect(result.rejected).toHaveLength(0);
});
it("rejects unsupported mime types", () => {
const result = validateEcommerceImageFiles([makeFile("x.bmp", "image/bmp", 1024)]);
expect(result.accepted).toHaveLength(0);
expect(result.rejected[0]).toMatchObject({ name: "x.bmp", reason: "不支持的图片格式" });
});
it("rejects files over 10MB", () => {
const result = validateEcommerceImageFiles([
makeFile("big.png", "image/png", ECOMMERCE_MAX_IMAGE_BYTES + 1),
]);
expect(result.accepted).toHaveLength(0);
expect(result.rejected[0]).toMatchObject({ name: "big.png", reason: "图片超过 10MB" });
});
it("accepts exactly 10MB (boundary)", () => {
const result = validateEcommerceImageFiles([
makeFile("edge.png", "image/png", ECOMMERCE_MAX_IMAGE_BYTES),
]);
expect(result.accepted).toHaveLength(1);
});
it("partitions a mixed batch", () => {
const result = validateEcommerceImageFiles([
makeFile("ok.png", "image/png", 100),
makeFile("bad.bmp", "image/bmp", 100),
makeFile("huge.jpg", "image/jpeg", ECOMMERCE_MAX_IMAGE_BYTES + 1),
]);
expect(result.accepted).toHaveLength(1);
expect(result.rejected).toHaveLength(2);
});
});
describe("summarizeRejectedImages", () => {
it("returns empty string for no rejections", () => {
expect(summarizeRejectedImages([])).toBe("");
});
it("summarizes a single rejection", () => {
expect(summarizeRejectedImages([{ name: "a.bmp", reason: "不支持的图片格式" }])).toBe(
"a.bmp 已跳过:不支持的图片格式",
);
});
it("appends count suffix for multiple rejections", () => {
const summary = summarizeRejectedImages([
{ name: "a.bmp", reason: "不支持的图片格式" },
{ name: "b.bmp", reason: "不支持的图片格式" },
]);
expect(summary).toBe("a.bmp 等 2 个文件 已跳过:不支持的图片格式");
});
});
describe("normalizeEcommerceImageMime", () => {
it("passes through supported types", () => {
expect(normalizeEcommerceImageMime("image/png")).toBe("image/png");
expect(normalizeEcommerceImageMime("image/jpeg")).toBe("image/jpeg");
expect(normalizeEcommerceImageMime("image/webp")).toBe("image/webp");
expect(normalizeEcommerceImageMime("image/gif")).toBe("image/gif");
});
it("falls back to image/png for unsupported or empty types", () => {
expect(normalizeEcommerceImageMime("image/bmp")).toBe("image/png");
expect(normalizeEcommerceImageMime("")).toBe("image/png");
expect(normalizeEcommerceImageMime("application/octet-stream")).toBe("image/png");
});
});
@@ -0,0 +1,122 @@
import { describe, it, expect } from "vitest";
import {
clampNumber,
normalizeHexColor,
hexToRgb,
rgbToHex,
parseSmartCutoutAspect,
parseSmartCutoutPercent,
hsvToRgb,
hexToHsv,
} from "./colorUtils";
describe("clampNumber", () => {
it("clamps below min", () => {
expect(clampNumber(-5, 0, 100)).toBe(0);
});
it("clamps above max", () => {
expect(clampNumber(200, 0, 100)).toBe(100);
});
it("passes through values in range", () => {
expect(clampNumber(50, 0, 100)).toBe(50);
});
});
describe("normalizeHexColor", () => {
it("normalizes a valid hex", () => {
expect(normalizeHexColor("#FF8800")).toBe("#ff8800");
});
it("accepts hex without leading #", () => {
expect(normalizeHexColor("ff8800")).toBe("#ff8800");
});
it("returns null for invalid hex", () => {
expect(normalizeHexColor("#fff")).toBeNull();
expect(normalizeHexColor("ggghhh")).toBeNull();
expect(normalizeHexColor("")).toBeNull();
});
});
describe("hex <-> rgb round-trip", () => {
const cases: Array<[string, { r: number; g: number; b: number }]> = [
["#000000", { r: 0, g: 0, b: 0 }],
["#ffffff", { r: 255, g: 255, b: 255 }],
["#ff8800", { r: 255, g: 136, b: 0 }],
["#2dd4bf", { r: 45, g: 212, b: 191 }],
];
for (const [hex, rgb] of cases) {
it(`hexToRgb(${hex}) -> rgb`, () => {
expect(hexToRgb(hex)).toEqual(rgb);
});
it(`rgbToHex(${rgb.r},${rgb.g},${rgb.b}) -> ${hex}`, () => {
expect(rgbToHex(rgb.r, rgb.g, rgb.b)).toBe(hex);
});
}
it("hexToRgb returns null for invalid", () => {
expect(hexToRgb("nope")).toBeNull();
});
it("rgbToHex clamps out-of-range channels", () => {
expect(rgbToHex(300, -5, 128)).toBe(rgbToHex(255, 0, 128));
});
});
describe("parseSmartCutoutAspect", () => {
it("parses a W / H aspect string", () => {
expect(parseSmartCutoutAspect("295 / 413")).toBeCloseTo(295 / 413, 5);
});
it("handles decimals", () => {
expect(parseSmartCutoutAspect("1.5 / 2")).toBeCloseTo(0.75, 5);
});
it("returns null when no ratio pattern is present", () => {
expect(parseSmartCutoutAspect("not-a-ratio")).toBeNull();
expect(parseSmartCutoutAspect("")).toBeNull();
});
it("returns null for zero dimensions", () => {
expect(parseSmartCutoutAspect("0 / 100")).toBeNull();
expect(parseSmartCutoutAspect("100 / 0")).toBeNull();
});
it("ignores leading sign (regex only matches digits)", () => {
// The regex \d+ does not match '-', so "-1 / 2" parses as 1/2.
expect(parseSmartCutoutAspect("-1 / 2")).toBeCloseTo(0.5, 5);
});
});
describe("parseSmartCutoutPercent", () => {
it("parses a percentage", () => {
expect(parseSmartCutoutPercent("82%", 0.5)).toBeCloseTo(0.82, 5);
});
it("clamps to [0.05, 1]", () => {
expect(parseSmartCutoutPercent("150%", 0.5)).toBe(1);
expect(parseSmartCutoutPercent("1%", 0.5)).toBe(0.05);
});
it("returns fallback for non-numeric", () => {
expect(parseSmartCutoutPercent("abc", 0.5)).toBe(0.5);
});
});
describe("hsv <-> rgb", () => {
it("hsvToRgb of pure red", () => {
expect(hsvToRgb(0, 100, 100)).toEqual({ r: 255, g: 0, b: 0 });
});
it("hsvToRgb of pure green", () => {
expect(hsvToRgb(120, 100, 100)).toEqual({ r: 0, g: 255, b: 0 });
});
it("hsvToRgb of white (saturation 0)", () => {
expect(hsvToRgb(0, 0, 100)).toEqual({ r: 255, g: 255, b: 255 });
});
it("hexToHsv then hsvToRgb round-trips within ±2 (rounding)", () => {
const hex = "#2dd4bf";
const hsv = hexToHsv(hex);
const rgb = hsvToRgb(hsv.h, hsv.s, hsv.v);
const original = hexToRgb(hex)!;
expect(Math.abs(rgb.r - original.r)).toBeLessThanOrEqual(2);
expect(Math.abs(rgb.g - original.g)).toBeLessThanOrEqual(2);
expect(Math.abs(rgb.b - original.b)).toBeLessThanOrEqual(2);
});
it("hexToHsv of white", () => {
expect(hexToHsv("#ffffff")).toEqual({ h: 0, s: 0, v: 100 });
});
});
@@ -0,0 +1,88 @@
// 智能抠图 / 调色板用到的纯数值与颜色转换工具。
// 从 EcommercePage.tsx 抽出,逻辑零改动,仅加 export 以便单测。
export const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
export const normalizeHexColor = (value: string) => {
const clean = value.trim().replace(/^#/, "");
if (!/^[0-9a-fA-F]{6}$/.test(clean)) return null;
return `#${clean.toLowerCase()}`;
};
export const hexToRgb = (value: string) => {
const normalized = normalizeHexColor(value);
if (!normalized) return null;
const numeric = Number.parseInt(normalized.slice(1), 16);
return {
r: (numeric >> 16) & 255,
g: (numeric >> 8) & 255,
b: numeric & 255,
};
};
export const rgbToHex = (r: number, g: number, b: number) =>
`#${[r, g, b].map((item) => clampNumber(Math.round(item), 0, 255).toString(16).padStart(2, "0")).join("")}`;
export const parseSmartCutoutAspect = (aspect: string) => {
const match = aspect.match(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/);
if (!match) return null;
const width = Number(match[1]);
const height = Number(match[2]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null;
return width / height;
};
export const parseSmartCutoutPercent = (value: string, fallback: number) => {
const numeric = Number(value.replace("%", ""));
if (!Number.isFinite(numeric)) return fallback;
return clampNumber(numeric / 100, 0.05, 1);
};
export const hsvToRgb = (h: number, s: number, v: number) => {
const hue = ((h % 360) + 360) % 360;
const saturation = clampNumber(s, 0, 100) / 100;
const value = clampNumber(v, 0, 100) / 100;
const chroma = value * saturation;
const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
const match = value - chroma;
const [red, green, blue] =
hue < 60
? [chroma, x, 0]
: hue < 120
? [x, chroma, 0]
: hue < 180
? [0, chroma, x]
: hue < 240
? [0, x, chroma]
: hue < 300
? [x, 0, chroma]
: [chroma, 0, x];
return {
r: (red + match) * 255,
g: (green + match) * 255,
b: (blue + match) * 255,
};
};
export const hexToHsv = (value: string) => {
const rgb = hexToRgb(value) ?? { r: 255, g: 255, b: 255 };
const red = rgb.r / 255;
const green = rgb.g / 255;
const blue = rgb.b / 255;
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const delta = max - min;
const hue =
delta === 0
? 0
: max === red
? 60 * (((green - blue) / delta) % 6)
: max === green
? 60 * ((blue - red) / delta + 2)
: 60 * ((red - green) / delta + 4);
return {
h: Math.round((hue + 360) % 360),
s: max === 0 ? 0 : Math.round((delta / max) * 100),
v: Math.round(max * 100),
};
};
@@ -0,0 +1,143 @@
import { describe, it, expect } from "vitest";
import {
normalizeRatioToken,
greatestCommonDivisor,
formatAspectRatio,
getQuickSetRatioValue,
formatRatioDisplayValue,
getRatioDisplayParts,
parseRatioToAspectCss,
toSupportedImageApiRatio,
normalizeRatioForApi,
} from "./ratioUtils";
describe("normalizeRatioToken", () => {
it("converts fullwidth and mojibake characters", () => {
expect(normalizeRatioToken("800\u00a0\u00a0px")).toBe("800 px");
});
it("replaces * with ×", () => {
expect(normalizeRatioToken("800*800")).toBe("800×800");
});
it("replaces mojibake 脳 with ×", () => {
expect(normalizeRatioToken("800脳800")).toBe("800×800");
});
it("replaces fullwidth colon", () => {
expect(normalizeRatioToken("11")).toBe("1:1");
});
it("collapses whitespace and trims", () => {
expect(normalizeRatioToken(" 1 : 1 ")).toBe("1 : 1");
});
});
describe("greatestCommonDivisor", () => {
it("computes GCD", () => {
expect(greatestCommonDivisor(12, 8)).toBe(4);
expect(greatestCommonDivisor(1920, 1080)).toBe(120);
});
it("handles zero with fallback to 1", () => {
expect(greatestCommonDivisor(0, 5)).toBe(5);
expect(greatestCommonDivisor(0, 0)).toBe(1);
});
it("handles negatives via abs", () => {
expect(greatestCommonDivisor(-12, 8)).toBe(4);
});
});
describe("formatAspectRatio", () => {
it("reduces 1920x1080 to 16:9", () => {
expect(formatAspectRatio(1920, 1080)).toBe("16:9");
});
it("reduces 750x1000 to 3:4", () => {
expect(formatAspectRatio(750, 1000)).toBe("3:4");
});
it("reduces 800x800 to 1:1", () => {
expect(formatAspectRatio(800, 800)).toBe("1:1");
});
});
describe("getQuickSetRatioValue", () => {
it("passes through a canonical quick-set value", () => {
expect(getQuickSetRatioValue("1:1")).toBe("1:1");
});
it("derives from a WxH size string", () => {
expect(getQuickSetRatioValue("1920×1080px")).toBe("16:9");
expect(getQuickSetRatioValue("750×1000px")).toBe("3:4");
});
it("derives from a raw ratio string", () => {
expect(getQuickSetRatioValue("9:16")).toBe("9:16");
});
it("falls back to 1:1 for unparseable input", () => {
expect(getQuickSetRatioValue("unknown")).toBe("1:1");
});
});
describe("formatRatioDisplayValue", () => {
it("formats a WxHpx string with aspect suffix", () => {
expect(formatRatioDisplayValue("1000×1000px 1:1")).toBe("1000×1000px\u00a0\u00a0\u00a01:1");
});
it("reformats 800×800px without explicit aspect", () => {
expect(formatRatioDisplayValue("800×800px")).toBe("800×800px\u00a0\u00a0\u00a01:1");
});
});
describe("getRatioDisplayParts", () => {
it("splits size and aspect", () => {
expect(getRatioDisplayParts("1000×1000px 1:1")).toEqual({
size: "1000×1000px",
aspect: "1:1",
});
});
it("uses 自适应 when no aspect present", () => {
const parts = getRatioDisplayParts("原图");
expect(parts.aspect).toBe("自适应");
});
});
describe("parseRatioToAspectCss", () => {
it("extracts CSS aspect-ratio", () => {
expect(parseRatioToAspectCss("1000×1000px 1:1")).toBe("1000 / 1000");
});
it("falls back to 1 / 1", () => {
expect(parseRatioToAspectCss("no numbers")).toBe("1 / 1");
});
});
describe("toSupportedImageApiRatio", () => {
it("snaps square to 1:1", () => {
expect(toSupportedImageApiRatio(800, 800)).toBe("1:1");
});
it("snaps 750x1000 to 3:4", () => {
expect(toSupportedImageApiRatio(750, 1000)).toBe("3:4");
});
it("snaps 1920x1080 to 16:9", () => {
expect(toSupportedImageApiRatio(1920, 1080)).toBe("16:9");
});
it("snaps 1080x1920 to 9:16", () => {
expect(toSupportedImageApiRatio(1080, 1920)).toBe("9:16");
});
it("snaps 800x600 to 4:3", () => {
expect(toSupportedImageApiRatio(800, 600)).toBe("4:3");
});
it("returns 1:1 for non-finite or non-positive", () => {
expect(toSupportedImageApiRatio(NaN, 100)).toBe("1:1");
expect(toSupportedImageApiRatio(0, 100)).toBe("1:1");
expect(toSupportedImageApiRatio(-1, 100)).toBe("1:1");
});
});
describe("normalizeRatioForApi", () => {
it("extracts the explicit ratio from a display string", () => {
expect(normalizeRatioForApi("1000×1000px 1:1")).toBe("1:1");
expect(normalizeRatioForApi("750×1000px 3:4")).toBe("3:4");
});
it("derives ratio from a bare size string", () => {
expect(normalizeRatioForApi("1920×1080px")).toBe("16:9");
});
it("returns 1:1 for unparseable input", () => {
expect(normalizeRatioForApi("")).toBe("1:1");
expect(normalizeRatioForApi("无尺寸信息")).toBe("1:1");
});
it("uses the last explicit ratio when multiple present", () => {
expect(normalizeRatioForApi("4:3 16:9")).toBe("16:9");
});
});
+121
View File
@@ -0,0 +1,121 @@
// 比例 / 尺寸相关的纯计算工具。
// 从 EcommercePage.tsx 抽出,逻辑零改动,仅加 export 以便单测。
// normalizeRatioForPlatform / formatUploadedImageRatio 因依赖平台规格表与 CloneImageItem
// 暂留在 EcommercePage.tsx,后续随 platformSpec 一起整理。
export const normalizeRatioToken = (value: string) =>
value
.replaceAll("\u00a0", " ")
.replaceAll("脳", "×")
.replaceAll("*", "×")
.replaceAll("", ":")
.replace(/锛\?/g, ":")
.replace(/\s+/g, " ")
.trim();
export const quickSetRatioOptions = ["1:1", "3:4", "4:3", "9:16", "16:9"];
export const greatestCommonDivisor = (left: number, right: number): number => {
let a = Math.abs(left);
let b = Math.abs(right);
while (b) {
[a, b] = [b, a % b];
}
return a || 1;
};
export const formatAspectRatio = (width: number, height: number) => {
const divisor = greatestCommonDivisor(width, height);
return `${Math.round(width / divisor)}:${Math.round(height / divisor)}`;
};
export const getQuickSetRatioValue = (value: string) => {
const normalizedValue = normalizeRatioToken(value);
if (quickSetRatioOptions.includes(normalizedValue)) return normalizedValue;
const sizeMatch = normalizedValue.match(/(\d+)\s*[×xX]\s*(\d+)/u);
if (sizeMatch) {
const width = Number(sizeMatch[1]);
const height = Number(sizeMatch[2]);
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
const aspect = formatAspectRatio(width, height);
if (quickSetRatioOptions.includes(aspect)) return aspect;
}
}
const ratioMatch = normalizedValue.match(/(\d+)\s*[:]\s*(\d+)/u);
if (ratioMatch) {
const aspect = `${Number(ratioMatch[1])}:${Number(ratioMatch[2])}`;
if (quickSetRatioOptions.includes(aspect)) return aspect;
}
return quickSetRatioOptions[0]!;
};
export const formatRatioDisplayValue = (value: string) => {
const normalizedValue = normalizeRatioToken(value);
const sizeMatch = normalizedValue.match(/(\d+)\s*[×xX]\s*(\d+)\s*px?/u);
if (sizeMatch) {
const width = Number(sizeMatch[1]);
const height = Number(sizeMatch[2]);
return `${width}×${height}px\u00a0\u00a0\u00a0${formatAspectRatio(width, height)}`;
}
return normalizedValue
.replace("淘宝主图 / SKU 图 ", "淘宝主图 / SKU 图 ")
.replace("京东主图 / SKU 图 ", "京东主图 / SKU 图 ")
.replace("详情页宽", "详情页宽")
.replace("短视频", "短视频")
.replace("主图", "主图")
.replace("商品主图", "商品主图")
.replace("鍟嗗搧鍥?", "商品图")
.replace(/\s+:/g, ":")
.replace(/:\s+/g, ":");
};
export const getRatioDisplayParts = (value: string) => {
const display = formatRatioDisplayValue(value).replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim();
const aspectMatch = display.match(/(\d+\s*[:]\s*\d+)(?!.*\d+\s*[:]\s*\d+)/u);
const aspect = aspectMatch?.[1]?.replace(/\s+/g, "") ?? "自适应";
const size = aspectMatch ? display.replace(aspectMatch[0], "").trim() : display;
return {
size: size || "原图比例",
aspect,
};
};
/** Extract CSS aspect-ratio from a ratio string like "1000x1000px 1:1" -> "1 / 1" */
export const parseRatioToAspectCss = (ratioStr: string): string => {
const match = ratioStr.match(/(\d+)\D+(\d+)/u);
if (!match) return "1 / 1";
return `${match[1]} / ${match[2]}`;
};
export const supportedImageApiRatios = ["1:1", "3:4", "4:3", "9:16", "16:9"] as const;
export type SupportedImageApiRatio = typeof supportedImageApiRatios[number];
export const toSupportedImageApiRatio = (width: number, height: number): SupportedImageApiRatio => {
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return "1:1";
let bestRatio: SupportedImageApiRatio = "1:1";
let bestScore = Number.POSITIVE_INFINITY;
const target = Math.log(width / height);
for (const ratio of supportedImageApiRatios) {
const [left, right] = ratio.split(":").map(Number);
const score = Math.abs(target - Math.log(left / right));
if (score < bestScore) {
bestRatio = ratio;
bestScore = score;
}
}
return bestRatio;
};
/** Normalize ratio display string ("1000×1000px 1:1") to an image API aspect ratio ("1:1"). */
export const normalizeRatioForApi = (ratioStr: string): string => {
const normalizedValue = normalizeRatioToken(ratioStr);
const explicitRatios = Array.from(normalizedValue.matchAll(/(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)/g));
const explicitRatio = explicitRatios.at(-1);
if (explicitRatio) {
return toSupportedImageApiRatio(Number(explicitRatio[1]), Number(explicitRatio[2]));
}
const sizeMatch = normalizedValue.match(/(\d+(?:\.\d+)?)\s*[×xX*]\s*(\d+(?:\.\d+)?)/u);
if (!sizeMatch) return "1:1";
return toSupportedImageApiRatio(Number(sizeMatch[1]), Number(sizeMatch[2]));
};
+81
View File
@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { classifyTaskError, translateTaskError, type TaskErrorCategory } from "./translateTaskError";
// 每条规则至少一个正例,按规则顺序排列(classifyTaskError 先匹配先返回)。
const RULE_CASES: Array<{ name: string; input: string; category: TaskErrorCategory }> = [
{ name: "content policy", input: "content violated our policies", category: "content_policy" },
{ name: "nsfw", input: "image flagged as nsfw", category: "content_policy" },
{ name: "auth 401", input: "401 Unauthorized", category: "auth_failure" },
{ name: "token expired", input: "token expired", category: "auth_failure" },
{ name: "insufficient balance 402", input: "402 Payment Required", category: "insufficient_balance" },
{ name: "余额不足", input: "余额不足", category: "insufficient_balance" },
{ name: "concurrency pool full", input: "concurrency pool is full", category: "concurrency_busy" },
{ name: "rate limit 429", input: "429 Too Many Requests", category: "concurrency_busy" },
{ name: "unsupported model", input: "model not found", category: "unsupported_model" },
{ name: "invalid asset", input: "invalid image format", category: "invalid_asset" },
{ name: "network ECONNREFUSED", input: "fetch failed: ECONNREFUSED", category: "network_failure" },
{ name: "timeout ETIMEDOUT", input: "ETIMEDOUT", category: "timeout" },
{ name: "quota exceeded", input: "quota exceeded", category: "insufficient_balance" },
{ name: "cancelled", input: "task was cancelled", category: "cancelled" },
{ name: "已取消", input: "任务已取消", category: "cancelled" },
{ name: "all providers failed", input: "all providers failed", category: "concurrency_busy" },
{ name: "500 server error", input: "500 Internal Server Error", category: "network_failure" },
{ name: "forbidden 403", input: "403 Forbidden", category: "auth_failure" },
{ name: "aborted", input: "request aborted", category: "timeout" },
];
describe("classifyTaskError rule coverage", () => {
for (const { name, input, category } of RULE_CASES) {
it(`classifies "${name}" as ${category}`, () => {
const result = classifyTaskError(input);
expect(result.category).toBe(category);
expect(result.message).toBeTruthy();
expect(result.action).toBeTruthy();
});
}
});
describe("classifyTaskError edge cases", () => {
it("returns unknown for empty/null/undefined", () => {
expect(classifyTaskError("").category).toBe("unknown");
expect(classifyTaskError(undefined).category).toBe("unknown");
expect(classifyTaskError(null).category).toBe("unknown");
});
it("returns the raw (truncated) message for unrecognized Chinese errors", () => {
const result = classifyTaskError("这是一条未知的中文错误信息");
expect(result.category).toBe("unknown");
expect(result.message).toContain("未知");
expect(result.message).not.toContain("服务异常");
});
it("truncates long Chinese errors to 80 chars + ellipsis", () => {
const long = "错误".repeat(50);
const result = classifyTaskError(long);
expect(result.message.endsWith("...")).toBe(true);
expect(result.message.length).toBeLessThanOrEqual(83);
});
it("returns generic service message for unrecognized English errors", () => {
const result = classifyTaskError("something completely unexpected");
expect(result.category).toBe("unknown");
expect(result.message).toBe("服务异常,请稍后重试");
});
});
describe("classifyTaskError rule ordering (first match wins)", () => {
it("content_policy beats auth_failure when both patterns present", () => {
// "nsfw" appears before "401" in rule order
const result = classifyTaskError("nsfw content with 401");
expect(result.category).toBe("content_policy");
});
});
describe("translateTaskError", () => {
it("returns the message from classifyTaskError", () => {
expect(translateTaskError("401")).toBe("登录已过期,请重新登录");
});
it("returns generic message for empty input", () => {
expect(translateTaskError("")).toBe("任务失败,请重试");
});
});
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import { resolveHappyHorseRequestModel, HAPPY_HORSE_UI_MODEL, HAPPY_HORSE_T2V_MODEL, HAPPY_HORSE_I2V_MODEL, HAPPY_HORSE_R2V_MODEL } from "./happyHorseRouting";
import { resolveViduRequestModel, VIDU_UI_MODEL, VIDU_T2V_MODEL, VIDU_I2V_MODEL } from "./viduRouting";
import { resolvePixverseRequestModel, PIXVERSE_UI_MODEL, PIXVERSE_T2V_MODEL, PIXVERSE_I2V_MODEL, PIXVERSE_KF2V_MODEL } from "./pixverseRouting";
type ResolveFn = (input: { model: string; referenceUrls?: string[]; imageReferenceCount?: number }) => string;
// 三家路由在参考图数量上的分支差异是回归测试重点。
// HappyHorse: 0->t2v, 1->i2v, >=2->r2v
// Vidu: 0->t2v, >=1->i2v (无 r2v)
// Pixverse: 0->t2v, 1->i2v, >=2->kf2v
describe.each([
{ name: "HappyHorse", resolve: resolveHappyHorseRequestModel, ui: HAPPY_HORSE_UI_MODEL, t2v: HAPPY_HORSE_T2V_MODEL, i2v: HAPPY_HORSE_I2V_MODEL, third: HAPPY_HORSE_R2V_MODEL },
{ name: "Vidu", resolve: resolveViduRequestModel, ui: VIDU_UI_MODEL, t2v: VIDU_T2V_MODEL, i2v: VIDU_I2V_MODEL, third: null },
{ name: "Pixverse", resolve: resolvePixverseRequestModel, ui: PIXVERSE_UI_MODEL, t2v: PIXVERSE_T2V_MODEL, i2v: PIXVERSE_I2V_MODEL, third: PIXVERSE_KF2V_MODEL },
] as Array<{ name: string; resolve: ResolveFn; ui: string; t2v: string; i2v: string; third: string | null }>)(
"$name routing by imageReferenceCount",
({ resolve, ui, t2v, i2v, third }) => {
it("returns the input model unchanged when it is not this provider", () => {
expect(resolve({ model: "some-other-model" })).toBe("some-other-model");
});
it("routes 0 reference images to t2v", () => {
expect(resolve({ model: ui, imageReferenceCount: 0 })).toBe(t2v);
});
it("routes 1 reference image to i2v", () => {
expect(resolve({ model: ui, imageReferenceCount: 1 })).toBe(i2v);
});
if (third) {
it("routes >=2 reference images to the third model", () => {
expect(resolve({ model: ui, imageReferenceCount: 2 })).toBe(third);
expect(resolve({ model: ui, imageReferenceCount: 5 })).toBe(third);
});
} else {
it("routes >=1 reference images to i2v (no third model for this provider)", () => {
expect(resolve({ model: ui, imageReferenceCount: 2 })).toBe(i2v);
expect(resolve({ model: ui, imageReferenceCount: 5 })).toBe(i2v);
});
}
},
);
describe("reference count fallback (referenceUrls when imageReferenceCount omitted)", () => {
it("HappyHorse counts non-empty urls", () => {
expect(
resolveHappyHorseRequestModel({
model: HAPPY_HORSE_UI_MODEL,
referenceUrls: ["", " ", "https://example.com/a.png"],
}),
).toBe(HAPPY_HORSE_I2V_MODEL);
});
it("Vidu falls back to 0 when all urls are empty/whitespace", () => {
expect(
resolveViduRequestModel({
model: VIDU_UI_MODEL,
referenceUrls: ["", " "],
}),
).toBe(VIDU_T2V_MODEL);
});
it("Pixverse counts two non-empty urls as kf2v", () => {
expect(
resolvePixverseRequestModel({
model: PIXVERSE_UI_MODEL,
referenceUrls: ["https://a.png", "https://b.png"],
}),
).toBe(PIXVERSE_KF2V_MODEL);
});
it("imageReferenceCount takes precedence over referenceUrls length", () => {
// Even though referenceUrls has 3 entries, explicit count of 0 wins.
expect(
resolveHappyHorseRequestModel({
model: HAPPY_HORSE_UI_MODEL,
referenceUrls: ["a", "b", "c"],
imageReferenceCount: 0,
}),
).toBe(HAPPY_HORSE_T2V_MODEL);
});
it("handles undefined referenceUrls with undefined count", () => {
expect(resolveViduRequestModel({ model: VIDU_UI_MODEL })).toBe(VIDU_T2V_MODEL);
});
});
+1 -1
View File
@@ -16,5 +16,5 @@
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "vite.config.ts"]
"include": ["src", "vite.config.ts", "vitest.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vitest/config";
// Vitest 配置独立于 vite.config.ts,避免影响 dev/build。
// 本轮只测纯函数(颜色/比例/平台/路由/错误翻译),用 node 环境即可,无需 jsdom。
// 后续要做组件测试时,再在 test.environment 切到 jsdom 并装 @testing-library/react。
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.{test,spec}.{ts,tsx}"],
coverage: {
provider: "v8",
include: ["src/**/*.{ts,tsx}"],
exclude: ["src/**/*.test.*", "src/**/*.spec.*", "src/main.tsx", "src/vite-env.d.ts"],
},
},
});