Files
SnapOtter/tests/unit/web/beautify-preview.test.ts
T
SnapOtter bf01dcd47e fix: make beautify live preview reflect all settings in real time
Background images, device frames, custom shadows, and watermark text
were not rendering in the right-pane preview. The preview now updates
in real time for all settings: gradient/solid/image backgrounds, macOS/
Windows/Browser frame chrome, iPhone/MacBook/iPad frame indicators,
custom shadow parameters, and watermark text overlay.

Also fixes a React StrictMode effect-ordering race where the parent
tool-page reset cleared preview state set by the child Settings
component on initial mount.
2026-05-13 10:28:44 +08:00

196 lines
6.9 KiB
TypeScript

// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
// Import the module source to test the exported helper functions.
// We test buildPreviewStyle and renderFramePreview indirectly by
// re-implementing the pure logic here (they're module-private).
// Instead, we test the CSS output contract directly.
// ---------------------------------------------------------------------------
// hexToRgba (matches the module-private helper)
// ---------------------------------------------------------------------------
function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
describe("hexToRgba", () => {
it("converts black with full opacity", () => {
expect(hexToRgba("#000000", 1)).toBe("rgba(0, 0, 0, 1)");
});
it("converts white with half opacity", () => {
expect(hexToRgba("#ffffff", 0.5)).toBe("rgba(255, 255, 255, 0.5)");
});
it("converts arbitrary color", () => {
expect(hexToRgba("#ff5f57", 0.3)).toBe("rgba(255, 95, 87, 0.3)");
});
});
// ---------------------------------------------------------------------------
// buildPreviewStyle contract tests
// ---------------------------------------------------------------------------
const SHADOW_CSS: Record<string, string> = {
subtle: "0px 4px 20px rgba(0,0,0,0.2)",
medium: "0px 10px 40px rgba(0,0,0,0.35)",
dramatic: "0px 20px 80px rgba(0,0,0,0.5)",
};
function buildPreviewStyle(
settings: Record<string, unknown>,
bgImageUrl?: string | null,
): React.CSSProperties {
const bg = settings.backgroundType as string;
let background: string | undefined;
const extra: React.CSSProperties = {};
if (bg === "solid") {
background = settings.backgroundColor as string;
} else if (bg === "linear-gradient") {
const stops = settings.gradientStops as { color: string; position: number }[];
const angle = settings.gradientAngle as number;
background = `linear-gradient(${angle}deg, ${stops.map((s) => `${s.color} ${s.position}%`).join(", ")})`;
} else if (bg === "radial-gradient") {
const stops = settings.gradientStops as { color: string; position: number }[];
background = `radial-gradient(circle, ${stops.map((s) => `${s.color} ${s.position}%`).join(", ")})`;
} else if (bg === "image" && bgImageUrl) {
extra.backgroundImage = `url("${bgImageUrl}")`;
extra.backgroundSize = "cover";
extra.backgroundPosition = "center";
}
const shadowPreset = settings.shadowPreset as string;
let boxShadow: string | undefined;
if (shadowPreset === "custom") {
const blur = settings.shadowBlur as number;
const ox = settings.shadowOffsetX as number;
const oy = settings.shadowOffsetY as number;
const color = settings.shadowColor as string;
const opacity = (settings.shadowOpacity as number) / 100;
boxShadow = `${ox}px ${oy}px ${blur}px ${hexToRgba(color, opacity)}`;
} else if (shadowPreset !== "none") {
boxShadow = SHADOW_CSS[shadowPreset];
}
const frame = settings.frame as string;
const hasFrame = frame && frame !== "none";
return {
background,
...extra,
padding: `${settings.padding}px`,
borderRadius: hasFrame ? "0px" : `${settings.borderRadius}px`,
boxShadow,
};
}
describe("buildPreviewStyle", () => {
const baseSettings = {
backgroundType: "solid",
backgroundColor: "#ff0000",
gradientStops: [
{ color: "#667eea", position: 0 },
{ color: "#764ba2", position: 100 },
],
gradientAngle: 135,
padding: 64,
borderRadius: 12,
shadowPreset: "subtle",
shadowBlur: 20,
shadowOffsetX: 0,
shadowOffsetY: 10,
shadowColor: "#000000",
shadowOpacity: 30,
frame: "none",
};
it("solid background returns the color", () => {
const style = buildPreviewStyle(baseSettings);
expect(style.background).toBe("#ff0000");
});
it("linear gradient builds CSS gradient string", () => {
const style = buildPreviewStyle({ ...baseSettings, backgroundType: "linear-gradient" });
expect(style.background).toContain("linear-gradient(135deg");
expect(style.background).toContain("#667eea 0%");
expect(style.background).toContain("#764ba2 100%");
});
it("radial gradient builds CSS gradient string", () => {
const style = buildPreviewStyle({ ...baseSettings, backgroundType: "radial-gradient" });
expect(style.background).toContain("radial-gradient(circle");
});
it("image background sets backgroundImage with blob URL", () => {
const style = buildPreviewStyle(
{ ...baseSettings, backgroundType: "image" },
"blob:http://localhost/abc-123",
);
expect(style.backgroundImage).toBe('url("blob:http://localhost/abc-123")');
expect(style.backgroundSize).toBe("cover");
expect(style.backgroundPosition).toBe("center");
expect(style.background).toBeUndefined();
});
it("image background without URL does not set backgroundImage", () => {
const style = buildPreviewStyle({ ...baseSettings, backgroundType: "image" });
expect(style.backgroundImage).toBeUndefined();
});
it("transparent background sets no background", () => {
const style = buildPreviewStyle({ ...baseSettings, backgroundType: "transparent" });
expect(style.background).toBeUndefined();
expect(style.backgroundImage).toBeUndefined();
});
it("padding and borderRadius are set from settings", () => {
const style = buildPreviewStyle(baseSettings);
expect(style.padding).toBe("64px");
expect(style.borderRadius).toBe("12px");
});
it("named shadow presets return correct CSS", () => {
expect(buildPreviewStyle({ ...baseSettings, shadowPreset: "subtle" }).boxShadow).toBe(
SHADOW_CSS.subtle,
);
expect(buildPreviewStyle({ ...baseSettings, shadowPreset: "medium" }).boxShadow).toBe(
SHADOW_CSS.medium,
);
expect(buildPreviewStyle({ ...baseSettings, shadowPreset: "dramatic" }).boxShadow).toBe(
SHADOW_CSS.dramatic,
);
});
it("none shadow returns no boxShadow", () => {
const style = buildPreviewStyle({ ...baseSettings, shadowPreset: "none" });
expect(style.boxShadow).toBeUndefined();
});
it("custom shadow computes CSS from parameters", () => {
const style = buildPreviewStyle({
...baseSettings,
shadowPreset: "custom",
shadowBlur: 30,
shadowOffsetX: 5,
shadowOffsetY: 15,
shadowColor: "#ff0000",
shadowOpacity: 50,
});
expect(style.boxShadow).toBe("5px 15px 30px rgba(255, 0, 0, 0.5)");
});
it("borderRadius is 0px when a frame is present", () => {
const style = buildPreviewStyle({ ...baseSettings, frame: "macos-light" });
expect(style.borderRadius).toBe("0px");
});
it("borderRadius is normal when frame is none", () => {
const style = buildPreviewStyle({ ...baseSettings, frame: "none" });
expect(style.borderRadius).toBe("12px");
});
});