diff --git a/packages/web/eslint.config.ts b/packages/web/eslint.config.ts index 337513e09..c7cdfd4a0 100644 --- a/packages/web/eslint.config.ts +++ b/packages/web/eslint.config.ts @@ -68,10 +68,38 @@ export default tseslint.config( "@typescript-eslint/only-throw-error": "off", }, }, + { + // doubleStrokeKeys is a pure helper exported alongside the SlidesPreview component purely so a test can pin its return value directly: the React `key` strings it computes never reach rendered DOM output at all (React keys are consumed internally, not written to markup), so a rendering-only test has no way to observe them -- the one case in this file where allowConstantExport's "still a component-shaped module" carve-out doesn't apply, since the export is a function, not a constant. + files: ["src/ui/SlidesPreview.tsx"], + rules: { + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true, allowExportNames: ["doubleStrokeKeys"] }, + ], + }, + }, + { + // formatBytes and reopenTooltipLabel are pure helpers exported alongside RecentFilesPanel for the identical reason __root.tsx's own colorSchemeTooltipLabel/navbarConfig are: Mantine's Tooltip only mounts its floating label content once genuinely open, which a render-only test cannot drive, so the label logic needs to be callable directly. + files: ["src/ui/RecentFilesPanel.tsx"], + rules: { + "react-refresh/only-export-components": [ + "warn", + { + allowConstantExport: true, + allowExportNames: ["formatBytes", "reopenTooltipLabel"], + }, + ], + }, + }, { files: ["src/workers/**/*.ts"], languageOptions: { globals: { ...globals.worker } }, }, + { + // setup.ts's own window.matchMedia stub implements MediaQueryList's legacy addListener/removeListener members because the real interface still declares them as required -- deprecated does not mean absent, and a stub that only satisfies the non-deprecated half of the type would be an incomplete implementation of what it stands in for. Testing that those two members are genuinely present and callable (setup.test.ts) means calling them by name, which is exactly what this rule exists to flag in ordinary application code; scoped to the one test file that has a legitimate reason to. + files: ["src/test/setup.test.ts"], + rules: { "@typescript-eslint/no-deprecated": "off" }, + }, { files: [ "vite.config.ts", diff --git a/packages/web/package.json b/packages/web/package.json index f47af632c..d12505b53 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -80,10 +80,12 @@ "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.5.3", + "fake-indexeddb": "6.2.5", "globals": "17.9.0", "husky": "9.1.7", "jiti": "2.7.0", "jsdom": "30.0.1", + "ooxml.js": "8.14.7", "semantic-release": "25.0.9", "typescript": "6.0.3", "typescript-eslint": "8.66.0", diff --git a/packages/web/src/adapters/documentConverter/workerDocumentConverter.test.ts b/packages/web/src/adapters/documentConverter/workerDocumentConverter.test.ts new file mode 100644 index 000000000..096f88235 --- /dev/null +++ b/packages/web/src/adapters/documentConverter/workerDocumentConverter.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createMockRpcClient } from "../../test/mockRpcClient"; + +vi.mock("../../rpc/client", () => ({ getRpcClient: vi.fn() })); + +import { getRpcClient } from "../../rpc/client"; +import { convertViaWorker } from "./workerDocumentConverter"; + +describe("convertViaWorker", () => { + it("calls the RPC client's convert with only the source, targetFormat, and bytes fields", async () => { + const client = createMockRpcClient(); + const output = { + document: { format: "pdf" as const, bytes: new Uint8Array([9]) }, + diagnostics: [], + content: undefined, + }; + vi.mocked(client.convert).mockResolvedValue(output); + vi.mocked(getRpcClient).mockReturnValue(client); + + const controller = new AbortController(); + const result = await convertViaWorker({ + source: "docx", + targetFormat: "pdf", + bytes: new Uint8Array([1, 2]), + signal: controller.signal, + }); + + expect(client.convert).toHaveBeenCalledWith( + { source: "docx", targetFormat: "pdf", bytes: new Uint8Array([1, 2]) }, + { signal: controller.signal }, + ); + expect(result).toEqual(output); + }); + + it("passes an undefined signal through unchanged when the caller supplies none", async () => { + const client = createMockRpcClient(); + vi.mocked(client.convert).mockResolvedValue({ + document: { format: "pdf" as const, bytes: new Uint8Array() }, + diagnostics: [], + content: undefined, + }); + vi.mocked(getRpcClient).mockReturnValue(client); + + await convertViaWorker({ + source: "docx", + targetFormat: "pdf", + bytes: new Uint8Array([1]), + }); + + expect(client.convert).toHaveBeenCalledWith(expect.anything(), { + signal: undefined, + }); + }); +}); diff --git a/packages/web/src/adapters/fileAccess/createFileAccess.test.ts b/packages/web/src/adapters/fileAccess/createFileAccess.test.ts new file mode 100644 index 000000000..0ba4acc10 --- /dev/null +++ b/packages/web/src/adapters/fileAccess/createFileAccess.test.ts @@ -0,0 +1,24 @@ +/// +/// +import { afterEach, describe, expect, it } from "vitest"; + +import { createFileAccess } from "./createFileAccess"; + +afterEach(() => { + Reflect.deleteProperty(window, "showOpenFilePicker"); +}); + +describe("createFileAccess", () => { + it("returns the native adapter when the browser exposes showOpenFilePicker", () => { + window.showOpenFilePicker = (): Promise<[FileSystemFileHandle]> => + Promise.reject(new Error("not used by this test")); + const access = createFileAccess(); + expect(access.supportsNativePicker()).toBe(true); + }); + + it("returns the fallback adapter when the browser has no showOpenFilePicker", () => { + Reflect.deleteProperty(window, "showOpenFilePicker"); + const access = createFileAccess(); + expect(access.supportsNativePicker()).toBe(false); + }); +}); diff --git a/packages/web/src/adapters/fileAccess/fallbackFileAccess.test.ts b/packages/web/src/adapters/fileAccess/fallbackFileAccess.test.ts new file mode 100644 index 000000000..c38204a7f --- /dev/null +++ b/packages/web/src/adapters/fileAccess/fallbackFileAccess.test.ts @@ -0,0 +1,154 @@ +/// +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createFallbackFileAccess } from "./fallbackFileAccess"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// The adapter only ever reads input.files?.[0] -- a numeric-indexed, length-and-item object is all FileList's real interface requires for that, so this builds one directly rather than via object-spreading a File[] (which TypeScript flags as overwriting length/index properties it considers already declared by the array's own structural type). +function fileList(files: File[]): FileList { + const list: FileList = { + length: files.length, + item: (index: number) => files[index] ?? null, + [Symbol.iterator]: () => files[Symbol.iterator](), + }; + files.forEach((file, index) => { + list[index] = file; + }); + return list; +} + +// The adapter drives the picker via input.click(), which a real browser resolves only after the user interacts -- here we intercept the click itself to synthesize the OS picker's outcome (a chosen file, or none) before dispatching the 'change' listener the code awaits. +function stubPickedFiles(files: File[]): void { + vi.spyOn(HTMLInputElement.prototype, "click").mockImplementation(function ( + this: HTMLInputElement, + ) { + Object.defineProperty(this, "files", { + value: fileList(files), + configurable: true, + }); + this.dispatchEvent(new Event("change")); + }); +} + +describe("createFallbackFileAccess", () => { + it("reports no native picker support", () => { + expect(createFallbackFileAccess().supportsNativePicker()).toBe(false); + }); + + it("resolves the opened file's bytes and name when a file is chosen", async () => { + const file = new File([new Uint8Array([1, 2, 3])], "report.pdf", { + type: "application/pdf", + }); + stubPickedFiles([file]); + const opened = await createFallbackFileAccess().openFile({}); + expect(opened?.name).toBe("report.pdf"); + expect(Array.from(opened?.bytes ?? [])).toEqual([1, 2, 3]); + expect(opened?.handle).toBeUndefined(); + }); + + it("creates a genuine file-type input element", async () => { + let capturedType = ""; + vi.spyOn(HTMLInputElement.prototype, "click").mockImplementation(function ( + this: HTMLInputElement, + ) { + capturedType = this.type; + Object.defineProperty(this, "files", { + value: fileList([]), + configurable: true, + }); + this.dispatchEvent(new Event("change")); + }); + await createFallbackFileAccess().openFile({}); + expect(capturedType).toBe("file"); + }); + + it("registers the change listener with once: true, so it never re-fires on a later change event", async () => { + const addEventListenerSpy = vi.spyOn( + HTMLInputElement.prototype, + "addEventListener", + ); + stubPickedFiles([]); + await createFallbackFileAccess().openFile({}); + const [, , options] = addEventListenerSpy.mock.calls.find( + ([type]) => type === "change", + )!; + expect(options).toEqual({ once: true }); + }); + + it("resolves undefined when the picker is dismissed with no file chosen", async () => { + stubPickedFiles([]); + const opened = await createFallbackFileAccess().openFile({}); + expect(opened).toBeUndefined(); + }); + + it("flattens and joins an accept map's extension groups into the input's accept attribute", async () => { + let capturedAccept = ""; + vi.spyOn(HTMLInputElement.prototype, "click").mockImplementation(function ( + this: HTMLInputElement, + ) { + capturedAccept = this.accept; + Object.defineProperty(this, "files", { + value: fileList([]), + configurable: true, + }); + this.dispatchEvent(new Event("change")); + }); + await createFallbackFileAccess().openFile({ + accept: { + "application/pdf": [".pdf"], + "text/markdown": [".md", ".markdown"], + }, + }); + expect(capturedAccept).toBe(".pdf,.md,.markdown"); + }); + + it("leaves the input's accept attribute empty when no accept option is given", async () => { + let capturedAccept = "not set"; + vi.spyOn(HTMLInputElement.prototype, "click").mockImplementation(function ( + this: HTMLInputElement, + ) { + capturedAccept = this.accept; + Object.defineProperty(this, "files", { + value: fileList([]), + configurable: true, + }); + this.dispatchEvent(new Event("change")); + }); + await createFallbackFileAccess().openFile({}); + expect(capturedAccept).toBe(""); + }); + + it("saves via a Blob-URL download anchor and revokes the object URL afterwards", async () => { + const createObjectURLSpy = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:mock-url"); + const revokeObjectURLSpy = vi + .spyOn(URL, "revokeObjectURL") + .mockImplementation(() => {}); + let clickedHref = ""; + let clickedDownload = ""; + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function ( + this: HTMLAnchorElement, + ) { + clickedHref = this.href; + clickedDownload = this.download; + }); + + const result = await createFallbackFileAccess().saveFile( + new Uint8Array([1, 2, 3]), + { suggestedName: "out.pdf", mimeType: "application/pdf" }, + ); + + expect(createObjectURLSpy).toHaveBeenCalledTimes(1); + const [blob] = createObjectURLSpy.mock.calls[0] as [Blob]; + expect(blob.size).toBe(3); + expect(blob.type).toBe("application/pdf"); + expect(clickedHref).toBe("blob:mock-url"); + expect(clickedDownload).toBe("out.pdf"); + expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:mock-url"); + expect(result).toEqual({}); + }); +}); diff --git a/packages/web/src/adapters/fileAccess/nativeFileAccess.test.ts b/packages/web/src/adapters/fileAccess/nativeFileAccess.test.ts new file mode 100644 index 000000000..e69d2ca3d --- /dev/null +++ b/packages/web/src/adapters/fileAccess/nativeFileAccess.test.ts @@ -0,0 +1,250 @@ +/// +/// +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createNativeFileAccess } from "./nativeFileAccess"; + +afterEach(() => { + vi.restoreAllMocks(); + Reflect.deleteProperty(window, "showOpenFilePicker"); + Reflect.deleteProperty(window, "showSaveFilePicker"); +}); + +// A fully-typed FileSystemFileHandle double: every member the real interface requires, not just the two (getFile/createWritable) this adapter actually calls, so the double stays sound without casting away the type it stands in for. +function stubHandle( + bytes: Uint8Array, + name: string, +): FileSystemFileHandle { + const file = new File([bytes], name); + return { + kind: "file", + name, + isFile: true, + isDirectory: false, + isSameEntry: () => Promise.resolve(false), + queryPermission: () => Promise.resolve("granted"), + requestPermission: () => Promise.resolve("granted"), + getFile: () => Promise.resolve(file), + createWritable: () => + Promise.reject(new Error("createWritable not stubbed on this handle")), + }; +} + +// Likewise a fully-typed FileSystemWritableFileStream double (write/close are spies the save tests assert on; the rest of WritableStream's own required surface is inert filler this adapter never touches). +function stubWritable() { + const write = vi.fn(() => Promise.resolve()); + const close = vi.fn(() => Promise.resolve()); + const stream: FileSystemWritableFileStream = { + locked: false, + write, + close, + abort: () => Promise.resolve(), + getWriter: () => { + throw new Error("getWriter is not implemented in this test double"); + }, + seek: () => Promise.resolve(), + truncate: () => Promise.resolve(), + }; + return { stream, write, close }; +} + +describe("createNativeFileAccess", () => { + it("reports native picker support", () => { + expect(createNativeFileAccess().supportsNativePicker()).toBe(true); + }); + + describe("openFile", () => { + it("resolves the chosen file's bytes, name, and handle", async () => { + const handle = stubHandle(new Uint8Array([9, 8, 7]), "a.docx"); + const showOpenFilePicker = vi.fn().mockResolvedValue([handle]); + window.showOpenFilePicker = showOpenFilePicker; + + const opened = await createNativeFileAccess().openFile({}); + expect(opened?.name).toBe("a.docx"); + expect(Array.from(opened?.bytes ?? [])).toEqual([9, 8, 7]); + expect(opened?.handle).toBe(handle); + }); + + it("passes a Document-described types array built from the given accept option", async () => { + const handle = stubHandle(new Uint8Array([1]), "a.pdf"); + const showOpenFilePicker = vi.fn().mockResolvedValue([handle]); + window.showOpenFilePicker = showOpenFilePicker; + const accept = { "application/pdf": [".pdf"] } as Record< + MIMEType, + FileExtension[] + >; + + await createNativeFileAccess().openFile({ accept }); + + expect(showOpenFilePicker).toHaveBeenCalledWith({ + types: [{ description: "Document", accept }], + multiple: false, + }); + }); + + it("passes undefined types when no accept option is given", async () => { + const handle = stubHandle(new Uint8Array([1]), "a.pdf"); + const showOpenFilePicker = vi.fn().mockResolvedValue([handle]); + window.showOpenFilePicker = showOpenFilePicker; + + await createNativeFileAccess().openFile({}); + + expect(showOpenFilePicker).toHaveBeenCalledWith({ + types: undefined, + multiple: false, + }); + }); + + it("resolves undefined when the user aborts the native picker", async () => { + const showOpenFilePicker = vi + .fn() + .mockRejectedValue(new DOMException("cancelled", "AbortError")); + window.showOpenFilePicker = showOpenFilePicker; + + const opened = await createNativeFileAccess().openFile({}); + expect(opened).toBeUndefined(); + }); + + it("rethrows a picker failure that is not an AbortError", async () => { + const showOpenFilePicker = vi + .fn() + .mockRejectedValue(new DOMException("denied", "SecurityError")); + window.showOpenFilePicker = showOpenFilePicker; + + await expect(createNativeFileAccess().openFile({})).rejects.toThrow( + "denied", + ); + }); + + it("rethrows a non-DOMException failure from the picker", async () => { + const showOpenFilePicker = vi.fn().mockRejectedValue(new Error("boom")); + window.showOpenFilePicker = showOpenFilePicker; + + await expect(createNativeFileAccess().openFile({})).rejects.toThrow( + "boom", + ); + }); + + it("resolves undefined when the picker resolves an empty handle list", async () => { + const showOpenFilePicker = vi.fn().mockResolvedValue([]); + window.showOpenFilePicker = showOpenFilePicker; + + const opened = await createNativeFileAccess().openFile({}); + expect(opened).toBeUndefined(); + }); + }); + + describe("saveFile", () => { + it("writes bytes through a writable stream and resolves the handle", async () => { + const { stream, write, close } = stubWritable(); + const handle = stubHandle(new Uint8Array([1]), "out.pdf"); + handle.createWritable = () => Promise.resolve(stream); + const showSaveFilePicker = vi.fn().mockResolvedValue(handle); + window.showSaveFilePicker = showSaveFilePicker; + + const bytes = new Uint8Array([1, 2, 3]); + const result = await createNativeFileAccess().saveFile(bytes, { + suggestedName: "out.pdf", + mimeType: "application/pdf", + }); + + expect(showSaveFilePicker).toHaveBeenCalledWith({ + suggestedName: "out.pdf", + types: [ + { + description: "Document", + accept: { "application/pdf": [".pdf"] }, + }, + ], + }); + expect(write).toHaveBeenCalledWith(bytes); + expect(close).toHaveBeenCalledTimes(1); + expect(result).toEqual({ handle }); + }); + + it("derives the accept extension from the suggested name's own extension", async () => { + const { stream } = stubWritable(); + const handle = stubHandle(new Uint8Array([1]), "archive.tar.docx"); + handle.createWritable = () => Promise.resolve(stream); + const showSaveFilePicker = vi.fn().mockResolvedValue(handle); + window.showSaveFilePicker = showSaveFilePicker; + + await createNativeFileAccess().saveFile(new Uint8Array([1]), { + suggestedName: "archive.tar.docx", + mimeType: "application/vnd.openxmlformats", + }); + + expect(showSaveFilePicker).toHaveBeenCalledWith({ + suggestedName: "archive.tar.docx", + types: [ + { + description: "Document", + accept: { "application/vnd.openxmlformats": [".docx"] }, + }, + ], + }); + }); + + it("uses the whole suggested name as the accept extension when it carries no dot at all", async () => { + const { stream } = stubWritable(); + const handle = stubHandle(new Uint8Array([1]), "noextension"); + handle.createWritable = () => Promise.resolve(stream); + const showSaveFilePicker = vi.fn().mockResolvedValue(handle); + window.showSaveFilePicker = showSaveFilePicker; + + await createNativeFileAccess().saveFile(new Uint8Array([1]), { + suggestedName: "noextension", + mimeType: "application/octet-stream", + }); + + expect(showSaveFilePicker).toHaveBeenCalledWith({ + suggestedName: "noextension", + types: [ + { + description: "Document", + accept: { "application/octet-stream": [".noextension"] }, + }, + ], + }); + }); + + it("resolves an empty result when the user aborts the save picker", async () => { + const showSaveFilePicker = vi + .fn() + .mockRejectedValue(new DOMException("cancelled", "AbortError")); + window.showSaveFilePicker = showSaveFilePicker; + + const result = await createNativeFileAccess().saveFile( + new Uint8Array([1]), + { suggestedName: "a.pdf", mimeType: "application/pdf" }, + ); + expect(result).toEqual({}); + }); + + it("rethrows a save-picker failure that is not an AbortError", async () => { + const showSaveFilePicker = vi + .fn() + .mockRejectedValue(new DOMException("denied", "SecurityError")); + window.showSaveFilePicker = showSaveFilePicker; + + await expect( + createNativeFileAccess().saveFile(new Uint8Array([1]), { + suggestedName: "a.pdf", + mimeType: "application/pdf", + }), + ).rejects.toThrow("denied"); + }); + + it("rethrows a non-DOMException failure from the save picker", async () => { + const showSaveFilePicker = vi.fn().mockRejectedValue(new Error("boom")); + window.showSaveFilePicker = showSaveFilePicker; + + await expect( + createNativeFileAccess().saveFile(new Uint8Array([1]), { + suggestedName: "a.pdf", + mimeType: "application/pdf", + }), + ).rejects.toThrow("boom"); + }); + }); +}); diff --git a/packages/web/src/adapters/fileAccess/nativeFileAccess.ts b/packages/web/src/adapters/fileAccess/nativeFileAccess.ts index 7a954bf8c..6aaa5f59d 100644 --- a/packages/web/src/adapters/fileAccess/nativeFileAccess.ts +++ b/packages/web/src/adapters/fileAccess/nativeFileAccess.ts @@ -38,7 +38,8 @@ export function createNativeFileAccess(): FileAccessPort { description: "Document", accept: { [options.mimeType]: [ - `.${options.suggestedName.split(".").pop() ?? "bin"}`, + // String.split on any input, including one with no '.' at all, always returns at least one element, so pop() here can never be undefined -- there is no genuinely-extension-less case to fall back for. + `.${options.suggestedName.split(".").pop()}`, ], }, }, diff --git a/packages/web/src/db/dexie.test.ts b/packages/web/src/db/dexie.test.ts new file mode 100644 index 000000000..d6434892b --- /dev/null +++ b/packages/web/src/db/dexie.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { db } from "./dexie"; + +beforeEach(async () => { + await db.recentFiles.clear(); + await db.preferences.clear(); + await db.customFonts.clear(); + await db.editorSessions.clear(); +}); + +afterEach(async () => { + await db.recentFiles.clear(); + await db.preferences.clear(); + await db.customFonts.clear(); + await db.editorSessions.clear(); +}); + +describe("DocumentsDatabase", () => { + it("names the database exadev-documents", () => { + expect(db.name).toBe("exadev-documents"); + }); + + it("stores and retrieves a recent-file record with an auto-assigned id", async () => { + const id = await db.recentFiles.add({ + format: "docx", + name: "a.docx", + sizeBytes: 42, + lastOpenedAt: 1, + }); + if (id === undefined) throw new Error("expected an auto-assigned id"); + const record = await db.recentFiles.get(id); + expect(record?.name).toBe("a.docx"); + expect(record?.sizeBytes).toBe(42); + }); + + it("indexes recentFiles by format and lastOpenedAt for ordered/filtered queries", async () => { + await db.recentFiles.bulkAdd([ + { format: "docx", name: "a", sizeBytes: 1, lastOpenedAt: 10 }, + { format: "pdf", name: "b", sizeBytes: 1, lastOpenedAt: 20 }, + { format: "docx", name: "c", sizeBytes: 1, lastOpenedAt: 30 }, + ]); + const byFormat = await db.recentFiles + .where("format") + .equals("docx") + .toArray(); + expect(byFormat.map((record) => record.name).sort()).toEqual(["a", "c"]); + + const ordered = await db.recentFiles.orderBy("lastOpenedAt").toArray(); + expect(ordered.map((record) => record.name)).toEqual(["a", "b", "c"]); + }); + + it("stores a preference record keyed by its own key column", async () => { + await db.preferences.put({ key: "theme", value: "dark" }); + const record = await db.preferences.get("theme"); + expect(record?.value).toBe("dark"); + }); + + it("stores and indexes custom-font records by family", async () => { + const bytes = new Blob([new Uint8Array([1, 2, 3])]); + await db.customFonts.add({ + family: "Custom Sans", + bold: false, + italic: false, + bytes, + }); + const found = await db.customFonts + .where("family") + .equals("Custom Sans") + .first(); + expect(found?.bold).toBe(false); + expect(found?.italic).toBe(false); + }); + + it("stores and indexes editor-session records by sessionId, format, lastSnapshotAt, and cleanlyClosed", async () => { + await db.editorSessions.add({ + sessionId: "s1", + format: "markdown", + originalName: "a.md", + lastSnapshotAt: 5, + sizeBytes: 10, + cleanlyClosed: false, + }); + const found = await db.editorSessions + .where("sessionId") + .equals("s1") + .first(); + expect(found?.originalName).toBe("a.md"); + expect(found?.cleanlyClosed).toBe(false); + }); +}); diff --git a/packages/web/src/hooks/useContentDump.test.tsx b/packages/web/src/hooks/useContentDump.test.tsx new file mode 100644 index 000000000..fb493d96d --- /dev/null +++ b/packages/web/src/hooks/useContentDump.test.tsx @@ -0,0 +1,59 @@ +import { assembleTree } from "document-schema.js"; +import type { ContentDocument } from "documents.js"; +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { createMockRpcClient } from "../test/mockRpcClient"; +import { renderHookWithQueryClient } from "../test/renderHook"; + +vi.mock("../rpc/client", () => ({ getRpcClient: vi.fn() })); + +import { getRpcClient } from "../rpc/client"; +import { useReadContent, useRestoreContent } from "./useContentDump"; + +describe("useReadContent", () => { + it("calls content.read with the given input and resolves its result", async () => { + const client = createMockRpcClient(); + const content: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [], + }; + const output = { + content, + // Stamped by hand rather than via documents.js's own documentTreeWithSchema helper: UI code (this file lives under src/hooks/) may not import documents.js's runtime functions directly (see eslint.config.ts's import-boundary rule), and the schema's own $schema field is a plain string, not a pinned literal, so a hand-applied stamp is exactly as valid a fixture as the real helper's output. + package: { ...assembleTree(content), $schema: "test-schema" }, + }; + vi.mocked(client.content.read).mockResolvedValue(output); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useReadContent(), + ); + const input = { format: "docx" as const, bytes: new Uint8Array([1]) }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.content.read).toHaveBeenCalledWith(input); + expect(resolved).toEqual(output); + unmount(); + }); +}); + +describe("useRestoreContent", () => { + it("calls content.restore with the given input and resolves its bytes", async () => { + const client = createMockRpcClient(); + const output = { bytes: new Uint8Array([1, 2]) }; + vi.mocked(client.content.restore).mockResolvedValue(output); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useRestoreContent(), + ); + const input = { format: "docx" as const, package: {} }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.content.restore).toHaveBeenCalledWith(input); + expect(resolved).toEqual(output); + unmount(); + }); +}); diff --git a/packages/web/src/hooks/useConversions.test.tsx b/packages/web/src/hooks/useConversions.test.tsx new file mode 100644 index 000000000..a0bf07392 --- /dev/null +++ b/packages/web/src/hooks/useConversions.test.tsx @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createMockRpcClient } from "../test/mockRpcClient"; +import { renderHookWithQueryClient } from "../test/renderHook"; + +vi.mock("../rpc/client", () => ({ getRpcClient: vi.fn() })); + +import { getRpcClient } from "../rpc/client"; +import { useConversions, useDocumentFormats } from "./useConversions"; + +describe("useConversions", () => { + it("fetches the conversion pair list via formats.listConversions", async () => { + const client = createMockRpcClient(); + const pairs: { source: "docx"; target: "pdf" }[] = [ + { source: "docx", target: "pdf" }, + ]; + vi.mocked(client.formats.listConversions).mockResolvedValue(pairs); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, queryClient, unmount } = renderHookWithQueryClient(() => + useConversions(), + ); + await vi.waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + expect(result.current.data).toEqual(pairs); + expect(client.formats.listConversions).toHaveBeenCalledTimes(1); + // Pins the exact queryKey the hook registers under, not just that it eventually resolves data -- a mutated key would still let the query above succeed, but would cache the result under a different key from the one this asserts against. + expect(queryClient.getQueryData(["formats", "listConversions"])).toEqual( + pairs, + ); + unmount(); + }); +}); + +describe("useDocumentFormats", () => { + it("fetches the document format list via formats.list", async () => { + const client = createMockRpcClient(); + vi.mocked(client.formats.list).mockResolvedValue(["docx", "pdf"]); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, queryClient, unmount } = renderHookWithQueryClient(() => + useDocumentFormats(), + ); + await vi.waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + expect(result.current.data).toEqual(["docx", "pdf"]); + // Pins the exact queryKey the hook registers under -- see the identical comment in the useConversions test above. + expect(queryClient.getQueryData(["formats", "list"])).toEqual([ + "docx", + "pdf", + ]); + unmount(); + }); +}); diff --git a/packages/web/src/hooks/useConvert.test.tsx b/packages/web/src/hooks/useConvert.test.tsx new file mode 100644 index 000000000..d8fafea4b --- /dev/null +++ b/packages/web/src/hooks/useConvert.test.tsx @@ -0,0 +1,38 @@ +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { createMockRpcClient } from "../test/mockRpcClient"; +import { renderHookWithQueryClient } from "../test/renderHook"; + +vi.mock("../rpc/client", () => ({ getRpcClient: vi.fn() })); + +import { getRpcClient } from "../rpc/client"; +import { useConvert } from "./useConvert"; + +describe("useConvert", () => { + it("drives convertViaWorker's convert call and resolves its result", async () => { + const client = createMockRpcClient(); + const output = { + document: { format: "pdf" as const, bytes: new Uint8Array([1]) }, + diagnostics: [], + content: undefined, + }; + vi.mocked(client.convert).mockResolvedValue(output); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => useConvert()); + const input = { + source: "docx" as const, + targetFormat: "pdf" as const, + bytes: new Uint8Array([1]), + }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.convert).toHaveBeenCalledWith( + { source: "docx", targetFormat: "pdf", bytes: input.bytes }, + { signal: undefined }, + ); + expect(resolved).toEqual(output); + unmount(); + }); +}); diff --git a/packages/web/src/hooks/useEditorSession.test.tsx b/packages/web/src/hooks/useEditorSession.test.tsx new file mode 100644 index 000000000..1c7afe903 --- /dev/null +++ b/packages/web/src/hooks/useEditorSession.test.tsx @@ -0,0 +1,109 @@ +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { createMockRpcClient } from "../test/mockRpcClient"; +import { renderHookWithQueryClient } from "../test/renderHook"; + +vi.mock("../rpc/client", () => ({ getRpcClient: vi.fn() })); + +import { getRpcClient } from "../rpc/client"; +import { + useAddParagraph, + useOpenEditor, + useRemoveParagraph, + useSaveEditor, + useSetParagraphText, +} from "./useEditorSession"; + +const snapshot = { id: 1, paragraphs: ["a", "b"] }; + +describe("useOpenEditor", () => { + it("calls editor.open with the given input and resolves its snapshot", async () => { + const client = createMockRpcClient(); + vi.mocked(client.editor.open).mockResolvedValue(snapshot); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useOpenEditor(), + ); + const input = { format: "markdown" as const, bytes: new Uint8Array([1]) }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.editor.open).toHaveBeenCalledWith(input); + expect(resolved).toEqual(snapshot); + unmount(); + }); +}); + +describe("useSetParagraphText", () => { + it("calls editor.setParagraphText with the given input and resolves its snapshot", async () => { + const client = createMockRpcClient(); + vi.mocked(client.editor.setParagraphText).mockResolvedValue(snapshot); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useSetParagraphText(), + ); + const input = { id: 1, index: 0, text: "edited" }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.editor.setParagraphText).toHaveBeenCalledWith(input); + expect(resolved).toEqual(snapshot); + unmount(); + }); +}); + +describe("useAddParagraph", () => { + it("calls editor.addParagraph with the given input and resolves its snapshot", async () => { + const client = createMockRpcClient(); + vi.mocked(client.editor.addParagraph).mockResolvedValue(snapshot); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useAddParagraph(), + ); + const input = { id: 1, text: "new" }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.editor.addParagraph).toHaveBeenCalledWith(input); + expect(resolved).toEqual(snapshot); + unmount(); + }); +}); + +describe("useRemoveParagraph", () => { + it("calls editor.removeParagraph with the given input and resolves its snapshot", async () => { + const client = createMockRpcClient(); + vi.mocked(client.editor.removeParagraph).mockResolvedValue(snapshot); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useRemoveParagraph(), + ); + const input = { id: 1, index: 0 }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.editor.removeParagraph).toHaveBeenCalledWith(input); + expect(resolved).toEqual(snapshot); + unmount(); + }); +}); + +describe("useSaveEditor", () => { + it("calls editor.save with the given input and resolves its bytes", async () => { + const client = createMockRpcClient(); + const output = { bytes: new Uint8Array([1, 2]) }; + vi.mocked(client.editor.save).mockResolvedValue(output); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useSaveEditor(), + ); + const input = { id: 1 }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.editor.save).toHaveBeenCalledWith(input); + expect(resolved).toEqual(output); + unmount(); + }); +}); diff --git a/packages/web/src/hooks/useFonts.test.tsx b/packages/web/src/hooks/useFonts.test.tsx new file mode 100644 index 000000000..48ea9aee4 --- /dev/null +++ b/packages/web/src/hooks/useFonts.test.tsx @@ -0,0 +1,29 @@ +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { createMockRpcClient } from "../test/mockRpcClient"; +import { renderHookWithQueryClient } from "../test/renderHook"; + +vi.mock("../rpc/client", () => ({ getRpcClient: vi.fn() })); + +import { getRpcClient } from "../rpc/client"; +import { useExtractSourceFonts } from "./useFonts"; + +describe("useExtractSourceFonts", () => { + it("calls fonts.extractSourceFonts with the given input and resolves its result", async () => { + const client = createMockRpcClient(); + const fonts = [{ family: "Times New Roman", bold: false, italic: false }]; + vi.mocked(client.fonts.extractSourceFonts).mockResolvedValue(fonts); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useExtractSourceFonts(), + ); + const input = { format: "docx" as const, bytes: new Uint8Array([1]) }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.fonts.extractSourceFonts).toHaveBeenCalledWith(input); + expect(resolved).toEqual(fonts); + unmount(); + }); +}); diff --git a/packages/web/src/hooks/useInspect.test.tsx b/packages/web/src/hooks/useInspect.test.tsx new file mode 100644 index 000000000..0707dc492 --- /dev/null +++ b/packages/web/src/hooks/useInspect.test.tsx @@ -0,0 +1,160 @@ +import { assembleTree } from "document-schema.js"; +import type { ContentDocument } from "documents.js"; +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { createMockRpcClient } from "../test/mockRpcClient"; +import { renderHookWithQueryClient } from "../test/renderHook"; + +vi.mock("../rpc/client", () => ({ getRpcClient: vi.fn() })); + +import { getRpcClient } from "../rpc/client"; +import { + contentInspectResult, + useInspectDocument, + useInspectPdfBytes, + useReadContent, +} from "./useInspect"; + +const wordprocessing: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [ + { + pageSize: { widthPt: 595, heightPt: 842 }, + margins: { topPt: 0, rightPt: 0, bottomPt: 0, leftPt: 0 }, + blocks: [{ kind: "paragraph", runs: [{ text: "x" }] }], + }, + ], +}; + +describe("contentInspectResult", () => { + it("builds a content-backed InspectResult with no diagnostics and the variant-aware summary", () => { + const pkg = { ...assembleTree(wordprocessing), $schema: "test-schema" }; + const result = contentInspectResult({ + content: wordprocessing, + package: pkg, + }); + + expect(result).toEqual({ + backing: "content", + diagnostics: [], + summary: ["1 section", "1 block"], + package: pkg, + }); + }); +}); + +describe("useReadContent", () => { + it("calls content.read with the given input and resolves its result", async () => { + const client = createMockRpcClient(); + const pkg = { ...assembleTree(wordprocessing), $schema: "test-schema" }; + const output = { content: wordprocessing, package: pkg }; + vi.mocked(client.content.read).mockResolvedValue(output); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useReadContent(), + ); + const input = { format: "docx" as const, bytes: new Uint8Array([1]) }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.content.read).toHaveBeenCalledWith(input); + expect(resolved).toEqual(output); + unmount(); + }); +}); + +const pdfInspectOutput = { + pageCount: 2, + itemKindCounts: { text: 3 }, + metadata: { title: "a title" }, + layout: { formatVersion: 1 as const, metadata: {}, pages: [], images: {} }, +}; + +describe("useInspectPdfBytes", () => { + it("calls pdf.inspect with the given bytes and tags the result as pdf-backed with no diagnostics", async () => { + const client = createMockRpcClient(); + vi.mocked(client.pdf.inspect).mockResolvedValue(pdfInspectOutput); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useInspectPdfBytes(), + ); + const bytes = new Uint8Array([1, 2]); + const resolved = await act(() => result.current.mutateAsync(bytes)); + + expect(client.pdf.inspect).toHaveBeenCalledWith({ bytes }); + expect(resolved).toEqual({ + backing: "pdf", + ...pdfInspectOutput, + diagnostics: [], + }); + unmount(); + }); +}); + +describe("useInspectDocument", () => { + it("inspects PDF bytes directly, without converting first, when the source format is already pdf", async () => { + const client = createMockRpcClient(); + vi.mocked(client.pdf.inspect).mockResolvedValue(pdfInspectOutput); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useInspectDocument(), + ); + const bytes = new Uint8Array([1]); + const resolved = await act(() => + result.current.mutateAsync({ format: "pdf", bytes }), + ); + + expect(client.convert).not.toHaveBeenCalled(); + expect(client.pdf.inspect).toHaveBeenCalledWith({ bytes }); + expect(resolved).toEqual({ + backing: "pdf", + ...pdfInspectOutput, + diagnostics: [], + }); + unmount(); + }); + + it("converts a non-pdf source to pdf first, then inspects the converted bytes and surfaces the conversion's own diagnostics", async () => { + const client = createMockRpcClient(); + const convertedBytes = new Uint8Array([9, 9]); + const diagnostics = [ + { + severity: "warning" as const, + code: "font-sub", + message: "substituted a font", + }, + ]; + vi.mocked(client.convert).mockResolvedValue({ + document: { format: "pdf" as const, bytes: convertedBytes }, + diagnostics, + content: undefined, + }); + vi.mocked(client.pdf.inspect).mockResolvedValue(pdfInspectOutput); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useInspectDocument(), + ); + const sourceBytes = new Uint8Array([1]); + const resolved = await act(() => + result.current.mutateAsync({ format: "docx", bytes: sourceBytes }), + ); + + expect(client.convert).toHaveBeenCalledWith({ + source: "docx", + targetFormat: "pdf", + bytes: sourceBytes, + }); + expect(client.pdf.inspect).toHaveBeenCalledWith({ bytes: convertedBytes }); + expect(resolved).toEqual({ + backing: "pdf", + ...pdfInspectOutput, + diagnostics, + }); + unmount(); + }); +}); diff --git a/packages/web/src/hooks/useMetadata.test.tsx b/packages/web/src/hooks/useMetadata.test.tsx new file mode 100644 index 000000000..f6050ddef --- /dev/null +++ b/packages/web/src/hooks/useMetadata.test.tsx @@ -0,0 +1,53 @@ +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { createMockRpcClient } from "../test/mockRpcClient"; +import { renderHookWithQueryClient } from "../test/renderHook"; + +vi.mock("../rpc/client", () => ({ getRpcClient: vi.fn() })); + +import { getRpcClient } from "../rpc/client"; +import { useReadMetadata, useWriteMetadata } from "./useMetadata"; + +describe("useReadMetadata", () => { + it("calls metadata.read with the given input and resolves its result", async () => { + const client = createMockRpcClient(); + const metadata = { title: "a title" }; + vi.mocked(client.metadata.read).mockResolvedValue(metadata); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useReadMetadata(), + ); + const input = { format: "docx" as const, bytes: new Uint8Array([1]) }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.metadata.read).toHaveBeenCalledWith(input); + expect(resolved).toEqual(metadata); + unmount(); + }); +}); + +describe("useWriteMetadata", () => { + it("calls metadata.write with the given input and resolves its bytes", async () => { + const client = createMockRpcClient(); + const bytes = new Uint8Array([9, 9]); + vi.mocked(client.metadata.write).mockResolvedValue(bytes); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => + useWriteMetadata(), + ); + const input = { + sourceFormat: "docx" as const, + targetFormat: "docx" as const, + bytes: new Uint8Array([1]), + overrides: {}, + }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.metadata.write).toHaveBeenCalledWith(input); + expect(resolved).toBe(bytes); + unmount(); + }); +}); diff --git a/packages/web/src/hooks/useOdbInventory.test.tsx b/packages/web/src/hooks/useOdbInventory.test.tsx new file mode 100644 index 000000000..11331144e --- /dev/null +++ b/packages/web/src/hooks/useOdbInventory.test.tsx @@ -0,0 +1,45 @@ +import type { ContentDocument } from "documents.js"; +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { createMockRpcClient } from "../test/mockRpcClient"; +import { renderHookWithQueryClient } from "../test/renderHook"; + +vi.mock("../rpc/client", () => ({ getRpcClient: vi.fn() })); + +import { getRpcClient } from "../rpc/client"; +import { useReadOdb } from "./useOdbInventory"; + +describe("useReadOdb", () => { + it("calls odb.read with the given input and resolves its result", async () => { + const client = createMockRpcClient(); + const content: ContentDocument = { + kind: "spreadsheet", + metadata: {}, + sheets: [], + }; + const output = { + inventory: { + tables: [] as string[], + queries: [] as { + name: string; + command: string; + escapeProcessing?: boolean; + }[], + forms: [] as { name: string; href: string; asTemplate?: boolean }[], + reports: [] as { name: string; href: string; asTemplate?: boolean }[], + }, + content, + }; + vi.mocked(client.odb.read).mockResolvedValue(output); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => useReadOdb()); + const input = { bytes: new Uint8Array([1]) }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.odb.read).toHaveBeenCalledWith(input); + expect(resolved).toEqual(output); + unmount(); + }); +}); diff --git a/packages/web/src/hooks/useOdmRender.test.tsx b/packages/web/src/hooks/useOdmRender.test.tsx new file mode 100644 index 000000000..0f987973e --- /dev/null +++ b/packages/web/src/hooks/useOdmRender.test.tsx @@ -0,0 +1,30 @@ +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { createMockRpcClient } from "../test/mockRpcClient"; +import { renderHookWithQueryClient } from "../test/renderHook"; + +vi.mock("../rpc/client", () => ({ getRpcClient: vi.fn() })); + +import { getRpcClient } from "../rpc/client"; +import { useOdmRender } from "./useOdmRender"; + +describe("useOdmRender", () => { + it("calls odm.render with the given input and resolves its result", async () => { + const client = createMockRpcClient(); + const output = { ok: true as const, pdf: new Uint8Array([1, 2]) }; + vi.mocked(client.odm.render).mockResolvedValue(output); + vi.mocked(getRpcClient).mockReturnValue(client); + + const { result, unmount } = renderHookWithQueryClient(() => useOdmRender()); + const input = { + master: new Uint8Array([1]), + chapters: [{ href: "ch1.odt", bytes: new Uint8Array([2]) }], + }; + const resolved = await act(() => result.current.mutateAsync(input)); + + expect(client.odm.render).toHaveBeenCalledWith(input); + expect(resolved).toEqual(output); + unmount(); + }); +}); diff --git a/packages/web/src/hooks/usePdfObjectUrl.test.tsx b/packages/web/src/hooks/usePdfObjectUrl.test.tsx new file mode 100644 index 000000000..7f6d4dbca --- /dev/null +++ b/packages/web/src/hooks/usePdfObjectUrl.test.tsx @@ -0,0 +1,99 @@ +/// +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { usePdfObjectUrl } from "./usePdfObjectUrl"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// A tiny harness scoped to one test: unlike a module-level mutable variable (which the react-hooks/immutability lint rule correctly flags as a cross-render hazard), a `result` object created fresh inside this factory function is owned by the single call site that renders into it, the same pattern src/test/renderHook.tsx already establishes for the react-query hooks. +function mountPdfObjectUrl(initialBytes: Uint8Array | undefined) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const result: { current: string | undefined } = { current: undefined }; + + function Harness({ bytes }: { bytes: Uint8Array | undefined }) { + result.current = usePdfObjectUrl(bytes); + return null; + } + + function rerender(bytes: Uint8Array | undefined) { + act(() => { + root.render(); + }); + } + + rerender(initialBytes); + + return { + result, + rerender, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +describe("usePdfObjectUrl", () => { + it("returns undefined when bytes is undefined", () => { + const { result, unmount } = mountPdfObjectUrl(undefined); + expect(result.current).toBeUndefined(); + unmount(); + }); + + it("creates a blob: object URL from the given bytes as application/pdf", () => { + const createObjectURLSpy = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:one"); + const { result, unmount } = mountPdfObjectUrl(new Uint8Array([1, 2, 3])); + expect(result.current).toBe("blob:one"); + const [blob] = createObjectURLSpy.mock.calls[0] ?? []; + expect((blob as Blob).type).toBe("application/pdf"); + // Pins that the Blob actually wraps the given bytes, not an empty blob -- .type alone can't tell those apart. + expect((blob as Blob).size).toBe(3); + unmount(); + }); + + it("revokes the previous object URL and creates a fresh one when bytes changes", () => { + const revokeObjectURLSpy = vi + .spyOn(URL, "revokeObjectURL") + .mockImplementation(() => {}); + vi.spyOn(URL, "createObjectURL") + .mockReturnValueOnce("blob:one") + .mockReturnValueOnce("blob:two"); + + const { result, rerender, unmount } = mountPdfObjectUrl( + new Uint8Array([1]), + ); + expect(result.current).toBe("blob:one"); + + rerender(new Uint8Array([2])); + expect(result.current).toBe("blob:two"); + expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:one"); + unmount(); + }); + + it("revokes the object URL and returns undefined once bytes goes back to undefined", () => { + const revokeObjectURLSpy = vi + .spyOn(URL, "revokeObjectURL") + .mockImplementation(() => {}); + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:one"); + + const { result, rerender, unmount } = mountPdfObjectUrl( + new Uint8Array([1]), + ); + expect(result.current).toBe("blob:one"); + + rerender(undefined); + expect(result.current).toBeUndefined(); + expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:one"); + unmount(); + }); +}); diff --git a/packages/web/src/hooks/useRecentFiles.test.tsx b/packages/web/src/hooks/useRecentFiles.test.tsx new file mode 100644 index 000000000..ba31a3260 --- /dev/null +++ b/packages/web/src/hooks/useRecentFiles.test.tsx @@ -0,0 +1,170 @@ +/// +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { db } from "../db/dexie"; +import { + definedIds, + recordRecentFile, + removeRecentFile, + type RecentFileEntry, + useRecentFiles, +} from "./useRecentFiles"; + +function mountUseRecentFiles() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const result: { current: ReturnType } = { + current: undefined, + }; + + function Harness() { + result.current = useRecentFiles(); + return null; + } + + act(() => { + root.render(); + }); + + return { + result, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +beforeEach(async () => { + await db.recentFiles.clear(); +}); + +afterEach(async () => { + await db.recentFiles.clear(); +}); + +describe("useRecentFiles", () => { + it("returns entries ordered by lastOpenedAt, most recent first", async () => { + // Inserted in the OPPOSITE order from lastOpenedAt (newest row added first) -- natural table/insertion order would read back [newest, oldest] unsorted, then .reverse() would wrongly flip it to [oldest, newest]. Only a genuine orderBy("lastOpenedAt") produces the correct [newest, oldest] here. + await db.recentFiles.bulkAdd([ + { format: "docx", name: "newest", sizeBytes: 1, lastOpenedAt: 2 }, + { format: "docx", name: "oldest", sizeBytes: 1, lastOpenedAt: 1 }, + ]); + const { result, unmount } = mountUseRecentFiles(); + await vi.waitFor(() => { + expect(result.current).toBeDefined(); + }); + expect(result.current?.map((entry) => entry.name)).toEqual([ + "newest", + "oldest", + ]); + unmount(); + }); + + it("caps the returned list at 20 entries even when more exist", async () => { + const entries: (RecentFileEntry & { lastOpenedAt: number })[] = Array.from( + { length: 25 }, + (_, index) => ({ + format: "docx", + name: `file-${index}`, + sizeBytes: 1, + lastOpenedAt: index, + }), + ); + await db.recentFiles.bulkAdd(entries); + const { result, unmount } = mountUseRecentFiles(); + await vi.waitFor(() => { + expect(result.current?.length).toBe(20); + }); + unmount(); + }); +}); + +describe("recordRecentFile", () => { + it("stamps the entry with the current time and stores it", async () => { + await recordRecentFile({ format: "docx", name: "a.docx", sizeBytes: 10 }); + const all = await db.recentFiles.toArray(); + expect(all).toHaveLength(1); + expect(all[0]?.name).toBe("a.docx"); + expect(typeof all[0]?.lastOpenedAt).toBe("number"); + }); + + it("evicts the single stalest entry once the table exceeds its 20-entry limit", async () => { + // Inserted in the OPPOSITE order from lastOpenedAt (file-19's own lastOpenedAt is the smallest, despite being added last) -- natural insertion order would evict whichever row happens to sort first by id, not by lastOpenedAt. Only a genuine orderBy("lastOpenedAt") picks file-19 as the actual stalest row. + await db.recentFiles.bulkAdd( + Array.from({ length: 20 }, (_, i) => ({ + format: "docx" as const, + name: `file-${i}`, + sizeBytes: 1, + lastOpenedAt: 20 - i, + })), + ); + expect(await db.recentFiles.count()).toBe(20); + + await recordRecentFile({ format: "docx", name: "file-20", sizeBytes: 1 }); + + const remaining = await db.recentFiles.count(); + expect(remaining).toBe(20); + const names = (await db.recentFiles.toArray()).map((r) => r.name); + expect(names).not.toContain("file-19"); + expect(names).toContain("file-0"); + expect(names).toContain("file-20"); + }); + + it("does not evict anything while at or under the limit", async () => { + for (let i = 0; i < 20; i++) { + await recordRecentFile({ + format: "docx", + name: `file-${i}`, + sizeBytes: 1, + }); + } + const names = (await db.recentFiles.toArray()).map((r) => r.name); + expect(names).toHaveLength(20); + expect(names).toContain("file-0"); + }); + + // A stale count of 0 (or negative, below the limit) skips the eviction query entirely, rather than running it anyway against a limit Dexie is guaranteed to resolve as "nothing to delete" -- IndexedDB's own getAll count is spec'd [EnforceRange] unsigned long, so a genuinely negative limit throws in a real browser rather than gracefully returning zero rows the way this suite's fake-indexeddb backend happens to for the specific query shape Dexie takes below the fast-path threshold. Asserting on bulkDelete's own call count (never on the resulting row count, which converges to the same "nothing changed" outcome via either path) is what actually distinguishes "skipped" from "ran and found nothing to do". + it("never queries for stale rows to delete while at or under the limit", async () => { + const bulkDeleteSpy = vi.spyOn(db.recentFiles, "bulkDelete"); + for (let i = 0; i < 20; i++) { + await recordRecentFile({ + format: "docx", + name: `file-${i}`, + sizeBytes: 1, + }); + } + expect(bulkDeleteSpy).not.toHaveBeenCalled(); + }); +}); + +describe("removeRecentFile", () => { + it("deletes the record with the given id", async () => { + const id = await db.recentFiles.add({ + format: "docx", + name: "a.docx", + sizeBytes: 1, + lastOpenedAt: 1, + }); + if (id === undefined) throw new Error("expected an auto-assigned id"); + await removeRecentFile(id); + expect(await db.recentFiles.get(id)).toBeUndefined(); + }); +}); + +describe("definedIds", () => { + it("keeps only the records whose id is actually defined", () => { + expect(definedIds([{ id: 1 }, { id: undefined }, { id: 3 }])).toEqual([ + 1, 3, + ]); + }); + + it("returns an empty array when every record's id is undefined", () => { + expect(definedIds([{ id: undefined }, { id: undefined }])).toEqual([]); + }); +}); diff --git a/packages/web/src/hooks/useRecentFiles.ts b/packages/web/src/hooks/useRecentFiles.ts index f9fc434cf..f78295733 100644 --- a/packages/web/src/hooks/useRecentFiles.ts +++ b/packages/web/src/hooks/useRecentFiles.ts @@ -5,6 +5,9 @@ import { db } from "../db/dexie"; const RECENT_FILES_LIMIT = 20; +// useLiveQuery's own deps array feeds a plain React useMemo internally (dexie-react-hooks' own useObservable): a module-scope constant, evaluated once at import time rather than a fresh literal on every call, so a mutation to its contents runs only during module load -- Stryker's own ignoreStatic setting already excludes exactly that class of mutant workspace-wide, rather than this file needing its own suppression. The querier below closes over no render-scoped value, so no dependency will ever legitimately change; a literal written inline here would still be correct, but only a stable reference this file itself controls -- not any single-element array's own particular contents -- is what useMemo's element-by-element comparison actually needs to keep re-subscribing from happening on every render. +const NO_DEPS: never[] = []; + // useLiveQuery re-runs (and every consumer re-renders) the instant any write lands in db.recentFiles -- no manual invalidation needed after recordRecentFile/removeRecentFile. export function useRecentFiles() { return useLiveQuery( @@ -14,7 +17,7 @@ export function useRecentFiles() { .reverse() .limit(RECENT_FILES_LIMIT) .toArray(), - [], + NO_DEPS, ); } @@ -25,6 +28,11 @@ export interface RecentFileEntry { handle?: FileSystemFileHandle; } +// Dexie types a record's own primary key as possibly undefined (a record that was never actually persisted), which bulkDelete's own number[] parameter can't accept -- exported so a test can drive the narrowing directly against a mixed array, since every record this module's own callers ever read back from the table already has a real assigned id. +export function definedIds(records: readonly { id?: number }[]): number[] { + return records.map((record) => record.id).filter((id) => id !== undefined); +} + // Called uniformly from FileUpload's onFile, so every tool's opens are recorded without each route wiring it up itself. FIFO eviction at write time keeps the table capped at RECENT_FILES_LIMIT rather than growing unbounded. export async function recordRecentFile(entry: RecentFileEntry) { await db.recentFiles.add({ ...entry, lastOpenedAt: Date.now() }); @@ -34,9 +42,7 @@ export async function recordRecentFile(entry: RecentFileEntry) { .orderBy("lastOpenedAt") .limit(staleCount) .toArray(); - await db.recentFiles.bulkDelete( - stale.map((record) => record.id).filter((id) => id !== undefined), - ); + await db.recentFiles.bulkDelete(definedIds(stale)); } export async function removeRecentFile(id: number) { diff --git a/packages/web/src/main.test.ts b/packages/web/src/main.test.ts new file mode 100644 index 000000000..6373b0c45 --- /dev/null +++ b/packages/web/src/main.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it, vi } from "vitest"; + +const mountApp = vi.fn(); +vi.mock("./mountApp", () => ({ mountApp })); + +describe("main entry point", () => { + it("mounts the app against the real document", async () => { + await import("./main"); + expect(mountApp).toHaveBeenCalledWith(document); + }); +}); diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index e76eac245..1637bc71a 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -2,17 +2,6 @@ import "@mantine/core/styles.css"; import "@mantine/dropzone/styles.css"; import "@mantine/notifications/styles.css"; -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; +import { mountApp } from "./mountApp"; -import { App } from "./app"; - -const container = document.getElementById("root"); -if (container === null) - throw new Error("#root element is missing from index.html"); - -createRoot(container).render( - - - , -); +mountApp(document); diff --git a/packages/web/src/mountApp.test.tsx b/packages/web/src/mountApp.test.tsx new file mode 100644 index 000000000..9011b1b00 --- /dev/null +++ b/packages/web/src/mountApp.test.tsx @@ -0,0 +1,32 @@ +/// +// tsconfig.node.json (which typechecks every *.test.ts(x)) deliberately omits the DOM lib -- this file constructs a real Document via document.implementation.createHTMLDocument, so it opts in per-file the same way contentBlocks.test.tsx does. +import { describe, expect, it, vi } from "vitest"; + +// Mocked rather than let mountApp mount the real : that would pull in the full router tree (every route, including convert.tsx's worker-backed conversion machinery), none of which is what mountApp's own logic -- the #root lookup and the createRoot(...).render(...) call -- actually needs exercised against. +const render = vi.fn(); +const createRoot = vi.fn(() => ({ render })); +vi.mock("react-dom/client", () => ({ createRoot })); + +const { mountApp } = await import("./mountApp"); + +describe("mountApp", () => { + it("throws when the document has no #root element", () => { + const doc = document.implementation.createHTMLDocument("no root"); + expect(() => { + mountApp(doc); + }).toThrow("#root element is missing from index.html"); + expect(createRoot).not.toHaveBeenCalled(); + }); + + it("mounts the app into the #root element when one exists", () => { + const doc = document.implementation.createHTMLDocument("has root"); + const container = doc.createElement("div"); + container.id = "root"; + doc.body.appendChild(container); + + mountApp(doc); + + expect(createRoot).toHaveBeenCalledWith(container); + expect(render).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/web/src/mountApp.tsx b/packages/web/src/mountApp.tsx new file mode 100644 index 000000000..bc956fde8 --- /dev/null +++ b/packages/web/src/mountApp.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./app"; + +// Extracted out of main.tsx so mountApp.test.tsx can drive the missing-#root failure path directly against a throwaway jsdom Document, without ever importing main.tsx itself -- main.tsx calls this unconditionally at module scope (the real entry point's own job), so importing it in a test would mount the real app against whatever #root element happens to exist in the test environment's own global document. +export function mountApp(rootDocument: Document): void { + const container = rootDocument.getElementById("root"); + if (container === null) + throw new Error("#root element is missing from index.html"); + + createRoot(container).render( + + + , + ); +} diff --git a/packages/web/src/routes/-Sidebar.test.tsx b/packages/web/src/routes/-Sidebar.test.tsx new file mode 100644 index 000000000..13724874b --- /dev/null +++ b/packages/web/src/routes/-Sidebar.test.tsx @@ -0,0 +1,103 @@ +import type * as TanstackRouter from "@tanstack/react-router"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { mountWithMantine } from "../test/mountComponent"; +import { computeVersionInfo, computeVersionTooltip, Sidebar } from "./-Sidebar"; + +let currentIsActive = false; +vi.mock("@tanstack/react-router", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Link: (props: { + to: string; + className: string; + children: (state: { isActive: boolean }) => React.ReactNode; + }) => ( + + {props.children({ isActive: currentIsActive })} + + ), + }; +}); + +describe("computeVersionInfo", () => { + it("links to the release tag, labelled with the tag itself, when one exists", () => { + const info = computeVersionInfo("v1.2.3", "abcdef0123456789", "https://x"); + expect(info.label).toBe("v1.2.3"); + expect(info.href).toBe("https://x/releases/tag/v1.2.3"); + }); + + it("links to the commit, labelled with its short sha, when there is no release tag", () => { + const info = computeVersionInfo(null, "abcdef0123456789", "https://x"); + expect(info.label).toBe("abcdef0"); + expect(info.href).toBe("https://x/commit/abcdef0123456789"); + }); +}); + +describe("computeVersionTooltip", () => { + it("reads 'Released ...' when a release tag exists", () => { + const tooltip = computeVersionTooltip( + "v1.2.3", + "abcdef0", + Date.now() - 1000, + ); + expect(tooltip).toBe("Released just now"); + }); + + it("reads 'Commit · ...' when there is no release tag", () => { + const tooltip = computeVersionTooltip(null, "abcdef0", Date.now() - 1000); + expect(tooltip).toBe("Commit abcdef0 · just now"); + }); +}); + +describe("Sidebar", () => { + let unmount: (() => void) | undefined; + + afterEach(() => { + unmount?.(); + unmount = undefined; + currentIsActive = false; + }); + + it("renders every nav item's label and links to its route", () => { + const mounted = mountWithMantine(); + unmount = mounted.unmount; + const html = mounted.container.innerHTML; + expect(html).toContain("Convert"); + expect(html).toContain('href="/odm"'); + }); + + it("marks the active nav item's NavLink active", () => { + currentIsActive = true; + const mounted = mountWithMantine(); + unmount = mounted.unmount; + expect( + mounted.container.querySelector(".mantine-NavLink-root"), + ).not.toBeNull(); + expect( + mounted.container + .querySelectorAll(".mantine-NavLink-root")[0] + ?.getAttribute("data-active"), + ).toBe("true"); + }); + + it("leaves nav items inactive when no route matches", () => { + currentIsActive = false; + const mounted = mountWithMantine(); + unmount = mounted.unmount; + expect( + mounted.container + .querySelectorAll(".mantine-NavLink-root")[0] + ?.getAttribute("data-active"), + ).toBeNull(); + }); + + it("renders a version anchor pointing at the current build's commit or release", () => { + const mounted = mountWithMantine(); + unmount = mounted.unmount; + const anchor = mounted.container.querySelector('a[target="_blank"]'); + expect(anchor).not.toBeNull(); + expect(anchor?.getAttribute("rel")).toBe("noopener noreferrer"); + }); +}); diff --git a/packages/web/src/routes/-Sidebar.tsx b/packages/web/src/routes/-Sidebar.tsx index 145c73ded..12b29b46c 100644 --- a/packages/web/src/routes/-Sidebar.tsx +++ b/packages/web/src/routes/-Sidebar.tsx @@ -30,20 +30,57 @@ const NAV_ITEMS = [ { to: "/odm", label: ".odm", icon: IconBooks }, ] as const; +export interface VersionInfo { + label: string; + href: string; + // IconTag and IconGitCommit share the identical generated component type, so naming both here would be a duplicate union constituent -- either one alone already types "whichever tabler icon component is picked". + Icon: typeof IconTag; +} + +// Pulled out of module scope so both branches (an exact release tag vs. a bare commit) are directly testable regardless of what this real build's own git state happens to be -- __APP_RELEASE_TAG__ (see vite.config.ts's `define` block) is whichever one is actually true of the checkout that built this bundle, not something a test run can choose between by picking an environment. +export function computeVersionInfo( + releaseTag: string | null, + commitSha: string, + repoUrl: string, +): VersionInfo { + if (releaseTag !== null) + return { + label: releaseTag, + href: `${repoUrl}/releases/tag/${releaseTag}`, + Icon: IconTag, + }; + return { + label: commitSha.slice(0, 7), + href: `${repoUrl}/commit/${commitSha}`, + Icon: IconGitCommit, + }; +} + +// Same reasoning as computeVersionInfo: the tooltip text's two forms depend only on whether a release tag exists, factored out so a test can drive both without depending on this build's real git state. +export function computeVersionTooltip( + releaseTag: string | null, + commitSha: string, + commitTimestampMs: number, +): string { + const elapsed = relativeTime(commitTimestampMs); + if (releaseTag !== null) return `Released ${elapsed}`; + return `Commit ${commitSha} · ${elapsed}`; +} + // Build-time git state (see vite.config.ts's `define` block) rather than a dry-run prediction: whenever this build's HEAD is an exact semantic-release tag, CI's own job graph guarantees that tag already exists on disk (the deploy job checks out `ref: main` fresh, strictly after the release job pushed) -- there is nothing to predict, only real state to read. -const versionLabel = __APP_RELEASE_TAG__ ?? __APP_COMMIT_SHA__.slice(0, 7); -const versionHref = - __APP_RELEASE_TAG__ !== null - ? `${__APP_REPO_URL__}/releases/tag/${__APP_RELEASE_TAG__}` - : `${__APP_REPO_URL__}/commit/${__APP_COMMIT_SHA__}`; -const VersionIcon = __APP_RELEASE_TAG__ !== null ? IconTag : IconGitCommit; +const versionInfo = computeVersionInfo( + __APP_RELEASE_TAG__, + __APP_COMMIT_SHA__, + __APP_REPO_URL__, +); export function Sidebar() { // Computed at render time, not module scope, so it stays roughly fresh across a long-lived session -- Tooltip only mounts its content while open, so there's no need for a ticking interval to keep it accurate. - const tooltipLabel = - __APP_RELEASE_TAG__ !== null - ? `Released ${relativeTime(__APP_COMMIT_TIMESTAMP__)}` - : `Commit ${__APP_COMMIT_SHA__} · ${relativeTime(__APP_COMMIT_TIMESTAMP__)}`; + const tooltipLabel = computeVersionTooltip( + __APP_RELEASE_TAG__, + __APP_COMMIT_SHA__, + __APP_COMMIT_TIMESTAMP__, + ); return ( @@ -63,7 +100,7 @@ export function Sidebar() { - - {versionLabel} + + {versionInfo.label} diff --git a/packages/web/src/routes/__root.test.tsx b/packages/web/src/routes/__root.test.tsx new file mode 100644 index 000000000..dbf1f1ea2 --- /dev/null +++ b/packages/web/src/routes/__root.test.tsx @@ -0,0 +1,151 @@ +import type * as MantineCore from "@mantine/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { mountWithMantine } from "../test/mountComponent"; +import { + activeColorSchemeOption, + colorSchemeTooltipLabel, + navbarConfig, + nextColorSchemeOption, + optionAt, + Route, +} from "./__root"; + +vi.mock("@tanstack/react-router", () => ({ + createRootRoute: (options: unknown) => ({ options }), + Outlet: () =>
, +})); + +vi.mock("./-Sidebar", () => ({ + Sidebar: () =>
, +})); + +const setColorScheme = vi.fn<(value: string) => void>(); +let colorScheme = "light"; +vi.mock("@mantine/core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useMantineColorScheme: () => ({ + colorScheme, + setColorScheme: (value: string) => { + setColorScheme(value); + }, + }), + }; +}); + +describe("optionAt", () => { + it("returns the option at a genuinely in-range index", () => { + expect(optionAt(0).value).toBe("light"); + expect(optionAt(2).value).toBe("auto"); + }); + + it("throws for an out-of-range index rather than silently substituting a fallback", () => { + expect(() => optionAt(99)).toThrow( + "Color scheme option index 99 out of range", + ); + expect(() => optionAt(-1)).toThrow( + "Color scheme option index -1 out of range", + ); + }); +}); + +describe("activeColorSchemeOption", () => { + it("finds the option matching the current value", () => { + expect(activeColorSchemeOption("dark").value).toBe("dark"); + expect(activeColorSchemeOption("auto").value).toBe("auto"); + }); + + it("falls back to the first option (light) for a value that isn't one of the three", () => { + expect(activeColorSchemeOption("not-a-real-scheme").value).toBe("light"); + }); +}); + +describe("nextColorSchemeOption", () => { + it("steps to the following option in cycle order", () => { + expect(nextColorSchemeOption("light").value).toBe("dark"); + expect(nextColorSchemeOption("dark").value).toBe("auto"); + }); + + it("wraps from the last option back to the first", () => { + expect(nextColorSchemeOption("auto").value).toBe("light"); + }); + + it("treats an unrecognised current value as if it were the first option, stepping to the second", () => { + expect(nextColorSchemeOption("not-a-real-scheme").value).toBe("dark"); + }); +}); + +describe("colorSchemeTooltipLabel", () => { + it("names the active option and offers the next one", () => { + expect(colorSchemeTooltipLabel(optionAt(0), optionAt(1))).toBe( + "Color scheme: Light (click for Dark)", + ); + }); +}); + +describe("navbarConfig", () => { + it("collapses the mobile navbar when the drawer is not open", () => { + expect(navbarConfig(false)).toEqual({ + width: 240, + breakpoint: "sm", + collapsed: { mobile: true }, + }); + }); + + it("leaves the mobile navbar expanded once the drawer is open", () => { + expect(navbarConfig(true)).toEqual({ + width: 240, + breakpoint: "sm", + collapsed: { mobile: false }, + }); + }); +}); + +describe("RootLayout", () => { + let unmount: (() => void) | undefined; + + afterEach(() => { + unmount?.(); + unmount = undefined; + colorScheme = "light"; + setColorScheme.mockClear(); + }); + + function render() { + const RootLayout = Route.options.component; + if (RootLayout === undefined) + throw new Error("root route has no component"); + const mounted = mountWithMantine(); + unmount = mounted.unmount; + return mounted.container; + } + + it("renders the sidebar and the routed outlet", () => { + const container = render(); + expect(container.querySelector('[data-testid="sidebar"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="outlet"]')).not.toBeNull(); + }); + + it("labels the colour scheme button with the active and next option", () => { + colorScheme = "light"; + const container = render(); + const button = container.querySelector( + 'button[aria-label^="Color scheme"]', + ); + expect(button?.getAttribute("aria-label")).toBe( + "Color scheme: Light. Click to switch to Dark.", + ); + }); + + it("cycles to the next colour scheme when clicked", () => { + colorScheme = "dark"; + const container = render(); + const button = container.querySelector( + 'button[aria-label^="Color scheme"]', + ); + button?.click(); + expect(setColorScheme).toHaveBeenCalledWith("auto"); + }); +}); diff --git a/packages/web/src/routes/__root.tsx b/packages/web/src/routes/__root.tsx index 742e72171..1747c43fc 100644 --- a/packages/web/src/routes/__root.tsx +++ b/packages/web/src/routes/__root.tsx @@ -24,24 +24,53 @@ const COLOR_SCHEME_OPTIONS = [ { value: "auto", label: "System", icon: IconDeviceDesktop }, ] as const; -// A computed index into a fixed-length array is `T | undefined` under noUncheckedIndexedAccess even when the arithmetic guarantees it's always in range (modulo COLOR_SCHEME_OPTIONS.length) -- this asserts that invariant explicitly rather than papering over it with a fallback option, which would silently substitute a different-but-valid choice if the arithmetic were ever wrong. -function optionAt(index: number) { +type ColorSchemeOption = (typeof COLOR_SCHEME_OPTIONS)[number]; + +// A computed index into a fixed-length array is `T | undefined` under noUncheckedIndexedAccess even when the arithmetic guarantees it's always in range (modulo COLOR_SCHEME_OPTIONS.length) -- this asserts that invariant explicitly rather than papering over it with a fallback option, which would silently substitute a different-but-valid choice if the arithmetic were ever wrong. Exported so __root.test.ts can drive the throw path directly with a genuinely out-of-range index, the only way to exercise it at all: RootLayout's own two call sites never produce one. +export function optionAt(index: number) { const option = COLOR_SCHEME_OPTIONS[index]; if (option === undefined) throw new Error(`Color scheme option index ${index} out of range`); return option; } -function RootLayout() { - const [navOpened, { toggle: toggleNav }] = useDisclosure(); - const { colorScheme, setColorScheme } = useMantineColorScheme(); +// The header button's own cycle-and-lookup logic, factored out of RootLayout so __root.test.ts can drive every branch (an unrecognised current value falling back to index 0, and the wrap-around from the last option back to the first) without mounting the real AppShell/RouterProvider tree neither of these pure lookups needs. +export function activeColorSchemeOption(currentValue: string) { const activeIndex = COLOR_SCHEME_OPTIONS.findIndex( - (option) => option.value === colorScheme, + (option) => option.value === currentValue, ); - const activeOption = optionAt(activeIndex === -1 ? 0 : activeIndex); - const nextOption = optionAt( - (Math.max(activeIndex, 0) + 1) % COLOR_SCHEME_OPTIONS.length, + return optionAt(activeIndex === -1 ? 0 : activeIndex); +} + +export function nextColorSchemeOption(currentValue: string) { + const activeIndex = COLOR_SCHEME_OPTIONS.findIndex( + (option) => option.value === currentValue, ); + return optionAt((Math.max(activeIndex, 0) + 1) % COLOR_SCHEME_OPTIONS.length); +} + +// Factored out so a test can assert the exact wording without depending on Mantine's Tooltip actually opening -- its floating content mounts into a portal only once Floating UI's own hover/focus interaction completes, which jsdom (no real layout engine, no real pointer) does not reliably drive. +export function colorSchemeTooltipLabel( + activeOption: ColorSchemeOption, + nextOption: ColorSchemeOption, +): string { + return `Color scheme: ${activeOption.label} (click for ${nextOption.label})`; +} + +// Factored out for the identical reason colorSchemeTooltipLabel is: AppShell's navbar prop drives generated CSS variables (breakpoint-keyed media queries, a collapse transform) that only Mantine's own build-time-styled internals read, so asserting against the real rendered stylesheet would test Mantine's implementation rather than this component's own logic. This pure function is what a test can actually pin down: the width and breakpoint are fixed, and collapsed.mobile is the one bit that depends on navOpened, collapsed on mobile exactly when the nav drawer is not open. +export function navbarConfig(navOpened: boolean): { + width: number; + breakpoint: string; + collapsed: { mobile: boolean }; +} { + return { width: 240, breakpoint: "sm", collapsed: { mobile: !navOpened } }; +} + +function RootLayout() { + const [navOpened, { toggle: toggleNav }] = useDisclosure(); + const { colorScheme, setColorScheme } = useMantineColorScheme(); + const activeOption = activeColorSchemeOption(colorScheme); + const nextOption = nextColorSchemeOption(colorScheme); const cycleColorScheme = () => { setColorScheme(nextOption.value); }; @@ -49,11 +78,7 @@ function RootLayout() { return ( @@ -67,9 +92,7 @@ function RootLayout() { /> documents - + ({ getRpcClient: vi.fn() })); + +const navigate = vi.fn<(options: unknown) => Promise>(); +let currentParams: { source?: string; target?: string } = {}; +vi.mock("@tanstack/react-router", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigate: () => navigate, + useParams: () => currentParams, + }; +}); + +// Stands in for the real FileUpload (already covered by its own dedicated test suite): ConvertLayout's own logic -- format detection/branch selection, the convert/download flows, and the URL-sync effect -- is what this file exercises. +let latestOnFile: ((file: OpenedFile) => void) | undefined; +let latestFile: OpenedFile | undefined; +vi.mock("../ui/FileUpload", () => ({ + FileUpload: (props: { + onFile: (file: OpenedFile) => void; + file?: OpenedFile; + }) => { + latestOnFile = props.onFile; + latestFile = props.file; + return
; + }, +})); + +// Real Mantine Select renders as a text input with no accessible way to drive its dropdown without @testing-library/user-event -- capturing its own props (as the mocked FileUpload above already does) lets this suite drive onChange directly, keyed by the Select's own label since ConvertLayout renders two ("From"/"To"). +interface CapturedSelect { + data: unknown; + value: string | null; + onChange: (value: string | null) => void; + disabled?: boolean; + description?: string; +} +let latestSelects: Record = {}; +vi.mock("@mantine/core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Select: (props: CapturedSelect & { label: string }) => { + latestSelects[props.label] = props; + return ( +
+ ); + }, + }; +}); + +function mockPreview(testId: string) { + return (props: { + label: string; + format?: string; + content?: unknown; + bytes?: unknown; + loading?: boolean; + error?: unknown; + }) => ( +
+ ); +} +vi.mock("../ui/MarkdownPreview", () => ({ + MarkdownPreview: mockPreview("markdown-preview"), +})); +vi.mock("../ui/SheetPreview", () => ({ + SheetPreview: mockPreview("sheet-preview"), +})); +vi.mock("../ui/WordProcessingPreview", () => ({ + WordProcessingPreview: mockPreview("wordprocessing-preview"), +})); +vi.mock("../ui/SlidesPreview", () => ({ + SlidesPreview: mockPreview("slides-preview"), +})); +vi.mock("../ui/FormulaPreview", () => ({ + FormulaPreview: mockPreview("formula-preview"), +})); +vi.mock("../ui/PdfPreview", () => ({ + PdfPreview: mockPreview("pdf-preview"), +})); + +vi.mock("../ui/DiagnosticsPanel", () => ({ + DiagnosticsPanel: (props: { diagnostics: readonly unknown[] }) => ( +
+ ), +})); + +let inspectPanelCalls: { + data?: { backing: string }; + loading?: boolean; + error?: unknown; +}[] = []; +vi.mock("../ui/InspectPanel", () => ({ + InspectPanel: (props: { + data?: { backing: string }; + loading?: boolean; + error?: unknown; + }) => { + inspectPanelCalls.push(props); + return ( +
+ ); + }, +})); + +const saveFile = + vi.fn< + ( + bytes: Uint8Array, + options: { suggestedName: string; mimeType: string }, + ) => Promise<{ handle?: undefined }> + >(); +vi.mock("../adapters/fileAccess/createFileAccess", () => ({ + createFileAccess: () => ({ saveFile }), +})); + +const notifyError = vi.fn<(title: string, error: unknown) => void>(); +const notifySuccess = vi.fn<(message: string, options?: unknown) => void>(); +vi.mock("../ui/notify", () => ({ + notifyError: (title: string, error: unknown) => { + notifyError(title, error); + }, + notifySuccess: (message: string, options?: unknown) => { + notifySuccess(message, options); + }, +})); + +const { getRpcClient } = await import("../rpc/client"); +const { + Route, + isContentBackedPreview, + isSheetFormat, + isSlidesFormat, + isWordProcessingFormat, +} = await import("./convert"); +const ConvertLayout = Route.options.component!; + +function openedFile(name: string): OpenedFile { + return { bytes: new Uint8Array([1, 2, 3]), name }; +} + +function mountConvertLayout() { + return mountWithProviders(); +} + +function click(element: Element) { + act(() => { + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +function convertButton(container: HTMLElement) { + return [...container.querySelectorAll("button")].find( + (candidate) => candidate.textContent === "Convert", + )!; +} + +function downloadButton(container: HTMLElement) { + return [...container.querySelectorAll("button")].find( + (candidate) => candidate.textContent === "Download", + )!; +} + +function baseClient() { + const client = createMockRpcClient(); + vi.mocked(client.formats.listConversions).mockResolvedValue([ + { source: "markdown", target: "xlsx" }, + { source: "xlsx", target: "docx" }, + { source: "docx", target: "pptx" }, + { source: "pptx", target: "odf" }, + { source: "odf", target: "pdf" }, + { source: "pdf", target: "markdown" }, + ]); + vi.mocked(client.formats.list).mockResolvedValue([ + "docx", + "pptx", + "xlsx", + "odt", + "odp", + "ods", + "odg", + "odf", + "csv", + "svg", + "markdown", + "pdf", + "rtf", + "doc", + "xls", + "ppt", + "epub", + ]); + const sampleContent: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [], + }; + vi.mocked(client.content.read).mockResolvedValue({ + content: sampleContent, + package: { ...assembleTree(sampleContent), $schema: "test" }, + }); + vi.mocked(client.pdf.inspect).mockResolvedValue({ + pageCount: 1, + itemKindCounts: {}, + metadata: {}, + layout: { formatVersion: 1, metadata: {}, pages: [], images: {} }, + }); + return client; +} + +afterEach(() => { + latestOnFile = undefined; + latestFile = undefined; + latestSelects = {}; + inspectPanelCalls = []; + currentParams = {}; + navigate.mockReset(); + notifyError.mockReset(); + notifySuccess.mockReset(); + saveFile.mockReset(); + vi.mocked(getRpcClient).mockReset(); +}); + +describe("ConvertLayout", () => { + it("renders only the FileUpload before anything is picked, with To disabled until a source exists", () => { + vi.mocked(getRpcClient).mockReturnValue(baseClient()); + const mounted = mountConvertLayout(); + expect( + mounted.container.querySelector('[data-testid="file-upload"]'), + ).not.toBeNull(); + expect(convertButton(mounted.container).disabled).toBe(true); + expect(mounted.container.textContent).not.toContain("Could not detect"); + expect(latestSelects.To?.disabled).toBe(true); + mounted.unmount(); + }); + + it("lists To's options in the same sorted order as From's, not merely with the right disabled flags", async () => { + vi.mocked(getRpcClient).mockReturnValue(baseClient()); + const mounted = mountConvertLayout(); + + act(() => { + latestOnFile?.(openedFile("a.docx")); + }); + await vi.waitFor(() => { + expect(latestSelects.To?.data).not.toEqual([]); + }); + + const targetData = latestSelects.To?.data as { value: string }[]; + const values = targetData.map((entry) => entry.value); + expect(values).toEqual([...values].sort()); + mounted.unmount(); + }); + + it("shows the could-not-detect alert for an unrecognised extension, without touching source", () => { + vi.mocked(getRpcClient).mockReturnValue(baseClient()); + const mounted = mountConvertLayout(); + + act(() => { + latestOnFile?.(openedFile("notes.xyz")); + }); + + expect(mounted.container.textContent).toContain( + `Could not detect "notes.xyz"'s format from its extension`, + ); + expect(latestSelects.From?.value).toBeNull(); + mounted.unmount(); + }); + + it.each([ + { + sourceFile: "a.md", + source: "markdown", + target: "xlsx", + originalTestId: "markdown-preview-Original", + convertedTestId: "sheet-preview-Converted", + }, + { + sourceFile: "a.xlsx", + source: "xlsx", + target: "docx", + originalTestId: "sheet-preview-Original", + convertedTestId: "wordprocessing-preview-Converted", + }, + { + sourceFile: "a.docx", + source: "docx", + target: "pptx", + originalTestId: "wordprocessing-preview-Original", + convertedTestId: "slides-preview-Converted", + }, + { + sourceFile: "a.pptx", + source: "pptx", + target: "odf", + originalTestId: "slides-preview-Original", + convertedTestId: "formula-preview-Converted", + }, + { + sourceFile: "a.odf", + source: "odf", + target: "pdf", + originalTestId: "formula-preview-Original", + convertedTestId: "pdf-preview-Converted", + }, + { + sourceFile: "a.pdf", + source: "pdf", + target: "markdown", + originalTestId: "pdf-preview-Original", + convertedTestId: "markdown-preview-Converted", + }, + ] satisfies { + sourceFile: string; + source: DocumentFormat; + target: DocumentFormat; + originalTestId: string; + convertedTestId: string; + }[])( + "converts $sourceFile ($source -> $target) into the right Original/Converted preview pair, each free of error, and inspected via the right backing", + async ({ sourceFile, source, target, originalTestId, convertedTestId }) => { + const client = baseClient(); + vi.mocked(client.convert).mockResolvedValue({ + document: { format: target, bytes: new Uint8Array([9, 9]) }, + diagnostics: [], + }); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountConvertLayout(); + + act(() => { + latestOnFile?.(openedFile(sourceFile)); + }); + expect(latestSelects.From?.value).toBe(source); + expect(mounted.container.textContent).not.toContain("Could not detect"); + expect(latestSelects.To?.disabled).toBe(false); + expect(latestSelects.From?.description).toBe("Detected from file"); + + act(() => { + latestSelects.To?.onChange(target); + }); + expect(latestSelects.To?.value).toBe(target); + + click(convertButton(mounted.container)); + await vi.waitFor(() => { + expect( + mounted.container.querySelector('[data-testid="diagnostics-panel"]'), + ).not.toBeNull(); + }); + + const [input] = vi.mocked(client.convert).mock.calls[0]!; + expect(input.source).toBe(source); + expect(input.targetFormat).toBe(target); + + const original = mounted.container.querySelector( + `[data-testid="${originalTestId}"]`, + ); + const converted = mounted.container.querySelector( + `[data-testid="${convertedTestId}"]`, + ); + expect(original).not.toBeNull(); + expect(converted).not.toBeNull(); + expect(original?.getAttribute("data-format")).toBe(source); + expect(converted?.getAttribute("data-format")).toBe(target); + expect(original?.getAttribute("data-has-error")).toBe("false"); + expect(converted?.getAttribute("data-has-error")).toBe("false"); + + // Every content.read/pdf.inspect call this pair should have triggered is a separate async operation from convert.mutate itself, settling on its own tick -- waited for explicitly rather than assumed already resolved the moment the Done panel first appears. + await vi.waitFor(() => { + const inspectPanels = mounted.container.querySelectorAll( + '[data-testid="inspect-panel"]', + ); + expect(inspectPanels[0]!.getAttribute("data-backing")).toBe( + source === "pdf" ? "pdf" : "content", + ); + expect(inspectPanels[1]!.getAttribute("data-backing")).toBe( + target === "pdf" ? "pdf" : "content", + ); + }); + const inspectPanels = mounted.container.querySelectorAll( + '[data-testid="inspect-panel"]', + ); + expect(inspectPanels).toHaveLength(2); + expect(inspectPanels[0]!.getAttribute("data-has-error")).toBe("false"); + expect(inspectPanels[1]!.getAttribute("data-has-error")).toBe("false"); + + const expectedContentReadCalls = + (source === "pdf" ? 0 : 1) + (target === "pdf" ? 0 : 1); + const expectedPdfInspectCalls = + (source === "pdf" ? 1 : 0) + (target === "pdf" ? 1 : 0); + expect(client.content.read).toHaveBeenCalledTimes( + expectedContentReadCalls, + ); + expect(client.pdf.inspect).toHaveBeenCalledTimes(expectedPdfInspectCalls); + mounted.unmount(); + }, + ); + + it("builds From as a sorted, deduplicated list of every conversion pair's source, and disables every To option the picked source cannot reach", async () => { + vi.mocked(getRpcClient).mockReturnValue(baseClient()); + const mounted = mountConvertLayout(); + + act(() => { + latestOnFile?.(openedFile("a.docx")); + }); + await vi.waitFor(() => { + expect(latestSelects.From?.data).not.toEqual([]); + }); + + expect(latestSelects.From?.data).toEqual([ + "docx", + "markdown", + "odf", + "pdf", + "pptx", + "xlsx", + ]); + const targetData = latestSelects.To?.data as { + value: string; + disabled: boolean; + }[]; + const byValue = Object.fromEntries( + targetData.map((entry) => [entry.value, entry.disabled]), + ); + expect(byValue.pptx).toBe(false); + expect(byValue.pdf).toBe(true); + expect(byValue.xlsx).toBe(true); + mounted.unmount(); + }); + + it("clears the description once the source is overridden away from the auto-detected format", () => { + vi.mocked(getRpcClient).mockReturnValue(baseClient()); + const mounted = mountConvertLayout(); + + act(() => { + latestOnFile?.(openedFile("a.docx")); + }); + expect(latestSelects.From?.description).toBe("Detected from file"); + + act(() => { + latestSelects.From?.onChange("odt"); + }); + expect(latestSelects.From?.description).toBeUndefined(); + mounted.unmount(); + }); + + it("clears a previous conversion's Done panel when the next pick's extension is unrecognised", async () => { + const client = baseClient(); + vi.mocked(client.convert).mockResolvedValue({ + document: { format: "pdf", bytes: new Uint8Array([1]) }, + diagnostics: [], + }); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountConvertLayout(); + + act(() => { + latestOnFile?.(openedFile("a.docx")); + }); + act(() => { + latestSelects.To?.onChange("pdf"); + }); + click(convertButton(mounted.container)); + await vi.waitFor(() => { + expect( + mounted.container.querySelector('[data-testid="diagnostics-panel"]'), + ).not.toBeNull(); + }); + + act(() => { + latestOnFile?.(openedFile("notes.xyz")); + }); + + expect( + mounted.container.querySelector('[data-testid="diagnostics-panel"]'), + ).toBeNull(); + mounted.unmount(); + }); + + it("never prefetches original content for a file whose extension is unrecognised", () => { + const client = baseClient(); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountConvertLayout(); + + act(() => { + latestOnFile?.(openedFile("notes.xyz")); + }); + + expect(client.content.read).not.toHaveBeenCalled(); + expect(client.pdf.inspect).not.toHaveBeenCalled(); + mounted.unmount(); + }); + + it("notifies and shows no Done panel when the conversion rejects", async () => { + const client = baseClient(); + vi.mocked(client.convert).mockRejectedValue(new Error("bad bytes")); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountConvertLayout(); + + act(() => { + latestOnFile?.(openedFile("a.docx")); + }); + act(() => { + latestSelects.To?.onChange("pdf"); + }); + click(convertButton(mounted.container)); + + await vi.waitFor(() => { + expect(notifyError).toHaveBeenCalledWith( + "Conversion failed", + expect.any(Error), + ); + }); + expect( + mounted.container.querySelector('[data-testid="diagnostics-panel"]'), + ).toBeNull(); + mounted.unmount(); + }); + + it("skips the second content read when the target is pdf", async () => { + const client = baseClient(); + vi.mocked(client.convert).mockResolvedValue({ + document: { format: "pdf", bytes: new Uint8Array([1]) }, + diagnostics: [], + }); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountConvertLayout(); + + act(() => { + latestOnFile?.(openedFile("a.md")); + }); + await vi.waitFor(() => { + // The original-side prefetch (source=markdown) already calls content.read once. + expect(client.content.read).toHaveBeenCalledTimes(1); + }); + + act(() => { + latestSelects.To?.onChange("pdf"); + }); + click(convertButton(mounted.container)); + await vi.waitFor(() => { + expect( + mounted.container.querySelector('[data-testid="diagnostics-panel"]'), + ).not.toBeNull(); + }); + + expect(client.content.read).toHaveBeenCalledTimes(1); + mounted.unmount(); + }); + + it("clears the target and any previous conversion when the source changes", async () => { + const client = baseClient(); + vi.mocked(client.convert).mockResolvedValue({ + document: { format: "pdf", bytes: new Uint8Array([1]) }, + diagnostics: [], + }); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountConvertLayout(); + + act(() => { + latestOnFile?.(openedFile("a.docx")); + }); + act(() => { + latestSelects.To?.onChange("pdf"); + }); + click(convertButton(mounted.container)); + await vi.waitFor(() => { + expect( + mounted.container.querySelector('[data-testid="diagnostics-panel"]'), + ).not.toBeNull(); + }); + + act(() => { + latestSelects.From?.onChange("odt"); + }); + + expect(latestSelects.To?.value).toBeNull(); + expect( + mounted.container.querySelector('[data-testid="diagnostics-panel"]'), + ).toBeNull(); + mounted.unmount(); + }); + + it("clears a previous conversion, but not the source, when the target changes", async () => { + const client = baseClient(); + vi.mocked(client.convert).mockResolvedValue({ + document: { format: "pdf", bytes: new Uint8Array([1]) }, + diagnostics: [], + }); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountConvertLayout(); + + act(() => { + latestOnFile?.(openedFile("a.docx")); + }); + act(() => { + latestSelects.To?.onChange("pdf"); + }); + click(convertButton(mounted.container)); + await vi.waitFor(() => { + expect( + mounted.container.querySelector('[data-testid="diagnostics-panel"]'), + ).not.toBeNull(); + }); + + act(() => { + latestSelects.To?.onChange("pptx"); + }); + + expect(latestSelects.From?.value).toBe("docx"); + expect( + mounted.container.querySelector('[data-testid="diagnostics-panel"]'), + ).toBeNull(); + mounted.unmount(); + }); + + it("syncs a complete pair into the URL via navigate, replacing the current entry", async () => { + vi.mocked(getRpcClient).mockReturnValue(baseClient()); + const mounted = mountConvertLayout(); + + act(() => { + latestOnFile?.(openedFile("a.docx")); + }); + expect(navigate).not.toHaveBeenCalled(); + + act(() => { + latestSelects.To?.onChange("pdf"); + }); + await vi.waitFor(() => { + expect(navigate).toHaveBeenCalledWith({ + to: "/convert/$source/$target", + params: { source: "docx", target: "pdf" }, + replace: true, + }); + }); + mounted.unmount(); + }); + + it("seeds source and target from the route params on first mount", () => { + currentParams = { source: "docx", target: "pdf" }; + vi.mocked(getRpcClient).mockReturnValue(baseClient()); + const mounted = mountConvertLayout(); + + expect(latestSelects.From?.value).toBe("docx"); + expect(latestSelects.To?.value).toBe("pdf"); + mounted.unmount(); + }); + + it("seeds the file and source from a pending Recent Files reopen", () => { + setPendingReopen({ file: openedFile("reopened.docx"), format: "docx" }); + vi.mocked(getRpcClient).mockReturnValue(baseClient()); + const mounted = mountConvertLayout(); + + expect(latestFile?.name).toBe("reopened.docx"); + expect(latestSelects.From?.value).toBe("docx"); + mounted.unmount(); + }); + + it("downloads the converted bytes under the source file's own basename plus the target extension", async () => { + const client = baseClient(); + const convertedBytes = new Uint8Array([7, 7, 7]); + vi.mocked(client.convert).mockResolvedValue({ + document: { format: "pdf", bytes: convertedBytes }, + diagnostics: [], + }); + saveFile.mockResolvedValue({}); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountConvertLayout(); + + act(() => { + latestOnFile?.(openedFile("report.final.docx")); + }); + act(() => { + latestSelects.To?.onChange("pdf"); + }); + click(convertButton(mounted.container)); + await vi.waitFor(() => { + expect( + mounted.container.querySelector('[data-testid="diagnostics-panel"]'), + ).not.toBeNull(); + }); + + click(downloadButton(mounted.container)); + await vi.waitFor(() => { + expect(saveFile).toHaveBeenCalledWith(convertedBytes, { + suggestedName: "report.final.pdf", + mimeType: "application/octet-stream", + }); + }); + mounted.unmount(); + }); + + it("notifies success with the conversion's own diagnostics", async () => { + const client = baseClient(); + vi.mocked(client.convert).mockResolvedValue({ + document: { format: "pdf", bytes: new Uint8Array([1]) }, + diagnostics: [ + { severity: "warning", code: "font", message: "substituted" }, + ], + }); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountConvertLayout(); + + act(() => { + latestOnFile?.(openedFile("a.docx")); + }); + act(() => { + latestSelects.To?.onChange("pdf"); + }); + click(convertButton(mounted.container)); + + await vi.waitFor(() => { + expect(notifySuccess).toHaveBeenCalledWith("Converted", { + diagnostics: [ + { severity: "warning", code: "font", message: "substituted" }, + ], + }); + }); + mounted.unmount(); + }); + + it("inspects a pdf source directly from its bytes rather than via content.read", async () => { + const client = baseClient(); + vi.mocked(client.convert).mockResolvedValue({ + document: { format: "markdown", bytes: new Uint8Array([1]) }, + diagnostics: [], + }); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountConvertLayout(); + + act(() => { + latestOnFile?.(openedFile("a.pdf")); + }); + await vi.waitFor(() => { + expect(client.pdf.inspect).toHaveBeenCalled(); + }); + expect(client.content.read).not.toHaveBeenCalled(); + + act(() => { + latestSelects.To?.onChange("markdown"); + }); + click(convertButton(mounted.container)); + await vi.waitFor(() => { + const originalPdfInspect = inspectPanelCalls.find( + (call) => call.data?.backing === "pdf", + ); + expect(originalPdfInspect).toBeDefined(); + }); + mounted.unmount(); + }); + + it("inspects a content-backed original from the already-read content, not a second RPC", async () => { + const client = baseClient(); + vi.mocked(client.convert).mockResolvedValue({ + document: { format: "pdf", bytes: new Uint8Array([1]) }, + diagnostics: [], + }); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountConvertLayout(); + + act(() => { + latestOnFile?.(openedFile("a.docx")); + }); + await vi.waitFor(() => { + expect(client.content.read).toHaveBeenCalled(); + }); + expect(client.pdf.inspect).not.toHaveBeenCalled(); + + act(() => { + latestSelects.To?.onChange("pdf"); + }); + click(convertButton(mounted.container)); + await vi.waitFor(() => { + const originalContentInspect = inspectPanelCalls.find( + (call) => call.data?.backing === "content", + ); + expect(originalContentInspect).toBeDefined(); + }); + mounted.unmount(); + }); +}); + +describe("format-family predicates", () => { + it("isSheetFormat is true for every spreadsheet-kind format and false otherwise", () => { + expect(isSheetFormat("xlsx")).toBe(true); + expect(isSheetFormat("ods")).toBe(true); + expect(isSheetFormat("csv")).toBe(true); + expect(isSheetFormat("xls")).toBe(true); + expect(isSheetFormat("docx")).toBe(false); + expect(isSheetFormat(null)).toBe(false); + }); + + it("isWordProcessingFormat is true for every wordprocessing-kind format and false otherwise", () => { + expect(isWordProcessingFormat("docx")).toBe(true); + expect(isWordProcessingFormat("odt")).toBe(true); + expect(isWordProcessingFormat("rtf")).toBe(true); + expect(isWordProcessingFormat("doc")).toBe(true); + expect(isWordProcessingFormat("epub")).toBe(true); + expect(isWordProcessingFormat("xlsx")).toBe(false); + expect(isWordProcessingFormat(null)).toBe(false); + }); + + it("isSlidesFormat is true for every presentation/drawing-kind format and false otherwise", () => { + expect(isSlidesFormat("pptx")).toBe(true); + expect(isSlidesFormat("odp")).toBe(true); + expect(isSlidesFormat("odg")).toBe(true); + expect(isSlidesFormat("svg")).toBe(true); + expect(isSlidesFormat("ppt")).toBe(true); + expect(isSlidesFormat("docx")).toBe(false); + expect(isSlidesFormat(null)).toBe(false); + }); + + it("isContentBackedPreview is false only for pdf and null", () => { + expect(isContentBackedPreview("docx")).toBe(true); + expect(isContentBackedPreview("markdown")).toBe(true); + expect(isContentBackedPreview("pdf")).toBe(false); + expect(isContentBackedPreview(null)).toBe(false); + }); +}); diff --git a/packages/web/src/routes/convert.tsx b/packages/web/src/routes/convert.tsx index cbecd6b39..74a892b3e 100644 --- a/packages/web/src/routes/convert.tsx +++ b/packages/web/src/routes/convert.tsx @@ -48,8 +48,10 @@ export const Route = createFileRoute("/convert")({ component: ConvertLayout, }); -// csv reads as a spreadsheet-kind ContentDocument (readCsvContent), so it previews through the same data grid as xlsx/ods. xls (BIFF8, readXlsContent) is the same spreadsheet kind too. -function isSheetFormat(format: string | null): boolean { +export type SheetFormat = "xlsx" | "ods" | "csv" | "xls"; + +// csv reads as a spreadsheet-kind ContentDocument (readCsvContent), so it previews through the same data grid as xlsx/ods. xls (BIFF8, readXlsContent) is the same spreadsheet kind too. A real type predicate (rather than a plain boolean) lets every call site narrow `source`/`target` straight to a valid SheetPreview `format` prop, with no separate `?? ""` fallback needed to satisfy its `string` type. +export function isSheetFormat(format: string | null): format is SheetFormat { return ( format === "xlsx" || format === "ods" || @@ -58,8 +60,12 @@ function isSheetFormat(format: string | null): boolean { ); } +export type WordProcessingFormat = "docx" | "odt" | "rtf" | "doc" | "epub"; + // doc (readDocContent) and epub (readEpubContent) are the same wordprocessing-kind ContentDocument as docx/odt/rtf. -function isWordProcessingFormat(format: string | null): boolean { +export function isWordProcessingFormat( + format: string | null, +): format is WordProcessingFormat { return ( format === "docx" || format === "odt" || @@ -69,8 +75,10 @@ function isWordProcessingFormat(format: string | null): boolean { ); } +export type SlidesFormat = "pptx" | "odp" | "odg" | "svg" | "ppt"; + // svg reads as a drawing-kind ContentDocument (readSvgContent), so it previews through the same pages/shapes/vectors renderer as odg. ppt (readPptContent) is the same presentation kind as pptx/odp. -function isSlidesFormat(format: string | null): boolean { +export function isSlidesFormat(format: string | null): format is SlidesFormat { return ( format === "pptx" || format === "odp" || @@ -81,7 +89,7 @@ function isSlidesFormat(format: string | null): boolean { } // True for every format whose preview renders the ContentDocument natively via content.read rather than a PDF rendition. PDF itself is the only exception -- its "native" representation IS the PDF bytes rendered in an iframe. -function isContentBackedPreview(format: string | null): boolean { +export function isContentBackedPreview(format: string | null): boolean { return format !== "pdf" && format !== null; } @@ -124,7 +132,8 @@ function ConvertLayout() { // Prefetches the original's content as soon as a file and its (auto-detected or manual) source are both known, rather than waiting for the user to click Convert -- so the "Original" preview panel is already populated the moment the "Done" panel appears. `mutate`'s identity is stable across renders (TanStack Query), so depending on it here doesn't retrigger this effect on every render. Skipped for PDF -- its bytes are already what PdfPreview needs. const { mutate: mutateOriginalContent } = originalContent; useEffect(() => { - if (file === undefined || source === null || source === "pdf") return; + if (file === undefined || source === "pdf") return; + // A null (nothing picked yet) or otherwise-invalid source fails this parse exactly the same way an explicit `source === null` check would have short-circuited above -- a separate null check would only re-reject a case safeParse already rejects, never a distinct one. const parsedSource = DocumentFormatSchema.safeParse(source); if (!parsedSource.success) return; mutateOriginalContent({ format: parsedSource.data, bytes: file.bytes }); @@ -165,17 +174,21 @@ function ConvertLayout() { if (detected !== undefined) handleSourceChange(detected); }; - const handleConvert = () => { - if (file === undefined || source === null || target === null) return; - // Mantine's Select works in plain strings, so `source`/`target` need re-narrowing to DocumentFormat here rather than a cast -- they can only ever hold a value drawn from sourceOptions/targetOptions, which are themselves real DocumentFormat values, so this parse cannot practically fail. - const parsedSource = DocumentFormatSchema.safeParse(source); - const parsedTarget = DocumentFormatSchema.safeParse(target); + // Called only from the Convert button below, which itself only exists (in its enabled, wired-up form) once file/source/target are all known defined -- the caller has already done that narrowing, so this takes the resolved values directly rather than re-deriving and re-checking them from component state. + const handleConvert = ( + opened: OpenedFile, + sourceValue: string, + targetValue: string, + ) => { + // Mantine's Select works in plain strings, so `sourceValue`/`targetValue` need re-narrowing to DocumentFormat here rather than a cast -- they can only ever hold a value drawn from sourceOptions/targetOptions, which are themselves real DocumentFormat values, so this parse cannot practically fail; it is still a genuine boundary validation, not dead code, since nothing about the Select's own string-based API enforces it at the type level. + const parsedSource = DocumentFormatSchema.safeParse(sourceValue); + const parsedTarget = DocumentFormatSchema.safeParse(targetValue); if (!parsedSource.success || !parsedTarget.success) return; convert.mutate( { source: parsedSource.data, targetFormat: parsedTarget.data, - bytes: file.bytes, + bytes: opened.bytes, }, { onSuccess: (result) => { @@ -223,14 +236,21 @@ function ConvertLayout() { mutateConvertedInspect(convert.data.document.bytes); }, [target, convert.data, mutateConvertedInspect]); - const handleDownload = () => { - if (convert.data === undefined) return; - void fileAccess.saveFile(convert.data.document.bytes, { - suggestedName: `${file?.name.replace(/\.[^.]+$/, "") ?? "document"}.${target ?? "bin"}`, + // Only ever called once a conversion has succeeded, which requires a file/target that were already valid at that point and neither of which this component ever clears back to undefined/null afterwards -- opened/targetFormat are taken as resolved values rather than re-reading the possibly-stale file/target state. + const handleDownload = ( + bytes: Uint8Array, + opened: OpenedFile, + targetFormat: string, + ) => { + void fileAccess.saveFile(bytes, { + suggestedName: `${opened.name.replace(/\.[^.]+$/, "")}.${targetFormat}`, mimeType: "application/octet-stream", }); }; + // Narrowed once here, as a plain const, so every reference below -- including inside the JSX event handler closures further down -- narrows to defined without each one re-deriving it from the live, always-optional convert.data. + const doneData = convert.data; + return ( // Fluid, not a fixed max-width -- Mantine's Container size prop is a static breakpoint (same cap at 1920px and 2560px alike), which is what previously left a growing dead margin on wide screens. The Done panel below applies its own clamp()-based max-width instead, so it scales continuously with viewport rather than jumping to one arbitrary number. @@ -276,29 +296,41 @@ function ConvertLayout() { /> - + {file !== undefined && source !== null && target !== null ? ( + + ) : ( + + )} - {convert.data && ( + {doneData !== undefined && ( // maxWidth scales with viewport via clamp() rather than jumping to one fixed breakpoint: never narrower than the controls column above (900px), grows at 85% of viewport width, never wider than 2200px so preview text doesn't sprawl on an ultrawide monitor. Below 900px (and always inside the fluid Container's own padding) it simply falls back to 100% of the available width. Done - + - + {source === "markdown" ? ( @@ -313,7 +345,7 @@ function ConvertLayout() { ) : isSheetFormat(source) ? ( ) : ( + // Every other branch above is a specific format check on `source`, so reaching here with `source` genuinely null (rather than "pdf") would mean this whole doneData-gated panel rendered without a completed conversion, which handleConvert/convert.reset() never allow -- same invariant as file above. )} ) : ( + // Same invariant as the Original side's own PdfPreview else-branch above: reaching here with `target` genuinely null would mean this panel rendered without a completed conversion. )} ({ getRpcClient: vi.fn() })); + +const saveFile = + vi.fn< + ( + bytes: Uint8Array, + options: { suggestedName: string; mimeType: string }, + ) => Promise<{ handle?: undefined }> + >(); +vi.mock("../adapters/fileAccess/createFileAccess", () => ({ + createFileAccess: () => ({ saveFile }), +})); + +const notifyError = vi.fn<(title: string, error: unknown) => void>(); +const notifySuccess = vi.fn<(message: string) => void>(); +vi.mock("../ui/notify", () => ({ + notifyError: (title: string, error: unknown) => { + notifyError(title, error); + }, + notifySuccess: (message: string) => { + notifySuccess(message); + }, +})); + +// Stands in for the real FileUpload (already covered by its own dedicated test suite): EditorsPage's own logic -- inferring the format, opening/editing/saving through the editor session mutations -- is what this file exercises. +let latestOnFile: ((file: OpenedFile) => void) | undefined; +let latestFile: OpenedFile | undefined; +let latestAccept: Record | undefined; +vi.mock("../ui/FileUpload", () => ({ + FileUpload: (props: { + onFile: (file: OpenedFile) => void; + file?: OpenedFile; + accept: Record; + }) => { + latestOnFile = props.onFile; + latestFile = props.file; + latestAccept = props.accept; + return
; + }, +})); + +const { getRpcClient } = await import("../rpc/client"); +const { Route } = await import("./editors"); +const EditorsPage = Route.options.component!; + +function openedFile(name: string): OpenedFile { + return { bytes: new Uint8Array([1, 2, 3]), name }; +} + +function mountEditorsPage() { + return mountWithProviders(); +} + +function paragraphTextareas(container: HTMLElement) { + return [...container.querySelectorAll("textarea")].slice(0, -1); +} + +function newParagraphTextarea(container: HTMLElement): HTMLTextAreaElement { + const all = container.querySelectorAll("textarea"); + return all[all.length - 1]!; +} + +function removeButton(container: HTMLElement, index: number) { + return container.querySelector( + `button[aria-label="Remove paragraph ${index + 1}"]`, + )!; +} + +function addButton(container: HTMLElement) { + return container.querySelector( + 'button[aria-label="Add paragraph"]', + )!; +} + +function saveButton(container: HTMLElement) { + return [...container.querySelectorAll("button")].find( + (candidate) => candidate.textContent === "Save", + )!; +} + +function typeInto(textarea: HTMLTextAreaElement, value: string) { + act(() => { + Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, + "value", + )?.set?.call(textarea, value); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +function click(element: HTMLElement) { + act(() => { + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +afterEach(() => { + latestOnFile = undefined; + latestFile = undefined; + latestAccept = undefined; + notifyError.mockReset(); + notifySuccess.mockReset(); + saveFile.mockReset(); + vi.mocked(getRpcClient).mockReset(); +}); + +describe("EditorsPage", () => { + it("renders only the FileUpload before anything is picked, restricted to docx/odt/doc/markdown", () => { + const client = createMockRpcClient(); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountEditorsPage(); + expect( + mounted.container.querySelector('[data-testid="file-upload"]'), + ).not.toBeNull(); + expect(mounted.container.querySelector("textarea")).toBeNull(); + expect(mounted.container.textContent).not.toContain( + "Could not open document", + ); + expect(latestFile).toBeUndefined(); + expect(latestAccept).toEqual({ + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + [".docx"], + "application/vnd.oasis.opendocument.text": [".odt"], + "application/msword": [".doc"], + "text/markdown": [".md", ".markdown"], + }); + mounted.unmount(); + }); + + it("notifies with a message naming the supported formats, and never opens the editor, for an unsupported extension", () => { + const client = createMockRpcClient(); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountEditorsPage(); + + act(() => { + latestOnFile?.(openedFile("notes.pdf")); + }); + + expect(notifyError).toHaveBeenCalledWith( + "Unsupported format", + new Error("the editors tool opens docx, odt, doc, or markdown files"), + ); + expect(client.editor.open).not.toHaveBeenCalled(); + expect(latestFile).toBeUndefined(); + mounted.unmount(); + }); + + it.each([ + ["report.docx", "docx"], + ["report.odt", "odt"], + ["report.doc", "doc"], + ["report.md", "markdown"], + ["report.markdown", "markdown"], + ] as const)( + "opens %s as format %s and renders its paragraph snapshot", + async (name, format) => { + const client = createMockRpcClient(); + vi.mocked(client.editor.open).mockResolvedValue({ + id: 1, + paragraphs: ["Hello"], + }); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountEditorsPage(); + + act(() => { + latestOnFile?.(openedFile(name)); + }); + await vi.waitFor(() => { + expect(paragraphTextareas(mounted.container)).toHaveLength(1); + }); + + const [input] = vi.mocked(client.editor.open).mock.calls[0]!; + expect(input.format).toBe(format); + expect(input.bytes).toBeInstanceOf(Uint8Array); + expect(mounted.container.textContent).toContain( + `${format.toUpperCase()} · 1 paragraph`, + ); + expect(mounted.container.textContent).not.toContain("1 paragraphs"); + expect(latestFile?.name).toBe(name); + mounted.unmount(); + }, + ); + + it("uses the plural 'paragraphs' label for zero or more than one paragraph", async () => { + const client = createMockRpcClient(); + vi.mocked(client.editor.open).mockResolvedValue({ id: 1, paragraphs: [] }); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountEditorsPage(); + + act(() => { + latestOnFile?.(openedFile("report.docx")); + }); + await vi.waitFor(() => { + expect(mounted.container.textContent).toContain("0 paragraphs"); + }); + + vi.mocked(client.editor.open).mockResolvedValue({ + id: 2, + paragraphs: ["a", "b"], + }); + act(() => { + latestOnFile?.(openedFile("second.docx")); + }); + await vi.waitFor(() => { + expect(mounted.container.textContent).toContain("2 paragraphs"); + }); + mounted.unmount(); + }); + + it("notifies and shows an alert when opening the document rejects", async () => { + const client = createMockRpcClient(); + vi.mocked(client.editor.open).mockRejectedValue(new Error("corrupt file")); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountEditorsPage(); + + act(() => { + latestOnFile?.(openedFile("report.docx")); + }); + await vi.waitFor(() => { + expect(notifyError).toHaveBeenCalledWith( + "Could not open document", + expect.any(Error), + ); + }); + expect(mounted.container.textContent).toContain("Could not open document"); + expect(mounted.container.textContent).toContain("Error: corrupt file"); + expect(mounted.container.querySelector("textarea")).toBeNull(); + mounted.unmount(); + }); + + it("clears a previous session's panel while the next file's open is still in flight", async () => { + const client = createMockRpcClient(); + vi.mocked(client.editor.open).mockResolvedValueOnce({ + id: 1, + paragraphs: ["first"], + }); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountEditorsPage(); + + act(() => { + latestOnFile?.(openedFile("first.docx")); + }); + await vi.waitFor(() => { + expect(paragraphTextareas(mounted.container)).toHaveLength(1); + }); + + vi.mocked(client.editor.open).mockReturnValue(new Promise(() => {})); + act(() => { + latestOnFile?.(openedFile("second.docx")); + }); + + expect(mounted.container.querySelector("textarea")).toBeNull(); + mounted.unmount(); + }); + + it("edits only the changed paragraph optimistically, leaving the others untouched, and applies the committed server snapshot on blur", async () => { + const client = createMockRpcClient(); + vi.mocked(client.editor.open).mockResolvedValue({ + id: 7, + paragraphs: ["First", "Second"], + }); + vi.mocked(client.editor.setParagraphText).mockResolvedValue({ + id: 7, + paragraphs: ["First (normalised)", "Second"], + }); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountEditorsPage(); + + act(() => { + latestOnFile?.(openedFile("report.docx")); + }); + await vi.waitFor(() => { + expect(paragraphTextareas(mounted.container)).toHaveLength(2); + }); + + const [first, second] = paragraphTextareas(mounted.container); + expect( + first!.closest(".mantine-Textarea-root")!.style.flex, + ).toBe("1 1 0%"); + expect( + second!.closest(".mantine-Textarea-root")!.style.flex, + ).toBe("1 1 0%"); + typeInto(first!, "First (edited)"); + expect(client.editor.setParagraphText).not.toHaveBeenCalled(); + expect(paragraphTextareas(mounted.container)[0]!.value).toBe( + "First (edited)", + ); + expect(paragraphTextareas(mounted.container)[1]!.value).toBe("Second"); + + act(() => { + second!.focus(); + }); + act(() => { + first!.dispatchEvent(new FocusEvent("focusout", { bubbles: true })); + }); + await vi.waitFor(() => { + expect(client.editor.setParagraphText).toHaveBeenCalled(); + }); + const [input] = vi.mocked(client.editor.setParagraphText).mock.calls[0]!; + expect(input).toEqual({ id: 7, index: 0, text: "First (edited)" }); + + // The committed response's own text, distinct from what was typed, proves the resolved snapshot -- not just the optimistic edit already on screen -- is what ends up rendered. + await vi.waitFor(() => { + expect(paragraphTextareas(mounted.container)[0]!.value).toBe( + "First (normalised)", + ); + }); + expect(paragraphTextareas(mounted.container)[1]!.value).toBe("Second"); + mounted.unmount(); + }); + + it("notifies when committing a paragraph edit rejects", async () => { + const client = createMockRpcClient(); + vi.mocked(client.editor.open).mockResolvedValue({ + id: 1, + paragraphs: ["Original"], + }); + vi.mocked(client.editor.setParagraphText).mockRejectedValue( + new Error("session gone"), + ); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountEditorsPage(); + + act(() => { + latestOnFile?.(openedFile("report.docx")); + }); + await vi.waitFor(() => { + expect(paragraphTextareas(mounted.container)).toHaveLength(1); + }); + + const textarea = paragraphTextareas(mounted.container)[0]!; + act(() => { + textarea.dispatchEvent(new FocusEvent("focusout", { bubbles: true })); + }); + await vi.waitFor(() => { + expect(notifyError).toHaveBeenCalledWith( + "Could not edit paragraph", + expect.any(Error), + ); + }); + mounted.unmount(); + }); + + it("disables the add button until a new paragraph is typed, then adds and clears it", async () => { + const client = createMockRpcClient(); + vi.mocked(client.editor.open).mockResolvedValue({ + id: 3, + paragraphs: ["one"], + }); + vi.mocked(client.editor.addParagraph).mockResolvedValue({ + id: 3, + paragraphs: ["one", "two"], + }); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountEditorsPage(); + + act(() => { + latestOnFile?.(openedFile("report.docx")); + }); + await vi.waitFor(() => { + expect(paragraphTextareas(mounted.container)).toHaveLength(1); + }); + + expect(addButton(mounted.container).disabled).toBe(true); + expect( + newParagraphTextarea(mounted.container).closest( + ".mantine-Textarea-root", + )!.style.flex, + ).toBe("1 1 0%"); + typeInto(newParagraphTextarea(mounted.container), "two"); + expect(addButton(mounted.container).disabled).toBe(false); + + click(addButton(mounted.container)); + await vi.waitFor(() => { + expect(paragraphTextareas(mounted.container)).toHaveLength(2); + }); + + const [input] = vi.mocked(client.editor.addParagraph).mock.calls[0]!; + expect(input).toEqual({ id: 3, text: "two" }); + expect(newParagraphTextarea(mounted.container).value).toBe(""); + expect(addButton(mounted.container).disabled).toBe(true); + mounted.unmount(); + }); + + it("notifies when adding a paragraph rejects", async () => { + const client = createMockRpcClient(); + vi.mocked(client.editor.open).mockResolvedValue({ + id: 1, + paragraphs: [], + }); + vi.mocked(client.editor.addParagraph).mockRejectedValue( + new Error("session gone"), + ); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountEditorsPage(); + + act(() => { + latestOnFile?.(openedFile("report.docx")); + }); + await vi.waitFor(() => { + expect(newParagraphTextarea(mounted.container)).toBeDefined(); + }); + + typeInto(newParagraphTextarea(mounted.container), "two"); + click(addButton(mounted.container)); + await vi.waitFor(() => { + expect(notifyError).toHaveBeenCalledWith( + "Could not add paragraph", + expect.any(Error), + ); + }); + mounted.unmount(); + }); + + it("removes the paragraph at the clicked row's own index", async () => { + const client = createMockRpcClient(); + vi.mocked(client.editor.open).mockResolvedValue({ + id: 5, + paragraphs: ["first", "second"], + }); + vi.mocked(client.editor.removeParagraph).mockResolvedValue({ + id: 5, + paragraphs: ["first"], + }); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountEditorsPage(); + + act(() => { + latestOnFile?.(openedFile("report.docx")); + }); + await vi.waitFor(() => { + expect(paragraphTextareas(mounted.container)).toHaveLength(2); + }); + + click(removeButton(mounted.container, 1)); + await vi.waitFor(() => { + expect(paragraphTextareas(mounted.container)).toHaveLength(1); + }); + + const [input] = vi.mocked(client.editor.removeParagraph).mock.calls[0]!; + expect(input).toEqual({ id: 5, index: 1 }); + mounted.unmount(); + }); + + it("notifies when removing a paragraph rejects", async () => { + const client = createMockRpcClient(); + vi.mocked(client.editor.open).mockResolvedValue({ + id: 1, + paragraphs: ["only"], + }); + vi.mocked(client.editor.removeParagraph).mockRejectedValue( + new Error("session gone"), + ); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountEditorsPage(); + + act(() => { + latestOnFile?.(openedFile("report.docx")); + }); + await vi.waitFor(() => { + expect(paragraphTextareas(mounted.container)).toHaveLength(1); + }); + + click(removeButton(mounted.container, 0)); + await vi.waitFor(() => { + expect(notifyError).toHaveBeenCalledWith( + "Could not remove paragraph", + expect.any(Error), + ); + }); + mounted.unmount(); + }); + + it("saves, notifies success, and downloads the written bytes", async () => { + const client = createMockRpcClient(); + vi.mocked(client.editor.open).mockResolvedValue({ + id: 9, + paragraphs: ["only"], + }); + const writtenBytes = new Uint8Array([4, 5, 6]); + vi.mocked(client.editor.save).mockResolvedValue({ bytes: writtenBytes }); + saveFile.mockResolvedValue({}); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountEditorsPage(); + + act(() => { + latestOnFile?.(openedFile("report.docx")); + }); + await vi.waitFor(() => { + expect(paragraphTextareas(mounted.container)).toHaveLength(1); + }); + + click(saveButton(mounted.container)); + await vi.waitFor(() => { + expect(notifySuccess).toHaveBeenCalledWith("Document saved"); + }); + + const [input] = vi.mocked(client.editor.save).mock.calls[0]!; + expect(input).toEqual({ id: 9 }); + expect(saveFile).toHaveBeenCalledWith(writtenBytes, { + suggestedName: "report.docx", + mimeType: "application/octet-stream", + }); + mounted.unmount(); + }); + + it("shows the Save button as loading while the save is in flight", async () => { + const client = createMockRpcClient(); + vi.mocked(client.editor.open).mockResolvedValue({ + id: 1, + paragraphs: ["only"], + }); + vi.mocked(client.editor.save).mockReturnValue(new Promise(() => {})); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountEditorsPage(); + + act(() => { + latestOnFile?.(openedFile("report.docx")); + }); + await vi.waitFor(() => { + expect(paragraphTextareas(mounted.container)).toHaveLength(1); + }); + + expect(saveButton(mounted.container).getAttribute("data-loading")).not.toBe( + "true", + ); + click(saveButton(mounted.container)); + await vi.waitFor(() => { + expect(saveButton(mounted.container).getAttribute("data-loading")).toBe( + "true", + ); + }); + mounted.unmount(); + }); + + it("notifies when saving rejects and never downloads", async () => { + const client = createMockRpcClient(); + vi.mocked(client.editor.open).mockResolvedValue({ + id: 1, + paragraphs: ["only"], + }); + vi.mocked(client.editor.save).mockRejectedValue(new Error("write failed")); + vi.mocked(getRpcClient).mockReturnValue(client); + const mounted = mountEditorsPage(); + + act(() => { + latestOnFile?.(openedFile("report.docx")); + }); + await vi.waitFor(() => { + expect(paragraphTextareas(mounted.container)).toHaveLength(1); + }); + + click(saveButton(mounted.container)); + await vi.waitFor(() => { + expect(notifyError).toHaveBeenCalledWith( + "Could not save document", + expect.any(Error), + ); + }); + expect(saveFile).not.toHaveBeenCalled(); + mounted.unmount(); + }); +}); diff --git a/packages/web/src/routes/editors.tsx b/packages/web/src/routes/editors.tsx index 6d484a85c..ae5936343 100644 --- a/packages/web/src/routes/editors.tsx +++ b/packages/web/src/routes/editors.tsx @@ -32,13 +32,16 @@ export const Route = createFileRoute("/editors")({ type EditorFormat = "docx" | "odt" | "doc" | "markdown"; +// The opened file, its inferred format, and its live snapshot always change together (the file/format are only ever set alongside the mutate() call whose result seeds the snapshot) and are only ever meaningful as a trio -- one state value carrying all three, rather than three separate pieces of state, is what makes that invariant a type-level fact instead of something every reader (and paragraph action below) needs to defensively re-check. +interface EditorSession { + file: OpenedFile; + format: EditorFormat; + snapshot: { id: number; paragraphs: string[] }; +} + // The Editors tool: an in-browser editing surface over documents.js's live-view editors, which run in the worker and hold the document itself -- every edit below is applied to the live document through the rpc session (nothing is buffered client-side), and Save re-serialises the whole document through the format's own writer. The v1 surface is the paragraph list every format family shares: edit a paragraph's text in place, append, remove, save. Formats beyond these four (and deeper per-run styling) stay out until they have the same genuine cross-format surface. function EditorsPage() { - const [file, setFile] = useState(undefined); - const [format, setFormat] = useState(undefined); - const [snapshot, setSnapshot] = useState< - { id: number; paragraphs: string[] } | undefined - >(undefined); + const [session, setSession] = useState(undefined); const [newParagraph, setNewParagraph] = useState(""); const openEditor = useOpenEditor(); @@ -57,14 +60,14 @@ function EditorsPage() { ); return; } - setFile(opened); - setFormat(inferred); - setSnapshot(undefined); - openEditor.reset(); + setSession(undefined); + // No reset() call precedes this: mutate() itself already clears any previous open's data/error the instant this dispatch starts, before its own result settles. openEditor.mutate( { format: inferred, bytes: opened.bytes }, { - onSuccess: setSnapshot, + onSuccess: (snapshot) => { + setSession({ file: opened, format: inferred, snapshot }); + }, onError: (error) => { notifyError("Could not open document", error); }, @@ -72,12 +75,14 @@ function EditorsPage() { ); }; - const applySet = (index: number, text: string) => { - if (snapshot === undefined) return; + // Every paragraph action below is wired only to elements rendered inside the `session !== undefined` panel further down, so by the time any of them can actually run, the session (and its file/snapshot) is already known to be defined -- there is no separate guard to check here. + const applySet = (index: number, text: string, session: EditorSession) => { setParagraphText.mutate( - { id: snapshot.id, index, text }, + { id: session.snapshot.id, index, text }, { - onSuccess: setSnapshot, + onSuccess: (snapshot) => { + setSession({ ...session, snapshot }); + }, onError: (error) => { notifyError("Could not edit paragraph", error); }, @@ -85,13 +90,12 @@ function EditorsPage() { ); }; - const applyAdd = () => { - if (snapshot === undefined || newParagraph === "") return; + const applyAdd = (session: EditorSession) => { addParagraph.mutate( - { id: snapshot.id, text: newParagraph }, + { id: session.snapshot.id, text: newParagraph }, { - onSuccess: (next) => { - setSnapshot(next); + onSuccess: (snapshot) => { + setSession({ ...session, snapshot }); setNewParagraph(""); }, onError: (error) => { @@ -101,12 +105,13 @@ function EditorsPage() { ); }; - const applyRemove = (index: number) => { - if (snapshot === undefined) return; + const applyRemove = (index: number, session: EditorSession) => { removeParagraph.mutate( - { id: snapshot.id, index }, + { id: session.snapshot.id, index }, { - onSuccess: setSnapshot, + onSuccess: (snapshot) => { + setSession({ ...session, snapshot }); + }, onError: (error) => { notifyError("Could not remove paragraph", error); }, @@ -114,15 +119,14 @@ function EditorsPage() { ); }; - const applySave = () => { - if (snapshot === undefined || file === undefined) return; + const applySave = (session: EditorSession) => { saveEditor.mutate( - { id: snapshot.id }, + { id: session.snapshot.id }, { onSuccess: (result) => { notifySuccess("Document saved"); void fileAccess.saveFile(result.bytes, { - suggestedName: file.name, + suggestedName: session.file.name, mimeType: "application/octet-stream", }); }, @@ -152,26 +156,31 @@ function EditorsPage() { "text/markdown": [".md", ".markdown"], }} formatHint="docx, odt, doc, or markdown" - file={file} + file={session?.file} loading={openEditor.isPending} /> - {snapshot !== undefined && ( + {session !== undefined && ( - {format?.toUpperCase()} · {snapshot.paragraphs.length}{" "} - {snapshot.paragraphs.length === 1 ? "paragraph" : "paragraphs"} + {session.format.toUpperCase()} ·{" "} + {session.snapshot.paragraphs.length}{" "} + {session.snapshot.paragraphs.length === 1 + ? "paragraph" + : "paragraphs"} - {snapshot.paragraphs.map((text, index) => ( + {session.snapshot.paragraphs.map((text, index) => (