fix: update persisted state workflow

This commit is contained in:
2026-02-19 08:05:33 +05:00
parent 8fccc1e36b
commit 833cc3d086
15 changed files with 216 additions and 205 deletions
+2 -1
View File
@@ -31,7 +31,8 @@
{width} {width}
{height} {height}
fontSize={textConfigState.fontSize} fontSize={textConfigState.fontSize}
fill={textConfigState.color} stroke={textConfigState.color}
fill="transparent"
fontFamily={textConfigState.fontFamily} fontFamily={textConfigState.fontFamily}
align={textConfigState.align} align={textConfigState.align}
wrap="none" wrap="none"
+2 -2
View File
@@ -40,8 +40,8 @@
<SettingsRow label="Смещение"> <SettingsRow label="Смещение">
<RangeSlider <RangeSlider
bind:value={textConfigState.offsetY} bind:value={textConfigState.offsetY}
min={TYPOGRAPHY.VERTICAL_OFFSET_MIN} min={TYPOGRAPHY.OFFSET_Y_MIN}
max={TYPOGRAPHY.VERTICAL_OFFSET_MAX} max={TYPOGRAPHY.OFFSET_Y_MAX}
step={1} step={1}
/> />
</SettingsRow> </SettingsRow>
+4 -3
View File
@@ -35,8 +35,9 @@ export const TYPOGRAPHY = {
PADDING_X_MAX: 100, PADDING_X_MAX: 100,
// Vertical offset for range inputs // Vertical offset for range inputs
VERTICAL_OFFSET_MAX: 100, OFFSET_Y_MAX: 100,
VERTICAL_OFFSET_MIN: -100, OFFSET_Y_MIN: -100,
OFFSET_Y_DEFAULT: 0,
// Colors // Colors
TEXT_COLOR_DEFAULT: "#ffffff", TEXT_COLOR_DEFAULT: "#ffffff",
@@ -82,7 +83,7 @@ export const TextAlign = {
export type TextAlignType = (typeof TextAlign)[keyof typeof TextAlign]; export type TextAlignType = (typeof TextAlign)[keyof typeof TextAlign];
export const DEFAULT_TEXT_ALIGN: TextAlignType = TextAlign.CENTER; export const TEXT_ALIGN_DEFAULT: TextAlignType = TextAlign.CENTER;
export const Theme = { export const Theme = {
DARK: "dark", DARK: "dark",
+5 -5
View File
@@ -1,14 +1,14 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
export const STATE_DATA = Symbol("state-data");
// eslint-disable-next-line @typescript-eslint/no-magic-numbers // eslint-disable-next-line @typescript-eslint/no-magic-numbers
export const DEBOUNCE_DURATION = import.meta.env.MODE === "test" ? 0 : 500; export const DEBOUNCE_DURATION = import.meta.env.MODE === "test" ? 0 : 500;
export interface Persistable<D> { export interface Persistable<D> {
[STATE_DATA]: D; toSnapshot(): D;
fromSnapshot(data: Partial<D>): void;
} }
export function withPersistence<D extends object, T extends Persistable<D>>( export function withPersistence<D, T extends Persistable<D>>(
key: string, key: string,
state: T, state: T,
debounceMs = DEBOUNCE_DURATION, debounceMs = DEBOUNCE_DURATION,
@@ -21,7 +21,7 @@ export function withPersistence<D extends object, T extends Persistable<D>>(
const parsed = JSON.parse(saved); const parsed = JSON.parse(saved);
if (parsed) { if (parsed) {
state[STATE_DATA] = parsed; state.fromSnapshot(parsed);
} }
} catch (e) { } catch (e) {
console.error(`Error repairing state for ${key}`, e); console.error(`Error repairing state for ${key}`, e);
@@ -30,7 +30,7 @@ export function withPersistence<D extends object, T extends Persistable<D>>(
$effect.root(() => { $effect.root(() => {
$effect(() => { $effect(() => {
const data = JSON.stringify($state.snapshot(state[STATE_DATA])); const data = JSON.stringify($state.snapshot(state.toSnapshot()));
if (debounceMs > 0) { if (debounceMs > 0) {
const timeout = setTimeout(() => localStorage.setItem(key, data), debounceMs); const timeout = setTimeout(() => localStorage.setItem(key, data), debounceMs);
+42 -70
View File
@@ -1,83 +1,55 @@
import { type TextAlignType } from "$lib/constants"; import { TEXT_ALIGN_DEFAULT, TYPOGRAPHY, type TextAlignType } from "$lib/constants";
import type { HexColor } from "$lib/types"; import type { HexColor } from "$lib/types";
import { STATE_DATA, withPersistence } from "./persisted.svelte"; import { withPersistence, type Persistable } from "./persisted.svelte";
export type TextConfig = { export type TextConfigDTO = {
fontSize: number; fontSize: number;
fontFamily: string; fontFamily: string;
color: HexColor; color: HexColor;
align: TextAlignType; align: TextAlignType;
paddingX: number; paddingX: number;
offsetY: number; offsetY: number;
outlined: boolean;
}; };
function createState() { const textConfigKeys: (keyof TextConfigDTO)[] = [
const defaults: TextConfig = { "fontSize",
fontSize: 24, "fontFamily",
fontFamily: "Arial", "color",
color: "#ffffff", "align",
align: "center", "paddingX",
paddingX: 10, "offsetY",
offsetY: 0, "outlined",
}; ];
const state: TextConfig = $state({ ...defaults });
return { export class TextConfigState implements Persistable<TextConfigDTO> {
get fontSize() { fontSize: number = $state(TYPOGRAPHY.FONT_SIZE_DEFAULT);
return state.fontSize; fontFamily: string = $state(TYPOGRAPHY.FONT_FAMILY_DEFAULT);
}, color: HexColor = $state(TYPOGRAPHY.TEXT_COLOR_DEFAULT);
set fontSize(value: number) { align: TextAlignType = $state(TEXT_ALIGN_DEFAULT);
state.fontSize = value; paddingX: number = $state(TYPOGRAPHY.PADDING_X_DEFAULT);
}, offsetY: number = $state(TYPOGRAPHY.OFFSET_Y_DEFAULT);
get fontFamily() { outlined: boolean = $state(false);
return state.fontFamily;
}, toSnapshot(): TextConfigDTO {
set fontFamily(fontFamily: string) { return {
state.fontFamily = fontFamily; fontSize: this.fontSize,
}, fontFamily: this.fontFamily,
get color() { color: this.color,
return state.color; align: this.align,
}, paddingX: this.paddingX,
set color(color: HexColor) { offsetY: this.offsetY,
state.color = color; outlined: this.outlined,
}, };
get align() { }
return state.align;
}, fromSnapshot(data: Partial<TextConfigDTO>): void {
set align(align: TextAlignType) { for (const key of textConfigKeys) {
state.align = align; if (key in data && data[key] !== undefined) {
}, (this as TextConfigDTO)[key] = data[key] as never;
get paddingX() { }
return state.paddingX; }
}, }
set paddingX(paddingX: number) {
state.paddingX = paddingX;
},
get offsetY() {
return state.offsetY;
},
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 = withPersistence("text-config", createState()); export const textConfigState = withPersistence("text-config", new TextConfigState());
+40 -33
View File
@@ -1,43 +1,50 @@
import { STATE_DATA, withPersistence } from "./persisted.svelte"; import { withPersistence, type Persistable } from "./persisted.svelte";
export interface TextItem { export interface TextItem {
text: string; text: string;
id: number; id: number;
} }
const defaultTexts: Array<TextItem> = ["About me", "Links", "Projects"].map((text, idx) => ({ export type TextState = Array<TextItem>;
text, export type TextStateDTO = Array<string>;
id: idx,
}));
export function createState() { const defaultTexts: TextStateDTO = ["About me", "Links", "Projects"];
let texts: Array<TextItem> = $state(defaultTexts);
let nextId = $state(defaultTexts.length);
return { export class TextsState implements Persistable<TextStateDTO> {
get texts() { #texts: Array<TextItem> = $state(this.fromDTO(defaultTexts));
return texts; #nextId = $state(defaultTexts.length);
},
addText(text: string) { get texts() {
if (text.trim().length === 0) return; return this.#texts;
texts.push({ text, id: nextId }); }
nextId++;
}, addText(text: string) {
removeText(id: number) { if (text.trim().length === 0) return;
texts = texts.filter((textItem) => textItem.id !== id); this.#texts.push({ text, id: this.#nextId });
}, this.#nextId++;
clear() { }
texts = [];
nextId = 0; removeText(id: number) {
}, this.#texts = this.#texts.filter((textItem) => textItem.id !== id);
get [STATE_DATA]() { }
return texts.map(({ text }) => text);
}, clear() {
set [STATE_DATA](newTexts: Array<string>) { this.#texts = [];
texts = newTexts.map((text, idx) => ({ text, id: idx })); this.#nextId = 0;
nextId = newTexts.length; }
},
}; toSnapshot(): TextStateDTO {
return this.#texts.map(({ text }) => text);
}
fromSnapshot(data: TextStateDTO) {
this.#texts = this.fromDTO(data);
this.#nextId = data.length;
}
fromDTO(data: TextStateDTO): TextState {
return data.map((text, idx) => ({ text, id: idx }));
}
} }
export const textsState = withPersistence("texts", createState()); export const textsState = withPersistence("texts", new TextsState());
+24 -24
View File
@@ -1,28 +1,28 @@
import { Theme, type ThemeType } from "$lib/constants"; import { Theme, type ThemeType } from "$lib/constants";
import { STATE_DATA, withPersistence } from "./persisted.svelte"; import { withPersistence, type Persistable } from "./persisted.svelte";
function createState() { export interface Theme {
let current: ThemeType = $state(Theme.LIGHT); current: ThemeType;
return {
get current() {
return current;
},
set current(value: ThemeType) {
current = value;
},
toggle() {
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 = withPersistence("theme", createState()); export class ThemeState implements Persistable<Theme> {
current: ThemeType = $state(Theme.LIGHT);
toggle() {
this.current = this.current === Theme.DARK ? Theme.LIGHT : Theme.DARK;
}
toSnapshot(): Theme {
return {
current: this.current,
};
}
fromSnapshot(data: Partial<Theme>): void {
if (data.current !== undefined) {
this.current = data.current;
}
}
}
export const themeState = withPersistence("theme", new ThemeState());
@@ -2,7 +2,6 @@
exports[`Application Constants Logic > Global Contract (Snapshot) > should match the previous configuration snapshot 1`] = ` exports[`Application Constants Logic > Global Contract (Snapshot) > should match the previous configuration snapshot 1`] = `
{ {
"DEFAULT_TEXT_ALIGN": "center",
"IMAGE_SETTINGS": { "IMAGE_SETTINGS": {
"BRIGHTNESS_MAX": 100, "BRIGHTNESS_MAX": 100,
"BRIGHTNESS_MIN": 0, "BRIGHTNESS_MIN": 0,
@@ -35,6 +34,7 @@ exports[`Application Constants Logic > Global Contract (Snapshot) > should match
"NEXT": "next", "NEXT": "next",
"PREV": "prev", "PREV": "prev",
}, },
"TEXT_ALIGN_DEFAULT": "center",
"TRANSITION_DURATION": 0, "TRANSITION_DURATION": 0,
"TYPOGRAPHY": { "TYPOGRAPHY": {
"FONT_FAMILIES": [ "FONT_FAMILIES": [
@@ -52,12 +52,13 @@ exports[`Application Constants Logic > Global Contract (Snapshot) > should match
"FONT_SIZE_MAX": 72, "FONT_SIZE_MAX": 72,
"FONT_SIZE_MIN": 10, "FONT_SIZE_MIN": 10,
"MAX_TEXT_LENGTH": 100, "MAX_TEXT_LENGTH": 100,
"OFFSET_Y_DEFAULT": 0,
"OFFSET_Y_MAX": 100,
"OFFSET_Y_MIN": -100,
"PADDING_X_DEFAULT": 10, "PADDING_X_DEFAULT": 10,
"PADDING_X_MAX": 100, "PADDING_X_MAX": 100,
"PADDING_X_MIN": 0, "PADDING_X_MIN": 0,
"TEXT_COLOR_DEFAULT": "#ffffff", "TEXT_COLOR_DEFAULT": "#ffffff",
"VERTICAL_OFFSET_MAX": 100,
"VERTICAL_OFFSET_MIN": -100,
}, },
"TextAlign": { "TextAlign": {
"CENTER": "center", "CENTER": "center",
@@ -1,7 +1,6 @@
import PreviewManager from "$components/panel/PreviewManager.svelte"; import PreviewManager from "$components/panel/PreviewManager.svelte";
import { downloadService } from "$services/downloadService"; import { downloadService } from "$services/downloadService";
import { konvaStageState } from "$states/konvaStage.svelte"; import { konvaStageState } from "$states/konvaStage.svelte";
import { STATE_DATA } from "$states/persisted.svelte";
import { textsState } from "$states/texts.svelte"; import { textsState } from "$states/texts.svelte";
import { render, screen, waitFor } from "@testing-library/svelte"; import { render, screen, waitFor } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
@@ -21,7 +20,7 @@ vi.mock("./Preview.svelte", () => ({
describe("PreviewManager Integration", () => { describe("PreviewManager Integration", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
textsState[STATE_DATA] = []; textsState.fromSnapshot([]);
}); });
it("should show empty state by aria-label when no texts", () => { it("should show empty state by aria-label when no texts", () => {
@@ -30,7 +29,7 @@ describe("PreviewManager Integration", () => {
}); });
it("should toggle navigation buttons availability based on texts length", async () => { it("should toggle navigation buttons availability based on texts length", async () => {
textsState[STATE_DATA] = ["1", "2"]; textsState.fromSnapshot(["1", "2"]);
render(PreviewManager); render(PreviewManager);
const nextBtn = screen.getByRole("button", { name: /next slide/i }); const nextBtn = screen.getByRole("button", { name: /next slide/i });
@@ -42,7 +41,7 @@ describe("PreviewManager Integration", () => {
it("should send correct data to downloadAll service", async () => { it("should send correct data to downloadAll service", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
textsState[STATE_DATA] = ["Apple", "Banana"]; textsState.fromSnapshot(["Apple", "Banana"]);
render(PreviewManager); render(PreviewManager);
await user.click(screen.getByRole("button", { name: /download all/i })); await user.click(screen.getByRole("button", { name: /download all/i }));
@@ -57,12 +56,11 @@ describe("PreviewManager Integration", () => {
it("should call downloadPanel with current active text", async () => { it("should call downloadPanel with current active text", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
textsState[STATE_DATA] = ["First", "Second"]; textsState.fromSnapshot(["First", "Second"]);
konvaStageState.stage = { node: { id: "stage-ref" } } as unknown as Stage; konvaStageState.stage = { node: { id: "stage-ref" } } as unknown as Stage;
render(PreviewManager); render(PreviewManager);
// Переходим на второй слайд
await user.click(screen.getByRole("button", { name: /next slide/i })); await user.click(screen.getByRole("button", { name: /next slide/i }));
await user.click(screen.getByRole("button", { name: /download current/i })); await user.click(screen.getByRole("button", { name: /download current/i }));
@@ -70,12 +68,12 @@ describe("PreviewManager Integration", () => {
}); });
it("should return to empty state when texts are removed", async () => { it("should return to empty state when texts are removed", async () => {
textsState[STATE_DATA] = ["Temp"]; textsState.fromSnapshot(["Temp"]);
render(PreviewManager); render(PreviewManager);
expect(screen.queryByLabelText(/Empty texts info/i)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/Empty texts info/i)).not.toBeInTheDocument();
textsState[STATE_DATA] = []; textsState.fromSnapshot([]);
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText(/Empty texts info/i)).toBeInTheDocument(); expect(screen.getByLabelText(/Empty texts info/i)).toBeInTheDocument();
@@ -84,18 +82,15 @@ describe("PreviewManager Integration", () => {
it("should automatically correct current index on items deletion", async () => { it("should automatically correct current index on items deletion", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
textsState[STATE_DATA] = ["1", "2", "3"]; textsState.fromSnapshot(["1", "2", "3"]);
render(PreviewManager); render(PreviewManager);
// Уходим на последний слайд
await user.click(screen.getByRole("button", { name: /next slide/i })); await user.click(screen.getByRole("button", { name: /next slide/i }));
await user.click(screen.getByRole("button", { name: /next slide/i })); await user.click(screen.getByRole("button", { name: /next slide/i }));
// Удаляем элементы. Индекс должен упасть с 2 до 0 textsState.fromSnapshot(["Only one left"]);
textsState[STATE_DATA] = ["Only one left"];
await waitFor(() => { await waitFor(() => {
// Проверяем, что кнопка "Next" заблокировалась (значит мы на последнем/единственном)
expect(screen.getByRole("button", { name: /next slide/i })).toBeDisabled(); expect(screen.getByRole("button", { name: /next slide/i })).toBeDisabled();
expect(screen.getByRole("button", { name: /previous slide/i })).toBeDisabled(); expect(screen.getByRole("button", { name: /previous slide/i })).toBeDisabled();
}); });
+1 -1
View File
@@ -17,7 +17,7 @@ describe("Application Constants Logic", () => {
PANEL_SETTINGS.PANEL_HEIGHT_MAX, PANEL_SETTINGS.PANEL_HEIGHT_MAX,
); );
expect(TYPOGRAPHY.VERTICAL_OFFSET_MAX).toBeGreaterThan(TYPOGRAPHY.VERTICAL_OFFSET_MIN); expect(TYPOGRAPHY.OFFSET_Y_MAX).toBeGreaterThan(TYPOGRAPHY.OFFSET_Y_MIN);
}); });
it("should not have duplicate values in lists", () => { it("should not have duplicate values in lists", () => {
@@ -100,7 +100,6 @@ describe("DownloadService", () => {
{ filename: "test-panel-5", stage: mockKonvaStage }, { filename: "test-panel-5", stage: mockKonvaStage },
]; ];
const result = await service.downloadAll(panels); const result = await service.downloadAll(panels);
// const [blobArg, fileNameArg] = vi.mocked(saveAs).mock.calls[0];
const zipInstance = vi.mocked(JSZip).mock.instances[0]; const zipInstance = vi.mocked(JSZip).mock.instances[0];
expect(result.success).toBe(true); expect(result.success).toBe(true);
+64 -23
View File
@@ -1,4 +1,4 @@
import { STATE_DATA, withPersistence } from "$states/persisted.svelte"; import { withPersistence } from "$states/persisted.svelte";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
function createMock() { function createMock() {
@@ -40,59 +40,83 @@ describe("persisted.svelte", () => {
describe("withPersistence", () => { describe("withPersistence", () => {
it("should return state unchanged when not in browser", () => { it("should return state unchanged when not in browser", () => {
const data = $state({ test: "value" });
const mockState = { const mockState = {
[STATE_DATA]: { test: "value" }, toSnapshot: () => data,
fromSnapshot: () => {},
}; };
const result = withPersistence("test-key", mockState); const result = withPersistence("test-key", mockState);
expect(result).toBe(mockState); expect(result).toBe(mockState);
}); });
it("should restore state from localStorage when valid JSON exists", () => { it("should restore state from localStorage when valid JSON exists", () => {
const savedData = { value: 42, name: "test" }; interface State {
value: number;
name: string;
}
const savedData: State = { value: 42, name: "test" };
localStorageMock.getItem.mockReturnValue(JSON.stringify(savedData)); localStorageMock.getItem.mockReturnValue(JSON.stringify(savedData));
const data: State = $state({ value: 0, name: "" });
const state = { const state = {
[STATE_DATA]: { value: 0, name: "" }, toSnapshot: () => data,
fromSnapshot: (newData: State) => {
if (newData.value !== undefined) data.value = newData.value;
if (newData.name !== undefined) data.name = newData.name;
},
}; };
withPersistence("test-key", state); withPersistence("test-key", state);
expect(state[STATE_DATA]).toEqual(savedData); expect(state.toSnapshot()).toEqual(savedData);
expect(localStorageMock.getItem).toHaveBeenCalledWith("test-key"); expect(localStorageMock.getItem).toHaveBeenCalledWith("test-key");
}); });
it("should handle corrupted JSON in localStorage gracefully", () => { it("should handle corrupted JSON in localStorage gracefully", () => {
localStorageMock.getItem.mockReturnValue("{ invalid json"); localStorageMock.getItem.mockReturnValue("{ invalid json");
const data = $state({ value: 0 });
const state = { const state = {
[STATE_DATA]: { value: 0 }, toSnapshot: () => data,
fromSnapshot: () => {},
}; };
expect(() => withPersistence("test-key", state)).not.toThrow(); expect(() => withPersistence("test-key", state)).not.toThrow();
expect(state[STATE_DATA]).toEqual({ value: 0 }); expect(state.toSnapshot()).toEqual({ value: 0 });
}); });
it("should handle empty localStorage (no saved data)", () => { it("should handle empty localStorage (no saved data)", () => {
localStorageMock.getItem.mockReturnValue(null); localStorageMock.getItem.mockReturnValue(null);
const data = $state({ value: 0 });
const state = { const state = {
[STATE_DATA]: { value: 0 }, toSnapshot: () => data,
fromSnapshot: () => {},
}; };
withPersistence("test-key", state); withPersistence("test-key", state);
expect(state[STATE_DATA]).toEqual({ value: 0 }); expect(state.toSnapshot()).toEqual({ value: 0 });
}); });
it("should save state to localStorage with debounce", async () => { it("should save state to localStorage with debounce", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
interface State {
count: number;
}
const data: State = $state({ count: 1 });
const state = { const state = {
[STATE_DATA]: { count: 1 }, toSnapshot: () => data,
fromSnapshot: (newData: State) => {
if (newData.count !== undefined) data.count = newData.count;
},
}; };
withPersistence("test-key", state, 500); withPersistence("test-key", state, 500);
state[STATE_DATA] = { count: 2 }; state.fromSnapshot({ count: 2 });
await Promise.resolve(); await Promise.resolve();
vi.advanceTimersByTime(500); vi.advanceTimersByTime(500);
@@ -108,13 +132,21 @@ describe("persisted.svelte", () => {
}); });
it("should save state immediately when debounce is 0", async () => { it("should save state immediately when debounce is 0", async () => {
interface State {
count: number;
}
const data: State = $state({ count: 1 });
const state = { const state = {
[STATE_DATA]: { count: 1 }, toSnapshot: () => data,
fromSnapshot: (newData: State) => {
if (newData.count !== undefined) data.count = newData.count;
},
}; };
withPersistence("test-key", state, 0); withPersistence("test-key", state, 0);
state[STATE_DATA] = { count: 2 }; state.fromSnapshot({ count: 2 });
await Promise.resolve(); await Promise.resolve();
@@ -126,23 +158,24 @@ describe("persisted.svelte", () => {
it("should cleanup timeout on state change during debounce", async () => { it("should cleanup timeout on state change during debounce", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const data = $state({ count: 1 }); interface State {
count: number;
}
const data: State = $state({ count: 1 });
const state = { const state = {
get [STATE_DATA]() { toSnapshot: () => data,
return data; fromSnapshot: (newData: State) => {
}, if (newData.count !== undefined) data.count = newData.count;
set [STATE_DATA](v) {
data.count = v.count;
}, },
}; };
withPersistence("test-key", state, 500); withPersistence("test-key", state, 500);
state[STATE_DATA] = { count: 2 }; state.fromSnapshot({ count: 2 });
await Promise.resolve(); await Promise.resolve();
state[STATE_DATA] = { count: 3 }; state.fromSnapshot({ count: 3 });
await Promise.resolve(); await Promise.resolve();
vi.runOnlyPendingTimers(); vi.runOnlyPendingTimers();
@@ -158,13 +191,21 @@ describe("persisted.svelte", () => {
}); });
it("should use DEBOUNCE_DURATION constant as default", async () => { it("should use DEBOUNCE_DURATION constant as default", async () => {
interface State {
test: boolean;
}
const data: State = $state({ test: true });
const state = { const state = {
[STATE_DATA]: { test: true }, toSnapshot: () => data,
fromSnapshot: (newData: State) => {
if (newData.test !== undefined) data.test = newData.test;
},
}; };
withPersistence("test-key", state); withPersistence("test-key", state);
state[STATE_DATA] = { test: false }; state.fromSnapshot({ test: false });
await Promise.resolve(); await Promise.resolve();
+5 -8
View File
@@ -1,6 +1,5 @@
import { TextAlign } from "$lib/constants"; import { TextAlign } from "$lib/constants";
import type { HexColor } from "$lib/types"; import type { HexColor } from "$lib/types";
import { STATE_DATA } from "$states/persisted.svelte";
import { textConfigState } from "$states/textConfig.svelte"; import { textConfigState } from "$states/textConfig.svelte";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
@@ -37,9 +36,8 @@ describe("textConfig.svelte", () => {
}); });
}); });
describe("STATE_DATA", () => { describe("persistence", () => {
it("should serialize and deserialize state", () => { it("should serialize and deserialize state", () => {
// Set custom values
textConfigState.fontSize = 18; textConfigState.fontSize = 18;
textConfigState.fontFamily = "Georgia"; textConfigState.fontFamily = "Georgia";
textConfigState.color = "#00ff00" as HexColor; textConfigState.color = "#00ff00" as HexColor;
@@ -47,8 +45,7 @@ describe("textConfig.svelte", () => {
textConfigState.paddingX = 5; textConfigState.paddingX = 5;
textConfigState.offsetY = 15; textConfigState.offsetY = 15;
// Get serialized data const data = textConfigState.toSnapshot();
const data = textConfigState[STATE_DATA];
expect(data).toEqual({ expect(data).toEqual({
fontSize: 18, fontSize: 18,
fontFamily: "Georgia", fontFamily: "Georgia",
@@ -56,17 +53,17 @@ describe("textConfig.svelte", () => {
align: TextAlign.RIGHT, align: TextAlign.RIGHT,
paddingX: 5, paddingX: 5,
offsetY: 15, offsetY: 15,
outlined: false,
}); });
// Restore from serialized data textConfigState.fromSnapshot({
textConfigState[STATE_DATA] = {
fontSize: 24, fontSize: 24,
fontFamily: "Arial", fontFamily: "Arial",
color: "#ffffff" as HexColor, color: "#ffffff" as HexColor,
align: TextAlign.CENTER, align: TextAlign.CENTER,
paddingX: 10, paddingX: 10,
offsetY: 0, offsetY: 0,
}; });
expect(textConfigState.fontSize).toBe(24); expect(textConfigState.fontSize).toBe(24);
expect(textConfigState.fontFamily).toBe("Arial"); expect(textConfigState.fontFamily).toBe("Arial");
+12 -14
View File
@@ -1,11 +1,10 @@
import { STATE_DATA } from "$states/persisted.svelte"; import { TextsState } from "$states/texts.svelte";
import { createState } from "$states/texts.svelte";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
describe("texts.svelte", () => { describe("texts.svelte", () => {
describe("addText", () => { describe("addText", () => {
it("should add new text with unique ID", () => { it("should add new text with unique ID", () => {
const state = createState(); const state = new TextsState();
const initialLength = state.texts.length; const initialLength = state.texts.length;
state.addText("Test text"); state.addText("Test text");
@@ -16,7 +15,7 @@ describe("texts.svelte", () => {
}); });
it("should not add empty text", () => { it("should not add empty text", () => {
const state = createState(); const state = new TextsState();
const initialLength = state.texts.length; const initialLength = state.texts.length;
state.addText(""); state.addText("");
@@ -28,7 +27,7 @@ describe("texts.svelte", () => {
describe("removeText", () => { describe("removeText", () => {
it("should remove text by ID", () => { it("should remove text by ID", () => {
const state = createState(); const state = new TextsState();
const idToRemove = state.texts[0].id; const idToRemove = state.texts[0].id;
const initialLength = state.texts.length; const initialLength = state.texts.length;
@@ -39,7 +38,7 @@ describe("texts.svelte", () => {
}); });
it("should not affect other texts when removing", () => { it("should not affect other texts when removing", () => {
const state = createState(); const state = new TextsState();
const remainingTexts = state.texts.filter((item) => item.id !== state.texts[0].id); const remainingTexts = state.texts.filter((item) => item.id !== state.texts[0].id);
const idToRemove = state.texts[0].id; const idToRemove = state.texts[0].id;
@@ -51,7 +50,7 @@ describe("texts.svelte", () => {
describe("clear", () => { describe("clear", () => {
it("should remove all texts", () => { it("should remove all texts", () => {
const state = createState(); const state = new TextsState();
state.addText("Test 1"); state.addText("Test 1");
state.addText("Test 2"); state.addText("Test 2");
state.addText("Test 3"); state.addText("Test 3");
@@ -62,19 +61,18 @@ describe("texts.svelte", () => {
}); });
}); });
describe("STATE_DATA", () => { describe("persistence", () => {
it("should serialize and deserialize texts", () => { it("should serialize and deserialize texts", () => {
const state = createState(); const state = new TextsState();
state.clear(); state.clear();
state.addText("First"); state.addText("First");
state.addText("Second"); state.addText("Second");
state.addText("Third"); state.addText("Third");
const data = state[STATE_DATA]; const data = state.toSnapshot();
expect(data).toEqual(["First", "Second", "Third"]); expect(data).toEqual(["First", "Second", "Third"]);
// Restore from serialized data state.fromSnapshot(["New 1", "New 2"]);
state[STATE_DATA] = ["New 1", "New 2"];
expect(state.texts).toHaveLength(2); expect(state.texts).toHaveLength(2);
expect(state.texts[0].text).toBe("New 1"); expect(state.texts[0].text).toBe("New 1");
@@ -84,10 +82,10 @@ describe("texts.svelte", () => {
}); });
it("should handle empty array", () => { it("should handle empty array", () => {
const state = createState(); const state = new TextsState();
state.addText("Test"); state.addText("Test");
state[STATE_DATA] = []; state.fromSnapshot([]);
expect(state.texts).toHaveLength(0); expect(state.texts).toHaveLength(0);
}); });
+3 -4
View File
@@ -1,5 +1,4 @@
import { Theme } from "$lib/constants"; import { Theme } from "$lib/constants";
import { STATE_DATA } from "$states/persisted.svelte";
import { themeState } from "$states/theme.svelte"; import { themeState } from "$states/theme.svelte";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
@@ -33,12 +32,12 @@ describe("theme.svelte", () => {
}); });
}); });
describe("STATE_DATA", () => { describe("persistence", () => {
it("should serialize and deserialize theme", () => { it("should serialize and deserialize theme", () => {
themeState.current = Theme.DARK; themeState.current = Theme.DARK;
expect(themeState[STATE_DATA]).toEqual({ current: Theme.DARK }); expect(themeState.toSnapshot()).toEqual({ current: Theme.DARK });
themeState[STATE_DATA] = { current: Theme.LIGHT }; themeState.fromSnapshot({ current: Theme.LIGHT });
expect(themeState.current).toBe(Theme.LIGHT); expect(themeState.current).toBe(Theme.LIGHT);
}); });
}); });