Files
ttstd-infinite-canvas/web/test/canvas-appearance.test.ts

194 lines
8.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { DEFAULT_CANVAS_COLOR_THEME } from "../src/lib/canvas-theme";
import {
DEFAULT_CANVAS_BACKGROUND_MODE,
canvasAppearanceForTheme,
customCanvasAppearanceFromTheme,
enterCustomCanvasAppearance,
normalizeCanvasAppearance,
normalizeHexColor,
readCanvasAppearanceDefault,
resolveCanvasAppearance,
resolveCanvasGridColor,
writeCanvasAppearanceDefault,
} from "../src/lib/canvas/canvas-appearance";
const values = new Map<string, string>();
let originalWindow: PropertyDescriptor | undefined;
beforeEach(() => {
originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
values.clear();
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
localStorage: {
getItem: (key: string) => values.get(key) || null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
},
},
});
});
afterEach(() => {
if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow);
else Reflect.deleteProperty(globalThis, "window");
});
describe("canvas custom appearance", () => {
test("uses point grid as the default for new canvases", () => {
expect(DEFAULT_CANVAS_BACKGROUND_MODE).toBe("dots");
expect(DEFAULT_CANVAS_COLOR_THEME).toBe("dark");
});
test("resolves the black preset to a black background and fully opaque black grid", () => {
const appearance = canvasAppearanceForTheme(DEFAULT_CANVAS_COLOR_THEME);
expect(resolveCanvasAppearance(appearance, "light")).toEqual({
baseTheme: "dark",
background: "#000000",
grid: "#000000",
});
expect(resolveCanvasGridColor(appearance, "light", "dots")).toBe("#000000");
expect(resolveCanvasAppearance(customCanvasAppearanceFromTheme("dark"), "light")).toEqual({
baseTheme: "dark",
background: "#000000",
grid: "rgba(0,0,0,1)",
});
});
test("selecting either fixed theme resets the spatial grid to dots", async () => {
const source = await Bun.file(new URL("../src/components/canvas/canvas-appearance-controls.tsx", import.meta.url)).text();
const fixedThemeSource = source.slice(source.indexOf("const selectFixedTheme"), source.indexOf("const selectCustomTheme"));
expect(fixedThemeSource).toContain("onBackgroundModeChange(DEFAULT_CANVAS_BACKGROUND_MODE)");
expect(resolveCanvasGridColor(canvasAppearanceForTheme("light"), "dark", "dots")).toBe("rgba(0,0,0,.80)");
});
test("inherits the active fixed theme the first time custom mode is selected", () => {
const light = enterCustomCanvasAppearance(canvasAppearanceForTheme("light"), "light");
expect(light).toEqual({
mode: "custom",
custom: {
baseTheme: "light",
backgroundColor: "#F0F0F0",
backgroundBrightness: 0,
gridColor: "#000000",
gridOpacity: 80,
},
});
const dark = enterCustomCanvasAppearance(canvasAppearanceForTheme("dark"), "dark");
expect(dark.custom).toMatchObject({ baseTheme: "dark", backgroundColor: "#000000", backgroundBrightness: 0, gridColor: "#000000", gridOpacity: 100 });
});
test("restores a previous custom profile only under the same base theme", () => {
const previous = customCanvasAppearanceFromTheme("light");
previous.custom = { ...previous.custom!, backgroundColor: "#F3DCE5" };
const fixedLight = canvasAppearanceForTheme("light", previous);
expect(enterCustomCanvasAppearance(fixedLight, "light").custom).toEqual(previous.custom);
const fixedDark = canvasAppearanceForTheme("dark", previous);
expect(fixedDark.custom).toBeUndefined();
expect(enterCustomCanvasAppearance(fixedDark, "dark").custom).toMatchObject({
baseTheme: "dark",
backgroundColor: "#000000",
});
});
test("keeps color-picker popups inside the appearance interaction boundary", async () => {
const toolbarSource = await Bun.file(new URL("../src/components/canvas/canvas-toolbar.tsx", import.meta.url)).text();
const controlsSource = await Bun.file(new URL("../src/components/canvas/canvas-appearance-controls.tsx", import.meta.url)).text();
expect(toolbarSource).toContain('closest(".ant-color-picker,.ant-popover")');
expect(controlsSource).toContain("if (normalized) onChange(normalized)");
});
test("commits custom colors immediately instead of discarding them when the panel closes", async () => {
const controlsSource = await Bun.file(new URL("../src/components/canvas/canvas-appearance-controls.tsx", import.meta.url)).text();
const updateCustomSource = controlsSource.slice(
controlsSource.indexOf("const updateCustom"),
controlsSource.indexOf("const resetCustom"),
);
expect(updateCustomSource).toContain("onAppearanceChange(next)");
expect(updateCustomSource).not.toContain("onAppearancePreviewChange(next)");
});
test("keeps hex editing live across parent updates and common hex formats", async () => {
expect(normalizeHexColor("F3DCE5")).toBe("#F3DCE5");
expect(normalizeHexColor("#f3d")).toBe("#FF33DD");
const controlsSource = await Bun.file(new URL("../src/components/canvas/canvas-appearance-controls.tsx", import.meta.url)).text();
expect(controlsSource).toContain("if (!editing) setTextValue(value)");
expect(controlsSource).toContain("updateCustom({ backgroundColor, backgroundBrightness: 0 })");
});
test("adjusts only the custom canvas substrate and grid", () => {
const appearance = normalizeCanvasAppearance({
mode: "custom",
custom: {
baseTheme: "light",
backgroundColor: "#F3DCE5",
backgroundBrightness: 0,
backgroundOpacity: 10,
gridColor: "#9D7182",
gridOpacity: 22,
},
}, "dark");
const resolved = resolveCanvasAppearance(appearance, "dark");
expect(resolved.baseTheme).toBe("light");
expect(resolved.background).toBe("#F3DCE5");
expect(resolveCanvasGridColor(appearance, "dark", "lines")).toBe("rgba(157,113,130,0.22)");
appearance.custom!.backgroundBrightness = 10;
const brighter = resolveCanvasAppearance(appearance, "dark").background;
appearance.custom!.backgroundBrightness = -10;
const darker = resolveCanvasAppearance(appearance, "dark").background;
const brighterParts = brighter.match(/^oklch\(([\d.]+)% ([\d.]+) ([\d.]+)\)$/);
const darkerParts = darker.match(/^oklch\(([\d.]+)% ([\d.]+) ([\d.]+)\)$/);
expect(brighterParts).not.toBeNull();
expect(darkerParts).not.toBeNull();
expect(Number(brighterParts![1])).toBeGreaterThan(Number(darkerParts![1]));
expect(brighterParts!.slice(2)).toEqual(darkerParts!.slice(2));
});
test("switches the custom interface style without changing custom canvas colors", () => {
const appearance = customCanvasAppearanceFromTheme("dark");
appearance.custom = {
...appearance.custom!,
baseTheme: "light",
backgroundColor: "#F3DCE5",
gridColor: "#9D7182",
gridOpacity: 22,
};
expect(resolveCanvasAppearance(appearance, "dark")).toEqual({
baseTheme: "light",
background: "#F3DCE5",
grid: "rgba(157,113,130,0.22)",
});
});
test("does not expose background opacity after legacy values are ignored", async () => {
const controlsSource = await Bun.file(new URL("../src/components/canvas/canvas-appearance-controls.tsx", import.meta.url)).text();
expect(controlsSource).toContain('const LIGHT_PRESETS = ["#F0F0F0"');
expect(controlsSource).toContain('const DARK_PRESETS = ["#000000"');
expect(controlsSource).not.toContain('label="背景透明度"');
expect(controlsSource).toContain('aria-label="界面样式"');
expect(controlsSource).toContain('label="网格强度"');
});
test("stores defaults locally with the active account scope", () => {
window.localStorage.setItem("infinite-canvas:active-user-scope", "account-A");
const value = { appearance: customCanvasAppearanceFromTheme("dark"), backgroundMode: "lines" as const };
writeCanvasAppearanceDefault(value);
expect(values.has("infinite-canvas:canvas-appearance-default:user:account-A")).toBe(true);
expect(readCanvasAppearanceDefault()).toEqual(value);
window.localStorage.setItem("infinite-canvas:active-user-scope", "account-B");
expect(readCanvasAppearanceDefault()).toBeNull();
});
});