From 9e1eeb57bc24ef10b03f2369bae19e8e40d0d67a Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Tue, 10 Feb 2026 02:37:02 +0500 Subject: [PATCH] feat: add withPersistence state, change theme state --- src/app.html | 29 +- src/components/layout/AppHeader.svelte | 4 +- src/lib/constants.ts | 7 + src/routes/+layout.svelte | 3 +- src/states/persisted.svelte.ts | 48 +++ src/states/textConfig.svelte.ts | 23 +- src/states/texts.svelte.ts | 11 +- src/states/theme.svelte.ts | 21 +- .../unit/components/layout/AppHeader.test.ts | 8 +- tests/unit/routes/layout.test.ts | 14 +- tests/unit/states/persisted.svelte.test.ts | 176 +++++++++++ tests/unit/states/textConfig.test.ts | 286 ++++-------------- tests/unit/states/texts.test.ts | 78 +++-- tests/unit/states/theme.test.ts | 58 ++-- 14 files changed, 438 insertions(+), 328 deletions(-) create mode 100644 src/states/persisted.svelte.ts create mode 100644 tests/unit/states/persisted.svelte.test.ts diff --git a/src/app.html b/src/app.html index f273cc5..258219d 100644 --- a/src/app.html +++ b/src/app.html @@ -1,11 +1,24 @@ - - - - %sveltekit.head% - - -
%sveltekit.body%
- + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ diff --git a/src/components/layout/AppHeader.svelte b/src/components/layout/AppHeader.svelte index 12f97a9..f21de50 100644 --- a/src/components/layout/AppHeader.svelte +++ b/src/components/layout/AppHeader.svelte @@ -12,8 +12,8 @@

Twitch Panels

diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 018d72f..68ea78f 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 98e58dc..8326c5e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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(); diff --git a/src/states/persisted.svelte.ts b/src/states/persisted.svelte.ts new file mode 100644 index 0000000..5d590c8 --- /dev/null +++ b/src/states/persisted.svelte.ts @@ -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 = { + [K in keyof T as T[K] extends Function ? never : K]: T[K]; +}; + +export interface Persistable { + [STATE_DATA]: D; +} + +export function withPersistence>( + 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; +} diff --git a/src/states/textConfig.svelte.ts b/src/states/textConfig.svelte.ts index 511c048..8002220 100644 --- a/src/states/textConfig.svelte.ts +++ b/src/states/textConfig.svelte.ts @@ -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()); diff --git a/src/states/texts.svelte.ts b/src/states/texts.svelte.ts index 04fbf68..8978d6a 100644 --- a/src/states/texts.svelte.ts +++ b/src/states/texts.svelte.ts @@ -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) { + texts = newTexts.map((text, idx) => ({ text, id: idx })); + nextId = newTexts.length; + }, }; } -export const textsState = createState(); +export const textsState = withPersistence("texts", createState()); diff --git a/src/states/theme.svelte.ts b/src/states/theme.svelte.ts index 29f297a..6e9440c 100644 --- a/src/states/theme.svelte.ts +++ b/src/states/theme.svelte.ts @@ -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()); diff --git a/tests/unit/components/layout/AppHeader.test.ts b/tests/unit/components/layout/AppHeader.test.ts index 5c15c72..5efd1a0 100644 --- a/tests/unit/components/layout/AppHeader.test.ts +++ b/tests/unit/components/layout/AppHeader.test.ts @@ -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"); }); }); diff --git a/tests/unit/routes/layout.test.ts b/tests/unit/routes/layout.test.ts index 0a43660..1afff3a 100644 --- a/tests/unit/routes/layout.test.ts +++ b/tests/unit/routes/layout.test.ts @@ -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"); }); }); diff --git a/tests/unit/states/persisted.svelte.test.ts b/tests/unit/states/persisted.svelte.test.ts new file mode 100644 index 0000000..e0c9ef6 --- /dev/null +++ b/tests/unit/states/persisted.svelte.test.ts @@ -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 = {}; + 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(); + }); + }); +}); diff --git a/tests/unit/states/textConfig.test.ts b/tests/unit/states/textConfig.test.ts index 71da0f9..0f7f3a4 100644 --- a/tests/unit/states/textConfig.test.ts +++ b/tests/unit/states/textConfig.test.ts @@ -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 = ["#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); - }); }); }); diff --git a/tests/unit/states/texts.test.ts b/tests/unit/states/texts.test.ts index 2fe7d05..50f2990 100644 --- a/tests/unit/states/texts.test.ts +++ b/tests/unit/states/texts.test.ts @@ -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); }); }); }); diff --git a/tests/unit/states/theme.test.ts b/tests/unit/states/theme.test.ts index 7f7e2d6..44445c0 100644 --- a/tests/unit/states/theme.test.ts +++ b/tests/unit/states/theme.test.ts @@ -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); }); }); });