diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx new file mode 100644 index 0000000..0d13579 --- /dev/null +++ b/app/src/App.test.tsx @@ -0,0 +1,51 @@ +import { render, screen, act } from "@testing-library/react"; +import { expect, test, vi, beforeEach } from "vitest"; +import App from "./App"; +import { api } from "./api"; + +vi.mock("./api", () => { + return { + api: { + getInitialState: vi.fn(), + showSettingsWindow: vi.fn(), + updateConfig: vi.fn(), + getHotkeyStatus: vi.fn(), + }, + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test("mounts App and fetches initial state", async () => { + const mockInitialPayload = { + config: { + strip_utms: true, + fix_quotes: true, + fix_newlines: true, + play_sound: true, + autostart: false, + hotkey: "Alt+Shift+C", + custom_params: [], + }, + hotkey_conflict: false, + hwid: "device-123", + is_licensed: true, + }; + + vi.mocked(api.getInitialState).mockResolvedValue(mockInitialPayload); + vi.mocked(api.showSettingsWindow).mockResolvedValue(undefined); + + await act(async () => { + render(); + }); + + expect(api.getInitialState).toHaveBeenCalledTimes(1); + expect(api.showSettingsWindow).toHaveBeenCalledTimes(1); + + expect(screen.getByText("Alt")).toBeInTheDocument(); + expect(screen.getByText("Shift")).toBeInTheDocument(); + expect(screen.getAllByText("C").length).toBe(2); // One in logo, one in keycap + expect(screen.getByText("Play sound on clean")).toBeInTheDocument(); +}); diff --git a/app/src/components/GeneralTab.test.tsx b/app/src/components/GeneralTab.test.tsx new file mode 100644 index 0000000..51e632c --- /dev/null +++ b/app/src/components/GeneralTab.test.tsx @@ -0,0 +1,61 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import { GeneralTab } from "./GeneralTab"; +import { CleanConfig } from "../types"; + +const mockConfig: CleanConfig = { + strip_utms: true, + fix_quotes: true, + fix_newlines: true, + play_sound: true, + autostart: false, + hotkey: "Alt+Shift+C", + custom_params: [], +}; + +test("renders hotkey display and toggles", () => { + const onToggleRecording = vi.fn(); + const onUpdateConfig = vi.fn(); + + render( + , + ); + + expect(screen.getByText("Alt")).toBeInTheDocument(); + expect(screen.getByText("Shift")).toBeInTheDocument(); + expect(screen.getByText("C")).toBeInTheDocument(); + + const hotkeyZone = screen.getByTestId("hotkey-display"); + + expect(hotkeyZone).toBeInTheDocument(); + + fireEvent.click(hotkeyZone); + + expect(onToggleRecording).toHaveBeenCalledTimes(1); + + const soundCheckbox = screen.getAllByRole("checkbox")[0]; + + fireEvent.click(soundCheckbox); + + expect(onUpdateConfig).toHaveBeenCalledWith({ play_sound: false }); +}); + +test("shows conflict text when isConflict is true", () => { + render( + {}} + onUpdateConfig={() => {}} + />, + ); + + expect(screen.getByText(/conflict/i)).toBeInTheDocument(); +}); diff --git a/app/src/components/LicenseTab.test.tsx b/app/src/components/LicenseTab.test.tsx new file mode 100644 index 0000000..dbbad82 --- /dev/null +++ b/app/src/components/LicenseTab.test.tsx @@ -0,0 +1,159 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import { LicenseTab } from "./LicenseTab"; + +test("renders licensed status when licensed", () => { + render( + {}} + onActivate={() => {}} + onOfflineActivate={() => {}} + />, + ); + + expect(screen.getByText("Active")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /activate/i }), + ).not.toBeInTheDocument(); + expect(screen.getByText("hw-123")).toBeInTheDocument(); +}); + +test("renders activation form when unlicensed", () => { + const onKeyInputChange = vi.fn(); + const onActivate = vi.fn(); + + render( + {}} + />, + ); + + expect(screen.getByText("Unregistered")).toBeInTheDocument(); + + const input = screen.getByPlaceholderText(/enter license key.../i); + + expect(input).toHaveValue("my-key"); + + fireEvent.change(input, { target: { value: "new-key" } }); + + expect(onKeyInputChange).toHaveBeenCalledWith("new-key"); + + expect(screen.getByText("Invalid key")).toBeInTheDocument(); + + const button = screen.getByRole("button", { name: "Activate" }); + + fireEvent.click(button); + + expect(onActivate).toHaveBeenCalledTimes(1); +}); + +test("renders spinner and disables input when activating", () => { + render( + {}} + onActivate={() => {}} + onOfflineActivate={() => {}} + />, + ); + + expect(screen.getByPlaceholderText(/enter license key.../i)).toBeDisabled(); + expect(screen.getByRole("button", { name: /activating/i })).toBeDisabled(); + expect(screen.getByText("Activating...")).toBeInTheDocument(); +}); + +test("switches to offline mode when toggle clicked", () => { + render( + {}} + onActivate={() => {}} + onOfflineActivate={() => {}} + />, + ); + + const toggle = screen.getByText("Have an offline token?"); + + fireEvent.click(toggle); + + expect( + screen.getByPlaceholderText(/paste offline token/i), + ).toBeInTheDocument(); + expect( + screen.queryByPlaceholderText(/enter license key.../i), + ).not.toBeInTheDocument(); +}); + +test("calls onOfflineActivate with token when offline mode submitted", () => { + const onOfflineActivate = vi.fn(); + + render( + {}} + onActivate={() => {}} + onOfflineActivate={onOfflineActivate} + />, + ); + + fireEvent.click(screen.getByText("Have an offline token?")); + + const input = screen.getByPlaceholderText(/paste offline token/i); + + fireEvent.change(input, { target: { value: "test.payload.signature" } }); + fireEvent.click(screen.getByRole("button", { name: "Verify Token" })); + + expect(onOfflineActivate).toHaveBeenCalledWith("test.payload.signature"); +}); + +test("switches back to online mode from offline mode", () => { + render( + {}} + onActivate={() => {}} + onOfflineActivate={() => {}} + />, + ); + + fireEvent.click(screen.getByText("Have an offline token?")); + expect( + screen.getByPlaceholderText(/paste offline token/i), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByText("Activate with license key instead")); + expect( + screen.getByPlaceholderText(/enter license key.../i), + ).toBeInTheDocument(); + expect( + screen.queryByPlaceholderText(/paste offline token/i), + ).not.toBeInTheDocument(); +}); diff --git a/app/src/components/RulesTab.test.tsx b/app/src/components/RulesTab.test.tsx new file mode 100644 index 0000000..3f2c3ca --- /dev/null +++ b/app/src/components/RulesTab.test.tsx @@ -0,0 +1,137 @@ +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { expect, test, vi, afterEach } from "vitest"; +import { RulesTab } from "./RulesTab"; +import { CleanConfig } from "../types"; + +const mockConfig: CleanConfig = { + strip_utms: true, + fix_quotes: false, + fix_newlines: true, + play_sound: true, + autostart: false, + hotkey: "Alt+Shift+C", + custom_params: [], +}; + +afterEach(() => { + vi.useRealTimers(); +}); + +test("renders rule options and calls toggles", () => { + const onUpdateConfig = vi.fn(); + + render(); + + expect(screen.getByText("Strip tracking parameters")).toBeInTheDocument(); + expect(screen.getByText("Normalize typography")).toBeInTheDocument(); + expect(screen.getByText("Clean trailing newlines")).toBeInTheDocument(); + + const checkboxes = screen.getAllByRole("checkbox"); + + expect(checkboxes[0]).toBeChecked(); + expect(checkboxes[1]).not.toBeChecked(); + expect(checkboxes[2]).toBeChecked(); + + fireEvent.click(checkboxes[1]); + + expect(onUpdateConfig).toHaveBeenCalledWith({ fix_quotes: true }); +}); + +test("custom params input is visible when strip_utms is enabled", () => { + const onUpdateConfig = vi.fn(); + + render(); + + expect( + screen.getByPlaceholderText("ref, tracking_id, campaign"), + ).toBeInTheDocument(); +}); + +test("custom params input is hidden when strip_utms is disabled", () => { + const onUpdateConfig = vi.fn(); + const configDisabled = { ...mockConfig, strip_utms: false }; + + render(); + + expect( + screen.queryByPlaceholderText("ref, tracking_id, campaign"), + ).not.toBeInTheDocument(); +}); + +test("typing shows debouncing indicator", () => { + vi.useFakeTimers(); + const onUpdateConfig = vi.fn(); + + render(); + + const input = screen.getByPlaceholderText("ref, tracking_id, campaign"); + + fireEvent.change(input, { target: { value: "ref" } }); + + expect(screen.getByText("...")).toBeInTheDocument(); +}); + +test("debouncing indicator disappears after debounce completes", async () => { + vi.useFakeTimers(); + const onUpdateConfig = vi.fn(); + + render(); + + const input = screen.getByPlaceholderText("ref, tracking_id, campaign"); + + fireEvent.change(input, { target: { value: "ref" } }); + + expect(screen.getByText("...")).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(500); + }); + + expect(screen.queryByText("...")).not.toBeInTheDocument(); + expect(screen.getByText("\u2713")).toBeInTheDocument(); +}); + +test("saved indicator fades away after 1.5 seconds", async () => { + vi.useFakeTimers(); + const onUpdateConfig = vi.fn(); + + render(); + + const input = screen.getByPlaceholderText("ref, tracking_id, campaign"); + + fireEvent.change(input, { target: { value: "ref" } }); + + await act(async () => { + vi.advanceTimersByTime(500); + }); + + expect(screen.getByText("\u2713")).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(1500); + }); + + expect(screen.queryByText("\u2713")).not.toBeInTheDocument(); +}); + +test("rapid keystrokes only trigger one config update", async () => { + vi.useFakeTimers(); + const onUpdateConfig = vi.fn(); + + render(); + + const input = screen.getByPlaceholderText("ref, tracking_id, campaign"); + + fireEvent.change(input, { target: { value: "r" } }); + fireEvent.change(input, { target: { value: "re" } }); + fireEvent.change(input, { target: { value: "ref" } }); + + await act(async () => { + vi.advanceTimersByTime(500); + }); + + expect(onUpdateConfig).toHaveBeenCalledTimes(1); + expect(onUpdateConfig).toHaveBeenCalledWith({ + custom_params: ["ref"], + }); +}); diff --git a/app/src/components/TabHeaders.test.tsx b/app/src/components/TabHeaders.test.tsx new file mode 100644 index 0000000..540a37e --- /dev/null +++ b/app/src/components/TabHeaders.test.tsx @@ -0,0 +1,66 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import { TabHeaders } from "./TabHeaders"; + +test("renders tab headers and handles switching", () => { + const onTabChange = vi.fn(); + + render( + , + ); + + expect(screen.getByText("General")).toBeInTheDocument(); + expect(screen.getByText("Rules")).toBeInTheDocument(); + expect(screen.getByText("License")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("Rules")); + + expect(onTabChange).toHaveBeenCalledWith("rules"); +}); + +test("shows warning badge on License tab when unlicensed", () => { + render( + {}} + />, + ); + + expect(screen.getByText("License ⚠️")).toBeInTheDocument(); +}); + +test("General and Rules tabs are disabled when unlicensed", () => { + render( + {}} + />, + ); + + expect(screen.getByRole("tab", { name: "General" })).toBeDisabled(); + expect(screen.getByRole("tab", { name: "Rules" })).toBeDisabled(); + expect(screen.getByRole("tab", { name: /License/i })).not.toBeDisabled(); +}); + +test("clicking disabled tabs when unlicensed does not fire onTabChange", () => { + const onTabChange = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("tab", { name: "General" })); + fireEvent.click(screen.getByRole("tab", { name: "Rules" })); + + expect(onTabChange).not.toHaveBeenCalled(); +}); diff --git a/app/src/components/ToggleSwitch.test.tsx b/app/src/components/ToggleSwitch.test.tsx new file mode 100644 index 0000000..32d275c --- /dev/null +++ b/app/src/components/ToggleSwitch.test.tsx @@ -0,0 +1,31 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import { ToggleSwitch } from "./ToggleSwitch"; + +test("renders unchecked", () => { + render( {}} />); + expect(screen.getByRole("checkbox")).not.toBeChecked(); +}); + +test("renders checked", () => { + render( {}} />); + expect(screen.getByRole("checkbox")).toBeChecked(); +}); + +test("calls onChange with true when clicked unchecked", () => { + const onChange = vi.fn(); + + render(); + fireEvent.click(screen.getByRole("checkbox")); + + expect(onChange).toHaveBeenCalledWith(true); +}); + +test("calls onChange with false when clicked checked", () => { + const onChange = vi.fn(); + + render(); + fireEvent.click(screen.getByRole("checkbox")); + + expect(onChange).toHaveBeenCalledWith(false); +}); diff --git a/app/src/reducer.test.ts b/app/src/reducer.test.ts new file mode 100644 index 0000000..9da5aad --- /dev/null +++ b/app/src/reducer.test.ts @@ -0,0 +1,207 @@ +import { expect, test } from "vitest"; +import { appReducer, initialAppState } from "./reducer"; +import { CleanConfig } from "./types"; + +const mockConfig: CleanConfig = { + strip_utms: false, + fix_quotes: false, + fix_newlines: false, + play_sound: false, + autostart: true, + hotkey: "Ctrl+Shift+D", + custom_params: [], +}; + +test("INITIALIZE action when licensed", () => { + const result = appReducer(initialAppState, { + type: "INITIALIZE", + payload: { + config: mockConfig, + hotkeyConflict: true, + hwid: "device-123", + isLicensed: true, + }, + }); + + expect(result.isLicensed).toBe(true); + expect(result.hwid).toBe("device-123"); + expect(result.config.hotkey).toBe("Ctrl+Shift+D"); + expect(result.tabState.tab).toBe("general"); +}); + +test("INITIALIZE action when unlicensed", () => { + const result = appReducer(initialAppState, { + type: "INITIALIZE", + payload: { + config: mockConfig, + hotkeyConflict: false, + hwid: "device-123", + isLicensed: false, + }, + }); + + expect(result.isLicensed).toBe(false); + expect(result.tabState.tab).toBe("license"); + + if (result.tabState.tab === "license") { + expect(result.tabState.keyInput).toBe(""); + } +}); + +test("SET_TAB transition", () => { + const licensedState = appReducer(initialAppState, { + type: "INITIALIZE", + payload: { + config: mockConfig, + hotkeyConflict: false, + hwid: "", + isLicensed: true, + }, + }); + + const state = appReducer(licensedState, { + type: "SET_TAB", + payload: "rules", + }); + + expect(state.tabState.tab).toBe("rules"); +}); + +test("SET_CONFIG partial update", () => { + const state = appReducer(initialAppState, { + type: "SET_CONFIG", + payload: { play_sound: false }, + }); + + expect(state.config.play_sound).toBe(false); +}); + +test("SET_RECORDING on general tab", () => { + let state = appReducer(initialAppState, { + type: "INITIALIZE", + payload: { + config: mockConfig, + hotkeyConflict: false, + hwid: "", + isLicensed: true, + }, + }); + + state = appReducer(state, { + type: "SET_TAB", + payload: "general", + }); + + state = appReducer(state, { + type: "SET_RECORDING", + payload: true, + }); + + expect(state.tabState.tab).toBe("general"); + + if (state.tabState.tab === "general") { + expect(state.tabState.isRecording).toBe(true); + } +}); + +test("ACTIVATION lifecycle", () => { + let state = appReducer(initialAppState, { + type: "SET_TAB", + payload: "license", + }); + + state = appReducer(state, { + type: "SET_LICENSE_KEY_INPUT", + payload: "test-key", + }); + + if (state.tabState.tab === "license") { + expect(state.tabState.keyInput).toBe("test-key"); + } + + state = appReducer(state, { type: "START_ACTIVATION" }); + + if (state.tabState.tab === "license") { + expect(state.tabState.isActivating).toBe(true); + } + + state = appReducer(state, { + type: "ACTIVATION_FAILURE", + payload: "Invalid Key", + }); + + if (state.tabState.tab === "license") { + expect(state.tabState.isActivating).toBe(false); + expect(state.tabState.error).toBe("Invalid Key"); + } + + state = appReducer(state, { type: "ACTIVATION_SUCCESS" }); + + expect(state.isLicensed).toBe(true); + expect(state.tabState.tab).toBe("license"); + + if (state.tabState.tab === "license") { + expect(state.tabState.keyInput).toBe(""); + expect(state.tabState.isActivating).toBe(false); + expect(state.tabState.error).toBe(""); + } +}); + +test("SET_TAB to general is blocked when unlicensed", () => { + const unlicensedState = appReducer(initialAppState, { + type: "INITIALIZE", + payload: { + config: mockConfig, + hotkeyConflict: false, + hwid: "", + isLicensed: false, + }, + }); + + expect(unlicensedState.tabState.tab).toBe("license"); + + const after = appReducer(unlicensedState, { + type: "SET_TAB", + payload: "general", + }); + + expect(after.tabState.tab).toBe("license"); +}); + +test("SET_TAB to rules is blocked when unlicensed", () => { + const unlicensedState = appReducer(initialAppState, { + type: "INITIALIZE", + payload: { + config: mockConfig, + hotkeyConflict: false, + hwid: "", + isLicensed: false, + }, + }); + + const after = appReducer(unlicensedState, { + type: "SET_TAB", + payload: "rules", + }); + + expect(after.tabState.tab).toBe("license"); +}); + +test("updating config does not reset active tab", () => { + let state = appReducer(initialAppState, { + type: "INITIALIZE", + payload: { + config: mockConfig, + hotkeyConflict: false, + hwid: "", + isLicensed: true, + }, + }); + state = appReducer(state, { type: "SET_TAB", payload: "rules" }); + + expect(state.tabState.tab).toBe("rules"); + + state = appReducer(state, { type: "SET_HOTKEY_CONFLICT", payload: false }); + + expect(state.tabState.tab).toBe("rules"); +}); diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts new file mode 100644 index 0000000..d0de870 --- /dev/null +++ b/app/src/test/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom"; diff --git a/app/vite.config.ts b/app/vite.config.ts new file mode 100644 index 0000000..0fa90ad --- /dev/null +++ b/app/vite.config.ts @@ -0,0 +1,31 @@ +/// +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// @ts-expect-error process is a nodejs global +const host = process.env.TAURI_DEV_HOST; + +export default defineConfig(async () => ({ + plugins: [react()], + test: { + globals: true, + environment: "jsdom", + setupFiles: "./src/test/setup.ts", + }, + clearScreen: false, + server: { + port: 1420, + strictPort: true, + host: host || false, + hmr: host + ? { + protocol: "ws", + host, + port: 1421, + } + : undefined, + watch: { + ignored: ["**/src-tauri/**"], + }, + }, +}));