test: 引入 Vitest 测试骨架并抽出颜色/比例纯函数模块

This commit is contained in:
2026-06-15 13:39:02 +08:00
parent 307537a7ce
commit 45e6534ee1
12 changed files with 3773 additions and 199 deletions
+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]));
};