feat: add withPersistence state, change theme state
This commit is contained in:
+21
-8
@@ -1,11 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
const saved = localStorage.getItem("theme");
|
||||
if (saved) {
|
||||
const theme = JSON.parse(saved).theme;
|
||||
if (theme === "dark") {
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
<div class="header-content">
|
||||
<h1>Twitch Panels</h1>
|
||||
<button class="theme-toggle" aria-label="Toggle theme" onclick={toggleTheme}>
|
||||
<Sun />
|
||||
<Moon />
|
||||
<Sun class="sun-icon" />
|
||||
<Moon class="moon-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -66,4 +66,11 @@ export const TextAlign = {
|
||||
export type TextAlignType = (typeof TextAlign)[keyof typeof TextAlign];
|
||||
export const DEFAULT_TEXT_ALIGN: TextAlignType = TextAlign.CENTER;
|
||||
|
||||
export const Theme = {
|
||||
DARK: "dark",
|
||||
LIGHT: "light",
|
||||
} as const;
|
||||
|
||||
export type ThemeType = (typeof Theme)[keyof typeof Theme];
|
||||
|
||||
export const TRANSITION_DURATION = import.meta.env.MODE === "test" ? 0 : 300;
|
||||
|
||||
@@ -10,9 +10,8 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const newTheme = themeState.theme;
|
||||
const newTheme = themeState.current;
|
||||
document.documentElement.setAttribute("data-theme", newTheme);
|
||||
localStorage.setItem("theme", newTheme);
|
||||
});
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
export const STATE_DATA = Symbol("state-data");
|
||||
export const DEBOUNCE_DURATION = import.meta.env.MODE === "test" ? 0 : 500;
|
||||
|
||||
type OnlyData<T> = {
|
||||
[K in keyof T as T[K] extends Function ? never : K]: T[K];
|
||||
};
|
||||
|
||||
export interface Persistable<D> {
|
||||
[STATE_DATA]: D;
|
||||
}
|
||||
|
||||
export function withPersistence<D extends object, T extends Persistable<D>>(
|
||||
key: string,
|
||||
state: T,
|
||||
debounceMs = DEBOUNCE_DURATION,
|
||||
): T {
|
||||
if (!browser) return state;
|
||||
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
|
||||
if (parsed) {
|
||||
state[STATE_DATA] = parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error repairing state for ${key}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
$effect.root(() => {
|
||||
$effect(() => {
|
||||
const data = JSON.stringify($state.snapshot(state[STATE_DATA]));
|
||||
|
||||
if (debounceMs > 0) {
|
||||
const timeout = setTimeout(() => localStorage.setItem(key, data), debounceMs);
|
||||
return () => clearTimeout(timeout);
|
||||
} else {
|
||||
localStorage.setItem(key, data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TextAlignType } from "$lib/constants";
|
||||
import { type TextAlignType } from "$lib/constants";
|
||||
import type { HexColor } from "$lib/types";
|
||||
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
||||
|
||||
export type TextConfig = {
|
||||
fontSize: number;
|
||||
@@ -58,7 +59,25 @@ function createState() {
|
||||
set offsetY(offsetY: number) {
|
||||
state.offsetY = offsetY;
|
||||
},
|
||||
get [STATE_DATA]() {
|
||||
return {
|
||||
fontSize: state.fontSize,
|
||||
fontFamily: state.fontFamily,
|
||||
color: state.color,
|
||||
align: state.align,
|
||||
paddingX: state.paddingX,
|
||||
offsetY: state.offsetY,
|
||||
};
|
||||
},
|
||||
set [STATE_DATA](newConfig: TextConfig) {
|
||||
state.fontSize = newConfig.fontSize;
|
||||
state.fontFamily = newConfig.fontFamily;
|
||||
state.color = newConfig.color;
|
||||
state.align = newConfig.align;
|
||||
state.paddingX = newConfig.paddingX;
|
||||
state.offsetY = newConfig.offsetY;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const textConfigState = createState();
|
||||
export const textConfigState = withPersistence("text-config", createState());
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
||||
|
||||
export interface TextItem {
|
||||
text: string;
|
||||
id: number;
|
||||
@@ -25,7 +27,14 @@ export function createState() {
|
||||
texts = [];
|
||||
nextId = 0;
|
||||
},
|
||||
get [STATE_DATA]() {
|
||||
return texts.map(({ text }) => text);
|
||||
},
|
||||
set [STATE_DATA](newTexts: Array<string>) {
|
||||
texts = newTexts.map((text, idx) => ({ text, id: idx }));
|
||||
nextId = newTexts.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const textsState = createState();
|
||||
export const textsState = withPersistence("texts", createState());
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
export type Theme = "dark" | "light";
|
||||
import { Theme, type ThemeType } from "$lib/constants";
|
||||
import { STATE_DATA, withPersistence } from "./persisted.svelte";
|
||||
|
||||
function createState() {
|
||||
let current: Theme = $state("dark");
|
||||
let current: ThemeType = $state(Theme.LIGHT);
|
||||
|
||||
return {
|
||||
get theme() {
|
||||
get current() {
|
||||
return current;
|
||||
},
|
||||
set theme(value: Theme) {
|
||||
set current(value: ThemeType) {
|
||||
current = value;
|
||||
},
|
||||
toggle() {
|
||||
current = current === "dark" ? "light" : "dark";
|
||||
current = current === Theme.DARK ? Theme.LIGHT : Theme.DARK;
|
||||
},
|
||||
get [STATE_DATA]() {
|
||||
return {
|
||||
current,
|
||||
};
|
||||
},
|
||||
set [STATE_DATA](data: { current: ThemeType }) {
|
||||
current = data.current;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const themeState = createState();
|
||||
export const themeState = withPersistence("theme", createState());
|
||||
|
||||
@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
describe("AppHeader.svelte", () => {
|
||||
beforeEach(() => {
|
||||
themeState.theme = "dark";
|
||||
themeState.current = "dark";
|
||||
});
|
||||
|
||||
it("should toggle theme on button click", async () => {
|
||||
@@ -13,11 +13,11 @@ describe("AppHeader.svelte", () => {
|
||||
|
||||
const toggleButton = screen.getByRole("button", { name: /toggle theme/i });
|
||||
|
||||
themeState.theme = "dark";
|
||||
themeState.current = "dark";
|
||||
await fireEvent.click(toggleButton);
|
||||
expect(themeState.theme).toBe("light");
|
||||
expect(themeState.current).toBe("light");
|
||||
|
||||
await fireEvent.click(toggleButton);
|
||||
expect(themeState.theme).toBe("dark");
|
||||
expect(themeState.current).toBe("dark");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,27 +5,27 @@ import LayoutTest from "./LayoutTest.svelte";
|
||||
|
||||
describe("+layout.svelte", () => {
|
||||
beforeEach(() => {
|
||||
themeState.theme = "dark";
|
||||
themeState.current = "dark";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should apply dark theme", () => {
|
||||
themeState.theme = "dark";
|
||||
themeState.current = "dark";
|
||||
|
||||
expect(themeState.theme).toBe("dark");
|
||||
expect(themeState.current).toBe("dark");
|
||||
});
|
||||
|
||||
it("should apply light theme", () => {
|
||||
themeState.theme = "light";
|
||||
themeState.current = "light";
|
||||
|
||||
expect(themeState.theme).toBe("light");
|
||||
expect(themeState.current).toBe("light");
|
||||
});
|
||||
|
||||
it("should toggle theme", () => {
|
||||
themeState.theme = "dark";
|
||||
themeState.current = "dark";
|
||||
themeState.toggle();
|
||||
|
||||
expect(themeState.theme).toBe("light");
|
||||
expect(themeState.current).toBe("light");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { STATE_DATA, withPersistence } from "$states/persisted.svelte";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
describe("persisted.svelte", () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("withPersistence", () => {
|
||||
it("should return state unchanged when not in browser", () => {
|
||||
const mockState = {
|
||||
[STATE_DATA]: { test: "value" },
|
||||
};
|
||||
const result = withPersistence("test-key", mockState);
|
||||
expect(result).toBe(mockState);
|
||||
});
|
||||
|
||||
it("should restore state from localStorage when valid JSON exists", () => {
|
||||
const savedData = { value: 42, name: "test" };
|
||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(savedData));
|
||||
|
||||
const state = {
|
||||
[STATE_DATA]: { value: 0, name: "" },
|
||||
};
|
||||
|
||||
withPersistence("test-key", state);
|
||||
|
||||
expect(state[STATE_DATA]).toEqual(savedData);
|
||||
expect(localStorageMock.getItem).toHaveBeenCalledWith("test-key");
|
||||
});
|
||||
|
||||
it("should handle corrupted JSON in localStorage gracefully", () => {
|
||||
localStorageMock.getItem.mockReturnValue("{ invalid json");
|
||||
|
||||
const state = {
|
||||
[STATE_DATA]: { value: 0 },
|
||||
};
|
||||
|
||||
// Should not throw and state should remain unchanged
|
||||
expect(() => withPersistence("test-key", state)).not.toThrow();
|
||||
expect(state[STATE_DATA]).toEqual({ value: 0 });
|
||||
});
|
||||
|
||||
it("should handle empty localStorage (no saved data)", () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
|
||||
const state = {
|
||||
[STATE_DATA]: { value: 0 },
|
||||
};
|
||||
|
||||
withPersistence("test-key", state);
|
||||
|
||||
expect(state[STATE_DATA]).toEqual({ value: 0 });
|
||||
});
|
||||
|
||||
it("should save state to localStorage with debounce", async () => {
|
||||
vi.useFakeTimers();
|
||||
const state = {
|
||||
[STATE_DATA]: { count: 1 },
|
||||
};
|
||||
|
||||
withPersistence("test-key", state, 500);
|
||||
|
||||
state[STATE_DATA] = { count: 2 };
|
||||
// Wait for effect to set the debounced timeout
|
||||
await Promise.resolve();
|
||||
|
||||
// Fast-forward time to trigger debounced save
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith("test-key", JSON.stringify({ count: 2 }));
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should save state immediately when debounce is 0", async () => {
|
||||
const state = {
|
||||
[STATE_DATA]: { count: 1 },
|
||||
};
|
||||
|
||||
withPersistence("test-key", state, 0);
|
||||
|
||||
state[STATE_DATA] = { count: 2 };
|
||||
|
||||
// Wait for effect to run
|
||||
await Promise.resolve();
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith("test-key", JSON.stringify({ count: 2 }));
|
||||
});
|
||||
|
||||
it("should cleanup timeout on state change during debounce", async () => {
|
||||
vi.useFakeTimers();
|
||||
// const state = {
|
||||
// [STATE_DATA]: { count: 1 },
|
||||
// };
|
||||
|
||||
const data = $state({ count: 1 });
|
||||
|
||||
const state = {
|
||||
get [STATE_DATA]() {
|
||||
return data;
|
||||
},
|
||||
set [STATE_DATA](v) {
|
||||
data.count = v.count;
|
||||
},
|
||||
};
|
||||
|
||||
withPersistence("test-key", state, 500);
|
||||
|
||||
// First change - sets timeout for count:2
|
||||
state[STATE_DATA] = { count: 2 };
|
||||
// Let effect run and set the timeout
|
||||
await Promise.resolve();
|
||||
|
||||
// Second change before first timeout fires - should clear previous and set new timeout for count:3
|
||||
state[STATE_DATA] = { count: 3 };
|
||||
// Let effect run and clear old timeout, set new one
|
||||
await Promise.resolve();
|
||||
|
||||
// Run only the currently pending timer (the one for count:3)
|
||||
vi.runOnlyPendingTimers();
|
||||
await Promise.resolve();
|
||||
|
||||
// Should only have saved the latest value (count:3)
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledTimes(1);
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith("test-key", JSON.stringify({ count: 3 }));
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should use DEBOUNCE_DURATION constant as default", async () => {
|
||||
const state = {
|
||||
[STATE_DATA]: { test: true },
|
||||
};
|
||||
|
||||
withPersistence("test-key", state);
|
||||
|
||||
// The default debounce should be used (0 in test mode)
|
||||
state[STATE_DATA] = { test: false };
|
||||
|
||||
// Wait for effect to run
|
||||
await Promise.resolve();
|
||||
|
||||
// In test mode, DEBOUNCE_DURATION is 0, so immediate save
|
||||
expect(localStorageMock.setItem).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,235 +1,79 @@
|
||||
import { TextAlign } from "$lib/constants";
|
||||
import type { HexColor } from "$lib/types";
|
||||
import { STATE_DATA } from "$states/persisted.svelte";
|
||||
import { textConfigState } from "$states/textConfig.svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const TEXT_ALIGN_VALUES = Object.values(TextAlign);
|
||||
|
||||
describe("textConfig.svelte", () => {
|
||||
describe("initial state", () => {
|
||||
it("should have default fontSize", () => {
|
||||
it("should have valid initial state structure", () => {
|
||||
expect(typeof textConfigState.fontSize).toBe("number");
|
||||
expect(typeof textConfigState.fontFamily).toBe("string");
|
||||
expect(typeof textConfigState.color).toBe("string");
|
||||
expect(textConfigState.color).toMatch(/^#[0-9a-fA-F]{6}$/);
|
||||
expect(TEXT_ALIGN_VALUES).toContain(textConfigState.align);
|
||||
expect(typeof textConfigState.paddingX).toBe("number");
|
||||
expect(typeof textConfigState.offsetY).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("property setters", () => {
|
||||
it("should update all properties correctly", () => {
|
||||
textConfigState.fontSize = 32;
|
||||
textConfigState.fontFamily = "Roboto";
|
||||
textConfigState.color = "#ff0000" as HexColor;
|
||||
textConfigState.align = TextAlign.LEFT;
|
||||
textConfigState.paddingX = 20;
|
||||
textConfigState.offsetY = -10;
|
||||
|
||||
expect(textConfigState.fontSize).toBe(32);
|
||||
expect(textConfigState.fontFamily).toBe("Roboto");
|
||||
expect(textConfigState.color).toBe("#ff0000");
|
||||
expect(textConfigState.align).toBe(TextAlign.LEFT);
|
||||
expect(textConfigState.paddingX).toBe(20);
|
||||
expect(textConfigState.offsetY).toBe(-10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("STATE_DATA", () => {
|
||||
it("should serialize and deserialize state", () => {
|
||||
// Set custom values
|
||||
textConfigState.fontSize = 18;
|
||||
textConfigState.fontFamily = "Georgia";
|
||||
textConfigState.color = "#00ff00" as HexColor;
|
||||
textConfigState.align = TextAlign.RIGHT;
|
||||
textConfigState.paddingX = 5;
|
||||
textConfigState.offsetY = 15;
|
||||
|
||||
// Get serialized data
|
||||
const data = textConfigState[STATE_DATA];
|
||||
expect(data).toEqual({
|
||||
fontSize: 18,
|
||||
fontFamily: "Georgia",
|
||||
color: "#00ff00",
|
||||
align: TextAlign.RIGHT,
|
||||
paddingX: 5,
|
||||
offsetY: 15,
|
||||
});
|
||||
|
||||
// Restore from serialized data
|
||||
textConfigState[STATE_DATA] = {
|
||||
fontSize: 24,
|
||||
fontFamily: "Arial",
|
||||
color: "#ffffff" as HexColor,
|
||||
align: TextAlign.CENTER,
|
||||
paddingX: 10,
|
||||
offsetY: 0,
|
||||
};
|
||||
|
||||
expect(textConfigState.fontSize).toBe(24);
|
||||
});
|
||||
|
||||
it("should have default fontFamily", () => {
|
||||
expect(textConfigState.fontFamily).toBe("Arial");
|
||||
});
|
||||
|
||||
it("should have default color", () => {
|
||||
expect(textConfigState.color).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("should have default align", () => {
|
||||
expect(textConfigState.align).toBe("center");
|
||||
});
|
||||
|
||||
it("should have default paddingX", () => {
|
||||
expect(textConfigState.align).toBe(TextAlign.CENTER);
|
||||
expect(textConfigState.paddingX).toBe(10);
|
||||
});
|
||||
|
||||
it("should have default offsetY", () => {
|
||||
expect(textConfigState.offsetY).toBe(0);
|
||||
});
|
||||
|
||||
it("should have all required properties", () => {
|
||||
expect(textConfigState).toHaveProperty("fontSize");
|
||||
expect(textConfigState).toHaveProperty("fontFamily");
|
||||
expect(textConfigState).toHaveProperty("color");
|
||||
expect(textConfigState).toHaveProperty("align");
|
||||
expect(textConfigState).toHaveProperty("paddingX");
|
||||
expect(textConfigState).toHaveProperty("offsetY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fontSize", () => {
|
||||
it("should set fontSize", () => {
|
||||
textConfigState.fontSize = 32;
|
||||
expect(textConfigState.fontSize).toBe(32);
|
||||
});
|
||||
|
||||
it("should accept positive values", () => {
|
||||
textConfigState.fontSize = 10;
|
||||
expect(textConfigState.fontSize).toBe(10);
|
||||
|
||||
textConfigState.fontSize = 100;
|
||||
expect(textConfigState.fontSize).toBe(100);
|
||||
});
|
||||
|
||||
it("should accept zero", () => {
|
||||
textConfigState.fontSize = 0;
|
||||
expect(textConfigState.fontSize).toBe(0);
|
||||
});
|
||||
|
||||
it("should accept negative values", () => {
|
||||
textConfigState.fontSize = -10;
|
||||
expect(textConfigState.fontSize).toBe(-10);
|
||||
});
|
||||
|
||||
it("should accept decimal values", () => {
|
||||
textConfigState.fontSize = 16.5;
|
||||
expect(textConfigState.fontSize).toBe(16.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fontFamily", () => {
|
||||
it("should set fontFamily", () => {
|
||||
textConfigState.fontFamily = "Roboto";
|
||||
expect(textConfigState.fontFamily).toBe("Roboto");
|
||||
});
|
||||
|
||||
it("should accept common font families", () => {
|
||||
const fonts = ["Arial", "Helvetica", "Times New Roman", "Georgia", "Verdana"];
|
||||
|
||||
fonts.forEach((font) => {
|
||||
textConfigState.fontFamily = font;
|
||||
expect(textConfigState.fontFamily).toBe(font);
|
||||
});
|
||||
});
|
||||
|
||||
it("should accept empty string", () => {
|
||||
textConfigState.fontFamily = "";
|
||||
expect(textConfigState.fontFamily).toBe("");
|
||||
});
|
||||
|
||||
it("should accept font families with spaces", () => {
|
||||
textConfigState.fontFamily = "Times New Roman";
|
||||
expect(textConfigState.fontFamily).toBe("Times New Roman");
|
||||
});
|
||||
});
|
||||
|
||||
describe("color", () => {
|
||||
it("should set color", () => {
|
||||
textConfigState.color = "#ff0000";
|
||||
expect(textConfigState.color).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("should accept valid hex colors", () => {
|
||||
const colors: Array<HexColor> = ["#ffffff", "#000000", "#ff0000", "#00ff00", "#0000ff", "#123456", "#abcdef"];
|
||||
|
||||
colors.forEach((color) => {
|
||||
textConfigState.color = color;
|
||||
expect(textConfigState.color).toBe(color);
|
||||
});
|
||||
});
|
||||
|
||||
it("should accept hex colors with uppercase", () => {
|
||||
textConfigState.color = "#FFFFFF";
|
||||
expect(textConfigState.color).toBe("#FFFFFF");
|
||||
});
|
||||
|
||||
it("should accept hex colors with mixed case", () => {
|
||||
textConfigState.color = "#FfFfFf";
|
||||
expect(textConfigState.color).toBe("#FfFfFf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("align", () => {
|
||||
it("should set align", () => {
|
||||
textConfigState.align = "left";
|
||||
expect(textConfigState.align).toBe("left");
|
||||
});
|
||||
|
||||
it("should accept left align", () => {
|
||||
textConfigState.align = "left";
|
||||
expect(textConfigState.align).toBe("left");
|
||||
});
|
||||
|
||||
it("should accept center align", () => {
|
||||
textConfigState.align = "center";
|
||||
expect(textConfigState.align).toBe("center");
|
||||
});
|
||||
|
||||
it("should accept right align", () => {
|
||||
textConfigState.align = "right";
|
||||
expect(textConfigState.align).toBe("right");
|
||||
});
|
||||
});
|
||||
|
||||
describe("paddingX", () => {
|
||||
it("should set paddingX", () => {
|
||||
textConfigState.paddingX = 20;
|
||||
expect(textConfigState.paddingX).toBe(20);
|
||||
});
|
||||
|
||||
it("should accept positive values", () => {
|
||||
textConfigState.paddingX = 10;
|
||||
expect(textConfigState.paddingX).toBe(10);
|
||||
|
||||
textConfigState.paddingX = 100;
|
||||
expect(textConfigState.paddingX).toBe(100);
|
||||
});
|
||||
|
||||
it("should accept zero", () => {
|
||||
textConfigState.paddingX = 0;
|
||||
expect(textConfigState.paddingX).toBe(0);
|
||||
});
|
||||
|
||||
it("should accept negative values", () => {
|
||||
textConfigState.paddingX = -10;
|
||||
expect(textConfigState.paddingX).toBe(-10);
|
||||
});
|
||||
|
||||
it("should accept decimal values", () => {
|
||||
textConfigState.paddingX = 15.5;
|
||||
expect(textConfigState.paddingX).toBe(15.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("offsetY", () => {
|
||||
it("should set offsetY", () => {
|
||||
textConfigState.offsetY = 20;
|
||||
expect(textConfigState.offsetY).toBe(20);
|
||||
});
|
||||
|
||||
it("should accept positive values", () => {
|
||||
textConfigState.offsetY = 10;
|
||||
expect(textConfigState.offsetY).toBe(10);
|
||||
|
||||
textConfigState.offsetY = 100;
|
||||
expect(textConfigState.offsetY).toBe(100);
|
||||
});
|
||||
|
||||
it("should accept zero", () => {
|
||||
textConfigState.offsetY = 0;
|
||||
expect(textConfigState.offsetY).toBe(0);
|
||||
});
|
||||
|
||||
it("should accept negative values", () => {
|
||||
textConfigState.offsetY = -10;
|
||||
expect(textConfigState.offsetY).toBe(-10);
|
||||
});
|
||||
|
||||
it("should accept decimal values", () => {
|
||||
textConfigState.offsetY = 15.5;
|
||||
expect(textConfigState.offsetY).toBe(15.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration", () => {
|
||||
it("should maintain independent values", () => {
|
||||
textConfigState.fontSize = 32;
|
||||
textConfigState.fontFamily = "Roboto";
|
||||
textConfigState.color = "#ff0000";
|
||||
textConfigState.align = "left";
|
||||
textConfigState.paddingX = 20;
|
||||
textConfigState.offsetY = -10;
|
||||
|
||||
expect(textConfigState.fontSize).toBe(32);
|
||||
expect(textConfigState.fontFamily).toBe("Roboto");
|
||||
expect(textConfigState.color).toBe("#ff0000");
|
||||
expect(textConfigState.align).toBe("left");
|
||||
expect(textConfigState.paddingX).toBe(20);
|
||||
expect(textConfigState.offsetY).toBe(-10);
|
||||
});
|
||||
|
||||
it("should handle multiple updates", () => {
|
||||
const initialFontSize = textConfigState.fontSize;
|
||||
|
||||
textConfigState.fontSize = 32;
|
||||
expect(textConfigState.fontSize).toBe(32);
|
||||
|
||||
textConfigState.fontSize = 48;
|
||||
expect(textConfigState.fontSize).toBe(48);
|
||||
|
||||
textConfigState.fontSize = initialFontSize;
|
||||
expect(textConfigState.fontSize).toBe(initialFontSize);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,105 +1,95 @@
|
||||
import { STATE_DATA } from "$states/persisted.svelte";
|
||||
import { createState } from "$states/texts.svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("texts.svelte", () => {
|
||||
describe("addText", () => {
|
||||
it("should add new text with unique ID and increment counter", () => {
|
||||
it("should add new text with unique ID", () => {
|
||||
const state = createState();
|
||||
const initialLength = state.texts.length;
|
||||
const initialIds = state.texts.map((item) => item.id);
|
||||
const maxId = Math.max(...initialIds);
|
||||
const newText = "Test text";
|
||||
|
||||
state.addText(newText);
|
||||
state.addText("Test text");
|
||||
|
||||
expect(state.texts).toHaveLength(initialLength + 1);
|
||||
expect(state.texts[initialLength].text).toBe(newText);
|
||||
expect(state.texts[initialLength].id).toBe(maxId + 1);
|
||||
expect(initialIds).not.toContain(state.texts[initialLength].id);
|
||||
expect(state.texts[initialLength].text).toBe("Test text");
|
||||
expect(state.texts[initialLength].id).toBeDefined();
|
||||
});
|
||||
|
||||
it("should not add text with empty string", () => {
|
||||
it("should not add empty text", () => {
|
||||
const state = createState();
|
||||
const initialLength = state.texts.length;
|
||||
|
||||
state.addText("");
|
||||
state.addText(" ");
|
||||
|
||||
expect(state.texts).toHaveLength(initialLength);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeText", () => {
|
||||
it("should remove text by ID and preserve other texts", () => {
|
||||
it("should remove text by ID", () => {
|
||||
const state = createState();
|
||||
const idToRemove = state.texts[0].id;
|
||||
const initialLength = state.texts.length;
|
||||
const idToRemove = state.texts[1].id;
|
||||
const otherTexts = state.texts.filter((item) => item.id !== idToRemove);
|
||||
|
||||
state.removeText(idToRemove);
|
||||
|
||||
expect(state.texts).toHaveLength(initialLength - 1);
|
||||
expect(state.texts.find((item) => item.id === idToRemove)).toBeUndefined();
|
||||
expect(state.texts).toStrictEqual(otherTexts);
|
||||
});
|
||||
|
||||
it("should not remove text with non-existent ID", () => {
|
||||
it("should not affect other texts when removing", () => {
|
||||
const state = createState();
|
||||
const initialLength = state.texts.length;
|
||||
const initialTexts = [...state.texts];
|
||||
const nonExistentId = 999999;
|
||||
const remainingTexts = state.texts.filter((item) => item.id !== state.texts[0].id);
|
||||
const idToRemove = state.texts[0].id;
|
||||
|
||||
state.removeText(nonExistentId);
|
||||
state.removeText(idToRemove);
|
||||
|
||||
expect(state.texts).toHaveLength(initialLength);
|
||||
expect(state.texts).toStrictEqual(initialTexts);
|
||||
expect(state.texts).toStrictEqual(remainingTexts);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("should clear all texts", () => {
|
||||
it("should remove all texts", () => {
|
||||
const state = createState();
|
||||
state.addText("Test 1");
|
||||
state.addText("Test 2");
|
||||
expect(state.texts.length).toBeGreaterThanOrEqual(2);
|
||||
state.addText("Test 3");
|
||||
|
||||
state.clear();
|
||||
|
||||
expect(state.texts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration", () => {
|
||||
it("should maintain ID uniqueness after multiple operations", () => {
|
||||
describe("STATE_DATA", () => {
|
||||
it("should serialize and deserialize texts", () => {
|
||||
const state = createState();
|
||||
const initialIds = new Set(state.texts.map((item) => item.id));
|
||||
|
||||
state.clear();
|
||||
state.addText("First");
|
||||
state.addText("Second");
|
||||
state.removeText(state.texts[0].id);
|
||||
state.addText("Third");
|
||||
|
||||
const finalIds = state.texts.map((item) => item.id);
|
||||
const uniqueIds = new Set(finalIds);
|
||||
const data = state[STATE_DATA];
|
||||
expect(data).toEqual(["First", "Second", "Third"]);
|
||||
|
||||
expect(finalIds.length).toBe(uniqueIds.size);
|
||||
// Restore from serialized data
|
||||
state[STATE_DATA] = ["New 1", "New 2"];
|
||||
|
||||
expect(state.texts).toHaveLength(2);
|
||||
expect(state.texts[0].text).toBe("New 1");
|
||||
expect(state.texts[1].text).toBe("New 2");
|
||||
expect(state.texts[0].id).toBe(0);
|
||||
expect(state.texts[1].id).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle adding and removing multiple texts", () => {
|
||||
it("should handle empty array", () => {
|
||||
const state = createState();
|
||||
const initialLength = state.texts.length;
|
||||
const addedIds: number[] = [];
|
||||
state.addText("Test");
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
state.addText(`Text ${i}`);
|
||||
addedIds.push(state.texts[state.texts.length - 1].id);
|
||||
}
|
||||
state[STATE_DATA] = [];
|
||||
|
||||
expect(state.texts).toHaveLength(initialLength + 5);
|
||||
|
||||
state.removeText(addedIds[0]);
|
||||
state.removeText(addedIds[2]);
|
||||
state.removeText(addedIds[4]);
|
||||
|
||||
expect(state.texts).toHaveLength(initialLength + 2);
|
||||
expect(state.texts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,49 +1,45 @@
|
||||
import { Theme } from "$lib/constants";
|
||||
import { STATE_DATA } from "$states/persisted.svelte";
|
||||
import { themeState } from "$states/theme.svelte";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const THEME_VALUES = Object.values(Theme);
|
||||
|
||||
describe("theme.svelte", () => {
|
||||
describe("initial state", () => {
|
||||
it("should have initial theme", () => {
|
||||
expect(themeState.theme).toBeDefined();
|
||||
expect(["light", "dark"]).toContain(themeState.theme);
|
||||
});
|
||||
|
||||
it("should start with dark theme by default", () => {
|
||||
expect(themeState.theme).toBe("dark");
|
||||
it("should have valid initial theme value", () => {
|
||||
expect(THEME_VALUES).toContain(themeState.current);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggle", () => {
|
||||
it("should toggle between light and dark themes", () => {
|
||||
const initialTheme = themeState.theme;
|
||||
it("should toggle between themes", () => {
|
||||
themeState.current = Theme.LIGHT;
|
||||
themeState.toggle();
|
||||
expect(themeState.current).toBe(Theme.DARK);
|
||||
|
||||
themeState.toggle();
|
||||
expect(themeState.theme).not.toBe(initialTheme);
|
||||
|
||||
themeState.toggle();
|
||||
expect(themeState.theme).toBe(initialTheme);
|
||||
});
|
||||
|
||||
it("should switch from dark to light", () => {
|
||||
themeState.theme = "dark";
|
||||
themeState.toggle();
|
||||
expect(themeState.theme).toBe("light");
|
||||
});
|
||||
|
||||
it("should switch from light to dark", () => {
|
||||
themeState.theme = "light";
|
||||
themeState.toggle();
|
||||
expect(themeState.theme).toBe("dark");
|
||||
expect(themeState.current).toBe(Theme.LIGHT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("theme getter", () => {
|
||||
it("should return current theme", () => {
|
||||
themeState.theme = "dark";
|
||||
expect(themeState.theme).toBe("dark");
|
||||
describe("current setter", () => {
|
||||
it("should set theme correctly", () => {
|
||||
themeState.current = Theme.DARK;
|
||||
expect(themeState.current).toBe(Theme.DARK);
|
||||
|
||||
themeState.theme = "light";
|
||||
expect(themeState.theme).toBe("light");
|
||||
themeState.current = Theme.LIGHT;
|
||||
expect(themeState.current).toBe(Theme.LIGHT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("STATE_DATA", () => {
|
||||
it("should serialize and deserialize theme", () => {
|
||||
themeState.current = Theme.DARK;
|
||||
expect(themeState[STATE_DATA]).toEqual({ current: Theme.DARK });
|
||||
|
||||
themeState[STATE_DATA] = { current: Theme.LIGHT };
|
||||
expect(themeState.current).toBe(Theme.LIGHT);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user