diff --git a/package-lock.json b/package-lock.json index 1d27e3f..635c960 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@sveltejs/vite-plugin-svelte": "^6.2.4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", + "@testing-library/user-event": "^14.6.1", "@types/file-saver": "^2.0.7", "@types/node": "^25.1.0", "@vitest/coverage-istanbul": "^4.0.18", @@ -1768,6 +1769,20 @@ "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", diff --git a/package.json b/package.json index 8acaa65..110e546 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@sveltejs/vite-plugin-svelte": "^6.2.4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", + "@testing-library/user-event": "^14.6.1", "@types/file-saver": "^2.0.7", "@types/node": "^25.1.0", "@vitest/coverage-istanbul": "^4.0.18", diff --git a/src/components/panel/PreviewControls.svelte b/src/components/panel/PreviewControls.svelte index 74f190a..0a89135 100644 --- a/src/components/panel/PreviewControls.svelte +++ b/src/components/panel/PreviewControls.svelte @@ -1,6 +1,6 @@ diff --git a/src/components/panel/PreviewManager.svelte b/src/components/panel/PreviewManager.svelte index 5536b72..30b9246 100644 --- a/src/components/panel/PreviewManager.svelte +++ b/src/components/panel/PreviewManager.svelte @@ -69,7 +69,7 @@ {/key} {:else} -
+

Добавьте тексты для создания панелей

