fix: update persisted state workflow
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user