that is not contentEditable", () => {
+ document.body.innerHTML = '
';
+ mockActiveElement(document.querySelector("div")!);
+ expect(isInputFocused()).toBeFalsy();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// matchesShortcut
+// ---------------------------------------------------------------------------
+describe("matchesShortcut", () => {
+ it("matches by code with modifier on mac (metaKey)", () => {
+ vi.stubGlobal("navigator", { platform: "MacIntel" });
+ expect(
+ matchesShortcut(
+ keyboardEvent({ code: "KeyB", key: "b", metaKey: true }),
+ APP_SHORTCUTS.toggleSidebar,
+ ),
+ ).toBe(true);
+ });
+
+ it("matches by code with modifier on windows (ctrlKey)", () => {
+ vi.stubGlobal("navigator", { platform: "Win32" });
+ expect(
+ matchesShortcut(
+ keyboardEvent({ code: "KeyB", key: "b", ctrlKey: true }),
+ APP_SHORTCUTS.toggleSidebar,
+ ),
+ ).toBe(true);
+ });
+
+ it("returns false when modifier is missing", () => {
+ vi.stubGlobal("navigator", { platform: "MacIntel" });
+ expect(
+ matchesShortcut(
+ keyboardEvent({ code: "KeyB", key: "b", metaKey: false }),
+ APP_SHORTCUTS.toggleSidebar,
+ ),
+ ).toBe(false);
+ });
+
+ it("returns false when shift is pressed but allowShift is not set", () => {
+ vi.stubGlobal("navigator", { platform: "MacIntel" });
+ expect(
+ matchesShortcut(
+ keyboardEvent({ code: "KeyB", key: "B", metaKey: true, shiftKey: true }),
+ APP_SHORTCUTS.toggleSidebar,
+ ),
+ ).toBe(false);
+ });
+
+ it("matches with shift when allowShift is true (retry shortcut)", () => {
+ vi.stubGlobal("navigator", { platform: "MacIntel" });
+ expect(
+ matchesShortcut(
+ keyboardEvent({ code: "KeyR", key: "R", metaKey: true, shiftKey: true }),
+ APP_SHORTCUTS.retryGeneration,
+ ),
+ ).toBe(true);
+ });
+
+ it("matches without shift when allowShift is true", () => {
+ vi.stubGlobal("navigator", { platform: "MacIntel" });
+ expect(
+ matchesShortcut(
+ keyboardEvent({ code: "KeyR", key: "r", metaKey: true, shiftKey: false }),
+ APP_SHORTCUTS.retryGeneration,
+ ),
+ ).toBe(true);
+ });
+
+ it("matches by key when code does not match", () => {
+ vi.stubGlobal("navigator", { platform: "MacIntel" });
+ const def: ShortcutDefinition = {
+ id: "test",
+ key: "z",
+ displayKey: "Z",
+ };
+ expect(
+ matchesShortcut(keyboardEvent({ code: "Unidentified", key: "z", metaKey: true }), def),
+ ).toBe(true);
+ });
+
+ it("matches requiresPrimaryModifier === false without any modifier", () => {
+ vi.stubGlobal("navigator", { platform: "MacIntel" });
+ expect(
+ matchesShortcut(keyboardEvent({ code: "Space", key: " " }), APP_SHORTCUTS.togglePlayback),
+ ).toBe(true);
+ });
+
+ it("matches requiresPrimaryModifier === false by key fallback", () => {
+ vi.stubGlobal("navigator", { platform: "MacIntel" });
+ expect(
+ matchesShortcut(
+ keyboardEvent({ code: "Unidentified", key: " " }),
+ APP_SHORTCUTS.togglePlayback,
+ ),
+ ).toBe(true);
+ });
+
+ it("returns false for requiresPrimaryModifier === false when neither code nor key matches", () => {
+ vi.stubGlobal("navigator", { platform: "MacIntel" });
+ expect(
+ matchesShortcut(keyboardEvent({ code: "KeyX", key: "x" }), APP_SHORTCUTS.togglePlayback),
+ ).toBe(false);
+ });
+
+ it("returns false when only modifier is pressed with no matching key", () => {
+ vi.stubGlobal("navigator", { platform: "MacIntel" });
+ expect(
+ matchesShortcut(
+ keyboardEvent({ code: "KeyZ", key: "z", metaKey: true }),
+ APP_SHORTCUTS.toggleSidebar,
+ ),
+ ).toBe(false);
+ });
+
+ it("returns false when shortcut has neither code nor key defined", () => {
+ vi.stubGlobal("navigator", { platform: "MacIntel" });
+ const def: ShortcutDefinition = { id: "empty", displayKey: "?" };
+ expect(matchesShortcut(keyboardEvent({ code: "KeyA", key: "a", metaKey: true }), def)).toBe(
+ false,
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// shouldHandleGlobalShortcut (extended)
+// ---------------------------------------------------------------------------
describe("global shortcuts", () => {
it("allows Space playback when focus is outside editable controls", () => {
document.body.innerHTML = "
";
@@ -29,4 +301,96 @@ describe("global shortcuts", () => {
),
).toBe(false);
});
+
+ it("blocks modifier shortcut when an input is focused", () => {
+ vi.stubGlobal("navigator", { platform: "MacIntel" });
+ document.body.innerHTML = "
";
+ document.querySelector("input")!.focus();
+
+ expect(
+ shouldHandleGlobalShortcut(
+ keyboardEvent({ code: "KeyB", key: "b", metaKey: true }),
+ APP_SHORTCUTS.toggleSidebar,
+ ),
+ ).toBe(false);
+ });
+
+ it("blocks modifier shortcut when a select is focused", () => {
+ vi.stubGlobal("navigator", { platform: "MacIntel" });
+ document.body.innerHTML = "
";
+ document.querySelector("select")!.focus();
+
+ expect(
+ shouldHandleGlobalShortcut(
+ keyboardEvent({ code: "KeyN", key: "n", metaKey: true }),
+ APP_SHORTCUTS.newGeneration,
+ ),
+ ).toBe(false);
+ });
+
+ it("blocks modifier shortcut when a contentEditable element is focused", () => {
+ vi.stubGlobal("navigator", { platform: "MacIntel" });
+ document.body.innerHTML = '
';
+ const div = document.querySelector("div")!;
+ Object.defineProperty(div, "isContentEditable", { value: true, configurable: true });
+ mockActiveElement(div);
+
+ expect(
+ shouldHandleGlobalShortcut(
+ keyboardEvent({ code: "KeyB", key: "b", metaKey: true }),
+ APP_SHORTCUTS.toggleSidebar,
+ ),
+ ).toBe(false);
+ });
+
+ it("allows Cmd+B toggleSidebar on a button", () => {
+ vi.stubGlobal("navigator", { platform: "MacIntel" });
+ document.body.innerHTML = "
";
+ document.querySelector("button")!.focus();
+
+ expect(
+ shouldHandleGlobalShortcut(
+ keyboardEvent({ code: "KeyB", key: "b", metaKey: true }),
+ APP_SHORTCUTS.toggleSidebar,
+ ),
+ ).toBe(true);
+ });
+
+ it("allows Ctrl+Enter submitGeneration on windows", () => {
+ vi.stubGlobal("navigator", { platform: "Win32" });
+ document.body.innerHTML = "
";
+ document.querySelector("button")!.focus();
+
+ expect(
+ shouldHandleGlobalShortcut(
+ keyboardEvent({ code: "Enter", key: "Enter", ctrlKey: true }),
+ APP_SHORTCUTS.submitGeneration,
+ ),
+ ).toBe(true);
+ });
+
+ it("allows Digit1 compareToggle without modifier (requiresPrimaryModifier === false)", () => {
+ document.body.innerHTML = "
";
+ document.querySelector("button")!.focus();
+
+ expect(
+ shouldHandleGlobalShortcut(
+ keyboardEvent({ code: "Digit1", key: "1" }),
+ APP_SHORTCUTS.compareToggle,
+ ),
+ ).toBe(true);
+ });
+
+ it("returns false when modifier is missing for a normal shortcut", () => {
+ vi.stubGlobal("navigator", { platform: "MacIntel" });
+ document.body.innerHTML = "
";
+ document.querySelector("button")!.focus();
+
+ expect(
+ shouldHandleGlobalShortcut(
+ keyboardEvent({ code: "KeyB", key: "b", metaKey: false }),
+ APP_SHORTCUTS.toggleSidebar,
+ ),
+ ).toBe(false);
+ });
});
diff --git a/tests/unit/bootstrap-banners.test.tsx b/tests/unit/bootstrap-banners.test.tsx
new file mode 100644
index 0000000..cc4d4a7
--- /dev/null
+++ b/tests/unit/bootstrap-banners.test.tsx
@@ -0,0 +1,450 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+// -- Store mock state (mutable per test) ----------------------------------
+
+let storeState: Record
= {};
+
+vi.mock("@/app/lib/store", () => ({
+ useGenerationStore: (selector: (state: Record) => unknown) => selector(storeState),
+}));
+
+// -- Tauri updater mock ---------------------------------------------------
+
+const mockCheck = vi.fn();
+const mockDownloadAndInstall = vi.fn();
+const mockRelaunch = vi.fn();
+
+vi.mock("@tauri-apps/plugin-updater", () => ({
+ check: (...args: unknown[]) => mockCheck(...args),
+}));
+
+vi.mock("@tauri-apps/plugin-process", () => ({
+ relaunch: (...args: unknown[]) => mockRelaunch(...args),
+}));
+
+// -- i18n mock (returns defaultValue when provided, else the key) ---------
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string, opts?: Record) => {
+ if (opts?.defaultValue) return opts.defaultValue as string;
+ return key;
+ },
+ i18n: { language: "en", changeLanguage: vi.fn() },
+ }),
+ initReactI18next: { type: "3rdParty", init: vi.fn() },
+ Trans: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+// -- Default store state --------------------------------------------------
+
+function defaultStoreState() {
+ return {
+ demoMode: false,
+ settings: {
+ modelVariant: null,
+ firstRunCompleted: false,
+ checkForUpdates: true,
+ },
+ bootstrapStatus: { state: "ready", message: "" },
+ dismissDemoMode: vi.fn(),
+ openSettings: vi.fn(),
+ reopenSetup: vi.fn(),
+ };
+}
+
+// -- Imports (must come after mocks) --------------------------------------
+
+import { DemoBanner } from "@/app/components/bootstrap/DemoBanner";
+import { ModelBootstrapBanner } from "@/app/components/bootstrap/ModelBootstrapBanner";
+import { UpdateBanner } from "@/app/components/bootstrap/UpdateBanner";
+
+// ==========================================================================
+// DemoBanner
+// ==========================================================================
+
+describe("DemoBanner", () => {
+ beforeEach(() => {
+ storeState = defaultStoreState();
+ vi.clearAllMocks();
+ });
+
+ it("renders nothing when demoMode is false", () => {
+ storeState.demoMode = false;
+ const { container } = render();
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("renders nothing when demoMode is true but modelVariant is set", () => {
+ storeState.demoMode = true;
+ storeState.settings = { ...storeState.settings, modelVariant: "small" };
+ const { container } = render();
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("renders the banner when demoMode is true and modelVariant is null", () => {
+ storeState.demoMode = true;
+ render();
+ expect(screen.getByRole("status")).toBeTruthy();
+ expect(screen.getByText(/Demo mode/)).toBeTruthy();
+ });
+
+ it("calls openSettings when the choose-model button is clicked", async () => {
+ storeState.demoMode = true;
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByText("model.chooseModel"));
+ expect(storeState.openSettings).toHaveBeenCalledOnce();
+ });
+
+ it("calls dismissDemoMode when the dismiss button is clicked", async () => {
+ storeState.demoMode = true;
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByLabelText("Dismiss"));
+ expect(storeState.dismissDemoMode).toHaveBeenCalledOnce();
+ });
+
+ it("has a role=status and aria-live=polite", () => {
+ storeState.demoMode = true;
+ render();
+ const banner = screen.getByRole("status");
+ expect(banner.getAttribute("aria-live")).toBe("polite");
+ });
+});
+
+// ==========================================================================
+// ModelBootstrapBanner
+// ==========================================================================
+
+describe("ModelBootstrapBanner", () => {
+ beforeEach(() => {
+ storeState = defaultStoreState();
+ vi.clearAllMocks();
+ });
+
+ it("renders nothing when bootstrap state is ready", () => {
+ storeState.bootstrapStatus = { state: "ready", message: "All good" };
+ const { container } = render();
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("renders pending state with a choose-model button (firstRunCompleted=false)", () => {
+ storeState.bootstrapStatus = { state: "pending", message: "Setup required" };
+ storeState.settings = { ...storeState.settings, firstRunCompleted: false };
+ render();
+
+ expect(screen.getByText("Setup required")).toBeTruthy();
+ expect(screen.getByText("setup.openSetup")).toBeTruthy();
+ });
+
+ it("renders pending state with a choose-model button (firstRunCompleted=true)", () => {
+ storeState.bootstrapStatus = { state: "pending", message: "Pick a model" };
+ storeState.settings = { ...storeState.settings, firstRunCompleted: true };
+ render();
+
+ expect(screen.getByText("Pick a model")).toBeTruthy();
+ expect(screen.getByText("model.chooseModel")).toBeTruthy();
+ });
+
+ it("calls reopenSetup when pending and firstRunCompleted is false", async () => {
+ storeState.bootstrapStatus = { state: "pending", message: "Setup" };
+ storeState.settings = { ...storeState.settings, firstRunCompleted: false };
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByText("setup.openSetup"));
+ expect(storeState.reopenSetup).toHaveBeenCalledOnce();
+ });
+
+ it("calls openSettings when pending and firstRunCompleted is true", async () => {
+ storeState.bootstrapStatus = { state: "pending", message: "Setup" };
+ storeState.settings = { ...storeState.settings, firstRunCompleted: true };
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByText("model.chooseModel"));
+ expect(storeState.openSettings).toHaveBeenCalledOnce();
+ });
+
+ it("renders downloading state with progress info", () => {
+ storeState.bootstrapStatus = {
+ state: "downloading",
+ message: "Downloading model...",
+ downloadedBytes: 1024 * 1024 * 1024, // 1 GB
+ totalBytes: 2 * 1024 * 1024 * 1024, // 2 GB
+ };
+ render();
+
+ expect(screen.getByText("Downloading model...")).toBeTruthy();
+ expect(screen.getByText(/1\.0 GB.*2\.0 GB/)).toBeTruthy();
+ expect(screen.getByText(/50%/)).toBeTruthy();
+ });
+
+ it("renders a progress bar during downloading", () => {
+ storeState.bootstrapStatus = {
+ state: "downloading",
+ message: "Downloading...",
+ downloadedBytes: 512 * 1024 * 1024,
+ totalBytes: 1024 * 1024 * 1024,
+ };
+ const { container } = render();
+
+ const progressBar = container.querySelector("[style*='width']");
+ expect(progressBar).toBeTruthy();
+ expect(progressBar?.getAttribute("style")).toContain("50%");
+ });
+
+ it("renders provisioning_backend state with progress", () => {
+ storeState.bootstrapStatus = {
+ state: "provisioning_backend",
+ message: "Provisioning backend...",
+ downloadedBytes: 256 * 1024 * 1024,
+ totalBytes: 512 * 1024 * 1024,
+ };
+ render();
+
+ expect(screen.getByText("Provisioning backend...")).toBeTruthy();
+ expect(screen.getByText(/50%/)).toBeTruthy();
+ });
+
+ it("renders failed state with error details", () => {
+ storeState.bootstrapStatus = {
+ state: "failed",
+ message: "Model not found",
+ error: {
+ code: "MODEL_NOT_FOUND",
+ message: "Model not found",
+ details: "The model file could not be located on disk.",
+ },
+ };
+ render();
+
+ expect(screen.getByText("Model not found")).toBeTruthy();
+ expect(screen.getByText("The model file could not be located on disk.")).toBeTruthy();
+ });
+
+ it("renders failed state without details when details match message", () => {
+ storeState.bootstrapStatus = {
+ state: "failed",
+ message: "Something broke",
+ error: { code: "GENERIC", message: "Something broke", details: "Something broke" },
+ };
+ const { container } = render();
+
+ // The details paragraph should not appear when it equals the message
+ const detailsEl = container.querySelector(".border-t.border-red-500\\/20");
+ expect(detailsEl).toBeNull();
+ });
+
+ it("renders experimental state with open-settings button", () => {
+ storeState.bootstrapStatus = { state: "experimental", message: "Experimental model" };
+ render();
+
+ expect(screen.getByText("Experimental model")).toBeTruthy();
+ expect(screen.getByText("model.openSettings")).toBeTruthy();
+ });
+
+ it("calls openSettings when experimental button is clicked", async () => {
+ storeState.bootstrapStatus = { state: "experimental", message: "Experimental" };
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByText("model.openSettings"));
+ expect(storeState.openSettings).toHaveBeenCalledOnce();
+ });
+
+ it("has role=status and aria-live=polite", () => {
+ storeState.bootstrapStatus = { state: "pending", message: "Setup" };
+ render();
+ const banner = screen.getByRole("status");
+ expect(banner.getAttribute("aria-live")).toBe("polite");
+ });
+});
+
+// ==========================================================================
+// UpdateBanner
+// ==========================================================================
+
+describe("UpdateBanner", () => {
+ beforeEach(() => {
+ storeState = defaultStoreState();
+ vi.clearAllMocks();
+ mockCheck.mockReset();
+ mockDownloadAndInstall.mockReset();
+ mockRelaunch.mockReset();
+ });
+
+ it("renders nothing when no update is available", () => {
+ mockCheck.mockResolvedValue(null);
+ const { container } = render();
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("renders nothing when checkForUpdates is false", () => {
+ storeState.settings = { ...storeState.settings, checkForUpdates: false };
+ mockCheck.mockResolvedValue({ version: "2.0.0", body: "New stuff" });
+ const { container } = render();
+ expect(mockCheck).not.toHaveBeenCalled();
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("shows the full modal when an update is available", async () => {
+ mockCheck.mockResolvedValue({ version: "2.0.0", body: "Bug fixes and improvements" });
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText(/Update available.*2\.0\.0/)).toBeTruthy();
+ });
+ expect(screen.getByText("Bug fixes and improvements")).toBeTruthy();
+ expect(screen.getByText("Install on restart")).toBeTruthy();
+ expect(screen.getByText("Skip")).toBeTruthy();
+ expect(screen.getByText("Release notes")).toBeTruthy();
+ });
+
+ it("shows the modal without release notes when body is null", async () => {
+ mockCheck.mockResolvedValue({ version: "2.1.0", body: null });
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText(/Update available.*2\.1\.0/)).toBeTruthy();
+ });
+ // No release notes box
+ expect(screen.queryByText(/Bug fixes/)).toBeNull();
+ });
+
+ it("dismisses the modal and shows compact banner", async () => {
+ mockCheck.mockResolvedValue({ version: "2.0.0", body: "Notes" });
+ const user = userEvent.setup();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Install on restart")).toBeTruthy();
+ });
+
+ // Click the close button (X icon, labeled with "common.close")
+ await user.click(screen.getByLabelText("common.close"));
+
+ // Modal should be gone, compact banner should appear
+ await waitFor(() => {
+ expect(screen.queryByText("Install on restart")).toBeNull();
+ });
+ expect(screen.getByText("update.view")).toBeTruthy();
+ });
+
+ it("re-opens the modal from the compact banner", async () => {
+ mockCheck.mockResolvedValue({ version: "2.0.0", body: "Notes" });
+ const user = userEvent.setup();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Install on restart")).toBeTruthy();
+ });
+
+ // Dismiss modal
+ await user.click(screen.getByLabelText("common.close"));
+
+ await waitFor(() => {
+ expect(screen.getByText("update.view")).toBeTruthy();
+ });
+
+ // Re-open
+ await user.click(screen.getByText("update.view"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Install on restart")).toBeTruthy();
+ });
+ });
+
+ it("skips the update entirely when skip is clicked", async () => {
+ mockCheck.mockResolvedValue({ version: "2.0.0", body: "Notes" });
+ const user = userEvent.setup();
+ const { container } = render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Skip")).toBeTruthy();
+ });
+
+ await user.click(screen.getByText("Skip"));
+
+ // Both modal and compact banner should be gone
+ await waitFor(() => {
+ expect(container.innerHTML).toBe("");
+ });
+ });
+
+ it("calls downloadAndInstall and relaunch when install is clicked", async () => {
+ mockDownloadAndInstall.mockResolvedValue(undefined);
+ mockRelaunch.mockResolvedValue(undefined);
+ // First call returns the update info, second call (from handleInstall) returns it again
+ mockCheck.mockResolvedValue({
+ version: "2.0.0",
+ body: "Notes",
+ downloadAndInstall: mockDownloadAndInstall,
+ });
+ const user = userEvent.setup();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Install on restart")).toBeTruthy();
+ });
+
+ await user.click(screen.getByText("Install on restart"));
+
+ await waitFor(() => {
+ expect(mockDownloadAndInstall).toHaveBeenCalledOnce();
+ expect(mockRelaunch).toHaveBeenCalledOnce();
+ });
+ });
+
+ it("shows installing text while installation is in progress", async () => {
+ let resolveInstall: () => void;
+ const installPromise = new Promise((resolve) => {
+ resolveInstall = resolve;
+ });
+ mockDownloadAndInstall.mockReturnValue(installPromise);
+ mockCheck.mockResolvedValue({
+ version: "2.0.0",
+ body: "Notes",
+ downloadAndInstall: mockDownloadAndInstall,
+ });
+ const user = userEvent.setup();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Install on restart")).toBeTruthy();
+ });
+
+ await user.click(screen.getByText("Install on restart"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Installing…")).toBeTruthy();
+ });
+
+ // Resolve to clean up
+ resolveInstall!();
+ });
+
+ it("handles updater check errors silently", () => {
+ mockCheck.mockRejectedValue(new Error("Network error"));
+ const { container } = render();
+ // Should not throw; renders nothing since no update was detected
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("renders the release notes link with correct href", async () => {
+ mockCheck.mockResolvedValue({ version: "2.0.0", body: "Notes" });
+ render();
+
+ await waitFor(() => {
+ const link = screen.getByText("Release notes").closest("a");
+ expect(link).toBeTruthy();
+ expect(link?.getAttribute("href")).toBe("https://github.com/thedavidweng/OpenLoop/releases");
+ expect(link?.getAttribute("target")).toBe("_blank");
+ });
+ });
+});
diff --git a/tests/unit/diagnostics.test.ts b/tests/unit/diagnostics.test.ts
new file mode 100644
index 0000000..5b19f55
--- /dev/null
+++ b/tests/unit/diagnostics.test.ts
@@ -0,0 +1,83 @@
+import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
+import {
+ collectDiagnostics,
+ formatDiagnostics,
+ type DiagnosticsBundle,
+} from "@/app/lib/diagnostics";
+
+vi.mock("@tauri-apps/api/core", () => ({
+ invoke: vi.fn(),
+}));
+
+const { invoke } = await import("@tauri-apps/api/core");
+
+const mockBundle: DiagnosticsBundle = {
+ appVersion: "1.0.0",
+ os: "macOS",
+ arch: "aarch64",
+ isAppleSilicon: true,
+ totalMemoryGb: 16,
+ tauriVersion: "2.0.0",
+ backendStatus: "ok",
+ recentErrors: null,
+};
+
+describe("collectDiagnostics", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Ensure window exists (jsdom provides it)
+ delete (window as any).__TAURI_INTERNALS__;
+ });
+
+ it("returns null when __TAURI_INTERNALS__ is absent", async () => {
+ const result = await collectDiagnostics();
+ expect(result).toBeNull();
+ expect(invoke).not.toHaveBeenCalled();
+ });
+
+ it("invokes collect_diagnostics when __TAURI_INTERNALS__ is present", async () => {
+ (invoke as Mock).mockResolvedValueOnce(mockBundle);
+ (window as any).__TAURI_INTERNALS__ = {};
+
+ const result = await collectDiagnostics();
+
+ expect(invoke).toHaveBeenCalledWith("collect_diagnostics");
+ expect(result).toEqual(mockBundle);
+ });
+
+ it("propagates errors from the Tauri invoke call", async () => {
+ (invoke as Mock).mockRejectedValueOnce(new Error("backend down"));
+ (window as any).__TAURI_INTERNALS__ = {};
+
+ await expect(collectDiagnostics()).rejects.toThrow("backend down");
+ });
+});
+
+describe("formatDiagnostics", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ delete (window as any).__TAURI_INTERNALS__;
+ });
+
+ it("returns error JSON when not in Tauri runtime", async () => {
+ const result = await formatDiagnostics();
+ const parsed = JSON.parse(result);
+
+ expect(parsed).toEqual({
+ error: "Diagnostics are only available in the Tauri runtime.",
+ });
+ });
+
+ it("returns pretty-printed bundle JSON in Tauri runtime", async () => {
+ (invoke as Mock).mockResolvedValueOnce(mockBundle);
+ (window as any).__TAURI_INTERNALS__ = {};
+
+ const result = await formatDiagnostics();
+ const parsed = JSON.parse(result);
+
+ expect(parsed).toEqual(mockBundle);
+ // Verify pretty-printing (2-space indent)
+ expect(result).toContain("\n");
+ expect(result).toContain(" ");
+ });
+});
diff --git a/tests/unit/generation-panel-subcomponents.test.tsx b/tests/unit/generation-panel-subcomponents.test.tsx
new file mode 100644
index 0000000..0981f88
--- /dev/null
+++ b/tests/unit/generation-panel-subcomponents.test.tsx
@@ -0,0 +1,911 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type {
+ GenerationFormValues,
+ ValidationErrors,
+ ModelCatalogItem,
+ ModelDownloadState,
+ GenerationState,
+ ActiveGenerationTask,
+} from "@/app/lib/types";
+import { DEFAULT_GENERATION_FORM_VALUES } from "@/app/lib/validation";
+import type { TextField } from "@/app/components/generation/GenerationPanel/shared";
+import {
+ SELECT_OPTIONS,
+ STRUCTURE_TAGS,
+} from "@/app/components/generation/generation-panel-options";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const getRandomPromptExample = vi.fn(() => "lo-fi warm piano, 90 BPM");
+const getRandomPromptByCategory = vi.fn((cat: string) => `a ${cat} track`);
+const PROMPT_CATEGORIES = ["pop", "cinematic", "edm"];
+
+vi.mock("@/app/lib/prompt-examples", () => ({
+ getRandomPromptExample,
+ getRandomPromptByCategory,
+ PROMPT_CATEGORIES,
+}));
+
+vi.mock("@/app/lib/api", () => ({
+ isTauriRuntime: () => false,
+ openFileDialog: vi.fn(),
+}));
+
+vi.mock("@/app/components/overlay/Toast", () => ({
+ useToast: () => ({ addToast: vi.fn() }),
+}));
+
+vi.mock("@/app/components/overlay/Tooltip", () => ({
+ Tooltip: ({ children, label }: { children: React.ReactNode; label: string }) => (
+
+ {children}
+
+ ),
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string, opts?: Record) => {
+ if (opts?.count !== undefined) return `${key}:${opts.count}`;
+ if (opts?.time !== undefined) return `${key}:${opts.time}`;
+ if (opts?.defaultValue) return opts.defaultValue as string;
+ return key;
+ },
+ i18n: { language: "en", changeLanguage: vi.fn() },
+ }),
+ initReactI18next: { type: "3rdParty", init: vi.fn() },
+ Trans: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+// Store mock with controllable state
+const mockToggleFavoritePrompt = vi.fn();
+const mockRemoveRecentPrompt = vi.fn();
+let storeState: {
+ recentPrompts: string[];
+ favoritePrompts: string[];
+ toggleFavoritePrompt: typeof mockToggleFavoritePrompt;
+ removeRecentPrompt: typeof mockRemoveRecentPrompt;
+};
+
+vi.mock("@/app/lib/store", () => ({
+ useGenerationStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
+}));
+
+// ---------------------------------------------------------------------------
+// Imports after mocks
+// ---------------------------------------------------------------------------
+
+const { FieldError, FieldLabel, FilePickerField, handleTextFieldChange } =
+ await import("@/app/components/generation/GenerationPanel/shared");
+const { Header } = await import("@/app/components/generation/GenerationPanel/Header");
+const { FormBody } = await import("@/app/components/generation/GenerationPanel/FormBody");
+const { ActionFooter } = await import("@/app/components/generation/GenerationPanel/ActionFooter");
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function makeForm(overrides: Partial = {}): GenerationFormValues {
+ return { ...DEFAULT_GENERATION_FORM_VALUES, ...overrides };
+}
+
+function makeModel(overrides: Partial = {}): ModelCatalogItem {
+ return {
+ variant: "turbo",
+ label: "ACE-Step Turbo",
+ modelName: "ace-step-turbo",
+ lmModel: null,
+ lmBackend: "mlx",
+ estimatedSizeBytes: 1_000_000_000,
+ description: "Fast generation",
+ recommendedMemoryGb: 8,
+ ...overrides,
+ };
+}
+
+function makeHeaderProps(overrides: Partial[0]> = {}) {
+ return {
+ isBusy: false,
+ activeTasks: [] as ActiveGenerationTask[],
+ prompt: "",
+ onSetField: vi.fn(),
+ onEnhancePrompt: vi.fn().mockResolvedValue(undefined),
+ onResumeTask: vi.fn().mockResolvedValue(undefined),
+ onDiscardTask: vi.fn().mockResolvedValue(undefined),
+ ...overrides,
+ };
+}
+
+function makeFormBodyProps(overrides: Partial[0]> = {}) {
+ return {
+ form: makeForm(),
+ isBusy: false,
+ validationErrors: {} as ValidationErrors,
+ selectedModel: makeModel(),
+ modelReady: true,
+ selectedModelState: "ready" as ModelDownloadState,
+ tweakOpen: false,
+ setTweakOpen: vi.fn(),
+ expertOpen: false,
+ setExpertOpen: vi.fn(),
+ openSettings: vi.fn(),
+ lyricsRef: { current: null },
+ setField: vi.fn(),
+ ...overrides,
+ };
+}
+
+function makeGenerationState(status: GenerationState["status"] = "idle"): GenerationState {
+ return {
+ status,
+ phase: status === "idle" ? "idle" : "running",
+ statusMessage: "",
+ error: null,
+ };
+}
+
+function makeFooterProps(overrides: Partial[0]> = {}) {
+ return {
+ isBusy: false,
+ isFailed: false,
+ canSubmit: true,
+ generationState: makeGenerationState(),
+ elapsedTime: 0,
+ modelReady: true,
+ onCancelGeneration: vi.fn(),
+ onResetForm: vi.fn(),
+ onRetry: vi.fn(),
+ ...overrides,
+ };
+}
+
+// ===========================================================================
+// shared.tsx
+// ===========================================================================
+
+describe("shared: FieldError", () => {
+ it("renders nothing when message is undefined", () => {
+ const { container } = render();
+ expect(container.textContent).toBe("");
+ });
+
+ it("renders nothing when message is empty string", () => {
+ const { container } = render();
+ expect(container.textContent).toBe("");
+ });
+
+ it("renders the error message text", () => {
+ render();
+ expect(screen.getByText("Required field")).toBeInTheDocument();
+ });
+});
+
+describe("shared: FieldLabel", () => {
+ it("renders children text", () => {
+ render(Prompt);
+ expect(screen.getByText("Prompt")).toBeInTheDocument();
+ });
+});
+
+describe("shared: FilePickerField", () => {
+ it("renders the label and an empty input", () => {
+ render();
+ expect(screen.getByText("Reference Audio")).toBeInTheDocument();
+ const input = screen.getByRole("textbox") as HTMLInputElement;
+ expect(input.value).toBe("");
+ });
+
+ it("displays the current value in the input", () => {
+ render();
+ const input = screen.getByRole("textbox") as HTMLInputElement;
+ expect(input.value).toBe("/path/to/file.mp3");
+ });
+
+ it("calls onChange when typing into the input", async () => {
+ const onChange = vi.fn();
+ const user = userEvent.setup();
+ render();
+ const input = screen.getByRole("textbox");
+ await user.type(input, "a");
+ expect(onChange).toHaveBeenCalledWith("a");
+ });
+
+ it("shows a clear button when value is non-empty", () => {
+ render();
+ const clearButton = screen.getAllByRole("button").find((btn) => {
+ const svg = btn.querySelector("svg");
+ return svg !== null;
+ });
+ expect(clearButton).toBeDefined();
+ });
+
+ it("calls onChange('') when the clear button is clicked", async () => {
+ const onChange = vi.fn();
+ const user = userEvent.setup();
+ render();
+ const buttons = screen.getAllByRole("button");
+ // The clear button is the last one with an X icon (no text label)
+ const clearButton = buttons[buttons.length - 1];
+ await user.click(clearButton);
+ expect(onChange).toHaveBeenCalledWith("");
+ });
+
+ it("does not show the browse button when not in Tauri runtime", () => {
+ render();
+ // Only the label text should be present, no "chooseFile" button since isTauriRuntime is false
+ expect(screen.queryByText("generation.chooseFile")).not.toBeInTheDocument();
+ });
+
+ it("disables the input when disabled prop is true", () => {
+ render();
+ const input = screen.getByRole("textbox");
+ expect(input).toBeDisabled();
+ });
+});
+
+describe("shared: handleTextFieldChange", () => {
+ it("returns a function that calls setField with the event value", () => {
+ const setField = vi.fn();
+ const handler = handleTextFieldChange("prompt" as TextField, setField);
+ handler({ target: { value: "new prompt" } } as never);
+ expect(setField).toHaveBeenCalledWith("prompt", "new prompt");
+ });
+});
+
+// ===========================================================================
+// Header
+// ===========================================================================
+
+describe("Header", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ storeState = {
+ recentPrompts: [],
+ favoritePrompts: [],
+ toggleFavoritePrompt: mockToggleFavoritePrompt,
+ removeRecentPrompt: mockRemoveRecentPrompt,
+ };
+ });
+
+ it("renders the composer title and description", () => {
+ render();
+ expect(screen.getByText("generation.composerTitle")).toBeInTheDocument();
+ expect(screen.getByText("generation.composerDescription")).toBeInTheDocument();
+ });
+
+ it("renders the dice, enhance, and favorite buttons", () => {
+ render();
+ expect(
+ screen.getByRole("button", { name: "generation.randomInspiration" }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "generation.enhancePrompt" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "generation.addFavorite" })).toBeInTheDocument();
+ });
+
+ it("calls onSetField with a random prompt when dice button is clicked", async () => {
+ const onSetField = vi.fn();
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByRole("button", { name: "generation.randomInspiration" }));
+ expect(getRandomPromptExample).toHaveBeenCalled();
+ expect(onSetField).toHaveBeenCalledWith("prompt", "lo-fi warm piano, 90 BPM");
+ });
+
+ it("calls onEnhancePrompt when the enhance button is clicked", async () => {
+ const onEnhancePrompt = vi.fn().mockResolvedValue(undefined);
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByRole("button", { name: "generation.enhancePrompt" }));
+ expect(onEnhancePrompt).toHaveBeenCalled();
+ });
+
+ it("disables dice and enhance buttons when isBusy", () => {
+ render();
+ expect(screen.getByRole("button", { name: "generation.randomInspiration" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "generation.enhancePrompt" })).toBeDisabled();
+ });
+
+ it("disables favorite button when isBusy", () => {
+ render();
+ expect(screen.getByRole("button", { name: "generation.addFavorite" })).toBeDisabled();
+ });
+
+ it("renders recent prompt chips when store has recent prompts", () => {
+ storeState = {
+ ...storeState,
+ recentPrompts: ["dark synthwave", "jazz piano trio"],
+ };
+ render();
+ expect(screen.getByText("generation.recentPrompts")).toBeInTheDocument();
+ expect(screen.getByText("dark synthwave")).toBeInTheDocument();
+ expect(screen.getByText("jazz piano trio")).toBeInTheDocument();
+ });
+
+ it("sets prompt to recent chip value when clicked", async () => {
+ const onSetField = vi.fn();
+ storeState = {
+ ...storeState,
+ recentPrompts: ["chill lo-fi beat"],
+ };
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByText("chill lo-fi beat"));
+ expect(onSetField).toHaveBeenCalledWith("prompt", "chill lo-fi beat");
+ });
+
+ it("renders favorite prompt chips when store has favorites", () => {
+ storeState = {
+ ...storeState,
+ favoritePrompts: ["epic orchestral"],
+ };
+ render();
+ expect(screen.getByText("generation.favoritePrompts")).toBeInTheDocument();
+ expect(screen.getByText("epic orchestral")).toBeInTheDocument();
+ });
+
+ it("sets prompt to favorite chip value when clicked", async () => {
+ const onSetField = vi.fn();
+ storeState = {
+ ...storeState,
+ favoritePrompts: ["ambient drone"],
+ };
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByText("ambient drone"));
+ expect(onSetField).toHaveBeenCalledWith("prompt", "ambient drone");
+ });
+
+ it("shows recovery banner with resume and discard buttons when active tasks exist", () => {
+ const activeTasks: ActiveGenerationTask[] = [
+ {
+ id: "rec-1",
+ taskId: "task-1",
+ request: {} as never,
+ variationIndex: 0,
+ variationTotal: 1,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ },
+ ];
+ render();
+ expect(screen.getByText(/generation\.recoveryAvailable/)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /generation\.resumeTask/ })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /generation\.discardTask/ })).toBeInTheDocument();
+ });
+
+ it("calls onResumeTask when resume button is clicked", async () => {
+ const onResumeTask = vi.fn().mockResolvedValue(undefined);
+ const activeTasks: ActiveGenerationTask[] = [
+ {
+ id: "rec-1",
+ taskId: "task-1",
+ request: {} as never,
+ variationIndex: 0,
+ variationTotal: 1,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ },
+ ];
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByRole("button", { name: /generation\.resumeTask/ }));
+ expect(onResumeTask).toHaveBeenCalledWith("rec-1");
+ });
+
+ it("calls onDiscardTask when discard button is clicked", async () => {
+ const onDiscardTask = vi.fn().mockResolvedValue(undefined);
+ const activeTasks: ActiveGenerationTask[] = [
+ {
+ id: "rec-2",
+ taskId: "task-2",
+ request: {} as never,
+ variationIndex: 0,
+ variationTotal: 1,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ },
+ ];
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByRole("button", { name: /generation\.discardTask/ }));
+ expect(onDiscardTask).toHaveBeenCalledWith("rec-2");
+ });
+
+ it("does not render recovery banner when no active tasks", () => {
+ render();
+ expect(screen.queryByText(/generation\.recoveryAvailable/)).not.toBeInTheDocument();
+ });
+});
+
+// ===========================================================================
+// FormBody
+// ===========================================================================
+
+describe("FormBody", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ storeState = {
+ recentPrompts: [],
+ favoritePrompts: [],
+ toggleFavoritePrompt: mockToggleFavoritePrompt,
+ removeRecentPrompt: mockRemoveRecentPrompt,
+ };
+ });
+
+ it("renders the task type select with current value", () => {
+ render();
+ const select = screen.getByDisplayValue("generation.taskTypes.cover");
+ expect(select).toBeInTheDocument();
+ });
+
+ it("renders all task type options", () => {
+ render();
+ const options = screen.getAllByRole("option");
+ const taskTypeOptions = options.filter((opt) =>
+ SELECT_OPTIONS.taskType.some((t) => opt.getAttribute("value") === t),
+ );
+ expect(taskTypeOptions).toHaveLength(SELECT_OPTIONS.taskType.length);
+ });
+
+ it("renders the prompt textarea with current value", () => {
+ render();
+ const textarea = screen.getByPlaceholderText("generation.promptPlaceholder");
+ expect(textarea).toHaveValue("my song");
+ });
+
+ it("calls setField when typing in prompt textarea", async () => {
+ const setField = vi.fn();
+ const user = userEvent.setup();
+ render();
+ const textarea = screen.getByPlaceholderText("generation.promptPlaceholder");
+ await user.type(textarea, "x");
+ expect(setField).toHaveBeenCalledWith("prompt", expect.any(String));
+ });
+
+ it("renders model label when a model is selected", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("ACE-Step Turbo")).toBeInTheDocument();
+ });
+
+ it("renders 'no model' text when selectedModel is null", () => {
+ render();
+ expect(screen.getByText("model.noModel")).toBeInTheDocument();
+ });
+
+ it("shows model ready badge when modelReady is true", () => {
+ render();
+ expect(screen.getByText("model.ready")).toBeInTheDocument();
+ });
+
+ it("shows downloading badge when model state is downloading", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("model.downloading")).toBeInTheDocument();
+ });
+
+ it("shows failed badge when model state is failed", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("model.failed")).toBeInTheDocument();
+ });
+
+ it("shows not installed badge when model state is not_installed", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("model.notInstalled")).toBeInTheDocument();
+ });
+
+ it("calls openSettings when model settings button is clicked", async () => {
+ const openSettings = vi.fn();
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByText(/model\.openSettings/));
+ expect(openSettings).toHaveBeenCalled();
+ });
+
+ it("renders the instrumental checkbox unchecked by default", () => {
+ render();
+ const checkbox = screen.getByRole("checkbox", { name: /generation\.instrumental/i });
+ expect(checkbox).not.toBeChecked();
+ });
+
+ it("calls setField when instrumental checkbox is toggled", async () => {
+ const setField = vi.fn();
+ const user = userEvent.setup();
+ render();
+ const checkbox = screen.getByRole("checkbox", { name: /generation\.instrumental/i });
+ await user.click(checkbox);
+ expect(setField).toHaveBeenCalledWith("instrumental", true);
+ // Also clears lyrics
+ expect(setField).toHaveBeenCalledWith("lyrics", "");
+ });
+
+ it("disables lyrics textarea when instrumental is checked", () => {
+ render();
+ const textarea = screen.getByPlaceholderText("generation.instrumentalDesc");
+ expect(textarea).toBeDisabled();
+ });
+
+ it("hides structure tags when instrumental is on", () => {
+ render();
+ // Structure tag buttons should not be rendered
+ const tagButtons = STRUCTURE_TAGS.map((tag) => screen.queryByText(`generation.${tag}`));
+ tagButtons.forEach((btn) => expect(btn).not.toBeInTheDocument());
+ });
+
+ it("renders structure tags when instrumental is off", () => {
+ render();
+ STRUCTURE_TAGS.forEach((tag) => {
+ expect(screen.getByText(`generation.${tag}`)).toBeInTheDocument();
+ });
+ });
+
+ it("renders duration input with current value", () => {
+ render();
+ const input = screen.getByDisplayValue("120");
+ expect(input).toBeInTheDocument();
+ expect(input).toHaveAttribute("type", "number");
+ });
+
+ it("renders BPM mode select with auto selected by default", () => {
+ render();
+ // Both BPM mode and keyScale selects default to "auto" which displays as "generation.auto"
+ const autoSelects = screen.getAllByDisplayValue("generation.auto");
+ expect(autoSelects.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("disables BPM input when bpmMode is auto", () => {
+ render();
+ const bpmInput = screen.getByPlaceholderText("generation.optional");
+ expect(bpmInput).toBeDisabled();
+ });
+
+ it("enables BPM input when bpmMode is manual", () => {
+ render();
+ const bpmInput = screen.getByPlaceholderText("generation.optional");
+ expect(bpmInput).not.toBeDisabled();
+ });
+
+ it("renders key scale select with options", () => {
+ render();
+ // "generation.auto" is the display text for the auto option in both selects
+ const autoSelects = screen.getAllByDisplayValue("generation.auto");
+ expect(autoSelects.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("renders time signature select", () => {
+ render();
+ const options = screen.getAllByRole("option").filter((o) => o.textContent?.includes("/4"));
+ expect(options.length).toBe(SELECT_OPTIONS.timeSignature.length);
+ });
+
+ it("renders vocal language select with options", () => {
+ render();
+ const enOption = screen.getByText("EN");
+ expect(enOption).toBeInTheDocument();
+ });
+
+ it("disables vocal language select when instrumental is on", () => {
+ render();
+ // Find the language select by its options
+ const selects = screen.getAllByRole("combobox");
+ // The language select is one of the comboboxes
+ const langSelect = selects.find((s) => within(s).queryByText("EN"));
+ expect(langSelect).toBeDefined();
+ expect(langSelect).toBeDisabled();
+ });
+
+ it("renders audio format select", () => {
+ render();
+ expect(screen.getByText("WAV")).toBeInTheDocument();
+ });
+
+ it("renders variation selector buttons 1-4", () => {
+ render();
+ [1, 2, 3, 4].forEach((n) => {
+ expect(
+ screen.getByRole("button", { name: `generation.variationOption:${n}` }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("calls setField when a variation button is clicked", async () => {
+ const setField = vi.fn();
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByRole("button", { name: "generation.variationOption:2" }));
+ expect(setField).toHaveBeenCalledWith("variations", 2);
+ });
+
+ it("marks the current variation button as pressed", () => {
+ render();
+ const btn3 = screen.getByRole("button", { name: "generation.variationOption:3" });
+ expect(btn3).toHaveAttribute("aria-pressed", "true");
+ const btn1 = screen.getByRole("button", { name: "generation.variationOption:1" });
+ expect(btn1).toHaveAttribute("aria-pressed", "false");
+ });
+
+ it("disables form fields when isBusy is true", () => {
+ render();
+ const taskTypeSelect = screen.getByDisplayValue("generation.taskTypes.text2music");
+ expect(taskTypeSelect).toBeDisabled();
+ const promptTextarea = screen.getByPlaceholderText("generation.promptPlaceholder");
+ expect(promptTextarea).toBeDisabled();
+ });
+
+ it("renders validation error for prompt field", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Prompt is required")).toBeInTheDocument();
+ });
+
+ it("renders validation error for lyrics field", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Lyrics too long")).toBeInTheDocument();
+ });
+
+ it("shows 'needsReview' badge on tweak section when tweak fields have errors", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("generation.needsReview")).toBeInTheDocument();
+ });
+
+ it("does not show 'needsReview' badge when no tweak errors", () => {
+ render();
+ expect(screen.queryByText("generation.needsReview")).not.toBeInTheDocument();
+ });
+
+ it("renders the tweak sound collapsible section", () => {
+ render();
+ expect(screen.getByText("generation.tweakSound")).toBeInTheDocument();
+ });
+
+ it("renders the expert mode collapsible section", () => {
+ render();
+ expect(screen.getByText("generation.expertMode")).toBeInTheDocument();
+ });
+
+ it("calls setTweakOpen when tweak collapsible is toggled", async () => {
+ const setTweakOpen = vi.fn();
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByText("generation.tweakSound"));
+ expect(setTweakOpen).toHaveBeenCalledWith(true);
+ });
+
+ it("calls setExpertOpen when expert collapsible is toggled", async () => {
+ const setExpertOpen = vi.fn();
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByText("generation.expertMode"));
+ expect(setExpertOpen).toHaveBeenCalledWith(true);
+ });
+
+ it("renders negative prompt textarea inside tweak section when open", () => {
+ render();
+ expect(screen.getByPlaceholderText("generation.negativePromptPlaceholder")).toBeInTheDocument();
+ });
+
+ it("renders inference steps and guidance scale inputs when tweak is open", () => {
+ render();
+ expect(screen.getByText("generation.inferenceSteps")).toBeInTheDocument();
+ expect(screen.getByText("generation.guidanceScale")).toBeInTheDocument();
+ });
+
+ it("renders random seed checkbox inside tweak section when open", () => {
+ render();
+ const checkbox = screen.getByRole("checkbox", { name: /generation\.randomSeed/i });
+ expect(checkbox).toBeInTheDocument();
+ });
+
+ it("renders expert mode checkboxes when expert section is open", () => {
+ render();
+ expect(screen.getByText("generation.thinking")).toBeInTheDocument();
+ expect(screen.getByText("generation.useFormat")).toBeInTheDocument();
+ expect(screen.getByText("generation.cotCaption")).toBeInTheDocument();
+ expect(screen.getByText("generation.cotLanguage")).toBeInTheDocument();
+ expect(screen.getByText("generation.constrained")).toBeInTheDocument();
+ });
+
+ it("disables lmModel and lmBackend selects when thinking is off", () => {
+ render(
+ ,
+ );
+ const selects = screen.getAllByRole("combobox");
+ // LM selects should be disabled when thinking is false
+ const lmModelSelect = selects.find((s) => within(s).queryByText("None"));
+ expect(lmModelSelect).toBeDefined();
+ expect(lmModelSelect).toBeDisabled();
+ });
+});
+
+// ===========================================================================
+// ActionFooter
+// ===========================================================================
+
+describe("ActionFooter", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ storeState = {
+ recentPrompts: [],
+ favoritePrompts: [],
+ toggleFavoritePrompt: mockToggleFavoritePrompt,
+ removeRecentPrompt: mockRemoveRecentPrompt,
+ };
+ });
+
+ it("renders the generate button with generate label when idle", () => {
+ render();
+ const btn = screen.getByRole("button", { name: /generation\.generate/ });
+ expect(btn).toBeInTheDocument();
+ expect(btn).toHaveAttribute("type", "submit");
+ });
+
+ it("renders the reset button", () => {
+ render();
+ expect(screen.getByRole("button", { name: /generation\.reset/ })).toBeInTheDocument();
+ });
+
+ it("calls onResetForm when reset button is clicked", async () => {
+ const onResetForm = vi.fn();
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByRole("button", { name: /generation\.reset/ }));
+ expect(onResetForm).toHaveBeenCalled();
+ });
+
+ it("shows cancel button when isBusy is true", () => {
+ render();
+ expect(screen.getByRole("button", { name: /common\.cancel/ })).toBeInTheDocument();
+ });
+
+ it("does not show cancel button when not busy", () => {
+ render();
+ expect(screen.queryByRole("button", { name: /common\.cancel/ })).not.toBeInTheDocument();
+ });
+
+ it("calls onCancelGeneration when cancel button is clicked", async () => {
+ const onCancelGeneration = vi.fn();
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByRole("button", { name: /common\.cancel/ }));
+ expect(onCancelGeneration).toHaveBeenCalled();
+ });
+
+ it("disables the generate button when isBusy is true", () => {
+ render(
+ ,
+ );
+ const btn = screen.getByRole("button", { name: /generation\.generatingElapsed/ });
+ expect(btn).toBeDisabled();
+ });
+
+ it("disables the generate button when canSubmit is false", () => {
+ render();
+ const btn = screen.getByRole("button", { name: /generation\.generate/ });
+ expect(btn).toBeDisabled();
+ });
+
+ it("shows retry button when isFailed is true and not busy", () => {
+ render();
+ expect(screen.getByRole("button", { name: /generation\.retry/ })).toBeInTheDocument();
+ });
+
+ it("does not show retry button when isFailed is false", () => {
+ render();
+ expect(screen.queryByRole("button", { name: /generation\.retry/ })).not.toBeInTheDocument();
+ });
+
+ it("does not show retry button when isFailed but isBusy", () => {
+ render();
+ expect(screen.queryByRole("button", { name: /generation\.retry/ })).not.toBeInTheDocument();
+ });
+
+ it("calls onRetry when retry button is clicked", async () => {
+ const onRetry = vi.fn();
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByRole("button", { name: /generation\.retry/ }));
+ expect(onRetry).toHaveBeenCalled();
+ });
+
+ it("shows 'generatingElapsed' label with formatted time when running", () => {
+ render(
+ ,
+ );
+ // formatElapsed(75) => "1:15"
+ expect(screen.getByText("generation.generatingElapsed:1:15")).toBeInTheDocument();
+ });
+
+ it("shows 'validating' label when validating", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("generation.validating")).toBeInTheDocument();
+ });
+
+ it("shows elapsed time with padded seconds", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("generation.generatingElapsed:1:05")).toBeInTheDocument();
+ });
+
+ it("shows zero elapsed time correctly", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("generation.generatingElapsed:0:00")).toBeInTheDocument();
+ });
+
+ it("shows localReady message when modelReady is true", () => {
+ render();
+ expect(screen.getByText("generation.localReady")).toBeInTheDocument();
+ });
+
+ it("shows chooseFirst message when modelReady is false", () => {
+ render();
+ expect(screen.getByText("model.chooseFirst")).toBeInTheDocument();
+ });
+
+ it("disables reset button when isBusy is true", () => {
+ render();
+ expect(screen.getByRole("button", { name: /generation\.reset/ })).toBeDisabled();
+ });
+});
diff --git a/tests/unit/layout-components.test.tsx b/tests/unit/layout-components.test.tsx
new file mode 100644
index 0000000..8ac66c3
--- /dev/null
+++ b/tests/unit/layout-components.test.tsx
@@ -0,0 +1,956 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+// jsdom lacks setPointerCapture — stub it for drag tests
+vi.hoisted(() => {
+ if (!HTMLElement.prototype.setPointerCapture) {
+ HTMLElement.prototype.setPointerCapture = () => {};
+ }
+ if (!HTMLElement.prototype.releasePointerCapture) {
+ HTMLElement.prototype.releasePointerCapture = () => {};
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Shared mocks
+// ---------------------------------------------------------------------------
+
+const mockRevealInFinder = vi.fn<(path: string) => Promise>();
+
+vi.mock("@/app/lib/api", () => ({
+ isTauriRuntime: () => true,
+ revealInFinder: (path: string) => mockRevealInFinder(path),
+ getWindowShellState: () =>
+ Promise.resolve({
+ chrome_variant: "mac",
+ tier: "mac",
+ toolbar_height: 48,
+ traffic_light_inset_leading: 78,
+ sidebar_header_height: 28,
+ sidebar_width: 260,
+ }),
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string, opts?: Record) => {
+ if (opts?.defaultValue) return opts.defaultValue as string;
+ return key;
+ },
+ i18n: { language: "en", changeLanguage: vi.fn() },
+ }),
+ initReactI18next: { type: "3rdParty", init: vi.fn() },
+ Trans: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+vi.mock("@/app/components/overlay/Tooltip", () => ({
+ Tooltip: ({ children, label }: { children: React.ReactNode; label: string }) => (
+ {children}
+ ),
+}));
+
+vi.mock("@/app/components/overlay/Toast", () => ({
+ useToast: () => ({ addToast: vi.fn() }),
+}));
+
+vi.mock("@/app/components/history/HistorySidebar", () => ({
+ HistorySidebar: () => ,
+}));
+
+vi.mock("@/app/components/player/PlaybackBar", () => ({
+ PlaybackBar: () => ,
+}));
+
+vi.mock("@/app/components/bootstrap/DemoBanner", () => ({
+ DemoBanner: () => ,
+}));
+
+vi.mock("@/app/components/bootstrap/ModelBootstrapBanner", () => ({
+ ModelBootstrapBanner: () => ,
+}));
+
+vi.mock("@/app/components/settings/SettingsOverlay", () => ({
+ SettingsOverlay: () => ,
+}));
+
+vi.mock("@/app/components/generation/GenerationPanel", () => ({
+ GenerationPanel: () => ,
+}));
+
+// ---------------------------------------------------------------------------
+// Store mock
+// ---------------------------------------------------------------------------
+
+const mockToggleSidebar = vi.fn();
+const mockToggleSettings = vi.fn();
+const mockResetForm = vi.fn();
+const mockRunGeneration = vi.fn(() => Promise.resolve());
+const mockRequestPlaybackToggle = vi.fn();
+const mockToggleCompareTarget = vi.fn();
+const mockSetSidebarWidth = vi.fn();
+const mockReopenSetup = vi.fn();
+
+interface StoreState {
+ sidebarVisible: boolean;
+ sidebarWidth: number;
+ setSidebarWidth: (w: number) => void;
+ toggleSidebar: () => void;
+ isSettingsOpen: boolean;
+ toggleSettings: () => void;
+ resetForm: () => void;
+ runGeneration: () => Promise;
+ requestPlaybackToggle: () => void;
+ generationState: {
+ status: string;
+ phase: string;
+ statusMessage: string;
+ error: { code: string; message: string; details?: string } | null;
+ };
+ compareModeActive: boolean;
+ toggleCompareTarget: () => void;
+ demoMode: boolean;
+ settings: { outputDirectory: string };
+ reopenSetup: () => void;
+}
+
+let currentStoreState: StoreState;
+
+function makeStoreOverrides(overrides: Partial = {}): StoreState {
+ return {
+ sidebarVisible: true,
+ sidebarWidth: 260,
+ setSidebarWidth: mockSetSidebarWidth,
+ toggleSidebar: mockToggleSidebar,
+ isSettingsOpen: false,
+ toggleSettings: mockToggleSettings,
+ resetForm: mockResetForm,
+ runGeneration: mockRunGeneration,
+ requestPlaybackToggle: mockRequestPlaybackToggle,
+ generationState: {
+ status: "idle",
+ phase: "idle",
+ statusMessage: "Ready",
+ error: null,
+ },
+ compareModeActive: false,
+ toggleCompareTarget: mockToggleCompareTarget,
+ demoMode: false,
+ settings: { outputDirectory: "/tmp/output" },
+ reopenSetup: mockReopenSetup,
+ ...overrides,
+ };
+}
+
+vi.mock("@/app/lib/store", () => ({
+ useGenerationStore: (selector: (state: StoreState) => unknown) => selector(currentStoreState),
+}));
+
+// ---------------------------------------------------------------------------
+// Imports after mocks
+// ---------------------------------------------------------------------------
+
+const { WindowChrome } = await import("@/app/components/layout/WindowChrome");
+const { Toolbar } = await import("@/app/components/layout/Toolbar");
+const { SidebarRail } = await import("@/app/components/layout/SidebarRail");
+const { MainContentView } = await import("@/app/components/layout/MainContentView");
+const { OpenLoopStage } = await import("@/app/components/layout/OpenLoopStage");
+const { AppLayout } = await import("@/app/components/layout/AppLayout");
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const macShellState = {
+ chromeVariant: "mac" as const,
+ tier: "mac" as const,
+ toolbarHeight: 48,
+ trafficLightInsetLeading: 78,
+ sidebarHeaderHeight: 28,
+ sidebarWidth: 260,
+};
+
+// ===========================================================================
+// WindowChrome
+// ===========================================================================
+
+describe("WindowChrome", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ currentStoreState = makeStoreOverrides();
+ });
+
+ it("renders a toolbar element", () => {
+ const { container } = render(
+ ,
+ );
+ // WindowChrome delegates to Toolbar which renders a div
+ expect(container.querySelector("[data-window-shell-tier]")).toBeTruthy();
+ });
+
+ it("forwards props to Toolbar", () => {
+ const onToggleSidebar = vi.fn();
+ const onToggleSettings = vi.fn();
+ render(
+ ,
+ );
+ // Sidebar toggle button should be present
+ expect(screen.getByLabelText("toolbar.toggleSidebar")).toBeTruthy();
+ // Settings button should be present
+ expect(screen.getByLabelText("toolbar.settings")).toBeTruthy();
+ });
+});
+
+// ===========================================================================
+// Toolbar
+// ===========================================================================
+
+describe("Toolbar", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ currentStoreState = makeStoreOverrides();
+ mockRevealInFinder.mockResolvedValue(undefined);
+ });
+
+ it("renders sidebar toggle, new generation, reveal output, open setup, and settings buttons", () => {
+ render(
+ ,
+ );
+ expect(screen.getByLabelText("toolbar.toggleSidebar")).toBeTruthy();
+ expect(screen.getByLabelText("toolbar.revealOutput")).toBeTruthy();
+ expect(screen.getByLabelText("toolbar.openSetup")).toBeTruthy();
+ expect(screen.getByLabelText("toolbar.settings")).toBeTruthy();
+ expect(screen.getByText("toolbar.newGeneration")).toBeTruthy();
+ });
+
+ it("applies active style to sidebar toggle when sidebar is visible", () => {
+ render(
+ ,
+ );
+ const sidebarBtn = screen.getByLabelText("toolbar.toggleSidebar");
+ expect(sidebarBtn.className).toContain("bg-[color-mix");
+ expect(sidebarBtn.className).toContain("text-white");
+ });
+
+ it("applies inactive style to sidebar toggle when sidebar is hidden", () => {
+ render(
+ ,
+ );
+ const sidebarBtn = screen.getByLabelText("toolbar.toggleSidebar");
+ expect(sidebarBtn.className).toContain("text-[var(--color-text-dim)]");
+ });
+
+ it("applies active style to settings button when settings are open", () => {
+ render(
+ ,
+ );
+ const settingsBtn = screen.getByLabelText("toolbar.settings");
+ expect(settingsBtn.className).toContain("text-white");
+ });
+
+ it("applies inactive style to settings button when settings are closed", () => {
+ render(
+ ,
+ );
+ const settingsBtn = screen.getByLabelText("toolbar.settings");
+ expect(settingsBtn.className).toContain("text-[var(--color-text-dim)]");
+ });
+
+ it("calls onToggleSidebar when sidebar button is clicked", async () => {
+ const user = userEvent.setup();
+ const onToggleSidebar = vi.fn();
+ render(
+ ,
+ );
+ await user.click(screen.getByLabelText("toolbar.toggleSidebar"));
+ expect(onToggleSidebar).toHaveBeenCalledOnce();
+ });
+
+ it("calls onToggleSettings when settings button is clicked", async () => {
+ const user = userEvent.setup();
+ const onToggleSettings = vi.fn();
+ render(
+ ,
+ );
+ await user.click(screen.getByLabelText("toolbar.settings"));
+ expect(onToggleSettings).toHaveBeenCalledOnce();
+ });
+
+ it("calls resetForm when new generation button is clicked", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ await user.click(screen.getByText("toolbar.newGeneration"));
+ expect(mockResetForm).toHaveBeenCalledOnce();
+ });
+
+ it("calls reopenSetup when open setup button is clicked", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ await user.click(screen.getByLabelText("toolbar.openSetup"));
+ expect(mockReopenSetup).toHaveBeenCalledOnce();
+ });
+
+ it("calls revealInFinder with output directory when reveal button is clicked", async () => {
+ const user = userEvent.setup();
+ currentStoreState = makeStoreOverrides({
+ settings: { outputDirectory: "/Users/test/music" },
+ });
+ render(
+ ,
+ );
+ await user.click(screen.getByLabelText("toolbar.revealOutput"));
+ expect(mockRevealInFinder).toHaveBeenCalledWith("/Users/test/music");
+ });
+
+ it("does not call revealInFinder when output directory is empty", async () => {
+ const user = userEvent.setup();
+ currentStoreState = makeStoreOverrides({
+ settings: { outputDirectory: "" },
+ });
+ render(
+ ,
+ );
+ await user.click(screen.getByLabelText("toolbar.revealOutput"));
+ expect(mockRevealInFinder).not.toHaveBeenCalled();
+ });
+
+ it("renders with a data-tauri-drag-region element", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("[data-tauri-drag-region]")).toBeTruthy();
+ });
+});
+
+// ===========================================================================
+// SidebarRail
+// ===========================================================================
+
+describe("SidebarRail", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders children", () => {
+ render(
+
+ Child
+ ,
+ );
+ expect(screen.getByTestId("child-content")).toBeTruthy();
+ expect(screen.getByText("Child")).toBeTruthy();
+ });
+
+ it("renders the resize separator when visible", () => {
+ render(
+
+ Child
+ ,
+ );
+ const separator = screen.getByRole("separator");
+ expect(separator).toBeTruthy();
+ expect(separator.getAttribute("aria-orientation")).toBe("vertical");
+ });
+
+ it("does not render the resize separator when hidden", () => {
+ render(
+
+ Child
+ ,
+ );
+ expect(screen.queryByRole("separator")).toBeNull();
+ });
+
+ it("applies w-0 class when not visible", () => {
+ const { container } = render(
+
+ Child
+ ,
+ );
+ const outerDiv = container.firstElementChild as HTMLElement;
+ expect(outerDiv.className).toContain("w-0");
+ });
+
+ it("applies variable width class when visible", () => {
+ const { container } = render(
+
+ Child
+ ,
+ );
+ const outerDiv = container.firstElementChild as HTMLElement;
+ expect(outerDiv.className).toContain("w-[var(--window-shell-sidebar-width)]");
+ });
+
+ it("applies translate and opacity classes when visible", () => {
+ render(
+
+ Child
+ ,
+ );
+ const inner = screen.getByTestId("inner").parentElement as HTMLElement;
+ expect(inner.className).toContain("translate-x-0");
+ expect(inner.className).toContain("opacity-100");
+ });
+
+ it("applies hidden translate and opacity when not visible", () => {
+ render(
+
+ Child
+ ,
+ );
+ const inner = screen.getByTestId("inner").parentElement as HTMLElement;
+ expect(inner.className).toContain("-translate-x-3");
+ expect(inner.className).toContain("opacity-0");
+ });
+
+ it("calls onResize with clamped width during drag", () => {
+ const onResize = vi.fn();
+ render(
+
+ Child
+ ,
+ );
+ const separator = screen.getByRole("separator");
+
+ // Simulate pointer down then pointer move
+ fireEvent.pointerDown(separator, { clientX: 100, pointerId: 1 });
+ fireEvent.pointerMove(window, { clientX: 150 });
+
+ // Delta = 50, new width = 300 + 50 = 350, clamped to max 420
+ expect(onResize).toHaveBeenCalledWith(350);
+ });
+
+ it("clamps resize to minimum width", () => {
+ const onResize = vi.fn();
+ render(
+
+ Child
+ ,
+ );
+ const separator = screen.getByRole("separator");
+
+ fireEvent.pointerDown(separator, { clientX: 200, pointerId: 1 });
+ fireEvent.pointerMove(window, { clientX: 50 });
+
+ // Delta = -150, new width = 260 - 150 = 110, clamped to min 240
+ expect(onResize).toHaveBeenCalledWith(240);
+ });
+
+ it("clamps resize to maximum width", () => {
+ const onResize = vi.fn();
+ render(
+
+ Child
+ ,
+ );
+ const separator = screen.getByRole("separator");
+
+ fireEvent.pointerDown(separator, { clientX: 100, pointerId: 1 });
+ fireEvent.pointerMove(window, { clientX: 200 });
+
+ // Delta = 100, new width = 400 + 100 = 500, clamped to max 420
+ expect(onResize).toHaveBeenCalledWith(420);
+ });
+});
+
+// ===========================================================================
+// MainContentView
+// ===========================================================================
+
+describe("MainContentView", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ currentStoreState = makeStoreOverrides();
+ });
+
+ it("renders OpenLoopStage", () => {
+ render();
+ expect(screen.getByTestId("generation-panel")).toBeTruthy();
+ });
+
+ it("renders PlaybackBar", () => {
+ render();
+ expect(screen.getByTestId("playback-bar")).toBeTruthy();
+ });
+
+ it("renders ModelBootstrapBanner in normal mode", () => {
+ currentStoreState = makeStoreOverrides({ demoMode: false });
+ render();
+ expect(screen.getByTestId("model-bootstrap-banner")).toBeTruthy();
+ expect(screen.queryByTestId("demo-banner")).toBeNull();
+ });
+
+ it("renders DemoBanner in demo mode", () => {
+ currentStoreState = makeStoreOverrides({ demoMode: true });
+ render();
+ expect(screen.getByTestId("demo-banner")).toBeTruthy();
+ expect(screen.queryByTestId("model-bootstrap-banner")).toBeNull();
+ });
+
+ it("does not render SettingsOverlay when settings are closed", () => {
+ currentStoreState = makeStoreOverrides({ isSettingsOpen: false });
+ render();
+ expect(screen.queryByTestId("settings-overlay")).toBeNull();
+ });
+
+ it("renders SettingsOverlay when settings are open", async () => {
+ currentStoreState = makeStoreOverrides({ isSettingsOpen: true });
+ render();
+ expect(await screen.findByTestId("settings-overlay")).toBeTruthy();
+ });
+
+ it("applies muted background when settings are open", () => {
+ currentStoreState = makeStoreOverrides({ isSettingsOpen: true });
+ const { container } = render();
+ const root = container.firstElementChild as HTMLElement;
+ expect(root.className).toContain("bg-[var(--color-surface-muted)]");
+ });
+
+ it("applies normal background when settings are closed", () => {
+ currentStoreState = makeStoreOverrides({ isSettingsOpen: false });
+ const { container } = render();
+ const root = container.firstElementChild as HTMLElement;
+ expect(root.className).toContain("bg-[var(--color-surface)]");
+ });
+
+ it("sets data-main-content-visual-variant attribute", () => {
+ const { container } = render();
+ const root = container.firstElementChild as HTMLElement;
+ expect(root.getAttribute("data-main-content-visual-variant")).toBe("unified");
+ });
+});
+
+// ===========================================================================
+// OpenLoopStage
+// ===========================================================================
+
+describe("OpenLoopStage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ currentStoreState = makeStoreOverrides();
+ vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined);
+ vi.spyOn(window, "open").mockReturnValue(null);
+ });
+
+ it("renders GenerationPanel", () => {
+ render();
+ expect(screen.getByTestId("generation-panel")).toBeTruthy();
+ });
+
+ it("does not show running banner when idle", () => {
+ currentStoreState = makeStoreOverrides({
+ generationState: { status: "idle", phase: "idle", statusMessage: "Ready", error: null },
+ });
+ render();
+ expect(screen.queryByText("Ready")).toBeNull();
+ });
+
+ it("shows running banner with status message when running", () => {
+ currentStoreState = makeStoreOverrides({
+ generationState: {
+ status: "running",
+ phase: "generating",
+ statusMessage: "Generating audio...",
+ error: null,
+ },
+ });
+ render();
+ expect(screen.getByText("Generating audio...")).toBeTruthy();
+ });
+
+ it("shows running banner when validating", () => {
+ currentStoreState = makeStoreOverrides({
+ generationState: {
+ status: "validating",
+ phase: "validating",
+ statusMessage: "Validating inputs...",
+ error: null,
+ },
+ });
+ render();
+ expect(screen.getByText("Validating inputs...")).toBeTruthy();
+ });
+
+ it("shows error banner when generation fails", () => {
+ currentStoreState = makeStoreOverrides({
+ generationState: {
+ status: "failed",
+ phase: "failed",
+ statusMessage: "Failed",
+ error: { code: "TASK_FAILED", message: "Generation task failed", details: "timeout" },
+ },
+ });
+ render();
+ expect(screen.getByText("Something went wrong")).toBeTruthy();
+ });
+
+ it("does not show error banner when failed but no error object", () => {
+ currentStoreState = makeStoreOverrides({
+ generationState: { status: "failed", phase: "failed", statusMessage: "Failed", error: null },
+ });
+ render();
+ expect(screen.queryByText("Something went wrong")).toBeNull();
+ });
+
+ it("shows error details in collapsible section", () => {
+ currentStoreState = makeStoreOverrides({
+ generationState: {
+ status: "failed",
+ phase: "failed",
+ statusMessage: "Failed",
+ error: {
+ code: "TASK_FAILED",
+ message: "Generation task failed",
+ details: "model not found",
+ },
+ },
+ });
+ render();
+ expect(screen.getByText("Show details")).toBeTruthy();
+ expect(screen.getByText(/TASK_FAILED/)).toBeTruthy();
+ expect(screen.getByText(/Generation task failed/)).toBeTruthy();
+ expect(screen.getByText(/model not found/)).toBeTruthy();
+ });
+
+ it("shows error details without details field when not provided", () => {
+ currentStoreState = makeStoreOverrides({
+ generationState: {
+ status: "failed",
+ phase: "failed",
+ statusMessage: "Failed",
+ error: { code: "TASK_FAILED", message: "Generation task failed" },
+ },
+ });
+ render();
+ expect(screen.getByText("Show details")).toBeTruthy();
+ expect(screen.getByText(/TASK_FAILED/)).toBeTruthy();
+ });
+
+ it("renders retry, copy details, and get help buttons when failed", () => {
+ currentStoreState = makeStoreOverrides({
+ generationState: {
+ status: "failed",
+ phase: "failed",
+ statusMessage: "Failed",
+ error: { code: "TASK_FAILED", message: "something broke" },
+ },
+ });
+ render();
+ expect(screen.getByText("Retry")).toBeTruthy();
+ expect(screen.getByText("Copy details")).toBeTruthy();
+ expect(screen.getByText("Get help")).toBeTruthy();
+ });
+
+ it("calls runGeneration when retry is clicked", async () => {
+ const user = userEvent.setup();
+ currentStoreState = makeStoreOverrides({
+ generationState: {
+ status: "failed",
+ phase: "failed",
+ statusMessage: "Failed",
+ error: { code: "TASK_FAILED", message: "broke" },
+ },
+ });
+ render();
+ await user.click(screen.getByText("Retry"));
+ expect(mockRunGeneration).toHaveBeenCalledOnce();
+ });
+
+ it("copies error details to clipboard when copy details is clicked", async () => {
+ const user = userEvent.setup();
+ const error = { code: "TASK_FAILED", message: "broke", details: "extra info" };
+ currentStoreState = makeStoreOverrides({
+ generationState: { status: "failed", phase: "failed", statusMessage: "Failed", error },
+ });
+ render();
+ await user.click(screen.getByText("Copy details"));
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(JSON.stringify(error, null, 2));
+ });
+
+ it("opens GitHub issue URL when get help is clicked", async () => {
+ const user = userEvent.setup();
+ const error = { code: "TASK_FAILED", message: "broke" };
+ currentStoreState = makeStoreOverrides({
+ generationState: { status: "failed", phase: "failed", statusMessage: "Failed", error },
+ });
+ render();
+ await user.click(screen.getByText("Get help"));
+ expect(window.open).toHaveBeenCalledWith(
+ expect.stringContaining("github.com"),
+ "_blank",
+ "noopener,noreferrer",
+ );
+ });
+
+ it("sets data-stage-visual-variant attribute", () => {
+ const { container } = render();
+ const root = container.firstElementChild as HTMLElement;
+ expect(root.getAttribute("data-stage-visual-variant")).toBe("ambience");
+ });
+});
+
+// ===========================================================================
+// AppLayout
+// ===========================================================================
+
+describe("AppLayout", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ currentStoreState = makeStoreOverrides();
+ });
+
+ it("renders the main layout structure", () => {
+ render();
+ // WindowChrome (toolbar)
+ expect(screen.getByLabelText("toolbar.toggleSidebar")).toBeTruthy();
+ // MainContentView -> OpenLoopStage -> GenerationPanel
+ expect(screen.getByTestId("generation-panel")).toBeTruthy();
+ // PlaybackBar from MainContentView
+ expect(screen.getByTestId("playback-bar")).toBeTruthy();
+ // HistorySidebar inside SidebarRail
+ expect(screen.getByTestId("history-sidebar")).toBeTruthy();
+ });
+
+ it("sets data-window-chrome-platform attribute on root", () => {
+ const { container } = render();
+ const root = container.firstElementChild as HTMLElement;
+ expect(root.getAttribute("data-window-chrome-platform")).toBeTruthy();
+ });
+
+ it("sets data-window-shell-tier attribute on root", () => {
+ const { container } = render();
+ const root = container.firstElementChild as HTMLElement;
+ expect(root.getAttribute("data-window-shell-tier")).toBeTruthy();
+ });
+
+ it("applies window shell CSS custom properties on root", () => {
+ const { container } = render();
+ const root = container.firstElementChild as HTMLElement;
+ expect(root.style.getPropertyValue("--window-shell-sidebar-width")).toBeTruthy();
+ expect(root.style.getPropertyValue("--window-shell-toolbar-height")).toBeTruthy();
+ });
+
+ it("toggles sidebar when Ctrl+B shortcut is pressed", () => {
+ render();
+ fireEvent.keyDown(window, { key: "b", code: "KeyB", ctrlKey: true });
+ expect(mockToggleSidebar).toHaveBeenCalledOnce();
+ });
+
+ it("toggles settings when Ctrl+, shortcut is pressed", () => {
+ render();
+ fireEvent.keyDown(window, { key: ",", code: "Comma", ctrlKey: true });
+ expect(mockToggleSettings).toHaveBeenCalledOnce();
+ });
+
+ it("resets form when Ctrl+N shortcut is pressed", () => {
+ render();
+ fireEvent.keyDown(window, { key: "n", code: "KeyN", ctrlKey: true });
+ expect(mockResetForm).toHaveBeenCalledOnce();
+ });
+
+ it("opens keyboard shortcuts dialog when Ctrl+/ is pressed", () => {
+ render();
+ fireEvent.keyDown(window, { key: "/", code: "Slash", ctrlKey: true });
+ expect(screen.getByRole("dialog")).toBeTruthy();
+ expect(screen.getByText("Common OpenLoop commands.")).toBeTruthy();
+ });
+
+ it("closes keyboard shortcuts dialog on Escape", () => {
+ render();
+ // Open dialog
+ fireEvent.keyDown(window, { key: "/", code: "Slash", ctrlKey: true });
+ expect(screen.getByRole("dialog")).toBeTruthy();
+ // Close with Escape
+ fireEvent.keyDown(window, { key: "Escape" });
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ it("closes keyboard shortcuts dialog on backdrop click", async () => {
+ const user = userEvent.setup();
+ render();
+ // Open dialog
+ fireEvent.keyDown(window, { key: "/", code: "Slash", ctrlKey: true });
+ const dialog = screen.getByRole("dialog");
+ // Click backdrop (the fixed overlay parent)
+ const backdrop = dialog.closest(".fixed") as HTMLElement;
+ await user.click(backdrop);
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ it("does not close keyboard shortcuts dialog when clicking inside dialog", async () => {
+ const user = userEvent.setup();
+ render();
+ fireEvent.keyDown(window, { key: "/", code: "Slash", ctrlKey: true });
+ const dialog = screen.getByRole("dialog");
+ await user.click(dialog);
+ expect(screen.getByRole("dialog")).toBeTruthy();
+ });
+
+ it("renders all shortcut rows in the keyboard shortcuts dialog", () => {
+ render();
+ fireEvent.keyDown(window, { key: "/", code: "Slash", ctrlKey: true });
+ expect(screen.getByText("Toggle sidebar")).toBeTruthy();
+ expect(screen.getByText("New generation")).toBeTruthy();
+ expect(screen.getByText("Open settings")).toBeTruthy();
+ expect(screen.getByText("Generate")).toBeTruthy();
+ expect(screen.getByText("Retry failed generation")).toBeTruthy();
+ expect(screen.getByText("Play / pause")).toBeTruthy();
+ expect(screen.getByText("A / B compare")).toBeTruthy();
+ // "Keyboard shortcuts" appears as both the dialog title and a shortcut row label
+ expect(screen.getAllByText("Keyboard shortcuts")).toHaveLength(2);
+ });
+
+ it("calls runGeneration on submit shortcut when not already running", () => {
+ currentStoreState = makeStoreOverrides({
+ generationState: { status: "idle", phase: "idle", statusMessage: "Ready", error: null },
+ });
+ render();
+ fireEvent.keyDown(window, { key: "Enter", code: "Enter", ctrlKey: true });
+ expect(mockRunGeneration).toHaveBeenCalledOnce();
+ });
+
+ it("does not call runGeneration on submit shortcut when already running", () => {
+ currentStoreState = makeStoreOverrides({
+ generationState: {
+ status: "running",
+ phase: "generating",
+ statusMessage: "Running...",
+ error: null,
+ },
+ });
+ render();
+ fireEvent.keyDown(window, { key: "Enter", code: "Enter", ctrlKey: true });
+ expect(mockRunGeneration).not.toHaveBeenCalled();
+ });
+
+ it("calls runGeneration on retry shortcut when generation has failed", () => {
+ currentStoreState = makeStoreOverrides({
+ generationState: {
+ status: "failed",
+ phase: "failed",
+ statusMessage: "Failed",
+ error: { code: "TASK_FAILED", message: "broke" },
+ },
+ });
+ render();
+ fireEvent.keyDown(window, { key: "r", code: "KeyR", ctrlKey: true, shiftKey: true });
+ expect(mockRunGeneration).toHaveBeenCalledOnce();
+ });
+
+ it("does not call runGeneration on retry shortcut when not failed", () => {
+ currentStoreState = makeStoreOverrides({
+ generationState: { status: "idle", phase: "idle", statusMessage: "Ready", error: null },
+ });
+ render();
+ fireEvent.keyDown(window, { key: "r", code: "KeyR", ctrlKey: true, shiftKey: true });
+ expect(mockRunGeneration).not.toHaveBeenCalled();
+ });
+
+ it("requests playback toggle on Space shortcut", () => {
+ render();
+ fireEvent.keyDown(window, { key: " ", code: "Space" });
+ expect(mockRequestPlaybackToggle).toHaveBeenCalledOnce();
+ });
+
+ it("toggles compare target on 1 shortcut when compare mode is active", () => {
+ currentStoreState = makeStoreOverrides({ compareModeActive: true });
+ render();
+ fireEvent.keyDown(window, { key: "1", code: "Digit1" });
+ expect(mockToggleCompareTarget).toHaveBeenCalledOnce();
+ });
+
+ it("does not toggle compare target on 1 shortcut when compare mode is inactive", () => {
+ currentStoreState = makeStoreOverrides({ compareModeActive: false });
+ render();
+ fireEvent.keyDown(window, { key: "1", code: "Digit1" });
+ expect(mockToggleCompareTarget).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/model-slice.test.ts b/tests/unit/model-slice.test.ts
new file mode 100644
index 0000000..35b0cab
--- /dev/null
+++ b/tests/unit/model-slice.test.ts
@@ -0,0 +1,806 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type {
+ AppSettings,
+ BackendProvisionStatus,
+ ModelStatusSnapshot,
+ ModelVariant,
+ GenerationFormValues,
+} from "@/app/lib/types";
+import type { GenerationStore } from "@/app/lib/store/types";
+
+/* ------------------------------------------------------------------ */
+/* Mocks */
+/* ------------------------------------------------------------------ */
+
+const mockApi = {
+ isTauriRuntime: vi.fn(() => false),
+ downloadModel: vi.fn(),
+ deleteModel: vi.fn(),
+ cancelDownload: vi.fn(),
+ clearPartialDownloads: vi.fn(),
+ deleteAllModels: vi.fn(),
+ listModelCatalog: vi.fn(),
+ getModelStatus: vi.fn(),
+ getBackendProvisionStatus: vi.fn(),
+ provisionBackend: vi.fn(),
+ updateBackend: vi.fn(),
+ setSetting: vi.fn(),
+};
+
+vi.mock("@/app/lib/api", () => mockApi);
+
+/* ------------------------------------------------------------------ */
+/* Imports (after mock setup) */
+/* ------------------------------------------------------------------ */
+
+const { DEFAULT_GENERATION_FORM_VALUES } = await import("@/app/lib/validation");
+const { createModelSlice } = await import("@/app/lib/store/slices/model");
+const { createUISlice } = await import("@/app/lib/store/slices/ui");
+const { createSettingsSlice } = await import("@/app/lib/store/slices/settings");
+const { createHistorySlice } = await import("@/app/lib/store/slices/history");
+const { createGenerationSlice } = await import("@/app/lib/store/slices/generation");
+
+const { create } = await import("zustand");
+
+/* ------------------------------------------------------------------ */
+/* Helpers */
+/* ------------------------------------------------------------------ */
+
+function createStore() {
+ return create((set, get) => ({
+ ...createUISlice(set, get),
+ ...createModelSlice(set, get),
+ ...createGenerationSlice(set, get),
+ ...createHistorySlice(set, get),
+ ...createSettingsSlice(set, get),
+ }));
+}
+
+function defaultSettings(overrides: Partial = {}): AppSettings {
+ return {
+ profile: "standard",
+ modelVariant: null,
+ downloadedModels: [],
+ outputDirectory: null,
+ backendPort: 8001,
+ defaultDurationSeconds: 30,
+ defaultAudioFormat: "wav",
+ defaultThinking: true,
+ firstRunCompleted: false,
+ ...overrides,
+ };
+}
+
+function defaultForm(overrides: Partial = {}): GenerationFormValues {
+ return { ...DEFAULT_GENERATION_FORM_VALUES, ...overrides };
+}
+
+function modelStatus(
+ variant: ModelVariant,
+ state: ModelStatusSnapshot["state"],
+ overrides: Partial = {},
+): ModelStatusSnapshot {
+ return {
+ variant,
+ state,
+ modelName: variant === "pro" ? "acestep-v15-xl-turbo" : "acestep-v15-turbo",
+ label: variant === "pro" ? "XL Turbo" : variant === "lite" ? "Lite" : "Turbo",
+ description: "",
+ downloadedBytes: state === "ready" ? 8 * 1024 * 1024 * 1024 : 0,
+ totalBytes: state === "ready" ? 8 * 1024 * 1024 * 1024 : null,
+ error: null,
+ ...overrides,
+ };
+}
+
+function defaultProvisionStatus(
+ overrides: Partial = {},
+): BackendProvisionStatus {
+ return {
+ state: "not_installed",
+ installedCommit: null,
+ installedTag: null,
+ latestCommit: null,
+ latestTag: null,
+ updateAvailable: false,
+ downloadedBytes: 0,
+ ...overrides,
+ };
+}
+
+/* ------------------------------------------------------------------ */
+/* beforeEach */
+/* ------------------------------------------------------------------ */
+
+let store: ReturnType;
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ store = createStore();
+ store.setState({
+ settings: defaultSettings(),
+ form: defaultForm(),
+ modelStatuses: [],
+ bootstrapStatus: { state: "pending", message: "Choose a model" },
+ backendProvisionStatus: defaultProvisionStatus(),
+ });
+});
+
+/* ================================================================== */
+/* Initial state */
+/* ================================================================== */
+
+describe("createModelSlice - initial state", () => {
+ it("starts with pending bootstrapStatus", () => {
+ expect(store.getState().bootstrapStatus.state).toBe("pending");
+ });
+
+ it("populates modelCatalog with all three variants", () => {
+ const catalog = store.getState().modelCatalog;
+ expect(catalog).toHaveLength(3);
+ expect(catalog.map((c) => c.variant).sort()).toEqual(["lite", "pro", "turbo"]);
+ });
+
+ it("starts with empty modelStatuses", () => {
+ expect(store.getState().modelStatuses).toEqual([]);
+ });
+});
+
+/* ================================================================== */
+/* applyModelStatus (sync) */
+/* ================================================================== */
+
+describe("applyModelStatus", () => {
+ it("adds a new status to modelStatuses", () => {
+ store.getState().applyModelStatus(modelStatus("turbo", "ready"));
+
+ const statuses = store.getState().modelStatuses;
+ expect(statuses).toHaveLength(1);
+ expect(statuses[0].variant).toBe("turbo");
+ expect(statuses[0].state).toBe("ready");
+ });
+
+ it("replaces status for the same variant", () => {
+ store.setState({
+ modelStatuses: [modelStatus("turbo", "downloading", { downloadedBytes: 1000 })],
+ });
+
+ store.getState().applyModelStatus(modelStatus("turbo", "ready"));
+
+ const statuses = store.getState().modelStatuses;
+ expect(statuses).toHaveLength(1);
+ expect(statuses[0].state).toBe("ready");
+ });
+
+ it("updates downloadedModels based on statuses", () => {
+ store.setState({
+ settings: defaultSettings({ modelVariant: "turbo" }),
+ });
+
+ store.getState().applyModelStatus(modelStatus("turbo", "ready"));
+
+ expect(store.getState().settings.downloadedModels).toContain("turbo");
+ expect(store.getState().settings.downloadedModels).toContain("lite");
+ });
+
+ describe("bootstrapStatus updates", () => {
+ it("sets downloading when pack aggregate is downloading for the selected pack", () => {
+ store.setState({
+ settings: defaultSettings({ modelVariant: "turbo" }),
+ });
+
+ store.getState().applyModelStatus(
+ modelStatus("turbo", "downloading", {
+ downloadedBytes: 500,
+ totalBytes: 8000,
+ }),
+ );
+
+ const bs = store.getState().bootstrapStatus;
+ expect(bs.state).toBe("downloading");
+ });
+
+ it("sets failed when pack aggregate is failed for the selected pack", () => {
+ store.setState({
+ settings: defaultSettings({ modelVariant: "turbo" }),
+ });
+
+ store.getState().applyModelStatus(
+ modelStatus("turbo", "failed", {
+ error: { code: "DL_FAIL", message: "disk full", recoverable: true },
+ }),
+ );
+
+ const bs = store.getState().bootstrapStatus;
+ expect(bs.state).toBe("failed");
+ });
+
+ it("sets ready when pack aggregate is ready for the selected pack", () => {
+ store.setState({
+ settings: defaultSettings({ modelVariant: "turbo" }),
+ });
+
+ store.getState().applyModelStatus(modelStatus("turbo", "ready"));
+
+ expect(store.getState().bootstrapStatus.state).toBe("ready");
+ });
+
+ it("sets pending when pack is not_installed for the selected pack", () => {
+ store.setState({
+ settings: defaultSettings({ modelVariant: "turbo" }),
+ });
+
+ store.getState().applyModelStatus(modelStatus("turbo", "not_installed"));
+
+ expect(store.getState().bootstrapStatus.state).toBe("pending");
+ });
+ });
+
+ it("clears modelVariant when selected variant's pack loses downloaded status", () => {
+ store.setState({
+ settings: defaultSettings({
+ modelVariant: "turbo",
+ downloadedModels: ["lite", "turbo"],
+ }),
+ modelStatuses: [modelStatus("turbo", "ready")],
+ });
+
+ // Apply a failed status for turbo - this removes it from downloadedModels
+ store.getState().applyModelStatus(modelStatus("turbo", "failed"));
+
+ expect(store.getState().settings.modelVariant).toBeNull();
+ });
+
+ it("preserves modelVariant when the deleted pack is not the selected one", () => {
+ store.setState({
+ settings: defaultSettings({
+ modelVariant: "turbo",
+ downloadedModels: ["lite", "turbo", "pro"],
+ }),
+ modelStatuses: [modelStatus("turbo", "ready"), modelStatus("pro", "ready")],
+ });
+
+ store.getState().applyModelStatus(modelStatus("pro", "failed"));
+
+ expect(store.getState().settings.modelVariant).toBe("turbo");
+ });
+
+ it("does not call setSetting in non-Tauri runtime", () => {
+ mockApi.isTauriRuntime.mockReturnValue(false);
+
+ store.getState().applyModelStatus(modelStatus("turbo", "ready"));
+
+ expect(mockApi.setSetting).not.toHaveBeenCalled();
+ });
+
+ it("persists downloadedModels via setSetting in Tauri runtime", () => {
+ mockApi.isTauriRuntime.mockReturnValue(true);
+
+ store.getState().applyModelStatus(modelStatus("turbo", "ready"));
+
+ expect(mockApi.setSetting).toHaveBeenCalledWith(
+ "downloadedModels",
+ expect.arrayContaining(["turbo"]),
+ );
+ });
+});
+
+/* ================================================================== */
+/* downloadModelVariant */
+/* ================================================================== */
+
+describe("downloadModelVariant", () => {
+ describe("non-Tauri runtime", () => {
+ beforeEach(() => {
+ mockApi.isTauriRuntime.mockReturnValue(false);
+ });
+
+ it("adds pack variants to downloadedModels and sets bootstrapStatus to ready", async () => {
+ store.setState({
+ settings: defaultSettings(),
+ });
+
+ await store.getState().downloadModelVariant("turbo");
+
+ const s = store.getState().settings;
+ expect(s.downloadedModels).toContain("turbo");
+ expect(s.downloadedModels).toContain("lite");
+ expect(s.modelVariant).toBe("turbo");
+ expect(s.profile).toBe("standard");
+ expect(store.getState().bootstrapStatus.state).toBe("ready");
+ });
+
+ it("applies profile preset for lite variant", async () => {
+ await store.getState().downloadModelVariant("lite");
+
+ const s = store.getState().settings;
+ expect(s.profile).toBe("low-memory");
+ expect(s.modelVariant).toBe("lite");
+ });
+
+ it("applies profile preset for pro variant", async () => {
+ await store.getState().downloadModelVariant("pro");
+
+ const s = store.getState().settings;
+ expect(s.profile).toBe("quality");
+ expect(s.modelVariant).toBe("pro");
+ });
+ });
+
+ describe("Tauri runtime", () => {
+ beforeEach(() => {
+ mockApi.isTauriRuntime.mockReturnValue(true);
+ mockApi.downloadModel.mockResolvedValue(modelStatus("turbo", "downloading"));
+ mockApi.setSetting.mockResolvedValue(undefined);
+ });
+
+ it("calls downloadModel with the pack's primary variant", async () => {
+ await store.getState().downloadModelVariant("lite");
+
+ // lite is in the "standard" pack whose primary variant is "turbo"
+ expect(mockApi.downloadModel).toHaveBeenCalledWith("turbo");
+ });
+
+ it("persists modelVariant, profile, and defaultThinking via setSetting", async () => {
+ await store.getState().downloadModelVariant("turbo");
+
+ expect(mockApi.setSetting).toHaveBeenCalledWith("modelVariant", "turbo");
+ expect(mockApi.setSetting).toHaveBeenCalledWith("profile", "standard");
+ expect(mockApi.setSetting).toHaveBeenCalledWith("defaultThinking", expect.any(Boolean));
+ });
+
+ it("applies the status returned by downloadModel", async () => {
+ await store.getState().downloadModelVariant("turbo");
+
+ expect(store.getState().modelStatuses.length).toBeGreaterThan(0);
+ });
+ });
+});
+
+/* ================================================================== */
+/* deleteModelVariant */
+/* ================================================================== */
+
+describe("deleteModelVariant", () => {
+ describe("non-Tauri runtime", () => {
+ beforeEach(() => {
+ mockApi.isTauriRuntime.mockReturnValue(false);
+ });
+
+ it("removes all pack variants from downloadedModels", async () => {
+ store.setState({
+ settings: defaultSettings({
+ downloadedModels: ["lite", "turbo", "pro"],
+ modelVariant: "turbo",
+ }),
+ });
+
+ await store.getState().deleteModelVariant("turbo");
+
+ const dm = store.getState().settings.downloadedModels;
+ expect(dm).not.toContain("lite");
+ expect(dm).not.toContain("turbo");
+ expect(dm).toContain("pro");
+ });
+
+ it("clears modelVariant when the selected variant belongs to the deleted pack", async () => {
+ store.setState({
+ settings: defaultSettings({
+ downloadedModels: ["lite", "turbo"],
+ modelVariant: "turbo",
+ }),
+ });
+
+ await store.getState().deleteModelVariant("turbo");
+
+ expect(store.getState().settings.modelVariant).toBeNull();
+ expect(store.getState().bootstrapStatus.state).toBe("pending");
+ });
+
+ it("preserves modelVariant when selected variant is in a different pack", async () => {
+ store.setState({
+ settings: defaultSettings({
+ downloadedModels: ["lite", "turbo", "pro"],
+ modelVariant: "pro",
+ }),
+ });
+
+ await store.getState().deleteModelVariant("turbo");
+
+ expect(store.getState().settings.modelVariant).toBe("pro");
+ });
+ });
+
+ describe("Tauri runtime", () => {
+ beforeEach(() => {
+ mockApi.isTauriRuntime.mockReturnValue(true);
+ mockApi.deleteModel.mockResolvedValue(modelStatus("turbo", "not_installed"));
+ });
+
+ it("calls deleteModel with the pack's primary variant", async () => {
+ await store.getState().deleteModelVariant("lite");
+
+ // lite is in "standard" pack whose primary variant is "turbo"
+ expect(mockApi.deleteModel).toHaveBeenCalledWith("turbo");
+ });
+
+ it("applies the status returned by deleteModel", async () => {
+ store.setState({
+ modelStatuses: [modelStatus("turbo", "ready")],
+ });
+
+ await store.getState().deleteModelVariant("turbo");
+
+ const turboStatus = store.getState().modelStatuses.find((s) => s.variant === "turbo");
+ expect(turboStatus?.state).toBe("not_installed");
+ });
+ });
+});
+
+/* ================================================================== */
+/* cancelModelDownload */
+/* ================================================================== */
+
+describe("cancelModelDownload", () => {
+ it("calls cancelDownload in Tauri runtime", async () => {
+ mockApi.isTauriRuntime.mockReturnValue(true);
+ mockApi.cancelDownload.mockResolvedValue(undefined);
+
+ await store.getState().cancelModelDownload("turbo");
+
+ expect(mockApi.cancelDownload).toHaveBeenCalledWith("turbo");
+ });
+
+ it("does nothing in non-Tauri runtime", async () => {
+ mockApi.isTauriRuntime.mockReturnValue(false);
+
+ await store.getState().cancelModelDownload("turbo");
+
+ expect(mockApi.cancelDownload).not.toHaveBeenCalled();
+ });
+});
+
+/* ================================================================== */
+/* clearPartialModelDownloads */
+/* ================================================================== */
+
+describe("clearPartialModelDownloads", () => {
+ it("calls clearPartialDownloads and applies status in Tauri runtime", async () => {
+ mockApi.isTauriRuntime.mockReturnValue(true);
+ const status = modelStatus("turbo", "not_installed");
+ mockApi.clearPartialDownloads.mockResolvedValue(status);
+
+ await store.getState().clearPartialModelDownloads("turbo");
+
+ expect(mockApi.clearPartialDownloads).toHaveBeenCalledWith("turbo");
+ expect(store.getState().modelStatuses).toHaveLength(1);
+ });
+
+ it("does nothing in non-Tauri runtime", async () => {
+ mockApi.isTauriRuntime.mockReturnValue(false);
+
+ await store.getState().clearPartialModelDownloads("turbo");
+
+ expect(mockApi.clearPartialDownloads).not.toHaveBeenCalled();
+ });
+});
+
+/* ================================================================== */
+/* deleteAllModels */
+/* ================================================================== */
+
+describe("deleteAllModels", () => {
+ it("does nothing in non-Tauri runtime", async () => {
+ mockApi.isTauriRuntime.mockReturnValue(false);
+
+ await store.getState().deleteAllModels();
+
+ expect(mockApi.deleteAllModels).not.toHaveBeenCalled();
+ });
+
+ it("clears downloadedModels and resets modelVariant when all deleted", async () => {
+ mockApi.isTauriRuntime.mockReturnValue(true);
+ store.setState({
+ settings: defaultSettings({
+ downloadedModels: ["lite", "turbo", "pro"],
+ modelVariant: "turbo",
+ }),
+ modelStatuses: [modelStatus("turbo", "ready"), modelStatus("pro", "ready")],
+ });
+
+ mockApi.deleteAllModels.mockResolvedValue([
+ modelStatus("turbo", "not_installed"),
+ modelStatus("pro", "not_installed"),
+ ]);
+
+ await store.getState().deleteAllModels();
+
+ expect(store.getState().settings.downloadedModels).toEqual([]);
+ expect(store.getState().settings.modelVariant).toBe("");
+ });
+
+ it("preserves modelVariant when some models remain downloaded", async () => {
+ mockApi.isTauriRuntime.mockReturnValue(true);
+ store.setState({
+ settings: defaultSettings({
+ downloadedModels: ["lite", "turbo", "pro"],
+ modelVariant: "turbo",
+ }),
+ });
+
+ // turbo not_installed but pro remains ready
+ mockApi.deleteAllModels.mockResolvedValue([
+ modelStatus("turbo", "not_installed"),
+ modelStatus("pro", "ready"),
+ ]);
+
+ await store.getState().deleteAllModels();
+
+ // downloadedModels will include pro's pack variants
+ expect(store.getState().settings.downloadedModels).toContain("pro");
+ expect(store.getState().settings.modelVariant).toBe("turbo");
+ });
+});
+
+/* ================================================================== */
+/* refreshModelStatuses */
+/* ================================================================== */
+
+describe("refreshModelStatuses", () => {
+ it("does nothing in non-Tauri runtime", async () => {
+ mockApi.isTauriRuntime.mockReturnValue(false);
+
+ await store.getState().refreshModelStatuses();
+
+ expect(mockApi.getModelStatus).not.toHaveBeenCalled();
+ });
+
+ it("fetches catalog, statuses, and provision in parallel", async () => {
+ mockApi.isTauriRuntime.mockReturnValue(true);
+ const catalog = [
+ {
+ variant: "turbo",
+ label: "Turbo",
+ modelName: "acestep-v15-turbo",
+ lmBackend: "mlx",
+ estimatedSizeBytes: 8 * 1024 * 1024 * 1024,
+ description: "",
+ recommendedMemoryGb: 16,
+ },
+ ];
+ mockApi.listModelCatalog.mockResolvedValue(catalog);
+ mockApi.getModelStatus.mockResolvedValue([modelStatus("turbo", "ready")]);
+ mockApi.getBackendProvisionStatus.mockResolvedValue(defaultProvisionStatus({ state: "ready" }));
+
+ await store.getState().refreshModelStatuses();
+
+ expect(store.getState().modelCatalog).toEqual(catalog);
+ expect(store.getState().modelStatuses).toHaveLength(1);
+ expect(store.getState().backendProvisionStatus.state).toBe("ready");
+ });
+
+ it("falls back to not_installed when backend provision fetch fails", async () => {
+ mockApi.isTauriRuntime.mockReturnValue(true);
+ mockApi.listModelCatalog.mockResolvedValue([]);
+ mockApi.getModelStatus.mockResolvedValue([]);
+ mockApi.getBackendProvisionStatus.mockRejectedValue(new Error("unavailable"));
+
+ await store.getState().refreshModelStatuses();
+
+ expect(store.getState().backendProvisionStatus.state).toBe("not_installed");
+ });
+
+ it("updates downloadedModels from fetched statuses", async () => {
+ mockApi.isTauriRuntime.mockReturnValue(true);
+ mockApi.listModelCatalog.mockResolvedValue([]);
+ mockApi.getModelStatus.mockResolvedValue([
+ modelStatus("turbo", "ready"),
+ modelStatus("pro", "ready"),
+ ]);
+ mockApi.getBackendProvisionStatus.mockResolvedValue(defaultProvisionStatus({ state: "ready" }));
+
+ await store.getState().refreshModelStatuses();
+
+ expect(store.getState().settings.downloadedModels).toEqual(
+ expect.arrayContaining(["lite", "turbo", "pro"]),
+ );
+ });
+});
+
+/* ================================================================== */
+/* selectModelVariant */
+/* ================================================================== */
+
+describe("selectModelVariant", () => {
+ describe("non-Tauri runtime", () => {
+ beforeEach(() => {
+ mockApi.isTauriRuntime.mockReturnValue(false);
+ });
+
+ it("sets modelVariant and profile in settings", async () => {
+ await store.getState().selectModelVariant("lite");
+
+ expect(store.getState().settings.modelVariant).toBe("lite");
+ expect(store.getState().settings.profile).toBe("low-memory");
+ });
+
+ it("applies profile preset to form", async () => {
+ await store.getState().selectModelVariant("pro");
+
+ const form = store.getState().form;
+ expect(form.model).toBe("acestep-v15-xl-turbo");
+ });
+ });
+
+ describe("Tauri runtime", () => {
+ beforeEach(() => {
+ mockApi.isTauriRuntime.mockReturnValue(true);
+ mockApi.setSetting.mockResolvedValue(undefined);
+ // Provide a stub for hydrateFromPersistence and refreshBootstrapStatus
+ store.setState({
+ hydrated: true,
+ });
+ });
+
+ it("persists modelVariant, profile, and defaultThinking", async () => {
+ // Stub the methods that selectModelVariant calls after setSetting
+ const originalState = store.getState();
+ store.setState({
+ ...originalState,
+ hydrateFromPersistence: vi.fn().mockResolvedValue(undefined),
+ refreshBootstrapStatus: vi.fn().mockResolvedValue(undefined),
+ });
+
+ await store.getState().selectModelVariant("pro");
+
+ expect(mockApi.setSetting).toHaveBeenCalledWith("modelVariant", "pro");
+ expect(mockApi.setSetting).toHaveBeenCalledWith("profile", "quality");
+ expect(mockApi.setSetting).toHaveBeenCalledWith("defaultThinking", expect.any(Boolean));
+ });
+ });
+});
+
+/* ================================================================== */
+/* refreshBootstrapStatus */
+/* ================================================================== */
+
+describe("refreshBootstrapStatus", () => {
+ it("resolves to ready when model is downloaded and firstRunCompleted", async () => {
+ store.setState({
+ settings: defaultSettings({
+ firstRunCompleted: true,
+ modelVariant: "turbo",
+ downloadedModels: ["lite", "turbo"],
+ }),
+ modelStatuses: [modelStatus("turbo", "ready")],
+ backendProvisionStatus: defaultProvisionStatus({ state: "ready" }),
+ });
+
+ await store.getState().refreshBootstrapStatus();
+
+ expect(store.getState().bootstrapStatus.state).toBe("ready");
+ });
+
+ it("sets pending when no model variant selected", async () => {
+ store.setState({
+ settings: defaultSettings({
+ firstRunCompleted: true,
+ modelVariant: null,
+ }),
+ });
+
+ await store.getState().refreshBootstrapStatus();
+
+ expect(store.getState().bootstrapStatus.state).toBe("pending");
+ });
+});
+
+/* ================================================================== */
+/* refreshBackendProvisionStatus */
+/* ================================================================== */
+
+describe("refreshBackendProvisionStatus", () => {
+ it("does nothing in non-Tauri runtime", async () => {
+ mockApi.isTauriRuntime.mockReturnValue(false);
+
+ await store.getState().refreshBackendProvisionStatus();
+
+ expect(mockApi.getBackendProvisionStatus).not.toHaveBeenCalled();
+ });
+
+ it("updates backendProvisionStatus on success", async () => {
+ mockApi.isTauriRuntime.mockReturnValue(true);
+ const status = defaultProvisionStatus({ state: "ready" });
+ mockApi.getBackendProvisionStatus.mockResolvedValue(status);
+
+ await store.getState().refreshBackendProvisionStatus();
+
+ expect(store.getState().backendProvisionStatus.state).toBe("ready");
+ });
+
+ it("silently ignores errors", async () => {
+ mockApi.isTauriRuntime.mockReturnValue(true);
+ mockApi.getBackendProvisionStatus.mockRejectedValue(new Error("boom"));
+
+ await store.getState().refreshBackendProvisionStatus();
+
+ // Should not throw and state unchanged
+ expect(store.getState().backendProvisionStatus.state).toBe("not_installed");
+ });
+});
+
+/* ================================================================== */
+/* provisionBackend */
+/* ================================================================== */
+
+describe("provisionBackend", () => {
+ it("does nothing in non-Tauri runtime", async () => {
+ mockApi.isTauriRuntime.mockReturnValue(false);
+
+ await store.getState().provisionBackend();
+
+ expect(mockApi.provisionBackend).not.toHaveBeenCalled();
+ });
+
+ it("sets status to ready on success", async () => {
+ mockApi.isTauriRuntime.mockReturnValue(true);
+ mockApi.provisionBackend.mockResolvedValue(defaultProvisionStatus({ state: "ready" }));
+
+ await store.getState().provisionBackend();
+
+ expect(store.getState().backendProvisionStatus.state).toBe("ready");
+ });
+
+ it("sets status to failed on error", async () => {
+ mockApi.isTauriRuntime.mockReturnValue(true);
+ mockApi.provisionBackend.mockRejectedValue(new Error("disk space"));
+
+ await store.getState().provisionBackend();
+
+ const bs = store.getState().backendProvisionStatus;
+ expect(bs.state).toBe("failed");
+ expect(bs.error?.code).toBe("BACKEND_PROVISION_FAILED");
+ expect(bs.error?.message).toBe("disk space");
+ expect(bs.error?.recoverable).toBe(true);
+ });
+});
+
+/* ================================================================== */
+/* updateBackend */
+/* ================================================================== */
+
+describe("updateBackend", () => {
+ it("does nothing in non-Tauri runtime", async () => {
+ mockApi.isTauriRuntime.mockReturnValue(false);
+
+ await store.getState().updateBackend();
+
+ expect(mockApi.updateBackend).not.toHaveBeenCalled();
+ });
+
+ it("sets status on success", async () => {
+ mockApi.isTauriRuntime.mockReturnValue(true);
+ mockApi.updateBackend.mockResolvedValue(
+ defaultProvisionStatus({ state: "ready", updateAvailable: false }),
+ );
+
+ await store.getState().updateBackend();
+
+ expect(store.getState().backendProvisionStatus.state).toBe("ready");
+ });
+
+ it("sets failed state with error details on failure", async () => {
+ mockApi.isTauriRuntime.mockReturnValue(true);
+ store.setState({
+ backendProvisionStatus: defaultProvisionStatus({ state: "ready" }),
+ });
+ mockApi.updateBackend.mockRejectedValue(new Error("network timeout"));
+
+ await store.getState().updateBackend();
+
+ const bs = store.getState().backendProvisionStatus;
+ expect(bs.state).toBe("failed");
+ expect(bs.error?.code).toBe("BACKEND_PROVISION_FAILED");
+ expect(bs.error?.message).toBe("network timeout");
+ });
+});
diff --git a/tests/unit/playback-bar.test.tsx b/tests/unit/playback-bar.test.tsx
new file mode 100644
index 0000000..2765ca9
--- /dev/null
+++ b/tests/unit/playback-bar.test.tsx
@@ -0,0 +1,402 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { GenerationRecord } from "@/app/lib/types";
+
+// --- Suppress jsdom "Not implemented" errors that deadlock vitest -----------
+vi.hoisted(() => {
+ HTMLMediaElement.prototype.load = function () {};
+ HTMLMediaElement.prototype.pause = function () {};
+ HTMLMediaElement.prototype.play = async function () {};
+ // jsdom has no URL.createObjectURL — provide one for blob audio loading
+ if (typeof URL.createObjectURL === "undefined") {
+ (URL as any).createObjectURL = () => "blob:mock-audio-url";
+ }
+ if (typeof URL.revokeObjectURL === "undefined") {
+ (URL as any).revokeObjectURL = () => {};
+ }
+});
+
+// jsdom returns 0 for getBoundingClientRect, which collapses the metadata
+// section. Override to return a realistic desktop width so track info renders.
+const REALISTIC_WIDTH = 1280;
+const origGetBCR = Element.prototype.getBoundingClientRect;
+Element.prototype.getBoundingClientRect = function () {
+ const rect = origGetBCR.call(this);
+ // Only inflate the playback bar container (has class app-panel-surface)
+ if ((this as HTMLElement).classList?.contains("app-panel-surface")) {
+ return { ...rect, width: REALISTIC_WIDTH, height: 86, right: REALISTIC_WIDTH };
+ }
+ return rect;
+};
+
+// --- Mocks ------------------------------------------------------------------
+
+const mockReadGenerationAudio = vi.fn();
+const mockReadGenerationWaveform = vi.fn();
+const mockDeleteGenerationFileAndRecord = vi.fn().mockResolvedValue(undefined);
+
+vi.mock("@/app/lib/api", () => ({
+ isTauriRuntime: () => false,
+ readGenerationAudio: (...args: unknown[]) => mockReadGenerationAudio(...args),
+ readGenerationWaveform: (...args: unknown[]) => mockReadGenerationWaveform(...args),
+ copyAudioTo: vi.fn(),
+ revealInFinder: vi.fn(),
+ deleteGenerationFileAndRecord: (...args: unknown[]) => mockDeleteGenerationFileAndRecord(...args),
+}));
+
+// IMPORTANT: `t` must be a stable reference — the PlaybackBar component has
+// `t` in a useEffect dependency array. A fresh function each render would
+// cause an infinite re-render loop.
+const stableT = (key: string, opts?: Record) => {
+ if (opts?.count !== undefined) return `${key}:${opts.count}`;
+ if (opts?.time !== undefined) return `${key}:${opts.time}`;
+ return key;
+};
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: stableT,
+ i18n: { language: "en", changeLanguage: vi.fn() },
+ }),
+ initReactI18next: { type: "3rdParty", init: vi.fn() },
+ Trans: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+vi.mock("@/app/components/overlay/Toast", () => ({
+ useToast: () => ({ addToast: vi.fn() }),
+}));
+
+vi.mock("@/app/components/overlay/Tooltip", () => ({
+ Tooltip: ({ children, label }: { children: React.ReactNode; label: string }) => (
+ {children}
+ ),
+}));
+
+// Store mock
+const mockDeleteGenerationRecord = vi.fn().mockResolvedValue(undefined);
+const mockToggleCompareTarget = vi.fn();
+
+interface MockStoreState {
+ currentGeneration: GenerationRecord | null;
+ deleteGenerationRecord: typeof mockDeleteGenerationRecord;
+ playbackToggleRequest: number;
+ compareModeActive: boolean;
+ toggleCompareTarget: typeof mockToggleCompareTarget;
+}
+
+let currentStoreState: MockStoreState;
+
+vi.mock("@/app/lib/store", () => ({
+ useGenerationStore: (selector: (state: MockStoreState) => unknown) => selector(currentStoreState),
+}));
+
+// --- Helpers -----------------------------------------------------------------
+
+const SAMPLE_GENERATION: GenerationRecord = {
+ id: "gen-1",
+ createdAt: "2026-01-01T00:00:00Z",
+ prompt: "lo-fi warm piano",
+ negativePrompt: "",
+ lyrics: "",
+ vocalLanguage: "en",
+ durationSeconds: 120,
+ bpm: 90,
+ keyScale: "C major",
+ timeSignature: "4",
+ model: "turbo",
+ taskType: "text2music",
+ thinking: false,
+ inferenceSteps: 30,
+ guidanceScale: 7,
+ useFormat: false,
+ useCotCaption: false,
+ useCotLanguage: false,
+ constrainedDecoding: false,
+ useRandomSeed: true,
+ audioFormat: "wav",
+ outputPath: "/output/gen-1.wav",
+ status: "completed",
+ errorMessage: null,
+ isFavorite: false,
+};
+
+function makeStoreOverrides(overrides: Partial = {}): MockStoreState {
+ return {
+ currentGeneration: overrides.currentGeneration ?? null,
+ deleteGenerationRecord: overrides.deleteGenerationRecord ?? mockDeleteGenerationRecord,
+ playbackToggleRequest: overrides.playbackToggleRequest ?? 0,
+ compareModeActive: overrides.compareModeActive ?? false,
+ toggleCompareTarget: overrides.toggleCompareTarget ?? mockToggleCompareTarget,
+ };
+}
+
+// Import component after mocks are installed
+const { PlaybackBar } = await import("@/app/components/player/PlaybackBar");
+
+// --- Helper to find buttons by tooltip label --------------------------------
+
+function getButtonByTooltip(label: string): HTMLButtonElement | undefined {
+ return screen.getAllByRole("button").find((btn) => {
+ const tooltip = btn.closest("[data-tooltip-label]");
+ return tooltip?.getAttribute("data-tooltip-label") === label;
+ }) as HTMLButtonElement | undefined;
+}
+
+// --- Tests -------------------------------------------------------------------
+
+describe("PlaybackBar", () => {
+ beforeEach(() => {
+ currentStoreState = makeStoreOverrides();
+ mockReadGenerationAudio.mockResolvedValue([0xff, 0xd8]);
+ mockReadGenerationWaveform.mockResolvedValue({ peaks: [0.5, 0.8] });
+ });
+
+ // 1. Renders correctly with no track
+ describe("with no track", () => {
+ it("shows the app name when no generation is active", () => {
+ render();
+ expect(screen.getByText("OpenLoop")).toBeInTheDocument();
+ });
+
+ it("shows the no-generation subtitle", () => {
+ render();
+ expect(screen.getByText("player.noGeneration")).toBeInTheDocument();
+ });
+
+ it("disables the play button when no audio source is loaded", () => {
+ render();
+ const playPauseBtn = getButtonByTooltip("player.play") ?? getButtonByTooltip("player.pause");
+ expect(playPauseBtn).toBeDefined();
+ expect(playPauseBtn).toBeDisabled();
+ });
+ });
+
+ // 2. Renders track info when a generation is loaded
+ describe("with a track loaded", () => {
+ it("shows the generation prompt as the track title", async () => {
+ currentStoreState = makeStoreOverrides({ currentGeneration: SAMPLE_GENERATION });
+ render();
+ expect(await screen.findByText("lo-fi warm piano")).toBeInTheDocument();
+ });
+
+ it("shows format and duration metadata", async () => {
+ currentStoreState = makeStoreOverrides({ currentGeneration: SAMPLE_GENERATION });
+ render();
+ await screen.findByText("lo-fi warm piano");
+ expect(screen.getByText(/WAV.*120s/)).toBeInTheDocument();
+ });
+
+ it("fetches audio and waveform data for the generation", () => {
+ currentStoreState = makeStoreOverrides({ currentGeneration: SAMPLE_GENERATION });
+ render();
+ expect(mockReadGenerationAudio).toHaveBeenCalledWith("gen-1");
+ expect(mockReadGenerationWaveform).toHaveBeenCalledWith("gen-1");
+ });
+
+ it("uses lyrics as track title when prompt is empty", async () => {
+ const lyricsGeneration = { ...SAMPLE_GENERATION, prompt: "", lyrics: "Verse one lyrics" };
+ currentStoreState = makeStoreOverrides({ currentGeneration: lyricsGeneration });
+ render();
+ expect(await screen.findByText("Verse one lyrics")).toBeInTheDocument();
+ });
+ });
+
+ // 3. Handles play/pause button click
+ describe("play/pause toggle", () => {
+ it("enables the play button when a track is loaded", async () => {
+ currentStoreState = makeStoreOverrides({ currentGeneration: SAMPLE_GENERATION });
+ render();
+
+ // Wait for the async audio fetch to resolve and set audioSrc
+ await vi.waitFor(() => {
+ const playPauseBtn =
+ getButtonByTooltip("player.play") ?? getButtonByTooltip("player.pause")!;
+ expect(playPauseBtn).not.toBeDisabled();
+ });
+ });
+ });
+
+ // 4. Handles seek interaction
+ describe("seek slider", () => {
+ it("renders a seek range input with aria-label", () => {
+ currentStoreState = makeStoreOverrides({ currentGeneration: SAMPLE_GENERATION });
+ render();
+ const seekSlider = screen.getByRole("slider", { name: /seek/i });
+ expect(seekSlider).toBeInTheDocument();
+ });
+
+ it("is disabled when no audio source is available", () => {
+ render();
+ const seekSlider = screen.getByRole("slider", { name: /seek/i });
+ expect(seekSlider).toBeDisabled();
+ });
+
+ it("renders the seek slider when audio source is loaded", () => {
+ currentStoreState = makeStoreOverrides({ currentGeneration: SAMPLE_GENERATION });
+ render();
+ const seekSlider = screen.getByRole("slider", { name: /seek/i });
+ expect(seekSlider).toBeInTheDocument();
+ });
+ });
+
+ // 5. Handles volume interaction
+ describe("volume control", () => {
+ it("renders a volume range input", () => {
+ currentStoreState = makeStoreOverrides({ currentGeneration: SAMPLE_GENERATION });
+ render();
+ const volumeSlider = screen.getByRole("slider", { name: /volume/i });
+ expect(volumeSlider).toBeInTheDocument();
+ });
+
+ it("defaults to full volume", () => {
+ currentStoreState = makeStoreOverrides({ currentGeneration: SAMPLE_GENERATION });
+ render();
+ const volumeSlider = screen.getByRole("slider", { name: /volume/i }) as HTMLInputElement;
+ expect(parseFloat(volumeSlider.value)).toBe(1);
+ });
+
+ it("is disabled when no audio source is available", () => {
+ render();
+ const volumeSlider = screen.getByRole("slider", { name: /volume/i });
+ expect(volumeSlider).toBeDisabled();
+ });
+
+ it("enables the volume slider when audio source is loaded", async () => {
+ currentStoreState = makeStoreOverrides({ currentGeneration: SAMPLE_GENERATION });
+ render();
+ await vi.waitFor(() => {
+ const volumeSlider = screen.getByRole("slider", { name: /volume/i });
+ expect(volumeSlider).not.toBeDisabled();
+ });
+ });
+
+ it("toggles mute when the mute button is clicked", async () => {
+ currentStoreState = makeStoreOverrides({ currentGeneration: SAMPLE_GENERATION });
+ const user = userEvent.setup();
+ render();
+
+ const muteBtn = getButtonByTooltip("player.mute")!;
+ await user.click(muteBtn);
+
+ const volumeSlider = screen.getByRole("slider", { name: /volume/i }) as HTMLInputElement;
+ expect(parseFloat(volumeSlider.value)).toBe(0);
+ });
+
+ it("restores previous volume when unmuted", async () => {
+ currentStoreState = makeStoreOverrides({ currentGeneration: SAMPLE_GENERATION });
+ const user = userEvent.setup();
+ render();
+
+ // Mute
+ await user.click(getButtonByTooltip("player.mute")!);
+
+ // Unmute
+ await user.click(getButtonByTooltip("player.unmute")!);
+
+ const volumeSlider = screen.getByRole("slider", { name: /volume/i }) as HTMLInputElement;
+ expect(parseFloat(volumeSlider.value)).toBe(1);
+ });
+ });
+
+ // 6. Handles next/previous track buttons (skip back / skip forward)
+ describe("skip buttons", () => {
+ it("renders skip-back and skip-forward buttons", () => {
+ currentStoreState = makeStoreOverrides({ currentGeneration: SAMPLE_GENERATION });
+ render();
+
+ expect(getButtonByTooltip("player.back10")).toBeDefined();
+ expect(getButtonByTooltip("player.forward10")).toBeDefined();
+ });
+
+ it("disables skip buttons when no audio source is available", () => {
+ render();
+
+ expect(getButtonByTooltip("player.back10")).toBeDisabled();
+ expect(getButtonByTooltip("player.forward10")).toBeDisabled();
+ });
+
+ it("enables skip buttons when audio source is loaded", async () => {
+ currentStoreState = makeStoreOverrides({ currentGeneration: SAMPLE_GENERATION });
+ render();
+
+ await vi.waitFor(() => {
+ expect(getButtonByTooltip("player.back10")).not.toBeDisabled();
+ expect(getButtonByTooltip("player.forward10")).not.toBeDisabled();
+ });
+ });
+ });
+
+ // Bonus: compare mode
+ describe("compare mode", () => {
+ it("shows A/B toggle when compare mode is active", () => {
+ currentStoreState = makeStoreOverrides({
+ currentGeneration: SAMPLE_GENERATION,
+ compareModeActive: true,
+ });
+ render();
+ expect(screen.getByText("A↔B")).toBeInTheDocument();
+ });
+
+ it("hides A/B toggle when compare mode is inactive", () => {
+ currentStoreState = makeStoreOverrides({
+ currentGeneration: SAMPLE_GENERATION,
+ compareModeActive: false,
+ });
+ render();
+ expect(screen.queryByText("A↔B")).not.toBeInTheDocument();
+ });
+ });
+
+ // Bonus: speed control
+ describe("speed control", () => {
+ it("displays the default speed of 1x", () => {
+ currentStoreState = makeStoreOverrides({ currentGeneration: SAMPLE_GENERATION });
+ render();
+ expect(screen.getByText("1x")).toBeInTheDocument();
+ });
+
+ it("cycles through speed options on click", async () => {
+ currentStoreState = makeStoreOverrides({ currentGeneration: SAMPLE_GENERATION });
+ const user = userEvent.setup();
+ render();
+
+ await vi.waitFor(() => {
+ expect(getButtonByTooltip("player.speed")).not.toBeDisabled();
+ });
+
+ const speedBtn = getButtonByTooltip("player.speed")!;
+ await user.click(speedBtn);
+ expect(screen.getByText("1.25x")).toBeInTheDocument();
+
+ await user.click(speedBtn);
+ expect(screen.getByText("1.5x")).toBeInTheDocument();
+ });
+ });
+
+ // Bonus: loop toggle
+ describe("loop toggle", () => {
+ it("toggles loop mode on click", async () => {
+ currentStoreState = makeStoreOverrides({ currentGeneration: SAMPLE_GENERATION });
+ const user = userEvent.setup();
+ render();
+
+ await vi.waitFor(() => {
+ expect(getButtonByTooltip("player.loop")).not.toBeDisabled();
+ });
+
+ const loopBtn = getButtonByTooltip("player.loop")!;
+ await user.click(loopBtn);
+ expect(loopBtn).toBeInTheDocument();
+ });
+ });
+
+ // Bonus: time display
+ describe("time display", () => {
+ it("shows 0:00 for current position and duration when nothing is playing", () => {
+ render();
+ const timeLabels = screen.getAllByText("0:00");
+ expect(timeLabels.length).toBeGreaterThanOrEqual(2);
+ });
+ });
+});
diff --git a/tests/unit/prompt-examples.test.ts b/tests/unit/prompt-examples.test.ts
index 1d03bb3..5d1b4a3 100644
--- a/tests/unit/prompt-examples.test.ts
+++ b/tests/unit/prompt-examples.test.ts
@@ -1,11 +1,17 @@
import { describe, expect, it } from "vitest";
import {
getPromptExampleAt,
+ getRandomPromptExample,
PROMPT_CATEGORIES,
+ PROMPT_EXAMPLE_CATEGORIES,
getPromptsByCategory,
+ getRandomPromptByCategory,
} from "@/app/lib/prompt-examples";
-describe("local prompt examples", () => {
+const EXAMPLES_PER_CATEGORY = 10;
+const TOTAL_EXAMPLES = PROMPT_CATEGORIES.length * EXAMPLES_PER_CATEGORY;
+
+describe("PROMPT_CATEGORIES", () => {
it("covers the required music categories without network access", () => {
expect(PROMPT_CATEGORIES).toEqual([
"pop",
@@ -22,16 +28,115 @@ describe("local prompt examples", () => {
]);
});
- it("returns deterministic examples by index", () => {
+ it("exposes PROMPT_EXAMPLE_CATEGORIES as an alias", () => {
+ expect(PROMPT_EXAMPLE_CATEGORIES).toBe(PROMPT_CATEGORIES);
+ });
+});
+
+describe("getPromptExampleAt", () => {
+ it("returns a non-empty string for index 0", () => {
const example = getPromptExampleAt(0);
expect(typeof example).toBe("string");
expect(example.length).toBeGreaterThan(0);
+ });
+
+ it("is deterministic for the same index", () => {
expect(getPromptExampleAt(999)).toBe(getPromptExampleAt(999));
});
+ it("wraps large indices via modular arithmetic", () => {
+ expect(getPromptExampleAt(0)).toBe(getPromptExampleAt(TOTAL_EXAMPLES));
+ expect(getPromptExampleAt(1)).toBe(getPromptExampleAt(TOTAL_EXAMPLES + 1));
+ });
+
+ it("treats negative indices as positive via Math.abs", () => {
+ expect(getPromptExampleAt(-0)).toBe(getPromptExampleAt(0));
+ expect(getPromptExampleAt(-1)).toBe(getPromptExampleAt(1));
+ expect(getPromptExampleAt(-(TOTAL_EXAMPLES + 5))).toBe(getPromptExampleAt(5));
+ });
+
+ it("truncates fractional indices", () => {
+ expect(getPromptExampleAt(2.9)).toBe(getPromptExampleAt(2));
+ expect(getPromptExampleAt(0.1)).toBe(getPromptExampleAt(0));
+ });
+
+ it("returns a prompt string for every valid index", () => {
+ for (let i = 0; i < TOTAL_EXAMPLES; i++) {
+ const prompt = getPromptExampleAt(i);
+ expect(typeof prompt).toBe("string");
+ expect(prompt.length).toBeGreaterThan(0);
+ }
+ });
+});
+
+describe("getRandomPromptExample", () => {
+ it("uses the provided random function to select an example", () => {
+ // random() = 0 should give the first example (index 0)
+ const first = getRandomPromptExample(() => 0);
+ expect(first).toBe(getPromptExampleAt(0));
+ });
+
+ it("selects the last example when random is close to 1", () => {
+ // random() just below 1 maps to the last index
+ const fakeRandom = () => 0.9999;
+ const last = getRandomPromptExample(fakeRandom);
+ expect(last).toBe(getPromptExampleAt(TOTAL_EXAMPLES - 1));
+ });
+
+ it("returns a non-empty string with default Math.random", () => {
+ const result = getRandomPromptExample();
+ expect(typeof result).toBe("string");
+ expect(result.length).toBeGreaterThan(0);
+ });
+});
+
+describe("getPromptsByCategory", () => {
it("filters examples by category", () => {
const popExamples = getPromptsByCategory("pop");
- expect(popExamples.length).toBeGreaterThan(0);
+ expect(popExamples.length).toBe(10);
expect(popExamples.every((e) => e.category === "pop")).toBe(true);
});
+
+ it("returns an empty array for a non-existent category", () => {
+ expect(getPromptsByCategory("nonexistent")).toEqual([]);
+ });
+
+ it("returns all category prompts with correct types", () => {
+ for (const category of PROMPT_CATEGORIES) {
+ const examples = getPromptsByCategory(category);
+ expect(examples.length).toBe(10);
+ for (const ex of examples) {
+ expect(ex).toHaveProperty("category", category);
+ expect(ex).toHaveProperty("prompt");
+ expect(typeof ex.prompt).toBe("string");
+ expect(ex.prompt.length).toBeGreaterThan(0);
+ }
+ }
+ });
+});
+
+describe("getRandomPromptByCategory", () => {
+ it("selects from the given category using the provided random", () => {
+ const catExamples = getPromptsByCategory("jazz");
+ const prompt = getRandomPromptByCategory("jazz", () => 0);
+ expect(prompt).toBe(catExamples[0].prompt);
+ });
+
+ it("selects the last item in the category when random is close to 1", () => {
+ const catExamples = getPromptsByCategory("edm");
+ const prompt = getRandomPromptByCategory("edm", () => 0.9999);
+ expect(prompt).toBe(catExamples[catExamples.length - 1].prompt);
+ });
+
+ it("falls back to a global random example for an unknown category", () => {
+ const fallback = getRandomPromptExample(() => 0);
+ const result = getRandomPromptByCategory("nonexistent", () => 0);
+ expect(result).toBe(fallback);
+ });
+
+ it("returns a non-empty string with default Math.random", () => {
+ const result = getRandomPromptByCategory("ambient");
+ expect(typeof result).toBe("string");
+ expect(result.length).toBeGreaterThan(0);
+ });
});
diff --git a/tests/unit/settings-components.test.tsx b/tests/unit/settings-components.test.tsx
new file mode 100644
index 0000000..8348dad
--- /dev/null
+++ b/tests/unit/settings-components.test.tsx
@@ -0,0 +1,688 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type {
+ AppSettings,
+ BackendProvisionStatus,
+ DeviceInfo,
+ GenerationRecord,
+ ModelStatusSnapshot,
+} from "@/app/lib/types";
+
+// ---------------------------------------------------------------------------
+// Mocks – declared before imports so vitest hoists them
+// ---------------------------------------------------------------------------
+
+const closeSettings = vi.fn();
+const completeSetup = vi.fn().mockResolvedValue(undefined);
+const enterDemoMode = vi.fn();
+const downloadModelVariant = vi.fn().mockResolvedValue(undefined);
+const selectModelVariant = vi.fn().mockResolvedValue(undefined);
+const refreshModelStatuses = vi.fn().mockResolvedValue(undefined);
+const provisionBackend = vi.fn().mockResolvedValue(undefined);
+const clearGenerationHistory = vi.fn().mockResolvedValue(undefined);
+const deleteAllModels = vi.fn().mockResolvedValue(undefined);
+const hydrateFromPersistence = vi.fn().mockResolvedValue(undefined);
+const openSettings = vi.fn();
+const reopenSetup = vi.fn();
+
+vi.mock("@/app/lib/store", () => ({
+ useGenerationStore: vi.fn(),
+}));
+
+vi.mock("@/app/lib/api", () => ({
+ isTauriRuntime: vi.fn(() => false),
+ getDefaultAppPaths: vi.fn(() =>
+ Promise.resolve({
+ outputDirectory: "~/Music/OpenLoop",
+ modelDirectory: "~/Library/Application Support/OpenLoop/models/checkpoints",
+ logDirectory: "~/Library/Application Support/OpenLoop/logs/backend",
+ }),
+ ),
+ selectDirectory: vi.fn(() => Promise.resolve(null)),
+ setSetting: vi.fn(() => Promise.resolve({})),
+ clearBackendCache: vi.fn(() => Promise.resolve(undefined)),
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string, opts?: Record) => {
+ if (opts?.defaultValue) return opts.defaultValue as string;
+ return key;
+ },
+ i18n: { language: "en", changeLanguage: vi.fn() },
+ }),
+ initReactI18next: { type: "3rdParty", init: vi.fn() },
+ Trans: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+vi.mock("@/app/components/overlay/Toast", () => ({
+ useToast: () => ({ addToast: vi.fn() }),
+}));
+
+vi.mock("@/app/components/settings/sections/ModelsSection", () => ({
+ ModelsSection: () => ModelsSection
,
+}));
+vi.mock("@/app/components/settings/sections/CliPathSection", () => ({
+ CliPathSection: () => CliPathSection
,
+}));
+vi.mock("@/app/components/settings/sections/DefaultsSection", () => ({
+ DefaultsSection: () => DefaultsSection
,
+}));
+vi.mock("@/app/components/settings/sections/GeneralSection", () => ({
+ GeneralSection: () => GeneralSection
,
+}));
+vi.mock("@/app/components/settings/sections/BackendSection", () => ({
+ BackendSection: () => BackendSection
,
+}));
+vi.mock("@/app/components/settings/sections/DangerZoneSection", () => ({
+ DangerZoneSection: (props: {
+ onClearHistory?: () => void;
+ onClearCache?: () => void;
+ onDeleteAllModels?: () => void;
+ }) => (
+
+
+
+
+
+ ),
+}));
+
+vi.mock("@/app/components/settings/SettingsSaveBar", () => ({
+ SettingsSaveBar: (props: {
+ hasUnsavedChanges?: boolean;
+ saveNotice?: string | null;
+ onSave?: () => void;
+ onDiscard?: () => void;
+ }) => (
+
+ {props.hasUnsavedChanges ? unsaved : null}
+ {props.saveNotice ? {props.saveNotice} : null}
+
+
+
+ ),
+}));
+
+vi.mock("@/app/components/settings/SettingsDialogs", () => ({
+ SettingsDialogs: (props: {
+ clearHistoryOpen?: boolean;
+ clearCacheOpen?: boolean;
+ deleteAllModelsOpen?: boolean;
+ onConfirmClearHistory?: () => void;
+ }) => (
+
+ {props.clearHistoryOpen ? clear-history-open : null}
+ {props.clearCacheOpen ? clear-cache-open : null}
+ {props.deleteAllModelsOpen ? delete-models-open : null}
+
+
+ ),
+}));
+
+// ---------------------------------------------------------------------------
+// Imports (after mocks)
+// ---------------------------------------------------------------------------
+
+import { useGenerationStore } from "@/app/lib/store";
+import { SetupScreen } from "@/app/components/settings/SetupScreen";
+import { SettingsOverlay } from "@/app/components/settings/SettingsOverlay";
+
+// ---------------------------------------------------------------------------
+// Fixture factories
+// ---------------------------------------------------------------------------
+
+function makeSettings(overrides?: Partial): AppSettings {
+ return {
+ profile: "standard",
+ modelVariant: "turbo",
+ downloadedModels: ["turbo"],
+ outputDirectory: null,
+ backendPort: 8080,
+ defaultDurationSeconds: 60,
+ defaultAudioFormat: "wav",
+ defaultThinking: false,
+ firstRunCompleted: true,
+ ...overrides,
+ };
+}
+
+function makeDeviceInfo(): DeviceInfo {
+ return {
+ os: "macOS",
+ arch: "aarch64",
+ isAppleSilicon: true,
+ totalMemoryGb: 16,
+ recommendedProfile: "standard",
+ };
+}
+
+function makeProvisionReady(): BackendProvisionStatus {
+ return {
+ state: "ready",
+ installedCommit: "abc123",
+ installedTag: "v0.1.0",
+ latestCommit: "abc123",
+ latestTag: "v0.1.0",
+ updateAvailable: false,
+ downloadedBytes: 0,
+ };
+}
+
+function makeModelStatuses(): ModelStatusSnapshot[] {
+ return [
+ {
+ variant: "turbo",
+ state: "ready",
+ modelName: "acestep-v15-turbo",
+ label: "Turbo",
+ description: "Turbo model",
+ downloadedBytes: 8 * 1024 * 1024 * 1024,
+ totalBytes: 8 * 1024 * 1024 * 1024,
+ },
+ ];
+}
+
+function makeGenerationRecord(): GenerationRecord {
+ return {
+ id: "gen-1",
+ createdAt: "2026-01-01T00:00:00Z",
+ prompt: "test prompt",
+ lyrics: "",
+ vocalLanguage: "en",
+ durationSeconds: 30,
+ timeSignature: "4",
+ taskType: "text2music",
+ thinking: false,
+ inferenceSteps: 30,
+ guidanceScale: 7,
+ useFormat: false,
+ useCotCaption: false,
+ useCotLanguage: false,
+ constrainedDecoding: false,
+ audioFormat: "wav",
+ outputPath: null,
+ status: "completed",
+ errorMessage: null,
+ isFavorite: false,
+ useRandomSeed: false,
+ };
+}
+
+function defaultStoreValues() {
+ return {
+ deviceInfo: makeDeviceInfo(),
+ settings: makeSettings(),
+ modelStatuses: makeModelStatuses(),
+ backendProvisionStatus: makeProvisionReady(),
+ history: [makeGenerationRecord()],
+ closeSettings,
+ completeSetup,
+ enterDemoMode,
+ downloadModelVariant,
+ selectModelVariant,
+ refreshModelStatuses,
+ provisionBackend,
+ clearGenerationHistory,
+ deleteAllModels,
+ hydrateFromPersistence,
+ openSettings,
+ reopenSetup,
+ };
+}
+
+function setupMockStore(overrides?: Record) {
+ const values = { ...defaultStoreValues(), ...overrides };
+ (vi.mocked(useGenerationStore) as any).mockImplementation(
+ (selector: (state: Record) => unknown) => selector(values),
+ );
+}
+
+/** Click the Next button and wait for the step title to appear. */
+async function goToStep(user: ReturnType, stepTitle: string) {
+ await user.click(screen.getByText("setup.next"));
+ await screen.findByText(stepTitle);
+}
+
+// ===========================================================================
+// SetupScreen
+// ===========================================================================
+
+describe("SetupScreen", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setupMockStore();
+ });
+
+ // -- Welcome step ---------------------------------------------------------
+
+ it("renders the welcome step by default with action cards", () => {
+ render();
+
+ expect(screen.getByText("setup.welcome")).toBeTruthy();
+ expect(screen.getByText("setup.welcomeBody")).toBeTruthy();
+ expect(screen.getByText("setup.downloadModel")).toBeTruthy();
+ expect(screen.getByText("setup.pickOutput")).toBeTruthy();
+ });
+
+ it("renders the privacy policy link on the welcome step", () => {
+ render();
+
+ const privacyLink = screen.getByText("Privacy policy");
+ expect(privacyLink.getAttribute("href")).toContain("privacy.md");
+ expect(privacyLink.getAttribute("target")).toBe("_blank");
+ });
+
+ // -- Navigation -----------------------------------------------------------
+
+ it("navigates through all steps with Next button", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await goToStep(user, "setup.device");
+ await goToStep(user, "setup.model");
+ await goToStep(user, "setup.output");
+ await goToStep(user, "setup.done");
+ });
+
+ it("shows Back button after the first step and navigates backward", async () => {
+ const user = userEvent.setup();
+ render();
+
+ // No back button on welcome step
+ expect(screen.queryByText("setup.back")).toBeNull();
+
+ // Move to device step
+ await goToStep(user, "setup.device");
+ expect(screen.getByText("setup.back")).toBeTruthy();
+
+ // Go back to welcome
+ await user.click(screen.getByText("setup.back"));
+ await screen.findByText("setup.welcome");
+ expect(screen.queryByText("setup.back")).toBeNull();
+ });
+
+ it("shows Finish button on the done step instead of Next", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await goToStep(user, "setup.device");
+ await goToStep(user, "setup.model");
+ await goToStep(user, "setup.output");
+ await goToStep(user, "setup.done");
+
+ expect(screen.queryByText("setup.next")).toBeNull();
+ expect(screen.getByText("setup.finish")).toBeTruthy();
+ });
+
+ it("calls completeSetup when Finish is clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await goToStep(user, "setup.device");
+ await goToStep(user, "setup.model");
+ await goToStep(user, "setup.output");
+ await goToStep(user, "setup.done");
+
+ await user.click(screen.getByText("setup.finish"));
+ expect(completeSetup).toHaveBeenCalledTimes(1);
+ });
+
+ // -- Close button ---------------------------------------------------------
+
+ it("shows Close button when onClose prop is provided", async () => {
+ const onClose = vi.fn();
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByText("setup.close"));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not show Close button when onClose is not provided", () => {
+ render();
+
+ expect(screen.queryByText("setup.close")).toBeNull();
+ });
+
+ // -- Device step ----------------------------------------------------------
+
+ it("renders device info cards on the device step", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await goToStep(user, "setup.device");
+
+ expect(screen.getByText("setup.os")).toBeTruthy();
+ expect(screen.getByText("macOS")).toBeTruthy();
+ expect(screen.getByText("setup.architecture")).toBeTruthy();
+ expect(screen.getByText("aarch64")).toBeTruthy();
+ expect(screen.getByText("setup.memory")).toBeTruthy();
+ expect(screen.getByText("16 GB")).toBeTruthy();
+ expect(screen.getByText("setup.recommendedProfile")).toBeTruthy();
+ });
+
+ it("falls back to 'common.unknown' when deviceInfo is null", async () => {
+ setupMockStore({ deviceInfo: null });
+ const user = userEvent.setup();
+ render();
+
+ await goToStep(user, "setup.device");
+
+ const unknowns = screen.getAllByText("common.unknown");
+ // os, arch, memory = 3 unknowns (profile falls back to settings.profile)
+ expect(unknowns.length).toBeGreaterThanOrEqual(3);
+ });
+
+ // -- Model step -----------------------------------------------------------
+
+ it("renders engine provisioning card and model packs on the model step", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await goToStep(user, "setup.device");
+ await goToStep(user, "setup.model");
+
+ expect(screen.getByText("ACE-Step Engine")).toBeTruthy();
+ expect(
+ screen.getByText("The ACE-Step Python engine runs locally to generate music."),
+ ).toBeTruthy();
+ // Ready badge (defaultValue: "Ready")
+ expect(screen.getByText("Ready")).toBeTruthy();
+ });
+
+ it("shows variant picker cards on the model step", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await goToStep(user, "setup.device");
+ await goToStep(user, "setup.model");
+
+ expect(screen.getByText("Lite")).toBeTruthy();
+ expect(screen.getByText("Turbo")).toBeTruthy();
+ expect(screen.getByText("XL Turbo")).toBeTruthy();
+ });
+
+ it("shows skip demo link on the model step", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await goToStep(user, "setup.device");
+ await goToStep(user, "setup.model");
+
+ // "setup.skipDemo" has defaultValue: "Skip and try a demo prompt"
+ expect(screen.getByText("Skip and try a demo prompt")).toBeTruthy();
+ });
+
+ it("calls enterDemoMode and completeSetup when skip demo is clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await goToStep(user, "setup.device");
+ await goToStep(user, "setup.model");
+ await user.click(screen.getByText("Skip and try a demo prompt"));
+
+ expect(enterDemoMode).toHaveBeenCalledTimes(1);
+ await waitFor(() => {
+ expect(completeSetup).toHaveBeenCalled();
+ });
+ });
+
+ // -- Output step ----------------------------------------------------------
+
+ it("renders output directory picker on the output step", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await goToStep(user, "setup.device");
+ await goToStep(user, "setup.model");
+ await goToStep(user, "setup.output");
+
+ expect(screen.getByText("settings.outputDirectory")).toBeTruthy();
+ expect(screen.getByText("settings.chooseFolder")).toBeTruthy();
+ expect(screen.getByText("settings.defaultPath")).toBeTruthy();
+ expect(screen.getByText("~/Music/OpenLoop")).toBeTruthy();
+ });
+
+ it("hides default path badge when custom directory is set", async () => {
+ setupMockStore({ settings: makeSettings({ outputDirectory: "/custom/path" }) });
+ const user = userEvent.setup();
+ render();
+
+ await goToStep(user, "setup.device");
+ await goToStep(user, "setup.model");
+ await goToStep(user, "setup.output");
+
+ expect(screen.getByText("/custom/path")).toBeTruthy();
+ expect(screen.queryByText("settings.defaultPath")).toBeNull();
+ });
+
+ // -- Done step ------------------------------------------------------------
+
+ it("renders keyboard shortcuts card on the done step", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await goToStep(user, "setup.device");
+ await goToStep(user, "setup.model");
+ await goToStep(user, "setup.output");
+ await goToStep(user, "setup.done");
+
+ // "setup.shortcutsHint" has defaultValue: "Keyboard shortcuts"
+ expect(screen.getByText("Keyboard shortcuts")).toBeTruthy();
+ });
+
+ // -- StepIndicator --------------------------------------------------------
+
+ it("renders step indicator with correct number of dots", () => {
+ const { container } = render();
+
+ // StepIndicator renders one child per step
+ const stepDots = container.querySelectorAll(".h-1.rounded-full");
+ expect(stepDots.length).toBe(5);
+ });
+
+ // -- Engine status states -------------------------------------------------
+
+ it("shows failed badge when backend provision fails", async () => {
+ setupMockStore({
+ backendProvisionStatus: {
+ ...makeProvisionReady(),
+ state: "failed",
+ error: { code: "ERR", message: "download error", recoverable: true },
+ },
+ });
+ const user = userEvent.setup();
+ render();
+
+ await goToStep(user, "setup.device");
+ await goToStep(user, "setup.model");
+
+ expect(screen.getByText("model.failed")).toBeTruthy();
+ });
+
+ it("shows retry button when engine download failed", async () => {
+ setupMockStore({
+ backendProvisionStatus: {
+ ...makeProvisionReady(),
+ state: "failed",
+ },
+ });
+ const user = userEvent.setup();
+ render();
+
+ await goToStep(user, "setup.device");
+ await goToStep(user, "setup.model");
+
+ const retryButtons = screen.getAllByText("model.retry");
+ expect(retryButtons.length).toBeGreaterThanOrEqual(1);
+ });
+});
+
+// ===========================================================================
+// SettingsOverlay
+// ===========================================================================
+
+describe("SettingsOverlay", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setupMockStore();
+ });
+
+ // -- Basic rendering ------------------------------------------------------
+
+ it("renders the settings title and description", () => {
+ render();
+
+ expect(screen.getByText("settings.title")).toBeTruthy();
+ expect(screen.getByText("settings.description")).toBeTruthy();
+ });
+
+ it("renders all section navigation tabs", () => {
+ render();
+
+ expect(screen.getByText("settings.models")).toBeTruthy();
+ expect(screen.getByText("settings.defaults")).toBeTruthy();
+ expect(screen.getByText("settings.general")).toBeTruthy();
+ expect(screen.getByText("settings.backend")).toBeTruthy();
+ expect(screen.getByText("settings.danger")).toBeTruthy();
+ });
+
+ it("renders all section components", () => {
+ render();
+
+ expect(screen.getByTestId("models-section")).toBeTruthy();
+ expect(screen.getByTestId("clipath-section")).toBeTruthy();
+ expect(screen.getByTestId("defaults-section")).toBeTruthy();
+ expect(screen.getByTestId("general-section")).toBeTruthy();
+ expect(screen.getByTestId("backend-section")).toBeTruthy();
+ expect(screen.getByTestId("danger-section")).toBeTruthy();
+ });
+
+ it("renders the save bar", () => {
+ render();
+
+ expect(screen.getByTestId("save-bar")).toBeTruthy();
+ });
+
+ // -- Close button ---------------------------------------------------------
+
+ it("calls closeSettings when close button is clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByLabelText("setup.close"));
+
+ expect(closeSettings).toHaveBeenCalledTimes(1);
+ });
+
+ // -- Section navigation scroll --------------------------------------------
+
+ it("scrolls to section when nav tab is clicked", async () => {
+ const scrollIntoViewMock = vi.fn();
+ Element.prototype.scrollIntoView = scrollIntoViewMock;
+
+ const user = userEvent.setup();
+ render();
+
+ // Create a target element for scrollIntoView
+ const target = document.createElement("div");
+ target.id = "settings-section-models";
+ document.body.appendChild(target);
+
+ await user.click(screen.getByText("settings.models"));
+
+ expect(scrollIntoViewMock).toHaveBeenCalledWith({ block: "start" });
+
+ document.body.removeChild(target);
+ });
+
+ // -- Dialogs (via DangerZoneSection mock) ---------------------------------
+
+ it("opens clear history dialog when trigger is clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByTestId("trigger-clear-history"));
+ expect(screen.getByText("clear-history-open")).toBeTruthy();
+ });
+
+ it("opens clear cache dialog when trigger is clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByTestId("trigger-clear-cache"));
+ expect(screen.getByText("clear-cache-open")).toBeTruthy();
+ });
+
+ it("opens delete all models dialog when trigger is clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByTestId("trigger-delete-models"));
+ expect(screen.getByText("delete-models-open")).toBeTruthy();
+ });
+
+ // -- Save and discard actions ---------------------------------------------
+
+ it("calls saveChanges via save bar trigger", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByTestId("trigger-save"));
+
+ // saveChanges calls persistSetting (which is mocked via api.setSetting),
+ // then hydrateFromPersistence on success
+ await waitFor(() => {
+ expect(hydrateFromPersistence).toHaveBeenCalled();
+ });
+ });
+
+ it("calls discardChanges via discard bar trigger", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByTestId("trigger-discard"));
+
+ // discard resets draft; component should remain rendered
+ expect(screen.getByTestId("save-bar")).toBeTruthy();
+ });
+
+ // -- Edge cases -----------------------------------------------------------
+
+ it("renders with empty history", () => {
+ setupMockStore({ history: [] });
+ render();
+
+ expect(screen.getByText("settings.title")).toBeTruthy();
+ expect(screen.getByTestId("danger-section")).toBeTruthy();
+ });
+
+ it("renders with no downloaded models", () => {
+ setupMockStore({
+ settings: makeSettings({ downloadedModels: [] }),
+ modelStatuses: [],
+ });
+ render();
+
+ expect(screen.getByText("settings.title")).toBeTruthy();
+ expect(screen.getByTestId("models-section")).toBeTruthy();
+ });
+});
diff --git a/tests/unit/settings-slice.test.ts b/tests/unit/settings-slice.test.ts
new file mode 100644
index 0000000..b8bd42f
--- /dev/null
+++ b/tests/unit/settings-slice.test.ts
@@ -0,0 +1,628 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { create } from "zustand";
+import type { GenerationStore } from "@/app/lib/store/types";
+
+/* ------------------------------------------------------------------ */
+/* Module mocks */
+/* ------------------------------------------------------------------ */
+
+vi.mock("@/app/lib/i18n", () => ({
+ default: {
+ t: vi.fn((key: string) => key),
+ changeLanguage: vi.fn(() => Promise.resolve()),
+ language: "en",
+ },
+ detectSystemLanguage: vi.fn(() => "en"),
+ SUPPORTED_LANGUAGES: [
+ { code: "en", name: "English" },
+ { code: "zh-CN", name: "简体中文" },
+ ],
+}));
+
+vi.mock("@/app/lib/api", () => ({
+ isTauriRuntime: vi.fn(() => false),
+ setSetting: vi.fn(() => Promise.resolve()),
+ getSettings: vi.fn(() => Promise.resolve({})),
+ getDeviceInfo: vi.fn(() => Promise.resolve(null)),
+ listGenerations: vi.fn(() => Promise.resolve([])),
+ listModelCatalog: vi.fn(() => Promise.resolve([])),
+ getModelStatus: vi.fn(() => Promise.resolve([])),
+ listActiveGenerationTasks: vi.fn(() => Promise.resolve([])),
+}));
+
+vi.mock("@/app/lib/errors", () => ({
+ localizeModelStatuses: vi.fn((s: unknown) => s),
+}));
+
+vi.mock(
+ "@/app/lib/model-packs",
+ async (importOriginal: () => Promise) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ expandDownloadedVariantsFromStatuses: vi.fn(() => []),
+ };
+ },
+);
+
+vi.mock("@/app/lib/validation-helpers", () => ({
+ computeValidationState: vi.fn(() => ({
+ validationErrors: {},
+ currentRequest: null,
+ })),
+}));
+
+vi.mock("@/app/lib/profile-presets", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ applyProfilePreset: vi.fn((form: unknown) => form),
+ applyModelVariantToForm: vi.fn((form: unknown) => form),
+ };
+});
+
+/* ------------------------------------------------------------------ */
+/* Imports (after mocks) */
+/* ------------------------------------------------------------------ */
+
+const { createSettingsSlice } = await import("@/app/lib/store/slices/settings");
+const api = await import("@/app/lib/api");
+const { PROFILE_FORM_PRESETS } = await import("@/app/lib/profile-presets");
+const { computeValidationState } = await import("@/app/lib/validation-helpers");
+const i18nModule = await import("@/app/lib/i18n");
+
+/* ------------------------------------------------------------------ */
+/* Helpers */
+/* ------------------------------------------------------------------ */
+
+const mockForm = {
+ prompt: "",
+ negativePrompt: "",
+ lyrics: "",
+ vocalLanguage: "en",
+ durationSeconds: "30",
+ bpmMode: "auto" as const,
+ bpm: "",
+ keyScale: "",
+ timeSignature: "4" as const,
+ model: "acestep-v15-turbo",
+ taskType: "text2music" as const,
+ thinking: true,
+ inferenceSteps: "8",
+ guidanceScale: "7.0",
+ useFormat: false,
+ useCotCaption: true,
+ useCotLanguage: true,
+ constrainedDecoding: true,
+ useRandomSeed: false,
+ seed: "",
+ audioFormat: "wav" as const,
+ lmBackend: "mlx" as const,
+ lmModelPath: "acestep-5Hz-lm-0.6B",
+ referenceAudioPath: "",
+ srcAudioPath: "",
+ instruction: "",
+ repaintingStart: "",
+ repaintingEnd: "",
+ audioCoverStrength: "",
+ instrumental: false,
+ variations: 1,
+};
+
+function createTestStore(overrides: Partial = {}) {
+ return create(
+ (set, get) =>
+ ({
+ ...createSettingsSlice(set, get),
+ form: { ...mockForm },
+ modelStatuses: [],
+ generationState: {
+ status: "idle",
+ phase: "idle",
+ statusMessage: "Ready",
+ error: null,
+ },
+ bootstrapStatus: { state: "ready", message: "ok" },
+ setupOverride: false,
+ refreshBootstrapStatus: vi.fn(() => Promise.resolve()),
+ ...overrides,
+ }) as GenerationStore,
+ );
+}
+
+/* ================================================================== */
+/* Settings Slice */
+/* ================================================================== */
+
+describe("Settings slice", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ /* --- initial state ---------------------------------------------- */
+
+ describe("initial state", () => {
+ it("sets hydrated to false", () => {
+ const store = createTestStore();
+ expect(store.getState().hydrated).toBe(false);
+ });
+
+ it("sets recentPrompts and favoritePrompts to empty arrays", () => {
+ const store = createTestStore();
+ expect(store.getState().recentPrompts).toEqual([]);
+ expect(store.getState().favoritePrompts).toEqual([]);
+ });
+
+ it("sets deviceInfo to null", () => {
+ const store = createTestStore();
+ expect(store.getState().deviceInfo).toBeNull();
+ });
+ });
+
+ /* --- addRecentPrompt ------------------------------------------- */
+
+ describe("addRecentPrompt", () => {
+ it("adds a trimmed prompt to recentPrompts", () => {
+ const store = createTestStore();
+ store.getState().addRecentPrompt(" jazz piano ");
+ expect(store.getState().recentPrompts).toEqual(["jazz piano"]);
+ });
+
+ it("deduplicates: moves an existing prompt to the front", () => {
+ const store = createTestStore({
+ recentPrompts: ["rock", "pop", "jazz"],
+ } as Partial);
+
+ store.getState().addRecentPrompt("rock");
+
+ expect(store.getState().recentPrompts).toEqual(["rock", "pop", "jazz"]);
+ });
+
+ it("caps at 20 entries, dropping the oldest", () => {
+ const existing = Array.from({ length: 20 }, (_, i) => `prompt-${i}`);
+ const store = createTestStore({
+ recentPrompts: existing,
+ } as Partial);
+
+ store.getState().addRecentPrompt("new-prompt");
+
+ const prompts = store.getState().recentPrompts;
+ expect(prompts).toHaveLength(20);
+ expect(prompts[0]).toBe("new-prompt");
+ // The last original item gets dropped by slice(0, 20)
+ expect(prompts).not.toContain("prompt-19");
+ });
+
+ it("ignores empty/whitespace-only strings", () => {
+ const store = createTestStore();
+ store.getState().addRecentPrompt(" ");
+ expect(store.getState().recentPrompts).toEqual([]);
+ });
+
+ it("trims whitespace before deduplication", () => {
+ const store = createTestStore({
+ recentPrompts: ["hello"],
+ } as Partial);
+
+ store.getState().addRecentPrompt(" hello ");
+
+ expect(store.getState().recentPrompts).toEqual(["hello"]);
+ });
+ });
+
+ /* --- toggleFavoritePrompt -------------------------------------- */
+
+ describe("toggleFavoritePrompt", () => {
+ it("adds a new prompt to favorites", () => {
+ const store = createTestStore();
+ store.getState().toggleFavoritePrompt("lo-fi beat");
+ expect(store.getState().favoritePrompts).toEqual(["lo-fi beat"]);
+ });
+
+ it("removes an existing prompt from favorites", () => {
+ const store = createTestStore({
+ favoritePrompts: ["lo-fi beat", "ambient"],
+ } as Partial);
+
+ store.getState().toggleFavoritePrompt("lo-fi beat");
+
+ expect(store.getState().favoritePrompts).toEqual(["ambient"]);
+ });
+
+ it("caps favorites at 50 entries, dropping the oldest", () => {
+ const existing = Array.from({ length: 50 }, (_, i) => `fav-${i}`);
+ const store = createTestStore({
+ favoritePrompts: existing,
+ } as Partial);
+
+ store.getState().toggleFavoritePrompt("new-fav");
+
+ const favs = store.getState().favoritePrompts;
+ expect(favs).toHaveLength(50);
+ expect(favs[0]).toBe("new-fav");
+ expect(favs).not.toContain("fav-49");
+ });
+
+ it("ignores empty/whitespace-only strings", () => {
+ const store = createTestStore();
+ store.getState().toggleFavoritePrompt(" ");
+ expect(store.getState().favoritePrompts).toEqual([]);
+ });
+
+ it("trims whitespace before toggling", () => {
+ const store = createTestStore({
+ favoritePrompts: ["hello"],
+ } as Partial);
+
+ store.getState().toggleFavoritePrompt(" hello ");
+ expect(store.getState().favoritePrompts).toEqual([]);
+ });
+ });
+
+ /* --- removeRecentPrompt ---------------------------------------- */
+
+ describe("removeRecentPrompt", () => {
+ it("removes the matching prompt from recentPrompts", () => {
+ const store = createTestStore({
+ recentPrompts: ["rock", "pop", "jazz"],
+ } as Partial);
+
+ store.getState().removeRecentPrompt("pop");
+
+ expect(store.getState().recentPrompts).toEqual(["rock", "jazz"]);
+ });
+
+ it("is a no-op when the prompt is not found", () => {
+ const store = createTestStore({
+ recentPrompts: ["rock"],
+ } as Partial);
+
+ store.getState().removeRecentPrompt("missing");
+
+ expect(store.getState().recentPrompts).toEqual(["rock"]);
+ });
+ });
+
+ /* --- setLanguage ------------------------------------------------ */
+
+ describe("setLanguage", () => {
+ it("persists language via api.setSetting when in Tauri runtime", async () => {
+ vi.mocked(api.isTauriRuntime).mockReturnValue(true);
+ const store = createTestStore();
+
+ await store.getState().setLanguage("zh-CN");
+
+ expect(api.setSetting).toHaveBeenCalledWith("language", "zh-CN");
+ });
+
+ it("skips api.setSetting when not in Tauri runtime", async () => {
+ vi.mocked(api.isTauriRuntime).mockReturnValue(false);
+ const store = createTestStore();
+
+ await store.getState().setLanguage("zh-CN");
+
+ expect(api.setSetting).not.toHaveBeenCalled();
+ });
+
+ it("updates settings.language in store", async () => {
+ const store = createTestStore();
+ await store.getState().setLanguage("zh-CN");
+ expect(store.getState().settings.language).toBe("zh-CN");
+ });
+
+ it("calls i18next.changeLanguage", async () => {
+ const store = createTestStore();
+ await store.getState().setLanguage("zh-CN");
+ expect(i18nModule.default.changeLanguage).toHaveBeenCalledWith("zh-CN");
+ });
+
+ it("resets idle generationState to idle with Ready message", async () => {
+ const store = createTestStore({
+ generationState: {
+ status: "idle",
+ phase: "idle",
+ statusMessage: "Old",
+ error: null,
+ },
+ });
+ await store.getState().setLanguage("en");
+ expect(store.getState().generationState.status).toBe("idle");
+ expect(store.getState().generationState.statusMessage).toBe("Ready");
+ });
+
+ it("preserves non-idle generationState", async () => {
+ const store = createTestStore({
+ generationState: {
+ status: "running",
+ phase: "running",
+ statusMessage: "Generating...",
+ error: null,
+ },
+ });
+ await store.getState().setLanguage("en");
+ expect(store.getState().generationState.status).toBe("running");
+ });
+ });
+
+ /* --- completeSetup --------------------------------------------- */
+
+ describe("completeSetup", () => {
+ describe("non-Tauri (browser) path", () => {
+ it("sets firstRunCompleted to true", async () => {
+ vi.mocked(api.isTauriRuntime).mockReturnValue(false);
+ const store = createTestStore();
+ await store.getState().completeSetup();
+ expect(store.getState().settings.firstRunCompleted).toBe(true);
+ });
+
+ it("sets setupOverride to false", async () => {
+ vi.mocked(api.isTauriRuntime).mockReturnValue(false);
+ const store = createTestStore({ setupOverride: true });
+ await store.getState().completeSetup();
+ expect(store.getState().setupOverride).toBe(false);
+ });
+
+ it("uses deviceInfo.recommendedProfile when available", async () => {
+ vi.mocked(api.isTauriRuntime).mockReturnValue(false);
+ const store = createTestStore({
+ deviceInfo: { recommendedProfile: "quality" } as any,
+ });
+ await store.getState().completeSetup();
+ expect(store.getState().settings.profile).toBe("quality");
+ });
+
+ it("falls back to current settings.profile when no deviceInfo", async () => {
+ vi.mocked(api.isTauriRuntime).mockReturnValue(false);
+ const store = createTestStore({ deviceInfo: null });
+ await store.getState().completeSetup();
+ expect(store.getState().settings.profile).toBe("standard");
+ });
+
+ it("sets defaultThinking from profile preset", async () => {
+ vi.mocked(api.isTauriRuntime).mockReturnValue(false);
+ const store = createTestStore();
+ await store.getState().completeSetup();
+ expect(store.getState().settings.defaultThinking).toBe(
+ PROFILE_FORM_PRESETS.standard.thinking,
+ );
+ });
+
+ it("calls refreshBootstrapStatus", async () => {
+ vi.mocked(api.isTauriRuntime).mockReturnValue(false);
+ const refreshMock = vi.fn(() => Promise.resolve());
+ const store = createTestStore({ refreshBootstrapStatus: refreshMock });
+ await store.getState().completeSetup();
+ expect(refreshMock).toHaveBeenCalled();
+ });
+
+ it("calls computeValidationState with showErrors false", async () => {
+ vi.mocked(api.isTauriRuntime).mockReturnValue(false);
+ const store = createTestStore();
+ await store.getState().completeSetup();
+ expect(computeValidationState).toHaveBeenCalledWith(expect.anything(), {
+ showErrors: false,
+ });
+ });
+ });
+
+ describe("Tauri path", () => {
+ it("persists profile, firstRunCompleted, and defaultThinking", async () => {
+ vi.mocked(api.isTauriRuntime).mockReturnValue(true);
+ const store = createTestStore();
+ await store.getState().completeSetup();
+ expect(api.setSetting).toHaveBeenCalledWith("profile", "standard");
+ expect(api.setSetting).toHaveBeenCalledWith("firstRunCompleted", true);
+ expect(api.setSetting).toHaveBeenCalledWith(
+ "defaultThinking",
+ PROFILE_FORM_PRESETS.standard.thinking,
+ );
+ });
+
+ it("calls hydrateFromPersistence", async () => {
+ vi.mocked(api.isTauriRuntime).mockReturnValue(true);
+ const hydrateMock = vi.fn(() => Promise.resolve());
+ const store = createTestStore({ hydrateFromPersistence: hydrateMock });
+ await store.getState().completeSetup();
+ expect(hydrateMock).toHaveBeenCalled();
+ });
+
+ it("sets setupOverride to false", async () => {
+ vi.mocked(api.isTauriRuntime).mockReturnValue(true);
+ const store = createTestStore({ setupOverride: true });
+ await store.getState().completeSetup();
+ expect(store.getState().setupOverride).toBe(false);
+ });
+
+ it("calls refreshBootstrapStatus after hydration", async () => {
+ vi.mocked(api.isTauriRuntime).mockReturnValue(true);
+ const callOrder: string[] = [];
+ const hydrateMock = vi.fn(async () => {
+ callOrder.push("hydrate");
+ });
+ const refreshMock = vi.fn(async () => {
+ callOrder.push("refresh");
+ });
+ const store = createTestStore({
+ hydrateFromPersistence: hydrateMock,
+ refreshBootstrapStatus: refreshMock,
+ });
+ await store.getState().completeSetup();
+ expect(callOrder).toEqual(["hydrate", "refresh"]);
+ });
+ });
+ });
+
+ /* --- hydrateFromPersistence ------------------------------------ */
+
+ describe("hydrateFromPersistence", () => {
+ describe("non-Tauri (browser) path", () => {
+ it("sets hydrated to true", async () => {
+ vi.mocked(api.isTauriRuntime).mockReturnValue(false);
+ const store = createTestStore();
+ await store.getState().hydrateFromPersistence();
+ expect(store.getState().hydrated).toBe(true);
+ });
+
+ it("sets bootstrapStatus to ready", async () => {
+ vi.mocked(api.isTauriRuntime).mockReturnValue(false);
+ const store = createTestStore();
+ await store.getState().hydrateFromPersistence();
+ expect(store.getState().bootstrapStatus.state).toBe("ready");
+ });
+
+ it("calls i18next.changeLanguage with detected system language", async () => {
+ vi.mocked(api.isTauriRuntime).mockReturnValue(false);
+ const store = createTestStore();
+ await store.getState().hydrateFromPersistence();
+ expect(i18nModule.default.changeLanguage).toHaveBeenCalledWith("en");
+ });
+ });
+
+ describe("Tauri path — success", () => {
+ function mockTauriApis(overrides: Record = {}) {
+ vi.mocked(api.isTauriRuntime).mockReturnValue(true);
+ vi.mocked(api.getSettings).mockResolvedValue({
+ profile: "standard",
+ firstRunCompleted: true,
+ language: "en",
+ ...overrides,
+ } as any);
+ vi.mocked(api.listGenerations).mockResolvedValue([
+ { id: "rec-1", isFavorite: true },
+ { id: "rec-2", isFavorite: false },
+ ] as any);
+ vi.mocked(api.getDeviceInfo).mockResolvedValue({
+ recommendedProfile: "standard",
+ } as any);
+ vi.mocked(api.listModelCatalog).mockResolvedValue([]);
+ vi.mocked(api.getModelStatus).mockResolvedValue([]);
+ vi.mocked(api.listActiveGenerationTasks).mockResolvedValue([]);
+ }
+
+ it("sets hydrated to true on success", async () => {
+ mockTauriApis();
+ const store = createTestStore();
+ await store.getState().hydrateFromPersistence();
+ expect(store.getState().hydrated).toBe(true);
+ });
+
+ it("merges persisted settings into store", async () => {
+ mockTauriApis({ backendPort: 9001 });
+ const store = createTestStore();
+ await store.getState().hydrateFromPersistence();
+ expect(store.getState().settings.backendPort).toBe(9001);
+ });
+
+ it("sets deviceInfo from API", async () => {
+ mockTauriApis();
+ const store = createTestStore();
+ await store.getState().hydrateFromPersistence();
+ expect(store.getState().deviceInfo).toEqual({
+ recommendedProfile: "standard",
+ });
+ });
+
+ it("sets history and first record as currentGeneration", async () => {
+ mockTauriApis();
+ const store = createTestStore();
+ await store.getState().hydrateFromPersistence();
+ expect(store.getState().history).toHaveLength(2);
+ expect(store.getState().currentGeneration?.id).toBe("rec-1");
+ });
+
+ it("extracts favorite record IDs", async () => {
+ mockTauriApis();
+ const store = createTestStore();
+ await store.getState().hydrateFromPersistence();
+ expect(store.getState().favoriteRecordIds).toEqual(["rec-1"]);
+ });
+
+ it("resets generationState to idle", async () => {
+ mockTauriApis();
+ const store = createTestStore();
+ await store.getState().hydrateFromPersistence();
+ expect(store.getState().generationState).toEqual({
+ status: "idle",
+ phase: "idle",
+ statusMessage: "Ready",
+ error: null,
+ });
+ });
+
+ it("uses deviceInfo.recommendedProfile when firstRunCompleted is false", async () => {
+ mockTauriApis();
+ vi.mocked(api.getSettings).mockResolvedValue({
+ profile: "standard",
+ firstRunCompleted: false,
+ } as any);
+ vi.mocked(api.getDeviceInfo).mockResolvedValue({
+ recommendedProfile: "quality",
+ } as any);
+
+ const store = createTestStore();
+ await store.getState().hydrateFromPersistence();
+
+ expect(store.getState().settings.profile).toBe("quality");
+ });
+
+ it("sets currentGeneration to null when history is empty", async () => {
+ mockTauriApis();
+ vi.mocked(api.listGenerations).mockResolvedValue([] as any);
+
+ const store = createTestStore();
+ await store.getState().hydrateFromPersistence();
+
+ expect(store.getState().currentGeneration).toBeNull();
+ });
+ });
+
+ describe("Tauri path — error", () => {
+ function mockFailingTauriApis() {
+ vi.mocked(api.isTauriRuntime).mockReturnValue(true);
+ vi.mocked(api.getSettings).mockRejectedValue(new Error("db error"));
+ vi.mocked(api.listGenerations).mockResolvedValue([]);
+ vi.mocked(api.getDeviceInfo).mockResolvedValue(null as any);
+ vi.mocked(api.listModelCatalog).mockResolvedValue([]);
+ vi.mocked(api.getModelStatus).mockResolvedValue([]);
+ vi.mocked(api.listActiveGenerationTasks).mockResolvedValue([]);
+ }
+
+ it("sets hydrated to true even on error", async () => {
+ mockFailingTauriApis();
+ const store = createTestStore();
+ await store.getState().hydrateFromPersistence();
+ expect(store.getState().hydrated).toBe(true);
+ });
+
+ it("sets bootstrapStatus to failed on error", async () => {
+ mockFailingTauriApis();
+ const store = createTestStore();
+ await store.getState().hydrateFromPersistence();
+ expect(store.getState().bootstrapStatus.state).toBe("failed");
+ });
+
+ it("sets generationState to failed with recoverable error", async () => {
+ mockFailingTauriApis();
+ const store = createTestStore();
+ await store.getState().hydrateFromPersistence();
+ expect(store.getState().generationState.status).toBe("failed");
+ expect(store.getState().generationState.error?.recoverable).toBe(true);
+ });
+
+ it("includes error details in the failure state", async () => {
+ vi.mocked(api.isTauriRuntime).mockReturnValue(true);
+ vi.mocked(api.getSettings).mockRejectedValue("connection lost");
+ vi.mocked(api.listGenerations).mockResolvedValue([]);
+ vi.mocked(api.getDeviceInfo).mockResolvedValue(null as any);
+ vi.mocked(api.listModelCatalog).mockResolvedValue([]);
+ vi.mocked(api.getModelStatus).mockResolvedValue([]);
+ vi.mocked(api.listActiveGenerationTasks).mockResolvedValue([]);
+
+ const store = createTestStore();
+ await store.getState().hydrateFromPersistence();
+
+ expect(store.getState().generationState.error?.details).toContain("connection lost");
+ });
+ });
+ });
+});
diff --git a/tests/unit/store-slices.test.ts b/tests/unit/store-slices.test.ts
index eb79112..d28d0a5 100644
--- a/tests/unit/store-slices.test.ts
+++ b/tests/unit/store-slices.test.ts
@@ -2,11 +2,26 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AppError, GenerationEvent, GenerationRecord } from "@/app/lib/types";
vi.mock("@/app/lib/api", () => ({
- isTauriRuntime: false,
+ isTauriRuntime: vi.fn(() => false),
+ enhancePrompt: vi.fn(),
+ resumeGenerationTask: vi.fn(),
+ cancelGeneration: vi.fn(),
}));
+vi.mock("@/app/lib/store-helpers", async (importOriginal) => {
+ const mod: any = await importOriginal();
+ return { ...mod, sleep: vi.fn().mockResolvedValue(undefined) };
+});
+
+vi.mock("@/app/lib/model-packs", async (importOriginal) => {
+ const mod: any = await importOriginal();
+ return { ...mod, isModelDownloaded: vi.fn(() => true) };
+});
+
const { DEFAULT_GENERATION_FORM_VALUES } = await import("@/app/lib/validation");
const { useGenerationStore } = await import("@/app/lib/store");
+const api = await import("@/app/lib/api");
+const { isModelDownloaded } = await import("@/app/lib/model-packs");
/* ------------------------------------------------------------------ */
/* helpers */
@@ -841,3 +856,1139 @@ describe("applyGenerationEvent", () => {
expect(gs.variationTotal).toBe(3);
});
});
+
+/* ================================================================== */
+/* 4. History Slice — uncovered actions */
+/* ================================================================== */
+
+describe("History slice (uncovered actions)", () => {
+ beforeEach(() => {
+ resetStore();
+ });
+
+ /* --- deleteGenerationRecord ------------------------------------- */
+
+ describe("deleteGenerationRecord", () => {
+ it("removes the record from history", async () => {
+ const rec = record({ id: "del-1" });
+ useGenerationStore.setState({ history: [rec] });
+
+ await useGenerationStore.getState().deleteGenerationRecord("del-1");
+
+ expect(useGenerationStore.getState().history).toEqual([]);
+ });
+
+ it("stores lastDeletedRecord by default (undoable)", async () => {
+ const rec = record({ id: "del-1" });
+ useGenerationStore.setState({ history: [rec] });
+
+ await useGenerationStore.getState().deleteGenerationRecord("del-1");
+
+ expect(useGenerationStore.getState().lastDeletedRecord?.id).toBe("del-1");
+ });
+
+ it("does not store lastDeletedRecord when undoable is false", async () => {
+ const rec = record({ id: "del-1" });
+ useGenerationStore.setState({ history: [rec], lastDeletedRecord: null });
+
+ await useGenerationStore.getState().deleteGenerationRecord("del-1", { undoable: false });
+
+ expect(useGenerationStore.getState().lastDeletedRecord).toBeNull();
+ });
+
+ it("advances currentGeneration to next record when deleting current", async () => {
+ const cur = record({ id: "cur" });
+ const next = record({ id: "next" });
+ useGenerationStore.setState({
+ history: [cur, next],
+ currentGeneration: cur,
+ });
+
+ await useGenerationStore.getState().deleteGenerationRecord("cur");
+
+ expect(useGenerationStore.getState().currentGeneration?.id).toBe("next");
+ });
+
+ it("sets currentGeneration to null when deleting the last record", async () => {
+ const cur = record({ id: "only" });
+ useGenerationStore.setState({
+ history: [cur],
+ currentGeneration: cur,
+ });
+
+ await useGenerationStore.getState().deleteGenerationRecord("only");
+
+ expect(useGenerationStore.getState().currentGeneration).toBeNull();
+ });
+
+ it("keeps currentGeneration when deleting a non-current record", async () => {
+ const cur = record({ id: "cur" });
+ const other = record({ id: "other" });
+ useGenerationStore.setState({
+ history: [cur, other],
+ currentGeneration: cur,
+ });
+
+ await useGenerationStore.getState().deleteGenerationRecord("other");
+
+ expect(useGenerationStore.getState().currentGeneration?.id).toBe("cur");
+ });
+ });
+
+ /* --- clearGenerationHistory ------------------------------------- */
+
+ describe("clearGenerationHistory", () => {
+ it("empties history array", async () => {
+ useGenerationStore.setState({
+ history: [record({ id: "a" }), record({ id: "b" })],
+ });
+
+ await useGenerationStore.getState().clearGenerationHistory();
+
+ expect(useGenerationStore.getState().history).toEqual([]);
+ });
+
+ it("clears currentGeneration", async () => {
+ const cur = record({ id: "cur" });
+ useGenerationStore.setState({ currentGeneration: cur });
+
+ await useGenerationStore.getState().clearGenerationHistory();
+
+ expect(useGenerationStore.getState().currentGeneration).toBeNull();
+ });
+
+ it("resets compare state", async () => {
+ useGenerationStore.setState({
+ compareModeActive: true,
+ compareGenerationId: "some-id",
+ selectedHistoryIds: ["a", "b"],
+ favoriteRecordIds: ["a"],
+ });
+
+ await useGenerationStore.getState().clearGenerationHistory();
+
+ const state = useGenerationStore.getState();
+ expect(state.compareModeActive).toBe(false);
+ expect(state.compareGenerationId).toBeNull();
+ expect(state.selectedHistoryIds).toEqual([]);
+ expect(state.favoriteRecordIds).toEqual([]);
+ });
+ });
+
+ /* --- loadGenerationSettings ------------------------------------- */
+
+ describe("loadGenerationSettings", () => {
+ it("is a no-op when record id is not in history", () => {
+ const curForm = useGenerationStore.getState().form;
+ useGenerationStore.setState({ history: [] });
+
+ useGenerationStore.getState().loadGenerationSettings("missing", "settings");
+
+ expect(useGenerationStore.getState().form).toEqual(curForm);
+ });
+
+ it("populates form fields from the record in settings mode", () => {
+ const rec = record({
+ id: "src",
+ prompt: "lo-fi beats",
+ durationSeconds: 60,
+ bpm: 120,
+ useRandomSeed: false,
+ seed: 99,
+ });
+ useGenerationStore.setState({ history: [rec] });
+
+ useGenerationStore.getState().loadGenerationSettings("src", "settings");
+
+ const form = useGenerationStore.getState().form;
+ expect(form.prompt).toBe("lo-fi beats");
+ expect(form.durationSeconds).toBe("60");
+ expect(form.bpm).toBe("120");
+ expect(form.useRandomSeed).toBe(false);
+ expect(form.seed).toBe("99");
+ });
+
+ it("forces useRandomSeed to false in reproduce mode", () => {
+ const rec = record({
+ id: "src",
+ useRandomSeed: true,
+ seed: 42,
+ });
+ useGenerationStore.setState({ history: [rec] });
+
+ useGenerationStore.getState().loadGenerationSettings("src", "reproduce");
+
+ expect(useGenerationStore.getState().form.useRandomSeed).toBe(false);
+ });
+
+ it("sets seed to the record's seed in reproduce mode", () => {
+ const rec = record({
+ id: "src",
+ useRandomSeed: false,
+ seed: 77,
+ });
+ useGenerationStore.setState({ history: [rec] });
+
+ useGenerationStore.getState().loadGenerationSettings("src", "reproduce");
+
+ expect(useGenerationStore.getState().form.seed).toBe("77");
+ });
+
+ it("sets currentGeneration to the loaded record", () => {
+ const rec = record({ id: "src" });
+ useGenerationStore.setState({ history: [rec], currentGeneration: null });
+
+ useGenerationStore.getState().loadGenerationSettings("src", "settings");
+
+ expect(useGenerationStore.getState().currentGeneration?.id).toBe("src");
+ });
+
+ it("resets generationState to idle", () => {
+ const rec = record({ id: "src" });
+ useGenerationStore.setState({
+ history: [rec],
+ generationState: {
+ status: "completed",
+ phase: "completed",
+ statusMessage: "Done",
+ error: null,
+ },
+ });
+
+ useGenerationStore.getState().loadGenerationSettings("src", "settings");
+
+ expect(useGenerationStore.getState().generationState.status).toBe("idle");
+ });
+
+ it("handles bpm undefined (auto mode)", () => {
+ const rec = record({ id: "src", bpm: undefined });
+ useGenerationStore.setState({ history: [rec] });
+
+ useGenerationStore.getState().loadGenerationSettings("src", "settings");
+
+ expect(useGenerationStore.getState().form.bpmMode).toBe("auto");
+ expect(useGenerationStore.getState().form.bpm).toBe("");
+ });
+
+ it("handles seed undefined in reproduce mode", () => {
+ const rec = record({ id: "src", useRandomSeed: false, seed: undefined });
+ useGenerationStore.setState({ history: [rec] });
+
+ useGenerationStore.getState().loadGenerationSettings("src", "reproduce");
+
+ expect(useGenerationStore.getState().form.seed).toBe("");
+ });
+
+ it("sets seed to empty string when useRandomSeed is true in settings mode", () => {
+ const rec = record({ id: "src", useRandomSeed: true, seed: 42 });
+ useGenerationStore.setState({ history: [rec] });
+
+ useGenerationStore.getState().loadGenerationSettings("src", "settings");
+
+ expect(useGenerationStore.getState().form.seed).toBe("");
+ });
+ });
+
+ /* --- toggleFavoriteRecord (non-Tauri path) ---------------------- */
+
+ describe("toggleFavoriteRecord", () => {
+ it("adds id to favoriteRecordIds when not currently favorite", async () => {
+ const rec = record({ id: "fav-1", isFavorite: false });
+ useGenerationStore.setState({
+ history: [rec],
+ favoriteRecordIds: [],
+ });
+
+ await useGenerationStore.getState().toggleFavoriteRecord("fav-1");
+
+ const state = useGenerationStore.getState();
+ expect(state.favoriteRecordIds).toContain("fav-1");
+ expect(state.history.find((r) => r.id === "fav-1")?.isFavorite).toBe(true);
+ });
+
+ it("removes id from favoriteRecordIds when already favorite", async () => {
+ const rec = record({ id: "fav-1", isFavorite: true });
+ useGenerationStore.setState({
+ history: [rec],
+ favoriteRecordIds: ["fav-1"],
+ });
+
+ await useGenerationStore.getState().toggleFavoriteRecord("fav-1");
+
+ const state = useGenerationStore.getState();
+ expect(state.favoriteRecordIds).not.toContain("fav-1");
+ expect(state.history.find((r) => r.id === "fav-1")?.isFavorite).toBe(false);
+ });
+
+ it("does not affect other records in history", async () => {
+ const a = record({ id: "a", isFavorite: false });
+ const b = record({ id: "b", isFavorite: false });
+ useGenerationStore.setState({
+ history: [a, b],
+ favoriteRecordIds: [],
+ });
+
+ await useGenerationStore.getState().toggleFavoriteRecord("a");
+
+ expect(useGenerationStore.getState().history.find((r) => r.id === "b")?.isFavorite).toBe(
+ false,
+ );
+ });
+ });
+
+ /* --- batchDeleteSelected ---------------------------------------- */
+
+ describe("batchDeleteSelected", () => {
+ it("is a no-op when selectedHistoryIds is empty", async () => {
+ const history = [record({ id: "a" })];
+ useGenerationStore.setState({ history, selectedHistoryIds: [] });
+
+ await useGenerationStore.getState().batchDeleteSelected();
+
+ expect(useGenerationStore.getState().history).toEqual(history);
+ });
+
+ it("removes all selected records from history", async () => {
+ const a = record({ id: "a" });
+ const b = record({ id: "b" });
+ const c = record({ id: "c" });
+ useGenerationStore.setState({
+ history: [a, b, c],
+ selectedHistoryIds: ["a", "c"],
+ });
+
+ await useGenerationStore.getState().batchDeleteSelected();
+
+ expect(useGenerationStore.getState().history.map((r) => r.id)).toEqual(["b"]);
+ });
+
+ it("clears selectedHistoryIds after batch delete", async () => {
+ useGenerationStore.setState({
+ history: [record({ id: "a" })],
+ selectedHistoryIds: ["a"],
+ });
+
+ await useGenerationStore.getState().batchDeleteSelected();
+
+ expect(useGenerationStore.getState().selectedHistoryIds).toEqual([]);
+ });
+
+ it("nullifies currentGeneration when it was among the deleted", async () => {
+ const a = record({ id: "a" });
+ const b = record({ id: "b" });
+ useGenerationStore.setState({
+ history: [a, b],
+ currentGeneration: a,
+ selectedHistoryIds: ["a"],
+ });
+
+ await useGenerationStore.getState().batchDeleteSelected();
+
+ expect(useGenerationStore.getState().currentGeneration).toBeNull();
+ });
+
+ it("keeps currentGeneration when it was not among the deleted", async () => {
+ const a = record({ id: "a" });
+ const b = record({ id: "b" });
+ useGenerationStore.setState({
+ history: [a, b],
+ currentGeneration: b,
+ selectedHistoryIds: ["a"],
+ });
+
+ await useGenerationStore.getState().batchDeleteSelected();
+
+ expect(useGenerationStore.getState().currentGeneration?.id).toBe("b");
+ });
+
+ it("exits compare mode when compare target is deleted", async () => {
+ const a = record({ id: "a" });
+ const b = record({ id: "b" });
+ useGenerationStore.setState({
+ history: [a, b],
+ currentGeneration: a,
+ compareModeActive: true,
+ compareGenerationId: "b",
+ selectedHistoryIds: ["b"],
+ });
+
+ await useGenerationStore.getState().batchDeleteSelected();
+
+ const state = useGenerationStore.getState();
+ expect(state.compareModeActive).toBe(false);
+ expect(state.compareGenerationId).toBeNull();
+ });
+
+ it("keeps compare mode when compare target is not deleted", async () => {
+ const a = record({ id: "a" });
+ const b = record({ id: "b" });
+ const c = record({ id: "c" });
+ useGenerationStore.setState({
+ history: [a, b, c],
+ currentGeneration: a,
+ compareModeActive: true,
+ compareGenerationId: "b",
+ selectedHistoryIds: ["c"],
+ });
+
+ await useGenerationStore.getState().batchDeleteSelected();
+
+ const state = useGenerationStore.getState();
+ expect(state.compareModeActive).toBe(true);
+ expect(state.compareGenerationId).toBe("b");
+ });
+ });
+
+ /* --- batchFavoriteSelected -------------------------------------- */
+
+ describe("batchFavoriteSelected", () => {
+ it("is a no-op when selectedHistoryIds is empty", async () => {
+ useGenerationStore.setState({
+ history: [record({ id: "a", isFavorite: false })],
+ selectedHistoryIds: [],
+ favoriteRecordIds: [],
+ });
+
+ await useGenerationStore.getState().batchFavoriteSelected();
+
+ expect(useGenerationStore.getState().history[0].isFavorite).toBe(false);
+ });
+
+ it("clears selectedHistoryIds after batch favorite", async () => {
+ useGenerationStore.setState({
+ history: [record({ id: "a" })],
+ selectedHistoryIds: ["a"],
+ favoriteRecordIds: [],
+ });
+
+ await useGenerationStore.getState().batchFavoriteSelected();
+
+ expect(useGenerationStore.getState().selectedHistoryIds).toEqual([]);
+ });
+
+ it("leaves favoriteRecordIds unchanged in non-Tauri (no Tauri calls)", async () => {
+ useGenerationStore.setState({
+ history: [record({ id: "a" }), record({ id: "b" })],
+ selectedHistoryIds: ["a", "b"],
+ favoriteRecordIds: [],
+ });
+
+ await useGenerationStore.getState().batchFavoriteSelected();
+
+ // In non-Tauri, newFavorites/removedFavorites stay empty, so records
+ // get isFavorite=false (empty includes check) and favoriteRecordIds
+ // gets deduped from existing minus removed plus new.
+ expect(useGenerationStore.getState().selectedHistoryIds).toEqual([]);
+ });
+ });
+});
+
+/* ================================================================== */
+/* 5. Generation Slice — async actions */
+/* ================================================================== */
+
+describe("requestPlaybackToggle", () => {
+ beforeEach(() => {
+ resetStore();
+ });
+
+ it("increments playbackToggleRequest from 0 to 1", () => {
+ useGenerationStore.setState({ playbackToggleRequest: 0 });
+ useGenerationStore.getState().requestPlaybackToggle();
+ expect(useGenerationStore.getState().playbackToggleRequest).toBe(1);
+ });
+
+ it("increments playbackToggleRequest from 5 to 6", () => {
+ useGenerationStore.setState({ playbackToggleRequest: 5 });
+ useGenerationStore.getState().requestPlaybackToggle();
+ expect(useGenerationStore.getState().playbackToggleRequest).toBe(6);
+ });
+
+ it("increments monotonically across repeated calls", () => {
+ useGenerationStore.setState({ playbackToggleRequest: 0 });
+ useGenerationStore.getState().requestPlaybackToggle();
+ useGenerationStore.getState().requestPlaybackToggle();
+ useGenerationStore.getState().requestPlaybackToggle();
+ expect(useGenerationStore.getState().playbackToggleRequest).toBe(3);
+ });
+});
+
+/* ------------------------------------------------------------------ */
+
+describe("cancelGeneration (preview)", () => {
+ beforeEach(() => {
+ resetStore();
+ vi.clearAllMocks();
+ });
+
+ it("sets generationState to cancelled", async () => {
+ useGenerationStore.setState({
+ generationState: {
+ status: "running",
+ phase: "running",
+ statusMessage: "Generating...",
+ error: null,
+ },
+ });
+
+ await useGenerationStore.getState().cancelGeneration();
+
+ const gs = useGenerationStore.getState().generationState;
+ expect(gs.status).toBe("cancelled");
+ expect(gs.phase).toBe("cancelled");
+ expect(gs.error).toBeNull();
+ });
+
+ it("overwrites a failed state with cancelled", async () => {
+ useGenerationStore.setState({
+ generationState: {
+ status: "failed",
+ phase: "failed",
+ statusMessage: "Error",
+ error: { code: "X", message: "err", recoverable: true },
+ },
+ });
+
+ await useGenerationStore.getState().cancelGeneration();
+
+ expect(useGenerationStore.getState().generationState.status).toBe("cancelled");
+ expect(useGenerationStore.getState().generationState.error).toBeNull();
+ });
+});
+
+/* ------------------------------------------------------------------ */
+
+describe("discardActiveTask (preview)", () => {
+ beforeEach(() => {
+ resetStore();
+ vi.clearAllMocks();
+ });
+
+ function task(id: string) {
+ return {
+ id,
+ taskId: `tid-${id}`,
+ request: {} as any,
+ variationIndex: 1,
+ variationTotal: 1,
+ createdAt: "2026-01-01T00:00:00Z",
+ updatedAt: "2026-01-01T00:00:00Z",
+ };
+ }
+
+ it("removes the matching task from activeTasks", async () => {
+ useGenerationStore.setState({ activeTasks: [task("a"), task("b")] as any });
+
+ await useGenerationStore.getState().discardActiveTask("a");
+
+ expect(useGenerationStore.getState().activeTasks.map((t: any) => t.id)).toEqual(["b"]);
+ });
+
+ it("is a no-op when the id is not found", async () => {
+ useGenerationStore.setState({ activeTasks: [task("a")] as any });
+
+ await useGenerationStore.getState().discardActiveTask("missing");
+
+ expect(useGenerationStore.getState().activeTasks).toHaveLength(1);
+ });
+
+ it("clears activeTasks when discarding the last task", async () => {
+ useGenerationStore.setState({ activeTasks: [task("only")] as any });
+
+ await useGenerationStore.getState().discardActiveTask("only");
+
+ expect(useGenerationStore.getState().activeTasks).toEqual([]);
+ });
+});
+
+/* ------------------------------------------------------------------ */
+
+describe("refreshActiveTasks (preview)", () => {
+ beforeEach(() => {
+ resetStore();
+ vi.clearAllMocks();
+ });
+
+ it("is a no-op in preview mode (isTauriRuntime is false)", async () => {
+ useGenerationStore.setState({ activeTasks: [] });
+
+ await useGenerationStore.getState().refreshActiveTasks();
+
+ expect(useGenerationStore.getState().activeTasks).toEqual([]);
+ });
+});
+
+/* ------------------------------------------------------------------ */
+
+describe("resumeActiveTask", () => {
+ beforeEach(() => {
+ resetStore();
+ vi.clearAllMocks();
+ });
+
+ function task(id: string) {
+ return {
+ id,
+ taskId: `tid-${id}`,
+ request: {} as any,
+ variationIndex: 1,
+ variationTotal: 1,
+ createdAt: "2026-01-01T00:00:00Z",
+ updatedAt: "2026-01-01T00:00:00Z",
+ };
+ }
+
+ it("sets phase to recovering before the api call", async () => {
+ let resolveFn: (v: any) => void;
+ const pending = new Promise((resolve) => {
+ resolveFn = resolve;
+ });
+ vi.mocked(api.resumeGenerationTask).mockReturnValue(pending as any);
+
+ useGenerationStore.setState({ activeTasks: [task("t1")] as any });
+
+ const promise = useGenerationStore.getState().resumeActiveTask("t1");
+
+ // Phase should be recovering while the api call is in-flight
+ expect(useGenerationStore.getState().generationState.phase).toBe("recovering");
+ expect(useGenerationStore.getState().generationState.status).toBe("running");
+
+ resolveFn!(record({ id: "resumed" }));
+ await promise;
+ });
+
+ it("sets completed state and currentGeneration on success", async () => {
+ const mockRecord = record({ id: "resumed-1", prompt: "recovered song" });
+ vi.mocked(api.resumeGenerationTask).mockResolvedValue(mockRecord as any);
+
+ useGenerationStore.setState({
+ activeTasks: [task("t1")] as any,
+ history: [],
+ });
+
+ await useGenerationStore.getState().resumeActiveTask("t1");
+
+ const state = useGenerationStore.getState();
+ expect(state.generationState.status).toBe("completed");
+ expect(state.generationState.phase).toBe("completed");
+ expect(state.generationState.error).toBeNull();
+ expect(state.currentGeneration?.id).toBe("resumed-1");
+ });
+
+ it("removes the resumed task from activeTasks", async () => {
+ vi.mocked(api.resumeGenerationTask).mockResolvedValue(record({ id: "r1" }) as any);
+
+ useGenerationStore.setState({
+ activeTasks: [task("t1"), task("t2")] as any,
+ history: [],
+ });
+
+ await useGenerationStore.getState().resumeActiveTask("t1");
+
+ expect(useGenerationStore.getState().activeTasks.map((t: any) => t.id)).toEqual(["t2"]);
+ });
+
+ it("prepends resumed record to history and deduplicates", async () => {
+ const existing = record({ id: "r1", prompt: "old version" });
+ const updated = record({ id: "r1", prompt: "new version" });
+ vi.mocked(api.resumeGenerationTask).mockResolvedValue(updated as any);
+
+ useGenerationStore.setState({
+ activeTasks: [task("t1")] as any,
+ history: [existing],
+ });
+
+ await useGenerationStore.getState().resumeActiveTask("t1");
+
+ const history = useGenerationStore.getState().history;
+ expect(history.filter((r: any) => r.id === "r1")).toHaveLength(1);
+ expect(history[0]?.prompt).toBe("new version");
+ });
+
+ it("sets failed state on error", async () => {
+ vi.mocked(api.resumeGenerationTask).mockRejectedValue(new Error("backend unreachable"));
+
+ useGenerationStore.setState({ activeTasks: [task("t1")] as any });
+
+ await useGenerationStore.getState().resumeActiveTask("t1");
+
+ const gs = useGenerationStore.getState().generationState;
+ expect(gs.status).toBe("failed");
+ expect(gs.phase).toBe("failed");
+ expect(gs.error).toBeDefined();
+ expect(gs.error?.code).toBeDefined();
+ });
+
+ it("sets failed state with localized error on api rejection", async () => {
+ vi.mocked(api.resumeGenerationTask).mockRejectedValue({
+ code: "TASK_NOT_FOUND",
+ message: "Task expired",
+ });
+
+ useGenerationStore.setState({ activeTasks: [task("t1")] as any });
+
+ await useGenerationStore.getState().resumeActiveTask("t1");
+
+ expect(useGenerationStore.getState().generationState.status).toBe("failed");
+ expect(useGenerationStore.getState().generationState.error?.code).toBe("TASK_NOT_FOUND");
+ });
+});
+
+/* ------------------------------------------------------------------ */
+
+describe("enhancePrompt", () => {
+ beforeEach(() => {
+ resetStore();
+ vi.clearAllMocks();
+ });
+
+ function validForm(overrides: Record = {}) {
+ return {
+ ...DEFAULT_GENERATION_FORM_VALUES,
+ prompt: "jazz piano",
+ lyrics: "",
+ ...overrides,
+ };
+ }
+
+ it("sets failed state and throws when validation fails (empty prompt and lyrics)", async () => {
+ useGenerationStore.setState({ form: validForm({ prompt: "", lyrics: "" }) });
+
+ await expect(useGenerationStore.getState().enhancePrompt()).rejects.toThrow();
+
+ const gs = useGenerationStore.getState().generationState;
+ expect(gs.status).toBe("failed");
+ expect(gs.phase).toBe("failed");
+ expect(gs.error?.code).toBe("VALIDATION_FAILED");
+ });
+
+ it("sets validationErrors before throwing on failure", async () => {
+ useGenerationStore.setState({ form: validForm({ prompt: "", lyrics: "" }) });
+
+ await expect(useGenerationStore.getState().enhancePrompt()).rejects.toThrow();
+
+ expect(useGenerationStore.getState().validationErrors.prompt).toBeDefined();
+ expect(useGenerationStore.getState().validationErrors.lyrics).toBeDefined();
+ });
+
+ it("sets currentRequest from the enhanced form after completion", async () => {
+ vi.mocked(api.enhancePrompt).mockResolvedValue({ prompt: "enhanced" });
+
+ useGenerationStore.setState({ form: validForm() });
+
+ await useGenerationStore.getState().enhancePrompt();
+
+ // currentRequest is recomputed from the enhanced form via computeValidationState
+ expect(useGenerationStore.getState().currentRequest).not.toBeNull();
+ expect(useGenerationStore.getState().currentRequest?.prompt).toBe("enhanced");
+ });
+
+ it("calls api.enhancePrompt with the validated request", async () => {
+ vi.mocked(api.enhancePrompt).mockResolvedValue({ prompt: "enhanced" });
+
+ useGenerationStore.setState({ form: validForm() });
+
+ await useGenerationStore.getState().enhancePrompt();
+
+ expect(api.enhancePrompt).toHaveBeenCalledOnce();
+ const calledWith = vi.mocked(api.enhancePrompt).mock.calls[0][0];
+ expect(calledWith.prompt).toBe("jazz piano");
+ });
+
+ it("updates form.prompt with enhanced value", async () => {
+ vi.mocked(api.enhancePrompt).mockResolvedValue({
+ prompt: "beautiful ambient jazz piano with soft brush drums",
+ });
+
+ useGenerationStore.setState({ form: validForm() });
+
+ await useGenerationStore.getState().enhancePrompt();
+
+ expect(useGenerationStore.getState().form.prompt).toBe(
+ "beautiful ambient jazz piano with soft brush drums",
+ );
+ });
+
+ it("falls back to original prompt when enhancement returns empty string", async () => {
+ vi.mocked(api.enhancePrompt).mockResolvedValue({ prompt: "" });
+
+ useGenerationStore.setState({ form: validForm() });
+
+ await useGenerationStore.getState().enhancePrompt();
+
+ expect(useGenerationStore.getState().form.prompt).toBe("jazz piano");
+ });
+
+ it("updates lyrics when enhancement provides them", async () => {
+ vi.mocked(api.enhancePrompt).mockResolvedValue({
+ prompt: "enhanced",
+ lyrics: "verse one\nchorus",
+ });
+
+ useGenerationStore.setState({ form: validForm() });
+
+ await useGenerationStore.getState().enhancePrompt();
+
+ expect(useGenerationStore.getState().form.lyrics).toBe("verse one\nchorus");
+ });
+
+ it("preserves original lyrics when enhancement returns undefined lyrics", async () => {
+ vi.mocked(api.enhancePrompt).mockResolvedValue({ prompt: "enhanced" });
+
+ useGenerationStore.setState({ form: validForm({ lyrics: "my lyrics" }) });
+
+ await useGenerationStore.getState().enhancePrompt();
+
+ expect(useGenerationStore.getState().form.lyrics).toBe("my lyrics");
+ });
+
+ it("sets bpmMode to manual and bpm when enhancement provides bpm", async () => {
+ vi.mocked(api.enhancePrompt).mockResolvedValue({
+ prompt: "enhanced",
+ bpm: 140,
+ });
+
+ useGenerationStore.setState({ form: validForm() });
+
+ await useGenerationStore.getState().enhancePrompt();
+
+ expect(useGenerationStore.getState().form.bpmMode).toBe("manual");
+ expect(useGenerationStore.getState().form.bpm).toBe("140");
+ });
+
+ it("preserves bpmMode and bpm when enhancement returns undefined bpm", async () => {
+ vi.mocked(api.enhancePrompt).mockResolvedValue({ prompt: "enhanced" });
+
+ useGenerationStore.setState({
+ form: validForm({ bpmMode: "manual", bpm: "100" }),
+ });
+
+ await useGenerationStore.getState().enhancePrompt();
+
+ expect(useGenerationStore.getState().form.bpmMode).toBe("manual");
+ expect(useGenerationStore.getState().form.bpm).toBe("100");
+ });
+
+ it("updates keyScale, timeSignature, durationSeconds, vocalLanguage", async () => {
+ vi.mocked(api.enhancePrompt).mockResolvedValue({
+ prompt: "enhanced",
+ keyScale: "D minor",
+ timeSignature: "3",
+ durationSeconds: 90,
+ vocalLanguage: "ja",
+ });
+
+ useGenerationStore.setState({ form: validForm() });
+
+ await useGenerationStore.getState().enhancePrompt();
+
+ const form = useGenerationStore.getState().form;
+ expect(form.keyScale).toBe("D minor");
+ expect(form.timeSignature).toBe("3");
+ expect(form.durationSeconds).toBe("90");
+ expect(form.vocalLanguage).toBe("ja");
+ });
+
+ it("preserves fields that enhancement returns as undefined", async () => {
+ vi.mocked(api.enhancePrompt).mockResolvedValue({ prompt: "enhanced" });
+
+ useGenerationStore.setState({
+ form: validForm({
+ keyScale: "F# major",
+ timeSignature: "6",
+ durationSeconds: "45",
+ vocalLanguage: "de",
+ }),
+ });
+
+ await useGenerationStore.getState().enhancePrompt();
+
+ const form = useGenerationStore.getState().form;
+ expect(form.keyScale).toBe("F# major");
+ expect(form.timeSignature).toBe("6");
+ expect(form.durationSeconds).toBe("45");
+ expect(form.vocalLanguage).toBe("de");
+ });
+
+ it("resets generationState to idle after enhancement", async () => {
+ vi.mocked(api.enhancePrompt).mockResolvedValue({ prompt: "enhanced" });
+
+ useGenerationStore.setState({
+ form: validForm(),
+ generationState: {
+ status: "failed",
+ phase: "failed",
+ statusMessage: "Previous error",
+ error: { code: "X", message: "err", recoverable: true },
+ },
+ });
+
+ await useGenerationStore.getState().enhancePrompt();
+
+ const gs = useGenerationStore.getState().generationState;
+ expect(gs.status).toBe("idle");
+ expect(gs.phase).toBe("idle");
+ expect(gs.error).toBeNull();
+ });
+
+ it("clears validationErrors after successful enhancement", async () => {
+ vi.mocked(api.enhancePrompt).mockResolvedValue({ prompt: "enhanced" });
+
+ useGenerationStore.setState({
+ form: validForm(),
+ validationErrors: { prompt: "old error" },
+ });
+
+ await useGenerationStore.getState().enhancePrompt();
+
+ expect(useGenerationStore.getState().validationErrors).toEqual({});
+ });
+});
+
+/* ------------------------------------------------------------------ */
+
+describe("runGeneration (preview)", () => {
+ beforeEach(() => {
+ resetStore();
+ vi.clearAllMocks();
+ vi.mocked(isModelDownloaded).mockReturnValue(true);
+ });
+
+ function validForm(overrides: Record = {}) {
+ return {
+ ...DEFAULT_GENERATION_FORM_VALUES,
+ prompt: "ambient piano",
+ lyrics: "",
+ ...overrides,
+ };
+ }
+
+ /* --- validation failure ----------------------------------------- */
+
+ it("sets failed state when both prompt and lyrics are empty", async () => {
+ useGenerationStore.setState({ form: validForm({ prompt: "", lyrics: "" }) });
+
+ await useGenerationStore.getState().runGeneration();
+
+ const gs = useGenerationStore.getState().generationState;
+ expect(gs.status).toBe("failed");
+ expect(gs.phase).toBe("failed");
+ expect(gs.error?.code).toBe("VALIDATION_FAILED");
+ });
+
+ it("populates validationErrors on failure", async () => {
+ useGenerationStore.setState({ form: validForm({ prompt: "", lyrics: "" }) });
+
+ await useGenerationStore.getState().runGeneration();
+
+ expect(useGenerationStore.getState().validationErrors.prompt).toBeDefined();
+ expect(useGenerationStore.getState().validationErrors.lyrics).toBeDefined();
+ });
+
+ it("sets currentRequest to null on validation failure", async () => {
+ useGenerationStore.setState({
+ form: validForm({ prompt: "", lyrics: "" }),
+ currentRequest: { prompt: "old" } as any,
+ });
+
+ await useGenerationStore.getState().runGeneration();
+
+ // Validation returns isValid:false so request is null
+ expect(useGenerationStore.getState().currentRequest).toBeNull();
+ });
+
+ /* --- model not downloaded --------------------------------------- */
+
+ it("sets MODEL_REQUIRED error when model is not downloaded", async () => {
+ vi.mocked(isModelDownloaded).mockReturnValue(false);
+ useGenerationStore.setState({
+ form: validForm(),
+ settings: { modelVariant: "turbo", downloadedModels: [] } as any,
+ });
+
+ await useGenerationStore.getState().runGeneration();
+
+ const gs = useGenerationStore.getState().generationState;
+ expect(gs.status).toBe("failed");
+ expect(gs.phase).toBe("failed");
+ expect(gs.error?.code).toBe("MODEL_REQUIRED");
+ });
+
+ /* --- successful preview generation ------------------------------ */
+
+ it("completes successfully in preview mode", async () => {
+ useGenerationStore.setState({
+ form: validForm(),
+ settings: { modelVariant: "turbo" } as any,
+ recentPrompts: [],
+ });
+
+ await useGenerationStore.getState().runGeneration();
+
+ const state = useGenerationStore.getState();
+ expect(state.generationState.status).toBe("completed");
+ expect(state.generationState.phase).toBe("completed");
+ expect(state.generationState.error).toBeNull();
+ });
+
+ it("creates a generation record and adds it to history", async () => {
+ useGenerationStore.setState({
+ form: validForm(),
+ settings: { modelVariant: "turbo" } as any,
+ history: [],
+ recentPrompts: [],
+ });
+
+ await useGenerationStore.getState().runGeneration();
+
+ const state = useGenerationStore.getState();
+ expect(state.history).toHaveLength(1);
+ expect(state.history[0]?.prompt).toBe("ambient piano");
+ expect(state.history[0]?.status).toBe("completed");
+ });
+
+ it("sets currentGeneration to the new record", async () => {
+ useGenerationStore.setState({
+ form: validForm(),
+ settings: { modelVariant: "turbo" } as any,
+ recentPrompts: [],
+ });
+
+ await useGenerationStore.getState().runGeneration();
+
+ expect(useGenerationStore.getState().currentGeneration).not.toBeNull();
+ expect(useGenerationStore.getState().currentGeneration?.prompt).toBe("ambient piano");
+ });
+
+ it("prepends new record to front of existing history", async () => {
+ const existing = record({ id: "old-1" });
+ useGenerationStore.setState({
+ form: validForm(),
+ settings: { modelVariant: "turbo" } as any,
+ history: [existing],
+ recentPrompts: [],
+ });
+
+ await useGenerationStore.getState().runGeneration();
+
+ expect(useGenerationStore.getState().history).toHaveLength(2);
+ expect(useGenerationStore.getState().history[1]?.id).toBe("old-1");
+ });
+
+ /* --- recent prompts --------------------------------------------- */
+
+ it("adds prompt to recentPrompts", async () => {
+ useGenerationStore.setState({
+ form: validForm(),
+ settings: { modelVariant: "turbo" } as any,
+ recentPrompts: [],
+ });
+
+ await useGenerationStore.getState().runGeneration();
+
+ expect(useGenerationStore.getState().recentPrompts).toContain("ambient piano");
+ });
+
+ it("deduplicates prompt in recentPrompts", async () => {
+ useGenerationStore.setState({
+ form: validForm(),
+ settings: { modelVariant: "turbo" } as any,
+ recentPrompts: ["ambient piano", "other"],
+ });
+
+ await useGenerationStore.getState().runGeneration();
+
+ const prompts = useGenerationStore.getState().recentPrompts;
+ expect(prompts.filter((p: string) => p === "ambient piano")).toHaveLength(1);
+ });
+
+ it("moves existing prompt to front of recentPrompts", async () => {
+ useGenerationStore.setState({
+ form: validForm(),
+ settings: { modelVariant: "turbo" } as any,
+ recentPrompts: ["first", "ambient piano", "last"],
+ });
+
+ await useGenerationStore.getState().runGeneration();
+
+ expect(useGenerationStore.getState().recentPrompts[0]).toBe("ambient piano");
+ });
+
+ it("does not add empty prompt to recentPrompts", async () => {
+ useGenerationStore.setState({
+ form: validForm({ prompt: "" }),
+ settings: { modelVariant: "turbo" } as any,
+ recentPrompts: [],
+ });
+
+ // This will fail validation since both prompt and lyrics are empty
+ // (validForm sets lyrics to ""), but let's use lyrics to pass validation
+ useGenerationStore.setState({
+ form: validForm({ prompt: "", lyrics: "some lyrics here" }),
+ });
+
+ await useGenerationStore.getState().runGeneration();
+
+ // Empty prompt means requestPrompt is falsy, so recentPrompts is unchanged
+ expect(useGenerationStore.getState().recentPrompts).toEqual([]);
+ });
+
+ /* --- preview failure (prompt contains "fail") ------------------- */
+
+ it("sets PREVIEW_GENERATION_FAILED when prompt contains 'fail'", async () => {
+ useGenerationStore.setState({
+ form: validForm({ prompt: "this should fail gracefully" }),
+ settings: { modelVariant: "turbo" } as any,
+ });
+
+ await useGenerationStore.getState().runGeneration();
+
+ const gs = useGenerationStore.getState().generationState;
+ expect(gs.status).toBe("failed");
+ expect(gs.phase).toBe("failed");
+ expect(gs.error?.code).toBe("PREVIEW_GENERATION_FAILED");
+ });
+
+ it("does not add to history when preview fails", async () => {
+ useGenerationStore.setState({
+ form: validForm({ prompt: "this will fail" }),
+ settings: { modelVariant: "turbo" } as any,
+ history: [],
+ });
+
+ await useGenerationStore.getState().runGeneration();
+
+ expect(useGenerationStore.getState().history).toEqual([]);
+ });
+
+ /* --- validation with lyrics only -------------------------------- */
+
+ it("passes validation when only lyrics are provided (no prompt)", async () => {
+ useGenerationStore.setState({
+ form: validForm({ prompt: "", lyrics: "just lyrics" }),
+ settings: { modelVariant: "turbo" } as any,
+ recentPrompts: [],
+ });
+
+ await useGenerationStore.getState().runGeneration();
+
+ expect(useGenerationStore.getState().generationState.status).toBe("completed");
+ expect(useGenerationStore.getState().history).toHaveLength(1);
+ });
+
+ /* --- currentRequest is populated -------------------------------- */
+
+ it("sets currentRequest to the validated request on success", async () => {
+ useGenerationStore.setState({
+ form: validForm(),
+ settings: { modelVariant: "turbo" } as any,
+ recentPrompts: [],
+ });
+
+ await useGenerationStore.getState().runGeneration();
+
+ const req = useGenerationStore.getState().currentRequest;
+ expect(req).not.toBeNull();
+ expect(req?.prompt).toBe("ambient piano");
+ });
+});
diff --git a/tests/unit/toast.test.tsx b/tests/unit/toast.test.tsx
new file mode 100644
index 0000000..2c02d41
--- /dev/null
+++ b/tests/unit/toast.test.tsx
@@ -0,0 +1,142 @@
+import { type RefObject } from "react";
+import { describe, expect, it, vi } from "vitest";
+import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
+import { ToastProvider, useToast } from "@/app/components/overlay/Toast";
+
+type ToastApi = ReturnType;
+
+function Harness({ apiRef }: { apiRef: RefObject }) {
+ const api = useToast();
+ apiRef.current = api;
+ return ;
+}
+
+function renderWithProvider() {
+ const apiRef: RefObject = { current: null };
+ const result = render(
+
+
+ ,
+ );
+ return { ...result, api: apiRef as RefObject };
+}
+
+describe("Toast", () => {
+ it("renders the toast message", () => {
+ const { api } = renderWithProvider();
+
+ act(() => {
+ api.current!.addToast("success", "It worked");
+ });
+
+ expect(screen.getByText("It worked")).toBeTruthy();
+ });
+
+ it("renders different toast types", () => {
+ const { api } = renderWithProvider();
+
+ act(() => {
+ api.current!.addToast("success", "It worked");
+ api.current!.addToast("error", "Something broke");
+ api.current!.addToast("info", "Heads up");
+ });
+
+ expect(screen.getByText("It worked")).toBeTruthy();
+ expect(screen.getByText("Something broke")).toBeTruthy();
+ expect(screen.getByText("Heads up")).toBeTruthy();
+ });
+
+ it("dismisses when the close button is clicked", () => {
+ const { api } = renderWithProvider();
+
+ act(() => {
+ api.current!.addToast("success", "It worked");
+ });
+
+ const message = screen.getByText("It worked");
+ const toastDiv = message.closest("div")!;
+ const dismissButton = toastDiv.querySelector("button:last-child")!;
+
+ act(() => {
+ fireEvent.click(dismissButton);
+ });
+
+ expect(screen.queryByText("It worked")).toBeNull();
+ });
+
+ it("auto-closes after the default duration (3000ms)", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ const { api } = renderWithProvider();
+
+ act(() => {
+ api.current!.addToast("success", "It worked");
+ });
+ expect(screen.getByText("It worked")).toBeTruthy();
+
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByText("It worked")).toBeNull();
+ });
+
+ vi.useRealTimers();
+ });
+
+ it("auto-closes after a custom duration", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ const { api } = renderWithProvider();
+
+ act(() => {
+ api.current!.addToast("info", "Custom duration", { duration: 5000 });
+ });
+ expect(screen.getByText("Custom duration")).toBeTruthy();
+
+ act(() => {
+ vi.advanceTimersByTime(4999);
+ });
+ expect(screen.getByText("Custom duration")).toBeTruthy();
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByText("Custom duration")).toBeNull();
+ });
+
+ vi.useRealTimers();
+ });
+
+ it("renders an action button and calls its onClick", () => {
+ const onClick = vi.fn();
+ const { api } = renderWithProvider();
+
+ act(() => {
+ api.current!.addToast("success", "Saved", { action: { label: "Undo", onClick } });
+ });
+
+ const undoButton = screen.getByText("Undo");
+ act(() => {
+ fireEvent.click(undoButton);
+ });
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ expect(screen.queryByText("Saved")).toBeNull();
+ });
+
+ it("cleans up timers on unmount", () => {
+ const { api, unmount } = renderWithProvider();
+
+ act(() => {
+ api.current!.addToast("success", "Gone soon");
+ });
+
+ const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
+ unmount();
+
+ expect(clearTimeoutSpy).toHaveBeenCalled();
+ clearTimeoutSpy.mockRestore();
+ });
+});
diff --git a/tests/unit/window-shell.test.ts b/tests/unit/window-shell.test.ts
new file mode 100644
index 0000000..e0aa31b
--- /dev/null
+++ b/tests/unit/window-shell.test.ts
@@ -0,0 +1,322 @@
+import { describe, expect, it, vi, beforeEach, type Mock } from "vitest";
+import { renderHook, waitFor } from "@testing-library/react";
+
+vi.mock("@/app/lib/app-shortcuts", () => ({
+ getShortcutPlatform: vi.fn(() => "mac"),
+}));
+
+vi.mock("@/app/lib/api", () => ({
+ getWindowShellState: vi.fn(),
+}));
+
+const { getShortcutPlatform } = await import("@/app/lib/app-shortcuts");
+const { getWindowShellState } = await import("@/app/lib/api");
+const {
+ getDefaultWindowShellState,
+ resolveWindowShellState,
+ createWindowShellStyle,
+ useWindowShellState,
+} = await import("@/app/lib/window-shell");
+
+function mockPlatform(platform: "mac" | "windows" | "linux") {
+ (getShortcutPlatform as Mock).mockReturnValue(platform);
+}
+
+function makeSnapshot(
+ overrides?: Partial<{
+ chrome_variant: "desktop" | "mac";
+ tier: "desktop" | "mac";
+ toolbar_height: number;
+ traffic_light_inset_leading: number;
+ sidebar_header_height: number;
+ sidebar_width: number;
+ }>,
+) {
+ return {
+ chrome_variant: "mac" as const,
+ tier: "mac" as const,
+ toolbar_height: 48,
+ traffic_light_inset_leading: 78,
+ sidebar_header_height: 28,
+ sidebar_width: 260,
+ ...overrides,
+ };
+}
+
+describe("getDefaultWindowShellState", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns mac state when platform is mac", () => {
+ const state = getDefaultWindowShellState("mac");
+ expect(state).toEqual({
+ chromeVariant: "mac",
+ tier: "mac",
+ toolbarHeight: 48,
+ trafficLightInsetLeading: 78,
+ sidebarHeaderHeight: 28,
+ sidebarWidth: 260,
+ });
+ });
+
+ it("returns desktop state when platform is windows", () => {
+ const state = getDefaultWindowShellState("windows");
+ expect(state).toEqual({
+ chromeVariant: "desktop",
+ tier: "desktop",
+ toolbarHeight: 48,
+ trafficLightInsetLeading: 0,
+ sidebarHeaderHeight: 0,
+ sidebarWidth: 260,
+ });
+ });
+
+ it("returns desktop state when platform is linux", () => {
+ const state = getDefaultWindowShellState("linux");
+ expect(state.chromeVariant).toBe("desktop");
+ expect(state.trafficLightInsetLeading).toBe(0);
+ });
+
+ it("uses getShortcutPlatform when called without argument", () => {
+ mockPlatform("mac");
+ const state = getDefaultWindowShellState();
+ expect(state.chromeVariant).toBe("mac");
+ });
+
+ it("returns a copy, not the module-level constant", () => {
+ const a = getDefaultWindowShellState("mac");
+ const b = getDefaultWindowShellState("mac");
+ expect(a).toEqual(b);
+ expect(a).not.toBe(b);
+ });
+});
+
+describe("resolveWindowShellState", () => {
+ it("returns desktop defaults for non-mac platform regardless of input", () => {
+ const state = resolveWindowShellState("windows", {
+ chromeVariant: "mac",
+ toolbarHeight: 100,
+ trafficLightInsetLeading: 50,
+ });
+ expect(state).toEqual({
+ chromeVariant: "desktop",
+ tier: "desktop",
+ toolbarHeight: 48,
+ trafficLightInsetLeading: 0,
+ sidebarHeaderHeight: 0,
+ sidebarWidth: 260,
+ });
+ });
+
+ it("returns desktop defaults for linux", () => {
+ const state = resolveWindowShellState("linux", { sidebarWidth: 400 });
+ expect(state.sidebarWidth).toBe(260);
+ expect(state.trafficLightInsetLeading).toBe(0);
+ });
+
+ it("returns mac defaults when state is undefined", () => {
+ const state = resolveWindowShellState("mac", undefined);
+ expect(state).toEqual({
+ chromeVariant: "mac",
+ tier: "mac",
+ toolbarHeight: 48,
+ trafficLightInsetLeading: 78,
+ sidebarHeaderHeight: 28,
+ sidebarWidth: 260,
+ });
+ });
+
+ it("returns mac defaults when state is null", () => {
+ const state = resolveWindowShellState("mac", null);
+ expect(state.chromeVariant).toBe("mac");
+ expect(state.trafficLightInsetLeading).toBe(78);
+ });
+
+ it("uses provided values when they are valid positive numbers", () => {
+ const state = resolveWindowShellState("mac", {
+ toolbarHeight: 64,
+ trafficLightInsetLeading: 90,
+ sidebarHeaderHeight: 36,
+ sidebarWidth: 320,
+ });
+ expect(state.toolbarHeight).toBe(64);
+ expect(state.trafficLightInsetLeading).toBe(90);
+ expect(state.sidebarHeaderHeight).toBe(36);
+ expect(state.sidebarWidth).toBe(320);
+ });
+
+ it("falls back to default when toolbarHeight is zero", () => {
+ const state = resolveWindowShellState("mac", { toolbarHeight: 0 });
+ expect(state.toolbarHeight).toBe(48);
+ });
+
+ it("falls back to default when toolbarHeight is negative", () => {
+ const state = resolveWindowShellState("mac", { toolbarHeight: -10 });
+ expect(state.toolbarHeight).toBe(48);
+ });
+
+ it("falls back to default when toolbarHeight is NaN", () => {
+ const state = resolveWindowShellState("mac", { toolbarHeight: Number.NaN });
+ expect(state.toolbarHeight).toBe(48);
+ });
+
+ it("falls back to default when toolbarHeight is Infinity", () => {
+ const state = resolveWindowShellState("mac", { toolbarHeight: Number.POSITIVE_INFINITY });
+ expect(state.toolbarHeight).toBe(48);
+ });
+
+ it("falls back when value is a string (not a number)", () => {
+ const state = resolveWindowShellState("mac", {
+ toolbarHeight: "48" as unknown as number,
+ });
+ expect(state.toolbarHeight).toBe(48);
+ });
+
+ it("accepts chromeVariant 'mac' for mac platform", () => {
+ const state = resolveWindowShellState("mac", { chromeVariant: "mac" });
+ expect(state.chromeVariant).toBe("mac");
+ });
+
+ it("falls back chromeVariant when not 'mac'", () => {
+ const state = resolveWindowShellState("mac", { chromeVariant: "desktop" });
+ expect(state.chromeVariant).toBe("mac");
+ });
+
+ it("falls back chromeVariant when undefined", () => {
+ const state = resolveWindowShellState("mac", {});
+ expect(state.chromeVariant).toBe("mac");
+ });
+
+ it("always sets tier to 'mac' on mac platform", () => {
+ const state = resolveWindowShellState("mac", { tier: "desktop" } as any);
+ expect(state.tier).toBe("mac");
+ });
+});
+
+describe("createWindowShellStyle", () => {
+ it("maps all state fields to CSS custom properties", () => {
+ const style = createWindowShellStyle({
+ chromeVariant: "mac",
+ tier: "mac",
+ toolbarHeight: 48,
+ trafficLightInsetLeading: 78,
+ sidebarHeaderHeight: 28,
+ sidebarWidth: 260,
+ });
+ expect(style).toEqual({
+ "--window-shell-leading-controls-space": "78px",
+ "--window-shell-sidebar-header-height": "28px",
+ "--window-shell-sidebar-width": "260px",
+ "--window-shell-toolbar-height": "48px",
+ });
+ });
+
+ it("reflects custom numeric values", () => {
+ const style = createWindowShellStyle({
+ chromeVariant: "desktop",
+ tier: "desktop",
+ toolbarHeight: 64,
+ trafficLightInsetLeading: 0,
+ sidebarHeaderHeight: 0,
+ sidebarWidth: 320,
+ });
+ expect((style as any)["--window-shell-toolbar-height"]).toBe("64px");
+ expect((style as any)["--window-shell-sidebar-width"]).toBe("320px");
+ });
+});
+
+describe("useWindowShellState", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns desktop-resolved state immediately on non-mac platform", () => {
+ mockPlatform("windows");
+ const { result } = renderHook(() => useWindowShellState(300));
+ expect(result.current.chromeVariant).toBe("desktop");
+ expect(result.current.sidebarWidth).toBe(260);
+ expect(result.current.trafficLightInsetLeading).toBe(0);
+ });
+
+ it("returns mac-resolved state immediately on mac before snapshot resolves", () => {
+ mockPlatform("mac");
+ (getWindowShellState as Mock).mockReturnValue(new Promise(() => {})); // never resolves
+ const { result } = renderHook(() => useWindowShellState(300));
+ expect(result.current.chromeVariant).toBe("mac");
+ expect(result.current.sidebarWidth).toBe(300);
+ expect(result.current.trafficLightInsetLeading).toBe(78);
+ });
+
+ it("hydrates from native snapshot on mac", async () => {
+ mockPlatform("mac");
+ (getWindowShellState as Mock).mockResolvedValue(
+ makeSnapshot({
+ toolbar_height: 56,
+ traffic_light_inset_leading: 88,
+ sidebar_header_height: 32,
+ sidebar_width: 280,
+ }),
+ );
+
+ const { result } = renderHook(() => useWindowShellState(300));
+
+ await waitFor(() => {
+ expect(result.current.toolbarHeight).toBe(56);
+ });
+
+ expect(result.current.trafficLightInsetLeading).toBe(88);
+ expect(result.current.sidebarHeaderHeight).toBe(32);
+ expect(result.current.sidebarWidth).toBe(300); // sidebarWidth comes from hook arg
+ });
+
+ it("falls back to defaults when snapshot fetch rejects", async () => {
+ mockPlatform("mac");
+ (getWindowShellState as Mock).mockRejectedValue(new Error("ipc failed"));
+
+ const { result } = renderHook(() => useWindowShellState(300));
+
+ await waitFor(() => {
+ expect(getWindowShellState).toHaveBeenCalled();
+ });
+
+ // Should still have the initial resolved state (not crash)
+ expect(result.current.chromeVariant).toBe("mac");
+ expect(result.current.toolbarHeight).toBe(48);
+ });
+
+ it("does not call getWindowShellState on non-mac platform", () => {
+ mockPlatform("windows");
+ renderHook(() => useWindowShellState(300));
+ expect(getWindowShellState).not.toHaveBeenCalled();
+ });
+
+ it("uses sidebarWidth argument to override snapshot sidebar_width", async () => {
+ mockPlatform("mac");
+ (getWindowShellState as Mock).mockResolvedValue(makeSnapshot({ sidebar_width: 999 }));
+
+ const { result } = renderHook(() => useWindowShellState(400));
+
+ await waitFor(() => {
+ expect(result.current.sidebarWidth).toBe(400);
+ });
+ });
+
+ it("ignores snapshot result after unmount", async () => {
+ mockPlatform("mac");
+ let resolveSnapshot!: (v: any) => void;
+ (getWindowShellState as Mock).mockReturnValue(
+ new Promise((resolve) => {
+ resolveSnapshot = resolve;
+ }),
+ );
+
+ const { unmount } = renderHook(() => useWindowShellState(300));
+ unmount();
+
+ // Resolving after unmount should not cause a state update warning
+ resolveSnapshot(makeSnapshot({ toolbar_height: 999 }));
+ // If this doesn't throw, the cleanup worked
+ expect(true).toBe(true);
+ });
+});