diff --git a/src/components/ui/Alignment.svelte b/src/components/ui/Alignment.svelte index 6548bb0..94cf52b 100644 --- a/src/components/ui/Alignment.svelte +++ b/src/components/ui/Alignment.svelte @@ -1,5 +1,5 @@ - - - diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 68ea78f..82064b8 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -64,6 +64,7 @@ export const TextAlign = { } as const; export type TextAlignType = (typeof TextAlign)[keyof typeof TextAlign]; + export const DEFAULT_TEXT_ALIGN: TextAlignType = TextAlign.CENTER; export const Theme = { diff --git a/tests/unit/__snapshots__/constants.test.ts.snap b/tests/unit/__snapshots__/constants.test.ts.snap new file mode 100644 index 0000000..24eabb9 --- /dev/null +++ b/tests/unit/__snapshots__/constants.test.ts.snap @@ -0,0 +1,59 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Application Constants Logic > Global Contract (Snapshot) > should match the previous configuration snapshot 1`] = ` +{ + "DEFAULT_TEXT_ALIGN": "center", + "IMAGE_SETTINGS": { + "MAX_FILE_SIZE": 10485760, + "SUPPORTED_FORMATS": [ + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", + "image/gif", + ], + }, + "PANEL_SETTINGS": { + "DEFAULT_BACKGROUND_IMAGE": "./backgrounds/b1.jpg", + "PANEL_HEIGHT_DEFAULT": 100, + "PANEL_HEIGHT_MAX": 200, + "PANEL_WIDTH": 320, + }, + "SlideDirection": { + "NEXT": "next", + "PREV": "prev", + }, + "TRANSITION_DURATION": 0, + "TYPOGRAPHY": { + "FONT_FAMILIES": [ + "Arial", + "Verdana", + "Georgia", + "Times New Roman", + "Courier New", + "Impact", + "Comic Sans MS", + "Trebuchet MS", + ], + "FONT_FAMILY_DEFAULT": "Arial", + "FONT_SIZE_DEFAULT": 32, + "FONT_SIZE_MAX": 72, + "FONT_SIZE_MIN": 10, + "MAX_TEXT_LENGTH": 100, + "PADDING_X_DEFAULT": 10, + "PADDING_X_MAX": 100, + "TEXT_COLOR_DEFAULT": "#ffffff", + "VERTICAL_OFFSET_MAX": 100, + "VERTICAL_OFFSET_MIN": -100, + }, + "TextAlign": { + "CENTER": "center", + "LEFT": "left", + "RIGHT": "right", + }, + "Theme": { + "DARK": "dark", + "LIGHT": "light", + }, +} +`; diff --git a/tests/unit/components/layout/AppHeader.test.ts b/tests/unit/components/layout/AppHeader.test.ts index 5efd1a0..2d3363b 100644 --- a/tests/unit/components/layout/AppHeader.test.ts +++ b/tests/unit/components/layout/AppHeader.test.ts @@ -1,6 +1,7 @@ import AppHeader from "$components/layout/AppHeader.svelte"; import { themeState } from "$states/theme.svelte"; -import { fireEvent, render, screen } from "@testing-library/svelte"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it } from "vitest"; describe("AppHeader.svelte", () => { @@ -9,15 +10,16 @@ describe("AppHeader.svelte", () => { }); it("should toggle theme on button click", async () => { + const user = userEvent.setup(); render(AppHeader); const toggleButton = screen.getByRole("button", { name: /toggle theme/i }); themeState.current = "dark"; - await fireEvent.click(toggleButton); + await user.click(toggleButton); expect(themeState.current).toBe("light"); - await fireEvent.click(toggleButton); + await user.click(toggleButton); expect(themeState.current).toBe("dark"); }); }); diff --git a/tests/unit/components/panel/PreviewControls.test.ts b/tests/unit/components/panel/PreviewControls.test.ts index a09e18d..0055d5f 100644 --- a/tests/unit/components/panel/PreviewControls.test.ts +++ b/tests/unit/components/panel/PreviewControls.test.ts @@ -1,22 +1,46 @@ -import PreviewControls from "$components/panel/PreviewControls.svelte"; -import { cleanup, render, screen } from "@testing-library/svelte"; -import { afterEach, describe, expect, it } from "vitest"; +import { SlideDirection } from "$lib/constants"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import PreviewControlsTest from "./PreviewControlsTest.svelte"; -afterEach(() => { - cleanup(); -}); +describe("PreviewControls logic", () => { + it("should increment current and update direction on next click", async () => { + const user = userEvent.setup(); + render(PreviewControlsTest, { props: { current: 0, max: 3 } }); -describe("PreviewControls.svelte", () => { - it("should render navigation buttons", () => { - render(PreviewControls, { - props: { - current: 1, - direction: "next", - max: 3, - }, - }); + const nextBtn = screen.getByRole("button", { name: /next slide/i }); - const buttons = screen.getAllByRole("button"); - expect(buttons.length).toBeGreaterThan(0); + await user.click(nextBtn); + + expect(screen.getByTestId("current").textContent).toBe("1"); + expect(screen.getByTestId("direction").textContent).toBe(SlideDirection.NEXT); + }); + + it("should decrement current on prev click", async () => { + const user = userEvent.setup(); + render(PreviewControlsTest, { props: { current: 2, max: 3 } }); + + const prevBtn = screen.getByRole("button", { name: /previous slide/i }); + + await user.click(prevBtn); + + expect(screen.getByTestId("current").textContent).toBe("1"); + }); + + it("should handle boundaries and disable buttons", async () => { + const user = userEvent.setup(); + render(PreviewControlsTest, { props: { current: 0, max: 2 } }); + + const prevBtn = screen.getByRole("button", { name: /previous slide/i }); + const nextBtn = screen.getByRole("button", { name: /next slide/i }); + + expect(prevBtn).toBeDisabled(); + + await user.click(nextBtn); + + expect(screen.getByTestId("current").textContent).toBe("1"); + expect(nextBtn).toBeDisabled(); + expect(prevBtn).not.toBeDisabled(); }); }); diff --git a/tests/unit/components/panel/PreviewControlsTest.svelte b/tests/unit/components/panel/PreviewControlsTest.svelte new file mode 100644 index 0000000..f482049 --- /dev/null +++ b/tests/unit/components/panel/PreviewControlsTest.svelte @@ -0,0 +1,13 @@ + + + + +
{current}
+
{direction}
diff --git a/tests/unit/components/panel/PreviewManager.test.ts b/tests/unit/components/panel/PreviewManager.test.ts index 84642de..96fb84e 100644 --- a/tests/unit/components/panel/PreviewManager.test.ts +++ b/tests/unit/components/panel/PreviewManager.test.ts @@ -1,14 +1,102 @@ import PreviewManager from "$components/panel/PreviewManager.svelte"; +import { downloadService } from "$services/downloadService"; +import { konvaStageState } from "$states/konvaStage.svelte"; +import { STATE_DATA } from "$states/persisted.svelte"; import { textsState } from "$states/texts.svelte"; -import { render } from "@testing-library/svelte"; -import { beforeEach, describe, it } from "vitest"; +import { render, screen, waitFor } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -describe("PreviewManager.svelte", () => { +vi.mock("$services/downloadService", () => ({ + downloadService: { + downloadAll: vi.fn(), + downloadPanel: vi.fn(), + }, +})); + +vi.mock("./Preview.svelte", () => ({ + default: { render: () => ({}) }, +})); +describe("PreviewManager Integration", () => { beforeEach(() => { - textsState.texts.length = 0; + vi.clearAllMocks(); + textsState[STATE_DATA] = []; }); - it("should render without crashing", () => { + it("should show empty state by aria-label when no texts", () => { render(PreviewManager); + expect(screen.getByLabelText(/Empty texts info/i)).toBeInTheDocument(); + }); + + it("should toggle navigation buttons availability based on texts length", async () => { + textsState[STATE_DATA] = ["1", "2"]; + render(PreviewManager); + + const nextBtn = screen.getByRole("button", { name: /next slide/i }); + const prevBtn = screen.getByRole("button", { name: /previous slide/i }); + + expect(nextBtn).not.toBeDisabled(); + expect(prevBtn).toBeDisabled(); + }); + + it("should send correct data to downloadAll service", async () => { + const user = userEvent.setup(); + textsState[STATE_DATA] = ["Apple", "Banana"]; + render(PreviewManager); + + await user.click(screen.getByRole("button", { name: /download all/i })); + + expect(downloadService.downloadAll).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ filename: "Apple" }), + expect.objectContaining({ filename: "Banana" }), + ]), + ); + }); + + it("should call downloadPanel with current active text", async () => { + const user = userEvent.setup(); + textsState[STATE_DATA] = ["First", "Second"]; + konvaStageState.stage = { node: { id: "stage-ref" } } as any; + + render(PreviewManager); + + // Переходим на второй слайд + await user.click(screen.getByRole("button", { name: /next slide/i })); + await user.click(screen.getByRole("button", { name: /download current/i })); + + expect(downloadService.downloadPanel).toHaveBeenCalledWith(expect.anything(), "Second"); + }); + + it("should return to empty state when texts are removed", async () => { + textsState[STATE_DATA] = ["Temp"]; + render(PreviewManager); + + expect(screen.queryByLabelText(/Empty texts info/i)).not.toBeInTheDocument(); + + textsState[STATE_DATA] = []; + + await waitFor(() => { + expect(screen.getByLabelText(/Empty texts info/i)).toBeInTheDocument(); + }); + }); + + it("should automatically correct current index on items deletion", async () => { + const user = userEvent.setup(); + textsState[STATE_DATA] = ["1", "2", "3"]; + render(PreviewManager); + + // Уходим на последний слайд + await user.click(screen.getByRole("button", { name: /next slide/i })); + await user.click(screen.getByRole("button", { name: /next slide/i })); + + // Удаляем элементы. Индекс должен упасть с 2 до 0 + textsState[STATE_DATA] = ["Only one left"]; + + await waitFor(() => { + // Проверяем, что кнопка "Next" заблокировалась (значит мы на последнем/единственном) + expect(screen.getByRole("button", { name: /next slide/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /previous slide/i })).toBeDisabled(); + }); }); }); diff --git a/tests/unit/components/text/TextInput.test.ts b/tests/unit/components/text/TextInput.test.ts index 0fe533e..9944d89 100644 --- a/tests/unit/components/text/TextInput.test.ts +++ b/tests/unit/components/text/TextInput.test.ts @@ -1,5 +1,6 @@ import TextInput from "$components/text/TextInput.svelte"; -import { fireEvent, render, screen } from "@testing-library/svelte"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; describe("TextInput.svelte", () => { @@ -18,6 +19,7 @@ describe("TextInput.svelte", () => { }); it("should update value on input", async () => { + const user = userEvent.setup(); render(TextInput, { props: { text: "Initial", @@ -27,41 +29,33 @@ describe("TextInput.svelte", () => { }); const input = screen.getByRole("textbox", { name: /test input/i }); - await fireEvent.input(input, { target: { value: "Updated text" } }); + await user.clear(input); + await user.type(input, "Updated text"); expect(input).toHaveValue("Updated text"); }); - it("should call onenter when Enter key is pressed", async () => { - const onenter = vi.fn(); + it("should call onenter only on Enter key and not on others", async () => { + const user = userEvent.setup(); + const onenterSpy = vi.fn(); + render(TextInput, { props: { - text: "Test", - onenter, + text: "test", + onenter: onenterSpy, ariaLabel: "Test input", }, }); const input = screen.getByRole("textbox", { name: /test input/i }); - await fireEvent.keyDown(input, { key: "Enter" }); - expect(onenter).toHaveBeenCalledTimes(1); - }); + await user.type(input, "abc"); + expect(onenterSpy).not.toHaveBeenCalled(); - it("should not call onenter when other keys are pressed", async () => { - const onenter = vi.fn(); - render(TextInput, { - props: { - text: "Test", - onenter, - ariaLabel: "Test input", - }, - }); + await user.keyboard("{Escape}"); + expect(onenterSpy).not.toHaveBeenCalled(); - const input = screen.getByRole("textbox", { name: /test input/i }); - await fireEvent.keyDown(input, { key: "Escape" }); - await fireEvent.keyDown(input, { key: "Tab" }); - - expect(onenter).not.toHaveBeenCalled(); + await user.type(input, "{Enter}"); + expect(onenterSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/unit/components/text/TextManager.test.ts b/tests/unit/components/text/TextManager.test.ts index aa7ec00..80ffb88 100644 --- a/tests/unit/components/text/TextManager.test.ts +++ b/tests/unit/components/text/TextManager.test.ts @@ -1,193 +1,60 @@ import TextManager from "$components/text/TextManager.svelte"; import { textsState } from "$states/texts.svelte"; -import { fireEvent, render, screen, waitFor, within } from "@testing-library/svelte"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it } from "vitest"; -describe("TextManager.svelte", () => { +describe("TextManager", () => { beforeEach(() => { textsState.clear(); }); - describe("Rendering", () => { - it("should render card with title, input and add button", () => { - render(TextManager); + it("should add text to state and clear input on button click", async () => { + const user = userEvent.setup(); + render(TextManager); - const cardTitle = screen.getByText("Тексты панелей"); - expect(cardTitle).toBeInTheDocument(); - - const input = screen.getByRole("textbox"); - expect(input).toBeInTheDocument(); - - const addButton = screen.getByRole("button", { name: /add/i }); - expect(addButton).toBeInTheDocument(); - }); - - it("should show empty list when textsState.texts is empty", () => { - render(TextManager); - - const textItems = screen.queryAllByRole("listitem"); - expect(textItems).toHaveLength(0); - }); + const input = screen.getByRole("textbox", { name: /input new text/i }); + const addButton = screen.getByRole("button", { name: /add text/i }); + await user.type(input, "New Note"); + await user.click(addButton); + expect(textsState.texts).toHaveLength(1); + expect(textsState.texts[0].text).toBe("New Note"); + expect(input).toHaveValue(""); }); - describe("Adding text", () => { - it("should call textsState.addText when add button is clicked", async () => { - const addTextSpy = vi.spyOn(textsState, "addText"); + it("should add text on enter key", async () => { + const user = userEvent.setup(); + render(TextManager); - render(TextManager); - - const input = screen.getByRole("textbox"); - const addButton = screen.getByRole("button", { name: /add/i }); - - fireEvent.input(input, { target: { value: "New text" } }); - fireEvent.click(addButton); - - expect(addTextSpy).toHaveBeenCalledWith("New text"); - }); - - it("should clear input after adding text", async () => { - render(TextManager); - - const input = screen.getByRole("textbox"); - const addButton = screen.getByRole("button", { name: /add/i }); - - fireEvent.input(input, { target: { value: "New text" } }); - fireEvent.click(addButton); - - await waitFor(() => { - expect(input).toHaveValue(""); - }); - }); - - it("should add text when Enter key is pressed", () => { - const addTextSpy = vi.spyOn(textsState, "addText"); - - render(TextManager); - - const input = screen.getByRole("textbox"); - - fireEvent.input(input, { target: { value: "New text" } }); - fireEvent.keyDown(input, { key: "Enter" }); - - expect(addTextSpy).toHaveBeenCalledWith("New text"); - }); + const input = screen.getByRole("textbox", { name: /input new text/i }); + await user.type(input, "Enter Note{Enter}"); + expect(textsState.texts).toHaveLength(1); + expect(textsState.texts[0].text).toBe("Enter Note"); }); - describe("List and deletion", () => { - it("should render TextInlineEdit for each text", () => { - textsState.addText("Text 1"); - textsState.addText("Text 2"); - textsState.addText("Text 3"); + it("should display all items from state", async () => { + textsState.addText("First"); + textsState.addText("Second"); - render(TextManager); + render(TextManager); - const textItems = screen.queryAllByRole("listitem"); - expect(textItems).toHaveLength(3); - }); - - it("should pass correct props to TextInlineEdit", () => { - textsState.addText("Test text"); - const textId = textsState.texts[0].id; - - render(TextManager); - - const textItems = screen.queryAllByRole("listitem"); - expect(textItems).toHaveLength(1); - - const input = within(textItems[0]).getByRole("textbox"); - expect(input).toHaveValue("Test text"); - }); - - it("should call textsState.removeText when delete button is clicked", () => { - textsState.addText("Text to delete"); - const textId = textsState.texts[0].id; - const removeTextSpy = vi.spyOn(textsState, "removeText"); - - render(TextManager); - - const deleteButton = screen.getByRole("button", { name: /delete/i }); - fireEvent.click(deleteButton); - - expect(removeTextSpy).toHaveBeenCalledWith(textId); - }); - - it("should remove element from DOM after deletion", async () => { - textsState.addText("Text to delete"); - - render(TextManager); - - let textItems = screen.queryAllByRole("listitem"); - expect(textItems).toHaveLength(1); - - const deleteButton = screen.getByRole("button", { name: /delete/i }); - fireEvent.click(deleteButton); - - await waitFor(() => { - textItems = screen.queryAllByRole("listitem"); - expect(textItems).toHaveLength(0); - }); - }); + const items = screen.getAllByRole("listitem"); + expect(items).toHaveLength(2); }); - describe("Reactivity", () => { - it("should update list when textsState.texts changes externally", async () => { - render(TextManager); + it("should remove text from state when delete button is clicked", async () => { + const user = userEvent.setup(); + textsState.addText("To be deleted"); - let textItems = screen.queryAllByRole("listitem"); - expect(textItems).toHaveLength(0); + render(TextManager); - textsState.addText("New text"); + const deleteBtn = screen.getByRole("button", { name: /delete/i }); + await user.click(deleteBtn); - await waitFor(() => { - textItems = screen.queryAllByRole("listitem"); - expect(textItems).toHaveLength(1); - }); - }); - - it("should update when text is removed externally", async () => { - vi.useFakeTimers(); - - textsState.addText("Text to remove"); - const textId = textsState.texts[0].id; - - render(TextManager); - - await waitFor(() => { - expect(screen.queryAllByRole("listitem")).toHaveLength(1); - }); - - textsState.removeText(textId); - await vi.advanceTimersByTimeAsync(400); - - await waitFor(() => { - expect(screen.queryAllByRole("listitem")).toHaveLength(0); - }); - vi.useRealTimers(); - }); - }); - - describe("Accessibility", () => { - // it("should keep focus in input after adding text", async () => { - // render(TextManager); - // const input = screen.getByRole("textbox"); - // const addButton = screen.getByRole("button", { name: /add/i }); - // fireEvent.input(input, { target: { value: "New text" } }); - // fireEvent.click(addButton); - // await waitFor(() => { - // expect(input).toHaveFocus(); - // }); - // }); - - it("should have proper aria-labels for input and button", () => { - render(TextManager); - const input = screen.getByRole("textbox"); - expect(input).toHaveAttribute("aria-label", "Input new text"); - const addButton = screen.getByRole("button", { name: /add/i }); - expect(addButton).toHaveAttribute("aria-label", "Add text"); - }); + expect(textsState.texts).toHaveLength(0); }); }); diff --git a/tests/unit/components/ui/Alignment.test.ts b/tests/unit/components/ui/Alignment.test.ts index 8daf890..b16a92b 100644 --- a/tests/unit/components/ui/Alignment.test.ts +++ b/tests/unit/components/ui/Alignment.test.ts @@ -1,13 +1,44 @@ -import Alignment from "$components/ui/Alignment.svelte"; -import { render } from "@testing-library/svelte"; -import { describe, it } from "vitest"; +import { TextAlign } from "$lib/constants"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import AlignmentTest from "./AlignmentTest.svelte"; describe("Alignment.svelte", () => { - it("should render without crashing", () => { - render(Alignment, { - props: { - align: "left", - }, - }); + it("should update state for every button by clicking in sequence", async () => { + const user = userEvent.setup(); + render(AlignmentTest); + + const buttons = screen.getAllByRole("button"); + const stateDisplay = screen.getByTestId("align-value"); + + const allButtonsToClick = [...buttons, buttons[0]]; + + for (const button of allButtonsToClick) { + const label = button.getAttribute("aria-label")?.toLowerCase() || ""; + + await user.click(button); + + const finalValue = stateDisplay.textContent?.toLowerCase() || ""; + expect(label).toContain(finalValue); + } + }); + + it("should have initial state from props", () => { + render(AlignmentTest, { props: { align: TextAlign.RIGHT } }); + expect(screen.getByTestId("align-value").textContent).toBe(TextAlign.RIGHT); + }); + + it("should sync with initial state and change state", async () => { + const user = userEvent.setup(); + const { rerender } = render(AlignmentTest, { props: { align: TextAlign.RIGHT } }); + const stateDisplay = screen.getByTestId("align-value"); + + expect(stateDisplay.textContent).toBe(TextAlign.RIGHT); + + await rerender({ align: TextAlign.CENTER }); + + const centerBtn = screen.getByRole("button", { name: new RegExp(TextAlign.CENTER, "i") }); + expect(centerBtn).toHaveClass("active"); }); }); diff --git a/tests/unit/components/ui/AlignmentTest.svelte b/tests/unit/components/ui/AlignmentTest.svelte new file mode 100644 index 0000000..48b90a6 --- /dev/null +++ b/tests/unit/components/ui/AlignmentTest.svelte @@ -0,0 +1,11 @@ + + + + +
{align}
diff --git a/tests/unit/components/ui/Button.test.ts b/tests/unit/components/ui/Button.test.ts index 2fa3c42..ec92e5a 100644 --- a/tests/unit/components/ui/Button.test.ts +++ b/tests/unit/components/ui/Button.test.ts @@ -1,5 +1,6 @@ import Button from "$components/ui/Button.svelte"; -import { fireEvent, render, screen } from "@testing-library/svelte"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import MockIcon from "./MockIcon.svelte"; @@ -25,11 +26,15 @@ describe("Button.svelte", () => { }, }); - expect(screen.getByRole("button", { name: /test button/i })).toBeInTheDocument(); + const button = screen.getByRole("button", { name: /test button/i }); + expect(button).toBeInTheDocument(); + expect(button.textContent.trim()).toBe(""); }); it("should call onclick handler", async () => { const onclick = vi.fn(); + const user = userEvent.setup(); + render(Button, { props: { icon: MockIcon, @@ -40,11 +45,27 @@ describe("Button.svelte", () => { }); const button = screen.getByRole("button", { name: /test button/i }); - await fireEvent.click(button); + await user.click(button); expect(onclick).toHaveBeenCalledTimes(1); }); + it("should not throw error when clicked without onclick prop", async () => { + const user = userEvent.setup(); + + render(Button, { + props: { + icon: MockIcon, + label: "Click me", + ariaLabel: "Test button", + }, + }); + + const button = screen.getByRole("button", { name: /test button/i }); + + expect(() => user.click(button)).not.toThrow(); + }); + it("should be disabled when disabled prop is true", () => { render(Button, { props: { diff --git a/tests/unit/components/ui/RangeSlider.test.ts b/tests/unit/components/ui/RangeSlider.test.ts index 91f300b..89cd1ff 100644 --- a/tests/unit/components/ui/RangeSlider.test.ts +++ b/tests/unit/components/ui/RangeSlider.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest"; describe("RangeSlider.svelte", () => { it("should call onchange handler", async () => { const onchange = vi.fn(); + render(RangeSlider, { props: { value: 50, diff --git a/tests/unit/constants.test.ts b/tests/unit/constants.test.ts index eca4225..d2f56ea 100644 --- a/tests/unit/constants.test.ts +++ b/tests/unit/constants.test.ts @@ -1,259 +1,68 @@ -import { - DEFAULT_TEXT_ALIGN, - IMAGE_SETTINGS, - PANEL_SETTINGS, - SlideDirection, - type SlideDirectionType, - TextAlign, - type TextAlignType, - TYPOGRAPHY, -} from "$lib/constants"; +import * as Constants from "$lib/constants"; +import fs from "fs"; +import path from "path"; import { describe, expect, it } from "vitest"; -describe("constants", () => { - describe("PANEL_SETTINGS", () => { - it("should have correct panel width", () => { - expect(PANEL_SETTINGS.PANEL_WIDTH).toBe(320); - }); +describe("Application Constants Logic", () => { + describe("Typography & Panel Constraints", () => { + it("should have default values within allowed boundaries", () => { + const { TYPOGRAPHY, PANEL_SETTINGS } = Constants; - it("should have correct default panel height", () => { - expect(PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT).toBe(100); - }); - - it("should have correct max panel height", () => { - expect(PANEL_SETTINGS.PANEL_HEIGHT_MAX).toBe(200); - }); - - it("should have default background image path", () => { - expect(PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE).toBe("./backgrounds/b1.jpg"); - }); - - it("should have all required properties", () => { - expect(PANEL_SETTINGS).toHaveProperty("PANEL_WIDTH"); - expect(PANEL_SETTINGS).toHaveProperty("PANEL_HEIGHT_DEFAULT"); - expect(PANEL_SETTINGS).toHaveProperty("PANEL_HEIGHT_MAX"); - expect(PANEL_SETTINGS).toHaveProperty("DEFAULT_BACKGROUND_IMAGE"); - }); - - it("should have numeric dimensions", () => { - expect(typeof PANEL_SETTINGS.PANEL_WIDTH).toBe("number"); - expect(typeof PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT).toBe("number"); - expect(typeof PANEL_SETTINGS.PANEL_HEIGHT_MAX).toBe("number"); - }); - - it("should have string background image path", () => { - expect(typeof PANEL_SETTINGS.DEFAULT_BACKGROUND_IMAGE).toBe("string"); - }); - - it("should have valid panel dimensions", () => { - expect(PANEL_SETTINGS.PANEL_WIDTH).toBeGreaterThan(0); - expect(PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT).toBeGreaterThan(0); - expect(PANEL_SETTINGS.PANEL_HEIGHT_MAX).toBeGreaterThan(PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT); - }); - }); - - describe("TYPOGRAPHY", () => { - it("should have default font family", () => { - expect(TYPOGRAPHY.FONT_FAMILY_DEFAULT).toBe("Arial"); - }); - - it("should have array of font families", () => { - expect(Array.isArray(TYPOGRAPHY.FONT_FAMILIES)).toBe(true); - expect(TYPOGRAPHY.FONT_FAMILIES.length).toBeGreaterThan(0); - }); - - it("should include default font in font families", () => { - expect(TYPOGRAPHY.FONT_FAMILIES).toContain(TYPOGRAPHY.FONT_FAMILY_DEFAULT); - }); - - it("should have correct font size range", () => { - expect(TYPOGRAPHY.FONT_SIZE_MIN).toBe(10); - expect(TYPOGRAPHY.FONT_SIZE_MAX).toBe(72); - expect(TYPOGRAPHY.FONT_SIZE_DEFAULT).toBe(32); - }); - - it("should have valid font size range", () => { - expect(TYPOGRAPHY.FONT_SIZE_MIN).toBeLessThan(TYPOGRAPHY.FONT_SIZE_MAX); expect(TYPOGRAPHY.FONT_SIZE_DEFAULT).toBeGreaterThanOrEqual(TYPOGRAPHY.FONT_SIZE_MIN); expect(TYPOGRAPHY.FONT_SIZE_DEFAULT).toBeLessThanOrEqual(TYPOGRAPHY.FONT_SIZE_MAX); + + expect(TYPOGRAPHY.FONT_FAMILIES).toContain(TYPOGRAPHY.FONT_FAMILY_DEFAULT); + + expect(PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT).toBeLessThanOrEqual(PANEL_SETTINGS.PANEL_HEIGHT_MAX); + + expect(TYPOGRAPHY.VERTICAL_OFFSET_MAX).toBeGreaterThan(TYPOGRAPHY.VERTICAL_OFFSET_MIN); }); - it("should have max text length", () => { - expect(TYPOGRAPHY.MAX_TEXT_LENGTH).toBe(100); - expect(typeof TYPOGRAPHY.MAX_TEXT_LENGTH).toBe("number"); - expect(TYPOGRAPHY.MAX_TEXT_LENGTH).toBeGreaterThan(0); - }); + it("should not have duplicate values in lists", () => { + const { TYPOGRAPHY, IMAGE_SETTINGS } = Constants; - it("should have padding settings", () => { - expect(TYPOGRAPHY.PADDING_X_DEFAULT).toBe(10); - expect(TYPOGRAPHY.PADDING_X_MAX).toBe(100); - expect(typeof TYPOGRAPHY.PADDING_X_DEFAULT).toBe("number"); - expect(typeof TYPOGRAPHY.PADDING_X_MAX).toBe("number"); - }); + const uniqueFonts = new Set(TYPOGRAPHY.FONT_FAMILIES); + expect(uniqueFonts.size).toBe(TYPOGRAPHY.FONT_FAMILIES.length); - it("should have valid padding range", () => { - expect(TYPOGRAPHY.PADDING_X_DEFAULT).toBeLessThanOrEqual(TYPOGRAPHY.PADDING_X_MAX); - expect(TYPOGRAPHY.PADDING_X_DEFAULT).toBeGreaterThanOrEqual(0); - }); - - it("should have vertical offset settings", () => { - expect(TYPOGRAPHY.VERTICAL_OFFSET_MAX).toBe(100); - expect(TYPOGRAPHY.VERTICAL_OFFSET_MIN).toBe(-100); - expect(typeof TYPOGRAPHY.VERTICAL_OFFSET_MAX).toBe("number"); - expect(typeof TYPOGRAPHY.VERTICAL_OFFSET_MIN).toBe("number"); - }); - - it("should have valid vertical offset range", () => { - expect(TYPOGRAPHY.VERTICAL_OFFSET_MIN).toBeLessThan(TYPOGRAPHY.VERTICAL_OFFSET_MAX); - }); - - it("should have default text color", () => { - expect(TYPOGRAPHY.TEXT_COLOR_DEFAULT).toBe("#ffffff"); - expect(typeof TYPOGRAPHY.TEXT_COLOR_DEFAULT).toBe("string"); - }); - - it("should have valid hex color format for default text color", () => { - const hexColorRegex = /^#[0-9A-Fa-f]{6}$/; - expect(TYPOGRAPHY.TEXT_COLOR_DEFAULT).toMatch(hexColorRegex); - }); - - it("should have all required typography properties", () => { - expect(TYPOGRAPHY).toHaveProperty("FONT_FAMILY_DEFAULT"); - expect(TYPOGRAPHY).toHaveProperty("FONT_FAMILIES"); - expect(TYPOGRAPHY).toHaveProperty("FONT_SIZE_MIN"); - expect(TYPOGRAPHY).toHaveProperty("FONT_SIZE_MAX"); - expect(TYPOGRAPHY).toHaveProperty("FONT_SIZE_DEFAULT"); - expect(TYPOGRAPHY).toHaveProperty("MAX_TEXT_LENGTH"); - expect(TYPOGRAPHY).toHaveProperty("PADDING_X_DEFAULT"); - expect(TYPOGRAPHY).toHaveProperty("PADDING_X_MAX"); - expect(TYPOGRAPHY).toHaveProperty("VERTICAL_OFFSET_MAX"); - expect(TYPOGRAPHY).toHaveProperty("VERTICAL_OFFSET_MIN"); - expect(TYPOGRAPHY).toHaveProperty("TEXT_COLOR_DEFAULT"); + const uniqueFormats = new Set(IMAGE_SETTINGS.SUPPORTED_FORMATS); + expect(uniqueFormats.size).toBe(IMAGE_SETTINGS.SUPPORTED_FORMATS.length); }); }); - describe("IMAGE_SETTINGS", () => { - it("should have correct max file size", () => { - expect(IMAGE_SETTINGS.MAX_FILE_SIZE).toBe(10 * 1024 * 1024); // 10MB - }); + describe("Integrity & Formats", () => { + it("should have valid format patterns for colors and mime-types", () => { + const { TYPOGRAPHY, IMAGE_SETTINGS } = Constants; - it("should have array of supported formats", () => { - expect(Array.isArray(IMAGE_SETTINGS.SUPPORTED_FORMATS)).toBe(true); - expect(IMAGE_SETTINGS.SUPPORTED_FORMATS.length).toBeGreaterThan(0); - }); + expect(TYPOGRAPHY.TEXT_COLOR_DEFAULT).toMatch(/^#[0-9a-f]{6}$/i); - it("should include common image formats", () => { - expect(IMAGE_SETTINGS.SUPPORTED_FORMATS).toContain("image/jpeg"); - expect(IMAGE_SETTINGS.SUPPORTED_FORMATS).toContain("image/png"); - expect(IMAGE_SETTINGS.SUPPORTED_FORMATS).toContain("image/webp"); - }); - - it("should have all required image settings properties", () => { - expect(IMAGE_SETTINGS).toHaveProperty("MAX_FILE_SIZE"); - expect(IMAGE_SETTINGS).toHaveProperty("SUPPORTED_FORMATS"); - }); - - it("should have valid file size", () => { - expect(typeof IMAGE_SETTINGS.MAX_FILE_SIZE).toBe("number"); - expect(IMAGE_SETTINGS.MAX_FILE_SIZE).toBeGreaterThan(0); - }); - - it("should have string format types", () => { IMAGE_SETTINGS.SUPPORTED_FORMATS.forEach((format) => { - expect(typeof format).toBe("string"); - expect(format).toMatch(/^image\//); + expect(format).toMatch(/^image\/(jpeg|jpg|png|webp|gif)$/); }); }); }); - describe("SlideDirection", () => { - it("should have NEXT and PREV directions", () => { - expect(SlideDirection.NEXT).toBe("next"); - expect(SlideDirection.PREV).toBe("prev"); - }); - - it("should have correct type for slide directions", () => { - const next: SlideDirectionType = SlideDirection.NEXT; - const prev: SlideDirectionType = SlideDirection.PREV; - - expect(next).toBe("next"); - expect(prev).toBe("prev"); - }); - - it("should have all required slide direction properties", () => { - expect(SlideDirection).toHaveProperty("NEXT"); - expect(SlideDirection).toHaveProperty("PREV"); - }); - - it("should have string values for directions", () => { - expect(typeof SlideDirection.NEXT).toBe("string"); - expect(typeof SlideDirection.PREV).toBe("string"); + describe("Environment Specifics", () => { + it("should set transition duration to 0 in test mode", () => { + expect(Constants.TRANSITION_DURATION).toBe(0); }); }); - describe("TextAlign", () => { - it("should have LEFT, CENTER and RIGHT alignments", () => { - expect(TextAlign.LEFT).toBe("left"); - expect(TextAlign.CENTER).toBe("center"); - expect(TextAlign.RIGHT).toBe("right"); - }); - - it("should have correct type for text alignments", () => { - const left: TextAlignType = TextAlign.LEFT; - const center: TextAlignType = TextAlign.CENTER; - const right: TextAlignType = TextAlign.RIGHT; - - expect(left).toBe("left"); - expect(center).toBe("center"); - expect(right).toBe("right"); - }); - - it("should have all required text alignment properties", () => { - expect(TextAlign).toHaveProperty("LEFT"); - expect(TextAlign).toHaveProperty("CENTER"); - expect(TextAlign).toHaveProperty("RIGHT"); - }); - - it("should have string values for alignments", () => { - expect(typeof TextAlign.LEFT).toBe("string"); - expect(typeof TextAlign.CENTER).toBe("string"); - expect(typeof TextAlign.RIGHT).toBe("string"); + describe("Global Contract (Snapshot)", () => { + it("should match the previous configuration snapshot", () => { + expect(Constants).toMatchSnapshot(); }); }); - describe("DEFAULT_TEXT_ALIGN", () => { - it("should be CENTER by default", () => { - expect(DEFAULT_TEXT_ALIGN).toBe("center"); - }); + describe("Assets Existence", () => { + it("should verify that the default background image exists", () => { + const { DEFAULT_BACKGROUND_IMAGE } = Constants.PANEL_SETTINGS; - it("should be of type TextAlignType", () => { - const align: TextAlignType = DEFAULT_TEXT_ALIGN; - expect(align).toBe("center"); - }); + const relativePath = DEFAULT_BACKGROUND_IMAGE.replace(/^\.\//, ""); + const fullPath = path.resolve(process.cwd(), "static", relativePath); - it("should match one of the TextAlign values", () => { - expect([TextAlign.LEFT, TextAlign.CENTER, TextAlign.RIGHT]).toContain(DEFAULT_TEXT_ALIGN); - }); - }); + const exists = fs.existsSync(fullPath); - describe("Constants integration", () => { - it("should have consistent panel dimensions", () => { - expect(PANEL_SETTINGS.PANEL_WIDTH).toBe(320); - expect(TYPOGRAPHY.PADDING_X_MAX).toBeLessThan(PANEL_SETTINGS.PANEL_WIDTH / 2); - }); - - it("should have valid text constraints for panel width", () => { - const maxTextWidth = PANEL_SETTINGS.PANEL_WIDTH - 2 * TYPOGRAPHY.PADDING_X_MAX; - expect(maxTextWidth).toBeGreaterThan(0); - }); - - it("should have reasonable font size range for panel height", () => { - const minFontSize = TYPOGRAPHY.FONT_SIZE_MIN; - const maxFontSize = TYPOGRAPHY.FONT_SIZE_MAX; - const panelHeight = PANEL_SETTINGS.PANEL_HEIGHT_DEFAULT; - - expect(minFontSize).toBeLessThan(panelHeight); - expect(maxFontSize).toBeLessThan(panelHeight * 2); + expect(exists, `Image not found at: ${fullPath}`).toBe(true); }); }); }); diff --git a/tests/unit/states/persisted.svelte.test.ts b/tests/unit/states/persisted.svelte.test.ts index e0c9ef6..14c50d9 100644 --- a/tests/unit/states/persisted.svelte.test.ts +++ b/tests/unit/states/persisted.svelte.test.ts @@ -1,7 +1,6 @@ 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 { @@ -62,7 +61,6 @@ describe("persisted.svelte", () => { [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 }); }); @@ -88,10 +86,8 @@ describe("persisted.svelte", () => { 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(); @@ -110,7 +106,6 @@ describe("persisted.svelte", () => { state[STATE_DATA] = { count: 2 }; - // Wait for effect to run await Promise.resolve(); expect(localStorageMock.setItem).toHaveBeenCalledWith("test-key", JSON.stringify({ count: 2 })); @@ -118,10 +113,6 @@ describe("persisted.svelte", () => { 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 = { @@ -135,21 +126,15 @@ describe("persisted.svelte", () => { 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 })); @@ -163,13 +148,10 @@ describe("persisted.svelte", () => { 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(); }); });