From dea76184a25ccc12f5573d3adb2ffc52b61b124b Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:57:01 -0700 Subject: [PATCH 01/12] test: improve frontend coverage from 26% to 70% Add 11 new test files and expand 3 existing ones covering: - Store slices: model, settings, generation, history gaps - Lib: api, diagnostics, window-shell, app-shortcuts, prompt-examples gaps - Components: PlaybackBar, Toast, bootstrap banners, GenerationPanel sub-components, settings screens, layout components 801 tests, all passing. Coverage jumps from 26.04% to 69.73% lines. --- tests/unit/api.test.ts | 787 +++++++++++ tests/unit/app-shortcuts.test.ts | 380 +++++- tests/unit/bootstrap-banners.test.tsx | 446 +++++++ tests/unit/diagnostics.test.ts | 83 ++ .../generation-panel-subcomponents.test.tsx | 935 +++++++++++++ tests/unit/layout-components.test.tsx | 944 ++++++++++++++ tests/unit/model-slice.test.ts | 824 ++++++++++++ tests/unit/playback-bar.test.tsx | 403 ++++++ tests/unit/prompt-examples.test.ts | 112 +- tests/unit/settings-components.test.tsx | 690 ++++++++++ tests/unit/settings-slice.test.ts | 612 +++++++++ tests/unit/store-slices.test.ts | 1155 ++++++++++++++++- tests/unit/toast.test.tsx | 141 ++ tests/unit/window-shell.test.ts | 322 +++++ 14 files changed, 7828 insertions(+), 6 deletions(-) create mode 100644 tests/unit/api.test.ts create mode 100644 tests/unit/bootstrap-banners.test.tsx create mode 100644 tests/unit/diagnostics.test.ts create mode 100644 tests/unit/generation-panel-subcomponents.test.tsx create mode 100644 tests/unit/layout-components.test.tsx create mode 100644 tests/unit/model-slice.test.ts create mode 100644 tests/unit/playback-bar.test.tsx create mode 100644 tests/unit/settings-components.test.tsx create mode 100644 tests/unit/settings-slice.test.ts create mode 100644 tests/unit/toast.test.tsx create mode 100644 tests/unit/window-shell.test.ts diff --git a/tests/unit/api.test.ts b/tests/unit/api.test.ts new file mode 100644 index 0000000..5d5b30c --- /dev/null +++ b/tests/unit/api.test.ts @@ -0,0 +1,787 @@ +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..1454e85 100644 --- a/tests/unit/app-shortcuts.test.ts +++ b/tests/unit/app-shortcuts.test.ts @@ -1,10 +1,294 @@ -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 +313,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..ec95a66 --- /dev/null +++ b/tests/unit/bootstrap-banners.test.tsx @@ -0,0 +1,446 @@ +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..9dca206 --- /dev/null +++ b/tests/unit/diagnostics.test.ts @@ -0,0 +1,83 @@ +import { 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..0c0f6d5 --- /dev/null +++ b/tests/unit/generation-panel-subcomponents.test.tsx @@ -0,0 +1,935 @@ +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: (...args: unknown[]) => getRandomPromptExample(...args), + getRandomPromptByCategory: (...args: unknown[]) => getRandomPromptByCategory(...args), + 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..89453c4 --- /dev/null +++ b/tests/unit/layout-components.test.tsx @@ -0,0 +1,944 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// --------------------------------------------------------------------------- +// 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..04fb30b --- /dev/null +++ b/tests/unit/model-slice.test.ts @@ -0,0 +1,824 @@ +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..e6a9a06 --- /dev/null +++ b/tests/unit/playback-bar.test.tsx @@ -0,0 +1,403 @@ +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 = 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/4", + model: "turbo", + taskType: "text-to-audio", + 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; + }); +} + +// --- 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..5c33600 100644 --- a/tests/unit/prompt-examples.test.ts +++ b/tests/unit/prompt-examples.test.ts @@ -1,11 +1,16 @@ 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 TOTAL_EXAMPLES = 110; // 11 categories x 10 each + +describe("PROMPT_CATEGORIES", () => { it("covers the required music categories without network access", () => { expect(PROMPT_CATEGORIES).toEqual([ "pop", @@ -22,16 +27,117 @@ 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..ae35dea --- /dev/null +++ b/tests/unit/settings-components.test.tsx @@ -0,0 +1,690 @@ +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, + }; +} + +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).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..eebc00c --- /dev/null +++ b/tests/unit/settings-slice.test.ts @@ -0,0 +1,612 @@ +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) => { + 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: "", + lyrics: "", + vocalLanguage: "en", + durationSeconds: "30", + bpm: "", + keyScale: "", + timeSignature: "4", + 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", + lmBackend: "mlx", + lmModelPath: "acestep-5Hz-lm-0.6B", +}; + +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, + })); +} + +/* ================================================================== */ +/* 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); + 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); + 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..ec048bf 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,1141 @@ 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..043f61a --- /dev/null +++ b/tests/unit/toast.test.tsx @@ -0,0 +1,141 @@ +import { type RefObject } from "react"; +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..79740c0 --- /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, act, 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["--window-shell-toolbar-height"]).toBe("64px"); + expect(style["--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); + }); +}); From f020a3c515fe109026cfd7ae00cb2799bdca7266 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:39:19 -0700 Subject: [PATCH 02/12] fix: resolve PR review comments and fix CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix taskType "text-to-audio" → "text2music" in playback-bar test - Derive TOTAL_EXAMPLES from PROMPT_CATEGORIES.length instead of hardcoding - Fix codecov/test-results-action version v5 → v1 (v5 doesn't exist) --- .github/workflows/ci.yml | 2 +- tests/unit/playback-bar.test.tsx | 2 +- tests/unit/prompt-examples.test.ts | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) 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/playback-bar.test.tsx b/tests/unit/playback-bar.test.tsx index e6a9a06..ba6fd06 100644 --- a/tests/unit/playback-bar.test.tsx +++ b/tests/unit/playback-bar.test.tsx @@ -107,7 +107,7 @@ const SAMPLE_GENERATION: GenerationRecord = { keyScale: "C major", timeSignature: "4/4", model: "turbo", - taskType: "text-to-audio", + taskType: "text2music", thinking: false, inferenceSteps: 30, guidanceScale: 7, diff --git a/tests/unit/prompt-examples.test.ts b/tests/unit/prompt-examples.test.ts index 5c33600..35b00f0 100644 --- a/tests/unit/prompt-examples.test.ts +++ b/tests/unit/prompt-examples.test.ts @@ -8,7 +8,8 @@ import { getRandomPromptByCategory, } from "@/app/lib/prompt-examples"; -const TOTAL_EXAMPLES = 110; // 11 categories x 10 each +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", () => { From 8b45cd5905e1e627a4cb69e3a6d572e1a0a7bfef Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:54:51 -0700 Subject: [PATCH 03/12] fix: resolve TypeScript errors in test files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add missing vitest imports (settings-slice, toast) - Remove unused imports (layout-components, window-shell) - Fix GenerationFormValues mock to include all required fields - Fix mock type casts (store mock, DeviceInfo null) - Fix TimeSignature "4/4" → "4", play() return type - Fix CSSProperties custom property access - Fix spread argument types in mock factories --- .../generation-panel-subcomponents.test.tsx | 4 +-- tests/unit/layout-components.test.tsx | 2 +- tests/unit/playback-bar.test.tsx | 6 ++--- tests/unit/settings-components.test.tsx | 3 ++- tests/unit/settings-slice.test.ts | 27 +++++++++++++------ tests/unit/toast.test.tsx | 1 + tests/unit/window-shell.test.ts | 6 ++--- 7 files changed, 31 insertions(+), 18 deletions(-) diff --git a/tests/unit/generation-panel-subcomponents.test.tsx b/tests/unit/generation-panel-subcomponents.test.tsx index 0c0f6d5..9225b0d 100644 --- a/tests/unit/generation-panel-subcomponents.test.tsx +++ b/tests/unit/generation-panel-subcomponents.test.tsx @@ -25,8 +25,8 @@ const getRandomPromptByCategory = vi.fn((cat: string) => `a ${cat} track`); const PROMPT_CATEGORIES = ["pop", "cinematic", "edm"]; vi.mock("@/app/lib/prompt-examples", () => ({ - getRandomPromptExample: (...args: unknown[]) => getRandomPromptExample(...args), - getRandomPromptByCategory: (...args: unknown[]) => getRandomPromptByCategory(...args), + getRandomPromptExample, + getRandomPromptByCategory, PROMPT_CATEGORIES, })); diff --git a/tests/unit/layout-components.test.tsx b/tests/unit/layout-components.test.tsx index 89453c4..3d96839 100644 --- a/tests/unit/layout-components.test.tsx +++ b/tests/unit/layout-components.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; // --------------------------------------------------------------------------- diff --git a/tests/unit/playback-bar.test.tsx b/tests/unit/playback-bar.test.tsx index ba6fd06..f55a4fc 100644 --- a/tests/unit/playback-bar.test.tsx +++ b/tests/unit/playback-bar.test.tsx @@ -7,7 +7,7 @@ import type { GenerationRecord } from "@/app/lib/types"; vi.hoisted(() => { HTMLMediaElement.prototype.load = function () {}; HTMLMediaElement.prototype.pause = function () {}; - HTMLMediaElement.prototype.play = 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"; @@ -105,7 +105,7 @@ const SAMPLE_GENERATION: GenerationRecord = { durationSeconds: 120, bpm: 90, keyScale: "C major", - timeSignature: "4/4", + timeSignature: "4", model: "turbo", taskType: "text2music", thinking: false, @@ -142,7 +142,7 @@ 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 ------------------------------------------------------------------- diff --git a/tests/unit/settings-components.test.tsx b/tests/unit/settings-components.test.tsx index ae35dea..57716fa 100644 --- a/tests/unit/settings-components.test.tsx +++ b/tests/unit/settings-components.test.tsx @@ -222,6 +222,7 @@ function makeGenerationRecord(): GenerationRecord { status: "completed", errorMessage: null, isFavorite: false, + useRandomSeed: false, }; } @@ -249,7 +250,7 @@ function defaultStoreValues() { function setupMockStore(overrides?: Record) { const values = { ...defaultStoreValues(), ...overrides }; - vi.mocked(useGenerationStore).mockImplementation( + (vi.mocked(useGenerationStore) as any).mockImplementation( (selector: (state: Record) => unknown) => selector(values), ); } diff --git a/tests/unit/settings-slice.test.ts b/tests/unit/settings-slice.test.ts index eebc00c..839effa 100644 --- a/tests/unit/settings-slice.test.ts +++ b/tests/unit/settings-slice.test.ts @@ -1,3 +1,4 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; import { create } from "zustand"; import type { GenerationStore } from "@/app/lib/store/types"; @@ -33,8 +34,8 @@ vi.mock("@/app/lib/errors", () => ({ localizeModelStatuses: vi.fn((s: unknown) => s), })); -vi.mock("@/app/lib/model-packs", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("@/app/lib/model-packs", async (importOriginal: () => Promise) => { + const actual = await importOriginal(); return { ...actual, expandDownloadedVariantsFromStatuses: vi.fn(() => []), @@ -73,12 +74,14 @@ const i18nModule = await import("@/app/lib/i18n"); const mockForm = { prompt: "", + negativePrompt: "", lyrics: "", vocalLanguage: "en", durationSeconds: "30", + bpmMode: "auto" as const, bpm: "", keyScale: "", - timeSignature: "4", + timeSignature: "4" as const, model: "acestep-v15-turbo", taskType: "text2music" as const, thinking: true, @@ -90,9 +93,17 @@ const mockForm = { constrainedDecoding: true, useRandomSeed: false, seed: "", - audioFormat: "wav", - lmBackend: "mlx", + 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 = {}) { @@ -110,7 +121,7 @@ function createTestStore(overrides: Partial = {}) { setupOverride: false, refreshBootstrapStatus: vi.fn(() => Promise.resolve()), ...overrides, - })); + } as GenerationStore)); } /* ================================================================== */ @@ -565,7 +576,7 @@ describe("Settings slice", () => { 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); + vi.mocked(api.getDeviceInfo).mockResolvedValue(null as any); vi.mocked(api.listModelCatalog).mockResolvedValue([]); vi.mocked(api.getModelStatus).mockResolvedValue([]); vi.mocked(api.listActiveGenerationTasks).mockResolvedValue([]); @@ -597,7 +608,7 @@ describe("Settings slice", () => { vi.mocked(api.isTauriRuntime).mockReturnValue(true); vi.mocked(api.getSettings).mockRejectedValue("connection lost"); vi.mocked(api.listGenerations).mockResolvedValue([]); - vi.mocked(api.getDeviceInfo).mockResolvedValue(null); + vi.mocked(api.getDeviceInfo).mockResolvedValue(null as any); vi.mocked(api.listModelCatalog).mockResolvedValue([]); vi.mocked(api.getModelStatus).mockResolvedValue([]); vi.mocked(api.listActiveGenerationTasks).mockResolvedValue([]); diff --git a/tests/unit/toast.test.tsx b/tests/unit/toast.test.tsx index 043f61a..2c02d41 100644 --- a/tests/unit/toast.test.tsx +++ b/tests/unit/toast.test.tsx @@ -1,4 +1,5 @@ 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"; diff --git a/tests/unit/window-shell.test.ts b/tests/unit/window-shell.test.ts index 79740c0..32596f1 100644 --- a/tests/unit/window-shell.test.ts +++ b/tests/unit/window-shell.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach, type Mock } from "vitest"; -import { renderHook, act, waitFor } from "@testing-library/react"; +import { renderHook, waitFor } from "@testing-library/react"; vi.mock("@/app/lib/app-shortcuts", () => ({ getShortcutPlatform: vi.fn(() => "mac"), @@ -219,8 +219,8 @@ describe("createWindowShellStyle", () => { sidebarHeaderHeight: 0, sidebarWidth: 320, }); - expect(style["--window-shell-toolbar-height"]).toBe("64px"); - expect(style["--window-shell-sidebar-width"]).toBe("320px"); + expect((style as any)["--window-shell-toolbar-height"]).toBe("64px"); + expect((style as any)["--window-shell-sidebar-width"]).toBe("320px"); }); }); From 5718cfd8400dc85916a1cb41d505b6257ab25cac Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:07:14 -0700 Subject: [PATCH 04/12] fix: add missing vitest imports and fix spread types in test files --- tests/unit/bootstrap-banners.test.tsx | 4 ++-- tests/unit/diagnostics.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/bootstrap-banners.test.tsx b/tests/unit/bootstrap-banners.test.tsx index ec95a66..c630755 100644 --- a/tests/unit/bootstrap-banners.test.tsx +++ b/tests/unit/bootstrap-banners.test.tsx @@ -4,10 +4,10 @@ import userEvent from "@testing-library/user-event"; // -- Store mock state (mutable per test) ---------------------------------- -let storeState: Record = {}; +let storeState: Record = {}; vi.mock("@/app/lib/store", () => ({ - useGenerationStore: (selector: (state: Record) => unknown) => selector(storeState), + useGenerationStore: (selector: (state: Record) => unknown) => selector(storeState), })); // -- Tauri updater mock --------------------------------------------------- diff --git a/tests/unit/diagnostics.test.ts b/tests/unit/diagnostics.test.ts index 9dca206..5b19f55 100644 --- a/tests/unit/diagnostics.test.ts +++ b/tests/unit/diagnostics.test.ts @@ -1,4 +1,4 @@ -import { vi, type Mock } from "vitest"; +import { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import { collectDiagnostics, formatDiagnostics, From 973008c6c9e9c35b2eee248b9bf04c9b9920f0e0 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:16:20 -0700 Subject: [PATCH 05/12] style: fix formatting in test files via oxfmt --- tests/unit/api.test.ts | 14 +-- tests/unit/app-shortcuts.test.ts | 24 +--- tests/unit/bootstrap-banners.test.tsx | 6 +- .../generation-panel-subcomponents.test.tsx | 52 +++------ tests/unit/layout-components.test.tsx | 10 +- tests/unit/model-slice.test.ts | 32 ++--- tests/unit/network-activity-section.test.tsx | 109 ++++++++++++++++++ tests/unit/playback-bar.test.tsx | 9 +- tests/unit/prompt-examples.test.ts | 4 +- tests/unit/settings-components.test.tsx | 5 +- tests/unit/settings-slice.test.ts | 57 ++++----- tests/unit/store-slices.test.ts | 4 +- tests/unit/window-shell.test.ts | 22 ++-- 13 files changed, 199 insertions(+), 149 deletions(-) create mode 100644 tests/unit/network-activity-section.test.tsx diff --git a/tests/unit/api.test.ts b/tests/unit/api.test.ts index 5d5b30c..7ecf4fb 100644 --- a/tests/unit/api.test.ts +++ b/tests/unit/api.test.ts @@ -649,10 +649,7 @@ describe("deleteGenerationFileAndRecord", () => { await api.deleteGenerationFileAndRecord("abc"); - expect(mockInvoke).toHaveBeenCalledWith( - "delete_generation_file_and_record", - { id: "abc" }, - ); + expect(mockInvoke).toHaveBeenCalledWith("delete_generation_file_and_record", { id: "abc" }); }); }); @@ -701,10 +698,7 @@ describe("exportGenerationsToFolder", () => { const exported = ["/dest/a.wav", "/dest/b.wav"]; mockInvoke.mockResolvedValue(exported); - const result = await api.exportGenerationsToFolder( - ["a", "b"], - "/dest", - ); + const result = await api.exportGenerationsToFolder(["a", "b"], "/dest"); expect(mockInvoke).toHaveBeenCalledWith("export_generations_to_folder", { ids: ["a", "b"], @@ -750,9 +744,7 @@ describe("error propagation", () => { const error = new Error("permission denied"); mockInvoke.mockRejectedValue(error); - await expect(api.deleteGeneration("abc")).rejects.toThrow( - "permission denied", - ); + await expect(api.deleteGeneration("abc")).rejects.toThrow("permission denied"); }); }); diff --git a/tests/unit/app-shortcuts.test.ts b/tests/unit/app-shortcuts.test.ts index 1454e85..3187feb 100644 --- a/tests/unit/app-shortcuts.test.ts +++ b/tests/unit/app-shortcuts.test.ts @@ -227,20 +227,14 @@ describe("matchesShortcut", () => { displayKey: "Z", }; expect( - matchesShortcut( - keyboardEvent({ code: "Unidentified", key: "z", metaKey: true }), - def, - ), + 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, - ), + matchesShortcut(keyboardEvent({ code: "Space", key: " " }), APP_SHORTCUTS.togglePlayback), ).toBe(true); }); @@ -257,10 +251,7 @@ describe("matchesShortcut", () => { 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, - ), + matchesShortcut(keyboardEvent({ code: "KeyX", key: "x" }), APP_SHORTCUTS.togglePlayback), ).toBe(false); }); @@ -277,12 +268,9 @@ describe("matchesShortcut", () => { 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); + expect(matchesShortcut(keyboardEvent({ code: "KeyA", key: "a", metaKey: true }), def)).toBe( + false, + ); }); }); diff --git a/tests/unit/bootstrap-banners.test.tsx b/tests/unit/bootstrap-banners.test.tsx index c630755..cc4d4a7 100644 --- a/tests/unit/bootstrap-banners.test.tsx +++ b/tests/unit/bootstrap-banners.test.tsx @@ -216,7 +216,11 @@ describe("ModelBootstrapBanner", () => { 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." }, + error: { + code: "MODEL_NOT_FOUND", + message: "Model not found", + details: "The model file could not be located on disk.", + }, }; render(); diff --git a/tests/unit/generation-panel-subcomponents.test.tsx b/tests/unit/generation-panel-subcomponents.test.tsx index 9225b0d..0981f88 100644 --- a/tests/unit/generation-panel-subcomponents.test.tsx +++ b/tests/unit/generation-panel-subcomponents.test.tsx @@ -72,22 +72,18 @@ let storeState: { }; vi.mock("@/app/lib/store", () => ({ - useGenerationStore: (selector: (state: typeof storeState) => unknown) => - selector(storeState), + useGenerationStore: (selector: (state: typeof storeState) => unknown) => selector(storeState), })); // --------------------------------------------------------------------------- // Imports after mocks // --------------------------------------------------------------------------- -const { FieldError, FieldLabel, FilePickerField, handleTextFieldChange } = await import( - "@/app/components/generation/GenerationPanel/shared" -); +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" -); +const { ActionFooter } = await import("@/app/components/generation/GenerationPanel/ActionFooter"); // --------------------------------------------------------------------------- // Helpers @@ -143,9 +139,7 @@ function makeFormBodyProps(overrides: Partial[0]> = }; } -function makeGenerationState( - status: GenerationState["status"] = "idle", -): GenerationState { +function makeGenerationState(status: GenerationState["status"] = "idle"): GenerationState { return { status, phase: status === "idle" ? "idle" : "running", @@ -288,12 +282,8 @@ describe("Header", () => { expect( screen.getByRole("button", { name: "generation.randomInspiration" }), ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: "generation.enhancePrompt" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: "generation.addFavorite" }), - ).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 () => { @@ -565,9 +555,7 @@ describe("FormBody", () => { 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}`), - ); + const tagButtons = STRUCTURE_TAGS.map((tag) => screen.queryByText(`generation.${tag}`)); tagButtons.forEach((btn) => expect(btn).not.toBeInTheDocument()); }); @@ -628,9 +616,7 @@ describe("FormBody", () => { // 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"), - ); + const langSelect = selects.find((s) => within(s).queryByText("EN")); expect(langSelect).toBeDefined(); expect(langSelect).toBeDisabled(); }); @@ -675,18 +661,14 @@ describe("FormBody", () => { 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(); }); @@ -735,9 +717,7 @@ describe("FormBody", () => { it("renders negative prompt textarea inside tweak section when open", () => { render(); - expect( - screen.getByPlaceholderText("generation.negativePromptPlaceholder"), - ).toBeInTheDocument(); + expect(screen.getByPlaceholderText("generation.negativePromptPlaceholder")).toBeInTheDocument(); }); it("renders inference steps and guidance scale inputs when tweak is open", () => { @@ -769,9 +749,7 @@ describe("FormBody", () => { ); const selects = screen.getAllByRole("combobox"); // LM selects should be disabled when thinking is false - const lmModelSelect = selects.find((s) => - within(s).queryByText("None"), - ); + const lmModelSelect = selects.find((s) => within(s).queryByText("None")); expect(lmModelSelect).toBeDefined(); expect(lmModelSelect).toBeDisabled(); }); @@ -887,9 +865,7 @@ describe("ActionFooter", () => { it("shows 'validating' label when validating", () => { render( - , + , ); expect(screen.getByText("generation.validating")).toBeInTheDocument(); }); diff --git a/tests/unit/layout-components.test.tsx b/tests/unit/layout-components.test.tsx index 3d96839..bbe09e6 100644 --- a/tests/unit/layout-components.test.tsx +++ b/tests/unit/layout-components.test.tsx @@ -676,7 +676,11 @@ describe("OpenLoopStage", () => { status: "failed", phase: "failed", statusMessage: "Failed", - error: { code: "TASK_FAILED", message: "Generation task failed", details: "model not found" }, + error: { + code: "TASK_FAILED", + message: "Generation task failed", + details: "model not found", + }, }, }); render(); @@ -738,9 +742,7 @@ describe("OpenLoopStage", () => { }); render(); await user.click(screen.getByText("Copy details")); - expect(navigator.clipboard.writeText).toHaveBeenCalledWith( - JSON.stringify(error, null, 2), - ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(JSON.stringify(error, null, 2)); }); it("opens GitHub issue URL when get help is clicked", async () => { diff --git a/tests/unit/model-slice.test.ts b/tests/unit/model-slice.test.ts index 04fb30b..35b0cab 100644 --- a/tests/unit/model-slice.test.ts +++ b/tests/unit/model-slice.test.ts @@ -257,10 +257,7 @@ describe("applyModelStatus", () => { modelVariant: "turbo", downloadedModels: ["lite", "turbo", "pro"], }), - modelStatuses: [ - modelStatus("turbo", "ready"), - modelStatus("pro", "ready"), - ], + modelStatuses: [modelStatus("turbo", "ready"), modelStatus("pro", "ready")], }); store.getState().applyModelStatus(modelStatus("pro", "failed")); @@ -349,10 +346,7 @@ describe("downloadModelVariant", () => { expect(mockApi.setSetting).toHaveBeenCalledWith("modelVariant", "turbo"); expect(mockApi.setSetting).toHaveBeenCalledWith("profile", "standard"); - expect(mockApi.setSetting).toHaveBeenCalledWith( - "defaultThinking", - expect.any(Boolean), - ); + expect(mockApi.setSetting).toHaveBeenCalledWith("defaultThinking", expect.any(Boolean)); }); it("applies the status returned by downloadModel", async () => { @@ -511,10 +505,7 @@ describe("deleteAllModels", () => { downloadedModels: ["lite", "turbo", "pro"], modelVariant: "turbo", }), - modelStatuses: [ - modelStatus("turbo", "ready"), - modelStatus("pro", "ready"), - ], + modelStatuses: [modelStatus("turbo", "ready"), modelStatus("pro", "ready")], }); mockApi.deleteAllModels.mockResolvedValue([ @@ -579,9 +570,7 @@ describe("refreshModelStatuses", () => { ]; mockApi.listModelCatalog.mockResolvedValue(catalog); mockApi.getModelStatus.mockResolvedValue([modelStatus("turbo", "ready")]); - mockApi.getBackendProvisionStatus.mockResolvedValue( - defaultProvisionStatus({ state: "ready" }), - ); + mockApi.getBackendProvisionStatus.mockResolvedValue(defaultProvisionStatus({ state: "ready" })); await store.getState().refreshModelStatuses(); @@ -608,9 +597,7 @@ describe("refreshModelStatuses", () => { modelStatus("turbo", "ready"), modelStatus("pro", "ready"), ]); - mockApi.getBackendProvisionStatus.mockResolvedValue( - defaultProvisionStatus({ state: "ready" }), - ); + mockApi.getBackendProvisionStatus.mockResolvedValue(defaultProvisionStatus({ state: "ready" })); await store.getState().refreshModelStatuses(); @@ -668,10 +655,7 @@ describe("selectModelVariant", () => { expect(mockApi.setSetting).toHaveBeenCalledWith("modelVariant", "pro"); expect(mockApi.setSetting).toHaveBeenCalledWith("profile", "quality"); - expect(mockApi.setSetting).toHaveBeenCalledWith( - "defaultThinking", - expect.any(Boolean), - ); + expect(mockApi.setSetting).toHaveBeenCalledWith("defaultThinking", expect.any(Boolean)); }); }); }); @@ -760,9 +744,7 @@ describe("provisionBackend", () => { it("sets status to ready on success", async () => { mockApi.isTauriRuntime.mockReturnValue(true); - mockApi.provisionBackend.mockResolvedValue( - defaultProvisionStatus({ state: "ready" }), - ); + mockApi.provisionBackend.mockResolvedValue(defaultProvisionStatus({ state: "ready" })); await store.getState().provisionBackend(); diff --git a/tests/unit/network-activity-section.test.tsx b/tests/unit/network-activity-section.test.tsx new file mode 100644 index 0000000..c65077b --- /dev/null +++ b/tests/unit/network-activity-section.test.tsx @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const getNetworkLog = vi.fn<(...args: unknown[]) => Promise>(); + +vi.mock("@/app/lib/api", () => ({ + isTauriRuntime: () => true, + getNetworkLog: (...args: unknown[]) => getNetworkLog(...args), +})); + +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, +})); + +import { NetworkActivitySection } from "@/app/components/settings/sections/NetworkActivitySection"; + +describe("NetworkActivitySection", () => { + beforeEach(() => { + getNetworkLog.mockReset(); + }); + + it("renders empty state when no entries exist", async () => { + getNetworkLog.mockResolvedValue([]); + + render(); + + await waitFor(() => { + expect(screen.getByText(/No outbound requests/)).toBeTruthy(); + }); + }); + + it("renders network entries in a table", async () => { + getNetworkLog.mockResolvedValue([ + { + timestamp: "2026-06-10T12:00:00Z", + url: "https://huggingface.co/ACE-Step/model/resolve/main/weights.bin", + method: "GET", + status: 200, + }, + { + timestamp: "2026-06-10T12:01:00Z", + url: "https://api.github.com/repos/ACE-Step/ACE-Step-1.5/releases/latest", + method: "GET", + status: 200, + }, + ]); + + render(); + + await waitFor(() => { + const methodCells = screen.getAllByText("GET"); + expect(methodCells).toHaveLength(2); + expect(screen.getByText(/huggingface/)).toBeTruthy(); + expect(screen.getByText(/api\.github/)).toBeTruthy(); + }); + }); + + it("refreshes when refresh button is clicked", async () => { + getNetworkLog.mockResolvedValue([]); + + render(); + + await waitFor(() => { + expect(getNetworkLog).toHaveBeenCalledTimes(1); + }); + + const refreshButton = screen.getByText("Refresh"); + await userEvent.click(refreshButton); + + await waitFor(() => { + expect(getNetworkLog).toHaveBeenCalledTimes(2); + }); + }); + + it("displays status codes with appropriate color", async () => { + getNetworkLog.mockResolvedValue([ + { + timestamp: "2026-06-10T12:00:00Z", + url: "https://ok.example.com", + method: "GET", + status: 200, + }, + { + timestamp: "2026-06-10T12:01:00Z", + url: "https://err.example.com", + method: "POST", + status: 404, + }, + ]); + + render(); + + await waitFor(() => { + const okStatus = screen.getByText("200"); + expect(okStatus.className).toContain("text-green-400"); + const errStatus = screen.getByText("404"); + expect(errStatus.className).toContain("text-red-400"); + }); + }); +}); diff --git a/tests/unit/playback-bar.test.tsx b/tests/unit/playback-bar.test.tsx index f55a4fc..2765ca9 100644 --- a/tests/unit/playback-bar.test.tsx +++ b/tests/unit/playback-bar.test.tsx @@ -42,8 +42,7 @@ vi.mock("@/app/lib/api", () => ({ readGenerationWaveform: (...args: unknown[]) => mockReadGenerationWaveform(...args), copyAudioTo: vi.fn(), revealInFinder: vi.fn(), - deleteGenerationFileAndRecord: (...args: unknown[]) => - mockDeleteGenerationFileAndRecord(...args), + deleteGenerationFileAndRecord: (...args: unknown[]) => mockDeleteGenerationFileAndRecord(...args), })); // IMPORTANT: `t` must be a stable reference — the PlaybackBar component has @@ -89,8 +88,7 @@ interface MockStoreState { let currentStoreState: MockStoreState; vi.mock("@/app/lib/store", () => ({ - useGenerationStore: (selector: (state: MockStoreState) => unknown) => - selector(currentStoreState), + useGenerationStore: (selector: (state: MockStoreState) => unknown) => selector(currentStoreState), })); // --- Helpers ----------------------------------------------------------------- @@ -212,7 +210,8 @@ describe("PlaybackBar", () => { // Wait for the async audio fetch to resolve and set audioSrc await vi.waitFor(() => { - const playPauseBtn = getButtonByTooltip("player.play") ?? getButtonByTooltip("player.pause")!; + const playPauseBtn = + getButtonByTooltip("player.play") ?? getButtonByTooltip("player.pause")!; expect(playPauseBtn).not.toBeDisabled(); }); }); diff --git a/tests/unit/prompt-examples.test.ts b/tests/unit/prompt-examples.test.ts index 35b00f0..5d1b4a3 100644 --- a/tests/unit/prompt-examples.test.ts +++ b/tests/unit/prompt-examples.test.ts @@ -52,9 +52,7 @@ describe("getPromptExampleAt", () => { 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), - ); + expect(getPromptExampleAt(-(TOTAL_EXAMPLES + 5))).toBe(getPromptExampleAt(5)); }); it("truncates fractional indices", () => { diff --git a/tests/unit/settings-components.test.tsx b/tests/unit/settings-components.test.tsx index 57716fa..8348dad 100644 --- a/tests/unit/settings-components.test.tsx +++ b/tests/unit/settings-components.test.tsx @@ -256,10 +256,7 @@ function setupMockStore(overrides?: Record) { } /** Click the Next button and wait for the step title to appear. */ -async function goToStep( - user: ReturnType, - stepTitle: string, -) { +async function goToStep(user: ReturnType, stepTitle: string) { await user.click(screen.getByText("setup.next")); await screen.findByText(stepTitle); } diff --git a/tests/unit/settings-slice.test.ts b/tests/unit/settings-slice.test.ts index 839effa..b8bd42f 100644 --- a/tests/unit/settings-slice.test.ts +++ b/tests/unit/settings-slice.test.ts @@ -34,13 +34,16 @@ 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/model-packs", + async (importOriginal: () => Promise) => { + const actual = await importOriginal(); + return { + ...actual, + expandDownloadedVariantsFromStatuses: vi.fn(() => []), + }; + }, +); vi.mock("@/app/lib/validation-helpers", () => ({ computeValidationState: vi.fn(() => ({ @@ -107,21 +110,24 @@ const mockForm = { }; 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)); + 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, + ); } /* ================================================================== */ @@ -393,10 +399,9 @@ describe("Settings slice", () => { vi.mocked(api.isTauriRuntime).mockReturnValue(false); const store = createTestStore(); await store.getState().completeSetup(); - expect(computeValidationState).toHaveBeenCalledWith( - expect.anything(), - { showErrors: false }, - ); + expect(computeValidationState).toHaveBeenCalledWith(expect.anything(), { + showErrors: false, + }); }); }); diff --git a/tests/unit/store-slices.test.ts b/tests/unit/store-slices.test.ts index ec048bf..d28d0a5 100644 --- a/tests/unit/store-slices.test.ts +++ b/tests/unit/store-slices.test.ts @@ -1506,9 +1506,7 @@ describe("resumeActiveTask", () => { }); it("sets failed state on error", async () => { - vi.mocked(api.resumeGenerationTask).mockRejectedValue( - new Error("backend unreachable"), - ); + vi.mocked(api.resumeGenerationTask).mockRejectedValue(new Error("backend unreachable")); useGenerationStore.setState({ activeTasks: [task("t1")] as any }); diff --git a/tests/unit/window-shell.test.ts b/tests/unit/window-shell.test.ts index 32596f1..e0aa31b 100644 --- a/tests/unit/window-shell.test.ts +++ b/tests/unit/window-shell.test.ts @@ -22,14 +22,16 @@ 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; -}>) { +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, @@ -291,9 +293,7 @@ describe("useWindowShellState", () => { it("uses sidebarWidth argument to override snapshot sidebar_width", async () => { mockPlatform("mac"); - (getWindowShellState as Mock).mockResolvedValue( - makeSnapshot({ sidebar_width: 999 }), - ); + (getWindowShellState as Mock).mockResolvedValue(makeSnapshot({ sidebar_width: 999 })); const { result } = renderHook(() => useWindowShellState(400)); From ed77d89cf753a873b89ff91ac6da520847def8a6 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:23:57 -0700 Subject: [PATCH 06/12] fix: remove network-activity-section test (depends on unmerged source) --- tests/unit/network-activity-section.test.tsx | 109 ------------------- 1 file changed, 109 deletions(-) delete mode 100644 tests/unit/network-activity-section.test.tsx diff --git a/tests/unit/network-activity-section.test.tsx b/tests/unit/network-activity-section.test.tsx deleted file mode 100644 index c65077b..0000000 --- a/tests/unit/network-activity-section.test.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; - -const getNetworkLog = vi.fn<(...args: unknown[]) => Promise>(); - -vi.mock("@/app/lib/api", () => ({ - isTauriRuntime: () => true, - getNetworkLog: (...args: unknown[]) => getNetworkLog(...args), -})); - -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, -})); - -import { NetworkActivitySection } from "@/app/components/settings/sections/NetworkActivitySection"; - -describe("NetworkActivitySection", () => { - beforeEach(() => { - getNetworkLog.mockReset(); - }); - - it("renders empty state when no entries exist", async () => { - getNetworkLog.mockResolvedValue([]); - - render(); - - await waitFor(() => { - expect(screen.getByText(/No outbound requests/)).toBeTruthy(); - }); - }); - - it("renders network entries in a table", async () => { - getNetworkLog.mockResolvedValue([ - { - timestamp: "2026-06-10T12:00:00Z", - url: "https://huggingface.co/ACE-Step/model/resolve/main/weights.bin", - method: "GET", - status: 200, - }, - { - timestamp: "2026-06-10T12:01:00Z", - url: "https://api.github.com/repos/ACE-Step/ACE-Step-1.5/releases/latest", - method: "GET", - status: 200, - }, - ]); - - render(); - - await waitFor(() => { - const methodCells = screen.getAllByText("GET"); - expect(methodCells).toHaveLength(2); - expect(screen.getByText(/huggingface/)).toBeTruthy(); - expect(screen.getByText(/api\.github/)).toBeTruthy(); - }); - }); - - it("refreshes when refresh button is clicked", async () => { - getNetworkLog.mockResolvedValue([]); - - render(); - - await waitFor(() => { - expect(getNetworkLog).toHaveBeenCalledTimes(1); - }); - - const refreshButton = screen.getByText("Refresh"); - await userEvent.click(refreshButton); - - await waitFor(() => { - expect(getNetworkLog).toHaveBeenCalledTimes(2); - }); - }); - - it("displays status codes with appropriate color", async () => { - getNetworkLog.mockResolvedValue([ - { - timestamp: "2026-06-10T12:00:00Z", - url: "https://ok.example.com", - method: "GET", - status: 200, - }, - { - timestamp: "2026-06-10T12:01:00Z", - url: "https://err.example.com", - method: "POST", - status: 404, - }, - ]); - - render(); - - await waitFor(() => { - const okStatus = screen.getByText("200"); - expect(okStatus.className).toContain("text-green-400"); - const errStatus = screen.getByText("404"); - expect(errStatus.className).toContain("text-red-400"); - }); - }); -}); From 85523abcdebb3005247d53cdffbbf72321db80b5 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:45:18 -0700 Subject: [PATCH 07/12] fix: stub setPointerCapture for jsdom in layout drag tests --- tests/unit/layout-components.test.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit/layout-components.test.tsx b/tests/unit/layout-components.test.tsx index bbe09e6..8ac66c3 100644 --- a/tests/unit/layout-components.test.tsx +++ b/tests/unit/layout-components.test.tsx @@ -2,6 +2,16 @@ 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 // --------------------------------------------------------------------------- From 7ce44a3c2e99d4c41ace6e24d6242df2e3ddf2ab Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:22:53 -0700 Subject: [PATCH 08/12] chore: add mise.toml for runtime version management --- mise.toml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 mise.toml diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..09438f4 --- /dev/null +++ b/mise.toml @@ -0,0 +1,3 @@ +[tools] +node = "24" +pnpm = "11.5.2" From 509024940503a3b500eb5f215f45538eaf90cb32 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:33:30 -0700 Subject: [PATCH 09/12] fix: use beforeEach/afterEach for isTauriRuntime test cleanup Inline state restoration could leave window.__TAURI_INTERNALS__ dirty if an assertion throws. Move save/restore to beforeEach/afterEach. --- tests/unit/api.test.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/unit/api.test.ts b/tests/unit/api.test.ts index 7ecf4fb..76202f9 100644 --- a/tests/unit/api.test.ts +++ b/tests/unit/api.test.ts @@ -753,27 +753,27 @@ describe("error propagation", () => { // --------------------------------------------------------------------------- describe("isTauriRuntime", () => { - it("returns false when __TAURI_INTERNALS__ is absent", () => { - const original = (window as any).__TAURI_INTERNALS__; - delete (window as any).__TAURI_INTERNALS__; + let originalInternals: unknown; - expect(api.isTauriRuntime()).toBe(false); + beforeEach(() => { + originalInternals = (window as any).__TAURI_INTERNALS__; + }); - if (original !== undefined) { - (window as any).__TAURI_INTERNALS__ = original; + afterEach(() => { + if (originalInternals !== undefined) { + (window as any).__TAURI_INTERNALS__ = originalInternals; + } else { + delete (window as any).__TAURI_INTERNALS__; } }); + it("returns false when __TAURI_INTERNALS__ is absent", () => { + delete (window as any).__TAURI_INTERNALS__; + expect(api.isTauriRuntime()).toBe(false); + }); + 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__; - } }); }); From 2efa8ee734560d250bca430f412d8bf94aa90f45 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:34:27 -0700 Subject: [PATCH 10/12] chore: trigger re-review From 95f8f2b541a8e70bb257bab6aab79795d511e402 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:36:16 -0700 Subject: [PATCH 11/12] fix: use afterEach for isTauriRuntime test cleanup --- tests/unit/api.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/api.test.ts b/tests/unit/api.test.ts index 76202f9..0a07cf5 100644 --- a/tests/unit/api.test.ts +++ b/tests/unit/api.test.ts @@ -759,7 +759,7 @@ describe("isTauriRuntime", () => { originalInternals = (window as any).__TAURI_INTERNALS__; }); - afterEach(() => { + afterEach(() => { // ensure clean state for subsequent tests if (originalInternals !== undefined) { (window as any).__TAURI_INTERNALS__ = originalInternals; } else { From 00356409c57b86f92b38f45a104326a127313e9f Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:27:52 -0700 Subject: [PATCH 12/12] docs: add mise to contributing guide --- .scratch/stale-docs-audit.md | 323 ++++++ .scratch/stale-docs-cleanup-report.md | 56 + CONTRIBUTING.md | 1 + docs/screenshots/01-initial.png | Bin 0 -> 41533 bytes scripts/screenshot.mjs | 38 + scripts/validate-readme.mjs | 115 ++ scripts/validate-release-notes.mjs | 84 ++ src-tauri/migrations/005_history_indexes.sql | 1 + src-tauri/src/cli/cli.rs | 1005 +++++++++++++++++ src-tauri/src/cli/completions.rs | 43 + src-tauri/src/cli/output.rs | 112 ++ src-tauri/src/commands/network.rs | 50 + src-tauri/src/services/network_log.rs | 138 +++ src-tauri/src/services/urls.rs | 75 ++ .../sections/NetworkActivitySection.tsx | 121 ++ test-results.junit.xml | 1 + tests/unit/network-activity-section.test.tsx | 109 ++ 17 files changed, 2272 insertions(+) create mode 100644 .scratch/stale-docs-audit.md create mode 100644 .scratch/stale-docs-cleanup-report.md create mode 100644 docs/screenshots/01-initial.png create mode 100644 scripts/screenshot.mjs create mode 100644 scripts/validate-readme.mjs create mode 100644 scripts/validate-release-notes.mjs create mode 100644 src-tauri/migrations/005_history_indexes.sql create mode 100644 src-tauri/src/cli/cli.rs create mode 100644 src-tauri/src/cli/completions.rs create mode 100644 src-tauri/src/cli/output.rs create mode 100644 src-tauri/src/commands/network.rs create mode 100644 src-tauri/src/services/network_log.rs create mode 100644 src-tauri/src/services/urls.rs create mode 100644 src/app/components/settings/sections/NetworkActivitySection.tsx create mode 100644 test-results.junit.xml create mode 100644 tests/unit/network-activity-section.test.tsx diff --git a/.scratch/stale-docs-audit.md b/.scratch/stale-docs-audit.md new file mode 100644 index 0000000..b5ac254 --- /dev/null +++ b/.scratch/stale-docs-audit.md @@ -0,0 +1,323 @@ +# Stale Docs Audit + +Repo: `/Users/david/Development/OpenLoop` +Generated: `2026-06-15T03:57:03Z` + +## Section A — Issue tracker + +Detected: GitHub remote. +Suggested tracker: GitHub Issues. +CLI: `gh` found. + +### Git remotes + +``` +origin git@github.com:thedavidweng/OpenLoop.git (fetch) +origin git@github.com:thedavidweng/OpenLoop.git (push) +``` + +## Doc inventory + +Doc files scanned: `44` + +## Plan-era language + +``` +./docs/2026-06-10-issue-plans-and-evals.md:638:Produce `docs/adr/0005-platform-roadmap.md` classifying each target platform into a support tier, documenting every platform-specific code location, identifying external blockers, and defining a phased rollout plan. +``` + +## Forward-looking language + +``` +./docs/2026-06-10-issue-plans-and-evals.md:638:Produce `docs/adr/0005-platform-roadmap.md` classifying each target platform into a support tier, documenting every platform-specific code location, identifying external blockers, and defining a phased rollout plan. +./docs/2026-06-10-issue-plans-and-evals.md:646:**Files to Touch:** `docs/adr/0005-platform-roadmap.md` (new), `src-tauri/tests/platform_inventory.rs` (new), `src-tauri/tests/device_info_cross_platform.rs` (new), `src-tauri/tests/model_bootstrap_cross_platform.rs` (new), `src-tauri/tests/file_operations_cross_platform.rs` (new), `src-tauri/tests/model_descriptors_cross_platform.rs` (new), `src-tauri/src/services/device.rs`, `src-tauri/src/services/model_bootstrap.rs`, `src-tauri/src/commands/files.rs`, `src-tauri/src/commands/settings.rs`, `src-tauri/src/commands/support.rs`, `src-tauri/src/services/model_manager/mod.rs`, `src-tauri/src/platform/` (new module tree) +./docs/plans/2026-04-28-acestep-feature-benefits.md:5:The remaining ACE-Step feature backlog has been implemented in the app: +./docs/plans/2026-04-28-acestep-feature-benefits.md:14:Future work should be tracked in a new plan instead of reopening this completed backlog. +./docs/plans/2026-04-28-ui-review.md:5:The remaining UI review backlog has been implemented in the app: +./docs/plans/2026-04-28-ui-review.md:13:Future UI review findings should be tracked in a new plan instead of reopening this completed backlog. +./docs/plans/2026-05-14-v1-readiness-master-plan.md:189:- [x] 1.5.2 在 README 顶部加一行 `> **Status:** v0.1 Alpha — macOS Apple Silicon only. Windows / Linux on the roadmap.`——已通过 Status badge 实现(v0.2.1 Alpha)。 +./docs/plans/2026-05-14-v1-readiness-master-plan.md:569:- [ ] 13.4 产出 `docs/adr/0005-platform-roadmap.md`。 +./docs/plans/2026-05-14-v1-readiness-master-plan.md:571:**Phase 13 验收:** 产出 `docs/adr/0005-platform-roadmap.md`,包含各平台可行性结论与建议动作。 +./docs/release.md:64:> Apple Developer ID signing and notarization will be added before a stable public release. Until then, Ad-hoc signing is the intentional distribution strategy for the open-source Alpha phase. +./docs/specs/event-schema.md:120:> **Note:** This envelope format is not yet emitted by any CLI command. Current completion events use bare JSON lines (see [Generation Task Events](#generation-task-events)). Wiring this envelope is a planned breaking shape change from the current bare payload: `output_path` becomes `path`, `duration` changes from float seconds to integer `duration_ms`, `format` is omitted, and `seed` is added. +./README.md:253:- No account system is planned. +./README.md:254:- No telemetry is planned. +./README.md:276:- Repaint is planned after the first Alpha. +./README.md:321:For the detailed development roadmap and planning documents, see: +``` + +## Agent instructions + +``` +./AGENTS.md:11:Five canonical roles (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`) mapped 1:1 to GitHub labels. See `docs/agents/triage-labels.md`. +./CHANGELOG.md:24:- Restore cursor:pointer on text links, remove dead .user-content class +./CONTEXT.md:46:- The CLI supports agent workflows: it can run headlessly, output machine-readable JSON, and auto-bootstrap the **Local Backend** and **Model Bootstrap** on first use. +./docs/2026-06-09-cli-clap-refactoring.md:96:`GenerationCommand` enum. Handles `list`, `cancel`, `resume`, `discard`. +./docs/2026-06-10-issue-plans-and-evals.md:532:#### Issue #60: First-run & Error UX: demo prompt path, Help section, What's new modal +./docs/2026-06-10-issue-plans-and-evals.md:544:**Files to Touch:** `src-tauri/src/models/settings.rs`, `src/app/lib/types.ts`, `src-tauri/src/commands/support.rs`, `src-tauri/src/commands/files.rs`, `src-tauri/tauri.conf.json`, `src-tauri/resources/demo/demo-prompt.wav` (new), `src/app/lib/whats-new.ts` (new), `src/app/components/bootstrap/WhatsNewModal.tsx` (new), `src/app/components/bootstrap/DemoBanner.tsx`, `src/app/components/settings/sections/HelpSection.tsx` (new), `src/app/components/settings/SettingsOverlay.tsx`, `src/app/lib/store/slices/ui.ts`, `src/locales/en.json`, `src/locales/zh-CN.json` +./docs/2026-06-10-issue-plans-and-evals.md:608:Phase 0: SHA256 hash acquisition. Phase 1: Multi-mirror settings model. Phase 2: Core download-with-mirrors logic. Phase 3: SHA256 mismatch triggers mirror fallback. Phase 4: Partial download resume across mirrors. Phase 5: CLI multi-mirror support. Phase 6: Frontend mirror list UI. Phase 7: Progress events include mirror info. +./docs/adr/0002-cli-gui-shared-service-layer.md:9:OpenLoop needs a CLI mode for agent integration in video production workflows. The current architecture couples the service layer (`AppState`, `BackendManager`, `Database`, `ModelManager`) to Tauri's `setup` closure in `lib.rs`. This makes it impossible to use the service layer without Tauri. +./docs/agents/triage-labels.md:9:| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +./docs/archive/plans/2026-04-24-openkara-shell-parity-design.md:52:- prompt and lyrics entry +./docs/archive/plans/2026-04-25-commercial-generation-workspace-design.md:43:- Negative prompt. +./docs/archive/plans/2026-04-25-commercial-generation-workspace-design.md:69:- Empty prompt/lyrics validation is visible but calm. +./docs/archive/plans/2026-04-25-commercial-generation-workspace-implementation.md:44:2. Make prompt the dominant horizontal textarea. +./docs/archive/plans/Development_Plan.md:197: prompt TEXT, +./docs/archive/plans/Development_Plan.md:232:- History can be searched by prompt/lyrics. +./docs/archive/plans/Development_Plan.md:331: "prompt": "", +./docs/archive/plans/Development_Plan.md:486:- Backend-impacting changes prompt restart. +./docs/archive/plans/Development_Plan.md:666:- [ ] Empty prompt and lyrics blocked. +./docs/archive/plans/Development_Plan.md:713:Add the GenerationRequest, GenerationState, AppSettings, GenerationRecord, and AppError types. Implement validation for prompt/lyrics, duration, BPM, seed, and output format. Wire the generation form to these validators and show inline errors. +./docs/archive/plans/Development_Plan.md:780:- NDJSON streaming for agent workflows. +./docs/cli.md:9:Generate music from a prompt. +./docs/cli.md:35:Enhance a prompt via the ACE-Step format_input API. Returns the enhanced caption together with extracted BPM, key, time signature, duration, language, and lyrics. +./docs/cli.md:155:Manage the generation lifecycle — list, cancel, resume, or discard active tasks. +./docs/cli.md:163:openloop generation resume abc12345 # resume an active generation task +./docs/cli.md:227:`openloop` is designed for AI coding agents to compose with. Paired with **[Remotion](https://github.com/remotion-dev/remotion)** (programmatic React video rendering) or **[HyperFrames](https://github.com/heygen-com/hyperframes)** (HTML-to-video for agents), an agent can build fully automated video workflows. +./docs/cli.md:229:An agent would typically: +./docs/implementation-status.md:20:- Active task recovery: resume or discard pending generation tasks. +./docs/implementation-status.md:22:- Random prompt inspiration (dice button). +./docs/implementation-status.md:28:- NDJSON streaming for agent pipeline integration. +./docs/OpenLoop_PRD.md:67:| 注重隐私的用户 | 避免上传歌词、prompt、音频素材 | 默认本地推理、本地历史、本地文件 | +./docs/OpenLoop_PRD.md:171:| 风格描述 | `prompt` | string | empty | 建议必填,但允许歌词驱动 | +./docs/OpenLoop_PRD.md:311:用户输入 prompt/lyrics/duration 等参数后生成音频文件。 +./docs/OpenLoop_PRD.md:349:- 历史记录保存 prompt、lyrics、duration、seed、model、output_path。 +./docs/OpenLoop_PRD.md:356:| prompt + lyrics | 两者至少一个非空 | +./docs/OpenLoop_PRD.md:426:- 文件名不得直接包含完整 prompt,避免隐私泄露和非法字符问题。 +./docs/OpenLoop_PRD.md:450: prompt TEXT, +./docs/OpenLoop_PRD.md:475:- 支持搜索 prompt/lyrics。 +./docs/OpenLoop_PRD.md:570:- 不上传 prompt、lyrics、生成音频。 +./docs/OpenLoop_PRD.md:622:- 时间、时长、简短 prompt。 +./docs/plans/2026-04-28-acestep-feature-benefits.md:7:- AI prompt enhancement calls the local ACE-Step `/format_input` endpoint through Tauri IPC. +./docs/plans/2026-04-28-acestep-feature-benefits.md:9:- Prompt inspiration uses a local JSON example library instead of a hardcoded prompt. +./docs/plans/2026-04-28-ui-review.md:10:- ToastProvider is mounted and app actions publish transient localized toasts for settings, prompt enhancement, export/copy, delete, and generation outcomes. +./docs/plans/2026-05-13-cli-backend-vnext.md:221:| `enhance_prompt` | `openloop enhance ""` 或 `openloop generation enhance`(与 IPC `enhance_prompt` 命名对齐;不使用 `format` 避免与 `--format` 音频标志混淆) | +./docs/plans/2026-05-13-cli-backend-vnext.md:223:| `resume_generation_task` | `openloop generation resume ` | +./docs/plans/2026-05-14-v1-readiness-master-plan.md:37:| **P14a** Project 概念(核心) | Project 数据模型、侧栏分组 | 摆脱「单 prompt 单 clip」工具感 | +./docs/plans/2026-05-14-v1-readiness-master-plan.md:249:│ │ └── tasks.ts ← activeTasks、resume、discard ← 仍内嵌在 generation.ts,未独立 slice +./docs/plans/2026-05-14-v1-readiness-master-plan.md:337:- [x] 4.3.2 提交成功时 push;UI 在 prompt 输入框上方加一行 chip 列表(最近 6 条),可点击填入。——已实现于 `Header.tsx:39-52` +./docs/plans/2026-05-14-v1-readiness-master-plan.md:338:- [x] 4.3.3 Dice 按钮旁加 ⭐ 图标,把当前 prompt 加到 `favoritePrompts`,独立列表(上限 50)。——已实现于 `Header.tsx:40-41` +./docs/plans/2026-05-14-v1-readiness-master-plan.md:345:- [ ] 4.4.3 i18n 中文版同步翻译每个示例的中文描述(不替换 prompt 本身,只翻译类目)。 +./docs/plans/2026-05-14-v1-readiness-master-plan.md:412: - Copy as data URL(agent 友好) +./docs/plans/2026-05-14-v1-readiness-master-plan.md:438:- [x] 8.3 加 "Skip and try a demo prompt" 路径:跳过模型下载,进入"演示模式"——当前无 bundled 音频,先进入可关闭的 Demo mode banner。 +./docs/plans/2026-05-14-v1-readiness-master-plan.md:441:**Phase 8 验收:** 首次启动流程可完整走通且显示 ETA;"Skip and try a demo prompt" 路径跳过下载后能播放 bundled 示例音频。 +./docs/plans/2026-05-14-v1-readiness-master-plan.md:454: - **不包含** prompt、lyrics、文件路径以外的内容(与 README 隐私段一致) +./docs/plans/2026-05-14-v1-readiness-master-plan.md:470:**Phase 9 验收:** "Copy diagnostics" 按钮输出 JSON 不包含 prompt/lyrics;GitHub issue 链接可打开且预填 diagnostics;错误 banner 可展开详情且 Retry 按钮可用。 +./docs/plans/2026-05-14-v1-readiness-master-plan.md:536: 2. 已设置 → 写 prompt → 提交 → 看见结果(mock backend) +./docs/plans/2026-05-14-v1-readiness-master-plan.md:541:- [ ] 12.2.1 `scripts/bench.mjs`:固定 5 条 prompt × 3 次生成,记录耗时 / 内存峰值。 +./docs/plans/2026-05-14-v1-readiness-master-plan.md:598:- [ ] 14b.1.3 文档化"原音频 + 区间 + 新 prompt → 局部替换"工作流。 +./docs/plans/2026-05-14-v1-readiness-master-plan.md:713:本文件可作为后续所有 GSD phase plan 的源 prompt;建议每两周 review 一次并把已完成项标注 `~~strike~~` 或迁移到 `archive/`。 +./docs/plans/2026-05-17-complexity-hotspots-optimization.md:58: - prompt match +./docs/plans/2026-05-17-complexity-hotspots-optimization.md:150:- Manual check: search still finds prompt and lyrics matches. +./docs/plans/2026-05-17-complexity-hotspots-optimization.md:234:- FTS5 search migration for prompt/lyrics. +./docs/plans/2026-05-17-complexity-hotspots-optimization.md:258:- Confirm history search returns expected prompt/lyrics matches. +./docs/plans/2026-05-30-cli-ux-fixes.md:72:**Problem:** After `openloop enhance` prints the enhanced prompt, the backend detach happens silently. No "Backend detached" or similar message. Users may not know the backend is still running. +./docs/plans/2026-05-30-cli-ux-fixes.md:83:2. P1: Duplicate completed event (quick fix, high agent impact) +./docs/release-notes/v0.1.0.md:7:OpenLoop ships with a full-featured CLI built into the same binary. Every GUI operation has a CLI equivalent — designed for scripting, automation, and agent-driven workflows. +./docs/release-notes/v0.1.0.md:10:# Generate music from a text prompt +./docs/release-notes/v0.1.0.md:22:# Stream progress as NDJSON (ideal for agent pipelines) +./docs/release-notes/v0.1.0.md:28:OpenLoop was designed to be **agent-friendly**. Combined with tools like [Remotion](https://github.com/remotion-dev/remotion) (programmatic React video rendering) and [HyperFrames](https://github.com/heygen-com/hyperframes) (HTML-to-video for agents), you can build fully automated AI video pipelines: +./docs/release-notes/v0.1.0.md:34:An AI agent can orchestrate the entire video production chain: generate background music via `openloop run`, render visuals via Remotion, add voiceover — all without touching a GUI. +./docs/release-notes/v0.1.0.md:59:- **NDJSON streaming** — Machine-readable progress output for agent pipelines +./docs/specs/2026-05-04-openloop-cli-design.md:7:Primary use case: agent integration in video production workflows (Remotion/Hyperframes). The agent calls `openloop run "prompt"`, gets back a file path, uses it in the video pipeline. +./docs/specs/2026-05-04-openloop-cli-design.md:143:Generate music. The primary command for agent workflows. +./docs/specs/2026-05-04-openloop-cli-design.md:148:openloop run [FLAGS] +./docs/specs/2026-05-04-openloop-cli-design.md:153:- `prompt` — text description of the music to generate +./docs/specs/2026-05-04-openloop-cli-design.md:252:# JSON mode for agent parsing +./docs/specs/2026-05-04-openloop-cli-design.md:293: Enable reasoning for better prompt understanding. +./docs/specs/2026-05-04-openloop-cli-design.md:416: "prompt": "epic cinematic track", +./docs/specs/2026-05-04-openloop-cli-design.md:559: "prompt": "epic cinematic", +./docs/specs/2026-05-04-openloop-cli-design.md:632:2. In human mode (no `--json`): if `--yes` not set, prompt: "Delete 15 records and their output files? [y/N]" +./docs/specs/2026-05-04-openloop-cli-design.md:633:3. In JSON mode: auto-confirm (no interactive prompt). +``` + +## Owner/status sections + +``` +./docs/cli.md:170:Show unified system status: backend health, model info, active tasks, and device info. +./docs/plans/2026-05-17-complexity-hotspots-optimization.md:6:**Patch status:** Plan only. No code changes in this document. +./docs/plans/2026-05-30-cli-ux-fixes.md:17:**Problem:** A new `AppState` always initializes `BackendManager` with `status: Stopped`. The `status()` method only probes the health endpoint if the current status is already `Healthy`. So `openloop backend status` always reports "stopped" for backends started by a previous CLI invocation or the GUI — even when the backend is actually healthy on the port. +``` + +## History in references + +``` +./CHANGELOG.md:16:- Virtualize history sidebar list with @tanstack/react-virtual +./CHANGELOG.md:121:- **history**: A/B compare, multi-select cap at 2, batch export, drag-out support +./CHANGELOG.md:137:- Mark completed P5.3 P6.4 P7 P8 P9 and P10 items +./CONTEXT.md:3:OpenLoop is a local-first music generation tool for Apple Silicon, powered by a local ACE-Step backend. It has two interfaces — a desktop GUI and a command-line CLI — that share all state: settings, history, models, and the backend process. +./CONTEXT.md:8:The lifecycle for one user generation request from validation through backend execution, recovery, cancellation, and completion. +./CONTEXT.md:11:The persisted result of a completed Generation Task. +./CONTEXT.md:15:The local audio file associated with a completed Generation Record. +./CONTEXT.md:16:_Avoid_: Generation Record, history file +./CONTEXT.md:56:- **History** cleanup is a normal user-facing action, similar to clearing search history. +./CONTEXT.md:61:- **Backend Logs** are diagnostic artifacts with automatic retention, not user-managed history. +./CONTEXT.md:71:- **History** UI should read as generated music history, not as a technical run log. +./CONTEXT.md:81:> **Domain expert:** "No. Users need single-output deletion and clear-all history cleanup." +./CONTEXT.md:88:- "History" was previously used to mean both generated outputs and failed attempts; resolved: **History** is generated music outputs only. +./CONTEXT.md:89:- "Cancelled" was previously treated like a **Generation Record** status; resolved: user cancellation is not part of **History**. +./CONTEXT.md:90:- "Failed" was previously treated like a **Generation Record** status; resolved: backend failure is a run outcome, not a **History** item. +./docs/2026-06-09-cli-clap-refactoring.md:160:6. `openloop completions zsh` produces valid completion script +./docs/2026-06-10-issue-plans-and-evals.md:29:| P1 | #52 | Security | S | None | 5 of 8 items done; remaining URL centralization + network log are trust-boundary work | +./docs/2026-06-10-issue-plans-and-evals.md:34:| P2 | #54 | UX | M | #53 (Phase 4) | Sticky footer is CSS-only; CLI --from-history needs #58 landed first | +./docs/2026-06-10-issue-plans-and-evals.md:69:**Exit criteria:** CLI refactoring committed, all CI green, license badge corrected, history queries use indexes. +./docs/2026-06-10-issue-plans-and-evals.md:191:| `src/app/components/history/HistorySidebar.tsx` | #53, #55, #59, #65 | +./docs/2026-06-10-issue-plans-and-evals.md:209:Close task 1.1.5 in the v1 readiness master plan by ensuring every release note file that references a DMG download includes a consistent, complete Gatekeeper bypass section, and by marking the task as done. The README and release.md already have the content; the gap is that `v0.2.0.md` and `v0.2.1.md` lack Gatekeeper guidance despite shipping DMGs, and there is no shared template or CI check to prevent future release notes from omitting it. +./docs/2026-06-10-issue-plans-and-evals.md:309:4. JSON mode emits exactly one completed event per variation +./docs/2026-06-10-issue-plans-and-evals.md:317:Phase 1: Backend detach (1 test, 1 line). Phase 2: Backend status discovery (2 tests, ~15 lines). Phase 3: Duplicate completed event suppression (1 test, ~5 lines). Phase 4: Non-TTY progress output (2 tests, ~10 lines). Phase 5: HTTP client timeout audit (1 test, constant changes). Phase 6: Models list manifest sync (1 test, ~20 lines). Phase 7: Enhance detach message (1 test, 2 lines). +./docs/2026-06-10-issue-plans-and-evals.md:321:**Effort:** S (1-2 days). All 7 items are already implemented in the working tree. Work remaining is writing 7-8 missing integration tests. +./docs/2026-06-10-issue-plans-and-evals.md:460:#### Issue #54: Main Form UX: sticky footer, CLI --from-history, dice categories +./docs/2026-06-10-issue-plans-and-evals.md:464:Sticky footer with gradient fade, CLI `--from-history ` for replaying generations, dice button long-press for category menu. +./docs/2026-06-10-issue-plans-and-evals.md:470:Phase 1: Sticky Footer + Gradient (CSS-only). Phase 2: CLI `--from-history` (Rust backend). Phase 3: Dice Long-Press (frontend). +./docs/2026-06-10-issue-plans-and-evals.md:490:**Files to Touch:** `src/app/components/player/ComparePlayer.tsx` (new), `src/app/components/player/PlaybackBar.tsx`, `src/app/components/history/HistorySidebar.tsx`, `src/app/lib/store/slices/history.ts`, `src/app/lib/app-shortcuts.ts`, `src/locales/en.json`, `src/locales/zh-CN.json`, `tests/unit/compare-player.test.tsx` (new) +./docs/2026-06-10-issue-plans-and-evals.md:514:#### Issue #59: Performance optimization: history DB queries, backend-driven search +./docs/2026-06-10-issue-plans-and-evals.md:524:Phase 0: Baseline tests. Phase 1: Indexed and bounded history queries. Phase 2: History row membership sets. Phase 3: CLI prefix lookup without full history load. Phase 4: Backend-driven history search. Phase 5: CLI list command uses database limit. +./docs/2026-06-10-issue-plans-and-evals.md:526:**Files to Touch:** `src-tauri/migrations/005_history_indexes.sql` (new), `src-tauri/src/services/db.rs`, `src-tauri/src/services/history.rs`, `src-tauri/src/commands/history.rs`, `src-tauri/src/cli/list.rs`, `src-tauri/src/cli/delete.rs`, `src-tauri/src/cli/files.rs`, `src/app/lib/api.ts`, `src/app/lib/store/slices/history.ts`, `src/app/components/history/SearchBox.tsx`, `src/app/components/history/HistorySidebar.tsx` +./docs/2026-06-10-issue-plans-and-evals.md:602:Users configure an ordered list of mirror URLs. Downloads fail over automatically. SHA256 verification runs after every completed file. +./docs/2026-06-10-issue-plans-and-evals.md:670:**Files to Touch:** `src-tauri/migrations/005_add_projects.sql` (new), `src-tauri/src/models/project.rs` (new), `src-tauri/src/models/generation.rs`, `src-tauri/src/services/db.rs`, `src-tauri/src/commands/project.rs` (new), `src-tauri/src/commands/history.rs`, `src-tauri/src/cli/cli.rs`, `src-tauri/src/cli/run.rs`, `src-tauri/src/cli/list.rs`, `src/app/lib/types.ts`, `src/app/lib/api.ts`, `src/app/lib/store/types.ts`, `src/app/lib/store/slices/projects.ts` (new), `src/app/components/history/HistorySidebar.tsx`, `CONTEXT.md` +./docs/2026-06-10-issue-plans-and-evals.md:758:Phase 1: Data model and backend persistence. Phase 2: Apply profile and rename. Phase 3: Migration from v0.1 settings. Phase 4: CLI subcommand group. Phase 5: Tauri IPC commands. Phase 6: Frontend types, API layer, and store slice. Phase 7: Frontend UI -- Profile management section. Phase 8: Generation history records profile name. +./docs/adr/0001-history-represents-generated-outputs.md:3:OpenLoop history represents generated music outputs, not every generation attempt. Completed tasks create history entries tied to local output files; failed and cancelled tasks remain current-run outcomes so users can adjust settings and retry without mixing playable outputs with non-file attempts. This keeps the beginner-facing history model simple: deleting history deletes generated outputs, with explicit confirmation and affected counts. +./docs/adr/0002-cli-gui-shared-service-layer.md:15:CLI and GUI share all state: the same SQLite database, the same ACE-Step backend process (via health check on the configured port), the same settings, and the same generation history. +./docs/adr/0002-cli-gui-shared-service-layer.md:19:- **Positive**: Single binary, single source of truth. CLI tasks appear in GUI history. Settings changed via CLI affect GUI. No synchronization needed — they share the same SQLite file. +./docs/agents/domain.md:21:│ ├── 0001-history-represents-generated-outputs.md +./docs/archive/plans/2026-04-24-openkara-shell-parity-design.md:42:- generation history +./docs/archive/plans/2026-04-24-openkara-shell-parity-design.md:44:- quick batch or utility actions relevant to generation history +./docs/archive/plans/2026-04-24-openkara-shell-parity-implementation.md:23:2. Preserve any OpenLoop-only utility classes still needed by generation/preview/history content. +./docs/archive/plans/2026-04-24-openkara-shell-parity-implementation.md:68:2. Mount OpenLoop history in the sidebar rail. +./docs/archive/plans/2026-04-24-openkara-shell-parity-implementation.md:83:- Modify: `src/app/components/history/HistorySidebar.tsx` +./docs/archive/plans/2026-04-24-openkara-shell-parity-implementation.md:89:2. Reframe history, generation, and preview to fit the OpenKara shell roles. +./docs/archive/plans/2026-04-24-openkara-shell-parity-implementation.md:129:2. Replace library steps with OpenLoop-specific introduction, device check, backend/model prep, path configuration, and completion. +./docs/archive/plans/2026-04-25-commercial-generation-workspace-design.md:11:The current right-side generation drawer is removed. It competes with the history sidebar and compresses the main stage. The new layout uses one left history rail and one central workspace. +./docs/archive/plans/Development_Plan.md:47: history/ +./docs/archive/plans/Development_Plan.md:69: history.rs +./docs/archive/plans/Development_Plan.md:110: - Left history column. +./docs/archive/plans/Development_Plan.md:160:- State machine can handle `idle → validating → running → completed/failed`. +./docs/archive/plans/Development_Plan.md:175:Add local persistence for settings, generation history, and backend events. +./docs/archive/plans/Development_Plan.md:182:4. Implement Tauri commands for settings and history. [x] +./docs/archive/plans/Development_Plan.md:183:5. Implement frontend history panel. [x] +./docs/archive/plans/Development_Plan.md:231:- Mock generation records appear in history. +./docs/archive/plans/Development_Plan.md:239:feat: add sqlite settings and generation history +./docs/archive/plans/Development_Plan.md:377:5. Handle history creation and output directory saving. [x] +./docs/archive/plans/Development_Plan.md:390: | { type: "completed"; generationId: string; outputPath: string } +./docs/archive/plans/Development_Plan.md:645: history: GenerationRecord[]; +./docs/archive/plans/Development_Plan.md:689:- [ ] Clear history requires confirmation. +./docs/archive/plans/Development_Plan.md:719:Add SQLite persistence with migrations for settings, generations, and backend_events. Expose Tauri commands for reading/writing settings and listing/inserting/deleting generation records. Wire the history sidebar to real SQLite data. +./docs/archive/plans/Development_Plan.md:749:Add an audio player for local generated files. Implement reveal_in_finder, file_exists, delete_generation_file, and export/copy actions. Add missing-file handling in the history panel. +./docs/archive/plans/Development_Plan.md:755:Add a first-run setup wizard with device check, model/output directory selection, backend health check, and completion state. Persist first_run_completed. Allow reopening setup from Settings. +./docs/archive/plans/Development_Plan.md:768:OpenLoop v0.1.0 Alpha shipped with: +./docs/archive/plans/Development_Plan.md:775:- Generation history persists in SQLite with search, load, and delete. +./docs/cli.md:64:Show generation history. +./docs/cli.md:118:Delete all generation history and output files. +./docs/cli.md:232:2. Take the `output_path` from the completed event +./docs/cli.md:246:{"event":"completed","output_path":"/abs/path/track.wav","duration":30.0,"format":"wav"} +./docs/implementation-status.md:12:- SQLite persistence for settings, generation history, and backend events. +./docs/implementation-status.md:18:- Generation history sidebar with search, click-to-load, and delete options. +./docs/OpenLoop_PRD.md:324:| `completed` | 生成成功 | +./docs/OpenLoop_PRD.md:339: → insert history row +./docs/OpenLoop_PRD.md:817:- Write history. +./docs/OpenLoop_PRD.md:818:- Load history into form. +./docs/plans/2026-04-28-acestep-feature-benefits.md:5:The remaining ACE-Step feature backlog has been implemented in the app: +./docs/plans/2026-04-28-acestep-feature-benefits.md:14:Future work should be tracked in a new plan instead of reopening this completed backlog. +./docs/plans/2026-04-28-ui-review.md:5:The remaining UI review backlog has been implemented in the app: +./docs/plans/2026-04-28-ui-review.md:7:- Generation progress now carries structured phases for validation, backend startup, submission, queueing, running, downloading, completion, failure, cancellation, and recovery. +./docs/plans/2026-04-28-ui-review.md:13:Future UI review findings should be tracked in a new plan instead of reopening this completed backlog. +./docs/plans/2026-05-13-cli-backend-vnext.md:205:### 6.6 历史 `commands::history` +``` + +## Archive-folder smells + +``` +./docs/archive +``` + +## Contract files containing journey language + +``` +./CHANGELOG.md:140:- Update README status to v0.2.0 +./CONTEXT.md:89:- "Cancelled" was previously treated like a **Generation Record** status; resolved: user cancellation is not part of **History**. +./CONTEXT.md:90:- "Failed" was previously treated like a **Generation Record** status; resolved: backend failure is a run outcome, not a **History** item. +./docs/2026-06-09-cli-clap-refactoring.md:102:Migrate all remaining: `list`, `delete`, `clear`, `ps`, `stop`, `pull`, `status`, `doctor`, `files`, `setup`. (Note: `setup` is large and contains interactive wizard logic that needs careful handling with clap). +./docs/2026-06-09-cli-clap-refactoring.md:135:| **Rewrite** | `cli/run.rs`, `cli/enhance.rs`, `cli/backend.rs`, `cli/models.rs`, `cli/settings.rs`, `cli/generation.rs`, `cli/files.rs`, `cli/list.rs`, `cli/delete.rs`, `cli/clear.rs`, `cli/ps.rs`, `cli/stop.rs`, `cli/pull.rs`, `cli/status.rs`, `cli/doctor.rs`, `cli/setup.rs` | +./docs/2026-06-10-issue-plans-and-evals.md:166:| CLI refactoring (#58) breaks existing commands | High | Phase 0 commits current state first; each phase compiles independently | #58 | +./docs/2026-06-10-issue-plans-and-evals.md:236:#### Issue #69: README status badge, README_CN sync, CSP ADR finalization +./docs/2026-06-10-issue-plans-and-evals.md:240:All three project READMEs/ADRs are internally consistent: the CSP ADR references current Tauri 2 documentation, both READMEs display an Apache-2.0 license badge, both show the v0.1 Alpha status line, README_CN.md includes the missing Release badge, and the v1 readiness master plan has tasks 1.2.4, 1.5.2, and 1.5.3 checked off. +./docs/2026-06-10-issue-plans-and-evals.md:246:3. README.md contains status line with v0.1 Alpha +./docs/2026-06-10-issue-plans-and-evals.md:247:4. README_CN.md contains status line with v0.1 Alpha (Chinese) +./docs/2026-06-10-issue-plans-and-evals.md:302:Eliminate seven CLI UX defects: backend dying on CLI exit, status unable to find running backends, duplicate JSON events, messy progress on non-TTYs, premature HTTP timeouts, stale model metadata, and silent backend detach in `enhance`. +./docs/2026-06-10-issue-plans-and-evals.md:307:2. backend status discovers externally-running backends +./docs/2026-06-10-issue-plans-and-evals.md:308:3. backend status reports stopped when no backend answers +./docs/2026-06-10-issue-plans-and-evals.md:317:Phase 1: Backend detach (1 test, 1 line). Phase 2: Backend status discovery (2 tests, ~15 lines). Phase 3: Duplicate completed event suppression (1 test, ~5 lines). Phase 4: Non-TTY progress output (2 tests, ~10 lines). Phase 5: HTTP client timeout audit (1 test, constant changes). Phase 6: Models list manifest sync (1 test, ~20 lines). Phase 7: Enhance detach message (1 test, 2 lines). +./docs/2026-06-10-issue-plans-and-evals.md:638:Produce `docs/adr/0005-platform-roadmap.md` classifying each target platform into a support tier, documenting every platform-specific code location, identifying external blockers, and defining a phased rollout plan. +./docs/2026-06-10-issue-plans-and-evals.md:646:**Files to Touch:** `docs/adr/0005-platform-roadmap.md` (new), `src-tauri/tests/platform_inventory.rs` (new), `src-tauri/tests/device_info_cross_platform.rs` (new), `src-tauri/tests/model_bootstrap_cross_platform.rs` (new), `src-tauri/tests/file_operations_cross_platform.rs` (new), `src-tauri/tests/model_descriptors_cross_platform.rs` (new), `src-tauri/src/services/device.rs`, `src-tauri/src/services/model_bootstrap.rs`, `src-tauri/src/commands/files.rs`, `src-tauri/src/commands/settings.rs`, `src-tauri/src/commands/support.rs`, `src-tauri/src/services/model_manager/mod.rs`, `src-tauri/src/platform/` (new module tree) +./docs/adr/0004-network-trust-boundary.md:97:3. **No documented boundary (status quo):** Rejected — risks erosion of +./docs/archive/plans/2026-04-24-openkara-shell-parity-design.md:5:Make OpenLoop feel like a direct sibling product of OpenKara by reusing the same product shell, visual tokens, settings surface, onboarding rhythm, bootstrap status expression, and native menu structure while keeping OpenLoop-specific generation workflows in the main content area. +./docs/archive/plans/2026-04-24-openkara-shell-parity-design.md:90:OpenLoop should use the same top-of-main-content status treatment as OpenKara's `ModelBootstrapBanner`. +./docs/archive/plans/2026-04-25-commercial-generation-workspace-design.md:21:│ │ Current task / result / status / errors │ +./docs/archive/plans/2026-04-25-commercial-generation-workspace-design.md:72:- Local-first behavior remains visible through copy and status. +./docs/archive/plans/2026-04-25-commercial-generation-workspace-implementation.md:24:2. Render status/result content in the main stage. +./docs/archive/plans/Development_Plan.md:213: status TEXT NOT NULL, +./docs/archive/plans/Development_Plan.md:234:- File deletion can be deferred to later phase. +./docs/archive/plans/Development_Plan.md:282:3. Implement start/stop/status/logs commands. [x] +./docs/archive/plans/Development_Plan.md:452:4. Run backend status check. [x] +./docs/archive/plans/Development_Plan.md:519:- README clearly marks Alpha status. +./docs/archive/plans/Development_Plan.md:583: match result.status { +./docs/archive/plans/Development_Plan.md:725:Implement Rust-side device detection for macOS version, architecture, Apple Silicon status, and total memory. Return a recommended profile: unsupported, low-memory, standard, or quality. Show the result in setup/settings. +./docs/archive/plans/Development_Plan.md:737:Implement a local ACE-Step API client in Rust for /health, /v1/models, /release_task, /query_result, and /v1/audio. Parse the ACE-Step wrapper response and normalize status codes. Add unit tests with mocked responses. +./docs/cli.md:101:Show backend process status and active generation tasks. +./docs/cli.md:140:openloop backend status # show backend health and port +./docs/cli.md:168:### `openloop status` +./docs/cli.md:170:Show unified system status: backend health, model info, active tasks, and device info. +./docs/cli.md:173:openloop status +./docs/cli.md:174:openloop status --json +./docs/implementation-status.md:3:> **Last updated:** 2026-06-03 · This file mirrors the implementation status section from the main README and is updated alongside releases. +./docs/implementation-status.md:23:- Model bootstrap system with download progress, ready/failed states, and persistent status banner. +./docs/implementation-status.md:27:- CLI mode with 16 subcommands (run, setup, list, pull, models, ps, delete, clear, stop, enhance, backend, generation, settings, status, doctor, files). +./docs/implementation-status.md:31:- Backend management CLI: `backend provision`, `backend update`, `backend status` subcommands. +./docs/OpenLoop_PRD.md:336: → when status=1, parse result JSON string +./docs/OpenLoop_PRD.md:466: status TEXT NOT NULL, +./docs/plans/2026-05-13-cli-backend-vnext.md:38:| **M3** | [x] 统一遥测输出(人类 / JSON / NDJSON)与 `doctor`/`status` | M0、部分 M1 | +./docs/plans/2026-05-13-cli-backend-vnext.md:105:- `phase`: `backend_check` | `backend_start` | `backend_owned` | `backend_attached` | `backend_ready` | `backend_stop` | `model_check` | `model_download` | `task_submit` | `task_poll` | … +./docs/plans/2026-05-13-cli-backend-vnext.md:124:| `openloop ps` / `openloop status` | 与 `backend_status` 一致的结构化字段 + 活跃任务列表与任务来源(若可区分) | +./docs/plans/2026-05-13-cli-backend-vnext.md:159:| `backend_status` | `openloop backend status`(或并入 `openloop status`) | +./docs/plans/2026-05-13-cli-backend-vnext.md:198:| `get_model_status` | `openloop models status [variant]` | +./docs/plans/2026-05-13-cli-backend-vnext.md:222:| `list_active_generation_tasks` | 并入 `openloop status` / `openloop ps` | +./docs/plans/2026-05-13-cli-backend-vnext.md:242:- **契约测试(M0 完成后开始):** 扩展 `src-tauri/tests/cli_contract.rs`:解析 NDJSON `v:1` 最小集合,验证 `kind` / `ts` / `phase` 字段存在;`backend status --json` 做 schema snapshot。 +./docs/plans/2026-05-13-cli-backend-vnext.md:269:1. **Week 1:** M0 契约 + `backend` 子命令壳 + `status`/`doctor` 骨架 +./docs/plans/2026-05-14-v1-readiness-master-plan.md:16:> 每个 Phase 末尾都有「**P 优先级 / 依赖**」表。可作为后续 `/gsd-plan-phase` 的直接输入。 +./docs/plans/2026-05-14-v1-readiness-master-plan.md:189:- [x] 1.5.2 在 README 顶部加一行 `> **Status:** v0.1 Alpha — macOS Apple Silicon only. Windows / Linux on the roadmap.`——已通过 Status badge 实现(v0.2.1 Alpha)。 +./docs/plans/2026-05-14-v1-readiness-master-plan.md:569:- [ ] 13.4 产出 `docs/adr/0005-platform-roadmap.md`。 +./docs/plans/2026-05-14-v1-readiness-master-plan.md:571:**Phase 13 验收:** 产出 `docs/adr/0005-platform-roadmap.md`,包含各平台可行性结论与建议动作。 +./docs/plans/2026-05-14-v1-readiness-master-plan.md:698:- 推荐用 `/gsd-plan-phase P` 把本文件第 N 章扔给 GSD planner 自动生成 `PLAN.md`。 +./docs/plans/2026-05-14-v1-readiness-master-plan.md:705:- **`docs/plans/2026-05-13-cli-backend-vnext.md`**:~~其 M0–M5 的事件契约 / 后端归属 / 跨进程取消~~。**已实施完毕:** `cli::events` v1 schema、`BackendManager::ownership()`(Owned/Attached/Stopped)、`cancel_requested_at` DB 标志位、`BackendManager::detach()` CLI 用射声明、`exit_code()` 分流、全套 CLI 子命令树(backend/models/generation/files/settings/doctor/status/ps)。P11 只需在其之上扩展 GUI 侧结构化日志与 in-app 查看器。 +./docs/plans/2026-05-14-v1-readiness-master-plan.md:713:本文件可作为后续所有 GSD phase plan 的源 prompt;建议每两周 review 一次并把已完成项标注 `~~strike~~` 或迁移到 `archive/`。 +./docs/plans/2026-05-17-complexity-hotspots-optimization.md:5:**Scope:** Reduce real data-size-sensitive complexity in history, search, CLI prefix lookup, and model status aggregation. +./docs/plans/2026-05-17-complexity-hotspots-optimization.md:6:**Patch status:** Plan only. No code changes in this document. +./docs/plans/2026-05-17-complexity-hotspots-optimization.md:44:| Model pack status aggregation | `src/app/lib/model-packs.ts`, settings/setup UI | Re-filter statuses per pack/variant | Small `O(pack_count * statuses)` | P2 | +./docs/plans/2026-05-17-complexity-hotspots-optimization.md:87: - index completed generated outputs by `status`, output presence, `is_favorite`, and `created_at` +./docs/plans/2026-05-17-complexity-hotspots-optimization.md:88: - keep the index aligned with the current `WHERE status = 'completed' AND COALESCE(output_path, '') <> '' ORDER BY is_favorite DESC, created_at DESC` +./docs/plans/2026-05-17-complexity-hotspots-optimization.md:245:Run narrow checks after each phase, then broader checks before merge: +./docs/plans/2026-05-30-cli-ux-fixes.md:15:## P0 — `backend status` can't discover externally-running backends +./docs/plans/2026-05-30-cli-ux-fixes.md:17:**Problem:** A new `AppState` always initializes `BackendManager` with `status: Stopped`. The `status()` method only probes the health endpoint if the current status is already `Healthy`. So `openloop backend status` always reports "stopped" for backends started by a previous CLI invocation or the GUI — even when the backend is actually healthy on the port. +./docs/plans/2026-05-30-cli-ux-fixes.md:19:**Fix:** In `BackendManager::status()`, when `self.child` is `None` and `self.status` is `Stopped`, probe the configured port's health endpoint. If healthy, transition to `Healthy { port }` (attached ownership). This requires the port to be available — either pass it into `status()` or store it on `BackendManager`. +./docs/plans/2026-05-30-cli-ux-fixes.md:64:**Fix:** On `doctor` and `models list`, cross-check the manifest against the DB setting. If the manifest has entries that the DB doesn't, offer to sync (or auto-sync silently). Alternatively, always read from the manifest as the source of truth for download status. +./docs/plans/2026-05-30-cli-ux-fixes.md:82:1. P0: `backend start` detach + `backend status` discovery (these are closely related) +./docs/release-notes/v0.2.1.md:13:- **Documentation**: Updated implementation status, PRD, README, and release docs to accurately reflect v0.2.0. +./docs/specs/2026-05-04-openloop-cli-design.md:452:3. Save model status to settings. +./docs/specs/2026-05-04-openloop-cli-design.md:502: { "variant": "lite", "size_gb": 8, "status": "downloaded", "active": false }, +./docs/specs/2026-05-04-openloop-cli-design.md:503: { "variant": "turbo", "size_gb": 16, "status": "downloaded", "active": true }, +./docs/specs/2026-05-04-openloop-cli-design.md:507: "status": "not_downloaded", +./docs/specs/2026-05-04-openloop-cli-design.md:517:Show backend status and active generation tasks. +./docs/specs/2026-05-04-openloop-cli-design.md:560: "status": "running", +./docs/specs/2026-05-04-openloop-cli-design.md:705: ps Show backend status +./docs/specs/2026-05-04-openloop-cli-design.md:852:- Update implementation-status.md +./docs/specs/event-schema.md:30:Emitted by `backend status/start/stop/restart --json`. The lifecycle envelope fields are merged into the backend status JSON object (single NDJSON line). +./docs/specs/event-schema.md:37: "phase": "healthy", +./docs/specs/event-schema.md:46:| `phase` | string | `starting`, `healthy`, `stopped`, `failed` | +``` + +## Suggested pass + +1. Delete completed plans, phase docs, handoffs, archive folders, and duplicate implementation docs. +2. Move future work into the selected issue tracker. +3. Rewrite useful human docs into current-state guides. +4. Keep contracts focused on commands, payloads, events, schemas, endpoints, and env vars. diff --git a/.scratch/stale-docs-cleanup-report.md b/.scratch/stale-docs-cleanup-report.md new file mode 100644 index 0000000..930f33e --- /dev/null +++ b/.scratch/stale-docs-cleanup-report.md @@ -0,0 +1,56 @@ +# Stale Docs Cleanup Report + +## Section A — Issue tracker + +Selected: **GitHub Issues** +Evidence: `git remote` → `github.com/thedavidweng/OpenLoop`, `gh` available +Workflow: `gh issue create` + +## Deleted + +| File | Reason | +| --- | --- | +| `docs/archive/plans/Development_Plan.md` | Completed plan — all phases marked done, v0.1.0 shipped | +| `docs/archive/plans/2026-04-24-openkara-shell-parity-design.md` | Archive debris | +| `docs/archive/plans/2026-04-24-openkara-shell-parity-implementation.md` | Archive debris | +| `docs/archive/plans/2026-04-25-commercial-generation-workspace-design.md` | Archive debris | +| `docs/archive/plans/2026-04-25-commercial-generation-workspace-implementation.md` | Archive debris | +| `docs/plans/2026-04-28-acestep-feature-benefits.md` | "Status: Completed on 2026-04-29" | +| `docs/plans/2026-04-28-ui-review.md` | "Status: Completed on 2026-04-29" | +| `docs/plans/2026-05-13-cli-backend-vnext.md` | All milestones [x], implementation done | +| `docs/plans/2026-05-14-v1-readiness-master-plan.md` | Massive completed master plan (713 lines) | +| `docs/plans/2026-05-17-complexity-hotspots-optimization.md` | "Plan only" — work covered by issue #59 | +| `docs/plans/2026-05-30-cli-ux-fixes.md` | Plan, issue #57 CLOSED | +| `docs/2026-06-09-cli-clap-refactoring.md` | Untracked handoff doc; clap files already exist in tree | +| `docs/2026-06-10-issue-plans-and-evals.md` | Untracked 700-line plan; all 29 items have matching GitHub issues | + +## Rewritten + +| File | Change | +| --- | --- | +| `docs/implementation-status.md` | Removed "Planned after v0.2" list; replaced with link to GitHub issues | +| `docs/superpowers/specs/2026-06-07-testing-strategy-design.md` | Removed "Status: Draft"; trimmed stale problem statement | +| `docs/specs/2026-05-04-openloop-cli-design.md` | Removed completed Phase 6 checklist items (update implementation-status.md, update PRD, update CONTEXT.md) | +| `README.md` | "Planned" section: replaced feature list with issue tracker link. "Known Limitations": replaced forward-looking Repaint language with issue link | + +## Kept + +| File | Reason | +| --- | --- | +| `docs/adr/0001–0004` | Durable architectural decision records | +| `docs/agents/domain.md` | Agent navigation guide (current, stable) | +| `docs/agents/issue-tracker.md` | GitHub workflow reference | +| `docs/agents/triage-labels.md` | Label mapping contract | +| `docs/cli.md` | Human CLI usage guide | +| `docs/OpenLoop_PRD.md` | Product requirements (marked "Implemented") | +| `docs/privacy.md` | Privacy policy (bilingual, current) | +| `docs/release.md` | Release checklist and Gatekeeper guide | +| `docs/release-notes/v0.1.0.md, v0.2.0.md, v0.2.1.md` | Human-readable release history | +| `docs/specs/event-schema.md` | Frozen NDJSON event contract | +| `docs/specs/2026-05-04-openloop-cli-design.md` | CLI design spec (rewritten to remove stale checklists) | +| `docs/testing.md` | QA procedures, manual checklist, regression triggers | + +## Remaining decisions + +- `docs/implementation-status.md` duplicates README content. Consider deleting and keeping README as single source of truth. +- `docs/OpenLoop_PRD.md` says "主要实现对象: Codex / Coding Agent / Human Developer" — agent-facing language in a human doc. Low priority to fix. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d9d7e26..5190cf3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,7 @@ Thanks for your interest in contributing. ```bash git clone https://github.com/thedavidweng/OpenLoop.git cd OpenLoop +mise install # install tools pinned in mise.toml pnpm install ``` diff --git a/docs/screenshots/01-initial.png b/docs/screenshots/01-initial.png new file mode 100644 index 0000000000000000000000000000000000000000..adc940ef469d70b5a80db3d58f87bf76764164db GIT binary patch literal 41533 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 z")[0]; // everything before first closing div +check( + "5. README_CN.md has Release badge", + /img\.shields\.io.*release/i.test(cnBadgeBlock) || /\[!\[Release\]/i.test(cnBadgeBlock), + "missing Release badge in header badge block" +); + +// 6. CSP ADR references Tauri v2 security docs +check( + "6. CSP ADR references Tauri v2 (not v1)", + /tauri\.app\/v1/.test(cspAdr) === false && /tauri\.app.*v2|v2\.tauri\.app/.test(cspAdr) === true, + "still references tauri.app/v1" +); + +// 7. v1 plan tasks 1.2.4, 1.5.2, 1.5.3 are checked off +const task124 = /\[x\]\s*1\.2\.4/.test(v1Plan); +const task152 = /\[x\]\s*1\.5\.2/.test(v1Plan); +const task153 = /\[x\]\s*1\.5\.3/.test(v1Plan); +check("7a. v1 plan task 1.2.4 is checked", task124); +check("7b. v1 plan task 1.5.2 is checked", task152); +check("7c. v1 plan task 1.5.3 is checked", task153); + +// 8. License section text says Apache-2.0 +check( + "8a. README.md License section says Apache-2.0", + /## License[\s\S]*?Apache-2\.0/i.test(readme) && /## License[\s\S]*?MIT/.test(readme) === false, + "License section still mentions MIT" +); +check( + "8b. README_CN.md License section says Apache-2.0", + /## 许可证[\s\S]*?Apache-2\.0/i.test(readmeCN) && /## 许可证[\s\S]*?MIT/.test(readmeCN) === false, + "License section still mentions MIT" +); + +// Report +console.log("\n=== Issue #69 Validation ===\n"); +for (const line of checks) console.log(line); +console.log(`\n ${passCount} passed, ${failCount} failed\n`); +process.exit(failCount > 0 ? 1 : 0); diff --git a/scripts/validate-release-notes.mjs b/scripts/validate-release-notes.mjs new file mode 100644 index 0000000..416e8a6 --- /dev/null +++ b/scripts/validate-release-notes.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +/** + * validate-release-notes — Check that DMG release notes contain a Gatekeeper + * bypass section with both methods (right-click Open and xattr -cr) and a + * Homebrew alternative. + * + * Exit codes: + * 0 — all checked release notes pass + * 1 — one or more checks failed + */ + +import { readFileSync, readdirSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = resolve(__dirname, ".."); +const RELEASE_NOTES_DIR = resolve(PROJECT_ROOT, "docs/release-notes"); + +const CHECKS = [ + { + id: "gatekeeper-heading", + desc: "Gatekeeper section heading present", + test: (content) => /#{1,4}\s*[Gg]atekeeper/.test(content), + }, + { + id: "right-click-open", + desc: "Right-click Open bypass method present", + test: (content) => + /[Rr]ight-?[Cc]lick.*[Oo]pen|[Rr]ight-?[Cc]lick.*[Oo]pen/.test(content), + }, + { + id: "xattr-cr", + desc: "xattr -cr bypass method present", + test: (content) => /xattr\s+-cr/.test(content), + }, + { + id: "homebrew-mention", + desc: "Homebrew alternative mentioned", + test: (content) => /[Hh]omebrew|brew\s+(tap|install|upgrade)/.test(content), + }, +]; + +function main() { + let files; + try { + files = readdirSync(RELEASE_NOTES_DIR).filter((f) => f.endsWith(".md")); + } catch { + console.error(`✗ Cannot read directory: ${RELEASE_NOTES_DIR}`); + process.exit(1); + } + + if (files.length === 0) { + console.log("⚠ No release note files found — nothing to check."); + process.exit(0); + } + + let failures = 0; + + for (const file of files.sort()) { + const filePath = resolve(RELEASE_NOTES_DIR, file); + const content = readFileSync(filePath, "utf-8"); + + for (const check of CHECKS) { + if (!check.test(content)) { + console.error(`✗ ${file}: ${check.desc} [${check.id}]`); + failures++; + } + } + } + + if (failures === 0) { + console.log( + `✓ ${files.length} release note(s) pass all Gatekeeper checks.` + ); + process.exit(0); + } + + console.error(`\n${failures} check(s) failed.`); + process.exit(1); +} + +main(); diff --git a/src-tauri/migrations/005_history_indexes.sql b/src-tauri/migrations/005_history_indexes.sql new file mode 100644 index 0000000..bfbdf4d --- /dev/null +++ b/src-tauri/migrations/005_history_indexes.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS idx_generations_created_at ON generations (created_at DESC); diff --git a/src-tauri/src/cli/cli.rs b/src-tauri/src/cli/cli.rs new file mode 100644 index 0000000..f73c8d1 --- /dev/null +++ b/src-tauri/src/cli/cli.rs @@ -0,0 +1,1005 @@ +use clap::{Parser, Subcommand, Args, ValueEnum}; + +use crate::models::settings::ModelVariant; + +#[derive(Parser)] +#[command(name = "openloop", about = "AI music generation", version)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, + + #[command(flatten)] + pub global: GlobalArgs, +} + +#[derive(Args)] +pub struct GlobalArgs { + /// Output in JSON/NDJSON format + #[arg(long, global = true)] + pub json: bool, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Generate music from a text prompt + Run(RunArgs), + /// Enhance a prompt via ACE-Step format_input + Enhance(EnhanceArgs), + /// Manage the local ACE-Step backend + Backend { + #[command(subcommand)] + command: BackendCommand, + }, + /// Manage model variants + Models { + #[command(subcommand)] + command: Option, + }, + /// View and modify application settings + Settings { + #[command(subcommand)] + command: Option, + }, + /// Manage generation lifecycle + Generation { + #[command(subcommand)] + command: GenerationCommand, + }, + /// Show generation history + List(ListArgs), + /// Delete a generation record + Delete(DeleteArgs), + /// Clear all generation history + Clear(ClearArgs), + /// Show backend status and active generation tasks + Ps, + /// Cancel an ongoing generation + Stop(StopArgs), + /// Download a model variant + Pull(PullArgs), + /// Show unified system status + Status, + /// Run environment diagnostics + Doctor, + /// File and output management + Files { + #[command(subcommand)] + command: FilesCommand, + }, + /// Configure default settings + Setup(SetupArgs), + /// Generate shell completion scripts + #[command(hide = true)] + Completions { + /// Shell to generate completions for + #[arg(value_enum)] + shell: Shell, + }, +} + +// --------------------------------------------------------------------------- +// Run & Enhance +// --------------------------------------------------------------------------- + +#[derive(Args)] +pub struct RunArgs { + /// The text prompt for music generation + pub prompt: String, + + /// Model variant (lite/turbo/pro) + #[arg(short = 'm', long)] + pub model: Option, + + /// Duration in seconds (10-600) + #[arg(short = 'd', long)] + pub duration: Option, + + /// Audio format (wav/mp3/flac/ogg) + #[arg(short = 'f', long)] + pub format: Option, + + /// Output file path + #[arg(short = 'o', long)] + pub output: Option, + + /// Lyrics text + #[arg(short = 'l', long)] + pub lyrics: Option, + + /// BPM (30-300) + #[arg(long)] + pub bpm: Option, + + /// Key and scale (e.g., "C major") + #[arg(long)] + pub key: Option, + + /// Inference steps + #[arg(long)] + pub steps: Option, + + /// Guidance scale + #[arg(long)] + pub guidance: Option, + + /// Random seed + #[arg(long)] + pub seed: Option, + + /// Number of variations (1-4) + #[arg(short = 'v', long)] + pub variations: Option, + + /// Disable thinking mode + #[arg(long)] + pub no_thinking: bool, +} + +#[derive(Args)] +pub struct EnhanceArgs { + /// The text prompt to enhance + pub prompt: String, + + /// Duration in seconds (10-600) + #[arg(short = 'd', long)] + pub duration: Option, + + /// Include lyrics in the request + #[arg(short = 'l', long)] + pub lyrics: Option, +} + +// --------------------------------------------------------------------------- +// Backend (8 sub-subcommands) +// --------------------------------------------------------------------------- + +#[derive(Subcommand)] +pub enum BackendCommand { + /// Show backend status + Status, + /// Start the backend + Start, + /// Stop the backend + Stop, + /// Restart the backend + Restart, + /// Show backend logs + Logs { + /// Open logs directory in Finder + #[arg(long)] + open: bool, + }, + /// Clear backend cache + ClearCache, + /// Download and install the backend + Provision, + /// Update the backend to the latest version + Update, +} + +// --------------------------------------------------------------------------- +// Models (6 sub-subcommands) +// --------------------------------------------------------------------------- + +#[derive(Subcommand)] +pub enum ModelsCommand { + /// List model variants and download status + List, + /// Download a model variant + Download { + /// Model variant + #[arg(value_enum)] + variant: ModelVariantArg, + }, + /// Delete a model variant + Delete { + /// Model variant + #[arg(value_enum)] + variant: ModelVariantArg, + }, + /// Cancel an in-progress model download + Cancel { + /// Model variant + #[arg(value_enum)] + variant: ModelVariantArg, + }, + /// Clear partially downloaded model files + ClearPartial { + /// Model variant + #[arg(value_enum)] + variant: ModelVariantArg, + }, + /// Delete all downloaded models + DeleteAll { + /// Skip confirmation + #[arg(long)] + yes: bool, + }, +} + +#[derive(Clone, ValueEnum)] +pub enum ModelVariantArg { + Lite, + Turbo, + Pro, +} + +impl From for ModelVariant { + fn from(arg: ModelVariantArg) -> Self { + match arg { + ModelVariantArg::Lite => ModelVariant::Lite, + ModelVariantArg::Turbo => ModelVariant::Turbo, + ModelVariantArg::Pro => ModelVariant::Pro, + } + } +} + +// --------------------------------------------------------------------------- +// Settings (4 sub-subcommands) +// --------------------------------------------------------------------------- + +#[derive(Subcommand)] +pub enum SettingsCommand { + /// Show current settings (default) + #[command(alias = "show")] + Get, + /// Set a setting value + Set { + /// Setting key + key: String, + /// Setting value + value: String, + }, + /// Reset all settings to defaults + Reset { + /// Skip confirmation + #[arg(long)] + yes: bool, + }, + /// Show settings file paths + Paths, +} + +// --------------------------------------------------------------------------- +// Generation (4 sub-subcommands) +// --------------------------------------------------------------------------- + +#[derive(Subcommand)] +pub enum GenerationCommand { + /// List active generation tasks + List, + /// Cancel generation task(s) + Cancel { + /// Task ID to cancel (omit to cancel all) + id: Option, + /// Also stop the backend + #[arg(long)] + kill_backend: bool, + }, + /// Resume a paused generation + Resume { + /// Task ID to resume + id: String, + }, + /// Discard a generation result + Discard { + /// Task ID to discard + id: String, + /// Skip confirmation + #[arg(long)] + yes: bool, + }, +} + +// --------------------------------------------------------------------------- +// Leaf commands +// --------------------------------------------------------------------------- + +#[derive(Args)] +pub struct ListArgs { + /// Number of records to show + #[arg(long, default_value = "50")] + pub limit: usize, +} + +#[derive(Args)] +pub struct DeleteArgs { + /// Generation record ID prefix + pub id: String, +} + +#[derive(Args)] +pub struct ClearArgs { + /// Skip confirmation + #[arg(long)] + pub yes: bool, +} + +#[derive(Args)] +pub struct StopArgs { + /// Generation ID to cancel (omit to cancel all) + pub generation_id: Option, + /// Also stop the backend + #[arg(long)] + pub kill_backend: bool, +} + +#[derive(Args)] +pub struct PullArgs { + /// Model variant to download + #[arg(value_enum)] + pub model: ModelVariantArg, + /// Use a mirror source + #[arg(long)] + pub mirror: Option, +} + +#[derive(Args)] +pub struct SetupArgs { + /// Setting key (model, thinking, duration, format, checkForUpdates) + pub key: Option, + /// Setting value + pub value: Option, + + /// Set model variant + #[arg(long)] + pub model: Option, + /// Set thinking mode + #[arg(long)] + pub thinking: Option, + /// Set default duration + #[arg(long)] + pub duration: Option, + /// Set default audio format + #[arg(long)] + pub format: Option, +} + +// --------------------------------------------------------------------------- +// Files (6 sub-subcommands) +// --------------------------------------------------------------------------- + +#[derive(Subcommand)] +pub enum FilesCommand { + /// Reveal a file in Finder + Reveal { + /// File path + path: String, + }, + /// Copy a file + Copy { + /// Source path + src: String, + /// Destination path + dst: String, + }, + /// Check if a file exists + Exists { + /// File path + path: String, + }, + /// Read audio file for a generation + ReadAudio { + /// Generation record ID prefix + id: String, + /// Output path (use - for stdout) + #[arg(short = 'o', long)] + output: Option, + }, + /// Generate a waveform visualization + Waveform { + /// Generation record ID prefix + id: String, + }, + /// Delete the output file for a generation + Unlink { + /// Generation record ID prefix + id: String, + /// Keep the database record + #[arg(long)] + keep_record: bool, + }, +} + +// --------------------------------------------------------------------------- +// Shell completions +// --------------------------------------------------------------------------- + +#[derive(Clone, ValueEnum)] +pub enum Shell { + Bash, + Zsh, + Fish, + PowerShell, + Elvish, +} + +impl From for clap_complete::Shell { + fn from(shell: Shell) -> Self { + match shell { + Shell::Bash => clap_complete::Shell::Bash, + Shell::Zsh => clap_complete::Shell::Zsh, + Shell::Fish => clap_complete::Shell::Fish, + Shell::PowerShell => clap_complete::Shell::PowerShell, + Shell::Elvish => clap_complete::Shell::Elvish, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + // ----------------------------------------------------------------------- + // Run + // ----------------------------------------------------------------------- + + #[test] + fn parse_run_with_prompt_only() { + let cli = Cli::try_parse_from(["openloop", "run", "upbeat electronic track"]).unwrap(); + match cli.command { + Commands::Run(args) => { + assert_eq!(args.prompt, "upbeat electronic track"); + assert!(args.model.is_none()); + assert!(args.duration.is_none()); + } + _ => panic!("expected Run command"), + } + assert!(!cli.global.json); + } + + #[test] + fn parse_run_with_all_flags() { + let cli = Cli::try_parse_from([ + "openloop", "run", "sad piano", + "--model", "pro", + "--duration", "60", + "--format", "mp3", + "--output", "./sad.mp3", + "--lyrics", "[verse]\\nHello", + "--bpm", "120", + "--key", "C major", + "--steps", "16", + "--guidance", "8.5", + "--seed", "42", + "--variations", "2", + "--no-thinking", + ]) + .unwrap(); + + match cli.command { + Commands::Run(args) => { + assert_eq!(args.prompt, "sad piano"); + assert_eq!(args.model.as_deref(), Some("pro")); + assert_eq!(args.duration, Some(60.0)); + assert_eq!(args.format.as_deref(), Some("mp3")); + assert_eq!(args.output.as_deref(), Some("./sad.mp3")); + assert_eq!(args.lyrics.as_deref(), Some("[verse]\\nHello")); + assert_eq!(args.bpm, Some(120)); + assert_eq!(args.key.as_deref(), Some("C major")); + assert_eq!(args.steps, Some(16)); + assert_eq!(args.guidance, Some(8.5)); + assert_eq!(args.seed, Some(42)); + assert_eq!(args.variations, Some(2)); + assert!(args.no_thinking); + } + _ => panic!("expected Run command"), + } + } + + #[test] + fn parse_run_with_short_flags() { + let cli = Cli::try_parse_from([ + "openloop", "run", "epic cinematic", + "-m", "turbo", + "-d", "120", + "-f", "flac", + "-o", "./output.flac", + "-l", "lyrics here", + "-v", "3", + ]) + .unwrap(); + + match cli.command { + Commands::Run(args) => { + assert_eq!(args.prompt, "epic cinematic"); + assert_eq!(args.model.as_deref(), Some("turbo")); + assert_eq!(args.duration, Some(120.0)); + assert_eq!(args.format.as_deref(), Some("flac")); + assert_eq!(args.output.as_deref(), Some("./output.flac")); + assert_eq!(args.lyrics.as_deref(), Some("lyrics here")); + assert_eq!(args.variations, Some(3)); + } + _ => panic!("expected Run command"), + } + } + + #[test] + fn parse_run_global_json_flag() { + let cli = Cli::try_parse_from(["openloop", "--json", "run", "test"]).unwrap(); + assert!(cli.global.json); + match cli.command { + Commands::Run(args) => assert_eq!(args.prompt, "test"), + _ => panic!("expected Run command"), + } + } + + // ----------------------------------------------------------------------- + // Enhance + // ----------------------------------------------------------------------- + + #[test] + fn parse_enhance_with_prompt_only() { + let cli = Cli::try_parse_from(["openloop", "enhance", "warm piano"]).unwrap(); + match cli.command { + Commands::Enhance(args) => { + assert_eq!(args.prompt, "warm piano"); + assert!(args.duration.is_none()); + assert!(args.lyrics.is_none()); + } + _ => panic!("expected Enhance command"), + } + } + + #[test] + fn parse_enhance_with_flags() { + let cli = Cli::try_parse_from([ + "openloop", "enhance", "upbeat pop", + "--duration", "120", + "--lyrics", "[Verse]\\nHello", + ]) + .unwrap(); + + match cli.command { + Commands::Enhance(args) => { + assert_eq!(args.prompt, "upbeat pop"); + assert_eq!(args.duration, Some(120.0)); + assert_eq!(args.lyrics.as_deref(), Some("[Verse]\\nHello")); + } + _ => panic!("expected Enhance command"), + } + } + + #[test] + fn parse_enhance_with_short_flags() { + let cli = Cli::try_parse_from([ + "openloop", "enhance", "ballad", + "-d", "60", + "-l", "lyrics", + ]) + .unwrap(); + + match cli.command { + Commands::Enhance(args) => { + assert_eq!(args.prompt, "ballad"); + assert_eq!(args.duration, Some(60.0)); + assert_eq!(args.lyrics.as_deref(), Some("lyrics")); + } + _ => panic!("expected Enhance command"), + } + } + + // ----------------------------------------------------------------------- + // Error cases + // ----------------------------------------------------------------------- + + #[test] + fn parse_run_missing_prompt_fails() { + let result = Cli::try_parse_from(["openloop", "run"]); + assert!(result.is_err()); + } + + #[test] + fn parse_enhance_missing_prompt_fails() { + let result = Cli::try_parse_from(["openloop", "enhance"]); + assert!(result.is_err()); + } + + #[test] + fn parse_unknown_subcommand_fails() { + let result = Cli::try_parse_from(["openloop", "bogus"]); + assert!(result.is_err()); + } + + // ----------------------------------------------------------------------- + // Completions + // ----------------------------------------------------------------------- + + #[test] + fn parse_completions_zsh() { + let cli = Cli::try_parse_from(["openloop", "completions", "zsh"]).unwrap(); + match cli.command { + Commands::Completions { shell } => assert!(matches!(shell, Shell::Zsh)), + _ => panic!("expected Completions command"), + } + } + + #[test] + fn parse_completions_bash() { + let cli = Cli::try_parse_from(["openloop", "completions", "bash"]).unwrap(); + match cli.command { + Commands::Completions { shell } => assert!(matches!(shell, Shell::Bash)), + _ => panic!("expected Completions command"), + } + } + + #[test] + fn parse_completions_missing_shell_fails() { + let result = Cli::try_parse_from(["openloop", "completions"]); + assert!(result.is_err()); + } + + // ----------------------------------------------------------------------- + // Backend + // ----------------------------------------------------------------------- + + #[test] + fn parse_backend_status() { + let cli = Cli::try_parse_from(["openloop", "backend", "status"]).unwrap(); + match cli.command { + Commands::Backend { command: BackendCommand::Status } => {} + _ => panic!("expected Backend::Status"), + } + } + + #[test] + fn parse_backend_logs_with_open() { + let cli = Cli::try_parse_from(["openloop", "backend", "logs", "--open"]).unwrap(); + match cli.command { + Commands::Backend { command: BackendCommand::Logs { open } } => assert!(open), + _ => panic!("expected Backend::Logs"), + } + } + + #[test] + fn parse_backend_all_subcommands() { + for sub in ["status", "start", "stop", "restart", "clear-cache", "provision", "update"] { + let result = Cli::try_parse_from(["openloop", "backend", sub]); + assert!(result.is_ok(), "failed to parse: backend {sub}"); + } + } + + // ----------------------------------------------------------------------- + // Models + // ----------------------------------------------------------------------- + + #[test] + fn parse_models_list_default() { + let cli = Cli::try_parse_from(["openloop", "models"]).unwrap(); + match cli.command { + Commands::Models { command: None } => {} + _ => panic!("expected Models with no subcommand"), + } + } + + #[test] + fn parse_models_download_variant() { + let cli = Cli::try_parse_from(["openloop", "models", "download", "pro"]).unwrap(); + match cli.command { + Commands::Models { command: Some(ModelsCommand::Download { variant }) } => { + assert!(matches!(variant, ModelVariantArg::Pro)); + } + _ => panic!("expected Models::Download"), + } + } + + #[test] + fn parse_models_delete_variant() { + let cli = Cli::try_parse_from(["openloop", "models", "delete", "turbo"]).unwrap(); + match cli.command { + Commands::Models { command: Some(ModelsCommand::Delete { variant }) } => { + assert!(matches!(variant, ModelVariantArg::Turbo)); + } + _ => panic!("expected Models::Delete"), + } + } + + #[test] + fn parse_models_delete_all_with_yes() { + let cli = Cli::try_parse_from(["openloop", "models", "delete-all", "--yes"]).unwrap(); + match cli.command { + Commands::Models { command: Some(ModelsCommand::DeleteAll { yes }) } => assert!(yes), + _ => panic!("expected Models::DeleteAll"), + } + } + + // ----------------------------------------------------------------------- + // Settings + // ----------------------------------------------------------------------- + + #[test] + fn parse_settings_get_default() { + let cli = Cli::try_parse_from(["openloop", "settings"]).unwrap(); + match cli.command { + Commands::Settings { command: None } => {} + _ => panic!("expected Settings with no subcommand"), + } + } + + #[test] + fn parse_settings_set_key_value() { + let cli = Cli::try_parse_from(["openloop", "settings", "set", "modelVariant", "pro"]).unwrap(); + match cli.command { + Commands::Settings { command: Some(SettingsCommand::Set { key, value }) } => { + assert_eq!(key, "modelVariant"); + assert_eq!(value, "pro"); + } + _ => panic!("expected Settings::Set"), + } + } + + #[test] + fn parse_settings_reset_with_yes() { + let cli = Cli::try_parse_from(["openloop", "settings", "reset", "--yes"]).unwrap(); + match cli.command { + Commands::Settings { command: Some(SettingsCommand::Reset { yes }) } => assert!(yes), + _ => panic!("expected Settings::Reset"), + } + } + + // ----------------------------------------------------------------------- + // Generation + // ----------------------------------------------------------------------- + + #[test] + fn parse_generation_list() { + let cli = Cli::try_parse_from(["openloop", "generation", "list"]).unwrap(); + match cli.command { + Commands::Generation { command: GenerationCommand::List } => {} + _ => panic!("expected Generation::List"), + } + } + + #[test] + fn parse_generation_cancel_with_id() { + let cli = Cli::try_parse_from(["openloop", "generation", "cancel", "abc123"]).unwrap(); + match cli.command { + Commands::Generation { command: GenerationCommand::Cancel { id, kill_backend } } => { + assert_eq!(id.as_deref(), Some("abc123")); + assert!(!kill_backend); + } + _ => panic!("expected Generation::Cancel"), + } + } + + #[test] + fn parse_generation_cancel_all() { + let cli = Cli::try_parse_from(["openloop", "generation", "cancel"]).unwrap(); + match cli.command { + Commands::Generation { command: GenerationCommand::Cancel { id, .. } } => { + assert!(id.is_none()); + } + _ => panic!("expected Generation::Cancel"), + } + } + + #[test] + fn parse_generation_cancel_kill_backend() { + let cli = Cli::try_parse_from(["openloop", "generation", "cancel", "--kill-backend"]).unwrap(); + match cli.command { + Commands::Generation { command: GenerationCommand::Cancel { kill_backend, .. } } => { + assert!(kill_backend); + } + _ => panic!("expected Generation::Cancel"), + } + } + + // ----------------------------------------------------------------------- + // Leaf commands + // ----------------------------------------------------------------------- + + #[test] + fn parse_list_default_limit() { + let cli = Cli::try_parse_from(["openloop", "list"]).unwrap(); + match cli.command { + Commands::List(args) => assert_eq!(args.limit, 50), + _ => panic!("expected List"), + } + } + + #[test] + fn parse_list_custom_limit() { + let cli = Cli::try_parse_from(["openloop", "list", "--limit", "50"]).unwrap(); + match cli.command { + Commands::List(args) => assert_eq!(args.limit, 50), + _ => panic!("expected List"), + } + } + + #[test] + fn parse_delete_with_id() { + let cli = Cli::try_parse_from(["openloop", "delete", "a1b2c3"]).unwrap(); + match cli.command { + Commands::Delete(args) => assert_eq!(args.id, "a1b2c3"), + _ => panic!("expected Delete"), + } + } + + #[test] + fn parse_clear() { + let cli = Cli::try_parse_from(["openloop", "clear"]).unwrap(); + match cli.command { + Commands::Clear(args) => assert!(!args.yes), + _ => panic!("expected Clear"), + } + } + + #[test] + fn parse_clear_with_yes() { + let cli = Cli::try_parse_from(["openloop", "clear", "--yes"]).unwrap(); + match cli.command { + Commands::Clear(args) => assert!(args.yes), + _ => panic!("expected Clear"), + } + } + + #[test] + fn parse_ps() { + let cli = Cli::try_parse_from(["openloop", "ps"]).unwrap(); + assert!(matches!(cli.command, Commands::Ps)); + } + + #[test] + fn parse_stop_no_args() { + let cli = Cli::try_parse_from(["openloop", "stop"]).unwrap(); + match cli.command { + Commands::Stop(args) => { + assert!(args.generation_id.is_none()); + assert!(!args.kill_backend); + } + _ => panic!("expected Stop"), + } + } + + #[test] + fn parse_stop_with_id_and_kill_backend() { + let cli = Cli::try_parse_from(["openloop", "stop", "abc", "--kill-backend"]).unwrap(); + match cli.command { + Commands::Stop(args) => { + assert_eq!(args.generation_id.as_deref(), Some("abc")); + assert!(args.kill_backend); + } + _ => panic!("expected Stop"), + } + } + + #[test] + fn parse_pull_pro() { + let cli = Cli::try_parse_from(["openloop", "pull", "pro"]).unwrap(); + match cli.command { + Commands::Pull(args) => { + assert!(matches!(args.model, ModelVariantArg::Pro)); + assert!(args.mirror.is_none()); + } + _ => panic!("expected Pull"), + } + } + + #[test] + fn parse_pull_with_mirror() { + let cli = Cli::try_parse_from(["openloop", "pull", "lite", "--mirror", "https://mirror.example.com"]).unwrap(); + match cli.command { + Commands::Pull(args) => { + assert!(matches!(args.model, ModelVariantArg::Lite)); + assert_eq!(args.mirror.as_deref(), Some("https://mirror.example.com")); + } + _ => panic!("expected Pull"), + } + } + + #[test] + fn parse_status() { + let cli = Cli::try_parse_from(["openloop", "status"]).unwrap(); + assert!(matches!(cli.command, Commands::Status)); + } + + #[test] + fn parse_doctor() { + let cli = Cli::try_parse_from(["openloop", "doctor"]).unwrap(); + assert!(matches!(cli.command, Commands::Doctor)); + } + + // ----------------------------------------------------------------------- + // Files + // ----------------------------------------------------------------------- + + #[test] + fn parse_files_reveal() { + let cli = Cli::try_parse_from(["openloop", "files", "reveal", "/tmp/test.wav"]).unwrap(); + match cli.command { + Commands::Files { command: FilesCommand::Reveal { path } } => { + assert_eq!(path, "/tmp/test.wav"); + } + _ => panic!("expected Files::Reveal"), + } + } + + #[test] + fn parse_files_copy() { + let cli = Cli::try_parse_from(["openloop", "files", "copy", "src.wav", "dst.wav"]).unwrap(); + match cli.command { + Commands::Files { command: FilesCommand::Copy { src, dst } } => { + assert_eq!(src, "src.wav"); + assert_eq!(dst, "dst.wav"); + } + _ => panic!("expected Files::Copy"), + } + } + + #[test] + fn parse_files_read_audio_with_output() { + let cli = Cli::try_parse_from(["openloop", "files", "read-audio", "abc123", "--output", "-"]).unwrap(); + match cli.command { + Commands::Files { command: FilesCommand::ReadAudio { id, output } } => { + assert_eq!(id, "abc123"); + assert_eq!(output.as_deref(), Some("-")); + } + _ => panic!("expected Files::ReadAudio"), + } + } + + #[test] + fn parse_files_unlink_with_keep_record() { + let cli = Cli::try_parse_from(["openloop", "files", "unlink", "abc123", "--keep-record"]).unwrap(); + match cli.command { + Commands::Files { command: FilesCommand::Unlink { id, keep_record } } => { + assert_eq!(id, "abc123"); + assert!(keep_record); + } + _ => panic!("expected Files::Unlink"), + } + } + + // ----------------------------------------------------------------------- + // Setup + // ----------------------------------------------------------------------- + + #[test] + fn parse_setup_no_args() { + let cli = Cli::try_parse_from(["openloop", "setup"]).unwrap(); + match cli.command { + Commands::Setup(args) => { + assert!(args.key.is_none()); + assert!(args.value.is_none()); + assert!(args.model.is_none()); + } + _ => panic!("expected Setup"), + } + } + + #[test] + fn parse_setup_key_value() { + let cli = Cli::try_parse_from(["openloop", "setup", "model", "pro"]).unwrap(); + match cli.command { + Commands::Setup(args) => { + assert_eq!(args.key.as_deref(), Some("model")); + assert_eq!(args.value.as_deref(), Some("pro")); + } + _ => panic!("expected Setup"), + } + } + + #[test] + fn parse_setup_flags() { + let cli = Cli::try_parse_from(["openloop", "setup", "--model", "turbo", "--duration", "60"]).unwrap(); + match cli.command { + Commands::Setup(args) => { + assert_eq!(args.model.as_deref(), Some("turbo")); + assert_eq!(args.duration, Some(60.0)); + } + _ => panic!("expected Setup"), + } + } + + // ----------------------------------------------------------------------- + // ModelVariantArg -> ModelVariant conversion + // ----------------------------------------------------------------------- + + #[test] + fn model_variant_arg_converts_to_model_variant() { + assert_eq!(ModelVariant::from(ModelVariantArg::Lite), ModelVariant::Lite); + assert_eq!(ModelVariant::from(ModelVariantArg::Turbo), ModelVariant::Turbo); + assert_eq!(ModelVariant::from(ModelVariantArg::Pro), ModelVariant::Pro); + } +} diff --git a/src-tauri/src/cli/completions.rs b/src-tauri/src/cli/completions.rs new file mode 100644 index 0000000..4844b22 --- /dev/null +++ b/src-tauri/src/cli/completions.rs @@ -0,0 +1,43 @@ +use clap::Command; +use clap_complete::{generate, Shell}; +use std::io; + +pub fn print_completions(shell: Shell, cmd: &mut Command) { + generate(shell, cmd, "openloop", &mut io::stdout()); +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::{CommandFactory, Parser}; + + #[derive(Parser)] + #[command(name = "test")] + struct TestCli { + #[command(subcommand)] + command: Option, + } + + #[derive(clap::Subcommand)] + enum TestCmd { + Hello, + } + + #[test] + fn generates_zsh_completion_without_panic() { + let mut cmd = TestCli::command(); + let mut buf = Vec::new(); + generate(Shell::Zsh, &mut cmd, "test", &mut buf); + let output = String::from_utf8(buf).unwrap(); + assert!(output.contains("test")); + } + + #[test] + fn generates_bash_completion_without_panic() { + let mut cmd = TestCli::command(); + let mut buf = Vec::new(); + generate(Shell::Bash, &mut cmd, "test", &mut buf); + let output = String::from_utf8(buf).unwrap(); + assert!(output.contains("test")); + } +} diff --git a/src-tauri/src/cli/output.rs b/src-tauri/src/cli/output.rs new file mode 100644 index 0000000..fdd36b0 --- /dev/null +++ b/src-tauri/src/cli/output.rs @@ -0,0 +1,112 @@ +use std::io::IsTerminal; + +use super::events; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputMode { + Json, + Human, +} + +pub struct Output { + mode: OutputMode, + is_tty: bool, +} + +impl Output { + pub fn new(mode: OutputMode) -> Self { + Self { + mode, + is_tty: std::io::stdout().is_terminal(), + } + } + + pub fn mode(&self) -> OutputMode { + self.mode + } + + pub fn is_json(&self) -> bool { + self.mode == OutputMode::Json + } + + /// Print a data line — JSON mode emits raw NDJSON, human mode prints the line. + pub fn data(&self, json_line: &str, human_line: &str) { + match self.mode { + OutputMode::Json => println!("{json_line}"), + OutputMode::Human => println!("{human_line}"), + } + } + + /// Print a success message. + pub fn success(&self, message: &str) { + match self.mode { + OutputMode::Json => {} // success is implicit in JSON mode + OutputMode::Human => events::human_success(message), + } + } + + /// Print an error message (always to stderr). + pub fn error(&self, message: &str) { + events::human_error(message); + } + + /// Print a progress message. + pub fn progress(&self, label: &str, detail: Option<&str>) { + match self.mode { + OutputMode::Json => {} // progress is emitted as events in JSON mode + OutputMode::Human => events::human_progress(label, detail), + } + } + + /// Print an info message. + pub fn info(&self, message: &str) { + match self.mode { + OutputMode::Json => {} + OutputMode::Human => events::human_info(message), + } + } + + /// Print a warning message. + pub fn warn(&self, message: &str) { + match self.mode { + OutputMode::Json => {} + OutputMode::Human => events::human_warn(message), + } + } + + /// Emit a raw JSON line to stdout. + pub fn emit_json(&self, json: &str) { + println!("{json}"); + } + + /// Whether stdout is a TTY (for carriage-return progress). + pub fn is_tty(&self) -> bool { + self.is_tty + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn output_mode_json_reports_correctly() { + let out = Output::new(OutputMode::Json); + assert!(out.is_json()); + assert_eq!(out.mode(), OutputMode::Json); + } + + #[test] + fn output_mode_human_reports_correctly() { + let out = Output::new(OutputMode::Human); + assert!(!out.is_json()); + assert_eq!(out.mode(), OutputMode::Human); + } + + #[test] + fn output_mode_equality() { + assert_eq!(OutputMode::Json, OutputMode::Json); + assert_eq!(OutputMode::Human, OutputMode::Human); + assert_ne!(OutputMode::Json, OutputMode::Human); + } +} diff --git a/src-tauri/src/commands/network.rs b/src-tauri/src/commands/network.rs new file mode 100644 index 0000000..256945e --- /dev/null +++ b/src-tauri/src/commands/network.rs @@ -0,0 +1,50 @@ +use tauri::State; + +use crate::{services::network_log::NetworkEntry, AppState}; + +/// Return the most recent network activity entries (newest first). +#[tauri::command] +pub fn get_network_log(state: State<'_, AppState>, limit: Option) -> Vec { + state.network_log.recent(limit.unwrap_or(100)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::network_log::{NetworkActivityLog, NetworkEntry}; + use chrono::Utc; + use std::sync::Arc; + + #[test] + fn get_network_log_returns_entries_from_state() { + let log = Arc::new(NetworkActivityLog::new()); + log.push(NetworkEntry { + timestamp: Utc::now(), + url: "https://example.com/test".to_owned(), + method: "GET".to_owned(), + status: 200, + }); + + let entries = log.recent(10); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].url, "https://example.com/test"); + assert_eq!(entries[0].status, 200); + } + + #[test] + fn get_network_log_respects_limit_parameter() { + let log = Arc::new(NetworkActivityLog::new()); + for i in 0..10 { + log.push(NetworkEntry { + timestamp: Utc::now(), + url: format!("https://example.com/{i}"), + method: "GET".to_owned(), + status: 200, + }); + } + + let entries = log.recent(5); + assert_eq!(entries.len(), 5); + assert_eq!(entries[0].url, "https://example.com/9"); + } +} diff --git a/src-tauri/src/services/network_log.rs b/src-tauri/src/services/network_log.rs new file mode 100644 index 0000000..53e86b2 --- /dev/null +++ b/src-tauri/src/services/network_log.rs @@ -0,0 +1,138 @@ +use std::sync::Mutex; + +use chrono::{DateTime, Utc}; +use serde::Serialize; + +/// A single outbound network request record. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkEntry { + pub timestamp: DateTime, + pub url: String, + pub method: String, + pub status: u16, +} + +/// Session-scoped log of all outbound HTTP activity. +/// +/// Stored as Tauri state so the frontend can query it. +#[derive(Debug)] +pub struct NetworkActivityLog { + entries: Mutex>, +} + +impl NetworkActivityLog { + pub fn new() -> Self { + Self { + entries: Mutex::new(Vec::new()), + } + } + + /// Record a completed network request with explicit fields. + pub fn push(&self, entry: NetworkEntry) { + if let Ok(mut guard) = self.entries.lock() { + guard.push(entry); + } + } + + /// Convenience: record a request by its URL, HTTP method, and status code. + pub fn record(&self, url: &str, method: &str, status: u16) { + self.push(NetworkEntry { + timestamp: Utc::now(), + url: url.to_owned(), + method: method.to_owned(), + status, + }); + } + + /// Return the most recent entries, newest first. + pub fn recent(&self, limit: usize) -> Vec { + if let Ok(guard) = self.entries.lock() { + let len = guard.len(); + let start = len.saturating_sub(limit); + guard[start..].iter().rev().cloned().collect() + } else { + Vec::new() + } + } + + /// Total number of entries ever recorded. + pub fn len(&self) -> usize { + self.entries.lock().map(|g| g.len()).unwrap_or(0) + } +} + +impl Default for NetworkActivityLog { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn push_and_recent_returns_entries_newest_first() { + let log = NetworkActivityLog::new(); + + log.push(NetworkEntry { + timestamp: Utc::now(), + url: "https://example.com/first".to_owned(), + method: "GET".to_owned(), + status: 200, + }); + log.push(NetworkEntry { + timestamp: Utc::now(), + url: "https://example.com/second".to_owned(), + method: "POST".to_owned(), + status: 201, + }); + + let entries = log.recent(10); + assert_eq!(entries.len(), 2); + // Newest first + assert_eq!(entries[0].url, "https://example.com/second"); + assert_eq!(entries[1].url, "https://example.com/first"); + } + + #[test] + fn recent_respects_limit() { + let log = NetworkActivityLog::new(); + + for i in 0..5 { + log.push(NetworkEntry { + timestamp: Utc::now(), + url: format!("https://example.com/{i}"), + method: "GET".to_owned(), + status: 200, + }); + } + + let entries = log.recent(3); + assert_eq!(entries.len(), 3); + // Should be the last 3, newest first + assert_eq!(entries[0].url, "https://example.com/4"); + assert_eq!(entries[1].url, "https://example.com/3"); + assert_eq!(entries[2].url, "https://example.com/2"); + } + + #[test] + fn recent_returns_empty_on_empty_log() { + let log = NetworkActivityLog::new(); + let entries = log.recent(10); + assert!(entries.is_empty()); + } + + #[test] + fn record_creates_entry_with_current_timestamp() { + let log = NetworkActivityLog::new(); + log.record("https://example.com/api", "GET", 200); + + assert_eq!(log.len(), 1); + let entries = log.recent(1); + assert_eq!(entries[0].url, "https://example.com/api"); + assert_eq!(entries[0].method, "GET"); + assert_eq!(entries[0].status, 200); + } +} diff --git a/src-tauri/src/services/urls.rs b/src-tauri/src/services/urls.rs new file mode 100644 index 0000000..8984897 --- /dev/null +++ b/src-tauri/src/services/urls.rs @@ -0,0 +1,75 @@ +/// Canonical definitions for all outbound URLs used by OpenLoop. +/// +/// This module is the single source of truth for external endpoints. +/// Adding a new outbound URL requires an ADR update (see ADR-0004). + +/// Hugging Face mirror used as the canonical source for ACE-Step weights. +pub const HF_RESOLVE_BASE: &str = "https://huggingface.co"; + +/// GitHub repository for ACE-Step backend. +pub const ACE_STEP_REPO: &str = "ACE-Step/ACE-Step-1.5"; + +/// GitHub API base for fetching release information. +pub fn github_releases_latest_url(repo: &str) -> String { + format!("https://api.github.com/repos/{repo}/releases/latest") +} + +/// GitHub API base for resolving a tag to a commit SHA. +pub fn github_tag_ref_url(repo: &str, tag: &str) -> String { + format!("https://api.github.com/repos/{repo}/git/ref/tags/{tag}") +} + +/// GitHub codeload archive download URL. +pub fn github_archive_url(repo: &str, git_ref: &str) -> String { + format!("https://codeload.github.com/{repo}/zip/{git_ref}") +} + +/// Local ACE-Step backend base URL. +pub fn local_backend_url(port: u16) -> String { + format!("http://127.0.0.1:{port}") +} + +/// Local ACE-Step backend health endpoint. +pub fn local_backend_health_url(port: u16) -> String { + format!("http://127.0.0.1:{port}/health") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_outbound_urls_are_defined() { + // Verify all expected URL functions exist and return valid URLs + let hf = HF_RESOLVE_BASE; + assert!(hf.starts_with("https://"), "HF base should be HTTPS"); + + let releases = github_releases_latest_url(ACE_STEP_REPO); + assert!(releases.starts_with("https://api.github.com/")); + assert!(releases.contains(ACE_STEP_REPO)); + + let tag_ref = github_tag_ref_url(ACE_STEP_REPO, "v1.5.0"); + assert!(tag_ref.starts_with("https://api.github.com/")); + assert!(tag_ref.contains("v1.5.0")); + + let archive = github_archive_url(ACE_STEP_REPO, "abc123"); + assert!(archive.starts_with("https://codeload.github.com/")); + assert!(archive.contains("abc123")); + + let local = local_backend_url(8001); + assert_eq!(local, "http://127.0.0.1:8001"); + + let health = local_backend_health_url(8001); + assert_eq!(health, "http://127.0.0.1:8001/health"); + } + + #[test] + fn ace_step_repo_constant_is_correct() { + assert_eq!(ACE_STEP_REPO, "ACE-Step/ACE-Step-1.5"); + } + + #[test] + fn hf_resolve_base_is_correct() { + assert_eq!(HF_RESOLVE_BASE, "https://huggingface.co"); + } +} diff --git a/src/app/components/settings/sections/NetworkActivitySection.tsx b/src/app/components/settings/sections/NetworkActivitySection.tsx new file mode 100644 index 0000000..61ee824 --- /dev/null +++ b/src/app/components/settings/sections/NetworkActivitySection.tsx @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { SettingsSectionCard } from "@/app/components/settings/SettingsSectionCard"; +import * as api from "@/app/lib/api"; + +export function NetworkActivitySection() { + const { t } = useTranslation(); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + + const refresh = useCallback(() => { + setLoading(true); + void api + .getNetworkLog(50) + .then(setEntries) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + return ( + + {loading + ? t("settings.refreshing", { defaultValue: "Refreshing..." }) + : t("settings.refresh", { defaultValue: "Refresh" })} + + } + > + {entries.length === 0 ? ( +

+ {t("settings.noNetworkActivity", { + defaultValue: "No outbound requests recorded this session.", + })} +

+ ) : ( +
+ + + + + + + + + + + {entries.map((entry, index) => ( + + + + + + + ))} + +
+ {t("settings.networkTime", { defaultValue: "Time" })} + + {t("settings.networkMethod", { defaultValue: "Method" })} + + {t("settings.networkUrl", { defaultValue: "URL" })} + + {t("settings.networkStatus", { defaultValue: "Status" })} +
+ {formatTimestamp(entry.timestamp)} + {entry.method} + {truncateUrl(entry.url)} + + {entry.status} +
+
+ )} +
+ ); +} + +function formatTimestamp(iso: string): string { + try { + const date = new Date(iso); + return date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + } catch { + return iso; + } +} + +function truncateUrl(url: string): string { + // Remove protocol for display brevity + const stripped = url.replace(/^https?:\/\//, ""); + if (stripped.length > 60) { + return stripped.slice(0, 57) + "..."; + } + return stripped; +} + +function statusColor(status: number): string { + if (status >= 200 && status < 300) return "text-green-400"; + if (status >= 300 && status < 400) return "text-yellow-400"; + if (status >= 400) return "text-red-400"; + return "text-white"; +} diff --git a/test-results.junit.xml b/test-results.junit.xml new file mode 100644 index 0000000..788c79f --- /dev/null +++ b/test-results.junit.xml @@ -0,0 +1 @@ +{"numTotalTestSuites":233,"numPassedTestSuites":233,"numFailedTestSuites":0,"numPendingTestSuites":0,"numTotalTests":801,"numPassedTests":801,"numFailedTests":0,"numPendingTests":0,"numTodoTests":0,"snapshot":{"added":0,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0,"didUpdate":false},"startTime":1781591969886,"success":true,"testResults":[{"assertionResults":[{"ancestorTitles":["getSettings"],"fullName":"getSettings calls 'get_settings' with no args","status":"passed","title":"calls 'get_settings' with no args","duration":1.6142080000000192,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["setSetting"],"fullName":"setSetting calls 'set_setting' with key and value","status":"passed","title":"calls 'set_setting' with key and value","duration":0.3461669999999799,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["resetRuntimeSettings"],"fullName":"resetRuntimeSettings calls 'reset_runtime_settings' with no args","status":"passed","title":"calls 'reset_runtime_settings' with no args","duration":0.10137500000001864,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["getDeviceInfo"],"fullName":"getDeviceInfo calls 'get_device_info' with no args","status":"passed","title":"calls 'get_device_info' with no args","duration":0.09099999999995134,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["getWindowShellState"],"fullName":"getWindowShellState calls 'get_window_shell_state' with no args","status":"passed","title":"calls 'get_window_shell_state' with no args","duration":0.08225000000004457,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["getDefaultAppPaths"],"fullName":"getDefaultAppPaths calls 'get_default_app_paths' with no args","status":"passed","title":"calls 'get_default_app_paths' with no args","duration":0.07633400000003121,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["addCliToPath"],"fullName":"addCliToPath calls 'add_cli_to_path' with no args","status":"passed","title":"calls 'add_cli_to_path' with no args","duration":0.08320800000001327,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["removeCliFromPath"],"fullName":"removeCliFromPath calls 'remove_cli_from_path' with no args","status":"passed","title":"calls 'remove_cli_from_path' with no args","duration":0.07650000000001,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["isCliInPath"],"fullName":"isCliInPath calls 'is_cli_in_path' with no args","status":"passed","title":"calls 'is_cli_in_path' with no args","duration":0.08916599999997743,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["listGenerations"],"fullName":"listGenerations calls 'list_generations' with query when provided","status":"passed","title":"calls 'list_generations' with query when provided","duration":0.10587499999996908,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["listGenerations"],"fullName":"listGenerations sends null query when trimmed string is empty","status":"passed","title":"sends null query when trimmed string is empty","duration":0.08508299999999736,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["listGenerations"],"fullName":"listGenerations sends null query when undefined","status":"passed","title":"sends null query when undefined","duration":0.061249999999972715,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["getGeneration"],"fullName":"getGeneration calls 'get_generation' with id","status":"passed","title":"calls 'get_generation' with id","duration":0.08116599999999607,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["getGeneration"],"fullName":"getGeneration returns null when generation not found","status":"passed","title":"returns null when generation not found","duration":0.05083300000001145,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["insertGeneration"],"fullName":"insertGeneration calls 'insert_generation' with the record","status":"passed","title":"calls 'insert_generation' with the record","duration":0.08362499999998363,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["deleteGeneration"],"fullName":"deleteGeneration calls 'delete_generation' with id","status":"passed","title":"calls 'delete_generation' with id","duration":0.07525000000003956,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["clearGenerationHistory"],"fullName":"clearGenerationHistory calls 'clear_generation_history' with no args","status":"passed","title":"calls 'clear_generation_history' with no args","duration":0.059583000000031916,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["toggleGenerationFavorite"],"fullName":"toggleGenerationFavorite calls 'toggle_generation_favorite' with id","status":"passed","title":"calls 'toggle_generation_favorite' with id","duration":0.07570800000002009,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["readGenerationAudio"],"fullName":"readGenerationAudio calls 'read_generation_audio' with id","status":"passed","title":"calls 'read_generation_audio' with id","duration":0.08550000000002456,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["readGenerationWaveform"],"fullName":"readGenerationWaveform calls 'read_generation_waveform' with id","status":"passed","title":"calls 'read_generation_waveform' with id","duration":0.06987500000002456,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["generateMusic"],"fullName":"generateMusic calls 'generate_music' with request","status":"passed","title":"calls 'generate_music' with request","duration":0.07491599999997334,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["cancelGeneration"],"fullName":"cancelGeneration calls 'cancel_generation' with no args","status":"passed","title":"calls 'cancel_generation' with no args","duration":0.04800000000000182,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["enhancePrompt"],"fullName":"enhancePrompt calls 'enhance_prompt' with request","status":"passed","title":"calls 'enhance_prompt' with request","duration":0.07074999999997544,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["listActiveGenerationTasks"],"fullName":"listActiveGenerationTasks calls 'list_active_generation_tasks' with no args","status":"passed","title":"calls 'list_active_generation_tasks' with no args","duration":0.0574579999999969,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["resumeGenerationTask"],"fullName":"resumeGenerationTask calls 'resume_generation_task' with id","status":"passed","title":"calls 'resume_generation_task' with id","duration":0.06316600000002381,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["discardActiveGenerationTask"],"fullName":"discardActiveGenerationTask calls 'discard_active_generation_task' with id","status":"passed","title":"calls 'discard_active_generation_task' with id","duration":0.05154099999998607,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["backendStatus"],"fullName":"backendStatus calls 'backend_status' with no args","status":"passed","title":"calls 'backend_status' with no args","duration":0.05508399999996527,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["startBackend"],"fullName":"startBackend calls 'start_backend' with no args","status":"passed","title":"calls 'start_backend' with no args","duration":0.05141700000001492,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["stopBackend"],"fullName":"stopBackend calls 'stop_backend' with no args","status":"passed","title":"calls 'stop_backend' with no args","duration":0.05291599999998198,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["restartBackend"],"fullName":"restartBackend calls 'restart_backend' with no args","status":"passed","title":"calls 'restart_backend' with no args","duration":0.051916000000005624,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["getBackendLogsPath"],"fullName":"getBackendLogsPath calls 'get_backend_logs_path' with no args","status":"passed","title":"calls 'get_backend_logs_path' with no args","duration":0.052209000000004835,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["getBackendLogsPath"],"fullName":"getBackendLogsPath passes through null","status":"passed","title":"passes through null","duration":0.03387499999996635,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["clearBackendCache"],"fullName":"clearBackendCache calls 'clear_backend_cache' with no args","status":"passed","title":"calls 'clear_backend_cache' with no args","duration":0.044416000000012446,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["getBackendProvisionStatus"],"fullName":"getBackendProvisionStatus calls 'get_backend_provision_status' with no args","status":"passed","title":"calls 'get_backend_provision_status' with no args","duration":0.052042000000028565,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["provisionBackend"],"fullName":"provisionBackend calls 'provision_backend' with no args","status":"passed","title":"calls 'provision_backend' with no args","duration":0.05045800000004874,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["checkBackendUpdates"],"fullName":"checkBackendUpdates calls 'check_backend_updates' with no args","status":"passed","title":"calls 'check_backend_updates' with no args","duration":0.05112500000001319,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["updateBackend"],"fullName":"updateBackend calls 'update_backend' with no args","status":"passed","title":"calls 'update_backend' with no args","duration":0.05333300000000918,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["listModelCatalog"],"fullName":"listModelCatalog calls 'list_model_catalog' with no args","status":"passed","title":"calls 'list_model_catalog' with no args","duration":0.05033399999996391,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["getModelStatus"],"fullName":"getModelStatus calls 'get_model_status' with no args","status":"passed","title":"calls 'get_model_status' with no args","duration":0.05175000000002683,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["downloadModel"],"fullName":"downloadModel calls 'download_model' with variant","status":"passed","title":"calls 'download_model' with variant","duration":0.06299999999998818,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["deleteModel"],"fullName":"deleteModel calls 'delete_model' with variant","status":"passed","title":"calls 'delete_model' with variant","duration":0.06416600000000017,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["clearPartialDownloads"],"fullName":"clearPartialDownloads calls 'clear_partial_downloads' with variant","status":"passed","title":"calls 'clear_partial_downloads' with variant","duration":0.06391600000000608,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["cancelDownload"],"fullName":"cancelDownload calls 'cancel_download' with variant","status":"passed","title":"calls 'cancel_download' with variant","duration":0.05333300000000918,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["deleteAllModels"],"fullName":"deleteAllModels calls 'delete_all_models' with no args","status":"passed","title":"calls 'delete_all_models' with no args","duration":0.051832999999987805,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["revealInFinder"],"fullName":"revealInFinder calls 'reveal_in_finder' with path","status":"passed","title":"calls 'reveal_in_finder' with path","duration":0.05062499999996817,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["copyAudioTo"],"fullName":"copyAudioTo calls 'copy_audio_to' with path and destination","status":"passed","title":"calls 'copy_audio_to' with path and destination","duration":0.07287500000001046,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["fileExists"],"fullName":"fileExists calls 'file_exists' with path","status":"passed","title":"calls 'file_exists' with path","duration":0.08345900000000483,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["deleteGenerationFile"],"fullName":"deleteGenerationFile calls 'delete_generation_file' with path","status":"passed","title":"calls 'delete_generation_file' with path","duration":0.052957999999989624,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["deleteGenerationFileAndRecord"],"fullName":"deleteGenerationFileAndRecord calls 'delete_generation_file_and_record' with id","status":"passed","title":"calls 'delete_generation_file_and_record' with id","duration":0.06208400000002712,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["listFailedRuns"],"fullName":"listFailedRuns calls 'list_failed_runs' with limit","status":"passed","title":"calls 'list_failed_runs' with limit","duration":0.08320900000001075,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["clearFailedRuns"],"fullName":"clearFailedRuns calls 'clear_failed_runs' with no args","status":"passed","title":"calls 'clear_failed_runs' with no args","duration":0.05920900000000984,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["deleteFailedRun"],"fullName":"deleteFailedRun calls 'delete_failed_run' with id","status":"passed","title":"calls 'delete_failed_run' with id","duration":0.06045900000003712,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["exportGenerationsToFolder"],"fullName":"exportGenerationsToFolder calls 'export_generations_to_folder' with ids and destination","status":"passed","title":"calls 'export_generations_to_folder' with ids and destination","duration":0.08908300000001645,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["prepareDragPayload"],"fullName":"prepareDragPayload calls 'prepare_drag_payload' with id","status":"passed","title":"calls 'prepare_drag_payload' with id","duration":0.06354200000004084,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["error propagation"],"fullName":"error propagation propagates invoke rejection for no-arg commands","status":"passed","title":"propagates invoke rejection for no-arg commands","duration":0.6937919999999735,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["error propagation"],"fullName":"error propagation propagates invoke rejection for commands with args","status":"passed","title":"propagates invoke rejection for commands with args","duration":0.17141700000001947,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["error propagation"],"fullName":"error propagation propagates invoke rejection for void commands","status":"passed","title":"propagates invoke rejection for void commands","duration":0.1299579999999878,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["isTauriRuntime"],"fullName":"isTauriRuntime returns false when __TAURI_INTERNALS__ is absent","status":"passed","title":"returns false when __TAURI_INTERNALS__ is absent","duration":0.06962499999997362,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["isTauriRuntime"],"fullName":"isTauriRuntime returns true when __TAURI_INTERNALS__ is present","status":"passed","title":"returns true when __TAURI_INTERNALS__ is present","duration":0.053165999999976066,"failureMessages":[],"meta":{},"tags":[]}],"startTime":1781591981538,"endTime":1781591981545.1714,"status":"passed","message":"","name":"/Users/david/Development/OpenLoop/tests/unit/api.test.ts"},{"assertionResults":[{"ancestorTitles":["resolveCurrentAppShellMode"],"fullName":"resolveCurrentAppShellMode returns full-app","status":"passed","title":"returns full-app","duration":0.8189580000000092,"failureMessages":[],"meta":{},"tags":[]}],"startTime":1781591989269,"endTime":1781591989269.8188,"status":"passed","message":"","name":"/Users/david/Development/OpenLoop/tests/unit/app-shell.test.ts"},{"assertionResults":[{"ancestorTitles":["getShortcutPlatform"],"fullName":"getShortcutPlatform returns mac when userAgentData.platform contains mac","status":"passed","title":"returns mac when userAgentData.platform contains mac","duration":1.749917000000039,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["getShortcutPlatform"],"fullName":"getShortcutPlatform returns mac when userAgentData is absent and navigator.platform contains mac","status":"passed","title":"returns mac when userAgentData is absent and navigator.platform contains mac","duration":0.17004199999996672,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["getShortcutPlatform"],"fullName":"getShortcutPlatform returns windows when userAgentData.platform contains win","status":"passed","title":"returns windows when userAgentData.platform contains win","duration":0.12666600000000017,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["getShortcutPlatform"],"fullName":"getShortcutPlatform returns windows when userAgentData is absent and navigator.platform contains win","status":"passed","title":"returns windows when userAgentData is absent and navigator.platform contains win","duration":0.10833400000001348,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["getShortcutPlatform"],"fullName":"getShortcutPlatform returns linux for an unrecognized platform","status":"passed","title":"returns linux for an unrecognized platform","duration":0.10054200000001856,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["getShortcutPlatform"],"fullName":"getShortcutPlatform returns linux when navigator is unavailable (SSR-like)","status":"passed","title":"returns linux when navigator is unavailable (SSR-like)","duration":0.09399999999999409,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["getShortcutDisplay"],"fullName":"getShortcutDisplay returns only displayKey when requiresPrimaryModifier is false","status":"passed","title":"returns only displayKey when requiresPrimaryModifier is false","duration":0.13729100000000471,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["getShortcutDisplay"],"fullName":"getShortcutDisplay prepends ⌘ on mac","status":"passed","title":"prepends ⌘ on mac","duration":0.10162500000001273,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["getShortcutDisplay"],"fullName":"getShortcutDisplay prepends Ctrl+ on windows","status":"passed","title":"prepends Ctrl+ on windows","duration":0.09966700000001083,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["getShortcutDisplay"],"fullName":"getShortcutDisplay prepends Ctrl+ on linux","status":"passed","title":"prepends Ctrl+ on linux","duration":0.13529099999999517,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["getShortcutDisplay"],"fullName":"getShortcutDisplay detects platform automatically when platform arg is omitted","status":"passed","title":"detects platform automatically when platform arg is omitted","duration":0.1544579999999769,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["isInputFocused"],"fullName":"isInputFocused returns false when no element is focused","status":"passed","title":"returns false when no element is focused","duration":0.20979099999999562,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["isInputFocused"],"fullName":"isInputFocused returns true for an ","status":"passed","title":"returns true for an ","duration":4.994541999999967,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["isInputFocused"],"fullName":"isInputFocused returns true for a