test: 引入 Vitest 测试骨架并抽出颜色/比例纯函数模块
This commit is contained in:
Generated
+3011
File diff suppressed because it is too large
Load Diff
+10
-4
@@ -7,20 +7,26 @@
|
|||||||
"dev": "vite --host 127.0.0.1",
|
"dev": "vite --host 127.0.0.1",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --host 127.0.0.1",
|
"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": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "5.3.0",
|
"@ant-design/icons": "5.3.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
|
"scheduler": "0.23.0",
|
||||||
"zustand": "5.0.13"
|
"zustand": "5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "18.2.0",
|
"@types/react": "18.2.55",
|
||||||
"@types/react-dom": "18.2.0",
|
"@types/react-dom": "18.2.18",
|
||||||
"@vitejs/plugin-react": "4.2.1",
|
"@vitejs/plugin-react": "4.2.1",
|
||||||
|
"@vitest/coverage-v8": "^1.6.0",
|
||||||
"typescript": "5.3.3",
|
"typescript": "5.3.3",
|
||||||
"vite": "5.1.0",
|
"vite": "5.1.0",
|
||||||
"vite-plugin-compression2": "2.5.3"
|
"vite-plugin-compression2": "2.5.3",
|
||||||
|
"vitest": "^1.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel";
|
|||||||
import EcommerceClonePanel from "./panels/EcommerceClonePanel";
|
import EcommerceClonePanel from "./panels/EcommerceClonePanel";
|
||||||
import { ecommerceOssScopes, saveUnifiedEcommerceGenerationRecord, deleteEcommerceGenerationRecord } from "./ecommerceGenerationPersistence";
|
import { ecommerceOssScopes, saveUnifiedEcommerceGenerationRecord, deleteEcommerceGenerationRecord } from "./ecommerceGenerationPersistence";
|
||||||
import { downloadResultAsset } from "../workbench/workbenchDownload";
|
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 = [
|
const smartCutoutColorPresets = [
|
||||||
"#ffffff",
|
"#ffffff",
|
||||||
@@ -166,92 +169,6 @@ const buildInspirationPrompt = (title: string, meta: string): string => {
|
|||||||
return points.length ? `${base}。风格要点:${points.join("、")}。` : `${base}。`;
|
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 { ServerRequestError } from "../../api/serverConnection";
|
||||||
import { waitForTask } from "../../api/taskSubscription";
|
import { waitForTask } from "../../api/taskSubscription";
|
||||||
import { toast } from "../../components/toast/toastStore";
|
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 getPlatformRatioOptions = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).ratios;
|
||||||
const getPlatformDefaultRatio = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).defaultRatio;
|
const getPlatformDefaultRatio = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).defaultRatio;
|
||||||
const getUniqueRatioOptions = (ratios: string[]) => Array.from(new Set(ratios));
|
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 normalizeRatioForPlatform = (platformValue: string, ratioValue: string, mode?: PlatformRatioModeKey) => {
|
||||||
const platformRatios = getPlatformRatioOptions(platformValue, mode);
|
const platformRatios = getPlatformRatioOptions(platformValue, mode);
|
||||||
if (platformRatios.includes(ratioValue)) return ratioValue;
|
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));
|
const matchedRatio = platformRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio));
|
||||||
return matchedRatio ?? getPlatformDefaultRatio(platformValue, mode);
|
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) => {
|
const formatUploadedImageRatio = (image?: CloneImageItem) => {
|
||||||
if (!image) return null;
|
if (!image) return null;
|
||||||
const format = image.format ? `\u00a0\u00a0\u00a0${image.format}` : "";
|
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("1:1")).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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]));
|
||||||
|
};
|
||||||
@@ -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("任务失败,请重试");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -16,5 +16,5 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx"
|
||||||
},
|
},
|
||||||
"include": ["src", "vite.config.ts"]
|
"include": ["src", "vite.config.ts", "vitest.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user