diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd3a3a5..f0d71aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,7 @@ jobs: - name: Upload test results to Codecov if: ${{ !cancelled() && steps.install.outcome == 'success' }} - uses: codecov/test-results-action@v5 + uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: false diff --git a/tests/unit/api.test.ts b/tests/unit/api.test.ts new file mode 100644 index 0000000..7ecf4fb --- /dev/null +++ b/tests/unit/api.test.ts @@ -0,0 +1,779 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockInvoke = vi.fn(); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => mockInvoke(...args), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: vi.fn(), +})); + +const api = await import("@/app/lib/api"); + +beforeEach(() => { + mockInvoke.mockReset(); +}); + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- + +describe("getSettings", () => { + it("calls 'get_settings' with no args", async () => { + const settings = { outputDirectory: "/tmp" }; + mockInvoke.mockResolvedValue(settings); + + const result = await api.getSettings(); + + expect(mockInvoke).toHaveBeenCalledWith("get_settings"); + expect(result).toBe(settings); + }); +}); + +describe("setSetting", () => { + it("calls 'set_setting' with key and value", async () => { + const updated = { outputDirectory: "/new" }; + mockInvoke.mockResolvedValue(updated); + + const result = await api.setSetting("outputDirectory", "/new"); + + expect(mockInvoke).toHaveBeenCalledWith("set_setting", { + key: "outputDirectory", + value: "/new", + }); + expect(result).toBe(updated); + }); +}); + +describe("resetRuntimeSettings", () => { + it("calls 'reset_runtime_settings' with no args", async () => { + const settings = { outputDirectory: "/default" }; + mockInvoke.mockResolvedValue(settings); + + const result = await api.resetRuntimeSettings(); + + expect(mockInvoke).toHaveBeenCalledWith("reset_runtime_settings"); + expect(result).toBe(settings); + }); +}); + +// --------------------------------------------------------------------------- +// Device & Window +// --------------------------------------------------------------------------- + +describe("getDeviceInfo", () => { + it("calls 'get_device_info' with no args", async () => { + const info = { os: "macOS", arch: "aarch64" }; + mockInvoke.mockResolvedValue(info); + + const result = await api.getDeviceInfo(); + + expect(mockInvoke).toHaveBeenCalledWith("get_device_info"); + expect(result).toBe(info); + }); +}); + +describe("getWindowShellState", () => { + it("calls 'get_window_shell_state' with no args", async () => { + const snapshot = { maximized: false }; + mockInvoke.mockResolvedValue(snapshot); + + const result = await api.getWindowShellState(); + + expect(mockInvoke).toHaveBeenCalledWith("get_window_shell_state"); + expect(result).toBe(snapshot); + }); +}); + +// --------------------------------------------------------------------------- +// Paths & CLI +// --------------------------------------------------------------------------- + +describe("getDefaultAppPaths", () => { + it("calls 'get_default_app_paths' with no args", async () => { + const paths = { + outputDirectory: "/out", + modelDirectory: "/models", + logDirectory: "/logs", + }; + mockInvoke.mockResolvedValue(paths); + + const result = await api.getDefaultAppPaths(); + + expect(mockInvoke).toHaveBeenCalledWith("get_default_app_paths"); + expect(result).toBe(paths); + }); +}); + +describe("addCliToPath", () => { + it("calls 'add_cli_to_path' with no args", async () => { + mockInvoke.mockResolvedValue("added"); + + const result = await api.addCliToPath(); + + expect(mockInvoke).toHaveBeenCalledWith("add_cli_to_path"); + expect(result).toBe("added"); + }); +}); + +describe("removeCliFromPath", () => { + it("calls 'remove_cli_from_path' with no args", async () => { + mockInvoke.mockResolvedValue("removed"); + + const result = await api.removeCliFromPath(); + + expect(mockInvoke).toHaveBeenCalledWith("remove_cli_from_path"); + expect(result).toBe("removed"); + }); +}); + +describe("isCliInPath", () => { + it("calls 'is_cli_in_path' with no args", async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.isCliInPath(); + + expect(mockInvoke).toHaveBeenCalledWith("is_cli_in_path"); + expect(result).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Generations CRUD +// --------------------------------------------------------------------------- + +describe("listGenerations", () => { + it("calls 'list_generations' with query when provided", async () => { + const records = [{ id: "1" }]; + mockInvoke.mockResolvedValue(records); + + const result = await api.listGenerations("hello"); + + expect(mockInvoke).toHaveBeenCalledWith("list_generations", { + query: "hello", + }); + expect(result).toBe(records); + }); + + it("sends null query when trimmed string is empty", async () => { + mockInvoke.mockResolvedValue([]); + + await api.listGenerations(" "); + + expect(mockInvoke).toHaveBeenCalledWith("list_generations", { + query: null, + }); + }); + + it("sends null query when undefined", async () => { + mockInvoke.mockResolvedValue([]); + + await api.listGenerations(); + + expect(mockInvoke).toHaveBeenCalledWith("list_generations", { + query: null, + }); + }); +}); + +describe("getGeneration", () => { + it("calls 'get_generation' with id", async () => { + const record = { id: "abc" }; + mockInvoke.mockResolvedValue(record); + + const result = await api.getGeneration("abc"); + + expect(mockInvoke).toHaveBeenCalledWith("get_generation", { id: "abc" }); + expect(result).toBe(record); + }); + + it("returns null when generation not found", async () => { + mockInvoke.mockResolvedValue(null); + + const result = await api.getGeneration("missing"); + + expect(result).toBeNull(); + }); +}); + +describe("insertGeneration", () => { + it("calls 'insert_generation' with the record", async () => { + const record = { id: "new" } as any; + mockInvoke.mockResolvedValue(record); + + const result = await api.insertGeneration(record); + + expect(mockInvoke).toHaveBeenCalledWith("insert_generation", { record }); + expect(result).toBe(record); + }); +}); + +describe("deleteGeneration", () => { + it("calls 'delete_generation' with id", async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.deleteGeneration("abc"); + + expect(mockInvoke).toHaveBeenCalledWith("delete_generation", { id: "abc" }); + }); +}); + +describe("clearGenerationHistory", () => { + it("calls 'clear_generation_history' with no args", async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.clearGenerationHistory(); + + expect(mockInvoke).toHaveBeenCalledWith("clear_generation_history"); + }); +}); + +describe("toggleGenerationFavorite", () => { + it("calls 'toggle_generation_favorite' with id", async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.toggleGenerationFavorite("abc"); + + expect(mockInvoke).toHaveBeenCalledWith("toggle_generation_favorite", { + id: "abc", + }); + expect(result).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Generation audio & waveform +// --------------------------------------------------------------------------- + +describe("readGenerationAudio", () => { + it("calls 'read_generation_audio' with id", async () => { + const buf = new ArrayBuffer(8); + mockInvoke.mockResolvedValue(buf); + + const result = await api.readGenerationAudio("abc"); + + expect(mockInvoke).toHaveBeenCalledWith("read_generation_audio", { + id: "abc", + }); + expect(result).toBe(buf); + }); +}); + +describe("readGenerationWaveform", () => { + it("calls 'read_generation_waveform' with id", async () => { + const waveform = { peaks: [0.1, 0.2] } as any; + mockInvoke.mockResolvedValue(waveform); + + const result = await api.readGenerationWaveform("abc"); + + expect(mockInvoke).toHaveBeenCalledWith("read_generation_waveform", { + id: "abc", + }); + expect(result).toBe(waveform); + }); +}); + +// --------------------------------------------------------------------------- +// Generation execution +// --------------------------------------------------------------------------- + +describe("generateMusic", () => { + it("calls 'generate_music' with request", async () => { + const request = { prompt: "jazz" } as any; + const result_ = { runId: "r1" } as any; + mockInvoke.mockResolvedValue(result_); + + const result = await api.generateMusic(request); + + expect(mockInvoke).toHaveBeenCalledWith("generate_music", { request }); + expect(result).toBe(result_); + }); +}); + +describe("cancelGeneration", () => { + it("calls 'cancel_generation' with no args", async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.cancelGeneration(); + + expect(mockInvoke).toHaveBeenCalledWith("cancel_generation"); + }); +}); + +describe("enhancePrompt", () => { + it("calls 'enhance_prompt' with request", async () => { + const request = { prompt: "test" } as any; + const enhanced = { enhancedPrompt: "better test" } as any; + mockInvoke.mockResolvedValue(enhanced); + + const result = await api.enhancePrompt(request); + + expect(mockInvoke).toHaveBeenCalledWith("enhance_prompt", { request }); + expect(result).toBe(enhanced); + }); +}); + +// --------------------------------------------------------------------------- +// Active generation tasks +// --------------------------------------------------------------------------- + +describe("listActiveGenerationTasks", () => { + it("calls 'list_active_generation_tasks' with no args", async () => { + const tasks = [{ id: "t1" }] as any; + mockInvoke.mockResolvedValue(tasks); + + const result = await api.listActiveGenerationTasks(); + + expect(mockInvoke).toHaveBeenCalledWith("list_active_generation_tasks"); + expect(result).toBe(tasks); + }); +}); + +describe("resumeGenerationTask", () => { + it("calls 'resume_generation_task' with id", async () => { + const record = { id: "t1" } as any; + mockInvoke.mockResolvedValue(record); + + const result = await api.resumeGenerationTask("t1"); + + expect(mockInvoke).toHaveBeenCalledWith("resume_generation_task", { + id: "t1", + }); + expect(result).toBe(record); + }); +}); + +describe("discardActiveGenerationTask", () => { + it("calls 'discard_active_generation_task' with id", async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.discardActiveGenerationTask("t1"); + + expect(mockInvoke).toHaveBeenCalledWith("discard_active_generation_task", { + id: "t1", + }); + }); +}); + +// --------------------------------------------------------------------------- +// Backend lifecycle +// --------------------------------------------------------------------------- + +describe("backendStatus", () => { + it("calls 'backend_status' with no args", async () => { + const status = { running: true }; + mockInvoke.mockResolvedValue(status); + + const result = await api.backendStatus(); + + expect(mockInvoke).toHaveBeenCalledWith("backend_status"); + expect(result).toBe(status); + }); +}); + +describe("startBackend", () => { + it("calls 'start_backend' with no args", async () => { + const status = { running: true }; + mockInvoke.mockResolvedValue(status); + + const result = await api.startBackend(); + + expect(mockInvoke).toHaveBeenCalledWith("start_backend"); + expect(result).toBe(status); + }); +}); + +describe("stopBackend", () => { + it("calls 'stop_backend' with no args", async () => { + const status = { running: false }; + mockInvoke.mockResolvedValue(status); + + const result = await api.stopBackend(); + + expect(mockInvoke).toHaveBeenCalledWith("stop_backend"); + expect(result).toBe(status); + }); +}); + +describe("restartBackend", () => { + it("calls 'restart_backend' with no args", async () => { + const status = { running: true }; + mockInvoke.mockResolvedValue(status); + + const result = await api.restartBackend(); + + expect(mockInvoke).toHaveBeenCalledWith("restart_backend"); + expect(result).toBe(status); + }); +}); + +describe("getBackendLogsPath", () => { + it("calls 'get_backend_logs_path' with no args", async () => { + mockInvoke.mockResolvedValue("/logs/backend.log"); + + const result = await api.getBackendLogsPath(); + + expect(mockInvoke).toHaveBeenCalledWith("get_backend_logs_path"); + expect(result).toBe("/logs/backend.log"); + }); + + it("passes through null", async () => { + mockInvoke.mockResolvedValue(null); + + const result = await api.getBackendLogsPath(); + + expect(result).toBeNull(); + }); +}); + +describe("clearBackendCache", () => { + it("calls 'clear_backend_cache' with no args", async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.clearBackendCache(); + + expect(mockInvoke).toHaveBeenCalledWith("clear_backend_cache"); + }); +}); + +// --------------------------------------------------------------------------- +// Backend provisioning +// --------------------------------------------------------------------------- + +describe("getBackendProvisionStatus", () => { + it("calls 'get_backend_provision_status' with no args", async () => { + const status = { state: "ready" } as any; + mockInvoke.mockResolvedValue(status); + + const result = await api.getBackendProvisionStatus(); + + expect(mockInvoke).toHaveBeenCalledWith("get_backend_provision_status"); + expect(result).toBe(status); + }); +}); + +describe("provisionBackend", () => { + it("calls 'provision_backend' with no args", async () => { + const status = { state: "provisioning" } as any; + mockInvoke.mockResolvedValue(status); + + const result = await api.provisionBackend(); + + expect(mockInvoke).toHaveBeenCalledWith("provision_backend"); + expect(result).toBe(status); + }); +}); + +describe("checkBackendUpdates", () => { + it("calls 'check_backend_updates' with no args", async () => { + const status = { state: "up-to-date" } as any; + mockInvoke.mockResolvedValue(status); + + const result = await api.checkBackendUpdates(); + + expect(mockInvoke).toHaveBeenCalledWith("check_backend_updates"); + expect(result).toBe(status); + }); +}); + +describe("updateBackend", () => { + it("calls 'update_backend' with no args", async () => { + const status = { state: "updating" } as any; + mockInvoke.mockResolvedValue(status); + + const result = await api.updateBackend(); + + expect(mockInvoke).toHaveBeenCalledWith("update_backend"); + expect(result).toBe(status); + }); +}); + +// --------------------------------------------------------------------------- +// Models +// --------------------------------------------------------------------------- + +describe("listModelCatalog", () => { + it("calls 'list_model_catalog' with no args", async () => { + const catalog = [{ id: "turbo" }] as any; + mockInvoke.mockResolvedValue(catalog); + + const result = await api.listModelCatalog(); + + expect(mockInvoke).toHaveBeenCalledWith("list_model_catalog"); + expect(result).toBe(catalog); + }); +}); + +describe("getModelStatus", () => { + it("calls 'get_model_status' with no args", async () => { + const statuses = [{ variant: "turbo", state: "ready" }] as any; + mockInvoke.mockResolvedValue(statuses); + + const result = await api.getModelStatus(); + + expect(mockInvoke).toHaveBeenCalledWith("get_model_status"); + expect(result).toBe(statuses); + }); +}); + +describe("downloadModel", () => { + it("calls 'download_model' with variant", async () => { + const snapshot = { variant: "turbo", state: "downloading" } as any; + mockInvoke.mockResolvedValue(snapshot); + + const result = await api.downloadModel("turbo"); + + expect(mockInvoke).toHaveBeenCalledWith("download_model", { + variant: "turbo", + }); + expect(result).toBe(snapshot); + }); +}); + +describe("deleteModel", () => { + it("calls 'delete_model' with variant", async () => { + const snapshot = { variant: "turbo", state: "not-downloaded" } as any; + mockInvoke.mockResolvedValue(snapshot); + + const result = await api.deleteModel("turbo"); + + expect(mockInvoke).toHaveBeenCalledWith("delete_model", { + variant: "turbo", + }); + expect(result).toBe(snapshot); + }); +}); + +describe("clearPartialDownloads", () => { + it("calls 'clear_partial_downloads' with variant", async () => { + const snapshot = { variant: "turbo", state: "not-downloaded" } as any; + mockInvoke.mockResolvedValue(snapshot); + + const result = await api.clearPartialDownloads("turbo"); + + expect(mockInvoke).toHaveBeenCalledWith("clear_partial_downloads", { + variant: "turbo", + }); + expect(result).toBe(snapshot); + }); +}); + +describe("cancelDownload", () => { + it("calls 'cancel_download' with variant", async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.cancelDownload("turbo"); + + expect(mockInvoke).toHaveBeenCalledWith("cancel_download", { + variant: "turbo", + }); + }); +}); + +describe("deleteAllModels", () => { + it("calls 'delete_all_models' with no args", async () => { + const statuses = [] as any; + mockInvoke.mockResolvedValue(statuses); + + const result = await api.deleteAllModels(); + + expect(mockInvoke).toHaveBeenCalledWith("delete_all_models"); + expect(result).toBe(statuses); + }); +}); + +// --------------------------------------------------------------------------- +// File operations +// --------------------------------------------------------------------------- + +describe("revealInFinder", () => { + it("calls 'reveal_in_finder' with path", async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.revealInFinder("/some/path"); + + expect(mockInvoke).toHaveBeenCalledWith("reveal_in_finder", { + path: "/some/path", + }); + }); +}); + +describe("copyAudioTo", () => { + it("calls 'copy_audio_to' with path and destination", async () => { + mockInvoke.mockResolvedValue("/dest/file.wav"); + + const result = await api.copyAudioTo("/src/file.wav", "/dest"); + + expect(mockInvoke).toHaveBeenCalledWith("copy_audio_to", { + path: "/src/file.wav", + destination: "/dest", + }); + expect(result).toBe("/dest/file.wav"); + }); +}); + +describe("fileExists", () => { + it("calls 'file_exists' with path", async () => { + mockInvoke.mockResolvedValue(true); + + const result = await api.fileExists("/some/file"); + + expect(mockInvoke).toHaveBeenCalledWith("file_exists", { + path: "/some/file", + }); + expect(result).toBe(true); + }); +}); + +describe("deleteGenerationFile", () => { + it("calls 'delete_generation_file' with path", async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.deleteGenerationFile("/audio.wav"); + + expect(mockInvoke).toHaveBeenCalledWith("delete_generation_file", { + path: "/audio.wav", + }); + }); +}); + +describe("deleteGenerationFileAndRecord", () => { + it("calls 'delete_generation_file_and_record' with id", async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.deleteGenerationFileAndRecord("abc"); + + expect(mockInvoke).toHaveBeenCalledWith("delete_generation_file_and_record", { id: "abc" }); + }); +}); + +// --------------------------------------------------------------------------- +// Failed runs +// --------------------------------------------------------------------------- + +describe("listFailedRuns", () => { + it("calls 'list_failed_runs' with limit", async () => { + const runs = [{ id: "f1" }] as any; + mockInvoke.mockResolvedValue(runs); + + const result = await api.listFailedRuns(10); + + expect(mockInvoke).toHaveBeenCalledWith("list_failed_runs", { limit: 10 }); + expect(result).toBe(runs); + }); +}); + +describe("clearFailedRuns", () => { + it("calls 'clear_failed_runs' with no args", async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.clearFailedRuns(); + + expect(mockInvoke).toHaveBeenCalledWith("clear_failed_runs"); + }); +}); + +describe("deleteFailedRun", () => { + it("calls 'delete_failed_run' with id", async () => { + mockInvoke.mockResolvedValue(undefined); + + await api.deleteFailedRun("f1"); + + expect(mockInvoke).toHaveBeenCalledWith("delete_failed_run", { id: "f1" }); + }); +}); + +// --------------------------------------------------------------------------- +// Export & drag +// --------------------------------------------------------------------------- + +describe("exportGenerationsToFolder", () => { + it("calls 'export_generations_to_folder' with ids and destination", async () => { + const exported = ["/dest/a.wav", "/dest/b.wav"]; + mockInvoke.mockResolvedValue(exported); + + const result = await api.exportGenerationsToFolder(["a", "b"], "/dest"); + + expect(mockInvoke).toHaveBeenCalledWith("export_generations_to_folder", { + ids: ["a", "b"], + destination: "/dest", + }); + expect(result).toBe(exported); + }); +}); + +describe("prepareDragPayload", () => { + it("calls 'prepare_drag_payload' with id", async () => { + mockInvoke.mockResolvedValue("/tmp/payload.wav"); + + const result = await api.prepareDragPayload("abc"); + + expect(mockInvoke).toHaveBeenCalledWith("prepare_drag_payload", { + id: "abc", + }); + expect(result).toBe("/tmp/payload.wav"); + }); +}); + +// --------------------------------------------------------------------------- +// Error propagation +// --------------------------------------------------------------------------- + +describe("error propagation", () => { + it("propagates invoke rejection for no-arg commands", async () => { + const error = new Error("backend crashed"); + mockInvoke.mockRejectedValue(error); + + await expect(api.backendStatus()).rejects.toThrow("backend crashed"); + }); + + it("propagates invoke rejection for commands with args", async () => { + const error = new Error("not found"); + mockInvoke.mockRejectedValue(error); + + await expect(api.getGeneration("abc")).rejects.toThrow("not found"); + }); + + it("propagates invoke rejection for void commands", async () => { + const error = new Error("permission denied"); + mockInvoke.mockRejectedValue(error); + + await expect(api.deleteGeneration("abc")).rejects.toThrow("permission denied"); + }); +}); + +// --------------------------------------------------------------------------- +// isTauriRuntime +// --------------------------------------------------------------------------- + +describe("isTauriRuntime", () => { + it("returns false when __TAURI_INTERNALS__ is absent", () => { + const original = (window as any).__TAURI_INTERNALS__; + delete (window as any).__TAURI_INTERNALS__; + + expect(api.isTauriRuntime()).toBe(false); + + if (original !== undefined) { + (window as any).__TAURI_INTERNALS__ = original; + } + }); + + it("returns true when __TAURI_INTERNALS__ is present", () => { + const original = (window as any).__TAURI_INTERNALS__; + (window as any).__TAURI_INTERNALS__ = {}; + + expect(api.isTauriRuntime()).toBe(true); + + if (original !== undefined) { + (window as any).__TAURI_INTERNALS__ = original; + } else { + delete (window as any).__TAURI_INTERNALS__; + } + }); +}); diff --git a/tests/unit/app-shortcuts.test.ts b/tests/unit/app-shortcuts.test.ts index 184eb1c..3187feb 100644 --- a/tests/unit/app-shortcuts.test.ts +++ b/tests/unit/app-shortcuts.test.ts @@ -1,10 +1,282 @@ -import { describe, expect, it } from "vitest"; -import { APP_SHORTCUTS, shouldHandleGlobalShortcut } from "@/app/lib/app-shortcuts"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + APP_SHORTCUTS, + getShortcutDisplay, + getShortcutPlatform, + isInputFocused, + matchesShortcut, + shouldHandleGlobalShortcut, + type ShortcutDefinition, +} from "@/app/lib/app-shortcuts"; function keyboardEvent(init: KeyboardEventInit) { return new KeyboardEvent("keydown", init); } +const originalActiveElementDescriptor = Object.getOwnPropertyDescriptor( + Document.prototype, + "activeElement", +); + +function mockActiveElement(el: Element | null) { + Object.defineProperty(Document.prototype, "activeElement", { + get: () => el, + configurable: true, + }); +} + +function restoreActiveElement() { + if (originalActiveElementDescriptor) { + Object.defineProperty(Document.prototype, "activeElement", originalActiveElementDescriptor); + } +} + +afterEach(() => { + document.body.innerHTML = ""; + restoreActiveElement(); + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// getShortcutPlatform +// --------------------------------------------------------------------------- +describe("getShortcutPlatform", () => { + it("returns mac when userAgentData.platform contains mac", () => { + vi.stubGlobal("navigator", { + userAgentData: { platform: "macOS" }, + platform: "MacIntel", + }); + expect(getShortcutPlatform()).toBe("mac"); + }); + + it("returns mac when userAgentData is absent and navigator.platform contains mac", () => { + vi.stubGlobal("navigator", { platform: "MacIntel" }); + expect(getShortcutPlatform()).toBe("mac"); + }); + + it("returns windows when userAgentData.platform contains win", () => { + vi.stubGlobal("navigator", { + userAgentData: { platform: "Windows" }, + platform: "Win32", + }); + expect(getShortcutPlatform()).toBe("windows"); + }); + + it("returns windows when userAgentData is absent and navigator.platform contains win", () => { + vi.stubGlobal("navigator", { platform: "Win32" }); + expect(getShortcutPlatform()).toBe("windows"); + }); + + it("returns linux for an unrecognized platform", () => { + vi.stubGlobal("navigator", { platform: "Linux x86_64" }); + expect(getShortcutPlatform()).toBe("linux"); + }); + + it("returns linux when navigator is unavailable (SSR-like)", () => { + vi.stubGlobal("navigator", undefined); + expect(getShortcutPlatform()).toBe("linux"); + }); +}); + +// --------------------------------------------------------------------------- +// getShortcutDisplay +// --------------------------------------------------------------------------- +describe("getShortcutDisplay", () => { + it("returns only displayKey when requiresPrimaryModifier is false", () => { + expect(getShortcutDisplay(APP_SHORTCUTS.togglePlayback, "mac")).toBe("Space"); + expect(getShortcutDisplay(APP_SHORTCUTS.togglePlayback, "windows")).toBe("Space"); + }); + + it("prepends ⌘ on mac", () => { + expect(getShortcutDisplay(APP_SHORTCUTS.toggleSidebar, "mac")).toBe("⌘B"); + }); + + it("prepends Ctrl+ on windows", () => { + expect(getShortcutDisplay(APP_SHORTCUTS.toggleSidebar, "windows")).toBe("Ctrl+B"); + }); + + it("prepends Ctrl+ on linux", () => { + expect(getShortcutDisplay(APP_SHORTCUTS.newGeneration, "linux")).toBe("Ctrl+N"); + }); + + it("detects platform automatically when platform arg is omitted", () => { + vi.stubGlobal("navigator", { platform: "MacIntel" }); + expect(getShortcutDisplay(APP_SHORTCUTS.toggleSidebar)).toBe("⌘B"); + }); +}); + +// --------------------------------------------------------------------------- +// isInputFocused +// --------------------------------------------------------------------------- +describe("isInputFocused", () => { + it("returns false when no element is focused", () => { + mockActiveElement(null); + expect(isInputFocused()).toBe(false); + }); + + it("returns true for an ", () => { + document.body.innerHTML = ""; + document.querySelector("input")!.focus(); + expect(isInputFocused()).toBe(true); + }); + + it("returns true for a "; + document.querySelector("textarea")!.focus(); + expect(isInputFocused()).toBe(true); + }); + + it("returns true for a "; + document.querySelector("select")!.focus(); + expect(isInputFocused()).toBe(true); + }); + + it("returns true for a contentEditable element", () => { + document.body.innerHTML = '
'; + const div = document.querySelector("div")!; + // jsdom doesn't implement isContentEditable, so mock it + Object.defineProperty(div, "isContentEditable", { value: true, configurable: true }); + mockActiveElement(div); + expect(isInputFocused()).toBe(true); + }); + + it("returns false for a plain "; + mockActiveElement(document.querySelector("button")!); + // jsdom may return undefined for isContentEditable; function result is still falsy + expect(isInputFocused()).toBeFalsy(); + }); + + it("returns false for a
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); + }); +});