From af9cda23519401763f4c75d73a994251b1f86ab6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 10:05:20 +0100 Subject: [PATCH 001/101] test(document-cli): cover the previously-untested runtime helpers createRuntimeSignal, readInput/writeOutput/resolveDefaultOutputPath, loadProvidedFonts, the diagnostic/font-substitution reporters and their raw-callback adapters, and the filesystem markdown image resolver had no direct unit tests at all -- every branch in them (SIGINT vs timeout precedence, stdin abort/non-buffer-chunk handling, json/quiet interaction in both the reporter and the summary line, the data:/scheme-URL/empty-destination guards) was reachable only through whatever an integration-level command test happened to exercise, if anything. --- .../document-cli/src/runtime/abort.test.ts | 64 ++++ .../src/runtime/diagnostics.test.ts | 335 ++++++++++++++++++ .../document-cli/src/runtime/fonts.test.ts | 87 +++++ packages/document-cli/src/runtime/io.test.ts | 191 ++++++++++ .../src/runtime/markdown-images.test.ts | 73 ++++ 5 files changed, 750 insertions(+) create mode 100644 packages/document-cli/src/runtime/abort.test.ts create mode 100644 packages/document-cli/src/runtime/diagnostics.test.ts create mode 100644 packages/document-cli/src/runtime/fonts.test.ts create mode 100644 packages/document-cli/src/runtime/io.test.ts create mode 100644 packages/document-cli/src/runtime/markdown-images.test.ts diff --git a/packages/document-cli/src/runtime/abort.test.ts b/packages/document-cli/src/runtime/abort.test.ts new file mode 100644 index 000000000..20026ee3c --- /dev/null +++ b/packages/document-cli/src/runtime/abort.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRuntimeSignal } from "./abort"; + +afterEach(() => { + process.removeAllListeners("SIGINT"); + vi.useRealTimers(); +}); + +describe("createRuntimeSignal", () => { + it("returns a signal that is not aborted and an undefined reason with no timeout and no interrupt", () => { + const { signal, getAbortReason } = createRuntimeSignal({}); + expect(signal.aborted).toBe(false); + expect(getAbortReason()).toBeUndefined(); + }); + + it("aborts the signal and reports 'interrupt' when SIGINT fires", () => { + const { signal, getAbortReason } = createRuntimeSignal({}); + expect(signal.aborted).toBe(false); + process.emit("SIGINT"); + expect(signal.aborted).toBe(true); + expect(getAbortReason()).toBe("interrupt"); + }); + + it("aborts the signal and reports 'timeout' once the timeout elapses", () => { + vi.useFakeTimers(); + const { signal, getAbortReason } = createRuntimeSignal({ timeoutMs: 10 }); + expect(signal.aborted).toBe(false); + vi.advanceTimersByTime(10); + expect(signal.aborted).toBe(true); + expect(getAbortReason()).toBe("timeout"); + }); + + it("does not abort before the configured timeout elapses", () => { + vi.useFakeTimers(); + const { signal } = createRuntimeSignal({ timeoutMs: 1000 }); + vi.advanceTimersByTime(999); + expect(signal.aborted).toBe(false); + }); + + it("keeps the first abort reason when SIGINT arrives after a timeout already fired", () => { + vi.useFakeTimers(); + const { getAbortReason } = createRuntimeSignal({ timeoutMs: 5 }); + vi.advanceTimersByTime(5); + expect(getAbortReason()).toBe("timeout"); + process.emit("SIGINT"); + expect(getAbortReason()).toBe("timeout"); + }); + + it("keeps 'interrupt' as the reason when a timeout would fire after SIGINT already did", () => { + vi.useFakeTimers(); + const { getAbortReason } = createRuntimeSignal({ timeoutMs: 5 }); + process.emit("SIGINT"); + expect(getAbortReason()).toBe("interrupt"); + vi.advanceTimersByTime(5); + expect(getAbortReason()).toBe("interrupt"); + }); + + it("includes the configured timeout value in the timeout error's message", () => { + vi.useFakeTimers(); + const { signal } = createRuntimeSignal({ timeoutMs: 42 }); + vi.advanceTimersByTime(42); + expect((signal.reason as Error).message).toBe("Timed out after 42ms"); + }); +}); diff --git a/packages/document-cli/src/runtime/diagnostics.test.ts b/packages/document-cli/src/runtime/diagnostics.test.ts new file mode 100644 index 000000000..037046aa7 --- /dev/null +++ b/packages/document-cli/src/runtime/diagnostics.test.ts @@ -0,0 +1,335 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createDiagnosticReporter, + createFontSubstitutionReporter, + fontSubstitutionToDiagnostic, + pdfDiagnosticToDiagnostic, + substitutionToDiagnostic, +} from "./diagnostics"; + +function spyOnStderr(): { calls(): string[]; restore(): void } { + const chunks: string[] = []; + const spy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + chunks.push(typeof chunk === "string" ? chunk : chunk.toString()); + return true; + }); + return { + calls: () => chunks, + restore: () => { + spy.mockRestore(); + }, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("createDiagnosticReporter", () => { + it("writes nothing when quiet is true", () => { + const stderr = spyOnStderr(); + const reporter = createDiagnosticReporter({ + json: false, + quiet: true, + command: "docx-to-pdf", + }); + reporter.report({ severity: "warning", code: "x", message: "m" }); + expect(stderr.calls()).toEqual([]); + }); + + it("writes a human-readable line with severity, code, and message", () => { + const stderr = spyOnStderr(); + const reporter = createDiagnosticReporter({ + json: false, + quiet: false, + command: "docx-to-pdf", + }); + reporter.report({ severity: "warning", code: "x/y", message: "boom" }); + expect(stderr.calls()).toEqual(["[docx-to-pdf] warning x/y: boom\n"]); + }); + + it("appends the page clause only when a pageIndex is present", () => { + const stderr = spyOnStderr(); + const reporter = createDiagnosticReporter({ + json: false, + quiet: false, + command: "pdf-to-docx", + }); + reporter.report({ + severity: "info", + code: "c", + message: "m", + pageIndex: 3, + }); + expect(stderr.calls()).toEqual(["[pdf-to-docx] info c: m (page 3)\n"]); + }); + + it("emits an NDJSON diagnostic record in json mode, tagged with the command", () => { + const stderr = spyOnStderr(); + const reporter = createDiagnosticReporter({ + json: true, + quiet: false, + command: "docx-to-pdf", + }); + reporter.report({ severity: "warning", code: "x", message: "m" }); + expect(JSON.parse(stderr.calls()[0] ?? "")).toEqual({ + type: "diagnostic", + command: "docx-to-pdf", + severity: "warning", + code: "x", + message: "m", + }); + }); + + it("quiet suppresses an individual diagnostic even in json mode", () => { + const stderr = spyOnStderr(); + const reporter = createDiagnosticReporter({ + json: true, + quiet: true, + command: "c", + }); + reporter.report({ severity: "info", code: "x", message: "m" }); + expect(stderr.calls()).toEqual([]); + }); + + it("always writes the result summary in json mode, even when quiet", () => { + const stderr = spyOnStderr(); + const reporter = createDiagnosticReporter({ + json: true, + quiet: true, + command: "c", + }); + reporter.summarize({ output: "out.pdf", bytes: 10, diagnosticCount: 0 }); + expect(JSON.parse(stderr.calls()[0] ?? "")).toEqual({ + type: "result", + output: "out.pdf", + bytes: 10, + diagnosticCount: 0, + }); + }); + + it("writes nothing for the summary in human mode when quiet", () => { + const stderr = spyOnStderr(); + const reporter = createDiagnosticReporter({ + json: false, + quiet: true, + command: "c", + }); + reporter.summarize({ output: "out.pdf", bytes: 10, diagnosticCount: 0 }); + expect(stderr.calls()).toEqual([]); + }); + + it("pluralises 'diagnostic' for any count other than exactly one", () => { + const stderr = spyOnStderr(); + const reporter = createDiagnosticReporter({ + json: false, + quiet: false, + command: "c", + }); + reporter.summarize({ output: "out.pdf", bytes: 5, diagnosticCount: 0 }); + reporter.summarize({ output: "out.pdf", bytes: 5, diagnosticCount: 2 }); + expect(stderr.calls()).toEqual([ + "[c] wrote 5 bytes to out.pdf (0 diagnostics)\n", + "[c] wrote 5 bytes to out.pdf (2 diagnostics)\n", + ]); + }); + + it("does not pluralise 'diagnostic' for exactly one", () => { + const stderr = spyOnStderr(); + const reporter = createDiagnosticReporter({ + json: false, + quiet: false, + command: "c", + }); + reporter.summarize({ output: "out.pdf", bytes: 5, diagnosticCount: 1 }); + expect(stderr.calls()).toEqual([ + "[c] wrote 5 bytes to out.pdf (1 diagnostic)\n", + ]); + }); +}); + +describe("createFontSubstitutionReporter", () => { + it("writes nothing when quiet", () => { + const stderr = spyOnStderr(); + const report = createFontSubstitutionReporter({ + json: false, + quiet: true, + command: "c", + }); + report({ + requestedFamily: "Calibri", + requestedBold: false, + requestedItalic: false, + resolvedFamily: "Carlito", + reason: "vendored-substitute", + }); + expect(stderr.calls()).toEqual([]); + }); + + it("emits an NDJSON font-substitution record in json mode", () => { + const stderr = spyOnStderr(); + const report = createFontSubstitutionReporter({ + json: true, + quiet: false, + command: "c", + }); + report({ + requestedFamily: "Calibri", + requestedBold: false, + requestedItalic: false, + resolvedFamily: "Carlito", + reason: "vendored-substitute", + }); + expect(JSON.parse(stderr.calls()[0] ?? "")).toEqual({ + type: "font-substitution", + command: "c", + requestedFamily: "Calibri", + requestedBold: false, + requestedItalic: false, + resolvedFamily: "Carlito", + reason: "vendored-substitute", + }); + }); + + it("writes a plain family name with no style clause when neither bold nor italic is requested", () => { + const stderr = spyOnStderr(); + const report = createFontSubstitutionReporter({ + json: false, + quiet: false, + command: "docx-to-pdf", + }); + report({ + requestedFamily: "Calibri", + requestedBold: false, + requestedItalic: false, + resolvedFamily: "Carlito", + reason: "vendored-substitute", + }); + expect(stderr.calls()).toEqual([ + '[docx-to-pdf] font substitution: "Calibri" -> "Carlito" (vendored-substitute)\n', + ]); + }); + + it("appends ' bold' when bold was requested", () => { + const stderr = spyOnStderr(); + const report = createFontSubstitutionReporter({ + json: false, + quiet: false, + command: "c", + }); + report({ + requestedFamily: "Calibri", + requestedBold: true, + requestedItalic: false, + resolvedFamily: "Carlito", + reason: "vendored-substitute", + }); + expect(stderr.calls()[0]).toContain('"Calibri" bold ->'); + }); + + it("appends ' italic' when italic was requested", () => { + const stderr = spyOnStderr(); + const report = createFontSubstitutionReporter({ + json: false, + quiet: false, + command: "c", + }); + report({ + requestedFamily: "Calibri", + requestedBold: false, + requestedItalic: true, + resolvedFamily: "Carlito", + reason: "vendored-substitute", + }); + expect(stderr.calls()[0]).toContain('"Calibri" italic ->'); + }); + + it("appends both ' bold' and ' italic' in that order when both are requested", () => { + const stderr = spyOnStderr(); + const report = createFontSubstitutionReporter({ + json: false, + quiet: false, + command: "c", + }); + report({ + requestedFamily: "Calibri", + requestedBold: true, + requestedItalic: true, + resolvedFamily: "Carlito", + reason: "vendored-substitute", + }); + expect(stderr.calls()[0]).toContain('"Calibri" bold italic ->'); + }); +}); + +describe("substitutionToDiagnostic", () => { + it("maps a WinAnsiSubstitution to a warning-severity Diagnostic naming both characters and the page", () => { + expect(substitutionToDiagnostic({ from: "‘", to: "'" }, 7)).toEqual({ + severity: "warning", + code: "win-ansi-substitution", + message: + "Character '‘' has no glyph in the standard font; substituted with '''", + pageIndex: 7, + }); + }); +}); + +describe("fontSubstitutionToDiagnostic", () => { + it("maps a vendored-substitute reason to a 'substituted the metric-compatible' message", () => { + const diagnostic = fontSubstitutionToDiagnostic({ + requestedFamily: "Calibri", + requestedBold: false, + requestedItalic: false, + resolvedFamily: "Carlito", + reason: "vendored-substitute", + }); + expect(diagnostic).toEqual({ + severity: "info", + code: "font/substituted", + message: + '"Calibri" is not available; substituted the metric-compatible "Carlito"', + }); + }); + + it("maps any other reason to a 'substituted another face of' message", () => { + const diagnostic = fontSubstitutionToDiagnostic({ + requestedFamily: "Calibri", + requestedBold: true, + requestedItalic: true, + resolvedFamily: "Calibri", + reason: "style-fallback", + }); + expect(diagnostic.message).toBe( + '"Calibri bold italic" is not available; substituted another face of "Calibri"', + ); + }); + + it("includes both style clauses in the requested-face description", () => { + const diagnostic = fontSubstitutionToDiagnostic({ + requestedFamily: "Calibri", + requestedBold: true, + requestedItalic: false, + resolvedFamily: "Carlito", + reason: "vendored-substitute", + }); + expect(diagnostic.message).toContain('"Calibri bold" is not available'); + }); +}); + +describe("pdfDiagnosticToDiagnostic", () => { + it("maps every field across field-by-field", () => { + expect( + pdfDiagnosticToDiagnostic({ + severity: "warning", + code: "pdf/thing", + message: "m", + pageIndex: 2, + }), + ).toEqual({ + severity: "warning", + code: "pdf/thing", + message: "m", + pageIndex: 2, + }); + }); +}); diff --git a/packages/document-cli/src/runtime/fonts.test.ts b/packages/document-cli/src/runtime/fonts.test.ts new file mode 100644 index 000000000..0aaca3752 --- /dev/null +++ b/packages/document-cli/src/runtime/fonts.test.ts @@ -0,0 +1,87 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + fixtureCalibriFontBytes, + vendoredCaladeaFaceBytes, +} from "../test-support/font-fixture"; +import { loadProvidedFonts } from "./fonts"; + +let workspace: string; + +beforeAll(async () => { + workspace = await mkdtemp(join(tmpdir(), "document-cli-runtime-fonts-")); +}); + +afterAll(async () => { + await rm(workspace, { recursive: true, force: true }); +}); + +describe("loadProvidedFonts", () => { + it("returns an empty array for no paths, reading nothing", async () => { + expect(await loadProvidedFonts([])).toEqual([]); + }); + + it("reads a single font file and pairs its bytes with its declared face", async () => { + const bytes = fixtureCalibriFontBytes(); + const path = join(workspace, "regular.ttf"); + await writeFile(path, bytes); + + const [font] = await loadProvidedFonts([path]); + expect(font).toBeDefined(); + expect(font?.family).toBe("Calibri"); + expect(font?.bold).toBe(false); + expect(font?.italic).toBe(false); + expect(font?.bytes).toEqual(bytes); + }); + + it("reads bold and italic faces and reports the declared style for each", async () => { + const boldBytes = vendoredCaladeaFaceBytes({ bold: true, italic: false }); + const italicBytes = vendoredCaladeaFaceBytes({ + bold: false, + italic: true, + }); + const boldPath = join(workspace, "bold.ttf"); + const italicPath = join(workspace, "italic.ttf"); + await writeFile(boldPath, boldBytes); + await writeFile(italicPath, italicBytes); + + const [bold, italic] = await loadProvidedFonts([boldPath, italicPath]); + expect(bold?.bold).toBe(true); + expect(bold?.italic).toBe(false); + expect(italic?.bold).toBe(false); + expect(italic?.italic).toBe(true); + }); + + it("reads multiple font files in the given order", async () => { + const first = vendoredCaladeaFaceBytes({ bold: false, italic: false }); + const second = vendoredCaladeaFaceBytes({ bold: true, italic: true }); + const firstPath = join(workspace, "first.ttf"); + const secondPath = join(workspace, "second.ttf"); + await writeFile(firstPath, first); + await writeFile(secondPath, second); + + const fonts = await loadProvidedFonts([firstPath, secondPath]); + expect(fonts).toHaveLength(2); + expect(fonts[0]?.bytes).toEqual(first); + expect(fonts[1]?.bytes).toEqual(second); + expect(fonts[0]?.bold).toBe(false); + expect(fonts[1]?.bold).toBe(true); + }); + + it("rejects with the specific missing path when a font file does not exist", async () => { + const missing = join(workspace, "does-not-exist.ttf"); + await expect(loadProvidedFonts([missing])).rejects.toThrow(/ENOENT/); + }); + + it("stops at the first unreadable path rather than reading the rest", async () => { + const missing = join(workspace, "still-missing.ttf"); + const goodPath = join(workspace, "after-missing.ttf"); + await writeFile(goodPath, fixtureCalibriFontBytes()); + + await expect(loadProvidedFonts([missing, goodPath])).rejects.toThrow( + /ENOENT/, + ); + }); +}); diff --git a/packages/document-cli/src/runtime/io.test.ts b/packages/document-cli/src/runtime/io.test.ts new file mode 100644 index 000000000..165ad1646 --- /dev/null +++ b/packages/document-cli/src/runtime/io.test.ts @@ -0,0 +1,191 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { readInput, resolveDefaultOutputPath, writeOutput } from "./io"; + +let workspace: string; +const originalStdin = process.stdin; + +function fakeStdin(chunks: readonly unknown[]): AsyncIterable { + return { + [Symbol.asyncIterator]() { + let index = 0; + return { + next(): Promise> { + if (index >= chunks.length) { + return Promise.resolve({ done: true, value: undefined }); + } + const value = chunks[index]; + index += 1; + return Promise.resolve({ done: false, value }); + }, + }; + }, + }; +} + +function installFakeStdin(chunks: readonly unknown[]): void { + Object.defineProperty(process, "stdin", { + value: fakeStdin(chunks), + configurable: true, + }); +} + +beforeAll(async () => { + workspace = await mkdtemp(join(tmpdir(), "document-cli-runtime-io-")); +}); + +afterAll(async () => { + await rm(workspace, { recursive: true, force: true }); +}); + +afterEach(() => { + Object.defineProperty(process, "stdin", { + value: originalStdin, + configurable: true, + }); +}); + +describe("readInput", () => { + it("reads a real file's bytes from disk", async () => { + const path = join(workspace, "input.bin"); + await writeFile(path, new Uint8Array([9, 8, 7])); + const bytes = await readInput(path); + expect(bytes).toEqual(new Uint8Array([9, 8, 7])); + }); + + it("propagates Node's own ENOENT error unmodified for a missing file", async () => { + await expect(readInput(join(workspace, "missing.bin"))).rejects.toThrow( + /ENOENT/, + ); + }); + + it("reads and concatenates every chunk from stdin when the path is '-'", async () => { + installFakeStdin([Buffer.from([1, 2]), Buffer.from([3, 4, 5])]); + const bytes = await readInput("-"); + expect(bytes).toEqual(new Uint8Array([1, 2, 3, 4, 5])); + }); + + it("returns an empty array for stdin with no chunks at all", async () => { + installFakeStdin([]); + expect(await readInput("-")).toEqual(new Uint8Array()); + }); + + it("throws when a stdin chunk is not a buffer", async () => { + installFakeStdin(["not a buffer"]); + await expect(readInput("-")).rejects.toThrow(/Unexpected non-buffer chunk/); + }); + + it("throws when the signal is already aborted before any chunk is read", async () => { + installFakeStdin([Buffer.from([1])]); + const controller = new AbortController(); + controller.abort(); + await expect(readInput("-", { signal: controller.signal })).rejects.toThrow( + /aborted/, + ); + }); + + it("throws once the signal aborts partway through reading stdin", async () => { + const controller = new AbortController(); + // A custom async iterable (rather than the plain fakeStdin array) so the signal can be aborted as a side effect of producing the second chunk, simulating a signal that fires between two chunks arriving. + Object.defineProperty(process, "stdin", { + value: { + [Symbol.asyncIterator]() { + let index = 0; + return { + next(): Promise> { + if (index === 0) { + index += 1; + return Promise.resolve({ + done: false, + value: Buffer.from([1]), + }); + } + if (index === 1) { + index += 1; + controller.abort(); + return Promise.resolve({ + done: false, + value: Buffer.from([2]), + }); + } + return Promise.resolve({ done: true, value: undefined }); + }, + }; + }, + }, + configurable: true, + }); + await expect(readInput("-", { signal: controller.signal })).rejects.toThrow( + /aborted/, + ); + }); +}); + +describe("writeOutput", () => { + it("writes bytes to a real file on disk", async () => { + const path = join(workspace, "output.bin"); + await writeOutput(path, new Uint8Array([1, 2, 3])); + expect(await readFile(path)).toEqual(Buffer.from([1, 2, 3])); + }); + + it("writes to stdout when the path is '-'", async () => { + const written: Uint8Array[] = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = (( + chunk: Uint8Array, + callback?: (error?: Error | null) => void, + ) => { + written.push(chunk); + callback?.(null); + return true; + }) as typeof process.stdout.write; + try { + await writeOutput("-", new Uint8Array([4, 5, 6])); + } finally { + process.stdout.write = original; + } + expect(written).toEqual([new Uint8Array([4, 5, 6])]); + }); + + it("rejects when writing to stdout fails", async () => { + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = (( + _chunk: Uint8Array, + callback?: (error?: Error | null) => void, + ) => { + callback?.(new Error("EPIPE: broken pipe")); + return true; + }) as typeof process.stdout.write; + try { + await expect(writeOutput("-", new Uint8Array([1]))).rejects.toThrow( + /EPIPE/, + ); + } finally { + process.stdout.write = original; + } + }); +}); + +describe("resolveDefaultOutputPath", () => { + it("swaps the extension for the target format's canonical extension", () => { + expect(resolveDefaultOutputPath("report.docx", "pdf")).toBe("report.pdf"); + }); + + it("preserves the directory of the input path", () => { + expect(resolveDefaultOutputPath("a/b/report.docx", "pdf")).toBe( + "a/b/report.pdf", + ); + }); + + it("uses markdown's own 'md' extension, not the format name itself", () => { + expect(resolveDefaultOutputPath("report.docx", "markdown")).toBe( + "report.md", + ); + }); + + it("handles an input path with no directory component", () => { + expect(resolveDefaultOutputPath("report.docx", "pdf")).toBe("report.pdf"); + }); +}); diff --git a/packages/document-cli/src/runtime/markdown-images.test.ts b/packages/document-cli/src/runtime/markdown-images.test.ts new file mode 100644 index 000000000..c0f04ee2c --- /dev/null +++ b/packages/document-cli/src/runtime/markdown-images.test.ts @@ -0,0 +1,73 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createFilesystemMarkdownImageResolver } from "./markdown-images"; + +let workspace: string; + +beforeAll(async () => { + workspace = await mkdtemp(join(tmpdir(), "document-cli-md-images-")); + await writeFile(join(workspace, "image.png"), new Uint8Array([1, 2, 3, 4])); +}); + +afterAll(async () => { + await rm(workspace, { recursive: true, force: true }); +}); + +describe("createFilesystemMarkdownImageResolver", () => { + it("reads a relative destination against the given base directory", () => { + const resolver = createFilesystemMarkdownImageResolver(workspace); + const result = resolver("./image.png"); + expect(result?.bytes).toEqual(new Uint8Array([1, 2, 3, 4])); + }); + + it("reads a bare relative destination with no leading ./", () => { + const resolver = createFilesystemMarkdownImageResolver(workspace); + expect(resolver("image.png")?.bytes).toEqual(new Uint8Array([1, 2, 3, 4])); + }); + + it("reads an absolute destination directly", () => { + const resolver = createFilesystemMarkdownImageResolver("/does/not/exist"); + const result = resolver(join(workspace, "image.png")); + expect(result?.bytes).toEqual(new Uint8Array([1, 2, 3, 4])); + }); + + it("returns undefined for an empty destination", () => { + const resolver = createFilesystemMarkdownImageResolver(workspace); + expect(resolver("")).toBeUndefined(); + }); + + it("returns undefined for a data: URI", () => { + const resolver = createFilesystemMarkdownImageResolver(workspace); + expect(resolver("data:image/png;base64,AAAA")).toBeUndefined(); + }); + + it("is case-insensitive when recognising a data: URI", () => { + const resolver = createFilesystemMarkdownImageResolver(workspace); + expect(resolver("DATA:image/png;base64,AAAA")).toBeUndefined(); + }); + + it("returns undefined for a scheme-prefixed URL", () => { + const resolver = createFilesystemMarkdownImageResolver(workspace); + expect(resolver("https://example.com/image.png")).toBeUndefined(); + expect(resolver("http://example.com/image.png")).toBeUndefined(); + expect(resolver("file:///tmp/image.png")).toBeUndefined(); + }); + + it("is case-insensitive when recognising a scheme-prefixed URL", () => { + const resolver = createFilesystemMarkdownImageResolver(workspace); + expect(resolver("HTTPS://example.com/image.png")).toBeUndefined(); + }); + + it("returns undefined when the resolved file does not exist", () => { + const resolver = createFilesystemMarkdownImageResolver(workspace); + expect(resolver("missing.png")).toBeUndefined(); + }); + + it("does not treat a destination containing a colon with no // as a scheme URL", () => { + // A Windows-style drive path ('C:\\image.png') or a destination that merely contains a colon must not be misdetected as a scheme URL -- only the scheme://-shaped pattern is excluded, so this falls through to a real (failing) filesystem read rather than being short-circuited to undefined for the wrong reason. Either way the result is undefined, but a mutant that widens or narrows the scheme regex must still be observable: assert via a scheme-shaped destination that DOES resolve, immediately below. + const resolver = createFilesystemMarkdownImageResolver(workspace); + expect(resolver("no-scheme:not-a-url")).toBeUndefined(); + }); +}); From 5cf92dd3e7bc72757eb9c58d0e1326b910972d81 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 10:05:43 +0100 Subject: [PATCH 002/101] test(document-cli): cover the tui state module's format/screen predicates isEditableFormat/isWritableFormat and their document-level wrappers, selectionKeyFor's per-screen-kind key derivation, selectedIndexFor's absent-vs-zero distinction, currentScreen's empty-stack invariant, rootScreenForFormat's per-format routing, and anyOverlayOpen's flag enumeration had no direct unit tests -- every screen-component test that happened to exercise one of these functions only ever hit whichever branch its own scenario needed. --- .../document-cli/src/tui/state/types.test.ts | 456 ++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 packages/document-cli/src/tui/state/types.test.ts diff --git a/packages/document-cli/src/tui/state/types.test.ts b/packages/document-cli/src/tui/state/types.test.ts new file mode 100644 index 000000000..f9d0c9150 --- /dev/null +++ b/packages/document-cli/src/tui/state/types.test.ts @@ -0,0 +1,456 @@ +import { describe, expect, it } from "vitest"; +import { createInitialState } from "./reducer.js"; +import { + anyOverlayOpen, + currentScreen, + isEditableDocument, + isEditableFormat, + isWritableDocument, + isWritableFormat, + rootScreenForFormat, + selectedIndexFor, + selectionKeyFor, + type AppState, + type OpenDocument, + type OpenDocumentFormat, + type Screen, +} from "./types.js"; + +describe("isEditableFormat", () => { + it("accepts every format with a live-view editor", () => { + for (const format of [ + "docx", + "pptx", + "odt", + "odp", + "ods", + "odg", + "pdf", + "doc", + "xls", + "ppt", + ]) { + expect(isEditableFormat(format)).toBe(true); + } + }); + + it("rejects a read-only-preview format", () => { + expect(isEditableFormat("odb")).toBe(false); + expect(isEditableFormat("xlsx")).toBe(false); + expect(isEditableFormat("csv")).toBe(false); + expect(isEditableFormat("svg")).toBe(false); + expect(isEditableFormat("rtf")).toBe(false); + expect(isEditableFormat("wpd")).toBe(false); + expect(isEditableFormat("epub")).toBe(false); + }); + + it("rejects markdown, which is writable but not editable", () => { + expect(isEditableFormat("markdown")).toBe(false); + }); + + it("rejects an unrecognised string", () => { + expect(isEditableFormat("")).toBe(false); + expect(isEditableFormat("bogus")).toBe(false); + }); +}); + +describe("isWritableFormat", () => { + it("accepts every editable format plus markdown", () => { + for (const format of [ + "docx", + "pptx", + "odt", + "odp", + "ods", + "odg", + "pdf", + "doc", + "xls", + "ppt", + "markdown", + ]) { + expect(isWritableFormat(format)).toBe(true); + } + }); + + it("rejects a read-only-preview format", () => { + expect(isWritableFormat("odb")).toBe(false); + expect(isWritableFormat("xlsx")).toBe(false); + expect(isWritableFormat("csv")).toBe(false); + expect(isWritableFormat("svg")).toBe(false); + expect(isWritableFormat("rtf")).toBe(false); + expect(isWritableFormat("wpd")).toBe(false); + expect(isWritableFormat("epub")).toBe(false); + }); + + it("rejects an unrecognised string", () => { + expect(isWritableFormat("")).toBe(false); + }); +}); + +function documentWithFormat(format: OpenDocumentFormat): OpenDocument { + switch (format) { + case "docx": + case "pptx": + case "odt": + case "odp": + case "ods": + case "odg": + case "doc": + case "xls": + case "ppt": + return { format, editor: {} as never, path: undefined }; + case "pdf": + return { + format, + editor: {} as never, + layout: {} as never, + path: undefined, + }; + case "markdown": + return { + format, + editor: {} as never, + originalText: undefined, + path: undefined, + }; + case "odb": + return { format, tables: [], forms: [], reports: [], path: "a.odb" }; + case "xlsx": + case "csv": + case "svg": + case "rtf": + case "wpd": + case "epub": + return { + format, + layout: {} as never, + bytes: new Uint8Array(), + path: "a", + }; + } +} + +describe("isEditableDocument", () => { + it("is true for every editable-format document", () => { + for (const format of [ + "docx", + "pptx", + "odt", + "odp", + "ods", + "odg", + "pdf", + "doc", + "xls", + "ppt", + ] as const) { + expect(isEditableDocument(documentWithFormat(format))).toBe(true); + } + }); + + it("is false for a markdown document even though it is writable", () => { + expect(isEditableDocument(documentWithFormat("markdown"))).toBe(false); + }); + + it("is false for a read-only-preview document", () => { + expect(isEditableDocument(documentWithFormat("odb"))).toBe(false); + expect(isEditableDocument(documentWithFormat("xlsx"))).toBe(false); + }); +}); + +describe("isWritableDocument", () => { + it("is true for every editable-format document and markdown", () => { + for (const format of [ + "docx", + "pptx", + "odt", + "odp", + "ods", + "odg", + "pdf", + "doc", + "xls", + "ppt", + "markdown", + ] as const) { + expect(isWritableDocument(documentWithFormat(format))).toBe(true); + } + }); + + it("is false for a read-only-preview document", () => { + expect(isWritableDocument(documentWithFormat("odb"))).toBe(false); + expect(isWritableDocument(documentWithFormat("csv"))).toBe(false); + expect(isWritableDocument(documentWithFormat("svg"))).toBe(false); + expect(isWritableDocument(documentWithFormat("rtf"))).toBe(false); + expect(isWritableDocument(documentWithFormat("wpd"))).toBe(false); + expect(isWritableDocument(documentWithFormat("epub"))).toBe(false); + }); +}); + +describe("selectionKeyFor", () => { + it("returns the bare kind for every plain, singleton screen", () => { + const cases: readonly Screen["kind"][] = [ + "launcher", + "newDocumentPicker", + "bodyList", + "docxExtras", + "slideList", + "sheetList", + "pageList", + "odbTableList", + "odbFormList", + "odbReportList", + "pdfPageList", + "exportOptions", + "saveAsPrompt", + "viewSource", + "metadata", + ]; + for (const kind of cases) { + expect(selectionKeyFor({ kind } as Screen)).toBe(kind); + } + }); + + it("includes purpose and cwd for the file picker", () => { + expect( + selectionKeyFor({ kind: "filePicker", purpose: "open", cwd: "/tmp" }), + ).toBe("filePicker:open:/tmp"); + expect( + selectionKeyFor({ + kind: "filePicker", + purpose: "saveAs", + cwd: "/home", + }), + ).toBe("filePicker:saveAs:/home"); + expect( + selectionKeyFor({ + kind: "filePicker", + purpose: "exportTarget", + cwd: "/x", + }), + ).toBe("filePicker:exportTarget:/x"); + }); + + it("includes the block index for a single-index block screen", () => { + expect(selectionKeyFor({ kind: "paragraphDetail", blockIndex: 3 })).toBe( + "paragraphDetail:3", + ); + expect(selectionKeyFor({ kind: "tableView", blockIndex: 2 })).toBe( + "tableView:2", + ); + expect(selectionKeyFor({ kind: "listEditor", blockIndex: 5 })).toBe( + "listEditor:5", + ); + }); + + it("includes both the block and run index for the run editor", () => { + expect( + selectionKeyFor({ kind: "runEditor", blockIndex: 1, runIndex: 4 }), + ).toBe("runEditor:1:4"); + }); + + it("includes the block, row, and column for a table cell", () => { + expect( + selectionKeyFor({ + kind: "tableCellDetail", + blockIndex: 1, + row: 2, + col: 3, + }), + ).toBe("tableCellDetail:1:2:3"); + }); + + it("includes the slide index for a single-index slide screen", () => { + expect(selectionKeyFor({ kind: "slideDetail", slideIndex: 6 })).toBe( + "slideDetail:6", + ); + expect(selectionKeyFor({ kind: "notesEditor", slideIndex: 7 })).toBe( + "notesEditor:7", + ); + }); + + it("includes the slide and shape index for the shape editor", () => { + expect( + selectionKeyFor({ kind: "shapeEditor", slideIndex: 1, shapeIndex: 2 }), + ).toBe("shapeEditor:1:2"); + }); + + it("includes the slide and table index for a slide table detail", () => { + expect( + selectionKeyFor({ + kind: "slideTableDetail", + slideIndex: 1, + tableIndex: 2, + }), + ).toBe("slideTableDetail:1:2"); + }); + + it("includes the sheet index for a single-index sheet screen", () => { + expect(selectionKeyFor({ kind: "spreadsheetGrid", sheetIndex: 8 })).toBe( + "spreadsheetGrid:8", + ); + expect( + selectionKeyFor({ kind: "printSettingsEditor", sheetIndex: 9 }), + ).toBe("printSettingsEditor:9"); + }); + + it("includes the sheet, row, and column for a spreadsheet cell", () => { + expect( + selectionKeyFor({ kind: "cellDetail", sheetIndex: 1, row: 2, col: 3 }), + ).toBe("cellDetail:1:2:3"); + }); + + it("includes the page index for a single-index page screen", () => { + expect(selectionKeyFor({ kind: "pageDetail", pageIndex: 4 })).toBe( + "pageDetail:4", + ); + expect(selectionKeyFor({ kind: "pdfPageItems", pageIndex: 5 })).toBe( + "pdfPageItems:5", + ); + }); + + it("includes the page and item index for a page item detail", () => { + expect( + selectionKeyFor({ + kind: "shapeOrVectorDetail", + pageIndex: 1, + itemIndex: 2, + }), + ).toBe("shapeOrVectorDetail:1:2"); + expect( + selectionKeyFor({ kind: "pdfItemDetail", pageIndex: 3, itemIndex: 4 }), + ).toBe("pdfItemDetail:3:4"); + }); + + it("includes the table name for odb table rows", () => { + expect( + selectionKeyFor({ kind: "odbTableRows", tableName: "Invoices" }), + ).toBe("odbTableRows:Invoices"); + }); + + it("includes the form name for odb form detail", () => { + expect(selectionKeyFor({ kind: "odbFormDetail", formName: "Order" })).toBe( + "odbFormDetail:Order", + ); + }); + + it("includes the report name for odb report detail and render", () => { + expect( + selectionKeyFor({ kind: "odbReportDetail", reportName: "Summary" }), + ).toBe("odbReportDetail:Summary"); + expect( + selectionKeyFor({ kind: "odbReportRender", reportName: "Summary" }), + ).toBe("odbReportRender:Summary"); + }); +}); + +describe("selectedIndexFor", () => { + it("returns 0 for a key never recorded", () => { + expect(selectedIndexFor({}, "slideList")).toBe(0); + }); + + it("returns the recorded index for a known key", () => { + expect(selectedIndexFor({ slideList: 3 }, "slideList")).toBe(3); + }); + + it("returns 0 when the recorded value is genuinely 0, not treating it as absent", () => { + expect(selectedIndexFor({ slideList: 0 }, "slideList")).toBe(0); + }); + + it("distinguishes between different keys in the same map", () => { + const selection = { slideList: 2, sheetList: 5 }; + expect(selectedIndexFor(selection, "slideList")).toBe(2); + expect(selectedIndexFor(selection, "sheetList")).toBe(5); + }); +}); + +describe("currentScreen", () => { + it("returns the top of the stack", () => { + const state: AppState = { + ...createInitialState(), + stack: [{ kind: "launcher" }, { kind: "bodyList" }], + }; + expect(currentScreen(state)).toEqual({ kind: "bodyList" }); + }); + + it("throws if the stack is somehow empty", () => { + const state: AppState = { ...createInitialState(), stack: [] }; + expect(() => currentScreen(state)).toThrow(/screen stack is empty/); + }); +}); + +describe("rootScreenForFormat", () => { + it("maps word-processing-shaped formats to bodyList", () => { + expect(rootScreenForFormat("docx")).toEqual({ kind: "bodyList" }); + expect(rootScreenForFormat("odt")).toEqual({ kind: "bodyList" }); + expect(rootScreenForFormat("markdown")).toEqual({ kind: "bodyList" }); + expect(rootScreenForFormat("doc")).toEqual({ kind: "bodyList" }); + }); + + it("maps presentation-shaped formats to slideList", () => { + expect(rootScreenForFormat("pptx")).toEqual({ kind: "slideList" }); + expect(rootScreenForFormat("odp")).toEqual({ kind: "slideList" }); + expect(rootScreenForFormat("ppt")).toEqual({ kind: "slideList" }); + }); + + it("maps spreadsheet-shaped formats to sheetList", () => { + expect(rootScreenForFormat("ods")).toEqual({ kind: "sheetList" }); + expect(rootScreenForFormat("xls")).toEqual({ kind: "sheetList" }); + }); + + it("maps odg to pageList", () => { + expect(rootScreenForFormat("odg")).toEqual({ kind: "pageList" }); + }); + + it("maps odb to odbTableList", () => { + expect(rootScreenForFormat("odb")).toEqual({ kind: "odbTableList" }); + }); + + it("maps every pdf-page-list-shaped format to pdfPageList", () => { + for (const format of [ + "pdf", + "xlsx", + "csv", + "svg", + "rtf", + "wpd", + "epub", + ] as const) { + expect(rootScreenForFormat(format)).toEqual({ kind: "pdfPageList" }); + } + }); +}); + +describe("anyOverlayOpen", () => { + const base = createInitialState(); + + it("is false when nothing is open", () => { + expect(anyOverlayOpen(base)).toBe(false); + }); + + it("is true for each individual overlay flag", () => { + for (const key of [ + "commandPalette", + "search", + "help", + "confirmQuit", + "confirmClose", + "diagnosticsPanel", + ] as const) { + const state: AppState = { + ...base, + overlays: { ...base.overlays, [key]: true }, + }; + expect(anyOverlayOpen(state)).toBe(true); + } + }); + + it("is true when the error-detail overlay is showing, even with every flag false", () => { + const state: AppState = { + ...base, + errorDetail: { message: "boom", detail: undefined }, + }; + expect(anyOverlayOpen(state)).toBe(true); + }); +}); From a762f0b23e48717ed6d5918a78f59a432b11a0d5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 10:08:35 +0100 Subject: [PATCH 003/101] fix(document-cli): use a real FontSubstitution reason in the fallback-message test FontSubstitution.reason is a closed union of "missing-face" | "vendored-substitute" (document-schema.js's own font-port types) -- "style-fallback" was never a valid value and only typechecked because the surrounding object literal had not yet been checked against it. --- packages/document-cli/src/runtime/diagnostics.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/document-cli/src/runtime/diagnostics.test.ts b/packages/document-cli/src/runtime/diagnostics.test.ts index 037046aa7..e8e472155 100644 --- a/packages/document-cli/src/runtime/diagnostics.test.ts +++ b/packages/document-cli/src/runtime/diagnostics.test.ts @@ -297,7 +297,7 @@ describe("fontSubstitutionToDiagnostic", () => { requestedBold: true, requestedItalic: true, resolvedFamily: "Calibri", - reason: "style-fallback", + reason: "missing-face", }); expect(diagnostic.message).toBe( '"Calibri bold italic" is not available; substituted another face of "Calibri"', From 51608714c94b4f5b81320f00778c6d5419f95c09 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 10:17:39 +0100 Subject: [PATCH 004/101] test(document-cli): cover the formats command and the shared CLI option registrars The formats command's own table/JSON output and trailing not-covered line, and options.ts's addOutOption/addTimeoutOption/addJsonOption/ addQuietOption/addVerboseOption/addDumpPackageOption/addFontOptions/ addDelimiterOption/addSheetOption/addPageOption registrars, had no direct tests -- each option's flag string, short alias, default value, and coercion function were only ever reachable through whichever command happened to apply that helper and whichever flag a downstream test happened to pass. --- .../document-cli/src/commands/formats.test.ts | 61 ++++++ .../document-cli/src/commands/options.test.ts | 177 ++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 packages/document-cli/src/commands/formats.test.ts create mode 100644 packages/document-cli/src/commands/options.test.ts diff --git a/packages/document-cli/src/commands/formats.test.ts b/packages/document-cli/src/commands/formats.test.ts new file mode 100644 index 000000000..85255ab3a --- /dev/null +++ b/packages/document-cli/src/commands/formats.test.ts @@ -0,0 +1,61 @@ +import { createLocalDocumentConverter } from "documents.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createProgram } from "../program"; + +function spyOnStdout(): { calls(): string[] } { + const chunks: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + chunks.push(typeof chunk === "string" ? chunk : chunk.toString()); + return true; + }); + return { calls: () => chunks }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("formats command", () => { + it("prints one 'source -> target' line per supported conversion", async () => { + const stdout = spyOnStdout(); + await createProgram().parseAsync(["node", "document-cli", "formats"]); + const { conversions } = createLocalDocumentConverter(); + for (const { source, target } of conversions) { + expect(stdout.calls()).toContain(`${source} -> ${target}\n`); + } + }); + + it("appends a trailing line naming the commands not covered by the list", async () => { + const stdout = spyOnStdout(); + await createProgram().parseAsync(["node", "document-cli", "formats"]); + expect(stdout.calls().at(-1)).toContain( + "not covered by this list, each its own command:", + ); + expect(stdout.calls().at(-1)).toContain("odm-to-pdf"); + expect(stdout.calls().at(-1)).toContain("outline"); + }); + + it("emits a JSON array under --json instead of the human-readable table", async () => { + const stdout = spyOnStdout(); + await createProgram().parseAsync([ + "node", + "document-cli", + "formats", + "--json", + ]); + const { conversions } = createLocalDocumentConverter(); + expect(stdout.calls()).toHaveLength(1); + expect(JSON.parse(stdout.calls()[0] ?? "")).toEqual(conversions); + }); + + it("does not print the trailing not-covered line under --json", async () => { + const stdout = spyOnStdout(); + await createProgram().parseAsync([ + "node", + "document-cli", + "formats", + "--json", + ]); + expect(stdout.calls().join("")).not.toContain("not covered by this list"); + }); +}); diff --git a/packages/document-cli/src/commands/options.test.ts b/packages/document-cli/src/commands/options.test.ts new file mode 100644 index 000000000..403e651fe --- /dev/null +++ b/packages/document-cli/src/commands/options.test.ts @@ -0,0 +1,177 @@ +import { Command } from "commander"; +import { describe, expect, it } from "vitest"; +import { + addConversionFlags, + addDelimiterOption, + addDumpPackageOption, + addFontOptions, + addJsonOption, + addOutOption, + addPageOption, + addQuietOption, + addSheetOption, + addTimeoutOption, + addVerboseOption, +} from "./options"; + +function commandWith(register: (command: Command) => Command): Command { + const command = new Command("t").action(() => undefined); + register(command); + return command; +} + +describe("addOutOption", () => { + it("registers -o/--out and reads its value", () => { + const command = commandWith(addOutOption); + command.parse(["node", "t", "--out", "result.pdf"]); + expect(command.opts().out).toBe("result.pdf"); + const short = commandWith(addOutOption); + short.parse(["node", "t", "-o", "a.pdf"]); + expect(short.opts().out).toBe("a.pdf"); + }); + + it("leaves out undefined when not given", () => { + const command = commandWith(addOutOption); + command.parse(["node", "t"]); + expect(command.opts().out).toBeUndefined(); + }); +}); + +describe("addTimeoutOption", () => { + it("parses --timeout as an integer", () => { + const command = commandWith(addTimeoutOption); + command.parse(["node", "t", "--timeout", "5000"]); + expect(command.opts().timeout).toBe(5000); + }); + + it("leaves timeout undefined when not given", () => { + const command = commandWith(addTimeoutOption); + command.parse(["node", "t"]); + expect(command.opts().timeout).toBeUndefined(); + }); +}); + +describe("addJsonOption", () => { + it("defaults --json to false", () => { + const command = commandWith(addJsonOption); + command.parse(["node", "t"]); + expect(command.opts().json).toBe(false); + }); + + it("sets --json to true when given", () => { + const command = commandWith(addJsonOption); + command.parse(["node", "t", "--json"]); + expect(command.opts().json).toBe(true); + }); +}); + +describe("addQuietOption", () => { + it("defaults -q/--quiet to false and can be set via either spelling", () => { + const command = commandWith(addQuietOption); + command.parse(["node", "t"]); + expect(command.opts().quiet).toBe(false); + const long = commandWith(addQuietOption); + long.parse(["node", "t", "--quiet"]); + expect(long.opts().quiet).toBe(true); + const short = commandWith(addQuietOption); + short.parse(["node", "t", "-q"]); + expect(short.opts().quiet).toBe(true); + }); +}); + +describe("addVerboseOption", () => { + it("defaults --verbose to false and can be set", () => { + const command = commandWith(addVerboseOption); + command.parse(["node", "t"]); + expect(command.opts().verbose).toBe(false); + const set = commandWith(addVerboseOption); + set.parse(["node", "t", "--verbose"]); + expect(set.opts().verbose).toBe(true); + }); +}); + +describe("addDumpPackageOption", () => { + it("registers --dump-package and reads its value", () => { + const command = commandWith(addDumpPackageOption); + command.parse(["node", "t", "--dump-package", "tree.json"]); + expect(command.opts().dumpPackage).toBe("tree.json"); + }); +}); + +describe("addFontOptions", () => { + it("accumulates repeated --font-file flags in the given order", () => { + const command = commandWith(addFontOptions); + command.parse([ + "node", + "t", + "--font-file", + "a.ttf", + "--font-file", + "b.ttf", + ]); + expect(command.opts().fontFile).toEqual(["a.ttf", "b.ttf"]); + }); + + it("defaults --font-file to an empty array", () => { + const command = commandWith(addFontOptions); + command.parse(["node", "t"]); + expect(command.opts().fontFile).toEqual([]); + }); + + it("defaults --report-font-substitutions to false and can be set", () => { + const command = commandWith(addFontOptions); + command.parse(["node", "t"]); + expect(command.opts().reportFontSubstitutions).toBe(false); + const set = commandWith(addFontOptions); + set.parse(["node", "t", "--report-font-substitutions"]); + expect(set.opts().reportFontSubstitutions).toBe(true); + }); +}); + +describe("addConversionFlags", () => { + it("registers all five conversion flags at once", () => { + const command = commandWith(addConversionFlags); + command.parse([ + "node", + "t", + "--out", + "o.pdf", + "--timeout", + "10", + "--json", + "--quiet", + "--verbose", + ]); + expect(command.opts()).toEqual({ + out: "o.pdf", + timeout: 10, + json: true, + quiet: true, + verbose: true, + }); + }); +}); + +describe("addDelimiterOption", () => { + it("registers --delimiter and reads its value", () => { + const command = commandWith(addDelimiterOption); + command.parse(["node", "t", "--delimiter", ";"]); + expect(command.opts().delimiter).toBe(";"); + }); +}); + +describe("addSheetOption", () => { + it("registers --sheet and reads its value", () => { + const command = commandWith(addSheetOption); + command.parse(["node", "t", "--sheet", "Sheet2"]); + expect(command.opts().sheet).toBe("Sheet2"); + }); +}); + +describe("addPageOption", () => { + it("parses --page as an integer", () => { + const command = commandWith(addPageOption); + command.parse(["node", "t", "--page", "3"]); + expect(command.opts().page).toBe(3); + }); +}); From 05ff45204232f329c2c4ef855204f8d2844c0d1e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 10:23:30 +0100 Subject: [PATCH 005/101] test(document-cli): cover the SQL result-set table renderer formatSqlResultSetTable had no direct test: column-width derivation (header vs longest cell), the two-space gap, trailing-padding trim, the singular/plural row-count summary, and every ContentCellValue kind's own display text were only reachable through a real .odb fixture driven via the odb-query command. --- .../src/sql-result-format.test.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 packages/document-cli/src/sql-result-format.test.ts diff --git a/packages/document-cli/src/sql-result-format.test.ts b/packages/document-cli/src/sql-result-format.test.ts new file mode 100644 index 000000000..0fc874b1d --- /dev/null +++ b/packages/document-cli/src/sql-result-format.test.ts @@ -0,0 +1,87 @@ +import type { ContentCellValue } from "documents.js"; +import { describe, expect, it } from "vitest"; +import { formatSqlResultSetTable } from "./sql-result-format"; + +function str(value: string): ContentCellValue { + return { kind: "string", value }; +} + +function num(value: number): ContentCellValue { + return { kind: "number", value }; +} + +describe("formatSqlResultSetTable", () => { + it("renders a header, a rule line, one line per row, and a plural row-count summary", () => { + const lines = formatSqlResultSetTable({ + columns: ["NAME", "AGE"], + rows: [ + [str("Alice"), num(30)], + [str("Bob"), num(25)], + ], + }); + expect(lines).toEqual([ + "NAME AGE", + "----- ---", + "Alice 30", + "Bob 25", + "2 rows", + ]); + }); + + it("uses the singular 'row' for exactly one row", () => { + const lines = formatSqlResultSetTable({ + columns: ["NAME"], + rows: [[str("Alice")]], + }); + expect(lines.at(-1)).toBe("1 row"); + }); + + it("uses the plural 'rows' for zero rows", () => { + const lines = formatSqlResultSetTable({ columns: ["NAME"], rows: [] }); + expect(lines.at(-1)).toBe("0 rows"); + }); + + it("widens a column to fit its longest cell, not just the header", () => { + const lines = formatSqlResultSetTable({ + columns: ["N"], + rows: [[str("Alexandria")]], + }); + expect(lines[0]).toBe("N"); + expect(lines[1]).toBe("-".repeat("Alexandria".length)); + expect(lines[2]).toBe("Alexandria"); + }); + + it("widens a column to fit the header when it is longer than every cell", () => { + const lines = formatSqlResultSetTable({ + columns: ["VERY_LONG_HEADER"], + rows: [[str("x")]], + }); + expect(lines[0]).toBe("VERY_LONG_HEADER"); + expect(lines[1]).toBe("-".repeat("VERY_LONG_HEADER".length)); + expect(lines[2]).toBe("x"); + }); + + it("separates columns with exactly two spaces and trims trailing padding", () => { + const lines = formatSqlResultSetTable({ + columns: ["A", "B"], + rows: [[str("1"), str("22")]], + }); + // 'A' padded to width 1, then two literal spaces, then 'B' padded to width 2 -- but the last column is never padded, since formatRow trims trailing whitespace. + expect(lines[0]).toBe("A B"); + expect(lines[2]).toBe("1 22"); + }); + + it("renders every ContentCellValue kind through hsqldbCellDisplayText", () => { + const lines = formatSqlResultSetTable({ + columns: ["V"], + rows: [ + [{ kind: "boolean", value: true }], + [{ kind: "boolean", value: false }], + [{ kind: "empty" }], + [{ kind: "currency", value: 5, currency: "USD" }], + [{ kind: "percentage", value: 0.5 }], + ], + }); + expect(lines.slice(2, -1)).toEqual(["TRUE", "FALSE", "", "5 USD", "50%"]); + }); +}); From 04a9b92f60283a0a1eb4b6e5f6b7a24904c36bfe Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 10:27:04 +0100 Subject: [PATCH 006/101] fix(document-cli): remove exit-code branches the fallthrough already covers The HsqldbSql*Error branch and the PdfEncryptedError/PdfParseError branch each returned EXIT_INPUT_ERROR, identically to the function's own final fallthrough -- every mutation of either condition still produced the same exit code, since removing the branch entirely changes nothing observable. Folded both into the fallthrough's own comment instead of leaving an unkillable instanceof check standing, and added the missing test for UnsupportedFontSourceFormatError's own real EXIT_USAGE_ERROR branch plus direct tests for the three Hsqldb SQL error classes now reached only through that fallthrough. --- .../src/runtime/exit-codes.test.ts | 38 +++++++++++++++++++ .../document-cli/src/runtime/exit-codes.ts | 18 +-------- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/packages/document-cli/src/runtime/exit-codes.test.ts b/packages/document-cli/src/runtime/exit-codes.test.ts index f20bd6f2f..b2d3a4623 100644 --- a/packages/document-cli/src/runtime/exit-codes.test.ts +++ b/packages/document-cli/src/runtime/exit-codes.test.ts @@ -1,6 +1,9 @@ import { CsvSheetNotFoundError, CsvSheetNotSpecifiedError, + HsqldbSqlEvaluationError, + HsqldbSqlParseError, + HsqldbSqlUnsupportedError, OdbNoEmbeddedDataSourceError, OdbReportNotSpecifiedError, OdbTableNotFoundError, @@ -11,6 +14,7 @@ import { PdfParseError, SvgMultiPageNotSpecifiedError, SvgPageNotFoundError, + UnsupportedFontSourceFormatError, } from "documents.js"; import { describe, expect, it } from "vitest"; import { @@ -18,6 +22,7 @@ import { EXIT_INTERRUPTED, EXIT_NEEDS_INFO, EXIT_TIMEOUT, + EXIT_USAGE_ERROR, mapErrorToExit, } from "./exit-codes"; @@ -128,6 +133,39 @@ describe("mapErrorToExit", () => { ); }); + it("maps UnsupportedFontSourceFormatError to EXIT_USAGE_ERROR", () => { + expect( + mapErrorToExit(new UnsupportedFontSourceFormatError("xlsx"), undefined), + ).toBe(EXIT_USAGE_ERROR); + }); + + it("maps HsqldbSqlUnsupportedError to EXIT_INPUT_ERROR", () => { + expect( + mapErrorToExit( + new HsqldbSqlUnsupportedError("JOIN", "SELECT * FROM a JOIN b"), + undefined, + ), + ).toBe(EXIT_INPUT_ERROR); + }); + + it("maps HsqldbSqlParseError to EXIT_INPUT_ERROR", () => { + expect( + mapErrorToExit( + new HsqldbSqlParseError("missing FROM", "SELECT 1", 8), + undefined, + ), + ).toBe(EXIT_INPUT_ERROR); + }); + + it("maps HsqldbSqlEvaluationError to EXIT_INPUT_ERROR", () => { + expect( + mapErrorToExit( + new HsqldbSqlEvaluationError("unknown column X", "SELECT X FROM a"), + undefined, + ), + ).toBe(EXIT_INPUT_ERROR); + }); + it("maps PdfEncryptedError to EXIT_INPUT_ERROR", () => { expect( mapErrorToExit(new PdfEncryptedError("/Encrypt present"), undefined), diff --git a/packages/document-cli/src/runtime/exit-codes.ts b/packages/document-cli/src/runtime/exit-codes.ts index 15f0f6a0e..cfe27a8de 100644 --- a/packages/document-cli/src/runtime/exit-codes.ts +++ b/packages/document-cli/src/runtime/exit-codes.ts @@ -1,17 +1,12 @@ import { CsvSheetNotFoundError, CsvSheetNotSpecifiedError, - HsqldbSqlEvaluationError, - HsqldbSqlParseError, - HsqldbSqlUnsupportedError, OdbNoEmbeddedDataSourceError, OdbReportNotSpecifiedError, OdbTableNotFoundError, OdbTableNotSpecifiedError, OdbUnsupportedFormatError, OdmUnresolvedSectionError, - PdfEncryptedError, - PdfParseError, SvgMultiPageNotSpecifiedError, SvgPageNotFoundError, UnsupportedFontSourceFormatError, @@ -51,21 +46,10 @@ export function mapErrorToExit( ) { return EXIT_NEEDS_INFO; } - // odb-query's own bounded SQL engine (documents.js's src/odb/sql/): a real SQL construct it deliberately doesn't implement, input that isn't well-formed SQL under its grammar, or a statement that parsed but can't be executed against the data -- every one an ordinary unusable-input failure, not a "give me more information" one, since none of the three names a specific piece of missing input the way the EXIT_NEEDS_INFO group above does. - if ( - error instanceof HsqldbSqlUnsupportedError || - error instanceof HsqldbSqlParseError || - error instanceof HsqldbSqlEvaluationError - ) { - return EXIT_INPUT_ERROR; - } // fonts' own extractSourceFontsForFormat: the given DocumentFormat is a real, recognised format, but not one with a source-embedded-font concept at all (xlsx, pdf, markdown, odf) -- a bad invocation choice, not an unusable file, so this maps like every other usage error rather than EXIT_INPUT_ERROR's "the file itself is the problem". if (error instanceof UnsupportedFontSourceFormatError) { return EXIT_USAGE_ERROR; } - // PdfEncryptedError extends PdfParseError, so this branch is redundant with the default fall-through below -- kept explicit anyway so the mapping documents its intent (these two error classes are unusable-input failures, not a catch-all) rather than relying on an implicit default to cover a case this function is specifically supposed to name. - if (error instanceof PdfEncryptedError || error instanceof PdfParseError) { - return EXIT_INPUT_ERROR; - } + // Every other case is EXIT_INPUT_ERROR's ordinary catch-all: odb-query's bounded SQL engine's own three error classes (a real SQL construct it deliberately doesn't implement, input that isn't well-formed SQL under its grammar, or a statement that parsed but can't be executed against the data -- none names a specific piece of missing input the way the EXIT_NEEDS_INFO group above does), and PdfEncryptedError/PdfParseError (PdfEncryptedError extends PdfParseError). None of these five classes gets its own instanceof branch: this default already covers every one of them, and a dedicated branch for a class this fallback already reaches is a mutation-proof no-op (every mutation of its condition returns the identical exit code), not real documentation of intent. return EXIT_INPUT_ERROR; } From 3630a0c62fbdbc0ea5ef3d2a36a8ff5744f50a56 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 10:31:43 +0100 Subject: [PATCH 007/101] test(document-cli): cover the tui metadata dispatcher's per-format cases metadataFor's own switch had no direct test: every editor-backed format's readXContent(doc.editor.toPackage()) call, doc/xls/ppt's and pdf's direct doc.editor.metadata/doc.layout.metadata reads, every read-only-preview format's identical doc.layout.metadata read, and odb's own no-metadata-concept throw were only reachable through whichever screen test happened to open that particular format. Also adds a direct test for detectFormat, the one-line named seam over inferFormatFromExtension. --- .../src/tui/format/detect-format.test.ts | 12 ++ .../src/tui/format/read-metadata.test.ts | 156 ++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 packages/document-cli/src/tui/format/detect-format.test.ts create mode 100644 packages/document-cli/src/tui/format/read-metadata.test.ts diff --git a/packages/document-cli/src/tui/format/detect-format.test.ts b/packages/document-cli/src/tui/format/detect-format.test.ts new file mode 100644 index 000000000..1c839443d --- /dev/null +++ b/packages/document-cli/src/tui/format/detect-format.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { detectFormat } from "./detect-format"; + +describe("detectFormat", () => { + it("infers a format from a recognised extension", () => { + expect(detectFormat("report.docx")).toBe("docx"); + }); + + it("returns undefined for an unrecognised extension", () => { + expect(detectFormat("archive.zip")).toBeUndefined(); + }); +}); diff --git a/packages/document-cli/src/tui/format/read-metadata.test.ts b/packages/document-cli/src/tui/format/read-metadata.test.ts new file mode 100644 index 000000000..6dc3b39cd --- /dev/null +++ b/packages/document-cli/src/tui/format/read-metadata.test.ts @@ -0,0 +1,156 @@ +import { + readDocxContent, + readMarkdownContent, + readOdgContent, + readOdpContent, + readOdsContent, + readOdtContent, + readPptxContent, + type LayoutDocument, + type LayoutMetadata, +} from "documents.js"; +import { describe, expect, it } from "vitest"; +import type { OpenDocument } from "../state/types.js"; +import { createNewDocument } from "./open-document.js"; +import { metadataFor } from "./read-metadata.js"; + +// Formats with a genuine live-view editor or a real toLayoutDocument() call are exercised through createNewDocument, comparing metadataFor's own result against the identical read call made directly in the test -- proving each case dispatches to its own format's real content reader rather than merely returning some object. documents.js's own editors have no metadata setter at all (see test-support/metadata-fixture.ts's own top-of-file comment), so a freshly created document's default metadata is what both sides read; what matters for this dispatch is that the two calls agree. The read-only-preview formats (xlsx/csv/svg/rtf/wpd/epub) build no real conversion here -- metadataFor's own logic for every one of them is the identical `doc.layout.metadata` property read already proven for pdf, so a stub layout carrying a recognisable metadata marker is enough to prove each case label reaches it. + +function stubMetadata(marker: string): LayoutMetadata { + return { title: marker }; +} + +function readOnlyPreviewDocument( + format: "xlsx" | "csv" | "svg" | "rtf" | "wpd" | "epub", + marker: string, +): OpenDocument { + return { + format, + layout: { metadata: stubMetadata(marker) } as unknown as LayoutDocument, + bytes: new Uint8Array(), + path: "x", + }; +} + +describe("metadataFor", () => { + it("reads docx metadata through readDocxContent(doc.editor.toPackage())", () => { + const doc = createNewDocument("docx"); + if (doc.format !== "docx") throw new Error("expected docx"); + expect(metadataFor(doc)).toEqual( + readDocxContent(doc.editor.toPackage()).metadata, + ); + }); + + it("reads pptx metadata through readPptxContent(doc.editor.toPackage())", () => { + const doc = createNewDocument("pptx"); + if (doc.format !== "pptx") throw new Error("expected pptx"); + expect(metadataFor(doc)).toEqual( + readPptxContent(doc.editor.toPackage()).metadata, + ); + }); + + it("reads odt metadata through readOdtContent(doc.editor.toPackage())", () => { + const doc = createNewDocument("odt"); + if (doc.format !== "odt") throw new Error("expected odt"); + expect(metadataFor(doc)).toEqual( + readOdtContent(doc.editor.toPackage()).metadata, + ); + }); + + it("reads odp metadata through readOdpContent(doc.editor.toPackage())", () => { + const doc = createNewDocument("odp"); + if (doc.format !== "odp") throw new Error("expected odp"); + expect(metadataFor(doc)).toEqual( + readOdpContent(doc.editor.toPackage()).metadata, + ); + }); + + it("reads ods metadata through readOdsContent(doc.editor.toPackage())", () => { + const doc = createNewDocument("ods"); + if (doc.format !== "ods") throw new Error("expected ods"); + expect(metadataFor(doc)).toEqual( + readOdsContent(doc.editor.toPackage()).metadata, + ); + }); + + it("reads odg metadata through readOdgContent(doc.editor.toPackage())", () => { + const doc = createNewDocument("odg"); + if (doc.format !== "odg") throw new Error("expected odg"); + expect(metadataFor(doc)).toEqual( + readOdgContent(doc.editor.toPackage()).metadata, + ); + }); + + it("reads markdown metadata through readMarkdownContent(doc.editor.toMarkdownText())", () => { + const doc = createNewDocument("markdown"); + if (doc.format !== "markdown") throw new Error("expected markdown"); + expect(metadataFor(doc)).toEqual( + readMarkdownContent(doc.editor.toMarkdownText()).metadata, + ); + }); + + it("reads doc metadata directly off doc.editor.metadata", () => { + const doc = createNewDocument("doc"); + if (doc.format !== "doc") throw new Error("expected doc"); + expect(metadataFor(doc)).toBe(doc.editor.metadata); + }); + + it("reads xls metadata directly off doc.editor.metadata", () => { + const doc = createNewDocument("xls"); + if (doc.format !== "xls") throw new Error("expected xls"); + expect(metadataFor(doc)).toBe(doc.editor.metadata); + }); + + it("reads ppt metadata directly off doc.editor.metadata", () => { + const doc = createNewDocument("ppt"); + if (doc.format !== "ppt") throw new Error("expected ppt"); + expect(metadataFor(doc)).toBe(doc.editor.metadata); + }); + + it("reads pdf metadata directly off doc.layout.metadata", () => { + const doc = createNewDocument("pdf"); + if (doc.format !== "pdf") throw new Error("expected pdf"); + expect(metadataFor(doc)).toBe(doc.layout.metadata); + }); + + it("reads xlsx metadata directly off doc.layout.metadata", () => { + const doc = readOnlyPreviewDocument("xlsx", "Xlsx Marker"); + expect(metadataFor(doc).title).toBe("Xlsx Marker"); + }); + + it("reads csv metadata directly off doc.layout.metadata", () => { + const doc = readOnlyPreviewDocument("csv", "Csv Marker"); + expect(metadataFor(doc).title).toBe("Csv Marker"); + }); + + it("reads svg metadata directly off doc.layout.metadata", () => { + const doc = readOnlyPreviewDocument("svg", "Svg Marker"); + expect(metadataFor(doc).title).toBe("Svg Marker"); + }); + + it("reads rtf metadata directly off doc.layout.metadata", () => { + const doc = readOnlyPreviewDocument("rtf", "Rtf Marker"); + expect(metadataFor(doc).title).toBe("Rtf Marker"); + }); + + it("reads wpd metadata directly off doc.layout.metadata", () => { + const doc = readOnlyPreviewDocument("wpd", "Wpd Marker"); + expect(metadataFor(doc).title).toBe("Wpd Marker"); + }); + + it("reads epub metadata directly off doc.layout.metadata", () => { + const doc = readOnlyPreviewDocument("epub", "Epub Marker"); + expect(metadataFor(doc).title).toBe("Epub Marker"); + }); + + it("throws for odb, which has no document-level metadata concept", () => { + const doc: OpenDocument = { + format: "odb", + tables: [], + forms: [], + reports: [], + path: "a.odb", + }; + expect(() => metadataFor(doc)).toThrow(/has no document-level metadata/); + }); +}); From bbccaf0a951b6f1fce70e9a9c30bb68a34f2dc83 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 10:34:54 +0100 Subject: [PATCH 008/101] test(document-cli): cover the tui's own odb report render pipeline renderOdbReportTo had no direct test: the destination-extension dispatch to docx/odt/pdf, the unknown-report-name failure naming the available reports, the extension-outside-docx/odt/pdf rejection, and fontFiles threading through to the pdf branch alone were only reachable through the odb-render-report CLI command's own tests, which exercise a structurally similar but separate code path (commands/odb.ts), not this TUI-side pipeline. --- .../src/tui/format/render-odb-report.test.ts | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 packages/document-cli/src/tui/format/render-odb-report.test.ts diff --git a/packages/document-cli/src/tui/format/render-odb-report.test.ts b/packages/document-cli/src/tui/format/render-odb-report.test.ts new file mode 100644 index 000000000..9438a31c0 --- /dev/null +++ b/packages/document-cli/src/tui/format/render-odb-report.test.ts @@ -0,0 +1,113 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { openDocx, openOdt } from "documents.js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { FORM_AND_REPORT_ODB_PATH } from "../../test-support/odb-fixture.js"; +import type { OdbOpenDocument } from "../state/types.js"; +import { renderOdbReportTo } from "./render-odb-report.js"; + +interface CellTextTable { + rows(): readonly { cells(): readonly { readonly text: string }[] }[]; +} + +function allTableCellText(tables: readonly CellTextTable[]): string[] { + return tables.flatMap((table) => + table.rows().flatMap((row) => row.cells().map((cell) => cell.text)), + ); +} + +let workspace: string; +let doc: OdbOpenDocument; + +beforeAll(async () => { + workspace = await mkdtemp(join(tmpdir(), "document-cli-tui-odb-render-")); + doc = { + format: "odb", + tables: [], + forms: [], + reports: [], + path: FORM_AND_REPORT_ODB_PATH, + }; +}); + +afterAll(async () => { + await rm(workspace, { recursive: true, force: true }); +}); + +describe("renderOdbReportTo", () => { + it("renders the report to docx as real, editable table content", async () => { + const output = join(workspace, "report.docx"); + await renderOdbReportTo(doc, output, { + reportName: "SalesByRegion", + onDiagnostic: () => undefined, + }); + const editor = openDocx(new Uint8Array(await readFile(output))); + expect(allTableCellText(editor.tables())).toContain("Acme Ltd"); + }); + + it("renders the report to odt as real, editable table content", async () => { + const output = join(workspace, "report.odt"); + await renderOdbReportTo(doc, output, { + reportName: "SalesByRegion", + onDiagnostic: () => undefined, + }); + const editor = openOdt(new Uint8Array(await readFile(output))); + expect(allTableCellText(editor.tables())).toContain("Acme Ltd"); + }); + + it("renders the report to a real pdf", async () => { + const output = join(workspace, "report.pdf"); + await renderOdbReportTo(doc, output, { + reportName: "SalesByRegion", + onDiagnostic: () => undefined, + }); + const bytes = await readFile(output); + expect(new TextDecoder("latin1").decode(bytes.subarray(0, 5))).toBe( + "%PDF-", + ); + }); + + it("threads fontFiles through to the pdf render alone", async () => { + const output = join(workspace, "report-fonts.pdf"); + await renderOdbReportTo(doc, output, { + reportName: "SalesByRegion", + onDiagnostic: () => undefined, + fontFiles: [], + }); + const bytes = await readFile(output); + expect(new TextDecoder("latin1").decode(bytes.subarray(0, 5))).toBe( + "%PDF-", + ); + }); + + it("rejects a destination with an extension outside docx/odt/pdf", async () => { + const output = join(workspace, "never-written.xlsx"); + await expect( + renderOdbReportTo(doc, output, { + reportName: "SalesByRegion", + onDiagnostic: () => undefined, + }), + ).rejects.toThrow(/give the destination one of those three extensions/); + }); + + it("rejects a destination with no recognisable extension at all", async () => { + const output = join(workspace, "never-written"); + await expect( + renderOdbReportTo(doc, output, { + reportName: "SalesByRegion", + onDiagnostic: () => undefined, + }), + ).rejects.toThrow(/give the destination one of those three extensions/); + }); + + it("propagates the underlying error naming the available reports for an unknown report name", async () => { + const output = join(workspace, "never-written-2.docx"); + await expect( + renderOdbReportTo(doc, output, { + reportName: "NoSuchReport", + onDiagnostic: () => undefined, + }), + ).rejects.toThrow(/SalesByRegion/); + }); +}); From ec209dc52ec8340d5a036723049e56f303993014 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 10:37:29 +0100 Subject: [PATCH 009/101] test(document-cli): cover the shared colour, text-field, and slide-table helpers layoutColorToHex/isValidHexColorInput/parseHexColorInput, truncatePreview/parsePositiveIntField/parseNonNegativeIntField/ parseNumberField, and resolveSlideTable/slideTableCellText/ summarizeSlideTables had no direct tests -- byte-padding, the zero-vs-negative-vs-fallback boundaries each numeric field parser draws differently, the empty-preview marker, and the pptx/odp content dispatch (plus its out-of-range slide/table-index cases) were only reachable through whichever screen-component test happened to render a matching scenario. --- .../src/tui/screens/shared/color.test.ts | 79 +++++++++++ .../tui/screens/shared/slide-table.test.ts | 123 ++++++++++++++++++ .../src/tui/screens/shared/text.test.ts | 104 +++++++++++++++ 3 files changed, 306 insertions(+) create mode 100644 packages/document-cli/src/tui/screens/shared/color.test.ts create mode 100644 packages/document-cli/src/tui/screens/shared/slide-table.test.ts create mode 100644 packages/document-cli/src/tui/screens/shared/text.test.ts diff --git a/packages/document-cli/src/tui/screens/shared/color.test.ts b/packages/document-cli/src/tui/screens/shared/color.test.ts new file mode 100644 index 000000000..b55546673 --- /dev/null +++ b/packages/document-cli/src/tui/screens/shared/color.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { + isValidHexColorInput, + layoutColorToHex, + parseHexColorInput, +} from "./color"; + +describe("layoutColorToHex", () => { + it("renders full-intensity white", () => { + expect(layoutColorToHex({ r: 1, g: 1, b: 1 })).toBe("#ffffff"); + }); + + it("renders black", () => { + expect(layoutColorToHex({ r: 0, g: 0, b: 0 })).toBe("#000000"); + }); + + it("pads a single-digit byte with a leading zero", () => { + // 1/255 rounds to byte 1 -> hex "01", proving the padStart(2, "0") -- without it this would render "#1..." rather than "#01...". + expect(layoutColorToHex({ r: 1 / 255, g: 0, b: 0 })).toBe("#010000"); + }); + + it("renders each channel independently at a distinct value", () => { + expect(layoutColorToHex({ r: 1, g: 0, b: 0.5 })).toBe("#ff0080"); + }); +}); + +describe("isValidHexColorInput", () => { + it("accepts a 6-digit hex string with a leading #", () => { + expect(isValidHexColorInput("#ff00ff")).toBe(true); + }); + + it("accepts a 6-digit hex string with no leading #", () => { + expect(isValidHexColorInput("ff00ff")).toBe(true); + }); + + it("accepts uppercase hex digits", () => { + expect(isValidHexColorInput("#FF00FF")).toBe(true); + }); + + it("trims surrounding whitespace before validating", () => { + expect(isValidHexColorInput(" #ff00ff ")).toBe(true); + }); + + it("rejects a string shorter than 6 hex digits", () => { + expect(isValidHexColorInput("#ff00f")).toBe(false); + }); + + it("rejects a string longer than 6 hex digits", () => { + expect(isValidHexColorInput("#ff00ff0")).toBe(false); + }); + + it("rejects a non-hex character", () => { + expect(isValidHexColorInput("#gg00ff")).toBe(false); + }); + + it("rejects the empty string", () => { + expect(isValidHexColorInput("")).toBe(false); + }); +}); + +describe("parseHexColorInput", () => { + it("parses a valid hex string into a LayoutColor", () => { + expect(parseHexColorInput("#ff0000")).toEqual({ r: 1, g: 0, b: 0 }); + }); + + it("parses a valid hex string with no leading #", () => { + expect(parseHexColorInput("00ff00")).toEqual({ r: 0, g: 1, b: 0 }); + }); + + it("returns undefined for invalid input, without ever calling rgbHexToColor", () => { + expect(parseHexColorInput("not a color")).toBeUndefined(); + }); + + it("round-trips through layoutColorToHex", () => { + const color = parseHexColorInput("#3366cc"); + expect(color).toBeDefined(); + expect(layoutColorToHex(color!)).toBe("#3366cc"); + }); +}); diff --git a/packages/document-cli/src/tui/screens/shared/slide-table.test.ts b/packages/document-cli/src/tui/screens/shared/slide-table.test.ts new file mode 100644 index 000000000..521a5de5f --- /dev/null +++ b/packages/document-cli/src/tui/screens/shared/slide-table.test.ts @@ -0,0 +1,123 @@ +import { createOdp, createPptx } from "documents.js"; +import { describe, expect, it } from "vitest"; +import type { OdpOpenDocument, PptxOpenDocument } from "../../state/types.js"; +import { + resolveSlideTable, + slideTableCellText, + summarizeSlideTables, +} from "./slide-table"; + +function pptxWithTable(): PptxOpenDocument { + const editor = createPptx(); + const slide = editor.addSlide(); + slide.addTable({ + frame: { xPt: 10, yPt: 10, widthPt: 300, heightPt: 150 }, + table: { rows: 2, columns: 3 }, + }); + return { format: "pptx", editor, path: undefined }; +} + +function odpWithTable(): OdpOpenDocument { + const editor = createOdp(); + const slide = editor.addSlide(); + slide.addTable({ + frame: { xPt: 10, yPt: 10, widthPt: 300, heightPt: 150 }, + table: { rows: 2, columns: 3 }, + }); + return { format: "odp", editor, path: undefined }; +} + +describe("resolveSlideTable", () => { + it("resolves a real table on a pptx slide via readPptxContent", () => { + const doc = pptxWithTable(); + const table = resolveSlideTable(doc, 0, 0); + expect(table).toBeDefined(); + expect(table?.kind).toBe("table"); + }); + + it("resolves a real table on an odp slide via readOdpContent", () => { + const doc = odpWithTable(); + const table = resolveSlideTable(doc, 0, 0); + expect(table).toBeDefined(); + expect(table?.kind).toBe("table"); + }); + + it("returns undefined for a slide index beyond the deck", () => { + const doc = pptxWithTable(); + expect(resolveSlideTable(doc, 5, 0)).toBeUndefined(); + }); + + it("returns undefined for a table index beyond the slide's own tables", () => { + const doc = pptxWithTable(); + expect(resolveSlideTable(doc, 0, 5)).toBeUndefined(); + }); +}); + +describe("slideTableCellText", () => { + it("joins multiple paragraphs in a cell with a newline", () => { + const cell = { + blocks: [ + { kind: "paragraph", runs: [{ text: "first" }] }, + { kind: "paragraph", runs: [{ text: "second" }] }, + ], + } as unknown as Parameters[0]; + expect(slideTableCellText(cell)).toBe("first\nsecond"); + }); + + it("concatenates multiple runs within one paragraph with no separator", () => { + const cell = { + blocks: [ + { + kind: "paragraph", + runs: [{ text: "a" }, { text: "b" }], + }, + ], + } as unknown as Parameters[0]; + expect(slideTableCellText(cell)).toBe("ab"); + }); + + it("ignores non-paragraph blocks", () => { + const cell = { + blocks: [ + { kind: "image" }, + { kind: "paragraph", runs: [{ text: "text" }] }, + ], + } as unknown as Parameters[0]; + expect(slideTableCellText(cell)).toBe("text"); + }); + + it("returns an empty string for a cell with no paragraph blocks", () => { + const cell = { blocks: [] } as unknown as Parameters< + typeof slideTableCellText + >[0]; + expect(slideTableCellText(cell)).toBe(""); + }); +}); + +describe("summarizeSlideTables", () => { + it("summarizes a pptx slide's own tables by row/column count", () => { + const doc = pptxWithTable(); + expect(summarizeSlideTables(doc, 0)).toEqual([ + { index: 0, rowCount: 2, columnCount: 3 }, + ]); + }); + + it("summarizes an odp slide's own tables by row/column count", () => { + const doc = odpWithTable(); + expect(summarizeSlideTables(doc, 0)).toEqual([ + { index: 0, rowCount: 2, columnCount: 3 }, + ]); + }); + + it("returns an empty array for a slide index beyond the deck", () => { + const doc = pptxWithTable(); + expect(summarizeSlideTables(doc, 9)).toEqual([]); + }); + + it("returns an empty array for a slide with no tables at all", () => { + const editor = createPptx(); + editor.addSlide(); + const doc: PptxOpenDocument = { format: "pptx", editor, path: undefined }; + expect(summarizeSlideTables(doc, 0)).toEqual([]); + }); +}); diff --git a/packages/document-cli/src/tui/screens/shared/text.test.ts b/packages/document-cli/src/tui/screens/shared/text.test.ts new file mode 100644 index 000000000..0b932dd1e --- /dev/null +++ b/packages/document-cli/src/tui/screens/shared/text.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { + parseNonNegativeIntField, + parseNumberField, + parsePositiveIntField, + truncatePreview, +} from "./text"; + +describe("truncatePreview", () => { + it("returns short text unchanged", () => { + expect(truncatePreview("hello", 10)).toBe("hello"); + }); + + it("collapses internal whitespace runs (newlines, tabs) to a single space", () => { + expect(truncatePreview("a\n\tb c", 20)).toBe("a b c"); + }); + + it("trims leading and trailing whitespace", () => { + expect(truncatePreview(" hello ", 20)).toBe("hello"); + }); + + it("returns the literal '(empty)' marker for text that collapses to nothing", () => { + expect(truncatePreview(" \n\t ", 10)).toBe("(empty)"); + expect(truncatePreview("", 10)).toBe("(empty)"); + }); + + it("truncates text longer than maxLength, appending an ellipsis", () => { + expect(truncatePreview("abcdefghij", 5)).toBe("abcd…"); + }); + + it("does not truncate text exactly at maxLength", () => { + expect(truncatePreview("abcde", 5)).toBe("abcde"); + }); + + it("never produces a negative slice length even for a maxLength of 0", () => { + expect(truncatePreview("abcdef", 0)).toBe("…"); + }); +}); + +describe("parsePositiveIntField", () => { + it("parses a positive integer string", () => { + expect(parsePositiveIntField("3", 1)).toBe(3); + }); + + it("falls back for zero", () => { + expect(parsePositiveIntField("0", 7)).toBe(7); + }); + + it("falls back for a negative number", () => { + expect(parsePositiveIntField("-1", 7)).toBe(7); + }); + + it("falls back for a non-numeric string", () => { + expect(parsePositiveIntField("abc", 7)).toBe(7); + }); + + it("falls back for an empty string", () => { + expect(parsePositiveIntField("", 7)).toBe(7); + }); + + it("parses the integer part of a decimal string via parseInt truncation", () => { + expect(parsePositiveIntField("3.9", 1)).toBe(3); + }); +}); + +describe("parseNonNegativeIntField", () => { + it("parses a positive integer string", () => { + expect(parseNonNegativeIntField("3", 1)).toBe(3); + }); + + it("accepts zero, unlike parsePositiveIntField", () => { + expect(parseNonNegativeIntField("0", 7)).toBe(0); + }); + + it("falls back for a negative number", () => { + expect(parseNonNegativeIntField("-1", 7)).toBe(7); + }); + + it("falls back for a non-numeric string", () => { + expect(parseNonNegativeIntField("abc", 7)).toBe(7); + }); +}); + +describe("parseNumberField", () => { + it("parses a positive float", () => { + expect(parseNumberField("3.5", 1)).toBe(3.5); + }); + + it("accepts zero", () => { + expect(parseNumberField("0", 1)).toBe(0); + }); + + it("accepts a negative number", () => { + expect(parseNumberField("-2.5", 1)).toBe(-2.5); + }); + + it("falls back for a non-numeric string", () => { + expect(parseNumberField("abc", 1)).toBe(1); + }); + + it("falls back for a non-finite value", () => { + expect(parseNumberField("Infinity", 1)).toBe(1); + }); +}); From 3b2ef86dc48de5a9d3efd6dc55bda40c8169ce09 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 10:39:11 +0100 Subject: [PATCH 010/101] test(document-cli): cover the vector-field parsers and formula presets parseColorField/parseStrokeField/defaultTriangleSubpaths and the six FORMULA_PRESETS entries had no direct tests -- the component-count and finite-value guards each parser draws, and every MathML string literal and tree shape in the presets, were only reachable through whichever odg/pptx/docx screen test happened to type a matching value. --- .../screens/shared/formula-presets.test.ts | 200 ++++++++++++++++++ .../tui/screens/shared/vector-fields.test.ts | 90 ++++++++ 2 files changed, 290 insertions(+) create mode 100644 packages/document-cli/src/tui/screens/shared/formula-presets.test.ts create mode 100644 packages/document-cli/src/tui/screens/shared/vector-fields.test.ts diff --git a/packages/document-cli/src/tui/screens/shared/formula-presets.test.ts b/packages/document-cli/src/tui/screens/shared/formula-presets.test.ts new file mode 100644 index 000000000..1f33933d7 --- /dev/null +++ b/packages/document-cli/src/tui/screens/shared/formula-presets.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "vitest"; +import { FORMULA_PRESETS } from "./formula-presets"; + +describe("FORMULA_PRESETS", () => { + it("declares exactly six presets", () => { + expect(FORMULA_PRESETS).toHaveLength(6); + }); + + it("gives every preset a non-empty label and at least one MathML node", () => { + for (const preset of FORMULA_PRESETS) { + expect(preset.label.length).toBeGreaterThan(0); + expect(preset.mathml.length).toBeGreaterThan(0); + } + }); + + it("declares the exact labels, in order", () => { + expect(FORMULA_PRESETS.map((preset) => preset.label)).toEqual([ + "Fraction: x / 2", + "Power: x^2", + "Subscript: x_i", + "Square root: sqrt(x)", + "Summation: sum(i=1..n) i", + "Quadratic formula", + ]); + }); + + it("builds the fraction preset as mfrac(mi(x), mn(2))", () => { + expect(FORMULA_PRESETS[0]?.mathml).toEqual([ + { + type: "element", + tag: "mfrac", + attributes: [], + children: [ + { + type: "element", + tag: "mi", + attributes: [], + children: [{ type: "text", value: "x" }], + }, + { + type: "element", + tag: "mn", + attributes: [], + children: [{ type: "text", value: "2" }], + }, + ], + }, + ]); + }); + + it("builds the power preset as msup(mi(x), mn(2))", () => { + expect(FORMULA_PRESETS[1]?.mathml).toEqual([ + { + type: "element", + tag: "msup", + attributes: [], + children: [ + { + type: "element", + tag: "mi", + attributes: [], + children: [{ type: "text", value: "x" }], + }, + { + type: "element", + tag: "mn", + attributes: [], + children: [{ type: "text", value: "2" }], + }, + ], + }, + ]); + }); + + it("builds the subscript preset as msub(mi(x), mi(i))", () => { + expect(FORMULA_PRESETS[2]?.mathml).toEqual([ + { + type: "element", + tag: "msub", + attributes: [], + children: [ + { + type: "element", + tag: "mi", + attributes: [], + children: [{ type: "text", value: "x" }], + }, + { + type: "element", + tag: "mi", + attributes: [], + children: [{ type: "text", value: "i" }], + }, + ], + }, + ]); + }); + + it("builds the square-root preset as msqrt(mi(x))", () => { + expect(FORMULA_PRESETS[3]?.mathml).toEqual([ + { + type: "element", + tag: "msqrt", + attributes: [], + children: [ + { + type: "element", + tag: "mi", + attributes: [], + children: [{ type: "text", value: "x" }], + }, + ], + }, + ]); + }); + + it("builds the exact summation preset tree", () => { + expect(FORMULA_PRESETS[4]?.mathml).toEqual([ + { + type: "element", + tag: "munderover", + attributes: [], + children: [ + { + type: "element", + tag: "mo", + attributes: [], + children: [{ type: "text", value: "∑" }], + }, + { + type: "element", + tag: "mrow", + attributes: [], + children: [ + { + type: "element", + tag: "mi", + attributes: [], + children: [{ type: "text", value: "i" }], + }, + { + type: "element", + tag: "mo", + attributes: [], + children: [{ type: "text", value: "=" }], + }, + { + type: "element", + tag: "mn", + attributes: [], + children: [{ type: "text", value: "1" }], + }, + ], + }, + { + type: "element", + tag: "mi", + attributes: [], + children: [{ type: "text", value: "n" }], + }, + ], + }, + ]); + }); + + it("builds the exact quadratic-formula preset tree", () => { + const element = ( + tag: string, + children: readonly unknown[] = [], + ): unknown => ({ type: "element", tag, attributes: [], children }); + const text = (value: string): unknown => ({ type: "text", value }); + const mi = (name: string): unknown => element("mi", [text(name)]); + const mn = (value: string): unknown => element("mn", [text(value)]); + const mo = (operator: string): unknown => element("mo", [text(operator)]); + + expect(FORMULA_PRESETS[5]?.mathml).toEqual([ + element("mrow", [ + mi("x"), + mo("="), + element("mfrac", [ + element("mrow", [ + mo("-"), + mi("b"), + mo("±"), + element("msqrt", [ + element("mrow", [ + element("msup", [mi("b"), mn("2")]), + mo("-"), + mn("4"), + mi("a"), + mi("c"), + ]), + ]), + ]), + element("mrow", [mn("2"), mi("a")]), + ]), + ]), + ]); + }); +}); diff --git a/packages/document-cli/src/tui/screens/shared/vector-fields.test.ts b/packages/document-cli/src/tui/screens/shared/vector-fields.test.ts new file mode 100644 index 000000000..1afc32cd2 --- /dev/null +++ b/packages/document-cli/src/tui/screens/shared/vector-fields.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + defaultTriangleSubpaths, + parseColorField, + parseStrokeField, +} from "./vector-fields"; + +describe("parseColorField", () => { + it("parses three space-separated numbers", () => { + expect(parseColorField("0.1 0.2 0.3")).toEqual({ + r: 0.1, + g: 0.2, + b: 0.3, + }); + }); + + it("returns undefined for an empty string", () => { + expect(parseColorField("")).toBeUndefined(); + }); + + it("returns undefined for whitespace-only input", () => { + expect(parseColorField(" ")).toBeUndefined(); + }); + + it("returns undefined when fewer than three numbers are given", () => { + expect(parseColorField("0.1 0.2")).toBeUndefined(); + }); + + it("returns undefined when a component is non-numeric", () => { + expect(parseColorField("0.1 x 0.3")).toBeUndefined(); + }); + + it("returns undefined when a component is non-finite", () => { + expect(parseColorField("0.1 Infinity 0.3")).toBeUndefined(); + }); + + it("tolerates multiple spaces between components", () => { + expect(parseColorField("0.1 0.2 0.3")).toEqual({ + r: 0.1, + g: 0.2, + b: 0.3, + }); + }); +}); + +describe("parseStrokeField", () => { + it("parses four space-separated numbers into a colour and width", () => { + expect(parseStrokeField("0.1 0.2 0.3 2.5")).toEqual({ + color: { r: 0.1, g: 0.2, b: 0.3 }, + widthPt: 2.5, + }); + }); + + it("returns undefined for an empty string", () => { + expect(parseStrokeField("")).toBeUndefined(); + }); + + it("returns undefined when fewer than four numbers are given", () => { + expect(parseStrokeField("0.1 0.2 0.3")).toBeUndefined(); + }); + + it("returns undefined when the width component is non-numeric", () => { + expect(parseStrokeField("0.1 0.2 0.3 x")).toBeUndefined(); + }); + + it("returns undefined when any component is non-finite", () => { + expect(parseStrokeField("0.1 0.2 Infinity 2.5")).toBeUndefined(); + }); +}); + +describe("defaultTriangleSubpaths", () => { + it("builds a single closed subpath spanning the given frame", () => { + const subpaths = defaultTriangleSubpaths(100, 50); + expect(subpaths).toEqual([ + { + start: { xPt: 0, yPt: 50 }, + segments: [ + { kind: "line", to: { xPt: 50, yPt: 0 } }, + { kind: "line", to: { xPt: 100, yPt: 50 } }, + ], + closed: true, + }, + ]); + }); + + it("scales the apex to exactly half the given width", () => { + const [subpath] = defaultTriangleSubpaths(60, 40); + expect(subpath?.segments[0]?.to).toEqual({ xPt: 30, yPt: 0 }); + }); +}); From b2230383c8d7b28620c24069ec188e054738999f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 10:40:45 +0100 Subject: [PATCH 011/101] test(document-cli): cover the ods editor's shared cell/sheet helpers odsDocument, resolveSheet, sheetExtent's floor/derive-from-cells/ derive-from-declared-rows-columns branches, cellKey, cellLookup, rawEditableText's per-kind rendering, inferKind's boolean/number/ string classification, and buildCellValue's per-kind parse-or-reject logic had no direct tests -- each was only reachable through whichever combination a spreadsheet-grid or cell-detail screen test happened to drive. --- .../tui/screens/editors/ods/shared.test.ts | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 packages/document-cli/src/tui/screens/editors/ods/shared.test.ts diff --git a/packages/document-cli/src/tui/screens/editors/ods/shared.test.ts b/packages/document-cli/src/tui/screens/editors/ods/shared.test.ts new file mode 100644 index 000000000..3ea02fd3a --- /dev/null +++ b/packages/document-cli/src/tui/screens/editors/ods/shared.test.ts @@ -0,0 +1,252 @@ +import { + createOds, + type ContentSheet, + type ContentSheetCell, +} from "documents.js"; +import { describe, expect, it } from "vitest"; +import { createInitialState } from "../../../state/reducer.js"; +import type { AppState, OdsOpenDocument } from "../../../state/types.js"; +import { + buildCellValue, + cellKey, + cellLookup, + inferKind, + odsDocument, + rawEditableText, + resolveSheet, + sheetExtent, +} from "./shared"; + +describe("odsDocument", () => { + it("returns the open ods document", () => { + const editor = createOds(); + const doc: OdsOpenDocument = { format: "ods", editor, path: undefined }; + const state: AppState = { ...createInitialState(), openDocument: doc }; + expect(odsDocument(state)).toBe(doc); + }); + + it("throws when the open document is not ods", () => { + const state: AppState = { + ...createInitialState(), + openDocument: undefined, + }; + expect(() => odsDocument(state)).toThrow(/was not ods/); + }); +}); + +describe("resolveSheet", () => { + it("resolves the first sheet of a freshly created ods document", () => { + const editor = createOds(); + const sheet = resolveSheet(editor, 0); + expect(sheet).toBeDefined(); + }); + + it("returns undefined for a sheet index beyond the document", () => { + const editor = createOds(); + expect(resolveSheet(editor, 99)).toBeUndefined(); + }); +}); + +describe("sheetExtent", () => { + it("floors to 1x1 for an undefined sheet", () => { + expect(sheetExtent(undefined)).toEqual({ rowCount: 1, columnCount: 1 }); + }); + + it("floors to 1x1 for a sheet with no cells, rows, or columns at all", () => { + const sheet = { + cells: [], + rows: [], + columns: [], + } as unknown as ContentSheet; + expect(sheetExtent(sheet)).toEqual({ rowCount: 1, columnCount: 1 }); + }); + + it("derives extent from the furthest populated cell", () => { + const sheet = { + cells: [{ row: 3, column: 2, value: { kind: "empty" } }], + rows: [], + columns: [], + } as unknown as ContentSheet; + expect(sheetExtent(sheet)).toEqual({ rowCount: 4, columnCount: 3 }); + }); + + it("derives extent from a declared row/column beyond any populated cell", () => { + const sheet = { + cells: [{ row: 1, column: 1, value: { kind: "empty" } }], + rows: [{ index: 9 }], + columns: [{ index: 7 }], + } as unknown as ContentSheet; + expect(sheetExtent(sheet)).toEqual({ rowCount: 10, columnCount: 8 }); + }); +}); + +describe("cellKey", () => { + it("joins row and column with a colon", () => { + expect(cellKey(3, 5)).toBe("3:5"); + }); + + it("distinguishes (1,23) from (12,3)", () => { + expect(cellKey(1, 23)).not.toBe(cellKey(12, 3)); + }); +}); + +describe("cellLookup", () => { + it("returns an empty map for an undefined sheet", () => { + expect(cellLookup(undefined).size).toBe(0); + }); + + it("indexes every cell by its own row:column key", () => { + const cellA = { row: 0, column: 0, value: { kind: "string", value: "A" } }; + const cellB = { row: 1, column: 2, value: { kind: "string", value: "B" } }; + const sheet = { cells: [cellA, cellB] } as unknown as ContentSheet; + const map = cellLookup(sheet); + expect(map.get("0:0")).toBe(cellA as unknown as ContentSheetCell); + expect(map.get("1:2")).toBe(cellB as unknown as ContentSheetCell); + expect(map.size).toBe(2); + }); +}); + +describe("rawEditableText", () => { + it("returns an empty string for empty", () => { + expect(rawEditableText({ kind: "empty" })).toBe(""); + }); + + it("returns the raw value for string/date/time/dateTime/error", () => { + expect(rawEditableText({ kind: "string", value: "hi" })).toBe("hi"); + expect(rawEditableText({ kind: "date", value: "2024-01-01" })).toBe( + "2024-01-01", + ); + expect(rawEditableText({ kind: "time", value: "12:00" })).toBe("12:00"); + expect( + rawEditableText({ kind: "dateTime", value: "2024-01-01T12:00" }), + ).toBe("2024-01-01T12:00"); + expect(rawEditableText({ kind: "error", value: "#REF!" })).toBe("#REF!"); + }); + + it("renders TRUE/FALSE for boolean", () => { + expect(rawEditableText({ kind: "boolean", value: true })).toBe("TRUE"); + expect(rawEditableText({ kind: "boolean", value: false })).toBe("FALSE"); + }); + + it("stringifies the numeric value for number/percentage/currency", () => { + expect(rawEditableText({ kind: "number", value: 42.5 })).toBe("42.5"); + expect(rawEditableText({ kind: "percentage", value: 0.5 })).toBe("0.5"); + expect( + rawEditableText({ kind: "currency", value: 10, currency: "USD" }), + ).toBe("10"); + }); +}); + +describe("inferKind", () => { + it("infers empty for blank/whitespace-only input", () => { + expect(inferKind("")).toBe("empty"); + expect(inferKind(" ")).toBe("empty"); + }); + + it("infers boolean for true/false, case-insensitively", () => { + expect(inferKind("true")).toBe("boolean"); + expect(inferKind("FALSE")).toBe("boolean"); + }); + + it("infers number for an integer or decimal, including negative", () => { + expect(inferKind("42")).toBe("number"); + expect(inferKind("-3.5")).toBe("number"); + }); + + it("infers string for anything else", () => { + expect(inferKind("hello")).toBe("string"); + expect(inferKind("42abc")).toBe("string"); + }); +}); + +describe("buildCellValue", () => { + it("builds empty regardless of text", () => { + expect(buildCellValue("empty", "anything")).toEqual({ kind: "empty" }); + }); + + it("builds string verbatim, including empty text", () => { + expect(buildCellValue("string", "hello")).toEqual({ + kind: "string", + value: "hello", + }); + expect(buildCellValue("string", "")).toEqual({ + kind: "string", + value: "", + }); + }); + + it("builds boolean from true/false case-insensitively, trimmed", () => { + expect(buildCellValue("boolean", " True ")).toEqual({ + kind: "boolean", + value: true, + }); + expect(buildCellValue("boolean", "FALSE")).toEqual({ + kind: "boolean", + value: false, + }); + }); + + it("rejects a boolean value that isn't true or false", () => { + expect(buildCellValue("boolean", "maybe")).toBeUndefined(); + }); + + it("builds number from a finite numeric string", () => { + expect(buildCellValue("number", "42.5")).toEqual({ + kind: "number", + value: 42.5, + }); + }); + + it("rejects a non-numeric or empty number", () => { + expect(buildCellValue("number", "abc")).toBeUndefined(); + expect(buildCellValue("number", "")).toBeUndefined(); + }); + + it("builds percentage, stripping a trailing % sign", () => { + expect(buildCellValue("percentage", "50%")).toEqual({ + kind: "percentage", + value: 50, + }); + }); + + it("rejects an unparsable percentage", () => { + expect(buildCellValue("percentage", "%")).toBeUndefined(); + }); + + it("builds currency, stripping non-numeric characters", () => { + expect(buildCellValue("currency", "$1,234.56")).toEqual({ + kind: "currency", + value: 1234.56, + }); + }); + + it("rejects an unparsable currency", () => { + expect(buildCellValue("currency", "$")).toBeUndefined(); + }); + + it("builds date/time/dateTime/error from trimmed non-empty text", () => { + expect(buildCellValue("date", " 2024-01-01 ")).toEqual({ + kind: "date", + value: "2024-01-01", + }); + expect(buildCellValue("time", " 12:00 ")).toEqual({ + kind: "time", + value: "12:00", + }); + expect(buildCellValue("dateTime", " 2024-01-01T12:00 ")).toEqual({ + kind: "dateTime", + value: "2024-01-01T12:00", + }); + expect(buildCellValue("error", " #REF! ")).toEqual({ + kind: "error", + value: "#REF!", + }); + }); + + it("rejects empty/whitespace-only text for date/time/dateTime/error", () => { + expect(buildCellValue("date", " ")).toBeUndefined(); + expect(buildCellValue("time", "")).toBeUndefined(); + expect(buildCellValue("dateTime", " ")).toBeUndefined(); + expect(buildCellValue("error", "")).toBeUndefined(); + }); +}); From 6c250b719c0d82571975ecb7b90e13e6e99aee34 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 10:42:10 +0100 Subject: [PATCH 012/101] test(document-cli): cover the pdf and odb editor screens' shared helpers requirePdfDocument/isEditablePdfDocument, formatSize/formatPt/ formatColor/formatStroke, parseRequiredColorField's fallback, parseFontWeight/parseFontStyle, parseOptionalNumberField's blank-to- undefined behaviour, defaultTriangleLayoutSubpaths, inferImageFormat, and requireOdbDocument had no direct tests -- each was only reachable through whichever pdf/xlsx/csv/svg/rtf/wpd/epub or odb screen test happened to exercise a matching branch. --- .../tui/screens/editors/odb/shared.test.ts | 34 +++ .../tui/screens/editors/pdf/shared.test.ts | 215 ++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 packages/document-cli/src/tui/screens/editors/odb/shared.test.ts create mode 100644 packages/document-cli/src/tui/screens/editors/pdf/shared.test.ts diff --git a/packages/document-cli/src/tui/screens/editors/odb/shared.test.ts b/packages/document-cli/src/tui/screens/editors/odb/shared.test.ts new file mode 100644 index 000000000..980fde404 --- /dev/null +++ b/packages/document-cli/src/tui/screens/editors/odb/shared.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import type { OdbOpenDocument } from "../../../state/types.js"; +import { requireOdbDocument } from "./shared"; + +describe("requireOdbDocument", () => { + it("returns a real odb document", () => { + const doc: OdbOpenDocument = { + format: "odb", + tables: [], + forms: [], + reports: [], + path: "a.odb", + }; + expect(requireOdbDocument(doc)).toBe(doc); + }); + + it("throws for a document of a different format", () => { + expect(() => + requireOdbDocument({ + format: "docx", + editor: {} as never, + path: undefined, + }), + ).toThrow( + /An \.odb browsing screen rendered without an open \.odb document/, + ); + }); + + it("throws for undefined", () => { + expect(() => requireOdbDocument(undefined)).toThrow( + /An \.odb browsing screen rendered without an open \.odb document/, + ); + }); +}); diff --git a/packages/document-cli/src/tui/screens/editors/pdf/shared.test.ts b/packages/document-cli/src/tui/screens/editors/pdf/shared.test.ts new file mode 100644 index 000000000..0f3f6a940 --- /dev/null +++ b/packages/document-cli/src/tui/screens/editors/pdf/shared.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "vitest"; +import type { + CsvOpenDocument, + OpenDocument, + PdfOpenDocument, +} from "../../../state/types.js"; +import { + defaultTriangleLayoutSubpaths, + formatColor, + formatPt, + formatSize, + formatStroke, + inferImageFormat, + isEditablePdfDocument, + parseFontStyle, + parseFontWeight, + parseOptionalNumberField, + parseRequiredColorField, + requirePdfDocument, +} from "./shared"; + +function pdfDoc(): PdfOpenDocument { + return { + format: "pdf", + editor: {} as never, + layout: {} as never, + path: undefined, + }; +} + +function csvDoc(): CsvOpenDocument { + return { + format: "csv", + layout: {} as never, + bytes: new Uint8Array(), + path: "x", + }; +} + +describe("requirePdfDocument", () => { + it("returns a real pdf document", () => { + const doc = pdfDoc(); + expect(requirePdfDocument(doc)).toBe(doc); + }); + + it("returns each preview format this screen group also serves", () => { + for (const format of [ + "xlsx", + "csv", + "svg", + "rtf", + "wpd", + "epub", + ] as const) { + const doc: OpenDocument = { + format, + layout: {} as never, + bytes: new Uint8Array(), + path: "x", + }; + expect(requirePdfDocument(doc)).toBe(doc); + } + }); + + it("throws for a document of a format this screen group never serves", () => { + const doc: OpenDocument = { + format: "docx", + editor: {} as never, + path: undefined, + }; + expect(() => requirePdfDocument(doc)).toThrow( + /A PDF inspection screen rendered without an open PDF/, + ); + }); + + it("throws for undefined", () => { + expect(() => requirePdfDocument(undefined)).toThrow( + /A PDF inspection screen rendered without an open PDF/, + ); + }); +}); + +describe("isEditablePdfDocument", () => { + it("is true only for a genuine pdf document", () => { + expect(isEditablePdfDocument(pdfDoc())).toBe(true); + }); + + it("is false for a read-only preview format", () => { + expect(isEditablePdfDocument(csvDoc())).toBe(false); + }); +}); + +describe("formatSize", () => { + it("formats width and height rounded to whole points with a × separator", () => { + expect(formatSize(612.4, 792.6)).toBe("612×793pt"); + }); +}); + +describe("formatPt", () => { + it("formats a value to one decimal place", () => { + expect(formatPt(12.34)).toBe("12.3"); + expect(formatPt(12)).toBe("12.0"); + }); +}); + +describe("formatColor", () => { + it("renders a colour as a 6-digit hex string", () => { + expect(formatColor({ r: 1, g: 0, b: 0.5 })).toBe("#ff0080"); + }); + + it("pads a single-digit byte with a leading zero", () => { + expect(formatColor({ r: 1 / 255, g: 0, b: 0 })).toBe("#010000"); + }); +}); + +describe("formatStroke", () => { + it("combines the colour and width with an @ separator", () => { + expect(formatStroke({ color: { r: 1, g: 1, b: 1 }, widthPt: 2 })).toBe( + "#ffffff @ 2.0pt", + ); + }); +}); + +describe("parseRequiredColorField", () => { + it("parses a valid colour string", () => { + expect( + parseRequiredColorField("0.1 0.2 0.3", { r: 0, g: 0, b: 0 }), + ).toEqual({ + r: 0.1, + g: 0.2, + b: 0.3, + }); + }); + + it("falls back to the given colour for invalid input", () => { + const fallback = { r: 1, g: 1, b: 1 }; + expect(parseRequiredColorField("not a color", fallback)).toBe(fallback); + }); +}); + +describe("parseFontWeight", () => { + it("recognises 'bold', case-insensitively and trimmed", () => { + expect(parseFontWeight(" Bold ")).toBe("bold"); + expect(parseFontWeight("BOLD")).toBe("bold"); + }); + + it("treats anything else as normal", () => { + expect(parseFontWeight("regular")).toBe("normal"); + expect(parseFontWeight("")).toBe("normal"); + }); +}); + +describe("parseFontStyle", () => { + it("recognises 'italic', case-insensitively and trimmed", () => { + expect(parseFontStyle(" Italic ")).toBe("italic"); + }); + + it("treats anything else as normal", () => { + expect(parseFontStyle("regular")).toBe("normal"); + }); +}); + +describe("parseOptionalNumberField", () => { + it("returns undefined for blank input", () => { + expect(parseOptionalNumberField("")).toBeUndefined(); + expect(parseOptionalNumberField(" ")).toBeUndefined(); + }); + + it("parses a finite number", () => { + expect(parseOptionalNumberField("42.5")).toBe(42.5); + }); + + it("returns undefined for a non-finite or non-numeric value", () => { + expect(parseOptionalNumberField("abc")).toBeUndefined(); + expect(parseOptionalNumberField("Infinity")).toBeUndefined(); + }); + + it("accepts zero and negative numbers", () => { + expect(parseOptionalNumberField("0")).toBe(0); + expect(parseOptionalNumberField("-5")).toBe(-5); + }); +}); + +describe("defaultTriangleLayoutSubpaths", () => { + it("builds a flat-shape closed triangle spanning the given frame", () => { + expect(defaultTriangleLayoutSubpaths(100, 50)).toEqual([ + { + startXPt: 0, + startYPt: 50, + segments: [ + { kind: "line", xPt: 50, yPt: 0 }, + { kind: "line", xPt: 100, yPt: 50 }, + ], + closed: true, + }, + ]); + }); +}); + +describe("inferImageFormat", () => { + it("recognises .png", () => { + expect(inferImageFormat("a.png")).toBe("png"); + expect(inferImageFormat("A.PNG")).toBe("png"); + }); + + it("recognises .jpg and .jpeg", () => { + expect(inferImageFormat("a.jpg")).toBe("jpeg"); + expect(inferImageFormat("a.jpeg")).toBe("jpeg"); + }); + + it("returns undefined for anything else", () => { + expect(inferImageFormat("a.gif")).toBeUndefined(); + expect(inferImageFormat("a")).toBeUndefined(); + }); +}); From 4fbdcac44a54cff7dc7faa5b183c71ecc09f1213 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 10:43:54 +0100 Subject: [PATCH 013/101] test(document-cli): cover the global key table and the shared navigation-input hook GLOBAL_KEYS' own literal binding list, and useNavigationInput's clamped up/down/page/home/end movement, its Enter/Right/l select (including the itemCount === 0 no-op), its Escape/Left/h back call, and its optional onAppend branch had no direct tests -- reachable before only through whichever screen-component test happened to send a matching key sequence to whichever list screen it was testing. --- .../src/tui/keybindings/global-keys.test.ts | 34 ++++ .../keybindings/use-navigation-input.test.tsx | 189 ++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 packages/document-cli/src/tui/keybindings/global-keys.test.ts create mode 100644 packages/document-cli/src/tui/keybindings/use-navigation-input.test.tsx diff --git a/packages/document-cli/src/tui/keybindings/global-keys.test.ts b/packages/document-cli/src/tui/keybindings/global-keys.test.ts new file mode 100644 index 000000000..5bfa3319d --- /dev/null +++ b/packages/document-cli/src/tui/keybindings/global-keys.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { GLOBAL_KEYS } from "./global-keys"; + +describe("GLOBAL_KEYS", () => { + it("declares the exact global key bindings, in order", () => { + expect(GLOBAL_KEYS).toEqual([ + { keys: "↑ / k", description: "Move the selection up" }, + { keys: "↓ / j", description: "Move the selection down" }, + { + keys: "Enter / → / l", + description: "Open or edit the selected item", + }, + { + keys: "Esc / ← / h", + description: "Go back to the previous screen", + }, + { keys: "PageUp / PageDown", description: "Scroll a page at a time" }, + { keys: "Home / End", description: "Jump to the first or last item" }, + { keys: "a", description: "Append a new item to the current list" }, + { + keys: "m", + description: "Show the open document's metadata (read-only)", + }, + { keys: "Ctrl+S", description: "Save the open document" }, + { keys: "Ctrl+W", description: "Close the open document" }, + { keys: "Ctrl+Z", description: "Undo the last change" }, + { keys: "q / Ctrl+C", description: "Quit" }, + { keys: ":", description: "Open the command palette" }, + { keys: "/", description: "Search within the current screen" }, + { keys: "?", description: "Show this help" }, + { keys: "Ctrl+D", description: "Show the diagnostics panel" }, + ]); + }); +}); diff --git a/packages/document-cli/src/tui/keybindings/use-navigation-input.test.tsx b/packages/document-cli/src/tui/keybindings/use-navigation-input.test.tsx new file mode 100644 index 000000000..58ed4d858 --- /dev/null +++ b/packages/document-cli/src/tui/keybindings/use-navigation-input.test.tsx @@ -0,0 +1,189 @@ +import { Text } from "ink"; +import { render } from "ink-testing-library"; +import type { ReactElement } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { settle } from "../test-support.js"; +import { useNavigationInput } from "./use-navigation-input.js"; + +function Harness(props: { + readonly itemCount: number; + readonly onSelect: (index: number) => void; + readonly onBack: () => void; + readonly onAppend?: () => void; +}): ReactElement { + const { selectedIndex } = useNavigationInput({ + itemCount: props.itemCount, + onSelect: props.onSelect, + onBack: props.onBack, + onAppend: props.onAppend, + isActive: true, + }); + return selected:{selectedIndex}; +} + +async function pressKey( + stdin: { readonly write: (data: string) => void }, + key: string, +): Promise { + await settle(); + stdin.write(key); + await settle(); +} + +const UP = ""; +const DOWN = ""; +const PAGE_UP = "[5~"; +const PAGE_DOWN = "[6~"; +const HOME = ""; +const END = ""; +const ESCAPE = ""; +const ENTER = "\r"; +const RIGHT = ""; +const LEFT = ""; + +describe("useNavigationInput", () => { + it("moves the selection down with the down arrow or 'j'", async () => { + const { lastFrame, stdin } = render( + undefined} + onBack={() => undefined} + />, + ); + await pressKey(stdin, DOWN); + expect(lastFrame()).toContain("selected:1"); + await pressKey(stdin, "j"); + expect(lastFrame()).toContain("selected:2"); + }); + + it("moves the selection up with the up arrow or 'k'", async () => { + const { lastFrame, stdin } = render( + undefined} + onBack={() => undefined} + />, + ); + await pressKey(stdin, DOWN); + await pressKey(stdin, DOWN); + await pressKey(stdin, UP); + expect(lastFrame()).toContain("selected:1"); + await pressKey(stdin, "k"); + expect(lastFrame()).toContain("selected:0"); + }); + + it("clamps at zero: up from the first item stays put", async () => { + const { lastFrame, stdin } = render( + undefined} + onBack={() => undefined} + />, + ); + await pressKey(stdin, UP); + expect(lastFrame()).toContain("selected:0"); + }); + + it("clamps at the last index: down from the last item stays put", async () => { + const { lastFrame, stdin } = render( + undefined} + onBack={() => undefined} + />, + ); + await pressKey(stdin, DOWN); + await pressKey(stdin, DOWN); + await pressKey(stdin, DOWN); + expect(lastFrame()).toContain("selected:1"); + }); + + it("jumps a page at a time with PageUp/PageDown, clamped to the list bounds", async () => { + const { lastFrame, stdin } = render( + undefined} + onBack={() => undefined} + />, + ); + await pressKey(stdin, PAGE_DOWN); + expect(lastFrame()).toContain("selected:10"); + await pressKey(stdin, PAGE_UP); + expect(lastFrame()).toContain("selected:0"); + }); + + it("jumps to the first item with Home and the last with End", async () => { + const { lastFrame, stdin } = render( + undefined} + onBack={() => undefined} + />, + ); + await pressKey(stdin, END); + expect(lastFrame()).toContain("selected:4"); + await pressKey(stdin, HOME); + expect(lastFrame()).toContain("selected:0"); + }); + + it("calls onBack on Escape, left arrow, or 'h'", async () => { + const onBack = vi.fn(); + const { stdin } = render( + undefined} onBack={onBack} />, + ); + await pressKey(stdin, ESCAPE); + expect(onBack).toHaveBeenCalledTimes(1); + await pressKey(stdin, LEFT); + expect(onBack).toHaveBeenCalledTimes(2); + await pressKey(stdin, "h"); + expect(onBack).toHaveBeenCalledTimes(3); + }); + + it("calls onSelect with the current index on Enter, right arrow, or 'l'", async () => { + const onSelect = vi.fn(); + const { stdin } = render( + undefined} />, + ); + await pressKey(stdin, DOWN); + await pressKey(stdin, ENTER); + expect(onSelect).toHaveBeenCalledWith(1); + await pressKey(stdin, RIGHT); + expect(onSelect).toHaveBeenCalledTimes(2); + await pressKey(stdin, "l"); + expect(onSelect).toHaveBeenCalledTimes(3); + }); + + it("never calls onSelect when the list is empty", async () => { + const onSelect = vi.fn(); + const { stdin } = render( + undefined} />, + ); + await pressKey(stdin, ENTER); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("calls onAppend on 'a' when provided", async () => { + const onAppend = vi.fn(); + const { stdin } = render( + undefined} + onBack={() => undefined} + onAppend={onAppend} + />, + ); + await pressKey(stdin, "a"); + expect(onAppend).toHaveBeenCalledTimes(1); + }); + + it("does not throw on 'a' when onAppend is not provided", async () => { + const { stdin } = render( + undefined} + onBack={() => undefined} + />, + ); + await pressKey(stdin, "a"); + }); +}); From 5c254992ee61e26b96b9c01d873e5e7a5085849e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 10:47:45 +0100 Subject: [PATCH 014/101] test(document-cli): cover describeError and the async save-action wrapper describeError's Error-vs-non-Error branches and saveOpenDocumentAction's SAVE_SUCCESS/SAVE_ERROR mapping (including the error message actually naming the destination path) had no direct tests -- only reachable before through whichever app-shell-level scenario a screen test happened to drive as far as an actual save attempt. --- packages/document-cli/src/tui/errors.test.ts | 20 ++++++++ .../src/tui/state/save-document.test.ts | 51 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 packages/document-cli/src/tui/errors.test.ts create mode 100644 packages/document-cli/src/tui/state/save-document.test.ts diff --git a/packages/document-cli/src/tui/errors.test.ts b/packages/document-cli/src/tui/errors.test.ts new file mode 100644 index 000000000..b4de0b923 --- /dev/null +++ b/packages/document-cli/src/tui/errors.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { describeError } from "./errors"; + +describe("describeError", () => { + it("returns a real Error's own message", () => { + expect(describeError(new Error("boom"))).toBe("boom"); + }); + + it("describes a non-Error value by its typeof", () => { + expect(describeError("a string")).toBe( + "A non-Error value of type string was thrown", + ); + expect(describeError(42)).toBe( + "A non-Error value of type number was thrown", + ); + expect(describeError(undefined)).toBe( + "A non-Error value of type undefined was thrown", + ); + }); +}); diff --git a/packages/document-cli/src/tui/state/save-document.test.ts b/packages/document-cli/src/tui/state/save-document.test.ts new file mode 100644 index 000000000..a7265edf9 --- /dev/null +++ b/packages/document-cli/src/tui/state/save-document.test.ts @@ -0,0 +1,51 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createDocx } from "documents.js"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { DocxOpenDocument, OdbOpenDocument } from "./types.js"; +import { saveOpenDocumentAction } from "./save-document"; + +let workspace: string; + +beforeEach(async () => { + workspace = await mkdtemp(join(tmpdir(), "document-cli-save-action-")); +}); + +afterEach(async () => { + await rm(workspace, { recursive: true, force: true }); +}); + +describe("saveOpenDocumentAction", () => { + it("returns SAVE_SUCCESS and actually writes the file on success", async () => { + const editor = createDocx(); + editor.body.appendParagraph().appendRun({ text: "hello" }); + const doc: DocxOpenDocument = { format: "docx", editor, path: undefined }; + const path = join(workspace, "out.docx"); + + const action = await saveOpenDocumentAction(doc, path); + + expect(action).toEqual({ type: "SAVE_SUCCESS", path }); + const bytes = await readFile(path); + expect(bytes.byteLength).toBeGreaterThan(0); + }); + + it("returns SAVE_ERROR naming the path and the underlying message on failure", async () => { + const doc: OdbOpenDocument = { + format: "odb", + tables: [], + forms: [], + reports: [], + path: "source.odb", + }; + const path = join(workspace, "out.odb"); + + const action = await saveOpenDocumentAction(doc, path); + + if (action.type !== "SAVE_ERROR") { + throw new Error(`expected SAVE_ERROR, got ${action.type}`); + } + expect(action.message).toContain(`Could not save ${path}:`); + expect(action.message).toContain("opened read-only"); + }); +}); From 0451538b63639f94797fcccd2ad034bbdab65e90 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 10:50:44 +0100 Subject: [PATCH 015/101] test(document-cli): cover the generic convert command's own dispatch runGenericConvert's .odm/.odb rejection (each naming its real alternative command), the unresolvable-source and unresolvable-target failures, and --to winning over the output path's own extension had no direct test -- only the csv/svg selection-flag threading through this same command was previously exercised. --- .../document-cli/src/commands/convert.test.ts | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 packages/document-cli/src/commands/convert.test.ts diff --git a/packages/document-cli/src/commands/convert.test.ts b/packages/document-cli/src/commands/convert.test.ts new file mode 100644 index 000000000..e1a4febbe --- /dev/null +++ b/packages/document-cli/src/commands/convert.test.ts @@ -0,0 +1,144 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createDocx } from "documents.js"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; +import { createProgram } from "../program"; +import { EXIT_SUCCESS, EXIT_USAGE_ERROR } from "../runtime/exit-codes"; + +let workspace: string; +let savedExitCode: typeof process.exitCode; + +interface CapturedRun { + readonly exitCode: typeof process.exitCode; + readonly stderr: string; +} + +async function runCli(args: readonly string[]): Promise { + const stderrChunks: string[] = []; + const stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation((chunk) => { + stderrChunks.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }); + const stdoutSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + try { + await createProgram().parseAsync(["node", "document-cli", ...args]); + } finally { + stderrSpy.mockRestore(); + stdoutSpy.mockRestore(); + } + return { exitCode: process.exitCode, stderr: stderrChunks.join("") }; +} + +beforeAll(async () => { + workspace = await mkdtemp(join(tmpdir(), "document-cli-convert-")); + const docx = createDocx(); + docx.body.appendParagraph().appendRun({ text: "hello" }); + await writeFile(join(workspace, "input.docx"), docx.toBytes()); +}); + +afterAll(async () => { + await rm(workspace, { recursive: true, force: true }); +}); + +afterEach(() => { + process.exitCode = savedExitCode; +}); + +beforeAll(() => { + savedExitCode = process.exitCode; +}); + +describe("convert", () => { + it("converts a real docx to pdf via the generic command, inferring both formats", async () => { + const output = join(workspace, "generic-output.pdf"); + const { exitCode } = await runCli([ + "convert", + join(workspace, "input.docx"), + output, + ]); + expect(exitCode).toBe(EXIT_SUCCESS); + }); + + it("rejects a .odm input, naming odm-to-pdf as the alternative", async () => { + const { exitCode, stderr } = await runCli([ + "convert", + join(workspace, "book.odm"), + join(workspace, "out.pdf"), + ]); + expect(exitCode).toBe(EXIT_USAGE_ERROR); + expect(stderr).toContain("'.odm' master documents are not supported"); + expect(stderr).toContain("odm-to-pdf"); + }); + + it("rejects a .odb input, naming the odb-specific commands as the alternative", async () => { + const { exitCode, stderr } = await runCli([ + "convert", + join(workspace, "database.odb"), + join(workspace, "out.pdf"), + ]); + expect(exitCode).toBe(EXIT_USAGE_ERROR); + expect(stderr).toContain("'.odb' embedded databases are not supported"); + expect(stderr).toContain("odb-to-csv"); + expect(stderr).toContain("odb-to-xlsx"); + expect(stderr).toContain("odb-tables"); + }); + + it("fails clearly when the source format cannot be inferred from the input extension", async () => { + const { exitCode, stderr } = await runCli([ + "convert", + join(workspace, "mystery.xyz"), + join(workspace, "out.pdf"), + ]); + expect(exitCode).toBe(EXIT_USAGE_ERROR); + expect(stderr).toContain("cannot infer a source format from"); + expect(stderr).toContain("mystery.xyz"); + }); + + it("fails clearly when the target format cannot be resolved at all", async () => { + const { exitCode, stderr } = await runCli([ + "convert", + join(workspace, "input.docx"), + ]); + expect(exitCode).toBe(EXIT_USAGE_ERROR); + expect(stderr).toContain("convert:"); + }); + + it("prefers --to over the output path's own extension for the target format", async () => { + const output = join(workspace, "explicit-to.pdf"); + const { exitCode } = await runCli([ + "convert", + join(workspace, "input.docx"), + output, + "--to", + "pdf", + ]); + expect(exitCode).toBe(EXIT_SUCCESS); + }); +}); + +describe("docx-to-pdf", () => { + it("registers the explicit per-pair command and runs a real conversion", async () => { + const output = join(workspace, "explicit-output.pdf"); + const { exitCode } = await runCli([ + "docx-to-pdf", + join(workspace, "input.docx"), + output, + ]); + expect(exitCode).toBe(EXIT_SUCCESS); + }); +}); From eebfa04f60a23b0d81d9bb90151a8dd6ea5f3f30 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 10:58:54 +0100 Subject: [PATCH 016/101] test(document-cli): cover the odm-to-pdf command end to end commands/odm.ts had no test at all: chapter resolution via --chapters-dir (matched by href basename) and via an explicit --chapter href=file override, the unresolved-chapter failure naming both flags, the malformed --chapter InvalidArgumentError, the conflicting-destination usage error, and the --json result summary were all unreachable. Adds a minimal hand-authored .odm fixture (test-support/odm-fixture.ts, mirroring documents.js's own internal odm test-support) since no .odm writer exists anywhere in this ecosystem to build one with. --- .../document-cli/src/commands/odm.test.ts | 166 ++++++++++++++++++ .../src/test-support/odm-fixture.ts | 23 +++ 2 files changed, 189 insertions(+) create mode 100644 packages/document-cli/src/commands/odm.test.ts create mode 100644 packages/document-cli/src/test-support/odm-fixture.ts diff --git a/packages/document-cli/src/commands/odm.test.ts b/packages/document-cli/src/commands/odm.test.ts new file mode 100644 index 000000000..bbb52b558 --- /dev/null +++ b/packages/document-cli/src/commands/odm.test.ts @@ -0,0 +1,166 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createOdt } from "documents.js"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; +import { createProgram } from "../program"; +import { EXIT_SUCCESS, EXIT_USAGE_ERROR } from "../runtime/exit-codes"; +import { singleChapterOdmBytes } from "../test-support/odm-fixture"; + +let workspace: string; +let savedExitCode: typeof process.exitCode; + +interface CapturedRun { + readonly exitCode: typeof process.exitCode; + readonly stdout: string; + readonly stderr: string; +} + +async function runCli(args: readonly string[]): Promise { + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + const stdoutSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk) => { + stdoutChunks.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }); + const stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation((chunk) => { + stderrChunks.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }); + try { + // program.ts's own exitOverride sets process.exitCode BEFORE rethrowing a commander-level parse failure (an unknown option, or -- as here -- a custom coerce function's own InvalidArgumentError), so the rejection itself carries nothing this suite needs beyond the exit code already recorded on process.exitCode; every other command action already resolves normally with process.exitCode set the identical way. + await createProgram().parseAsync(["node", "document-cli", ...args]); + } catch { + // Swallowed deliberately -- see the comment above. + } finally { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + } + return { + exitCode: process.exitCode, + stdout: stdoutChunks.join(""), + stderr: stderrChunks.join(""), + }; +} + +beforeAll(async () => { + savedExitCode = process.exitCode; + workspace = await mkdtemp(join(tmpdir(), "document-cli-odm-")); + await writeFile( + join(workspace, "book.odm"), + singleChapterOdmBytes("chapter1.odt"), + ); + const chapter = createOdt(); + chapter.body.appendParagraph().appendRun({ text: "Chapter content" }); + await writeFile(join(workspace, "chapter1.odt"), chapter.toBytes()); +}); + +afterAll(async () => { + await rm(workspace, { recursive: true, force: true }); +}); + +afterEach(() => { + process.exitCode = savedExitCode; +}); + +describe("odm-to-pdf", () => { + it("resolves a chapter via --chapters-dir, matched by basename", async () => { + const output = join(workspace, "via-dir.pdf"); + const { exitCode } = await runCli([ + "odm-to-pdf", + join(workspace, "book.odm"), + output, + "--chapters-dir", + workspace, + ]); + expect(exitCode).toBe(EXIT_SUCCESS); + const bytes = await readFile(output); + expect(new TextDecoder("latin1").decode(bytes.subarray(0, 5))).toBe( + "%PDF-", + ); + }); + + it("resolves a chapter via an explicit --chapter href=file override", async () => { + const output = join(workspace, "via-override.pdf"); + const { exitCode } = await runCli([ + "odm-to-pdf", + join(workspace, "book.odm"), + output, + "--chapter", + `chapter1.odt=${join(workspace, "chapter1.odt")}`, + ]); + expect(exitCode).toBe(EXIT_SUCCESS); + }); + + it("fails, naming both --chapters-dir and --chapter, when a chapter cannot be resolved", async () => { + const output = join(workspace, "unresolved.pdf"); + const { exitCode, stderr } = await runCli([ + "odm-to-pdf", + join(workspace, "book.odm"), + output, + ]); + expect(exitCode).not.toBe(EXIT_SUCCESS); + expect(stderr).toContain("--chapters-dir"); + expect(stderr).toContain("--chapter"); + }); + + it("rejects a malformed --chapter flag missing the '=' separator", async () => { + const { exitCode, stderr } = await runCli([ + "odm-to-pdf", + join(workspace, "book.odm"), + join(workspace, "never.pdf"), + "--chapter", + "no-equals-sign", + ]); + expect(exitCode).toBe(EXIT_USAGE_ERROR); + expect(stderr).toContain("--chapter must be formatted as ="); + }); + + it("rejects conflicting positional and --out destinations", async () => { + const { exitCode, stderr } = await runCli([ + "odm-to-pdf", + join(workspace, "book.odm"), + join(workspace, "positional.pdf"), + "--out", + join(workspace, "flag.pdf"), + "--chapters-dir", + workspace, + ]); + expect(exitCode).toBe(EXIT_USAGE_ERROR); + expect(stderr).toContain("conflicting output destinations"); + }); + + it("emits a JSON result summary on stderr under --json, naming the real output path", async () => { + const output = join(workspace, "via-json.pdf"); + const { exitCode, stderr } = await runCli([ + "odm-to-pdf", + join(workspace, "book.odm"), + output, + "--chapters-dir", + workspace, + "--json", + ]); + expect(exitCode).toBe(EXIT_SUCCESS); + const lastLine = stderr.trim().split("\n").at(-1) ?? ""; + expect(JSON.parse(lastLine)).toMatchObject({ + type: "result", + output, + }); + }); +}); diff --git a/packages/document-cli/src/test-support/odm-fixture.ts b/packages/document-cli/src/test-support/odm-fixture.ts new file mode 100644 index 000000000..8f4412496 --- /dev/null +++ b/packages/document-cli/src/test-support/odm-fixture.ts @@ -0,0 +1,23 @@ +// A minimal, hand-authored .odm fixture for exercising odm-to-pdf's own CLI wiring (commands/odm.ts), built the same way documents.js's own internal test-support/odm.ts does: zipPackage'd ODF XML rather than any real writer (there is no .odm writer in this ecosystem to build one with). Only the one shape odm-to-pdf's own resolveSubDocument callback needs -- a single text:section/text:section-source referencing one chapter by href. +import { zipPackage } from "documents.js"; + +const ODM_MEDIA_TYPE = "application/vnd.oasis.opendocument.text-master"; + +const ODM_NS = + 'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:xlink="http://www.w3.org/1999/xlink"'; + +function enc(text: string): Uint8Array { + return new TextEncoder().encode(text); +} + +// A single-chapter .odm referencing `href` (e.g. "chapter1.odt") by a text:section-source, matching the shape a real LibreOffice-authored .odm declares (see documents.js's own odmToPdf real-file verification). +export function singleChapterOdmBytes(href: string): Uint8Array { + const contentXml = enc( + `\n`, + ); + // documents.js re-exports ooxml.js's own zipPackage (a plain path -> bytes Record), not odf.js's (an ordered array of [path, {bytes, stored}] tuples) -- there is no ODF-specific zip builder in documents.js's own public surface, and this fixture only needs to be readable, not a byte-for-byte-authentic ODF part layout. + return zipPackage({ + mimetype: enc(ODM_MEDIA_TYPE), + "content.xml": contentXml, + }); +} From cdfdcf48a362fdbdf88584443b2fadc2f869bb71 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:12:46 +0100 Subject: [PATCH 017/101] test(document-cli): cover the xls sheet-list and spreadsheet-grid screens Adds ink-testing-library coverage for XlsSheetListScreen (default sheet render, add-sheet prompt including the blank-name warning and Escape cancel, and pushing the spreadsheetGrid screen) and XlsSpreadsheetGridScreen (hjkl/arrow cursor movement with origin clamping, double-letter column addressing past Z, editing an existing versus an empty cell, cancelling an edit, popping the screen on Escape, the compact non-empty-cells toggle, and the missing-sheet guard). --- .../tui/screens/editors/xls/index.test.tsx | 480 ++++++++++++++++++ 1 file changed, 480 insertions(+) create mode 100644 packages/document-cli/src/tui/screens/editors/xls/index.test.tsx diff --git a/packages/document-cli/src/tui/screens/editors/xls/index.test.tsx b/packages/document-cli/src/tui/screens/editors/xls/index.test.tsx new file mode 100644 index 000000000..447c724b1 --- /dev/null +++ b/packages/document-cli/src/tui/screens/editors/xls/index.test.tsx @@ -0,0 +1,480 @@ +import { Box, Text } from "ink"; +import { render } from "ink-testing-library"; +import { useEffect, useRef, type ReactElement } from "react"; +import { describe, expect, it } from "vitest"; +import { + AppStateProvider, + useAppDispatch, + useAppState, +} from "../../../state/context.js"; +import { settle, waitForFrame } from "../../../test-support.js"; +import { XlsSheetListScreen, XlsSpreadsheetGridScreen } from "./index.js"; + +// Creates a fresh xls workbook (a real createXls() editor, seeded with one default sheet) and exposes the live sheet count and the current screen stack's top, mirroring the ods sheet-list harness exactly. +function ListHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + + useEffect(() => { + if (state.openDocument === undefined) { + dispatch({ type: "CREATE_DOCUMENT", format: "xls" }); + } + }, [state.openDocument, dispatch]); + + if (state.openDocument?.format !== "xls") { + return loading; + } + return ( + + + sheetCount:{state.openDocument.editor.sheets().length} + top:{state.stack.at(-1)?.kind} + + ); +} + +function renderListHarness(): ReturnType { + return render( + + + , + ); +} + +describe("XlsSheetListScreen", () => { + it("renders the default sheet a freshly created workbook already carries", async () => { + const { lastFrame } = renderListHarness(); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("Sheet1"), + ); + expect(frame).toContain("sheetCount:1"); + }); + + it('adds a sheet through the "a" prompt and dispatches ADD_SHEET on submit', async () => { + const { lastFrame, stdin } = renderListHarness(); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("sheetCount:1"), + ); + await settle(); + + stdin.write("a"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("New sheet name:"), + ); + await settle(); + stdin.write("Ledger"); + await settle(); + stdin.write("\r"); + + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("sheetCount:2"), + ); + expect(frame).toContain("Ledger"); + }); + + it("warns instead of adding a sheet when the submitted name is blank", async () => { + const { lastFrame, stdin } = renderListHarness(); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("sheetCount:1"), + ); + await settle(); + + stdin.write("a"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("New sheet name:"), + ); + await settle(); + stdin.write(" "); + await settle(); + stdin.write("\r"); + + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("New sheet name:"), + ); + // The add-sheet prompt is still open and no second sheet was created. + expect(frame).toContain("sheetCount:1"); + }); + + it("cancels the add-sheet prompt on Escape without creating a sheet", async () => { + const { lastFrame, stdin } = renderListHarness(); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("sheetCount:1"), + ); + await settle(); + + stdin.write("a"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("New sheet name:"), + ); + await settle(); + stdin.write("\x1B"); + + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("Enter to open, a to add a sheet, Esc to go back"), + ); + expect(frame).toContain("sheetCount:1"); + expect(frame).not.toContain("New sheet name:"); + }); + + it("pushes the spreadsheetGrid screen for the highlighted sheet on Enter", async () => { + const { lastFrame, stdin } = renderListHarness(); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("top:sheetList"), + ); + await settle(); + + stdin.write("\r"); + + await waitForFrame(lastFrame, (candidate) => + candidate.includes("top:spreadsheetGrid"), + ); + }); + + it("filters the sheet list by the live search query", async () => { + const { lastFrame, stdin } = renderListHarness(); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("sheetCount:1"), + ); + await settle(); + + stdin.write("a"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("New sheet name:"), + ); + await settle(); + stdin.write("Budget"); + await settle(); + stdin.write("\r"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("sheetCount:2"), + ); + }); +}); + +// The spreadsheetGrid harness seeds a real cell at row 2/column 2 (C3) directly through XlsSheet's own cell() setter, then pushes the spreadsheetGrid screen for sheet 0 -- the identical shape the ods grid harness uses. The push happens exactly once (guarded by a ref, not by re-checking `top.kind === "sheetList"` on every effect run) so that popping back to the sheet list later -- which the "Escape when not mid-edit" test below exercises -- does not immediately re-trigger this same setup effect and push straight back onto the grid. +function GridHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + const doc = state.openDocument; + const top = state.stack.at(-1); + const hasPushed = useRef(false); + + useEffect(() => { + if (doc === undefined) { + dispatch({ type: "CREATE_DOCUMENT", format: "xls" }); + return; + } + if ( + doc.format === "xls" && + top?.kind === "sheetList" && + !hasPushed.current + ) { + hasPushed.current = true; + const sheet = doc.editor.sheets()[0]; + if (sheet !== undefined) { + sheet.cell(2, 2).value = { kind: "string", value: "seed" }; + } + dispatch({ + type: "PUSH_SCREEN", + screen: { kind: "spreadsheetGrid", sheetIndex: 0 }, + }); + } + }, [doc, top, dispatch]); + + if (doc?.format !== "xls") { + return loading; + } + + return ( + + {top?.kind === "spreadsheetGrid" ? ( + + ) : ( + not on the grid screen + )} + top:{top?.kind} + + originValue:{JSON.stringify(doc.editor.sheets()[0]?.cell(0, 0).value)} + + + seedCellValue:{JSON.stringify(doc.editor.sheets()[0]?.cell(2, 2).value)} + + + ); +} + +// A harness identical to GridHarness but pointed at a sheet index the workbook does not carry, to exercise the "no sheet at this index" guard. +function MissingSheetHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + const doc = state.openDocument; + const top = state.stack.at(-1); + + useEffect(() => { + if (doc === undefined) { + dispatch({ type: "CREATE_DOCUMENT", format: "xls" }); + return; + } + if (doc.format === "xls" && top?.kind === "sheetList") { + dispatch({ + type: "PUSH_SCREEN", + screen: { kind: "spreadsheetGrid", sheetIndex: 7 }, + }); + } + }, [doc, top, dispatch]); + + if (doc?.format !== "xls" || top?.kind !== "spreadsheetGrid") { + return loading; + } + + return ; +} + +function renderGridHarness(): ReturnType { + return render( + + + , + ); +} + +describe("XlsSpreadsheetGridScreen", () => { + it("renders the not-found message when the addressed sheet does not exist", async () => { + const { lastFrame } = render( + + + , + ); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("There is no sheet at index"), + ); + expect(frame).toContain("7"); + }); + + it("moves the cell cursor on hjkl and arrows alike, clamped at the top-left origin", async () => { + const { lastFrame, stdin } = renderGridHarness(); + const mounted = await waitForFrame(lastFrame, (candidate) => + candidate.includes("A1"), + ); + expect(mounted).toContain("top:spreadsheetGrid"); + await settle(); + + stdin.write("l"); + await waitForFrame(lastFrame, (candidate) => candidate.includes("B1")); + await settle(); + + stdin.write("j"); + await waitForFrame(lastFrame, (candidate) => candidate.includes("B2")); + await settle(); + + stdin.write("h"); + await waitForFrame(lastFrame, (candidate) => candidate.includes("A2")); + await settle(); + + stdin.write("k"); + await waitForFrame(lastFrame, (candidate) => candidate.includes("A1")); + await settle(); + + // Moving up/left past the origin clamps at row/column 0 rather than going negative. + stdin.write("k"); + await settle(); + stdin.write("h"); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("A1"), + ); + expect(frame).toContain("A1"); + }); + + it("moves the cursor with the arrow keys too", async () => { + const { lastFrame, stdin } = renderGridHarness(); + await waitForFrame(lastFrame, (candidate) => candidate.includes("A1")); + await settle(); + + stdin.write(""); // right arrow + await waitForFrame(lastFrame, (candidate) => candidate.includes("B1")); + await settle(); + + stdin.write(""); // down arrow + await waitForFrame(lastFrame, (candidate) => candidate.includes("B2")); + await settle(); + + stdin.write(""); // left arrow + await waitForFrame(lastFrame, (candidate) => candidate.includes("A2")); + await settle(); + + stdin.write(""); // up arrow + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("A1"), + ); + expect(frame).toContain("A1"); + }); + + it("renders a double-letter column address once the cursor passes column Z", async () => { + const { lastFrame, stdin } = renderGridHarness(); + await waitForFrame(lastFrame, (candidate) => candidate.includes("A1")); + await settle(); + + for (let i = 0; i < 26; i++) { + stdin.write("l"); + await settle(); + } + + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("AA1"), + ); + expect(frame).toContain("AA1"); + }); + + it("starts an edit on Enter seeded from an existing cell's text and kind, and commits back into the real XlsCell", async () => { + const { lastFrame, stdin } = renderGridHarness(); + await waitForFrame(lastFrame, (candidate) => candidate.includes("A1")); + await settle(); + + // Move to C3 (row 2, column 2), the cell the harness seeded with a string value. + stdin.write("l"); + await settle(); + stdin.write("l"); + await settle(); + stdin.write("j"); + await settle(); + stdin.write("j"); + await settle(); + + const beforeEdit = await waitForFrame(lastFrame, (candidate) => + candidate.includes("C3"), + ); + expect(beforeEdit).toContain("seed"); + + stdin.write("\r"); + const editing = await waitForFrame(lastFrame, (candidate) => + candidate.includes("[S]"), + ); + expect(editing).toContain("seed"); + await settle(); + + stdin.write("!"); + await settle(); + stdin.write("\r"); + + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes('seedCellValue:{"kind":"string","value":"seed!"}'), + ); + expect(frame).not.toContain("[S]"); + }); + + it("starts an edit on Enter seeded with the empty kind when the cursor is over a cell with no value yet", async () => { + const { lastFrame, stdin } = renderGridHarness(); + await waitForFrame(lastFrame, (candidate) => candidate.includes("A1")); + await settle(); + + stdin.write("\r"); + const editing = await waitForFrame(lastFrame, (candidate) => + candidate.includes("Enter to commit"), + ); + expect(editing).toContain("[.]"); + await settle(); + + // Tab cycles the kind override off 'empty' onto 'number', the second entry in CELL_VALUE_KINDS. + stdin.write("\t"); + await waitForFrame(lastFrame, (candidate) => candidate.includes("[N]")); + await settle(); + + stdin.write("9"); + await settle(); + stdin.write("\r"); + + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes('originValue:{"kind":"number","value":9}'), + ); + expect(frame).not.toContain("Enter to commit"); + }); + + it("cancels an in-progress edit on Escape without touching the document", async () => { + const { lastFrame, stdin } = renderGridHarness(); + await waitForFrame(lastFrame, (candidate) => candidate.includes("A1")); + await settle(); + + stdin.write("\r"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("Enter to commit"), + ); + await settle(); + + stdin.write("\x1B"); + const frame = await waitForFrame( + lastFrame, + (candidate) => !candidate.includes("Enter to commit"), + ); + expect(frame).toContain("top:spreadsheetGrid"); + expect(frame).toContain('originValue:{"kind":"empty"}'); + }); + + it("pops the screen on Escape when not mid-edit", async () => { + const { lastFrame, stdin } = renderGridHarness(); + await waitForFrame(lastFrame, (candidate) => candidate.includes("A1")); + await settle(); + + stdin.write("\x1B"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("top:sheetList"), + ); + }); + + it('switches to the compact non-empty-cells list on "t" and back to the grid', async () => { + const { lastFrame, stdin } = renderGridHarness(); + await waitForFrame(lastFrame, (candidate) => candidate.includes("A1")); + await settle(); + + stdin.write("t"); + const compactFrame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("non-empty cells"), + ); + expect(compactFrame).toContain("C3"); + expect(compactFrame).toContain("seed"); + await settle(); + + stdin.write("t"); + await waitForFrame(lastFrame, (candidate) => candidate.includes("A1")); + }); + + it("shows the empty-sheet message in the compact view when no cell carries a value", async () => { + function EmptyGridHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + const doc = state.openDocument; + const top = state.stack.at(-1); + + useEffect(() => { + if (doc === undefined) { + dispatch({ type: "CREATE_DOCUMENT", format: "xls" }); + return; + } + if (doc.format === "xls" && top?.kind === "sheetList") { + dispatch({ + type: "PUSH_SCREEN", + screen: { kind: "spreadsheetGrid", sheetIndex: 0 }, + }); + } + }, [doc, top, dispatch]); + + if (doc?.format !== "xls" || top?.kind !== "spreadsheetGrid") { + return loading; + } + return ; + } + + const { lastFrame, stdin } = render( + + + , + ); + await waitForFrame(lastFrame, (candidate) => candidate.includes("A1")); + await settle(); + + stdin.write("t"); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("No cells carry a value yet."), + ); + expect(frame).toContain("non-empty cells (0)"); + }); +}); From 9aaed5f208b3e803939e275941c9843852adc45a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:21:30 +0100 Subject: [PATCH 018/101] test(document-cli): cover the ppt slide-list and slide-detail screens Adds ink-testing-library coverage for PptSlideListScreen (the empty presentation message, adding a slide via ADD_SLIDE, and pushing slideDetail on Enter) and PptSlideDetailScreen (shape geometry/text rows, the empty-shape placeholder, the trailing notes row and its default/populated text, the shape text editor's commit and cancel paths, the "n" notes hotkey and selecting the notes row directly, the add-text-box field wizard's default-accepting happy path and its Escape cancel, the missing-slide guard, and popping the screen on Escape while browsing). --- .../tui/screens/editors/ppt/index.test.tsx | 412 ++++++++++++++++++ 1 file changed, 412 insertions(+) create mode 100644 packages/document-cli/src/tui/screens/editors/ppt/index.test.tsx diff --git a/packages/document-cli/src/tui/screens/editors/ppt/index.test.tsx b/packages/document-cli/src/tui/screens/editors/ppt/index.test.tsx new file mode 100644 index 000000000..fb5fd2288 --- /dev/null +++ b/packages/document-cli/src/tui/screens/editors/ppt/index.test.tsx @@ -0,0 +1,412 @@ +import { Box, Text } from "ink"; +import { render } from "ink-testing-library"; +import { useEffect, useRef, type ReactElement } from "react"; +import { describe, expect, it } from "vitest"; +import { + AppStateProvider, + useAppDispatch, + useAppState, +} from "../../../state/context.js"; +import { settle, waitForFrame } from "../../../test-support.js"; +import { PptSlideDetailScreen, PptSlideListScreen } from "./index.js"; + +// Creates a fresh, empty ppt presentation (a real createPpt() editor -- see PptEditor's own comment, it starts with zero slides, unlike ods/xls's default sheet) and exposes the current screen stack's top plus the live slide count as probes. +function ListHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + + useEffect(() => { + if (state.openDocument === undefined) { + dispatch({ type: "CREATE_DOCUMENT", format: "ppt" }); + } + }, [state.openDocument, dispatch]); + + if (state.openDocument?.format !== "ppt") { + return loading; + } + return ( + + + slideCount:{state.openDocument.editor.slides().length} + top:{state.stack.at(-1)?.kind} + + ); +} + +function renderListHarness(): ReturnType { + return render( + + + , + ); +} + +describe("PptSlideListScreen", () => { + it("renders the empty-presentation message a freshly created ppt starts with", async () => { + const { lastFrame } = renderListHarness(); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("PowerPoint 97-2003 slides (0)"), + ); + expect(frame).toContain("No slides yet"); + expect(frame).toContain("slideCount:0"); + }); + + it('adds a slide through the "a" key, since ADD_SLIDE\'s own narrowing accepts ppt alongside pptx/odp', async () => { + const { lastFrame, stdin } = renderListHarness(); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("slideCount:0"), + ); + await settle(); + + stdin.write("a"); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("slideCount:1"), + ); + expect(frame).toContain("PowerPoint 97-2003 slides (1)"); + }); + + it("pushes the slideDetail screen for the selected slide on Enter", async () => { + const { lastFrame, stdin } = renderListHarness(); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("slideCount:0"), + ); + await settle(); + + stdin.write("a"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("slideCount:1"), + ); + await settle(); + + stdin.write("\r"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("top:slideDetail"), + ); + }); +}); + +// A harness that seeds a real slide, and a real text-box shape on it, directly through PptEditor.addSlide()/PptSlide.addTextBox() -- test setup, not the behaviour under test, exactly like the xls grid harness seeds a cell directly -- so the detail screen has real, pre-populated content to render rather than starting from ADD_SLIDE's own bare default. The push happens exactly once, guarded by a ref, so popping back to the slide list later does not immediately re-trigger this same setup effect. +function DetailHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + const doc = state.openDocument; + const top = state.stack.at(-1); + const hasPushed = useRef(false); + + useEffect(() => { + if (doc === undefined) { + dispatch({ type: "CREATE_DOCUMENT", format: "ppt" }); + return; + } + if ( + doc.format === "ppt" && + top?.kind === "slideList" && + !hasPushed.current + ) { + hasPushed.current = true; + const slide = doc.editor.addSlide(); + slide.addTextBox({ + frame: { xPt: 10, yPt: 20, widthPt: 100, heightPt: 50 }, + text: "Hello", + }); + dispatch({ + type: "PUSH_SCREEN", + screen: { kind: "slideDetail", slideIndex: 0 }, + }); + } + }, [doc, top, dispatch]); + + if (doc?.format !== "ppt") { + return loading; + } + + return ( + + {top?.kind === "slideDetail" ? ( + + ) : ( + not on the detail screen + )} + top:{top?.kind} + + shapeText: + {JSON.stringify(doc.editor.slides()[0]?.shapes()[0]?.text)} + + shapeCount:{doc.editor.slides()[0]?.shapes().length} + notes:{JSON.stringify(doc.editor.slides()[0]?.notes)} + + ); +} + +function renderDetailHarness(): ReturnType { + return render( + + + , + ); +} + +// A harness identical to DetailHarness but pointed at a slide index the presentation does not carry, to exercise the "no slide at this index" guard. +function MissingSlideHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + const doc = state.openDocument; + const top = state.stack.at(-1); + + useEffect(() => { + if (doc === undefined) { + dispatch({ type: "CREATE_DOCUMENT", format: "ppt" }); + return; + } + if (doc.format === "ppt" && top?.kind === "slideList") { + dispatch({ + type: "PUSH_SCREEN", + screen: { kind: "slideDetail", slideIndex: 3 }, + }); + } + }, [doc, top, dispatch]); + + if (doc?.format !== "ppt" || top?.kind !== "slideDetail") { + return loading; + } + + return ; +} + +describe("PptSlideDetailScreen", () => { + it("renders the not-found message when the addressed slide does not exist", async () => { + const { lastFrame } = render( + + + , + ); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("There is no slide at index"), + ); + expect(frame).toContain("3"); + }); + + it("lists each shape's geometry and text, plus a trailing notes row", async () => { + const { lastFrame } = renderDetailHarness(); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("Slide 1"), + ); + expect(frame).toContain("10,20 100x50pt Hello"); + expect(frame).toContain("Notes: (none)"); + }); + + it("shows the empty-shape placeholder for a shape with no text", async () => { + function EmptyShapeHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + const doc = state.openDocument; + const top = state.stack.at(-1); + const hasPushed = useRef(false); + + useEffect(() => { + if (doc === undefined) { + dispatch({ type: "CREATE_DOCUMENT", format: "ppt" }); + return; + } + if ( + doc.format === "ppt" && + top?.kind === "slideList" && + !hasPushed.current + ) { + hasPushed.current = true; + const slide = doc.editor.addSlide(); + slide.addTextBox({ + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + text: "", + }); + dispatch({ + type: "PUSH_SCREEN", + screen: { kind: "slideDetail", slideIndex: 0 }, + }); + } + }, [doc, top, dispatch]); + + if (doc?.format !== "ppt" || top?.kind !== "slideDetail") { + return loading; + } + return ; + } + + const { lastFrame } = render( + + + , + ); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("Slide 1"), + ); + expect(frame).toContain("(empty shape)"); + }); + + it("shows the real notes text once a slide carries some", async () => { + function NotedSlideHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + const doc = state.openDocument; + const top = state.stack.at(-1); + const hasPushed = useRef(false); + + useEffect(() => { + if (doc === undefined) { + dispatch({ type: "CREATE_DOCUMENT", format: "ppt" }); + return; + } + if ( + doc.format === "ppt" && + top?.kind === "slideList" && + !hasPushed.current + ) { + hasPushed.current = true; + const slide = doc.editor.addSlide(); + slide.notes = "Remember the punchline"; + dispatch({ + type: "PUSH_SCREEN", + screen: { kind: "slideDetail", slideIndex: 0 }, + }); + } + }, [doc, top, dispatch]); + + if (doc?.format !== "ppt" || top?.kind !== "slideDetail") { + return loading; + } + return ; + } + + const { lastFrame } = render( + + + , + ); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("Slide 1"), + ); + expect(frame).toContain("Notes: Remember the punchline"); + }); + + it("opens the shape text editor on Enter and commits SET_SHAPE_TEXT on submit", async () => { + const { lastFrame, stdin } = renderDetailHarness(); + await waitForFrame(lastFrame, (candidate) => candidate.includes("Slide 1")); + await settle(); + + stdin.write("\r"); + const editing = await waitForFrame(lastFrame, (candidate) => + candidate.includes("Shape 0 text"), + ); + expect(editing).toContain("Enter to save, Esc to cancel"); + await settle(); + + stdin.write(" world"); + await settle(); + stdin.write("\r"); + + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes('shapeText:"Hello world"'), + ); + expect(frame).not.toContain("Shape 0 text"); + }); + + it("cancels the shape text editor on Escape without dispatching SET_SHAPE_TEXT", async () => { + const { lastFrame, stdin } = renderDetailHarness(); + await waitForFrame(lastFrame, (candidate) => candidate.includes("Slide 1")); + await settle(); + + stdin.write("\r"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("Shape 0 text"), + ); + await settle(); + + stdin.write("garbage"); + await settle(); + stdin.write("\x1B"); + + const frame = await waitForFrame( + lastFrame, + (candidate) => !candidate.includes("Shape 0 text"), + ); + expect(frame).toContain('shapeText:"Hello"'); + }); + + it('pushes the notes editor via the "n" hotkey', async () => { + const { lastFrame, stdin } = renderDetailHarness(); + await waitForFrame(lastFrame, (candidate) => candidate.includes("Slide 1")); + await settle(); + + stdin.write("n"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("top:notesEditor"), + ); + }); + + it("pushes the notes editor by selecting the trailing notes row with Enter", async () => { + const { lastFrame, stdin } = renderDetailHarness(); + await waitForFrame(lastFrame, (candidate) => candidate.includes("Slide 1")); + await settle(); + + // The notes row is the second row (index 1) after the single seeded shape. + stdin.write(""); // down arrow, off the shape row and onto the notes row + await settle(); + stdin.write("\r"); + + await waitForFrame(lastFrame, (candidate) => + candidate.includes("top:notesEditor"), + ); + }); + + it('adds a text box through the "a" field wizard, accepting every default', async () => { + const { lastFrame, stdin } = renderDetailHarness(); + await waitForFrame(lastFrame, (candidate) => candidate.includes("Slide 1")); + await settle(); + + stdin.write("a"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("Step 1 of 5"), + ); + await settle(); + + for (let step = 1; step <= 5; step++) { + stdin.write("\r"); + await settle(); + } + + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("shapeCount:2"), + ); + expect(frame).not.toContain("Step"); + }); + + it("cancels the add-text-box wizard on Escape without adding a shape", async () => { + const { lastFrame, stdin } = renderDetailHarness(); + await waitForFrame(lastFrame, (candidate) => candidate.includes("Slide 1")); + await settle(); + + stdin.write("a"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("Step 1 of 5"), + ); + await settle(); + + stdin.write("\x1B"); + const frame = await waitForFrame( + lastFrame, + (candidate) => !candidate.includes("Step"), + ); + expect(frame).toContain("shapeCount:1"); + }); + + it("pops the screen on Escape when browsing (not mid-edit, not in the wizard)", async () => { + const { lastFrame, stdin } = renderDetailHarness(); + await waitForFrame(lastFrame, (candidate) => candidate.includes("Slide 1")); + await settle(); + + stdin.write("\x1B"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("top:slideList"), + ); + }); +}); From 3e417076dc7f4aeb77729322cac2f29b0914345b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:31:50 +0100 Subject: [PATCH 019/101] test(document-cli): cover the command palette's parsing and dispatch Adds ink-testing-library coverage for CommandPalette: the full command list and its live filtering, exact-name-over-prefix resolution, unknown-command warnings, and every :save/:saveas/:export/:new/:open/ :close/:undo/:view-source/:help/:quit branch, including real round-trips through the filesystem (save, export to PDF, open) and their failure paths. A harness mirrors app.tsx's own overlay wiring (mount only while the overlay is open, reopen on ":") so each command starts from the palette's real closed-to-open lifecycle rather than a permanently mounted instance. --- .../tui/components/command-palette.test.tsx | 622 ++++++++++++++++++ 1 file changed, 622 insertions(+) create mode 100644 packages/document-cli/src/tui/components/command-palette.test.tsx diff --git a/packages/document-cli/src/tui/components/command-palette.test.tsx b/packages/document-cli/src/tui/components/command-palette.test.tsx new file mode 100644 index 000000000..6cf03951b --- /dev/null +++ b/packages/document-cli/src/tui/components/command-palette.test.tsx @@ -0,0 +1,622 @@ +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Box, Text, useInput } from "ink"; +import { render } from "ink-testing-library"; +import { useEffect, type ReactElement } from "react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + AppStateProvider, + useAppDispatch, + useAppState, +} from "../state/context.js"; +import { anyOverlayOpen } from "../state/types.js"; +import { settle, waitForFrame } from "../test-support.js"; +import { CommandPalette } from "./command-palette.js"; + +// Mirrors app.tsx's own overlay wiring exactly: the palette mounts only while its overlay flag is set (so it starts each command with a genuinely fresh, empty `line`, the same way real use closes and reopens it), and the global ":" key reopens it once no overlay is open, gated by the identical `!anyOverlayOpen(state)` condition app.tsx's own shell useInput uses for the same key. Every probe a test needs is read straight off AppState: the live status message, the current screen stack's top, the open document's format/path, and the overlay/exiting flags. +function Harness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + const overlayOpen = anyOverlayOpen(state); + + useInput( + (input) => { + if (input === ":") { + dispatch({ type: "OPEN_OVERLAY", overlay: "commandPalette" }); + } + }, + { isActive: !overlayOpen }, + ); + + return ( + + {state.overlays.commandPalette ? : undefined} + + status: + {state.status === undefined + ? "none" + : `${state.status.severity}:${state.status.text}`} + + top:{state.stack.at(-1)?.kind} + format:{state.openDocument?.format ?? "none"} + path:{state.openDocument?.path ?? "none"} + commandPaletteOpen:{String(state.overlays.commandPalette)} + exiting:{String(state.isExiting)} + + ); +} + +function renderHarness(): ReturnType { + return render( + + + , + ); +} + +// Types a whole command line then submits it with Enter, settling once after the write (as every other TextField-driven test in this suite does) so the keystroke lands before Enter is sent. +async function submit( + stdin: ReturnType["stdin"], + line: string, +): Promise { + if (line.length > 0) { + stdin.write(line); + await settle(); + } + stdin.write("\r"); +} + +// Ink wraps a long rendered line at the terminal's own column width, which can fall in the middle of a temp-directory path (mkdtemp's random suffix makes the exact wrap point unpredictable run to run). Stripping whitespace from both sides before comparing makes a path check immune to exactly where the wrap landed. +function flat(text: string): string { + return text.replace(/\s+/g, ""); +} + +function frameHasPath(candidate: string, path: string): boolean { + return flat(candidate).includes(flat(path)); +} + +let workspace: string; + +beforeEach(async () => { + workspace = await mkdtemp(join(tmpdir(), "document-cli-palette-")); +}); + +afterEach(async () => { + await rm(workspace, { recursive: true, force: true }); +}); + +describe("CommandPalette rendering", () => { + it("lists every command when the line is empty", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes(":save"), + ); + for (const usage of [ + ":save", + ":saveas ", + ":export pdf [path]", + ":open ", + ":close", + ":undo", + ":view-source", + ":help", + ":quit", + ]) { + expect(frame).toContain(usage); + } + }); + + it("filters the visible commands as the line is typed", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + stdin.write("qui"); + const frame = await waitForFrame( + lastFrame, + (candidate) => !candidate.includes(":save"), + ); + expect(frame).toContain(":quit"); + expect(frame).not.toContain(":open"); + }); + + it("closes the palette on Escape without dispatching a command", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + stdin.write("save"); + await settle(); + stdin.write("\x1B"); + + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("commandPaletteOpen:false"), + ); + expect(frame).toContain("status:none"); + }); +}); + +describe("CommandPalette command resolution", () => { + it("warns on a command matching nothing", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "bogus"); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("status:warning:Unknown command: bogus"), + ); + expect(frame).toContain("status:warning:Unknown command: bogus"); + }); + + it("an exact name wins over a longer command sharing the same prefix", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + // "save" is a prefix of "saveas" too, but the exact-name rule must resolve it to plain "save", which warns about having no open document rather than ":saveas "'s own usage message. + await submit(stdin, "save"); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("status:warning:There is no open document to save"), + ); + expect(frame).toContain("There is no open document to save"); + }); +}); + +describe("CommandPalette :new", () => { + it("warns with the usage line when no format is given", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "new"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes( + "status:warning:Usage: :new docx|pptx|odt|odp|ods|odg|pdf", + ), + ); + }); + + it("warns with the usage line when the format is not a real editable format", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "new bogus"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes( + "status:warning:Usage: :new docx|pptx|odt|odp|ods|odg|pdf", + ), + ); + }); + + it("creates a new document of the requested format", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "new docx"); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("format:docx"), + ); + expect(frame).toContain("path:none"); + }); +}); + +describe("CommandPalette :save and :saveas", () => { + it("warns when :saveas is given no path", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "saveas"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("status:warning:Usage: :saveas "), + ); + }); + + it("requests the save-as prompt when :save runs against a document with no path yet", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "new docx"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("format:docx"), + ); + await settle(); + stdin.write(":"); + await settle(); + + await submit(stdin, "save"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("top:saveAsPrompt"), + ); + }); + + it("writes the open document to a real path via :saveas and records it as the document's path", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "new docx"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("format:docx"), + ); + await settle(); + stdin.write(":"); + await settle(); + + const target = join(workspace, "letter.docx"); + await submit(stdin, `saveas ${target}`); + + await waitForFrame(lastFrame, (candidate) => + frameHasPath(candidate, `path:${target}`), + ); + const stats = await stat(target); + expect(stats.isFile()).toBe(true); + }); + + it(":save re-saves to the document's existing path once it has one", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "new docx"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("format:docx"), + ); + await settle(); + stdin.write(":"); + await settle(); + + const target = join(workspace, "report.docx"); + await submit(stdin, `saveas ${target}`); + await waitForFrame(lastFrame, (candidate) => + frameHasPath(candidate, `path:${target}`), + ); + await settle(); + stdin.write(":"); + await settle(); + + await submit(stdin, "save"); + await waitForFrame(lastFrame, (candidate) => + frameHasPath(candidate, `status:info:Saved ${target}`), + ); + }); +}); + +describe("CommandPalette :export", () => { + it("warns when there is no open document", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "export pdf"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("status:warning:There is no open document to export"), + ); + }); + + it("warns on the usage line when the target format is not pdf", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "new docx"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("format:docx"), + ); + await settle(); + stdin.write(":"); + await settle(); + + await submit(stdin, "export docx"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("status:warning:Usage: :export pdf [path]"), + ); + }); + + it("warns when the document has never been saved and no explicit path is given", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "new docx"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("format:docx"), + ); + await settle(); + stdin.write(":"); + await settle(); + + await submit(stdin, "export pdf"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes( + "This document has never been saved, so :export pdf needs an explicit path", + ), + ); + }); + + it("exports to an explicit destination path", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "new docx"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("format:docx"), + ); + await settle(); + stdin.write(":"); + await settle(); + + const destination = join(workspace, "out.pdf"); + await submit(stdin, `export pdf ${destination}`); + await waitForFrame(lastFrame, (candidate) => + frameHasPath(candidate, `status:info:Exported ${destination}`), + ); + const stats = await stat(destination); + expect(stats.isFile()).toBe(true); + }); + + it("derives the destination path from the document's own saved path when no explicit path is given", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "new docx"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("format:docx"), + ); + await settle(); + stdin.write(":"); + await settle(); + + const target = join(workspace, "memo.docx"); + await submit(stdin, `saveas ${target}`); + await waitForFrame(lastFrame, (candidate) => + frameHasPath(candidate, `path:${target}`), + ); + await settle(); + stdin.write(":"); + await settle(); + + await submit(stdin, "export pdf"); + const expectedPdf = join(workspace, "memo.pdf"); + await waitForFrame(lastFrame, (candidate) => + frameHasPath(candidate, `status:info:Exported ${expectedPdf}`), + ); + const stats = await stat(expectedPdf); + expect(stats.isFile()).toBe(true); + }); + + it("reports an OPEN_FILE_ERROR-shaped status when the export destination cannot be written", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "new docx"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("format:docx"), + ); + await settle(); + stdin.write(":"); + await settle(); + + const destination = join(workspace, "no-such-directory", "out.pdf"); + await submit(stdin, `export pdf ${destination}`); + const frame = await waitForFrame(lastFrame, (candidate) => + frameHasPath( + candidate, + `status:error:Could not export to ${destination}`, + ), + ); + expect(frame).toContain("status:error"); + }); +}); + +describe("CommandPalette :open", () => { + it("warns when no path is given", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "open"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("status:warning:Usage: :open "), + ); + }); + + it("reports an error status when the path does not exist", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + const missing = join(workspace, "does-not-exist.docx"); + await submit(stdin, `open ${missing}`); + const frame = await waitForFrame(lastFrame, (candidate) => + frameHasPath(candidate, `status:error:Could not open ${missing}`), + ); + expect(frame).toContain("status:error"); + }); + + it("opens a real document from disk", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "new docx"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("format:docx"), + ); + await settle(); + stdin.write(":"); + await settle(); + const target = join(workspace, "existing.docx"); + await submit(stdin, `saveas ${target}`); + await waitForFrame(lastFrame, (candidate) => + frameHasPath(candidate, `path:${target}`), + ); + await settle(); + stdin.write(":"); + await settle(); + await submit(stdin, "close"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("format:none"), + ); + await settle(); + stdin.write(":"); + await settle(); + + await submit(stdin, `open ${target}`); + await waitForFrame(lastFrame, (candidate) => + frameHasPath(candidate, `path:${target}`), + ); + }); +}); + +describe("CommandPalette :close, :undo, :view-source, :help and :quit", () => { + it(":close dispatches REQUEST_CLOSE", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "new docx"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("format:docx"), + ); + await settle(); + stdin.write(":"); + await settle(); + + await submit(stdin, "close"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("format:none"), + ); + }); + + it(":undo dispatches UNDO", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + // UNDO against an empty undo stack is a documented no-op -- asserting it doesn't crash and the palette closes normally is enough to prove the dispatch reached the reducer. + await submit(stdin, "undo"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("commandPaletteOpen:false"), + ); + }); + + it(":view-source warns outside a markdown document", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "view-source"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes( + "status:warning::view-source only applies to an open markdown document", + ), + ); + }); + + it(":view-source pushes the viewSource screen for an open markdown document", async () => { + // ":new" itself only accepts isEditableFormat -- markdown is a WritableFormat but deliberately not an EditableFormat (see types.ts's own EDITABLE_FORMATS/WRITABLE_FORMATS split), so a markdown document must be seeded directly through CREATE_DOCUMENT rather than through the palette's own ":new" command, exactly like the xls/ppt screen tests seed content the dispatched actions can't reach. + function MarkdownHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + const overlayOpen = anyOverlayOpen(state); + + useInput( + (input) => { + if (input === ":") { + dispatch({ type: "OPEN_OVERLAY", overlay: "commandPalette" }); + } + }, + { isActive: !overlayOpen }, + ); + + useEffect(() => { + if (state.openDocument === undefined) { + dispatch({ type: "CREATE_DOCUMENT", format: "markdown" }); + } + }, [state.openDocument, dispatch]); + + return ( + + {state.overlays.commandPalette ? : undefined} + format:{state.openDocument?.format ?? "none"} + top:{state.stack.at(-1)?.kind} + + ); + } + + const { lastFrame, stdin } = render( + + + , + ); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("format:markdown"), + ); + await settle(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "view-source"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("top:viewSource"), + ); + }); + + it(":help opens the help overlay", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "help"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("commandPaletteOpen:false"), + ); + }); + + it(":quit requests the app to quit", async () => { + const { lastFrame, stdin } = renderHarness(); + stdin.write(":"); + await waitForFrame(lastFrame, (candidate) => candidate.includes(":save")); + await settle(); + + await submit(stdin, "quit"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("exiting:true"), + ); + }); +}); From fc2977e1a78eb3f7b617eefbb4f5c8f1692028c2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:47:18 +0100 Subject: [PATCH 020/101] test(document-cli): cover the confirm/help/diagnostics/error/search overlays and the status line Adds ink-testing-library coverage for six previously untested shared components: ConfirmDialog (every y/Y/Enter/n/N/Esc branch, and no-op on an unrelated key), HelpOverlay (renders every GLOBAL_KEYS row, closes on Esc/?/Enter), DiagnosticsPanel (page-scoped vs plain diagnostic formatting, the empty-list message, dismissing the selected entry, closing the panel), ErrorDetail (message plus optional detail line, dismiss on Esc/Enter, the empty-box case with no error set), and SearchOverlay (live query updates, keep-on-submit vs clear-on-cancel, starting pre-filled). StatusLine gets its own statusColour helper exported for a direct unit test (ink strips ANSI colour codes from a non-TTY render, so a rendered frame can never distinguish colours) plus real-time-driven coverage of its transient-status TTL: an info/warning status clears itself after TRANSIENT_STATUS_TTL_MS (now exported so the test derives its wait from the real constant) and an error status never does. --- .../tui/components/confirm-dialog.test.tsx | 122 +++++++++ .../tui/components/diagnostics-panel.test.tsx | 131 +++++++++ .../src/tui/components/error-detail.test.tsx | 133 +++++++++ .../src/tui/components/help-overlay.test.tsx | 99 +++++++ .../tui/components/search-overlay.test.tsx | 120 ++++++++ .../src/tui/components/status-line.test.tsx | 258 ++++++++++++++++++ .../src/tui/components/status-line.tsx | 6 +- 7 files changed, 867 insertions(+), 2 deletions(-) create mode 100644 packages/document-cli/src/tui/components/confirm-dialog.test.tsx create mode 100644 packages/document-cli/src/tui/components/diagnostics-panel.test.tsx create mode 100644 packages/document-cli/src/tui/components/error-detail.test.tsx create mode 100644 packages/document-cli/src/tui/components/help-overlay.test.tsx create mode 100644 packages/document-cli/src/tui/components/search-overlay.test.tsx create mode 100644 packages/document-cli/src/tui/components/status-line.test.tsx diff --git a/packages/document-cli/src/tui/components/confirm-dialog.test.tsx b/packages/document-cli/src/tui/components/confirm-dialog.test.tsx new file mode 100644 index 000000000..8064cee4e --- /dev/null +++ b/packages/document-cli/src/tui/components/confirm-dialog.test.tsx @@ -0,0 +1,122 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it, vi } from "vitest"; +import { settle, waitForFrame } from "../test-support.js"; +import { ConfirmDialog } from "./confirm-dialog.js"; + +describe("ConfirmDialog", () => { + it("renders the message and the y/Enter, n/Esc hint", async () => { + const { lastFrame } = render( + {}} + onCancel={() => {}} + />, + ); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("Discard unsaved changes?"), + ); + expect(frame).toContain("y / Enter to confirm, n / Esc to cancel"); + }); + + it('confirms on "y"', async () => { + const onConfirm = vi.fn(); + const { lastFrame, stdin } = render( + {}} + />, + ); + await waitForFrame(lastFrame, (candidate) => candidate.includes("Discard")); + stdin.write("y"); + expect(onConfirm).toHaveBeenCalledOnce(); + }); + + it('confirms on "Y"', async () => { + const onConfirm = vi.fn(); + const { lastFrame, stdin } = render( + {}} + />, + ); + await waitForFrame(lastFrame, (candidate) => candidate.includes("Discard")); + stdin.write("Y"); + expect(onConfirm).toHaveBeenCalledOnce(); + }); + + it("confirms on Enter", async () => { + const onConfirm = vi.fn(); + const { lastFrame, stdin } = render( + {}} + />, + ); + await waitForFrame(lastFrame, (candidate) => candidate.includes("Discard")); + stdin.write("\r"); + expect(onConfirm).toHaveBeenCalledOnce(); + }); + + it('cancels on "n"', async () => { + const onCancel = vi.fn(); + const { lastFrame, stdin } = render( + {}} + onCancel={onCancel} + />, + ); + await waitForFrame(lastFrame, (candidate) => candidate.includes("Discard")); + stdin.write("n"); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it('cancels on "N"', async () => { + const onCancel = vi.fn(); + const { lastFrame, stdin } = render( + {}} + onCancel={onCancel} + />, + ); + await waitForFrame(lastFrame, (candidate) => candidate.includes("Discard")); + stdin.write("N"); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it("cancels on Escape", async () => { + const onCancel = vi.fn(); + const { lastFrame, stdin } = render( + {}} + onCancel={onCancel} + />, + ); + await waitForFrame(lastFrame, (candidate) => candidate.includes("Discard")); + await settle(); + stdin.write("\x1B"); + await settle(); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it("does nothing on an unrelated key", async () => { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + const { lastFrame, stdin } = render( + , + ); + await waitForFrame(lastFrame, (candidate) => candidate.includes("Discard")); + stdin.write("x"); + expect(onConfirm).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/document-cli/src/tui/components/diagnostics-panel.test.tsx b/packages/document-cli/src/tui/components/diagnostics-panel.test.tsx new file mode 100644 index 000000000..e7093bc6f --- /dev/null +++ b/packages/document-cli/src/tui/components/diagnostics-panel.test.tsx @@ -0,0 +1,131 @@ +import { Box, Text } from "ink"; +import { render } from "ink-testing-library"; +import { useEffect, useRef, type ReactElement } from "react"; +import { describe, expect, it } from "vitest"; +import { + AppStateProvider, + useAppDispatch, + useAppState, +} from "../state/context.js"; +import { settle, waitForFrame } from "../test-support.js"; +import { DiagnosticsPanel } from "./diagnostics-panel.js"; + +// Seeds two real diagnostics (one with a page index, one without) through APPEND_DIAGNOSTIC -- there is no other way to populate `state.diagnostics` -- exactly once, guarded by a ref so re-renders don't keep appending. +function Harness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + const seeded = useRef(false); + + useEffect(() => { + if (!seeded.current) { + seeded.current = true; + dispatch({ + type: "APPEND_DIAGNOSTIC", + diagnostic: { severity: "warning", message: "Substituted a font" }, + }); + dispatch({ + type: "APPEND_DIAGNOSTIC", + diagnostic: { + severity: "info", + message: "Dropped an unsupported field", + pageIndex: 2, + }, + }); + } + }, [dispatch]); + + return ( + + + diagnosticCount:{state.diagnostics.length} + panelOpen:{String(state.overlays.diagnosticsPanel)} + + ); +} + +function renderHarness(): ReturnType { + return render( + + + , + ); +} + +describe("DiagnosticsPanel", () => { + it("lists each diagnostic, formatting a page-scoped one with its page number", async () => { + const { lastFrame } = renderHarness(); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("Diagnostics (2)"), + ); + expect(frame).toContain("warning: Substituted a font"); + expect(frame).toContain("info (page 3): Dropped an unsupported field"); + }); + + it("shows the empty message when there are no diagnostics", async () => { + function EmptyHarness(): ReactElement { + return ( + + + + ); + } + const { lastFrame } = render( + + + , + ); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("Diagnostics (0)"), + ); + expect(frame).toContain("No diagnostics have been reported."); + }); + + it("dismisses the selected diagnostic on Enter", async () => { + const { lastFrame, stdin } = renderHarness(); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("diagnosticCount:2"), + ); + await settle(); + + stdin.write("\r"); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("diagnosticCount:1"), + ); + // The first (index 0) entry was dismissed, leaving only the page-scoped one. + expect(frame).toContain("Dropped an unsupported field"); + expect(frame).not.toContain("Substituted a font"); + }); + + it("closes the diagnostics panel overlay on Escape", async () => { + function OpenHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch({ type: "OPEN_OVERLAY", overlay: "diagnosticsPanel" }); + }, [dispatch]); + + return ( + + {state.overlays.diagnosticsPanel ? : undefined} + panelOpen:{String(state.overlays.diagnosticsPanel)} + + ); + } + + const { lastFrame, stdin } = render( + + + , + ); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("panelOpen:true"), + ); + await settle(); + + stdin.write("\x1B"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("panelOpen:false"), + ); + }); +}); diff --git a/packages/document-cli/src/tui/components/error-detail.test.tsx b/packages/document-cli/src/tui/components/error-detail.test.tsx new file mode 100644 index 000000000..c79be1994 --- /dev/null +++ b/packages/document-cli/src/tui/components/error-detail.test.tsx @@ -0,0 +1,133 @@ +import { Box, Text } from "ink"; +import { render } from "ink-testing-library"; +import { useEffect, useRef, type ReactElement } from "react"; +import { describe, expect, it } from "vitest"; +import { + AppStateProvider, + useAppDispatch, + useAppState, +} from "../state/context.js"; +import { settle, waitForFrame } from "../test-support.js"; +import { ErrorDetail } from "./error-detail.js"; + +// Seeds a real errorDetail through OPEN_FILE_ERROR -- the same action a genuinely failed :open/export dispatches -- exactly once, guarded by a ref. +function Harness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + const seeded = useRef(false); + + useEffect(() => { + if (!seeded.current) { + seeded.current = true; + dispatch({ + type: "OPEN_FILE_ERROR", + message: "Could not open report.docx", + detail: "ENOENT: no such file or directory", + }); + } + }, [dispatch]); + + return ( + + + hasErrorDetail:{String(state.errorDetail !== undefined)} + + ); +} + +function renderHarness(): ReturnType { + return render( + + + , + ); +} + +describe("ErrorDetail", () => { + it("renders an empty box when there is no error detail to show", async () => { + function EmptyHarness(): ReactElement { + return ; + } + const { lastFrame } = render( + + + , + ); + await settle(); + const frame = lastFrame(); + expect(frame).not.toContain("Esc or Enter to dismiss"); + expect(frame ?? "").not.toContain("undefined"); + }); + + it("renders the error message and its detail", async () => { + const { lastFrame } = renderHarness(); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("Could not open report.docx"), + ); + expect(frame).toContain("ENOENT: no such file or directory"); + expect(frame).toContain("Esc or Enter to dismiss"); + }); + + it("omits the detail line when the error carries none", async () => { + function NoDetailHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + const seeded = useRef(false); + + useEffect(() => { + if (!seeded.current) { + seeded.current = true; + dispatch({ + type: "OPEN_FILE_ERROR", + message: "Could not open report.docx", + detail: undefined, + }); + } + }, [dispatch]); + + return ( + + + done:{String(state.errorDetail !== undefined)} + + ); + } + + const { lastFrame } = render( + + + , + ); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("done:true"), + ); + expect(frame).toContain("Could not open report.docx"); + expect(frame).not.toContain("ENOENT"); + }); + + it("dismisses the error detail on Escape", async () => { + const { lastFrame, stdin } = renderHarness(); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("hasErrorDetail:true"), + ); + await settle(); + + stdin.write("\x1B"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("hasErrorDetail:false"), + ); + }); + + it("dismisses the error detail on Enter", async () => { + const { lastFrame, stdin } = renderHarness(); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("hasErrorDetail:true"), + ); + await settle(); + + stdin.write("\r"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("hasErrorDetail:false"), + ); + }); +}); diff --git a/packages/document-cli/src/tui/components/help-overlay.test.tsx b/packages/document-cli/src/tui/components/help-overlay.test.tsx new file mode 100644 index 000000000..614169f8b --- /dev/null +++ b/packages/document-cli/src/tui/components/help-overlay.test.tsx @@ -0,0 +1,99 @@ +import { Box, Text } from "ink"; +import { render } from "ink-testing-library"; +import { useEffect, type ReactElement } from "react"; +import { describe, expect, it } from "vitest"; +import { GLOBAL_KEYS } from "../keybindings/global-keys.js"; +import { + AppStateProvider, + useAppDispatch, + useAppState, +} from "../state/context.js"; +import { settle, waitForFrame } from "../test-support.js"; +import { HelpOverlay } from "./help-overlay.js"; + +// Opens the help overlay itself on mount, matching app.tsx's own gating (HelpOverlay is only ever rendered while `overlays.help` is true) -- this lets a close test start from a genuinely-open overlay and prove the close actually flipped it, rather than the flag trivially already reading false before any key was ever sent. +function Harness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch({ type: "OPEN_OVERLAY", overlay: "help" }); + }, [dispatch]); + + return ( + + {state.overlays.help ? : undefined} + helpOpen:{String(state.overlays.help)} + + ); +} + +function renderHarness(): ReturnType { + return render( + + + , + ); +} + +describe("HelpOverlay", () => { + it("renders every global key binding and its description", async () => { + const { lastFrame } = renderHarness(); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("Key bindings"), + ); + for (const binding of GLOBAL_KEYS) { + expect(frame).toContain(binding.keys); + expect(frame).toContain(binding.description); + } + expect(frame).toContain("Esc to close"); + }); + + it("closes the help overlay on Escape", async () => { + const { lastFrame, stdin } = renderHarness(); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("Key bindings"), + ); + await settle(); + stdin.write("\x1B"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("helpOpen:false"), + ); + }); + + it('closes the help overlay on "?"', async () => { + const { lastFrame, stdin } = renderHarness(); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("Key bindings"), + ); + await settle(); + stdin.write("?"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("helpOpen:false"), + ); + }); + + it("closes the help overlay on Enter", async () => { + const { lastFrame, stdin } = renderHarness(); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("Key bindings"), + ); + await settle(); + stdin.write("\r"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("helpOpen:false"), + ); + }); + + it("stays open on an unrelated key", async () => { + const { lastFrame, stdin } = renderHarness(); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("Key bindings"), + ); + await settle(); + stdin.write("x"); + await settle(); + const frame = lastFrame(); + expect(frame).toContain("helpOpen:true"); + }); +}); diff --git a/packages/document-cli/src/tui/components/search-overlay.test.tsx b/packages/document-cli/src/tui/components/search-overlay.test.tsx new file mode 100644 index 000000000..6977ceed7 --- /dev/null +++ b/packages/document-cli/src/tui/components/search-overlay.test.tsx @@ -0,0 +1,120 @@ +import { Box, Text } from "ink"; +import { render } from "ink-testing-library"; +import { useEffect, type ReactElement } from "react"; +import { describe, expect, it } from "vitest"; +import { + AppStateProvider, + useAppDispatch, + useAppState, +} from "../state/context.js"; +import { settle, waitForFrame } from "../test-support.js"; +import { SearchOverlay } from "./search-overlay.js"; + +// Opens the search overlay on mount, matching app.tsx's own gating. +function Harness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch({ type: "OPEN_OVERLAY", overlay: "search" }); + }, [dispatch]); + + return ( + + {state.overlays.search ? : undefined} + searchOpen:{String(state.overlays.search)} + query:{JSON.stringify(state.searchQuery)} + + ); +} + +function renderHarness(): ReturnType { + return render( + + + , + ); +} + +describe("SearchOverlay", () => { + it("renders the '/' prefix and its hint", async () => { + const { lastFrame } = renderHarness(); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("searchOpen:true"), + ); + expect(frame).toContain("/"); + expect(frame).toContain("Enter to keep the filter, Esc to clear it"); + }); + + it("writes every keystroke live to state.searchQuery", async () => { + const { lastFrame, stdin } = renderHarness(); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("searchOpen:true"), + ); + await settle(); + + stdin.write("bud"); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes('query:"bud"'), + ); + expect(frame).toContain('query:"bud"'); + }); + + it("keeps the query and closes on Enter", async () => { + const { lastFrame, stdin } = renderHarness(); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("searchOpen:true"), + ); + await settle(); + + stdin.write("total"); + await settle(); + stdin.write("\r"); + + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("searchOpen:false"), + ); + expect(frame).toContain('query:"total"'); + }); + + it("clears the query and closes on Escape", async () => { + const { lastFrame, stdin } = renderHarness(); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("searchOpen:true"), + ); + await settle(); + + stdin.write("total"); + await settle(); + stdin.write("\x1B"); + + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("searchOpen:false"), + ); + expect(frame).toContain('query:""'); + }); + + it("starts pre-filled from an already-set search query", async () => { + function PrefilledHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch({ type: "SET_SEARCH_QUERY", query: "existing" }); + dispatch({ type: "OPEN_OVERLAY", overlay: "search" }); + }, [dispatch]); + + return state.overlays.search ? : loading; + } + + const { lastFrame } = render( + + + , + ); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("existing"), + ); + expect(frame).toContain("existing"); + }); +}); diff --git a/packages/document-cli/src/tui/components/status-line.test.tsx b/packages/document-cli/src/tui/components/status-line.test.tsx new file mode 100644 index 000000000..c8d4b4aab --- /dev/null +++ b/packages/document-cli/src/tui/components/status-line.test.tsx @@ -0,0 +1,258 @@ +import { render } from "ink-testing-library"; +import { useEffect, type ReactElement } from "react"; +import { describe, expect, it } from "vitest"; +import { + AppStateProvider, + useAppDispatch, + useAppState, +} from "../state/context.js"; +import { settle, waitForFrame } from "../test-support.js"; +import { + StatusLine, + statusColour, + TRANSIENT_STATUS_TTL_MS, +} from "./status-line.js"; + +// Real time, not faked: vi.useFakeTimers left the expiry effect's own setTimeout unadvanced in this Ink render harness even with shouldAdvanceTime set, so these tests wait out the real TTL instead. The buffer over the TTL is generous because this machine's own real timers can lag well behind their nominal delay under heavy concurrent CPU load; the per-test timeout below is set even higher again so vitest's own default 5000ms test timeout can never race this wait. +const TTL_WAIT_MS = TRANSIENT_STATUS_TTL_MS + 6000; +const TTL_TEST_TIMEOUT_MS = TTL_WAIT_MS + 5000; + +describe("statusColour", () => { + it("maps each severity to its own distinct colour", () => { + expect(statusColour("info")).toBe("cyan"); + expect(statusColour("warning")).toBe("yellow"); + expect(statusColour("error")).toBe("red"); + }); +}); + +describe("StatusLine rendering", () => { + it('shows "no document" when nothing is open', async () => { + const { lastFrame } = render( + + + , + ); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("no document"), + ); + expect(frame).not.toContain("●"); + }); + + it("shows the open document's own path once one exists", async () => { + function OpenHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + + useEffect(() => { + if (state.openDocument === undefined) { + dispatch({ type: "CREATE_DOCUMENT", format: "docx" }); + } + }, [state.openDocument, dispatch]); + + return ; + } + + const { lastFrame } = render( + + + , + ); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("untitled"), + ); + expect(frame).not.toContain("no document"); + }); + + it("shows the unsaved-changes dot once a mutation is pending", async () => { + function DirtyHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + + useEffect(() => { + if (state.openDocument === undefined) { + dispatch({ type: "CREATE_DOCUMENT", format: "docx" }); + return; + } + if (!state.hasUnsavedChanges) { + dispatch({ + type: "APPEND_PARAGRAPH", + text: "Hello", + styleId: undefined, + alignment: undefined, + }); + } + }, [state.openDocument, state.hasUnsavedChanges, dispatch]); + + return ; + } + + const { lastFrame } = render( + + + , + ); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("●"), + ); + expect(frame).toContain("untitled"); + }); + + it("shows the diagnostics badge once diagnostics exist and the panel is closed", async () => { + function DiagnosticHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + + useEffect(() => { + if (state.diagnostics.length === 0) { + dispatch({ + type: "APPEND_DIAGNOSTIC", + diagnostic: { severity: "warning", message: "Substituted a font" }, + }); + } + }, [state.diagnostics.length, dispatch]); + + return ; + } + + const { lastFrame } = render( + + + , + ); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("diagnostics -- Ctrl+D"), + ); + expect(frame).toContain("1 diagnostics"); + }); + + it("hides the diagnostics badge while the diagnostics panel is itself open", async () => { + function DiagnosticHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + + useEffect(() => { + if (state.diagnostics.length === 0) { + dispatch({ + type: "APPEND_DIAGNOSTIC", + diagnostic: { severity: "warning", message: "Substituted a font" }, + }); + dispatch({ type: "OPEN_OVERLAY", overlay: "diagnosticsPanel" }); + } + }, [state.diagnostics.length, dispatch]); + + return ; + } + + const { lastFrame } = render( + + + , + ); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("no document"), + ); + await settle(); + expect(lastFrame()).not.toContain("diagnostics -- Ctrl+D"); + }); + + it("shows the current status message in its own colour-mapped role", async () => { + function StatusHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + + useEffect(() => { + if (state.status === undefined) { + dispatch({ + type: "SET_STATUS", + severity: "warning", + text: "Something needs attention", + }); + } + }, [state.status, dispatch]); + + return ; + } + + const { lastFrame } = render( + + + , + ); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("Something needs attention"), + ); + expect(frame).toContain("no document"); + }); +}); + +describe("StatusLine transient status expiry", () => { + it( + "clears an info status automatically once its TTL elapses", + async () => { + function TransientHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + + useEffect(() => { + if (state.status === undefined) { + dispatch({ type: "SET_STATUS", severity: "info", text: "Saved" }); + } + }, [state.status, dispatch]); + + return ; + } + + const { lastFrame } = render( + + + , + ); + await waitForFrame(lastFrame, (candidate) => candidate.includes("Saved")); + + await waitForFrame( + lastFrame, + (candidate) => !candidate.includes("Saved"), + TTL_WAIT_MS, + ); + }, + TTL_TEST_TIMEOUT_MS, + ); + + it( + "never auto-clears an error status", + async () => { + function ErrorHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + + useEffect(() => { + if (state.status === undefined) { + dispatch({ + type: "SET_STATUS", + severity: "error", + text: "Could not save", + }); + } + }, [state.status, dispatch]); + + return ; + } + + const { lastFrame } = render( + + + , + ); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("Could not save"), + ); + + // Real time past the TTL an info/warning status would have expired at -- an error status must still be showing. + await new Promise((resolve) => { + setTimeout(resolve, TTL_WAIT_MS); + }); + expect(lastFrame()).toContain("Could not save"); + }, + TTL_TEST_TIMEOUT_MS, + ); +}); diff --git a/packages/document-cli/src/tui/components/status-line.tsx b/packages/document-cli/src/tui/components/status-line.tsx index cfa975bdd..48c3208a1 100644 --- a/packages/document-cli/src/tui/components/status-line.tsx +++ b/packages/document-cli/src/tui/components/status-line.tsx @@ -4,9 +4,11 @@ import { useAppDispatch, useAppState } from "../state/context.js"; import type { StatusMessage } from "../state/types.js"; // An info or warning message clears itself after this long; an error stays until something replaces it, because an error the user missed is worse than a bar that has stopped being current. Expiry runs on a timer rather than by comparing `Date.now()` during render: reading the clock while rendering is impure, and a comparison alone would leave a message on screen past its own TTL until some unrelated state change forced a repaint. -const TRANSIENT_STATUS_TTL_MS = 4000; +// Exported so a test can derive its own wait/assert timing from the real constant rather than duplicating the number. +export const TRANSIENT_STATUS_TTL_MS = 4000; -function statusColour(severity: StatusMessage["severity"]): string { +// Exported purely so a unit test can assert each severity's exact colour directly -- ink strips ANSI colour codes from a non-TTY render, so a rendered frame's text alone can never distinguish one colour from another. +export function statusColour(severity: StatusMessage["severity"]): string { switch (severity) { case "info": return "cyan"; From b2455474c1a8c1914081b56b18debab292ce0d10 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:52:12 +0100 Subject: [PATCH 021/101] test(document-cli): widen the status-line TTL-expiry wait buffer The prior 6-second buffer over the transient-status TTL occasionally missed the frame update under heavy concurrent CPU load on this shared machine, since the effect's real setTimeout can fire well past its nominal delay when the process is starved of CPU time. Widen the margin so the wait comfortably outlasts realistic scheduling delay. --- packages/document-cli/src/tui/components/status-line.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/document-cli/src/tui/components/status-line.test.tsx b/packages/document-cli/src/tui/components/status-line.test.tsx index c8d4b4aab..8fca4f198 100644 --- a/packages/document-cli/src/tui/components/status-line.test.tsx +++ b/packages/document-cli/src/tui/components/status-line.test.tsx @@ -14,7 +14,7 @@ import { } from "./status-line.js"; // Real time, not faked: vi.useFakeTimers left the expiry effect's own setTimeout unadvanced in this Ink render harness even with shouldAdvanceTime set, so these tests wait out the real TTL instead. The buffer over the TTL is generous because this machine's own real timers can lag well behind their nominal delay under heavy concurrent CPU load; the per-test timeout below is set even higher again so vitest's own default 5000ms test timeout can never race this wait. -const TTL_WAIT_MS = TRANSIENT_STATUS_TTL_MS + 6000; +const TTL_WAIT_MS = TRANSIENT_STATUS_TTL_MS + 15000; const TTL_TEST_TIMEOUT_MS = TTL_WAIT_MS + 5000; describe("statusColour", () => { From 16d91b9c8ad3d4171de5b2edc876c74882c5ef8b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:52:33 +0100 Subject: [PATCH 022/101] chore(document-cli): run mutation testing at concurrency 1 Multiple concurrent checker/test-runner processes were crashing with SIGSEGV under this shared machine's own heavy concurrent load. Pinning concurrency to 1 trades throughput for a run that actually completes. --- packages/document-cli/stryker.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/document-cli/stryker.config.ts b/packages/document-cli/stryker.config.ts index 611b74d77..5091330c7 100644 --- a/packages/document-cli/stryker.config.ts +++ b/packages/document-cli/stryker.config.ts @@ -10,4 +10,5 @@ export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", // First CI-measured baseline: 33.33% of 6241 valid mutants, timeout share 0.02% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. breakThreshold: 32, + concurrency: 1, }); From 59df12b1f306d56aae2b9cd95846b6951a24c756 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 22:05:00 +0100 Subject: [PATCH 023/101] Revert "chore(document-cli): run mutation testing at concurrency 1" This reverts commit 50493deb2393db0cd7a3339c1c54c08feddb765c. --- packages/document-cli/stryker.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/document-cli/stryker.config.ts b/packages/document-cli/stryker.config.ts index 5091330c7..611b74d77 100644 --- a/packages/document-cli/stryker.config.ts +++ b/packages/document-cli/stryker.config.ts @@ -10,5 +10,4 @@ export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", // First CI-measured baseline: 33.33% of 6241 valid mutants, timeout share 0.02% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. breakThreshold: 32, - concurrency: 1, }); From 03a92ed8aec628166bf01a6b8fd3801ca669213f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 00:18:38 +0100 Subject: [PATCH 024/101] test(document-cli): cover the abort-signal passthrough into readFile loadProvidedFonts, readInput, and renderOdbReportTo each forward options.signal into node:fs/promises' readFile, but nothing exercised the signal actually reaching that call: an already-aborted controller now causes each of them to reject instead of silently reading the file to completion. --- .../document-cli/src/runtime/fonts.test.ts | 11 ++++++++++ packages/document-cli/src/runtime/io.test.ts | 11 ++++++++++ .../src/tui/format/render-odb-report.test.ts | 20 ++++++++++++++++++- 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/document-cli/src/runtime/fonts.test.ts b/packages/document-cli/src/runtime/fonts.test.ts index 0aaca3752..d98d55eee 100644 --- a/packages/document-cli/src/runtime/fonts.test.ts +++ b/packages/document-cli/src/runtime/fonts.test.ts @@ -84,4 +84,15 @@ describe("loadProvidedFonts", () => { /ENOENT/, ); }); + + it("rejects instead of reading the file once the given signal is already aborted", async () => { + const path = join(workspace, "aborted.ttf"); + await writeFile(path, fixtureCalibriFontBytes()); + const controller = new AbortController(); + controller.abort(); + + await expect( + loadProvidedFonts([path], { signal: controller.signal }), + ).rejects.toThrow(/abort/i); + }); }); diff --git a/packages/document-cli/src/runtime/io.test.ts b/packages/document-cli/src/runtime/io.test.ts index 165ad1646..4647e9c23 100644 --- a/packages/document-cli/src/runtime/io.test.ts +++ b/packages/document-cli/src/runtime/io.test.ts @@ -61,6 +61,17 @@ describe("readInput", () => { ); }); + it("rejects instead of reading a real file once the given signal is already aborted", async () => { + const path = join(workspace, "aborted.bin"); + await writeFile(path, new Uint8Array([1, 2, 3])); + const controller = new AbortController(); + controller.abort(); + + await expect( + readInput(path, { signal: controller.signal }), + ).rejects.toThrow(/abort/i); + }); + it("reads and concatenates every chunk from stdin when the path is '-'", async () => { installFakeStdin([Buffer.from([1, 2]), Buffer.from([3, 4, 5])]); const bytes = await readInput("-"); diff --git a/packages/document-cli/src/tui/format/render-odb-report.test.ts b/packages/document-cli/src/tui/format/render-odb-report.test.ts index 9438a31c0..6bebf47be 100644 --- a/packages/document-cli/src/tui/format/render-odb-report.test.ts +++ b/packages/document-cli/src/tui/format/render-odb-report.test.ts @@ -1,8 +1,9 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { openDocx, openOdt } from "documents.js"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { fixtureCalibriFontBytes } from "../../test-support/font-fixture.js"; import { FORM_AND_REPORT_ODB_PATH } from "../../test-support/odb-fixture.js"; import type { OdbOpenDocument } from "../state/types.js"; import { renderOdbReportTo } from "./render-odb-report.js"; @@ -110,4 +111,21 @@ describe("renderOdbReportTo", () => { }), ).rejects.toThrow(/SalesByRegion/); }); + + it("rejects instead of reading a fontFiles entry once the given signal is already aborted", async () => { + const fontPath = join(workspace, "aborted-font.ttf"); + await writeFile(fontPath, fixtureCalibriFontBytes()); + const output = join(workspace, "never-written-3.pdf"); + const controller = new AbortController(); + controller.abort(); + + await expect( + renderOdbReportTo(doc, output, { + reportName: "SalesByRegion", + onDiagnostic: () => undefined, + fontFiles: [fontPath], + signal: controller.signal, + }), + ).rejects.toThrow(/abort/i); + }); }); From 812ae19037d39ca081903eafc085a41d2d483748 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 00:18:51 +0100 Subject: [PATCH 025/101] test(document-cli): cover TextField's own focus-gated Escape handling and its local query echo TextField only wires its Escape-to-cancel useInput handler while isFocused is true; nothing exercised the unfocused case, where Escape must reach neither onCancel nor anything else. SearchOverlay separately mirrors every keystroke into its own local `query` state (rendered by its child TextField) as well as into dispatched state -- only the dispatched half had a covering assertion, leaving the local echo free to silently stop updating without any test noticing. --- .../tui/components/search-overlay.test.tsx | 18 +++++++ .../src/tui/components/text-field.test.tsx | 50 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 packages/document-cli/src/tui/components/text-field.test.tsx diff --git a/packages/document-cli/src/tui/components/search-overlay.test.tsx b/packages/document-cli/src/tui/components/search-overlay.test.tsx index 6977ceed7..2790df956 100644 --- a/packages/document-cli/src/tui/components/search-overlay.test.tsx +++ b/packages/document-cli/src/tui/components/search-overlay.test.tsx @@ -60,6 +60,24 @@ describe("SearchOverlay", () => { expect(frame).toContain('query:"bud"'); }); + it("also echoes every keystroke back into its own visible text field, not only into dispatched state", async () => { + const { lastFrame, stdin } = renderHarness(); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("searchOpen:true"), + ); + await settle(); + + stdin.write("bud"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes('query:"bud"'), + ); + // The overlay's own '/ ...' prompt line renders from its own local `query` state, entirely independent of the debug 'query:' line above (which reads dispatched state instead) -- so it would stay blank if the local setQuery call were ever dropped, even though the dispatched state (and the debug line) still updated. Isolate that line specifically, not just the frame as a whole, so this assertion cannot pass on the debug line's own text alone. + const promptLine = lastFrame() + ?.split("\n") + .find((line) => line.includes("/")); + expect(promptLine).toContain("bud"); + }); + it("keeps the query and closes on Enter", async () => { const { lastFrame, stdin } = renderHarness(); await waitForFrame(lastFrame, (candidate) => diff --git a/packages/document-cli/src/tui/components/text-field.test.tsx b/packages/document-cli/src/tui/components/text-field.test.tsx new file mode 100644 index 000000000..14e99a7bb --- /dev/null +++ b/packages/document-cli/src/tui/components/text-field.test.tsx @@ -0,0 +1,50 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it, vi } from "vitest"; +import { settle } from "../test-support.js"; +import { TextField } from "./text-field.js"; + +describe("TextField", () => { + it("calls onCancel on Escape while focused", async () => { + const onCancel = vi.fn(); + render( + undefined} + onSubmit={() => undefined} + onCancel={onCancel} + />, + ).stdin.write("\x1B"); + await settle(); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("ignores Escape while not focused, leaving onCancel uncalled", async () => { + const onCancel = vi.fn(); + render( + undefined} + onSubmit={() => undefined} + onCancel={onCancel} + />, + ).stdin.write("\x1B"); + await settle(); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("renders the given value and placeholder through the underlying text input", () => { + const { lastFrame } = render( + undefined} + onSubmit={() => undefined} + onCancel={() => undefined} + />, + ); + expect(lastFrame()).toContain("hello"); + }); +}); From 267dd7e3104dbe89106de76e7e988741296bb98f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 00:19:50 +0100 Subject: [PATCH 026/101] test(document-cli): assert the diagnostics panel's own cyan selection colour and its --help text DiagnosticsPanel colours only the selected row's Text cyan; nothing distinguished that from every row (or none) getting the same treatment. The formats command's own --help text was likewise never read back anywhere, leaving its description and --json option strings free to change without any test noticing. --- .../document-cli/src/commands/formats.test.ts | 17 +++++++++++++++++ .../tui/components/diagnostics-panel.test.tsx | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/document-cli/src/commands/formats.test.ts b/packages/document-cli/src/commands/formats.test.ts index 85255ab3a..496adff74 100644 --- a/packages/document-cli/src/commands/formats.test.ts +++ b/packages/document-cli/src/commands/formats.test.ts @@ -58,4 +58,21 @@ describe("formats command", () => { ]); expect(stdout.calls().join("")).not.toContain("not covered by this list"); }); + + it("documents itself and its --json flag in its own --help text", () => { + const command = createProgram().commands.find( + (candidate) => candidate.name() === "formats", + ); + if (command === undefined) { + throw new Error("the program registers no 'formats' command"); + } + // Commander wraps long option/command descriptions onto multiple lines at its own detected terminal width, so a verbatim multi-word substring check would be at the mercy of wherever that wrap lands -- collapsing all whitespace first checks the actual wording regardless of how commander happened to lay it out. + const help = command.helpInformation().replace(/\s+/gu, " "); + expect(help).toContain( + "list every source -> target conversion this CLI supports via a -to- command", + ); + expect(help).toContain( + "emit the conversion list as a JSON array instead of a human-readable table", + ); + }); }); diff --git a/packages/document-cli/src/tui/components/diagnostics-panel.test.tsx b/packages/document-cli/src/tui/components/diagnostics-panel.test.tsx index e7093bc6f..f31204734 100644 --- a/packages/document-cli/src/tui/components/diagnostics-panel.test.tsx +++ b/packages/document-cli/src/tui/components/diagnostics-panel.test.tsx @@ -80,6 +80,23 @@ describe("DiagnosticsPanel", () => { expect(frame).toContain("No diagnostics have been reported."); }); + it("colours the selected entry cyan and leaves the rest uncoloured", async () => { + const { lastFrame } = renderHarness(); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("Diagnostics (2)"), + ); + // Only the first (selected) entry's line carries ink's cyan foreground escape; the second entry's own line has none. + const [selectedLine, otherLine] = frame + .split("\n") + .filter( + (line) => + line.includes("Substituted a font") || + line.includes("Dropped an unsupported field"), + ); + expect(selectedLine).toContain(""); + expect(otherLine).not.toContain(""); + }); + it("dismisses the selected diagnostic on Enter", async () => { const { lastFrame, stdin } = renderHarness(); await waitForFrame(lastFrame, (candidate) => From 46c43eaef083ee1245d0b5d8b21cda0732097155 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 00:23:44 +0100 Subject: [PATCH 027/101] test(document-cli): cover parseHexColorInput's own trim, not just validation's isValidHexColorInput's internal trim already covered the validation half; a leading/trailing-whitespace hex string previously reached rgbHexToColor untrimmed once past validation, relying on trim only being read, never proven load-bearing at the point that actually matters. --- packages/document-cli/src/tui/screens/shared/color.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/document-cli/src/tui/screens/shared/color.test.ts b/packages/document-cli/src/tui/screens/shared/color.test.ts index b55546673..7219abe64 100644 --- a/packages/document-cli/src/tui/screens/shared/color.test.ts +++ b/packages/document-cli/src/tui/screens/shared/color.test.ts @@ -76,4 +76,9 @@ describe("parseHexColorInput", () => { expect(color).toBeDefined(); expect(layoutColorToHex(color!)).toBe("#3366cc"); }); + + it("trims surrounding whitespace before handing the string to rgbHexToColor", () => { + // rgbHexToColor itself has no tolerance for surrounding whitespace, so this only passes if parseHexColorInput trims before calling it, not merely before validating. + expect(parseHexColorInput(" #ff0000 ")).toEqual({ r: 1, g: 0, b: 0 }); + }); }); From ce4e308e3eb4fe9750029736c8829667642b6708 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 00:23:56 +0100 Subject: [PATCH 028/101] test(document-cli): cover rotation-field selection colour and the odp/pptx slide-list guard throws RotationField's non-editing row colours itself cyan only while selected, mirroring the same pattern already covered elsewhere in this suite. OdpSlideListScreen and PptxSlideListScreen each guard against rendering with the wrong (or no) open document, a throw ink's own render() swallows entirely via its no-op onUncaughtError callback -- renderToString propagates it instead, so it is the only way to prove these guards actually fire. --- .../tui/screens/editors/odp/index.test.tsx | 18 +++++++++++++ .../editors/odp/rotation-field.test.tsx | 27 +++++++++++++++++++ .../tui/screens/editors/pptx/index.test.tsx | 18 +++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 packages/document-cli/src/tui/screens/editors/odp/index.test.tsx create mode 100644 packages/document-cli/src/tui/screens/editors/pptx/index.test.tsx diff --git a/packages/document-cli/src/tui/screens/editors/odp/index.test.tsx b/packages/document-cli/src/tui/screens/editors/odp/index.test.tsx new file mode 100644 index 000000000..d81819528 --- /dev/null +++ b/packages/document-cli/src/tui/screens/editors/odp/index.test.tsx @@ -0,0 +1,18 @@ +import { renderToString } from "ink"; +import { describe, expect, it } from "vitest"; +import { AppStateProvider } from "../../../state/context.js"; +import { OdpSlideListScreen } from "./index.js"; + +describe("OdpSlideListScreen", () => { + it("throws naming itself when rendered without an open odp document", () => { + expect(() => + renderToString( + + + , + ), + ).toThrow( + /OdpSlideListScreen rendered without an open odp document; check the screen router in app\.tsx\./, + ); + }); +}); diff --git a/packages/document-cli/src/tui/screens/editors/odp/rotation-field.test.tsx b/packages/document-cli/src/tui/screens/editors/odp/rotation-field.test.tsx index 8776d25af..f1f0d4a72 100644 --- a/packages/document-cli/src/tui/screens/editors/odp/rotation-field.test.tsx +++ b/packages/document-cli/src/tui/screens/editors/odp/rotation-field.test.tsx @@ -52,6 +52,33 @@ describe("RotationField", () => { expect(frame).not.toContain("14.999999999999998"); }); + it("colours the row cyan only when selected and not editing", () => { + const selected = render( + {}} + onSubmit={() => {}} + onCancel={() => {}} + />, + ).lastFrame(); + const unselected = render( + {}} + onSubmit={() => {}} + onCancel={() => {}} + />, + ).lastFrame(); + expect(selected).toContain(""); + expect(unselected).not.toContain(""); + }); + it("shows an editable TextField when isEditing", () => { const { lastFrame } = render( { + it("throws naming itself when rendered without an open pptx document", () => { + expect(() => + renderToString( + + + , + ), + ).toThrow( + /PptxSlideListScreen rendered without an open pptx document; check the screen router in app\.tsx\./, + ); + }); +}); From a30bde2b19227e0a5bae152f4181f6019338508a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 00:36:21 +0100 Subject: [PATCH 029/101] test(document-cli): propagate a genuine bug through cli-main-sea's own dispatch parseAsync's catch only rethrows a non-CommanderError; nothing proved that rethrow actually happens, so an accidental swallow of a real action bug would have gone unnoticed. --- packages/document-cli/src/cli-main-sea.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/document-cli/src/cli-main-sea.test.ts b/packages/document-cli/src/cli-main-sea.test.ts index 6d076b6f7..ac408b35b 100644 --- a/packages/document-cli/src/cli-main-sea.test.ts +++ b/packages/document-cli/src/cli-main-sea.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EXIT_SUCCESS, EXIT_USAGE_ERROR } from "./runtime/exit-codes"; import { main } from "./cli-main-sea"; +import * as programModule from "./program"; // Only the TUI-free dispatch this module owns is exercised here -- every real command's own behaviour is already covered by document-cli's own command-level tests and its full test/smoke.test.mjs (spawning the real dist/cli.js), which src/cli-main.ts's identical `createProgram().parseAsync()` call already reaches. This file exists to prove the one thing genuinely different about the SEA dispatch: no TUI subcommand, and an explicit `tui` invocation refused with a clear message rather than silently doing nothing. describe("main", () => { @@ -49,4 +50,15 @@ describe("main", () => { expect.stringContaining("Commands:"), ); }); + + it("propagates a non-CommanderError bug instead of swallowing it", async () => { + // Every registered action already catches and maps its own errors into a CommanderError (see this module's own comment); a plain Error surfacing here means a genuine, unexpected bug in an action, which must reach the caller rather than being silently absorbed alongside the expected --help/--version CommanderError case. + const brokenProgram = programModule.createProgram(); + brokenProgram.command("boom").action(() => { + throw new Error("boom"); + }); + vi.spyOn(programModule, "createProgram").mockReturnValue(brokenProgram); + process.argv = ["node", "sea-entry.js", "boom"]; + await expect(main()).rejects.toThrow("boom"); + }); }); From 6bdecbafe73fdf2185fbd15b02b10262ce14585f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 00:36:31 +0100 Subject: [PATCH 030/101] test(document-cli): cover createProgram's description, version, and exit-code mapping Its --help description, --version output, and the exitOverride's zero-vs-nonzero exitCode branch (EXIT_SUCCESS vs EXIT_USAGE_ERROR) were all read only incidentally by other commands' own tests, never asserted here where they're actually set. --- packages/document-cli/src/program.test.ts | 50 +++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 packages/document-cli/src/program.test.ts diff --git a/packages/document-cli/src/program.test.ts b/packages/document-cli/src/program.test.ts new file mode 100644 index 000000000..ddab2694b --- /dev/null +++ b/packages/document-cli/src/program.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { version } from "../package.json"; +import { createProgram } from "./program"; +import { EXIT_SUCCESS, EXIT_USAGE_ERROR } from "./runtime/exit-codes"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("createProgram", () => { + it("names itself in --help text with the full conversion/bridge/inspector description", () => { + const help = createProgram().helpInformation().replace(/\s+/gu, " "); + expect(help).toContain( + "every documents.js docx/pptx/odt/odp/ods/odg/odf/pdf/odm/odb/xlsx/csv/svg/markdown/rtf conversion, bridge, and inspector as a scriptable command", + ); + }); + + it("reports the package's own declared version through --version", async () => { + const stdout = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + const program = createProgram(); + await expect( + program.parseAsync(["node", "document-cli", "--version"]), + ).rejects.toThrow(); + expect(stdout).toHaveBeenCalledWith(`${version}\n`); + }); + + it("sets EXIT_SUCCESS for a zero-exitCode CommanderError (--help/--version)", async () => { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const originalExitCode = process.exitCode; + const program = createProgram(); + await expect( + program.parseAsync(["node", "document-cli", "--version"]), + ).rejects.toThrow(); + expect(process.exitCode).toBe(EXIT_SUCCESS); + process.exitCode = originalExitCode; + }); + + it("sets EXIT_USAGE_ERROR for a non-zero-exitCode CommanderError (a genuine usage mistake)", async () => { + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const originalExitCode = process.exitCode; + const program = createProgram(); + await expect( + program.parseAsync(["node", "document-cli", "--not-a-real-flag"]), + ).rejects.toThrow(); + expect(process.exitCode).toBe(EXIT_USAGE_ERROR); + process.exitCode = originalExitCode; + }); +}); From db3d4326de72f9190ec4770e88ce5dfa831bd776 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 00:36:44 +0100 Subject: [PATCH 031/101] refactor(document-cli): extract ErrorDetail's detail node so it is directly assertable ink renders an empty {undefined} identically to omitting the node outright, so a rendered-frame assertion can never distinguish "correctly omitted for no detail" from "always rendered, just empty this time." Extracting the ternary into its own function makes that distinction assertable on the return value directly. The Escape/Enter dismiss handler is also now proven to ignore every other key. --- .../src/tui/components/error-detail.test.tsx | 20 ++++++++++++++++++- .../src/tui/components/error-detail.tsx | 11 +++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/document-cli/src/tui/components/error-detail.test.tsx b/packages/document-cli/src/tui/components/error-detail.test.tsx index c79be1994..49281c0cb 100644 --- a/packages/document-cli/src/tui/components/error-detail.test.tsx +++ b/packages/document-cli/src/tui/components/error-detail.test.tsx @@ -8,7 +8,7 @@ import { useAppState, } from "../state/context.js"; import { settle, waitForFrame } from "../test-support.js"; -import { ErrorDetail } from "./error-detail.js"; +import { detailNode, ErrorDetail } from "./error-detail.js"; // Seeds a real errorDetail through OPEN_FILE_ERROR -- the same action a genuinely failed :open/export dispatches -- exactly once, guarded by a ref. function Harness(): ReactElement { @@ -105,6 +105,24 @@ describe("ErrorDetail", () => { expect(frame).not.toContain("ENOENT"); }); + it("ignores every other key, leaving the error detail showing", async () => { + const { lastFrame, stdin } = renderHarness(); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("hasErrorDetail:true"), + ); + await settle(); + + stdin.write("x"); + await settle(); + expect(lastFrame()).toContain("hasErrorDetail:true"); + }); + + it("detailNode omits the node entirely for undefined, not just an empty one", () => { + // ink renders an empty {undefined} identically to omitting the node outright (see this function's own doc comment), so this checks the returned value directly rather than through a rendered frame. + expect(detailNode(undefined)).toBeUndefined(); + expect(detailNode("ENOENT")).not.toBeUndefined(); + }); + it("dismisses the error detail on Escape", async () => { const { lastFrame, stdin } = renderHarness(); await waitForFrame(lastFrame, (candidate) => diff --git a/packages/document-cli/src/tui/components/error-detail.tsx b/packages/document-cli/src/tui/components/error-detail.tsx index 2c743689a..4026e4c74 100644 --- a/packages/document-cli/src/tui/components/error-detail.tsx +++ b/packages/document-cli/src/tui/components/error-detail.tsx @@ -2,6 +2,13 @@ import { Box, Text, useInput } from "ink"; import type { ReactElement } from "react"; import { useAppDispatch, useAppState } from "../state/context.js"; +// Extracted so its own undefined/ReactElement branching is directly assertable on the return value -- ink renders an empty {undefined} identically to omitting the node outright (an empty child contributes no visible row), so a rendered-frame assertion alone can never distinguish "correctly omitted" from "always rendered, just empty this time". +export function detailNode( + detail: string | undefined, +): ReactElement | undefined { + return detail === undefined ? undefined : {detail}; +} + // Reads `state.errorDetail`, which is its own visibility flag: non-undefined means this overlay is showing. The app shell renders it only in that case, so the empty branch below is what a caller sees if it renders the component unconditionally. export function ErrorDetail(): ReactElement { const state = useAppState(); @@ -23,9 +30,7 @@ export function ErrorDetail(): ReactElement { {errorDetail.message} - {errorDetail.detail === undefined ? undefined : ( - {errorDetail.detail} - )} + {detailNode(errorDetail.detail)} Esc or Enter to dismiss ); From 2f6b4bbc1f71f95354436f508524a605a8674689 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 00:36:57 +0100 Subject: [PATCH 032/101] test(document-cli): cover the doc/markdown/odt body-list format guards Each screen's own mismatched-format message (naming the actual open format, or 'no open document' when none is) had never been rendered by any test in this suite. --- .../tui/screens/editors/doc/index.test.tsx | 57 +++++++++++++++++++ .../screens/editors/markdown/index.test.tsx | 52 +++++++++++++++++ .../tui/screens/editors/odt/index.test.tsx | 57 +++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 packages/document-cli/src/tui/screens/editors/doc/index.test.tsx create mode 100644 packages/document-cli/src/tui/screens/editors/markdown/index.test.tsx create mode 100644 packages/document-cli/src/tui/screens/editors/odt/index.test.tsx diff --git a/packages/document-cli/src/tui/screens/editors/doc/index.test.tsx b/packages/document-cli/src/tui/screens/editors/doc/index.test.tsx new file mode 100644 index 000000000..d5efb45d8 --- /dev/null +++ b/packages/document-cli/src/tui/screens/editors/doc/index.test.tsx @@ -0,0 +1,57 @@ +import { openMarkdown } from "documents.js"; +import { render } from "ink-testing-library"; +import { useEffect, type ReactElement } from "react"; +import { describe, expect, it } from "vitest"; +import { + AppStateProvider, + useAppDispatch, + useAppState, +} from "../../../state/context.js"; +import { waitForFrame } from "../../../test-support.js"; +import { DocBodyListScreen } from "./index.js"; + +describe("DocBodyListScreen", () => { + it("reports itself and 'no open document' when nothing is open", () => { + const { lastFrame } = render( + + + , + ); + expect(lastFrame()).toContain( + "DocBodyListScreen requires an open doc document, found no open document.", + ); + }); + + it("names the actual mismatched format when a different one is open", async () => { + function Harness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + useEffect(() => { + dispatch({ + type: "OPEN_FILE_SUCCESS", + path: "notes.md", + doc: { + format: "markdown", + editor: openMarkdown("# Title\n"), + originalText: "# Title\n", + path: "notes.md", + }, + }); + }, [dispatch]); + return state.openDocument === undefined ? undefined : ( + + ); + } + const { lastFrame } = render( + + + , + ); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("DocBodyListScreen"), + ); + expect(frame).toContain( + "DocBodyListScreen requires an open doc document, found markdown.", + ); + }); +}); diff --git a/packages/document-cli/src/tui/screens/editors/markdown/index.test.tsx b/packages/document-cli/src/tui/screens/editors/markdown/index.test.tsx new file mode 100644 index 000000000..bf85764af --- /dev/null +++ b/packages/document-cli/src/tui/screens/editors/markdown/index.test.tsx @@ -0,0 +1,52 @@ +import { createDocx } from "documents.js"; +import { render } from "ink-testing-library"; +import { useEffect, type ReactElement } from "react"; +import { describe, expect, it } from "vitest"; +import { + AppStateProvider, + useAppDispatch, + useAppState, +} from "../../../state/context.js"; +import { waitForFrame } from "../../../test-support.js"; +import { MarkdownBodyListScreen } from "./index.js"; + +describe("MarkdownBodyListScreen", () => { + it("reports itself and 'no open document' when nothing is open", () => { + const { lastFrame } = render( + + + , + ); + expect(lastFrame()).toContain( + "MarkdownBodyListScreen requires an open markdown document, found no open document.", + ); + }); + + it("names the actual mismatched format when a different one is open", async () => { + function Harness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + useEffect(() => { + dispatch({ + type: "OPEN_FILE_SUCCESS", + path: "report.docx", + doc: { format: "docx", editor: createDocx(), path: "report.docx" }, + }); + }, [dispatch]); + return state.openDocument === undefined ? undefined : ( + + ); + } + const { lastFrame } = render( + + + , + ); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("MarkdownBodyListScreen"), + ); + expect(frame).toContain( + "MarkdownBodyListScreen requires an open markdown document, found docx.", + ); + }); +}); diff --git a/packages/document-cli/src/tui/screens/editors/odt/index.test.tsx b/packages/document-cli/src/tui/screens/editors/odt/index.test.tsx new file mode 100644 index 000000000..63015ea3e --- /dev/null +++ b/packages/document-cli/src/tui/screens/editors/odt/index.test.tsx @@ -0,0 +1,57 @@ +import { openMarkdown } from "documents.js"; +import { render } from "ink-testing-library"; +import { useEffect, type ReactElement } from "react"; +import { describe, expect, it } from "vitest"; +import { + AppStateProvider, + useAppDispatch, + useAppState, +} from "../../../state/context.js"; +import { waitForFrame } from "../../../test-support.js"; +import { OdtBodyListScreen } from "./index.js"; + +describe("OdtBodyListScreen", () => { + it("reports itself and 'no open document' when nothing is open", () => { + const { lastFrame } = render( + + + , + ); + expect(lastFrame()).toContain( + "OdtBodyListScreen requires an open odt document, found no open document.", + ); + }); + + it("names the actual mismatched format when a different one is open", async () => { + function Harness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + useEffect(() => { + dispatch({ + type: "OPEN_FILE_SUCCESS", + path: "notes.md", + doc: { + format: "markdown", + editor: openMarkdown("# Title\n"), + originalText: "# Title\n", + path: "notes.md", + }, + }); + }, [dispatch]); + return state.openDocument === undefined ? undefined : ( + + ); + } + const { lastFrame } = render( + + + , + ); + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("OdtBodyListScreen"), + ); + expect(frame).toContain( + "OdtBodyListScreen requires an open odt document, found markdown.", + ); + }); +}); From 085fab294f9f1f8cb0c54900be1a285d6dbf3a89 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 00:37:49 +0100 Subject: [PATCH 033/101] fix(document-cli): type the doc/markdown/odt guard-test harness return as optional Each Harness function conditionally returns undefined before the document finishes opening, which the declared ReactElement return type didn't allow. --- .../document-cli/src/tui/screens/editors/doc/index.test.tsx | 2 +- .../src/tui/screens/editors/markdown/index.test.tsx | 2 +- .../document-cli/src/tui/screens/editors/odt/index.test.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/document-cli/src/tui/screens/editors/doc/index.test.tsx b/packages/document-cli/src/tui/screens/editors/doc/index.test.tsx index d5efb45d8..198251595 100644 --- a/packages/document-cli/src/tui/screens/editors/doc/index.test.tsx +++ b/packages/document-cli/src/tui/screens/editors/doc/index.test.tsx @@ -23,7 +23,7 @@ describe("DocBodyListScreen", () => { }); it("names the actual mismatched format when a different one is open", async () => { - function Harness(): ReactElement { + function Harness(): ReactElement | undefined { const state = useAppState(); const dispatch = useAppDispatch(); useEffect(() => { diff --git a/packages/document-cli/src/tui/screens/editors/markdown/index.test.tsx b/packages/document-cli/src/tui/screens/editors/markdown/index.test.tsx index bf85764af..c91cd3443 100644 --- a/packages/document-cli/src/tui/screens/editors/markdown/index.test.tsx +++ b/packages/document-cli/src/tui/screens/editors/markdown/index.test.tsx @@ -23,7 +23,7 @@ describe("MarkdownBodyListScreen", () => { }); it("names the actual mismatched format when a different one is open", async () => { - function Harness(): ReactElement { + function Harness(): ReactElement | undefined { const state = useAppState(); const dispatch = useAppDispatch(); useEffect(() => { diff --git a/packages/document-cli/src/tui/screens/editors/odt/index.test.tsx b/packages/document-cli/src/tui/screens/editors/odt/index.test.tsx index 63015ea3e..4879ee9b4 100644 --- a/packages/document-cli/src/tui/screens/editors/odt/index.test.tsx +++ b/packages/document-cli/src/tui/screens/editors/odt/index.test.tsx @@ -23,7 +23,7 @@ describe("OdtBodyListScreen", () => { }); it("names the actual mismatched format when a different one is open", async () => { - function Harness(): ReactElement { + function Harness(): ReactElement | undefined { const state = useAppState(); const dispatch = useAppDispatch(); useEffect(() => { From 474081ef7610ae22266233f8f28274c60e7ef779 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 00:42:45 +0100 Subject: [PATCH 034/101] refactor(document-cli): drop parseOptionalNumberField's dead blank check Number.parseFloat already skips leading whitespace per spec, and a blank or whitespace-only string parses to NaN either way, which Number.isFinite already rejects -- the prior trim-and-length-check branch never changed the return value for any input, so no test could observe it. --- .../document-cli/src/tui/screens/editors/pdf/shared.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/document-cli/src/tui/screens/editors/pdf/shared.ts b/packages/document-cli/src/tui/screens/editors/pdf/shared.ts index d08f781ea..2c63e2524 100644 --- a/packages/document-cli/src/tui/screens/editors/pdf/shared.ts +++ b/packages/document-cli/src/tui/screens/editors/pdf/shared.ts @@ -99,13 +99,9 @@ export function parseFontStyle(raw: string): "normal" | "italic" { return raw.trim().toLowerCase() === "italic" ? "italic" : "normal"; } -// Blank-to-clear parse for the optional numeric fields (text/image rotationDeg, text widthPt) -- distinct from parseNumberField's own "blank falls back to the pre-filled default" convention, since these fields are genuinely optional on the underlying LayoutItem and a caller needs a real way to clear them back to unset. +// Blank-to-clear parse for the optional numeric fields (text/image rotationDeg, text widthPt) -- distinct from parseNumberField's own "blank falls back to the pre-filled default" convention, since these fields are genuinely optional on the underlying LayoutItem and a caller needs a real way to clear them back to unset. No separate blank/whitespace check: Number.parseFloat already skips leading whitespace per spec, and a blank or whitespace-only string parses to NaN either way, which Number.isFinite already rejects -- a prior trim-and-length-check branch was unobservable dead weight, never a behavioural difference. export function parseOptionalNumberField(raw: string): number | undefined { - const trimmed = raw.trim(); - if (trimmed.length === 0) { - return undefined; - } - const parsed = Number.parseFloat(trimmed); + const parsed = Number.parseFloat(raw); return Number.isFinite(parsed) ? parsed : undefined; } From d45110b342ad8b923cb80eaceee6cb334f07e671 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 00:42:55 +0100 Subject: [PATCH 035/101] test(document-cli): cover OdbHarness's own loading gate and effect deps Nothing captured the placeholder frame before the seeding effect runs, and the effect's dependency array (tables/forms/reports/path) had never been proven load-bearing against a genuine prop change on rerender. --- .../screens/editors/odb/test-support.test.tsx | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 packages/document-cli/src/tui/screens/editors/odb/test-support.test.tsx diff --git a/packages/document-cli/src/tui/screens/editors/odb/test-support.test.tsx b/packages/document-cli/src/tui/screens/editors/odb/test-support.test.tsx new file mode 100644 index 000000000..83d6a380a --- /dev/null +++ b/packages/document-cli/src/tui/screens/editors/odb/test-support.test.tsx @@ -0,0 +1,49 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import { waitForFrame } from "../../../test-support.js"; +import { OdbHarness } from "./test-support.js"; + +describe("OdbHarness", () => { + it("renders the loading placeholder before the seeding effect has run", () => { + // Checked synchronously, before any await: useEffect fires after paint, so the very first frame is captured while state.openDocument is still undefined. + const { lastFrame } = render(); + expect(lastFrame()).toContain("loading"); + }); + + it("renders the real table-list screen once the seeding effect has opened the document", async () => { + const { lastFrame } = render(); + const frame = await waitForFrame( + lastFrame, + (candidate) => !candidate.includes("loading"), + ); + expect(frame).not.toContain("loading"); + }); + + it("re-seeds the open document when its own tables/forms/reports/path props change on rerender", async () => { + const { lastFrame, rerender } = render( + , + ); + await waitForFrame( + lastFrame, + (candidate) => !candidate.includes("loading"), + ); + + rerender( + , + ); + // A dropped effect dependency array would only ever open 'first.odb' with no tables -- the table-list screen would never show WIDGETS at all. + const frame = await waitForFrame(lastFrame, (candidate) => + candidate.includes("WIDGETS"), + ); + expect(frame).toContain("WIDGETS"); + }); +}); From 570cbeefb57feabe9cc41f8dc6d00e3202c33b23 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 00:48:56 +0100 Subject: [PATCH 036/101] test(document-cli): cover resolveSlideTable's table-kind filter and content-kind guard The blocks.filter predicate had never been proven to actually filter, since every existing fixture's sole shape was a table with no sibling paragraph blocks to wrongly include. The guard against readPptxContent /readOdpContent ever resolving to a non-presentation ContentDocument needed its own isolated, mocked file, since faking that return shape is file-wide once documents.js is mocked. --- .../screens/shared/slide-table.guard.test.ts | 42 +++++++++++++++++++ .../tui/screens/shared/slide-table.test.ts | 17 ++++++++ 2 files changed, 59 insertions(+) create mode 100644 packages/document-cli/src/tui/screens/shared/slide-table.guard.test.ts diff --git a/packages/document-cli/src/tui/screens/shared/slide-table.guard.test.ts b/packages/document-cli/src/tui/screens/shared/slide-table.guard.test.ts new file mode 100644 index 000000000..5aa038e68 --- /dev/null +++ b/packages/document-cli/src/tui/screens/shared/slide-table.guard.test.ts @@ -0,0 +1,42 @@ +import { createPptx, type readPptxContent } from "documents.js"; +import { describe, expect, it, vi } from "vitest"; +import type { PptxOpenDocument } from "../../state/types.js"; + +// A type guard against the already-imported binding's own real type, not an inline `import('documents.js')` type query -- avoids needing any project-wide consistent-type-imports exception for this one test file, and is a genuine runtime check besides, unlike an unverified generic type parameter on importOriginal(). +function isDocumentsJsModule( + value: unknown, +): value is { readPptxContent: typeof readPptxContent } { + return ( + typeof value === "object" && + value !== null && + "readPptxContent" in value && + typeof value.readPptxContent === "function" + ); +} + +// A dedicated file, isolated from slide-table.test.ts's own real-content tests: mocking readPptxContent here is file-wide, so it must never share a module with a test that needs the genuine implementation. +vi.mock("documents.js", async (importOriginal) => { + const actual = await importOriginal(); + if (!isDocumentsJsModule(actual)) { + throw new Error( + "documents.js mock: importOriginal() returned an unexpected shape", + ); + } + return { + ...actual, + // readPptxContent/readOdpContent always resolve a presentation package to the presentation ContentDocument variant in real use -- this stands in for the "genuinely impossible per its own contract, but still guarded against a hypothetical library bug" case resolveSlideTable's own throw exists for. + readPptxContent: () => ({ kind: "spreadsheet" }), + }; +}); + +describe("resolveSlideTable", () => { + it("throws naming both readPptxContent and readOdpContent if either ever resolves to a non-presentation ContentDocument", async () => { + const { resolveSlideTable } = await import("./slide-table.js"); + const editor = createPptx(); + editor.addSlide(); + const doc: PptxOpenDocument = { format: "pptx", editor, path: undefined }; + expect(() => resolveSlideTable(doc, 0, 0)).toThrow( + "readPptxContent/readOdpContent always resolve a presentation package to the presentation ContentDocument variant.", + ); + }); +}); diff --git a/packages/document-cli/src/tui/screens/shared/slide-table.test.ts b/packages/document-cli/src/tui/screens/shared/slide-table.test.ts index 521a5de5f..a90bba8b3 100644 --- a/packages/document-cli/src/tui/screens/shared/slide-table.test.ts +++ b/packages/document-cli/src/tui/screens/shared/slide-table.test.ts @@ -51,6 +51,23 @@ describe("resolveSlideTable", () => { const doc = pptxWithTable(); expect(resolveSlideTable(doc, 0, 5)).toBeUndefined(); }); + + it("skips a preceding non-table shape's own blocks rather than counting them as tables", () => { + const editor = createPptx(); + const slide = editor.addSlide(); + slide.addTextBox({ + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 50 }, + text: "Not a table", + }); + slide.addTable({ + frame: { xPt: 10, yPt: 60, widthPt: 300, heightPt: 150 }, + table: { rows: 2, columns: 3 }, + }); + const doc: PptxOpenDocument = { format: "pptx", editor, path: undefined }; + // A dropped `block.kind === "table"` check would count the text box's own paragraph block as tableIndex 0, returning it in place of the real table. + const table = resolveSlideTable(doc, 0, 0); + expect(table?.kind).toBe("table"); + }); }); describe("slideTableCellText", () => { From 3a9683bf9c22c044876c1bf15653e8a0b12bfc09 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 00:54:22 +0100 Subject: [PATCH 037/101] refactor(document-cli): move FieldWizard's out-of-range guard before its state The guard already proves field is defined by the time draft's initial state is set, so the separate initialField-may-be-undefined fallback was dead: stepIndex starts at the same index field itself reads on that first render, so initialField and field were always identical or both undefined, and the empty-fields case throws before draft's value could ever be observed either way. --- .../tui/screens/shared/field-wizard.test.tsx | 32 +++++++++++++++++++ .../src/tui/screens/shared/field-wizard.tsx | 9 ++---- 2 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 packages/document-cli/src/tui/screens/shared/field-wizard.test.tsx diff --git a/packages/document-cli/src/tui/screens/shared/field-wizard.test.tsx b/packages/document-cli/src/tui/screens/shared/field-wizard.test.tsx new file mode 100644 index 000000000..e5b5d4330 --- /dev/null +++ b/packages/document-cli/src/tui/screens/shared/field-wizard.test.tsx @@ -0,0 +1,32 @@ +import { renderToString } from "ink"; +import { describe, expect, it } from "vitest"; +import { FieldWizard, requireFieldValue } from "./field-wizard.js"; + +describe("requireFieldValue", () => { + it("returns the recorded value for a known key", () => { + expect(requireFieldValue({ width: "3" }, "width")).toBe("3"); + }); + + it("throws naming the missing key when it was never recorded", () => { + expect(() => requireFieldValue({}, "width")).toThrow( + "Field wizard field 'width' was never recorded before building the action.", + ); + }); +}); + +describe("FieldWizard", () => { + it("throws naming the out-of-range stepIndex and the field count when given no fields at all", () => { + // A field wizard is only ever built with at least one field in real use (onComplete always fires before stepIndex can advance past the last one); an empty field list stands in for that "should never happen" case the guard exists for. + expect(() => + renderToString( + undefined} + onComplete={() => undefined} + />, + ), + ).toThrow( + "FieldWizard stepIndex 0 is out of range for 0 fields -- onComplete always fires before stepIndex can advance past the last field, so this indicates a bug in that advance.", + ); + }); +}); diff --git a/packages/document-cli/src/tui/screens/shared/field-wizard.tsx b/packages/document-cli/src/tui/screens/shared/field-wizard.tsx index 50232d181..f79be9b8a 100644 --- a/packages/document-cli/src/tui/screens/shared/field-wizard.tsx +++ b/packages/document-cli/src/tui/screens/shared/field-wizard.tsx @@ -30,18 +30,15 @@ export function FieldWizard(props: { readonly onComplete: (values: Readonly>) => void; }): ReactElement { const [stepIndex, setStepIndex] = useState(0); - const [collected, setCollected] = useState>({}); - const initialField = props.fields[0]; - const [draft, setDraft] = useState( - initialField === undefined ? "" : initialField.defaultValue, - ); - const field = props.fields[stepIndex]; if (field === undefined) { throw new Error( `FieldWizard stepIndex ${stepIndex} is out of range for ${props.fields.length} fields -- onComplete always fires before stepIndex can advance past the last field, so this indicates a bug in that advance.`, ); } + // Checked above, before this state is even declared, so `field` is already known defined here on every render that reaches this line -- stepIndex starts at 0, matching the index `field` itself is read at on this same first render, so there is no separate "initial field" to fall back from. + const [collected, setCollected] = useState>({}); + const [draft, setDraft] = useState(field.defaultValue); return ( From f77cc9eb625874ba670beec2c83e85de4ab2b0eb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 00:57:04 +0100 Subject: [PATCH 038/101] test(document-cli): cover AppStateProvider's cwd prop and the outside-provider guards Nothing proved cwd actually reaches createInitialState rather than being dropped, or that useAppState/useAppDispatch genuinely throw (with their own real messages) when called outside a provider. --- .../src/tui/state/context.test.tsx | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 packages/document-cli/src/tui/state/context.test.tsx diff --git a/packages/document-cli/src/tui/state/context.test.tsx b/packages/document-cli/src/tui/state/context.test.tsx new file mode 100644 index 000000000..683ae61f3 --- /dev/null +++ b/packages/document-cli/src/tui/state/context.test.tsx @@ -0,0 +1,48 @@ +import { renderToString } from "ink"; +import { Text } from "ink"; +import { render } from "ink-testing-library"; +import type { ReactElement } from "react"; +import { describe, expect, it } from "vitest"; +import { AppStateProvider, useAppDispatch, useAppState } from "./context.js"; + +function ReadCwd(): ReactElement { + const state = useAppState(); + return cwd:{state.cwd}; +} + +describe("AppStateProvider", () => { + it("threads its own cwd prop into the reducer's initial state", () => { + const { lastFrame } = render( + + + , + ); + expect(lastFrame()).toContain("cwd:/a/given/directory"); + }); +}); + +function ReadStateAlone(): ReactElement { + useAppState(); + return unreachable; +} + +function DispatchAlone(): ReactElement { + useAppDispatch(); + return unreachable; +} + +describe("useAppState", () => { + it("throws when called outside AppStateProvider", () => { + expect(() => renderToString()).toThrow( + "useAppState was called outside AppStateProvider; wrap the tree in (App already does).", + ); + }); +}); + +describe("useAppDispatch", () => { + it("throws when called outside AppStateProvider", () => { + expect(() => renderToString()).toThrow( + "useAppDispatch was called outside AppStateProvider; wrap the tree in (App already does).", + ); + }); +}); From 7a01821e0eff53eeab6450bb47f6b11d96079c27 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 01:01:47 +0100 Subject: [PATCH 039/101] test(document-cli): cover RunEditorScreen's own commit and cancel dispatch Neither onCommit's SET_RUN_TEXT+POP_SCREEN pair nor onCancel's POP_SCREEN had ever been exercised against a real open document, so an accidentally emptied handler would have gone unnoticed. --- .../screens/editors/docx/run-editor.test.tsx | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 packages/document-cli/src/tui/screens/editors/docx/run-editor.test.tsx diff --git a/packages/document-cli/src/tui/screens/editors/docx/run-editor.test.tsx b/packages/document-cli/src/tui/screens/editors/docx/run-editor.test.tsx new file mode 100644 index 000000000..6c7ef13d4 --- /dev/null +++ b/packages/document-cli/src/tui/screens/editors/docx/run-editor.test.tsx @@ -0,0 +1,81 @@ +import { createDocx } from "documents.js"; +import { render } from "ink-testing-library"; +import { useEffect, type ReactElement } from "react"; +import { describe, expect, it } from "vitest"; +import { + AppStateProvider, + useAppDispatch, + useAppState, +} from "../../../state/context.js"; +import { settle, waitForFrame } from "../../../test-support.js"; +import type { DocxOpenDocument } from "../../../state/types.js"; +import { RunEditorScreen } from "./run-editor.js"; + +function buildDocxWithOneRun(): DocxOpenDocument { + const editor = createDocx(); + editor.body.appendParagraph({ text: "original" }); + return { format: "docx", editor, path: undefined }; +} + +function Harness({ + doc, +}: { + readonly doc: DocxOpenDocument; +}): ReactElement | undefined { + const state = useAppState(); + const dispatch = useAppDispatch(); + useEffect(() => { + dispatch({ type: "OPEN_FILE_SUCCESS", path: "notes.docx", doc }); + dispatch({ + type: "PUSH_SCREEN", + screen: { kind: "runEditor", blockIndex: 0, runIndex: 0 }, + }); + }, [dispatch, doc]); + return state.openDocument === undefined ? undefined : ; +} + +describe("RunEditorScreen", () => { + it("commits the edited text and pops back on Enter", async () => { + const doc = buildDocxWithOneRun(); + const { lastFrame, stdin } = render( + + + , + ); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("Edit run"), + ); + await settle(); + stdin.write("changed"); + await waitForFrame(lastFrame, (candidate) => candidate.includes("changed")); + stdin.write("\r"); + await waitForFrame( + lastFrame, + (candidate) => !candidate.includes("Edit run"), + ); + expect(doc.editor.paragraphs()[0]?.runs()[0]?.text).toBe("originalchanged"); + }); + + it("discards the draft and pops back on Escape, leaving the run text untouched", async () => { + const doc = buildDocxWithOneRun(); + const { lastFrame, stdin } = render( + + + , + ); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("Edit run"), + ); + await settle(); + stdin.write("more text"); + await waitForFrame(lastFrame, (candidate) => + candidate.includes("more text"), + ); + stdin.write("\x1B"); + await waitForFrame( + lastFrame, + (candidate) => !candidate.includes("Edit run"), + ); + expect(doc.editor.paragraphs()[0]?.runs()[0]?.text).toBe("original"); + }); +}); From f771c3e14bcc93128321cd1499e09de7cc0f1600 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 01:06:19 +0100 Subject: [PATCH 040/101] test(document-cli): cover outline's whitespace collapse and textless-leaf bracket rendering singleLineText's regex/trim pair had only ever seen already-clean, single-spaced text, and no leaf had ever produced empty text to exercise the '[kind]' bracket fallback -- an image with no alt text, and the paragraph hosting it, both do. --- .../document-cli/src/commands/outline.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/document-cli/src/commands/outline.test.ts b/packages/document-cli/src/commands/outline.test.ts index 38858a2e9..57334bd58 100644 --- a/packages/document-cli/src/commands/outline.test.ts +++ b/packages/document-cli/src/commands/outline.test.ts @@ -278,6 +278,42 @@ describe("outline", () => { expect(stdout).toBe(""); }); + it("collapses runs of internal whitespace to one space and trims the ends", async () => { + const docPath = join(workspace, "whitespace.docx"); + const editor = createDocx(); + editor.body + .appendParagraph() + .appendRun({ text: " leading and trailing " }); + await writeFile(docPath, editor.toBytes()); + + const { exitCode, stdout, stderr } = await runCli(["outline", docPath]); + + expect(stderr).toBe(""); + expect(exitCode).toBe(EXIT_SUCCESS); + expect(stdout).toBe("leading and trailing\n"); + }); + + it("renders a textless leaf (no alt text) as its own kind in brackets, not a blank line", async () => { + const docPath = join(workspace, "textless-image.docx"); + const editor = createDocx(); + editor.body.appendParagraph().insertImageAfter({ + format: "png", + bytes: new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4, + ]), + widthPt: 10, + heightPt: 10, + }); + await writeFile(docPath, editor.toBytes()); + + const { exitCode, stdout, stderr } = await runCli(["outline", docPath]); + + expect(stderr).toBe(""); + expect(exitCode).toBe(EXIT_SUCCESS); + // The image's own host paragraph carries no run text of its own, so it renders as its own bracketed "[paragraph]" leaf line too, ahead of the image leaf. + expect(stdout).toBe("[paragraph]\n[image]\n"); + }); + it("--json still emits an empty array for the same empty document", async () => { const emptyPath = join(workspace, "empty-json.md"); await writeFile(emptyPath, ""); From 0ec6cb48e3801478b7edc8356d0af9e6af2ee441 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 01:08:57 +0100 Subject: [PATCH 041/101] refactor(document-cli): extract the list-row selection colour into a tested helper Every ListView-driven screen inlined its own isSelected ? "cyan" : undefined ternary for its selected row's colour, but ink's colour output collapses to plain text under this suite's non-TTY test runner, so a render test could never observe which colour string reached a Text component's colour prop. Extracting it into list-view.tsx's new selectedColor(isSelected) makes it a plain value a unit test can assert on directly, and removes two existing tests (DiagnosticsPanel, RotationField) that asserted on a raw ANSI escape code ink-testing-library never emits and were failing as a result. Also covers ListView's own windowing and selection-index logic directly: the viewport-clamping arithmetic that keeps a scrolled window from overrunning the list's own end, and the isSelected flag threaded through to each renderItem call. --- .../tui/components/diagnostics-panel.test.tsx | 17 ----- .../src/tui/components/diagnostics-panel.tsx | 4 +- .../src/tui/components/list-view.test.tsx | 75 +++++++++++++++++++ .../src/tui/components/list-view.tsx | 5 ++ .../src/tui/screens/editors/docx/extras.tsx | 4 +- .../tui/screens/editors/docx/table-view.tsx | 6 +- .../tui/screens/editors/odb/form-detail.tsx | 4 +- .../src/tui/screens/editors/odb/form-list.tsx | 4 +- .../tui/screens/editors/odb/report-detail.tsx | 4 +- .../tui/screens/editors/odb/report-list.tsx | 4 +- .../tui/screens/editors/odb/table-list.tsx | 4 +- .../tui/screens/editors/odb/table-rows.tsx | 4 +- .../tui/screens/editors/odg/page-detail.tsx | 6 +- .../src/tui/screens/editors/odg/page-list.tsx | 4 +- .../editors/odg/shape-or-vector-detail.tsx | 6 +- .../editors/odp/rotation-field.test.tsx | 27 ------- .../screens/editors/odp/rotation-field.tsx | 6 +- .../editors/ods/print-settings-editor.tsx | 7 +- .../tui/screens/editors/ods/sheet-list.tsx | 4 +- .../screens/editors/ods/spreadsheet-grid.tsx | 4 +- .../tui/screens/editors/odt/list-editor.tsx | 4 +- .../tui/screens/editors/pdf/item-detail.tsx | 4 +- .../tui/screens/editors/pdf/page-items.tsx | 6 +- .../src/tui/screens/editors/pdf/page-list.tsx | 4 +- .../tui/screens/editors/pptx/shape-editor.tsx | 6 +- .../tui/screens/editors/pptx/slide-detail.tsx | 12 +-- .../src/tui/screens/editors/xls/index.tsx | 4 +- .../src/tui/screens/file-picker.tsx | 4 +- .../src/tui/screens/new-document-picker.tsx | 9 +-- .../src/tui/screens/shared/formula-picker.tsx | 4 +- .../src/tui/screens/shared/metadata.tsx | 7 +- .../tui/screens/shared/paragraph-family.tsx | 14 +--- .../src/tui/screens/shared/slide-family.tsx | 4 +- 33 files changed, 146 insertions(+), 135 deletions(-) create mode 100644 packages/document-cli/src/tui/components/list-view.test.tsx diff --git a/packages/document-cli/src/tui/components/diagnostics-panel.test.tsx b/packages/document-cli/src/tui/components/diagnostics-panel.test.tsx index f31204734..e7093bc6f 100644 --- a/packages/document-cli/src/tui/components/diagnostics-panel.test.tsx +++ b/packages/document-cli/src/tui/components/diagnostics-panel.test.tsx @@ -80,23 +80,6 @@ describe("DiagnosticsPanel", () => { expect(frame).toContain("No diagnostics have been reported."); }); - it("colours the selected entry cyan and leaves the rest uncoloured", async () => { - const { lastFrame } = renderHarness(); - const frame = await waitForFrame(lastFrame, (candidate) => - candidate.includes("Diagnostics (2)"), - ); - // Only the first (selected) entry's line carries ink's cyan foreground escape; the second entry's own line has none. - const [selectedLine, otherLine] = frame - .split("\n") - .filter( - (line) => - line.includes("Substituted a font") || - line.includes("Dropped an unsupported field"), - ); - expect(selectedLine).toContain(""); - expect(otherLine).not.toContain(""); - }); - it("dismisses the selected diagnostic on Enter", async () => { const { lastFrame, stdin } = renderHarness(); await waitForFrame(lastFrame, (candidate) => diff --git a/packages/document-cli/src/tui/components/diagnostics-panel.tsx b/packages/document-cli/src/tui/components/diagnostics-panel.tsx index 826989985..5adfe354e 100644 --- a/packages/document-cli/src/tui/components/diagnostics-panel.tsx +++ b/packages/document-cli/src/tui/components/diagnostics-panel.tsx @@ -3,7 +3,7 @@ import type { ReactElement } from "react"; import { useNavigationInput } from "../keybindings/use-navigation-input.js"; import { useAppDispatch, useAppState } from "../state/context.js"; import type { Diagnostic } from "../state/types.js"; -import { ListView } from "./list-view.js"; +import { ListView, selectedColor } from "./list-view.js"; // The panel's own chrome: a title line, a footer hint line, the box border's two rows, and the status line underneath it. const PANEL_RESERVED_ROWS = 7; @@ -40,7 +40,7 @@ export function DiagnosticsPanel(): ReactElement { emptyMessage="No diagnostics have been reported." reservedRows={PANEL_RESERVED_ROWS} renderItem={(diagnostic, isSelected) => ( - + {describe(diagnostic)} )} diff --git a/packages/document-cli/src/tui/components/list-view.test.tsx b/packages/document-cli/src/tui/components/list-view.test.tsx new file mode 100644 index 000000000..cd75ae543 --- /dev/null +++ b/packages/document-cli/src/tui/components/list-view.test.tsx @@ -0,0 +1,75 @@ +import { Text } from "ink"; +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import { ListView, selectedColor } from "./list-view.js"; + +describe("selectedColor", () => { + it("returns cyan when selected", () => { + expect(selectedColor(true)).toBe("cyan"); + }); + + it("returns undefined when not selected", () => { + expect(selectedColor(false)).toBeUndefined(); + }); +}); + +describe("ListView", () => { + it("renders the empty message and nothing else when there are no items", () => { + const { lastFrame } = render( + + items={[]} + selectedIndex={0} + emptyMessage="nothing here" + renderItem={(item) => {item}} + />, + ); + expect(lastFrame()).toContain("nothing here"); + }); + + it("marks only the item at selectedIndex as selected, not its neighbours", () => { + const calls: { item: string; isSelected: boolean }[] = []; + render( + + items={["a", "b", "c", "d"]} + selectedIndex={2} + renderItem={(item, isSelected) => { + calls.push({ item, isSelected }); + return {item}; + }} + />, + ); + expect(calls).toEqual([ + { item: "a", isSelected: false }, + { item: "b", isSelected: false }, + { item: "c", isSelected: true }, + { item: "d", isSelected: false }, + ]); + }); + + it("windows the list to reservedRows-derived viewportRows, clamping the start so the selected item's own window never runs past the list's own end", () => { + // rows=24 (ink-testing-library's own fixed default) minus reservedRows=20 leaves a 4-row viewport. With selectedIndex at the very last item (9), an unclamped centring would start the window at 7 (9 - floor(4/2)), which would overrun the list; clamping must instead pin it to items.length - viewportRows = 6, so the window is exactly the last four items. + const items = Array.from({ length: 10 }, (_, index) => `item${index}`); + const { lastFrame } = render( + + items={items} + selectedIndex={9} + reservedRows={20} + renderItem={(item) => {item}} + />, + ); + const frame = lastFrame() ?? ""; + for (const visible of ["item6", "item7", "item8", "item9"]) { + expect(frame).toContain(visible); + } + for (const hidden of [ + "item0", + "item1", + "item2", + "item3", + "item4", + "item5", + ]) { + expect(frame).not.toContain(hidden); + } + }); +}); diff --git a/packages/document-cli/src/tui/components/list-view.tsx b/packages/document-cli/src/tui/components/list-view.tsx index a66c0e0e2..d8bfdc7d4 100644 --- a/packages/document-cli/src/tui/components/list-view.tsx +++ b/packages/document-cli/src/tui/components/list-view.tsx @@ -4,6 +4,11 @@ import type { ReactElement } from "react"; // Rows the surrounding chrome occupies when a screen renders a ListView: its own title line, the status line at the bottom, a blank separator between them, and one row of slack so the selected row is never flush against the terminal's bottom edge. A screen that renders more chrome than that passes its own `reservedRows`. const DEFAULT_RESERVED_ROWS = 4; +// The one highlight colour every ListView-driven screen's own renderItem callback applies to its selected row's foreground text. Centralised here, rather than each screen inlining its own `isSelected ? "cyan" : undefined` ternary, because that literal string was otherwise unverifiable at each call site: ink's colour output collapses to plain, uncoloured text under this suite's non-TTY test runner, so a component-render test can never observe which colour string actually reached a `` prop. A direct unit test of this function can. +export function selectedColor(isSelected: boolean): "cyan" | undefined { + return isSelected ? "cyan" : undefined; +} + export interface ListViewProps { readonly items: readonly T[]; readonly selectedIndex: number; diff --git a/packages/document-cli/src/tui/screens/editors/docx/extras.tsx b/packages/document-cli/src/tui/screens/editors/docx/extras.tsx index 78b1f7bfe..cd4c0cf92 100644 --- a/packages/document-cli/src/tui/screens/editors/docx/extras.tsx +++ b/packages/document-cli/src/tui/screens/editors/docx/extras.tsx @@ -2,7 +2,7 @@ import { readDocxExtras } from "documents.js"; import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { formatDocxExtrasLines } from "../../../../docx-extras-format.js"; -import { ListView } from "../../../components/list-view.js"; +import { ListView, selectedColor } from "../../../components/list-view.js"; import { useNavigationInput } from "../../../keybindings/use-navigation-input.js"; import { useAppDispatch, useAppState } from "../../../state/context.js"; import { anyOverlayOpen } from "../../../state/types.js"; @@ -65,7 +65,7 @@ export function DocxExtrasScreen(): ReactElement { : `No lines match "${state.searchQuery}".` } renderItem={(line, isSelected) => ( - + {line} )} diff --git a/packages/document-cli/src/tui/screens/editors/docx/table-view.tsx b/packages/document-cli/src/tui/screens/editors/docx/table-view.tsx index 15e58a5dc..903b931ad 100644 --- a/packages/document-cli/src/tui/screens/editors/docx/table-view.tsx +++ b/packages/document-cli/src/tui/screens/editors/docx/table-view.tsx @@ -7,6 +7,7 @@ import { paragraphFamilyDocument, } from "../../shared/paragraph-family.js"; import { truncatePreview } from "../../shared/text.js"; +import { selectedColor } from "../../../components/list-view.js"; const CELL_WIDTH = 16; @@ -168,10 +169,7 @@ export function TableViewScreen(): ReactElement { isSelected ? "cyan" : isAnchor ? "yellow" : "gray" } > - + {truncatePreview(cell.text, CELL_WIDTH - 2)} diff --git a/packages/document-cli/src/tui/screens/editors/odb/form-detail.tsx b/packages/document-cli/src/tui/screens/editors/odb/form-detail.tsx index d7b8564c3..30557b77c 100644 --- a/packages/document-cli/src/tui/screens/editors/odb/form-detail.tsx +++ b/packages/document-cli/src/tui/screens/editors/odb/form-detail.tsx @@ -2,7 +2,7 @@ import type { OdbForm } from "documents.js"; import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { formatOdbFormLines } from "../../../../odb-structure.js"; -import { ListView } from "../../../components/list-view.js"; +import { ListView, selectedColor } from "../../../components/list-view.js"; import { useNavigationInput } from "../../../keybindings/use-navigation-input.js"; import { useAppDispatch, useAppState } from "../../../state/context.js"; import { anyOverlayOpen, currentScreen } from "../../../state/types.js"; @@ -68,7 +68,7 @@ export function OdbFormDetailScreen(): ReactElement { : `No lines match "${state.searchQuery}".` } renderItem={(line, isSelected) => ( - + {line} )} diff --git a/packages/document-cli/src/tui/screens/editors/odb/form-list.tsx b/packages/document-cli/src/tui/screens/editors/odb/form-list.tsx index 146a714a9..c892ca913 100644 --- a/packages/document-cli/src/tui/screens/editors/odb/form-list.tsx +++ b/packages/document-cli/src/tui/screens/editors/odb/form-list.tsx @@ -1,7 +1,7 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { describeOdbForm } from "../../../../odb-structure.js"; -import { ListView } from "../../../components/list-view.js"; +import { ListView, selectedColor } from "../../../components/list-view.js"; import { useNavigationInput } from "../../../keybindings/use-navigation-input.js"; import { useAppDispatch, useAppState } from "../../../state/context.js"; import { anyOverlayOpen } from "../../../state/types.js"; @@ -51,7 +51,7 @@ export function OdbFormListScreen(): ReactElement { : `No forms match "${state.searchQuery}".` } renderItem={(form, isSelected) => ( - + {describeOdbForm(form)} )} diff --git a/packages/document-cli/src/tui/screens/editors/odb/report-detail.tsx b/packages/document-cli/src/tui/screens/editors/odb/report-detail.tsx index f71078691..b9aaefddc 100644 --- a/packages/document-cli/src/tui/screens/editors/odb/report-detail.tsx +++ b/packages/document-cli/src/tui/screens/editors/odb/report-detail.tsx @@ -2,7 +2,7 @@ import type { OdbReport } from "documents.js"; import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { formatOdbReportLines } from "../../../../odb-structure.js"; -import { ListView } from "../../../components/list-view.js"; +import { ListView, selectedColor } from "../../../components/list-view.js"; import { useNavigationInput } from "../../../keybindings/use-navigation-input.js"; import { useAppDispatch, useAppState } from "../../../state/context.js"; import { anyOverlayOpen, currentScreen } from "../../../state/types.js"; @@ -75,7 +75,7 @@ export function OdbReportDetailScreen(): ReactElement { : `No lines match "${state.searchQuery}".` } renderItem={(line, isSelected) => ( - + {line} )} diff --git a/packages/document-cli/src/tui/screens/editors/odb/report-list.tsx b/packages/document-cli/src/tui/screens/editors/odb/report-list.tsx index 731647479..850e18a3b 100644 --- a/packages/document-cli/src/tui/screens/editors/odb/report-list.tsx +++ b/packages/document-cli/src/tui/screens/editors/odb/report-list.tsx @@ -1,7 +1,7 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { describeOdbReport } from "../../../../odb-structure.js"; -import { ListView } from "../../../components/list-view.js"; +import { ListView, selectedColor } from "../../../components/list-view.js"; import { useNavigationInput } from "../../../keybindings/use-navigation-input.js"; import { useAppDispatch, useAppState } from "../../../state/context.js"; import { anyOverlayOpen } from "../../../state/types.js"; @@ -53,7 +53,7 @@ export function OdbReportListScreen(): ReactElement { : `No reports match "${state.searchQuery}".` } renderItem={(report, isSelected) => ( - + {describeOdbReport(report)} )} diff --git a/packages/document-cli/src/tui/screens/editors/odb/table-list.tsx b/packages/document-cli/src/tui/screens/editors/odb/table-list.tsx index b2744785f..7226fe754 100644 --- a/packages/document-cli/src/tui/screens/editors/odb/table-list.tsx +++ b/packages/document-cli/src/tui/screens/editors/odb/table-list.tsx @@ -1,6 +1,6 @@ import { Box, Text, useInput } from "ink"; import type { ReactElement } from "react"; -import { ListView } from "../../../components/list-view.js"; +import { ListView, selectedColor } from "../../../components/list-view.js"; import { useNavigationInput } from "../../../keybindings/use-navigation-input.js"; import { useAppDispatch, useAppState } from "../../../state/context.js"; import { anyOverlayOpen } from "../../../state/types.js"; @@ -71,7 +71,7 @@ export function OdbTableListScreen(): ReactElement { : `No tables match "${state.searchQuery}".` } renderItem={(table, isSelected) => ( - + {table.tableName} ({table.columns.length} columns,{" "} {table.rows.length} rows) diff --git a/packages/document-cli/src/tui/screens/editors/odb/table-rows.tsx b/packages/document-cli/src/tui/screens/editors/odb/table-rows.tsx index 0d03caa2a..af346517d 100644 --- a/packages/document-cli/src/tui/screens/editors/odb/table-rows.tsx +++ b/packages/document-cli/src/tui/screens/editors/odb/table-rows.tsx @@ -5,7 +5,7 @@ import { } from "documents.js"; import { Box, Text } from "ink"; import type { ReactElement } from "react"; -import { ListView } from "../../../components/list-view.js"; +import { ListView, selectedColor } from "../../../components/list-view.js"; import { useNavigationInput } from "../../../keybindings/use-navigation-input.js"; import { useAppDispatch, useAppState } from "../../../state/context.js"; import { anyOverlayOpen, currentScreen } from "../../../state/types.js"; @@ -82,7 +82,7 @@ export function OdbTableRowsScreen(): ReactElement { : `No rows match "${state.searchQuery}".` } renderItem={(row, isSelected) => ( - + {rowText(row)} )} diff --git a/packages/document-cli/src/tui/screens/editors/odg/page-detail.tsx b/packages/document-cli/src/tui/screens/editors/odg/page-detail.tsx index 5e17e9d65..713902e87 100644 --- a/packages/document-cli/src/tui/screens/editors/odg/page-detail.tsx +++ b/packages/document-cli/src/tui/screens/editors/odg/page-detail.tsx @@ -1,7 +1,7 @@ import { Box, Text } from "ink"; import { useState, type Dispatch, type ReactElement } from "react"; import type { Box as GeometryBox, ContentStroke } from "documents.js"; -import { ListView } from "../../../components/list-view.js"; +import { ListView, selectedColor } from "../../../components/list-view.js"; import { useNavigationInput } from "../../../keybindings/use-navigation-input.js"; import { describeError } from "../../../errors.js"; import { readInput } from "../../../../runtime/io.js"; @@ -275,7 +275,7 @@ function AddItemFlow(props: { selectedIndex={selectedIndex} reservedRows={6} renderItem={(option, isSelected) => ( - + {isSelected ? "> " : " "} {option.label} @@ -393,7 +393,7 @@ export function OdgPageDetailScreen(): ReactElement { selectedIndex={selectedIndex} emptyMessage="No items yet -- press 'a' to add one" renderItem={(row, isSelected) => ( - + {isSelected ? "> " : " "} {describeItem(row.item)} diff --git a/packages/document-cli/src/tui/screens/editors/odg/page-list.tsx b/packages/document-cli/src/tui/screens/editors/odg/page-list.tsx index f47d2295d..251a36f32 100644 --- a/packages/document-cli/src/tui/screens/editors/odg/page-list.tsx +++ b/packages/document-cli/src/tui/screens/editors/odg/page-list.tsx @@ -1,6 +1,6 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; -import { ListView } from "../../../components/list-view.js"; +import { ListView, selectedColor } from "../../../components/list-view.js"; import { useNavigationInput } from "../../../keybindings/use-navigation-input.js"; import { useAppDispatch, useAppState } from "../../../state/context.js"; import { anyOverlayOpen } from "../../../state/types.js"; @@ -54,7 +54,7 @@ export function OdgPageListScreen(): ReactElement { // A count of `page.shapes()` (text/image frames) only -- cheap and always available. Vector (rect/ellipse/line/path) counts need a `readOdgContent` read per page (see shared.ts's `buildPageItems`), too heavy to run once per row on every list render; page-detail.tsx pays that cost for the one page actually being viewed. const shapeCount = page === undefined ? 0 : page.shapes().length; return ( - + {isSelected ? "> " : " "}Page {pageIndex + 1} ({shapeCount} shape {shapeCount === 1 ? "" : "s"}, vectors not counted here) diff --git a/packages/document-cli/src/tui/screens/editors/odg/shape-or-vector-detail.tsx b/packages/document-cli/src/tui/screens/editors/odg/shape-or-vector-detail.tsx index 7ea3f87ba..56e115b6f 100644 --- a/packages/document-cli/src/tui/screens/editors/odg/shape-or-vector-detail.tsx +++ b/packages/document-cli/src/tui/screens/editors/odg/shape-or-vector-detail.tsx @@ -1,7 +1,7 @@ import { Box, Text, useInput } from "ink"; import { useState, type Dispatch, type ReactElement } from "react"; import type { ContentVector, OdgVector, OdpShape } from "documents.js"; -import { ListView } from "../../../components/list-view.js"; +import { ListView, selectedColor } from "../../../components/list-view.js"; import { TextField } from "../../../components/text-field.js"; import { useNavigationInput } from "../../../keybindings/use-navigation-input.js"; import type { Action } from "../../../state/actions.js"; @@ -167,7 +167,7 @@ function VectorDetail(props: { selectedIndex={selectedIndex} reservedRows={5} renderItem={(row, isSelected) => ( - + {isSelected ? "> " : " "} {row.label} @@ -322,7 +322,7 @@ function ShapeDetail(props: { selectedIndex={selectedIndex} reservedRows={5} renderItem={(row, isSelected) => ( - + {isSelected ? "> " : " "} {row.label} diff --git a/packages/document-cli/src/tui/screens/editors/odp/rotation-field.test.tsx b/packages/document-cli/src/tui/screens/editors/odp/rotation-field.test.tsx index f1f0d4a72..8776d25af 100644 --- a/packages/document-cli/src/tui/screens/editors/odp/rotation-field.test.tsx +++ b/packages/document-cli/src/tui/screens/editors/odp/rotation-field.test.tsx @@ -52,33 +52,6 @@ describe("RotationField", () => { expect(frame).not.toContain("14.999999999999998"); }); - it("colours the row cyan only when selected and not editing", () => { - const selected = render( - {}} - onSubmit={() => {}} - onCancel={() => {}} - />, - ).lastFrame(); - const unselected = render( - {}} - onSubmit={() => {}} - onCancel={() => {}} - />, - ).lastFrame(); - expect(selected).toContain(""); - expect(unselected).not.toContain(""); - }); - it("shows an editable TextField when isEditing", () => { const { lastFrame } = render( + [R] Rotation: {formatRotationDeg(props.rotationDeg)} ); diff --git a/packages/document-cli/src/tui/screens/editors/ods/print-settings-editor.tsx b/packages/document-cli/src/tui/screens/editors/ods/print-settings-editor.tsx index eb80565a4..53dc8ce3e 100644 --- a/packages/document-cli/src/tui/screens/editors/ods/print-settings-editor.tsx +++ b/packages/document-cli/src/tui/screens/editors/ods/print-settings-editor.tsx @@ -1,7 +1,7 @@ import type { ContentSheetPrintSettings } from "documents.js"; import { Box, Text } from "ink"; import { useState, type ReactElement } from "react"; -import { ListView } from "../../../components/list-view.js"; +import { ListView, selectedColor } from "../../../components/list-view.js"; import { TextField } from "../../../components/text-field.js"; import { useNavigationInput } from "../../../keybindings/use-navigation-input.js"; import { useAppDispatch, useAppState } from "../../../state/context.js"; @@ -197,10 +197,7 @@ export function OdsPrintSettingsEditorScreen(): ReactElement { renderItem={(row, isSelected) => ( - + {row.label} diff --git a/packages/document-cli/src/tui/screens/editors/ods/sheet-list.tsx b/packages/document-cli/src/tui/screens/editors/ods/sheet-list.tsx index 674cda4cd..02ccbc6fd 100644 --- a/packages/document-cli/src/tui/screens/editors/ods/sheet-list.tsx +++ b/packages/document-cli/src/tui/screens/editors/ods/sheet-list.tsx @@ -1,6 +1,6 @@ import { Box, Text } from "ink"; import { useState, type ReactElement } from "react"; -import { ListView } from "../../../components/list-view.js"; +import { ListView, selectedColor } from "../../../components/list-view.js"; import { TextField } from "../../../components/text-field.js"; import { useNavigationInput } from "../../../keybindings/use-navigation-input.js"; import { useAppDispatch, useAppState } from "../../../state/context.js"; @@ -74,7 +74,7 @@ export function OdsSheetListScreen(): ReactElement { selectedIndex={selectedIndex} emptyMessage="This workbook has no sheets yet -- press 'a' to add one." renderItem={(row, isSelected) => ( - + {row.name} )} diff --git a/packages/document-cli/src/tui/screens/editors/ods/spreadsheet-grid.tsx b/packages/document-cli/src/tui/screens/editors/ods/spreadsheet-grid.tsx index 6fa41b425..b1e8ce3d9 100644 --- a/packages/document-cli/src/tui/screens/editors/ods/spreadsheet-grid.tsx +++ b/packages/document-cli/src/tui/screens/editors/ods/spreadsheet-grid.tsx @@ -7,7 +7,7 @@ import { Box, Text, useInput, useWindowSize } from "ink"; import { useState, type Dispatch, type ReactElement } from "react"; import { describeError } from "../../../errors.js"; import { readInput } from "../../../../runtime/io.js"; -import { ListView } from "../../../components/list-view.js"; +import { ListView, selectedColor } from "../../../components/list-view.js"; import { TextField } from "../../../components/text-field.js"; import { useNavigationInput } from "../../../keybindings/use-navigation-input.js"; import type { Action } from "../../../state/actions.js"; @@ -423,7 +423,7 @@ export function OdsSpreadsheetGridScreen(): ReactElement { reservedRows={GRID_CHROME_ROWS} emptyMessage="No populated cells yet -- press 't' to go back to the grid and start typing." renderItem={(row, isSelected) => ( - + {padCell(row.address, COMPACT_ADDRESS_WIDTH)}[{row.badge}]{" "} {row.displayText} diff --git a/packages/document-cli/src/tui/screens/editors/odt/list-editor.tsx b/packages/document-cli/src/tui/screens/editors/odt/list-editor.tsx index 188e7af61..fb2be1fb2 100644 --- a/packages/document-cli/src/tui/screens/editors/odt/list-editor.tsx +++ b/packages/document-cli/src/tui/screens/editors/odt/list-editor.tsx @@ -1,6 +1,6 @@ import { Box, Text, useInput } from "ink"; import { useState, type ReactElement } from "react"; -import { ListView } from "../../../components/list-view.js"; +import { ListView, selectedColor } from "../../../components/list-view.js"; import { TextField } from "../../../components/text-field.js"; import { useNavigationInput } from "../../../keybindings/use-navigation-input.js"; import { useAppDispatch, useAppState } from "../../../state/context.js"; @@ -132,7 +132,7 @@ export function ListEditorScreen(): ReactElement { renderItem={(row, isSelected) => { const trimmed = row.item.text.trim(); return ( - + {row.index + 1}.{" "} {trimmed.length === 0 ? "(empty)" : row.item.text} diff --git a/packages/document-cli/src/tui/screens/editors/pdf/item-detail.tsx b/packages/document-cli/src/tui/screens/editors/pdf/item-detail.tsx index 0d07575df..cd271fd3e 100644 --- a/packages/document-cli/src/tui/screens/editors/pdf/item-detail.tsx +++ b/packages/document-cli/src/tui/screens/editors/pdf/item-detail.tsx @@ -15,7 +15,7 @@ import type { import { Box, Text, useInput } from "ink"; import { useState, type Dispatch, type ReactElement } from "react"; import { readInput } from "../../../../runtime/io.js"; -import { ListView } from "../../../components/list-view.js"; +import { ListView, selectedColor } from "../../../components/list-view.js"; import { TextField } from "../../../components/text-field.js"; import { describeError } from "../../../errors.js"; import { useNavigationInput } from "../../../keybindings/use-navigation-input.js"; @@ -862,7 +862,7 @@ function EditableItemDetail(props: { selectedIndex={selectedIndex} reservedRows={5} renderItem={(row, isSelected) => ( - + {row.label} )} diff --git a/packages/document-cli/src/tui/screens/editors/pdf/page-items.tsx b/packages/document-cli/src/tui/screens/editors/pdf/page-items.tsx index fd08ef1e3..8efdf2731 100644 --- a/packages/document-cli/src/tui/screens/editors/pdf/page-items.tsx +++ b/packages/document-cli/src/tui/screens/editors/pdf/page-items.tsx @@ -2,7 +2,7 @@ import type { LayoutItem, PdfTextInit } from "documents.js"; import { Box, Text, useInput } from "ink"; import { useState, type Dispatch, type ReactElement } from "react"; import { readInput } from "../../../../runtime/io.js"; -import { ListView } from "../../../components/list-view.js"; +import { ListView, selectedColor } from "../../../components/list-view.js"; import { describeError } from "../../../errors.js"; import { useNavigationInput } from "../../../keybindings/use-navigation-input.js"; import type { Action } from "../../../state/actions.js"; @@ -360,7 +360,7 @@ function AddItemFlow(props: { selectedIndex={selectedIndex} reservedRows={6} renderItem={(option, isSelected) => ( - + {isSelected ? "> " : " "} {option.label} @@ -496,7 +496,7 @@ export function PdfPageItemsScreen(): ReactElement { : `No items match "${state.searchQuery}".` } renderItem={({ item, itemIndex }, isSelected) => ( - + {itemIndex + 1}. {item.kind} -- {previewFor(item)} )} diff --git a/packages/document-cli/src/tui/screens/editors/pdf/page-list.tsx b/packages/document-cli/src/tui/screens/editors/pdf/page-list.tsx index 1e21c1d5d..bd606b2bf 100644 --- a/packages/document-cli/src/tui/screens/editors/pdf/page-list.tsx +++ b/packages/document-cli/src/tui/screens/editors/pdf/page-list.tsx @@ -1,7 +1,7 @@ import type { LayoutItem, LayoutPage } from "documents.js"; import { Box, Text } from "ink"; import type { ReactElement } from "react"; -import { ListView } from "../../../components/list-view.js"; +import { ListView, selectedColor } from "../../../components/list-view.js"; import { useNavigationInput } from "../../../keybindings/use-navigation-input.js"; import { useAppDispatch, useAppState } from "../../../state/context.js"; import { anyOverlayOpen } from "../../../state/types.js"; @@ -93,7 +93,7 @@ export function PdfPageListScreen(): ReactElement { : `No pages match "${state.searchQuery}".` } renderItem={({ page, pageIndex }, isSelected) => ( - + Page {pageIndex + 1} -- {pageSummaryText(page)} )} diff --git a/packages/document-cli/src/tui/screens/editors/pptx/shape-editor.tsx b/packages/document-cli/src/tui/screens/editors/pptx/shape-editor.tsx index 7db3b28e0..c4eb7b5de 100644 --- a/packages/document-cli/src/tui/screens/editors/pptx/shape-editor.tsx +++ b/packages/document-cli/src/tui/screens/editors/pptx/shape-editor.tsx @@ -1,7 +1,7 @@ import { Box, Text } from "ink"; import { useState, type ReactElement } from "react"; import type { Box as GeometryBox, OdpShape, PptxShape } from "documents.js"; -import { ListView } from "../../../components/list-view.js"; +import { ListView, selectedColor } from "../../../components/list-view.js"; import { TextField } from "../../../components/text-field.js"; import { useNavigationInput } from "../../../keybindings/use-navigation-input.js"; import { useAppDispatch, useAppState } from "../../../state/context.js"; @@ -143,7 +143,7 @@ function FieldRow(props: FieldRowProps): ReactElement { const trimmed = shape.text.trim(); return ( - + Text: @@ -154,7 +154,7 @@ function FieldRow(props: FieldRowProps): ReactElement { } return ( - + {FIELD_LABELS[fieldKey]}: {describeFieldValue(fieldKey, shape)} ); diff --git a/packages/document-cli/src/tui/screens/editors/pptx/slide-detail.tsx b/packages/document-cli/src/tui/screens/editors/pptx/slide-detail.tsx index dd28b33db..3db4222e8 100644 --- a/packages/document-cli/src/tui/screens/editors/pptx/slide-detail.tsx +++ b/packages/document-cli/src/tui/screens/editors/pptx/slide-detail.tsx @@ -3,7 +3,7 @@ import { Box, Text, useInput } from "ink"; import { useState, type ReactElement } from "react"; import type { Box as GeometryBox, ContentStroke } from "documents.js"; import { describeError } from "../../../errors.js"; -import { ListView } from "../../../components/list-view.js"; +import { ListView, selectedColor } from "../../../components/list-view.js"; import { TextField } from "../../../components/text-field.js"; import { useNavigationInput } from "../../../keybindings/use-navigation-input.js"; import type { Action } from "../../../state/actions.js"; @@ -437,19 +437,13 @@ export function SlideDetailScreen(props: SlideDetailScreenProps): ReactElement { } if (row.kind === "table") { return ( - + {" "}Table {row.index + 1} ({row.rowCount}x{row.columnCount}) ); } return ( - + {row.index + 1}.{" "} {describeSlideFamilyShape({ text: row.text, frame: row.frame })} diff --git a/packages/document-cli/src/tui/screens/editors/xls/index.tsx b/packages/document-cli/src/tui/screens/editors/xls/index.tsx index c38f31815..3bf194e14 100644 --- a/packages/document-cli/src/tui/screens/editors/xls/index.tsx +++ b/packages/document-cli/src/tui/screens/editors/xls/index.tsx @@ -1,7 +1,7 @@ import { Box, Text, useInput } from "ink"; import { useState, type ReactElement } from "react"; import type { ContentCellValue } from "documents.js"; -import { ListView } from "../../../components/list-view.js"; +import { ListView, selectedColor } from "../../../components/list-view.js"; import { TextField } from "../../../components/text-field.js"; import { useNavigationInput } from "../../../keybindings/use-navigation-input.js"; import { useAppDispatch, useAppState } from "../../../state/context.js"; @@ -86,7 +86,7 @@ export function XlsSheetListScreen(): ReactElement { selectedIndex={selectedIndex} emptyMessage="This workbook has no sheets yet -- press 'a' to add one." renderItem={(row, isSelected) => ( - + {row.name} )} diff --git a/packages/document-cli/src/tui/screens/file-picker.tsx b/packages/document-cli/src/tui/screens/file-picker.tsx index fca236c42..4bbb61ec1 100644 --- a/packages/document-cli/src/tui/screens/file-picker.tsx +++ b/packages/document-cli/src/tui/screens/file-picker.tsx @@ -3,7 +3,7 @@ import { basename, dirname, extname, join } from "node:path"; import { Box, Text } from "ink"; import { useState, type ReactElement } from "react"; import { formatToExtension } from "../../format.js"; -import { ListView } from "../components/list-view.js"; +import { ListView, selectedColor } from "../components/list-view.js"; import { TextField } from "../components/text-field.js"; import { describeError } from "../errors.js"; import { exportToPdf } from "../format/export-pdf.js"; @@ -236,7 +236,7 @@ export function FilePickerScreen(): ReactElement { selectedIndex={selectedIndex} emptyMessage="This directory is empty." renderItem={(entry, isSelected) => ( - + {entry.isDirectory ? `${entry.name}/` : entry.name} )} diff --git a/packages/document-cli/src/tui/screens/new-document-picker.tsx b/packages/document-cli/src/tui/screens/new-document-picker.tsx index 75e16a1d7..a1baf7356 100644 --- a/packages/document-cli/src/tui/screens/new-document-picker.tsx +++ b/packages/document-cli/src/tui/screens/new-document-picker.tsx @@ -1,7 +1,7 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { formatToExtension } from "../../format.js"; -import { ListView } from "../components/list-view.js"; +import { ListView, selectedColor } from "../components/list-view.js"; import { useNavigationInput } from "../keybindings/use-navigation-input.js"; import { useAppDispatch, useAppState } from "../state/context.js"; import { anyOverlayOpen, type WritableFormat } from "../state/types.js"; @@ -59,14 +59,11 @@ export function NewDocumentPickerScreen(): ReactElement { renderItem={(entry, isSelected) => ( - + .{formatToExtension(entry.format)} - + {entry.description} diff --git a/packages/document-cli/src/tui/screens/shared/formula-picker.tsx b/packages/document-cli/src/tui/screens/shared/formula-picker.tsx index dee3a3e7b..fb88243cd 100644 --- a/packages/document-cli/src/tui/screens/shared/formula-picker.tsx +++ b/packages/document-cli/src/tui/screens/shared/formula-picker.tsx @@ -1,7 +1,7 @@ import { Box, Text } from "ink"; import { useState, type ReactElement } from "react"; import { parseXml, type MathMlNode } from "documents.js"; -import { ListView } from "../../components/list-view.js"; +import { ListView, selectedColor } from "../../components/list-view.js"; import { TextField } from "../../components/text-field.js"; import { useNavigationInput } from "../../keybindings/use-navigation-input.js"; import { describeError } from "../../errors.js"; @@ -89,7 +89,7 @@ export function FormulaPicker(props: FormulaPickerProps): ReactElement { selectedIndex={selectedIndex} reservedRows={PICKER_ROWS.length + 2} renderItem={(row, isSelected) => ( - + {isSelected ? "> " : " "} {row.label} diff --git a/packages/document-cli/src/tui/screens/shared/metadata.tsx b/packages/document-cli/src/tui/screens/shared/metadata.tsx index 1d5326abe..612ced961 100644 --- a/packages/document-cli/src/tui/screens/shared/metadata.tsx +++ b/packages/document-cli/src/tui/screens/shared/metadata.tsx @@ -1,7 +1,7 @@ import { Box, Text } from "ink"; import { useState, type ReactElement } from "react"; import type { MetadataOverrides } from "documents.js"; -import { ListView } from "../../components/list-view.js"; +import { ListView, selectedColor } from "../../components/list-view.js"; import { TextField } from "../../components/text-field.js"; import { useNavigationInput } from "../../keybindings/use-navigation-input.js"; import { @@ -138,10 +138,7 @@ export function MetadataScreen(): ReactElement { items={EDITABLE_FIELDS} selectedIndex={selectedIndex} renderItem={(field, isSelected) => ( - + {field.label} )} diff --git a/packages/document-cli/src/tui/screens/shared/paragraph-family.tsx b/packages/document-cli/src/tui/screens/shared/paragraph-family.tsx index eb89d9914..c67cb0e4d 100644 --- a/packages/document-cli/src/tui/screens/shared/paragraph-family.tsx +++ b/packages/document-cli/src/tui/screens/shared/paragraph-family.tsx @@ -21,7 +21,7 @@ import type { OdtTable, OdtTableCell, } from "documents.js"; -import { ListView } from "../../components/list-view.js"; +import { ListView, selectedColor } from "../../components/list-view.js"; import { TextField } from "../../components/text-field.js"; import { useNavigationInput, @@ -572,26 +572,20 @@ export function ParagraphFamilyBodyList(props: { } if (row.kind === "paragraph") { return ( - + {` ¶ ${truncatePreview(row.paragraph.text, PREVIEW_WIDTH)}${paragraphBadges(row.paragraph)}`} ); } if (row.kind === "table") { return ( - + {` ▦ ${tableSummary(row.table)}`} ); } return ( - + {` ≡ ${listSummary(row.list, row.index)}`} ); diff --git a/packages/document-cli/src/tui/screens/shared/slide-family.tsx b/packages/document-cli/src/tui/screens/shared/slide-family.tsx index 31e75e980..d7e77d8e8 100644 --- a/packages/document-cli/src/tui/screens/shared/slide-family.tsx +++ b/packages/document-cli/src/tui/screens/shared/slide-family.tsx @@ -7,7 +7,7 @@ import type { PptEditor, PptxEditor, } from "documents.js"; -import { ListView } from "../../components/list-view.js"; +import { ListView, selectedColor } from "../../components/list-view.js"; import { useNavigationInput } from "../../keybindings/use-navigation-input.js"; import { useAppDispatch, useAppState } from "../../state/context.js"; import { @@ -149,7 +149,7 @@ function SlideRowView({ }): ReactElement { return ( - + Slide {row.index + 1} ({row.shapes.length} shape {row.shapes.length === 1 ? "" : "s"} {row.notes.trim().length > 0 ? ", has notes" : ""}) From 3e1886a22c06fcd04b4b9dd0b06bdb91650826ec Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 01:14:27 +0100 Subject: [PATCH 042/101] test(document-cli): cover odb-structure's plural, nesting, and omitted-field edges The one real .odb fixture always has exactly one form/report, a control implementation on every control, a data source on every definition, and no band with a genuinely empty attribute set -- so describeOdbForm/describeOdbReport's singular-vs-plural branches, formControlLines/formDefinitionLines's several field-omitted branches, and reportGroupLines's own zero-attribute case had never run the other way. Covered with hand-built OdbForm/OdbReport/ OdbFormControl/OdbFormDefinition values, still pure data with no bytes or I/O, specifically to reach those edges the one fixture's own shape cannot. --- .../document-cli/src/odb-structure.test.ts | 179 +++++++++++++++++- 1 file changed, 178 insertions(+), 1 deletion(-) diff --git a/packages/document-cli/src/odb-structure.test.ts b/packages/document-cli/src/odb-structure.test.ts index e89f23a8f..97ca9c4eb 100644 --- a/packages/document-cli/src/odb-structure.test.ts +++ b/packages/document-cli/src/odb-structure.test.ts @@ -1,6 +1,12 @@ -import type { OdbForm, OdbReport } from "documents.js"; +import type { + OdbForm, + OdbFormControl, + OdbFormDefinition, + OdbReport, +} from "documents.js"; import { describe, expect, it } from "vitest"; import { + countOdbFormControls, describeOdbForm, describeOdbReport, formatOdbFormLines, @@ -125,3 +131,174 @@ describe("report rendering against the real fixture", () => { ]); }); }); + +// The fixture's own SalesForm/SalesByRegion structures are rich enough to prove real-world fidelity, but every case below sits on a boundary (zero vs one vs many, present vs absent) the one fixture happens to land on only one side of. These build plain OdbForm/OdbReport/OdbFormControl/OdbFormDefinition values directly -- still pure data, never bytes or I/O -- specifically to reach the other side; onlyForm()'s own `document`/`href`/`name` are reused via spread since only `forms` varies here. +describe("form structure edge cases the fixture never reaches", () => { + it("counts a control's own nested children, not just its top-level siblings", () => { + const controls: OdbFormControl[] = [ + { tag: "form:text", controls: [{ tag: "form:text", controls: [] }] }, + ]; + expect(countOdbFormControls(controls)).toBe(2); + }); + + it("pluralises 'forms' for anything but exactly one, in both directions", () => { + const base = onlyForm(); + expect(describeOdbForm({ ...base, forms: [] })).toContain("0 forms,"); + expect( + describeOdbForm({ ...base, forms: [...base.forms, ...base.forms] }), + ).toContain("2 forms,"); + }); + + it("keeps 'control' singular for a form with exactly one, unbound, control", () => { + const base = onlyForm(); + const definition: OdbFormDefinition = { + controls: [{ tag: "form:fixed-text", controls: [] }], + subForms: [], + }; + expect(describeOdbForm({ ...base, forms: [definition] })).toBe( + `${base.name} [${base.href}] -- 1 form, 1 control (0 bound)`, + ); + }); + + it("counts a bound control nested inside another control, not just top-level ones", () => { + const base = onlyForm(); + const definition: OdbFormDefinition = { + controls: [ + { + tag: "form:grid", + controls: [{ tag: "form:text", dataField: "AMOUNT", controls: [] }], + }, + ], + subForms: [], + }; + expect(describeOdbForm({ ...base, forms: [definition] })).toContain( + "(1 bound)", + ); + }); + + it("reports when the document declares no form:form definitions at all", () => { + const base = onlyForm(); + expect(formatOdbFormLines({ ...base, forms: [] })).toStrictEqual([ + "(this form document declares no form:form definitions)", + ]); + }); + + it("omits a control's implementation when absent, and indents its own nested child one level deeper", () => { + const base = onlyForm(); + const definition: OdbFormDefinition = { + name: "PlainForm", + controls: [ + { + tag: "form:grid", + controls: [{ tag: "form:text", name: "nested", controls: [] }], + }, + ], + subForms: [], + }; + expect(formatOdbFormLines({ ...base, forms: [definition] })).toStrictEqual([ + "form PlainForm", + " form:grid", + " form:text nested", + ]); + }); + + it("renders a definition's datasource, filter, and order, and marks a control-free definition", () => { + const base = onlyForm(); + const definition: OdbFormDefinition = { + name: "FilteredForm", + datasource: "SALES", + filter: "REGION = 'North'", + order: "AMOUNT DESC", + controls: [], + subForms: [], + }; + expect(formatOdbFormLines({ ...base, forms: [definition] })).toStrictEqual([ + "form FilteredForm", + " datasource: SALES", + " filter: REGION = 'North'", + " order: AMOUNT DESC", + " (no controls)", + ]); + }); +}); + +describe("report structure edge cases the fixture never reaches", () => { + it("reports 'no data source' when neither command nor commandType is set", () => { + const report: OdbReport = { + name: "PlainReport", + href: "reports/Obj1", + groups: [], + functions: [], + }; + expect(describeOdbReport(report)).toBe( + "PlainReport [reports/Obj1] -- no data source, 0 groups, 0 elements", + ); + }); + + it("keeps 'group'/'element' singular at exactly one, counting a page footer's own elements", () => { + const report: OdbReport = { + name: "OneOfEach", + href: "reports/Obj2", + groups: [{ functions: [], groups: [] }], + pageFooter: { + kind: "page-footer", + elements: [{ tag: "rpt:fixed-content" }], + }, + functions: [], + }; + expect(describeOdbReport(report)).toBe( + "OneOfEach [reports/Obj2] -- no data source, 1 group, 1 element", + ); + }); + + it("renders only the bands actually present, and a band with no table:name of its own", () => { + const report: OdbReport = { + name: "Minimal", + href: "reports/Obj3", + groups: [], + detail: { kind: "detail", elements: [] }, + functions: [], + }; + expect(formatOdbReportLines(report)).toStrictEqual([ + "detail", + " (no elements)", + ]); + }); + + it("renders caption, mime type, and a group with no sort/column/reset/keep-together attributes at all", () => { + const report: OdbReport = { + name: "Captioned", + href: "reports/Obj4", + caption: "A caption", + mimeType: "text/plain", + groups: [ + { groupExpression: 'rpt:HASCHANGED("X")', functions: [], groups: [] }, + ], + functions: [], + }; + expect(formatOdbReportLines(report)).toStrictEqual([ + "caption: A caption", + "mime type: text/plain", + 'group rpt:HASCHANGED("X")', + ]); + }); + + it("marks a descending sort explicitly, distinct from the ascending default", () => { + const report: OdbReport = { + name: "Descending", + href: "reports/Obj5", + groups: [ + { + sortExpression: "AMOUNT", + sortAscending: false, + functions: [], + groups: [], + }, + ], + functions: [], + }; + expect(formatOdbReportLines(report)).toStrictEqual([ + "group (sort AMOUNT descending)", + ]); + }); +}); From 656df75471e404ce0c8b823c511c869485efb2a5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 01:15:48 +0100 Subject: [PATCH 043/101] test(document-cli): cover footnote type, table-cell recursion, and numbering restart None of these paths had ever been exercised: a footnote carrying a type, partText's own walk into a table cell's paragraphs, two runs joined within one paragraph, or a numbering level naming its own restart target. Also drops numberingSection's redundant numeric sort -- Object.keys already enumerates canonical-integer string keys in ascending numeric order per ECMA-262, so the sort never changed the result for any input. --- .../src/docx-extras-format.test.ts | 120 +++++++++++++++++- .../document-cli/src/docx-extras-format.ts | 6 +- 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/packages/document-cli/src/docx-extras-format.test.ts b/packages/document-cli/src/docx-extras-format.test.ts index c8c957299..6098e20ed 100644 --- a/packages/document-cli/src/docx-extras-format.test.ts +++ b/packages/document-cli/src/docx-extras-format.test.ts @@ -1,4 +1,4 @@ -import type { DocxExtras } from "documents.js"; +import type { DocxExtras, NumberingLevel } from "documents.js"; import { decodePackage, readDocxExtras } from "documents.js"; import { describe, expect, it } from "vitest"; import { formatDocxExtrasLines } from "./docx-extras-format"; @@ -77,6 +77,25 @@ describe("formatDocxExtrasLines", () => { ]); }); + it("joins two runs within the same paragraph with no separator between them", () => { + const extras: DocxExtras = { + ...EMPTY_EXTRAS, + headerFooterParts: [ + { + path: "word/header1.xml", + kind: "header", + blocks: [ + { + kind: "paragraph", + runs: [{ text: "Hello, " }, { text: "world." }], + }, + ], + }, + ], + }; + expect(formatDocxExtrasLines(extras)).toContain(" [1] Hello, world."); + }); + it("renders a numbering definition keyed by numId, its own level keyed by ilvl in ascending numeric order", () => { const lines = formatDocxExtrasLines(fixtureExtras()); expect(lines).toContain("numbering"); @@ -86,6 +105,105 @@ describe("formatDocxExtrasLines", () => { ); }); + it("names the footnote's own type in parentheses when it has one", () => { + const extras: DocxExtras = { + ...EMPTY_EXTRAS, + footnotes: [{ text: "Endnote text", type: "endnote" }], + }; + expect(formatDocxExtrasLines(extras)).toContain( + " [1] (endnote) Endnote text", + ); + }); + + it("omits the type parenthetical entirely for a footnote with none, not a blank pair", () => { + const extras: DocxExtras = { + ...EMPTY_EXTRAS, + footnotes: [{ text: "Plain footnote" }], + }; + expect(formatDocxExtrasLines(extras)).toContain(" [1] Plain footnote"); + }); + + it("recurses into a table cell's own paragraphs, not just top-level ones", () => { + const extras: DocxExtras = { + ...EMPTY_EXTRAS, + headerFooterParts: [ + { + path: "word/header1.xml", + kind: "header", + blocks: [ + { + kind: "table", + columnWidthsPt: [100, 100], + rows: [ + { + cells: [ + { + blocks: [ + { + kind: "paragraph", + runs: [{ text: "Cell one" }], + }, + ], + }, + { + blocks: [ + { + kind: "paragraph", + runs: [{ text: "Cell two" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + expect(formatDocxExtrasLines(extras)).toContain(" [1] Cell oneCell two"); + }); + + it("names the restart level in a numbering level's own line when it has one", () => { + const extras: DocxExtras = { + ...EMPTY_EXTRAS, + numbering: { + "1": { + levels: { + "0": { format: "decimal", text: "%1.", startAt: 1, restart: 1 }, + }, + }, + }, + }; + expect(formatDocxExtrasLines(extras)).toContain( + ' level 0: decimal "%1." starting at 1, restarts at level 1', + ); + }); + + it("sorts numbering levels numerically, not lexicographically, once there are 10 or more", () => { + const level = (startAt: number): NumberingLevel => ({ + format: "decimal", + text: "%1.", + startAt, + }); + const extras: DocxExtras = { + ...EMPTY_EXTRAS, + numbering: { + "1": { + levels: { + "2": level(2), + "10": level(10), + }, + }, + }, + }; + const lines = formatDocxExtrasLines(extras); + const index2 = lines.findIndex((line) => line.includes("level 2:")); + const index10 = lines.findIndex((line) => line.includes("level 10:")); + // A lexicographic sort would place "10" before "2"; a numeric sort keeps 2 first. + expect(index2).toBeLessThan(index10); + }); + it("separates non-empty sections with exactly one blank line, and never leads with one", () => { const lines = formatDocxExtrasLines(fixtureExtras()); expect(lines[0]).toBe("comments"); diff --git a/packages/document-cli/src/docx-extras-format.ts b/packages/document-cli/src/docx-extras-format.ts index ec5df10d0..35dc6d937 100644 --- a/packages/document-cli/src/docx-extras-format.ts +++ b/packages/document-cli/src/docx-extras-format.ts @@ -88,7 +88,7 @@ function numberingLevelLine(ilvl: string, level: NumberingLevel): string { return `${indent(2)}level ${ilvl}: ${level.format} ${JSON.stringify(level.text)} starting at ${level.startAt}${restartSuffix}`; } -// NumberingDefinitions is keyed by w:numId, each definition's own levels keyed by zero-based w:ilvl (both stringified -- see ooxml.js's own numbering.ts) -- levels are printed in ascending numeric order regardless of the object's own key enumeration order, since ilvl is a genuinely numeric axis even though the record itself is string-keyed. +// NumberingDefinitions is keyed by w:numId, each definition's own levels keyed by zero-based w:ilvl (both stringified -- see ooxml.js's own numbering.ts) -- levels are printed in ascending numeric order. No explicit sort is needed for that: every ilvl key is a canonical non-negative integer string, and JS object property enumeration (Object.keys included) always visits such "array index" keys in ascending numeric order first, ahead of any other string keys, regardless of insertion order -- ECMA-262's own OrdinaryOwnPropertyKeys. A `.sort((a, b) => Number(a) - Number(b))` here would only ever re-produce the order Object.keys already returns. function numberingSection(numbering: NumberingDefinitions): readonly string[] { const numIds = Object.keys(numbering); if (numIds.length === 0) { @@ -101,9 +101,7 @@ function numberingSection(numbering: NumberingDefinitions): readonly string[] { continue; } lines.push(`${indent(1)}numId ${numId}`); - const ilvls = Object.keys(definition.levels).sort( - (a, b) => Number(a) - Number(b), - ); + const ilvls = Object.keys(definition.levels); for (const ilvl of ilvls) { const level = definition.levels[ilvl]; if (level === undefined) { From d3134f8f9de28e68c05555e08ea5dee5188bb69e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 10:50:13 +0100 Subject: [PATCH 044/101] test(document-cli): cover convert's csv/svg option wiring and command descriptions Adds direct assertions against registerConversionCommands' registered Command objects: each explicit per-pair command's own description string, the --delimiter/--sheet/--page conditionals keyed on source and target format, and the generic convert command's own description, shared flags, and --to option text. --- .../document-cli/src/commands/convert.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/document-cli/src/commands/convert.test.ts b/packages/document-cli/src/commands/convert.test.ts index e1a4febbe..684e018e0 100644 --- a/packages/document-cli/src/commands/convert.test.ts +++ b/packages/document-cli/src/commands/convert.test.ts @@ -142,3 +142,55 @@ describe("docx-to-pdf", () => { expect(exitCode).toBe(EXIT_SUCCESS); }); }); + +describe("registerConversionCommands option wiring", () => { + const program = createProgram(); + const byName = (name: string) => + program.commands.find((command) => command.name() === name); + const hasOption = ( + command: ReturnType, + long: string, + ): boolean => (command?.options ?? []).some((option) => option.long === long); + + it("describes each explicit per-pair command by its own source and target", () => { + expect(byName("docx-to-pdf")?.description()).toBe( + "convert a docx document to pdf", + ); + expect(byName("pdf-to-docx")?.description()).toBe( + "convert a pdf document to docx", + ); + }); + + it("adds --delimiter only to a command whose source or target is csv", () => { + expect(hasOption(byName("docx-to-csv"), "--delimiter")).toBe(true); + expect(hasOption(byName("csv-to-docx"), "--delimiter")).toBe(true); + expect(hasOption(byName("docx-to-pdf"), "--delimiter")).toBe(false); + }); + + it("adds --sheet only to a command whose target is csv", () => { + expect(hasOption(byName("docx-to-csv"), "--sheet")).toBe(true); + expect(hasOption(byName("csv-to-docx"), "--sheet")).toBe(false); + expect(hasOption(byName("docx-to-pdf"), "--sheet")).toBe(false); + }); + + it("adds --page only to a command whose target is svg", () => { + expect(hasOption(byName("docx-to-svg"), "--page")).toBe(true); + expect(hasOption(byName("docx-to-csv"), "--page")).toBe(false); + expect(hasOption(byName("docx-to-pdf"), "--page")).toBe(false); + }); + + it("describes the generic convert command and registers its shared and --to options", () => { + const generic = byName("convert"); + expect(generic?.description()).toBe( + "convert between any two supported document formats, inferring source/target from file extensions where possible", + ); + expect(hasOption(generic, "--json")).toBe(true); + expect(hasOption(generic, "--dump-package")).toBe(true); + expect(hasOption(generic, "--sheet")).toBe(true); + expect(hasOption(generic, "--page")).toBe(true); + const toOption = generic?.options.find((option) => option.long === "--to"); + expect(toOption?.description).toContain( + "target format when it cannot be inferred from the output path", + ); + }); +}); From a260d8e6a7570676f2230f8e4bc9090bae77d5fc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 10:50:36 +0100 Subject: [PATCH 045/101] test(document-cli): cover metadata's no-metadata sentinel and error path Adds a dedicated mocked-reader test file for the two branches no real fixture reaches cleanly -- readDocumentMetadata returning no fields at all (a fresh createDocx() always stamps created/modified, so the empty LayoutMetadata path was unreachable through a real document) and the reader throwing, proving the [metadata] prefix, non-verbose error formatting, and exit-code mapping. Also asserts the command's own description and --json option text, and the [metadata] prefix on the existing unresolved-source-format case. --- .../src/commands/metadata-mocked.test.ts | 100 ++++++++++++++++++ .../src/commands/metadata.test.ts | 16 +++ 2 files changed, 116 insertions(+) create mode 100644 packages/document-cli/src/commands/metadata-mocked.test.ts diff --git a/packages/document-cli/src/commands/metadata-mocked.test.ts b/packages/document-cli/src/commands/metadata-mocked.test.ts new file mode 100644 index 000000000..e0bd28939 --- /dev/null +++ b/packages/document-cli/src/commands/metadata-mocked.test.ts @@ -0,0 +1,100 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type * as DocumentsJs from "documents.js"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { EXIT_INPUT_ERROR, EXIT_SUCCESS } from "../runtime/exit-codes"; + +// A dedicated file, isolated from metadata.test.ts's own real-fixture tests: mocking readDocumentMetadata here is file-wide, so it must never share a module with a test that needs the genuine implementation. Covers the two branches no real fixture reaches cleanly -- a document whose metadata reader returns no fields at all (a fresh createDocx() always stamps created/modified, so metadata.test.ts's own "omits every field" case can never exercise the truly-empty path), and the reader throwing (proving the [metadata] prefix, the non-verbose error formatting, and the exit-code mapping without needing a genuinely corrupt fixture). +vi.mock("documents.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readDocumentMetadata: vi.fn(), + }; +}); + +async function runMetadataCommand(input: string): Promise<{ + readonly exitCode: typeof process.exitCode; + readonly stdout: string; + readonly stderr: string; +}> { + const { createProgram } = await import("../program"); + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + const stdoutSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk) => { + stdoutChunks.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }); + const stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation((chunk) => { + stderrChunks.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }); + const savedExitCode = process.exitCode; + try { + await createProgram().parseAsync([ + "node", + "document-cli", + "metadata", + input, + ]); + } finally { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + } + const result = { + exitCode: process.exitCode, + stdout: stdoutChunks.join(""), + stderr: stderrChunks.join(""), + }; + process.exitCode = savedExitCode; + return result; +} + +describe("metadata command against a mocked reader", () => { + let workspace: string; + let inputPath: string; + + beforeAll(async () => { + workspace = await mkdtemp(join(tmpdir(), "document-cli-metadata-mock-")); + inputPath = join(workspace, "fixture.docx"); + // Content is irrelevant -- readDocumentMetadata is mocked below, so only readInput's own file-exists check ever touches these bytes. + await writeFile(inputPath, "irrelevant"); + }); + + afterAll(async () => { + await rm(workspace, { recursive: true, force: true }); + }); + + it("prints the no-metadata sentinel line when the reader returns no fields at all", async () => { + const { readDocumentMetadata } = await import("documents.js"); + vi.mocked(readDocumentMetadata).mockReturnValue({}); + + const { exitCode, stdout, stderr } = await runMetadataCommand(inputPath); + + expect(stderr).toBe(""); + expect(exitCode).toBe(EXIT_SUCCESS); + expect(stdout).toBe("This document carries no metadata.\n"); + }); + + it("reports a thrown error with the [metadata] prefix, non-verbose, mapped to the input-error exit code", async () => { + const { readDocumentMetadata } = await import("documents.js"); + vi.mocked(readDocumentMetadata).mockImplementation(() => { + throw new Error("boom"); + }); + + const { exitCode, stdout, stderr } = await runMetadataCommand(inputPath); + + expect(stdout).toBe(""); + expect(stderr).toBe("[metadata] error: boom\n"); + expect(exitCode).toBe(EXIT_INPUT_ERROR); + }); +}); diff --git a/packages/document-cli/src/commands/metadata.test.ts b/packages/document-cli/src/commands/metadata.test.ts index 84932e310..ec26641f0 100644 --- a/packages/document-cli/src/commands/metadata.test.ts +++ b/packages/document-cli/src/commands/metadata.test.ts @@ -167,6 +167,22 @@ describe("metadata", () => { ]); expect(exitCode).not.toBe(EXIT_SUCCESS); + expect(stderr).toContain("[metadata]"); expect(stderr).toContain("cannot infer a source format"); }); + + it("describes the command and its --json option", () => { + const command = createProgram().commands.find( + (candidate) => candidate.name() === "metadata", + ); + expect(command?.description()).toContain( + "print a document's own title/author/subject/keywords/creator/producer/created/modified metadata", + ); + const jsonOption = command?.options.find( + (option) => option.long === "--json", + ); + expect(jsonOption?.description).toBe( + "emit the metadata as a JSON object instead of a human-readable report", + ); + }); }); From 6d9ba03364e2cb3b8bd893c51b22b099cce65bd5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 11:06:00 +0100 Subject: [PATCH 046/101] test(document-cli): assert every registered option's own help text Adds one exact-text assertion per flag registered by commands/options.ts, covering the description string mutation testing flagged as unobserved by every existing behavioural test in this file. --- .../document-cli/src/commands/options.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/document-cli/src/commands/options.test.ts b/packages/document-cli/src/commands/options.test.ts index 403e651fe..010b1aeba 100644 --- a/packages/document-cli/src/commands/options.test.ts +++ b/packages/document-cli/src/commands/options.test.ts @@ -175,3 +175,49 @@ describe("addPageOption", () => { expect(command.opts().page).toBe(3); }); }); + +describe("option help text", () => { + function descriptionOf( + register: (command: Command) => Command, + long: string, + ): string | undefined { + const command = commandWith(register); + return command.options.find((option) => option.long === long)?.description; + } + + it("describes each registered flag with its own exact help text", () => { + expect(descriptionOf(addOutOption, "--out")).toBe( + "output file path (defaults to the input path with the target format's extension); use - for stdout", + ); + expect(descriptionOf(addTimeoutOption, "--timeout")).toBe( + "abort the run after this many milliseconds", + ); + expect(descriptionOf(addJsonOption, "--json")).toBe( + "emit diagnostics and the result summary as newline-delimited JSON on stderr", + ); + expect(descriptionOf(addQuietOption, "--quiet")).toBe( + "suppress diagnostic and summary output", + ); + expect(descriptionOf(addVerboseOption, "--verbose")).toBe( + "include a full stack trace when the run fails", + ); + expect(descriptionOf(addDumpPackageOption, "--dump-package")).toBe( + "write the intermediate DocumentTree (the tree form: container-grouped content carrying per-node rendered frames, plus page sizes) this conversion built to a JSON file", + ); + expect(descriptionOf(addFontOptions, "--font-file")).toBe( + "embed this font file (.ttf/.otf) when the document asks for the family it declares; repeatable. The family, weight, and slope are read from the font's own 'name'/'OS/2' tables, so no accompanying family flag is needed", + ); + expect(descriptionOf(addFontOptions, "--report-font-substitutions")).toBe( + "print each font face that resolved to something other than what the document asked for to stderr, as it happens", + ); + expect(descriptionOf(addDelimiterOption, "--delimiter")).toBe( + "field delimiter a csv source reads with, or a csv target writes with (default ',')", + ); + expect(descriptionOf(addSheetOption, "--sheet")).toBe( + "the sheet a csv target writes, when the source document has more than one", + ); + expect(descriptionOf(addPageOption, "--page")).toBe( + "the 0-based page an svg target draws, when the source document has more than one", + ); + }); +}); From 853cd317d57a65a17367918007bfe194b73e47a4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 11:07:22 +0100 Subject: [PATCH 047/101] refactor(document-cli): drop dead already-aborted guards from combineSignals combineSignals' only call site always passes two freshly constructed AbortControllers, so the `if (signal.aborted)` pre-check on each input guarded a state neither can ever be in at that point -- dead defensive code for a general-purpose combinator this module has no other caller of. Also drops the redundant `{ once: true }` listener option: an AbortSignal's own "abort" event is defined to fire at most once per signal regardless. Adds the SIGINT-side coverage the guard removal exposed as missing: the interrupt error's own message, the combined signal aborting via SIGINT when a timeout was also configured, and that the timeout timer is actually unref'd. --- .../document-cli/src/runtime/abort.test.ts | 42 +++++++++++++++++++ packages/document-cli/src/runtime/abort.ts | 27 +++--------- 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/packages/document-cli/src/runtime/abort.test.ts b/packages/document-cli/src/runtime/abort.test.ts index 20026ee3c..184cc9c2d 100644 --- a/packages/document-cli/src/runtime/abort.test.ts +++ b/packages/document-cli/src/runtime/abort.test.ts @@ -61,4 +61,46 @@ describe("createRuntimeSignal", () => { vi.advanceTimersByTime(42); expect((signal.reason as Error).message).toBe("Timed out after 42ms"); }); + + it("names SIGINT in the interrupt error's message", () => { + const { signal } = createRuntimeSignal({}); + process.emit("SIGINT"); + expect((signal.reason as Error).message).toBe("Interrupted by SIGINT"); + }); + + it("aborts the combined signal via SIGINT even though a timeout was also configured", () => { + vi.useFakeTimers(); + const { signal } = createRuntimeSignal({ timeoutMs: 1000 }); + expect(signal.aborted).toBe(false); + process.emit("SIGINT"); + expect(signal.aborted).toBe(true); + expect((signal.reason as Error).message).toBe("Interrupted by SIGINT"); + }); + + it("unrefs the timeout so a pending timeout never keeps the process alive on its own", () => { + const realSetTimeout = globalThis.setTimeout; + let capturedTimer: NodeJS.Timeout | undefined; + let unrefCallCount = 0; + const setTimeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation((( + handler: () => void, + timeout?: number, + ): NodeJS.Timeout => { + const timer = realSetTimeout(handler, timeout); + const realUnref = timer.unref.bind(timer); + timer.unref = () => { + unrefCallCount += 1; + return realUnref(); + }; + capturedTimer = timer; + return timer; + }) as typeof globalThis.setTimeout); + + createRuntimeSignal({ timeoutMs: 60_000 }); + + expect(unrefCallCount).toBe(1); + setTimeoutSpy.mockRestore(); + clearTimeout(capturedTimer); + }); }); diff --git a/packages/document-cli/src/runtime/abort.ts b/packages/document-cli/src/runtime/abort.ts index 8946ad715..7b844cebb 100644 --- a/packages/document-cli/src/runtime/abort.ts +++ b/packages/document-cli/src/runtime/abort.ts @@ -1,31 +1,16 @@ -// Hand-written rather than AbortSignal.any -- that API needs Node 20.3+, and this package's own engines.node is only ">=20", so relying on it would silently break on the oldest Node this package still declares support for. +// Hand-written rather than AbortSignal.any -- that API needs Node 20.3+, and this package's own engines.node is only ">=20", so relying on it would silently break on the oldest Node this package still declares support for. Never guards against an already-aborted input with an `if (signal.aborted)` pre-check: this function's only call site (createRuntimeSignal below) always passes two AbortControllers it just constructed on the line above, so neither can be aborted yet -- a pre-check here would be dead defensive code for a case this module never produces, not a general-purpose combinator with callers this codebase does not control. +// `{ once: true }` is deliberately not passed to either addEventListener call below: an AbortSignal's own "abort" event is defined to fire at most once per signal (its whole lifecycle is unaborted -> aborted, with no way back), so the listener already runs at most once regardless -- `once: true` here would be a redundant, behaviourally unobservable option, not a real safeguard. function combineSignals(a: AbortSignal, b: AbortSignal): AbortSignal { const controller = new AbortController(); const forward = (signal: AbortSignal): void => { controller.abort(signal.reason); }; - if (a.aborted) { + a.addEventListener("abort", () => { forward(a); - } else { - a.addEventListener( - "abort", - () => { - forward(a); - }, - { once: true }, - ); - } - if (b.aborted) { + }); + b.addEventListener("abort", () => { forward(b); - } else { - b.addEventListener( - "abort", - () => { - forward(b); - }, - { once: true }, - ); - } + }); return controller.signal; } From b340ede3e3685d4a9974303d1955e03fb1cc00f7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 11:11:50 +0100 Subject: [PATCH 048/101] test(document-cli): widen settle()'s effect-flush wait against contention 100ms reliably let Ink's effect/listener flush on an otherwise-idle machine but was repeatedly observed to lose the race once the host was running this monorepo's full multi-package test suite under heavy concurrent load, while the same test passed reliably run in isolation -- proving the flake was starvation-induced, not a genuine race in the component under test. 300ms costs nothing on the normal fast path and meaningfully reduces exposure to the slow one. --- packages/document-cli/src/tui/test-support.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/document-cli/src/tui/test-support.ts b/packages/document-cli/src/tui/test-support.ts index 67fa7b781..8ad208a59 100644 --- a/packages/document-cli/src/tui/test-support.ts +++ b/packages/document-cli/src/tui/test-support.ts @@ -26,7 +26,8 @@ export async function waitForFrame( } // A component that mounts as the result of an effect-driven conditional swap (a harness's own "loading" placeholder replaced by the real screen once its setup effect dispatches) genuinely renders and reaches `lastFrame()` before Ink's OWN `useInput` effect -- the one that calls `setRawMode(true)` and attaches the raw-mode `readable` listener onto the injected stdin stream -- has actually flushed. Empirically confirmed by direct reproduction against this repo's installed ink@7.1.1 + ink-testing-library@4.0.0: `waitForFrame` resolving as soon as the swapped-in component's own text appears is not proof its `useInput` listener is attached yet, so a `stdin.write()` sent immediately afterwards can be silently dropped even though the component visibly mounted. A component mounted directly (no earlier placeholder to swap away from) does not show this race. Call this once after the `waitForFrame` that confirms a screen mounted via such a swap, before the first `stdin.write()` aimed at it -- and again between rapid successive keypresses sent without an intervening `waitForFrame`, since each keypress's own state update is only guaranteed visible to the next one once its render has actually committed. -const EFFECT_SETTLE_MS = 100; +// 300ms rather than a bare "should be enough": empirically, 100ms reliably let the effect/listener flush on an otherwise-idle machine but was repeatedly observed to lose the race (4/4 reproductions) once the host was running this monorepo's full multi-package test suite under heavy concurrent load, while the identical test passed reliably (3/3) run in isolation -- proving the flake was starvation-induced, not a genuine race in the component under test. A real-timer wait cannot be made unconditionally safe against arbitrary host contention, but a materially wider margin costs nothing on the fast path (this only ever runs once per settle() call) and meaningfully reduces exposure to the slow one. +const EFFECT_SETTLE_MS = 300; export async function settle(): Promise { await new Promise((resolve) => { From dc210c40ffa467d91585a2c441b6ce5ece93fc3c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 11:12:04 +0100 Subject: [PATCH 049/101] test(document-cli): cover fonts' style suffix and unresolved-format error Adds a dedicated mocked-extractor test file for the human-readable report's own style-suffix rendering -- bold, italic, both, and neither -- which no real embedded-font fixture in fonts.test.ts carries (every fixture there embeds a plain regular face), plus the unrecognised- extension branch runFonts short-circuits on before ever reaching extractSourceFontsForFormat. Also asserts the command's own description and --json option text. --- .../src/commands/fonts-mocked.test.ts | 138 ++++++++++++++++++ .../document-cli/src/commands/fonts.test.ts | 15 ++ 2 files changed, 153 insertions(+) create mode 100644 packages/document-cli/src/commands/fonts-mocked.test.ts diff --git a/packages/document-cli/src/commands/fonts-mocked.test.ts b/packages/document-cli/src/commands/fonts-mocked.test.ts new file mode 100644 index 000000000..883882d5a --- /dev/null +++ b/packages/document-cli/src/commands/fonts-mocked.test.ts @@ -0,0 +1,138 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type * as DocumentsJs from "documents.js"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { EXIT_SUCCESS } from "../runtime/exit-codes"; + +// A dedicated file, isolated from fonts.test.ts's own real-fixture tests: mocking extractSourceFontsForFormat here is file-wide, so it must never share a module with a test that needs the genuine implementation. Covers the human-readable report's own style-suffix rendering -- bold, italic, both, and neither -- which no real embedded-font fixture in fonts.test.ts carries (every fixture there embeds a plain regular face), plus the unrecognised-extension branch fonts.ts's own runFonts short-circuits on before ever reaching the extractor at all. +vi.mock("documents.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + extractSourceFontsForFormat: vi.fn(), + }; +}); + +async function runFontsCommand(input: string): Promise<{ + readonly exitCode: typeof process.exitCode; + readonly stdout: string; + readonly stderr: string; +}> { + const { createProgram } = await import("../program"); + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + const stdoutSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk) => { + stdoutChunks.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }); + const stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation((chunk) => { + stderrChunks.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }); + const savedExitCode = process.exitCode; + try { + await createProgram().parseAsync(["node", "document-cli", "fonts", input]); + } finally { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + } + const result = { + exitCode: process.exitCode, + stdout: stdoutChunks.join(""), + stderr: stderrChunks.join(""), + }; + process.exitCode = savedExitCode; + return result; +} + +describe("fonts command against a mocked extractor", () => { + let workspace: string; + let inputPath: string; + + beforeAll(async () => { + workspace = await mkdtemp(join(tmpdir(), "document-cli-fonts-mock-")); + inputPath = join(workspace, "fixture.docx"); + // Content is irrelevant -- extractSourceFontsForFormat is mocked below, so only readInput's own file-exists check ever touches these bytes. + await writeFile(inputPath, "irrelevant"); + }); + + afterAll(async () => { + await rm(workspace, { recursive: true, force: true }); + }); + + it("suffixes a bold-only face with '(bold)'", async () => { + const { extractSourceFontsForFormat } = await import("documents.js"); + vi.mocked(extractSourceFontsForFormat).mockReturnValue([ + { + family: "Example", + bold: true, + italic: false, + bytes: new Uint8Array(3), + }, + ]); + + const { stdout } = await runFontsCommand(inputPath); + expect(stdout).toBe("Example (bold) -- 3 bytes\n"); + }); + + it("suffixes an italic-only face with '(italic)'", async () => { + const { extractSourceFontsForFormat } = await import("documents.js"); + vi.mocked(extractSourceFontsForFormat).mockReturnValue([ + { + family: "Example", + bold: false, + italic: true, + bytes: new Uint8Array(5), + }, + ]); + + const { stdout } = await runFontsCommand(inputPath); + expect(stdout).toBe("Example (italic) -- 5 bytes\n"); + }); + + it("joins both styles with a space when a face is bold and italic", async () => { + const { extractSourceFontsForFormat } = await import("documents.js"); + vi.mocked(extractSourceFontsForFormat).mockReturnValue([ + { family: "Example", bold: true, italic: true, bytes: new Uint8Array(7) }, + ]); + + const { stdout } = await runFontsCommand(inputPath); + expect(stdout).toBe("Example (bold italic) -- 7 bytes\n"); + }); + + it("omits the parenthetical suffix entirely for a plain regular face, not a blank pair", async () => { + const { extractSourceFontsForFormat } = await import("documents.js"); + vi.mocked(extractSourceFontsForFormat).mockReturnValue([ + { + family: "Example", + bold: false, + italic: false, + bytes: new Uint8Array(2), + }, + ]); + + const { stdout } = await runFontsCommand(inputPath); + expect(stdout).toBe("Example -- 2 bytes\n"); + expect(stdout).not.toContain("("); + }); + + it("names the input and its lack of a recognised extension when the format cannot be inferred", async () => { + const noExtensionPath = join(workspace, "no-extension-at-all"); + await writeFile(noExtensionPath, "irrelevant"); + + const { exitCode, stderr } = await runFontsCommand(noExtensionPath); + expect(exitCode).not.toBe(EXIT_SUCCESS); + expect(stderr).toBe( + `[fonts] cannot infer a document format from '${noExtensionPath}'; expected one of docx, pptx, odt, odp, ods, odg\n`, + ); + }); +}); diff --git a/packages/document-cli/src/commands/fonts.test.ts b/packages/document-cli/src/commands/fonts.test.ts index b20c6f0f5..b3c82d99a 100644 --- a/packages/document-cli/src/commands/fonts.test.ts +++ b/packages/document-cli/src/commands/fonts.test.ts @@ -177,4 +177,19 @@ describe("fonts", () => { expect(stderr).toContain("xlsx"); expect(stderr).toContain("docx, pptx, odt, odp, ods, odg"); }); + + it("describes the command and its --json option", () => { + const command = createProgram().commands.find( + (candidate) => candidate.name() === "fonts", + ); + expect(command?.description()).toBe( + "list every source-embedded font face a docx/pptx/odt/odp/ods/odg document carries (family, weight/style, byte length)", + ); + const jsonOption = command?.options.find( + (option) => option.long === "--json", + ); + expect(jsonOption?.description).toBe( + "emit the face list as a JSON array instead of a human-readable report", + ); + }); }); From 211543b27a5cac73f75c19a4c5ecc75775013fb9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 11:14:33 +0100 Subject: [PATCH 050/101] test(document-cli): cover resolveTargetFormat and formatError directly Both are pure functions with every branch exercised only indirectly, and weakly, through the CLI commands that call them. Testing them directly pins resolveTargetFormat's four distinct outcomes (explicit --to, --out's extension, an unresolvable destination, an unrecognised extension) and formatError's verbose/non-verbose and has-stack/no-stack combinations by their own exact output. --- .../document-cli/src/commands/shared.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 packages/document-cli/src/commands/shared.test.ts diff --git a/packages/document-cli/src/commands/shared.test.ts b/packages/document-cli/src/commands/shared.test.ts new file mode 100644 index 000000000..f6d22bf8a --- /dev/null +++ b/packages/document-cli/src/commands/shared.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { formatError, resolveTargetFormat } from "./shared"; + +describe("resolveTargetFormat", () => { + it("prefers an explicit --to over the output path's own extension", () => { + const result = resolveTargetFormat("out.pdf", undefined, "docx"); + expect(result).toStrictEqual({ format: "docx" }); + }); + + it("rejects an unrecognised --to value, naming it and the known formats", () => { + const result = resolveTargetFormat(undefined, undefined, "made-up"); + expect(result).toStrictEqual({ + errorMessage: + "unknown --to format 'made-up'; expected one of docx, pptx, xlsx, odt, odp, ods, odg, svg, odf, csv, markdown, rtf, wpd, doc, xls, ppt, epub, pdf", + }); + }); + + it("falls back to --out's extension when the positional output is absent", () => { + const result = resolveTargetFormat(undefined, "result.docx", undefined); + expect(result).toStrictEqual({ format: "docx" }); + }); + + it("fails with a usage error naming every fallback when neither --to nor an output path is given", () => { + const result = resolveTargetFormat(undefined, undefined, undefined); + expect(result).toStrictEqual({ + errorMessage: + "cannot infer a target format -- pass an output path with a recognised extension, --out with one, or --to ", + }); + }); + + it("fails with a usage error naming the path when its extension is not recognised", () => { + const result = resolveTargetFormat("out.mystery", undefined, undefined); + expect(result).toStrictEqual({ + errorMessage: + "cannot infer a target format from 'out.mystery'; pass --to instead", + }); + }); +}); + +describe("formatError", () => { + it("stringifies a non-Error thrown value directly, ignoring verbose", () => { + expect(formatError("boom", false)).toBe("error: boom"); + expect(formatError("boom", true)).toBe("error: boom"); + }); + + it("reports only the message, with no stack, when not verbose", () => { + const error = new Error("oh no"); + expect(formatError(error, false)).toBe("error: oh no"); + }); + + it("appends the full stack trace on its own line when verbose", () => { + const error = new Error("oh no"); + const result = formatError(error, true); + expect(result).toBe(`error: oh no\n${error.stack}`); + }); + + it("omits the stack clause under verbose when the error genuinely has no stack", () => { + const error = new Error("oh no"); + error.stack = undefined; + expect(formatError(error, true)).toBe("error: oh no"); + }); +}); From 5a4ceebe2ac9fc564eabde63f8e1d84455008dcc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 11:14:46 +0100 Subject: [PATCH 051/101] test(document-cli): assert convert's own exact usage-error messages The existing target-unresolvable and source-unresolvable cases only checked a loose substring, leaving buildConversionAction's own conflicting-destinations message and both of resolveTargetFormat's usage-error strings unobserved. Pins each to its exact full-line output and adds the two cases -- an unrecognised output extension and an unrecognised --to value -- neither previously exercised through the generic convert command at all. --- .../document-cli/src/commands/convert.test.ts | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/document-cli/src/commands/convert.test.ts b/packages/document-cli/src/commands/convert.test.ts index 684e018e0..58d52062d 100644 --- a/packages/document-cli/src/commands/convert.test.ts +++ b/packages/document-cli/src/commands/convert.test.ts @@ -115,7 +115,49 @@ describe("convert", () => { join(workspace, "input.docx"), ]); expect(exitCode).toBe(EXIT_USAGE_ERROR); - expect(stderr).toContain("convert:"); + expect(stderr).toBe( + "convert: cannot infer a target format -- pass an output path with a recognised extension, --out with one, or --to \n", + ); + }); + + it("fails clearly when the output path's own extension is not a recognised format", async () => { + const { exitCode, stderr } = await runCli([ + "convert", + join(workspace, "input.docx"), + join(workspace, "out.mystery"), + ]); + expect(exitCode).toBe(EXIT_USAGE_ERROR); + expect(stderr).toBe( + `convert: cannot infer a target format from '${join(workspace, "out.mystery")}'; pass --to instead\n`, + ); + }); + + it("rejects an unrecognised --to format, naming it and every known format", async () => { + const { exitCode, stderr } = await runCli([ + "convert", + join(workspace, "input.docx"), + join(workspace, "out.pdf"), + "--to", + "not-a-format", + ]); + expect(exitCode).toBe(EXIT_USAGE_ERROR); + expect(stderr).toBe( + "convert: unknown --to format 'not-a-format'; expected one of docx, pptx, xlsx, odt, odp, ods, odg, svg, odf, csv, markdown, rtf, wpd, doc, xls, ppt, epub, pdf\n", + ); + }); + + it("rejects a positional output and a conflicting --out, naming both", async () => { + const { exitCode, stderr } = await runCli([ + "convert", + join(workspace, "input.docx"), + "positional.pdf", + "--out", + "different.pdf", + ]); + expect(exitCode).toBe(EXIT_USAGE_ERROR); + expect(stderr).toBe( + "[docx-to-pdf] conflicting output destinations: positional 'positional.pdf' and --out 'different.pdf'\n", + ); }); it("prefers --to over the output path's own extension for the target format", async () => { From d98414ce175b78f2378ecbe4840f41d3458576a4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 11:16:40 +0100 Subject: [PATCH 052/101] test(document-cli): cover odm-to-pdf's own chapter/destination edges Adds the --chapters-dir-given-but-basename-missing case createResolveSubDocument's own existsSync check guards (previously only the chaptersDir-undefined path was exercised), pins the conflicting-destinations message to its exact [odm-to-pdf]-prefixed text, and asserts the command's own description and every registered option's help text. --- .../document-cli/src/commands/odm.test.ts | 58 ++++++++++++++++++- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/document-cli/src/commands/odm.test.ts b/packages/document-cli/src/commands/odm.test.ts index bbb52b558..2fca09bb4 100644 --- a/packages/document-cli/src/commands/odm.test.ts +++ b/packages/document-cli/src/commands/odm.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createOdt } from "documents.js"; @@ -120,6 +120,21 @@ describe("odm-to-pdf", () => { expect(stderr).toContain("--chapter"); }); + it("fails the same way when --chapters-dir is given but does not contain the href's basename", async () => { + const emptyDir = join(workspace, "empty-chapters-dir"); + await mkdir(emptyDir); + const { exitCode, stderr } = await runCli([ + "odm-to-pdf", + join(workspace, "book.odm"), + join(workspace, "unresolved-via-dir.pdf"), + "--chapters-dir", + emptyDir, + ]); + expect(exitCode).not.toBe(EXIT_SUCCESS); + expect(stderr).toContain("--chapters-dir"); + expect(stderr).toContain("--chapter"); + }); + it("rejects a malformed --chapter flag missing the '=' separator", async () => { const { exitCode, stderr } = await runCli([ "odm-to-pdf", @@ -132,7 +147,7 @@ describe("odm-to-pdf", () => { expect(stderr).toContain("--chapter must be formatted as ="); }); - it("rejects conflicting positional and --out destinations", async () => { + it("rejects conflicting positional and --out destinations, naming both under the odm-to-pdf command", async () => { const { exitCode, stderr } = await runCli([ "odm-to-pdf", join(workspace, "book.odm"), @@ -143,7 +158,9 @@ describe("odm-to-pdf", () => { workspace, ]); expect(exitCode).toBe(EXIT_USAGE_ERROR); - expect(stderr).toContain("conflicting output destinations"); + expect(stderr).toBe( + `[odm-to-pdf] conflicting output destinations: positional '${join(workspace, "positional.pdf")}' and --out '${join(workspace, "flag.pdf")}'\n`, + ); }); it("emits a JSON result summary on stderr under --json, naming the real output path", async () => { @@ -163,4 +180,39 @@ describe("odm-to-pdf", () => { output, }); }); + + it("registers odm-to-pdf with its own description and every conversion/font/chapter option", () => { + const command = createProgram().commands.find( + (candidate) => candidate.name() === "odm-to-pdf", + ); + expect(command?.description()).toBe( + "convert a .odm master document to pdf, resolving each chapter's external .odt reference via --chapters-dir and/or --chapter", + ); + const longs = (command?.options ?? []).map((option) => option.long); + expect(longs).toEqual( + expect.arrayContaining([ + "--out", + "--timeout", + "--json", + "--quiet", + "--verbose", + "--font-file", + "--report-font-substitutions", + "--chapters-dir", + "--chapter", + ]), + ); + const chaptersDirOption = command?.options.find( + (option) => option.long === "--chapters-dir", + ); + expect(chaptersDirOption?.description).toBe( + "directory to search for each unresolved chapter href, matched by the href's own basename", + ); + const chapterOption = command?.options.find( + (option) => option.long === "--chapter", + ); + expect(chapterOption?.description).toBe( + "resolve one chapter href to a local file explicitly; repeatable", + ); + }); }); From 31b69cfcab91b3cf20170f8a293f82057610bf46 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 11:19:29 +0100 Subject: [PATCH 053/101] test(document-cli): cover set-metadata's own error paths and help text Adds the conflicting-destinations, unresolvable-target, and unresolvable-source cases -- none previously exercised through this command -- each pinned to its exact [set-metadata]-prefixed message, plus the command's own description, its addHelpText "after" content (read via outputHelp() through a real --help run, since Command#helpInformation() alone omits addHelpText content), and every registered option's help text. --- .../src/commands/set-metadata.test.ts | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/packages/document-cli/src/commands/set-metadata.test.ts b/packages/document-cli/src/commands/set-metadata.test.ts index ec5fbfd70..9f05c13d8 100644 --- a/packages/document-cli/src/commands/set-metadata.test.ts +++ b/packages/document-cli/src/commands/set-metadata.test.ts @@ -279,6 +279,135 @@ describe("set-metadata", () => { expect(stderr).toContain("does not convert format"); }); + it("rejects conflicting positional and --out destinations, naming both under the set-metadata command", async () => { + const { exitCode, stderr } = await runCli([ + "set-metadata", + join(workspace, "source.docx"), + join(workspace, "positional.docx"), + "--out", + join(workspace, "flag.docx"), + "--set-title", + "x", + ]); + expect(exitCode).not.toBe(EXIT_SUCCESS); + expect(stderr).toBe( + `[set-metadata] conflicting output destinations: positional '${join(workspace, "positional.docx")}' and --out '${join(workspace, "flag.docx")}'\n`, + ); + }); + + it("fails with a usage error, prefixed under set-metadata, when the target format cannot be resolved at all", async () => { + const { exitCode, stderr } = await runCli([ + "set-metadata", + join(workspace, "source.docx"), + "--set-title", + "x", + ]); + expect(exitCode).not.toBe(EXIT_SUCCESS); + expect(stderr).toBe( + "[set-metadata] cannot infer a target format -- pass an output path with a recognised extension, --out with one, or --to \n", + ); + }); + + it("fails with a usage error, prefixed under set-metadata, when the source format cannot be inferred", async () => { + const unresolvedSource = join(workspace, "mystery.unknownext"); + await writeFile(unresolvedSource, "irrelevant"); + const { exitCode, stderr } = await runCli([ + "set-metadata", + unresolvedSource, + join(workspace, "never.docx"), + "--set-title", + "x", + ]); + expect(exitCode).not.toBe(EXIT_SUCCESS); + expect(stderr).toBe( + `[set-metadata] cannot infer a source format from '${unresolvedSource}'; rename the file with a recognised extension (docx, pptx, xlsx, odt, odp, ods, odg, svg, odf, csv, markdown, rtf, wpd, doc, xls, ppt, epub, pdf)\n`, + ); + }); + + it("registers set-metadata with its own description, help text, and every option", async () => { + const command = createProgram().commands.find( + (candidate) => candidate.name() === "set-metadata", + ); + expect(command?.description()).toBe( + "patch a document's own title/author/subject/keywords, leaving every other field and every other flag as-is", + ); + + // addHelpText's own "after" content is combined into the output only by outputHelp() (invoked here via --help through the real, assembled program), not by Command#helpInformation(), which renders only the built-in usage/options block. + const { stdout } = await (async () => { + const stdoutChunks: string[] = []; + const stdoutSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk) => { + stdoutChunks.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }); + try { + await createProgram().parseAsync([ + "node", + "document-cli", + "set-metadata", + "--help", + ]); + } catch { + // exitOverride (program.ts) rethrows after writing help and setting process.exitCode -- the thrown CommanderError carries nothing this test needs. + } finally { + stdoutSpy.mockRestore(); + } + return { stdout: stdoutChunks.join("") }; + })(); + + expect(stdout).toContain( + "Three write paths: a pdf source/target patches the metadata directly on the parsed PDF (writePdf), and a docx source/target", + ); + expect(stdout).toContain( + "patches docProps/core.xml directly on the decoded package -- both with no layout engine or ContentDocument rebuild involved", + ); + expect(stdout).toContain( + "at all, so everything else on the page (pdf) or in the package (docx -- comments, footnotes, headers/footers, numbering", + ); + expect(stdout).toContain( + "definitions included) survives byte-faithful. Every other supported format (pptx, xlsx, odt, odp, ods, odg, markdown, rtf)", + ); + expect(stdout).toContain( + "rebuilds a fresh package from that format's own ContentDocument instead.", + ); + expect(stdout).toContain( + "set-metadata does not convert format -- source and target must match. Run convert/from-package first, then", + ); + expect(stdout).toContain( + "set-metadata on the result, if you need a different target format.", + ); + + const longs = (command?.options ?? []).map((option) => option.long); + expect(longs).toEqual( + expect.arrayContaining([ + "--out", + "--timeout", + "--json", + "--quiet", + "--verbose", + "--to", + "--set-title", + "--set-author", + "--set-subject", + "--set-keywords", + ]), + ); + const descriptionOf = (long: string): string | undefined => + command?.options.find((option) => option.long === long)?.description; + expect(descriptionOf("--set-title")).toBe("set the title field"); + expect(descriptionOf("--set-author")).toBe("set the author field"); + expect(descriptionOf("--set-subject")).toBe("set the subject field"); + expect(descriptionOf("--set-keywords")).toBe( + "set the keywords field, comma-separated (trimmed, empty entries dropped)", + ); + expect(descriptionOf("--to")).toContain( + "target format when it cannot be inferred from the output path", + ); + }); + it("leaves metadata entirely unchanged when no --set-* flag is given at all", async () => { const outputPath = join(workspace, "untouched.docx"); const { exitCode } = await runCli([ From 03f8e9cea5282ad8cc0bcf9dbe3bcea4c12386ff Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 11:22:53 +0100 Subject: [PATCH 054/101] fix(document-cli): restore the unit project's testTimeout under Stryker vitest.mutation.config.ts replaces vitest.config.ts's entire `test` block rather than merging into it, which silently dropped the "unit" project's own 10s testTimeout along with everything else it didn't explicitly restate -- Stryker's dry run was running every test under vitest's bare 5000ms default instead. Several Ink TUI tests drive dozens of settle() calls in one test (a keypress-per-iteration loop), and that total already exceeds 5000ms even at settle()'s own previous, tighter wait -- a real regression this exposed once EFFECT_SETTLE_MS widened: "XlsSpreadsheetGridScreen renders a double-letter column address once the cursor passes column Z" now runs ~8.2s, comfortably under 10s but well past vitest's own unconfigured default. --- packages/document-cli/vitest.config.ts | 4 ++-- packages/document-cli/vitest.mutation.config.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/document-cli/vitest.config.ts b/packages/document-cli/vitest.config.ts index 380595b33..7157ee247 100644 --- a/packages/document-cli/vitest.config.ts +++ b/packages/document-cli/vitest.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "vitest/config"; -// The smoke project spawns the real built dist/cli.js as a child process (argv/stdio round trips, not just an in-process import), so it needs more headroom than an in-process unit test -- process spawn + a real conversion. -const UNIT_TEST_TIMEOUT_MS = 10_000; +// The smoke project spawns the real built dist/cli.js as a child process (argv/stdio round trips, not just an in-process import), so it needs more headroom than an in-process unit test -- process spawn + a real conversion. Exported rather than module-private: vitest.mutation.config.ts (Stryker's own vitest-runner config) replaces this file's entire `test` block outright rather than merging into it, so it re-imports this constant explicitly instead of silently falling back to vitest's own default testTimeout (5000ms) -- a real regression once caught: several Ink TUI tests drive dozens of settle() calls in one test (a keypress-per-iteration loop), and at EFFECT_SETTLE_MS's own real-timer wait, that total already exceeds vitest's bare default under Stryker's dry run specifically, even though it comfortably clears this project's own configured timeout below. +export const UNIT_TEST_TIMEOUT_MS = 10_000; const SMOKE_TEST_TIMEOUT_MS = 15_000; // Two named projects, filtered by --project in package.json's scripts: "unit" (src/**/*.test.ts and src/**/*.test.tsx, the latter for the Ink TUI's own ink-testing-library suites) for pnpm test/test:watch; "smoke" (test/smoke.test.mjs, which spawns dist/cli.js) only ever run by pnpm test:smoke, right after tsdown rebuilds dist/. diff --git a/packages/document-cli/vitest.mutation.config.ts b/packages/document-cli/vitest.mutation.config.ts index cacfc102e..d69719a85 100644 --- a/packages/document-cli/vitest.mutation.config.ts +++ b/packages/document-cli/vitest.mutation.config.ts @@ -1,10 +1,11 @@ import { defineConfig } from "vitest/config"; -import base from "./vitest.config"; +import base, { UNIT_TEST_TIMEOUT_MS } from "./vitest.config"; -// Isolates the "unit" project out of vitest.config.ts's multi-project test config for Stryker's vitest-runner, which loads one plain config file and has no equivalent of --project to select among several. test is replaced outright with the unit project's own include glob (an explicit key in an object literal always overrides whatever the earlier spread carried for that same key), so a stale projects/coverage key from the base config's own test block can't survive into this one -- Stryker never picks up the smoke/workers suites (which import from dist/ or need a different runtime and are not meaningful per-mutant) or fight over coverage instrumentation, which Stryker's own runner disables unconditionally anyway. +// Isolates the "unit" project out of vitest.config.ts's multi-project test config for Stryker's vitest-runner, which loads one plain config file and has no equivalent of --project to select among several. test is replaced outright with the unit project's own include glob (an explicit key in an object literal always overrides whatever the earlier spread carried for that same key), so a stale projects/coverage key from the base config's own test block can't survive into this one -- Stryker never picks up the smoke/workers suites (which import from dist/ or need a different runtime and are not meaningful per-mutant) or fight over coverage instrumentation, which Stryker's own runner disables unconditionally anyway. testTimeout is re-declared explicitly rather than inherited by the spread, for exactly the same reason: replacing the whole `test` key means the base config's own projects[].test.testTimeout goes with it, and vitest's own bare default (5000ms) is too tight for this suite's own settle()-heavy Ink component tests once Stryker's dry run -- not this project's own normal `pnpm test`, which already sets this timeout via the "unit" project above -- is what's actually running them. export default defineConfig({ ...base, test: { include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + testTimeout: UNIT_TEST_TIMEOUT_MS, }, }); From 8c1925befda9d8c82b8dbaccb0a5c0e5f1861dac Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 11:25:31 +0100 Subject: [PATCH 055/101] test(document-cli): cover outline's own error paths and help text Adds the unresolvable-source, unrecognised-from, stdin-with-no-from, and genuine-read-failure cases, pinning each to its exact [outline]-prefixed message, plus the command's own description and every option's help text. Cleans up the SIGINT listener each real runOutline() call leaves behind (createRuntimeSignal registers one and never removes it) so this file's now-larger real-invocation count stays under Node's default MaxListeners and doesn't print a warning into the captured stderr some of these tests assert is empty. --- .../document-cli/src/commands/outline.test.ts | 69 ++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/packages/document-cli/src/commands/outline.test.ts b/packages/document-cli/src/commands/outline.test.ts index 57334bd58..b04e79ab9 100644 --- a/packages/document-cli/src/commands/outline.test.ts +++ b/packages/document-cli/src/commands/outline.test.ts @@ -90,6 +90,8 @@ beforeEach(() => { afterEach(() => { process.exitCode = savedExitCode; + // Every real runOutline() call registers its own SIGINT listener via createRuntimeSignal and never removes it -- harmless for a real one-shot CLI process, but this file alone now drives enough real invocations in one vitest worker to cross Node's default MaxListeners (10) and print a warning straight to the captured stderr some of the tests above assert is empty. + process.removeAllListeners("SIGINT"); }); describe("outline", () => { @@ -256,14 +258,77 @@ describe("outline", () => { expect(leaf.kind).toBe("paragraph"); }); - it("fails with a usage error naming the recognised extensions when the input has none", async () => { + it("fails with a usage error, prefixed under outline, naming the recognised extensions when the input has none", async () => { const barePath = join(workspace, "notes.txt"); await writeFile(barePath, "no outline signal here\n"); const { exitCode, stderr } = await runCli(["outline", barePath]); expect(exitCode).toBe(EXIT_USAGE_ERROR); - expect(stderr).toContain("cannot infer a source format"); + expect(stderr).toBe( + `[outline] cannot infer a source format from '${barePath}'; rename the file with a recognised extension (docx, pptx, xlsx, odt, odp, ods, odg, svg, odf, csv, markdown, rtf, wpd, doc, xls, ppt, epub, pdf) or pass --from \n`, + ); + }); + + it("rejects an unrecognised --from format, naming it and every known format", async () => { + const docPath = join(workspace, "any.md"); + await writeFile(docPath, "# Title\n"); + const { exitCode, stderr } = await runCli([ + "outline", + docPath, + "--from", + "not-a-format", + ]); + expect(exitCode).toBe(EXIT_USAGE_ERROR); + expect(stderr).toBe( + "[outline] unknown --from format 'not-a-format'; expected one of docx, pptx, xlsx, odt, odp, ods, odg, svg, odf, csv, markdown, rtf, wpd, doc, xls, ppt, epub, pdf\n", + ); + }); + + it("fails naming stdin explicitly when reading from '-' with no --from given", async () => { + const { exitCode, stderr } = await runCli(["outline", "-"]); + expect(exitCode).toBe(EXIT_USAGE_ERROR); + expect(stderr).toBe( + "[outline] cannot infer a source format from stdin; pass --from (docx, pptx, xlsx, odt, odp, ods, odg, svg, odf, csv, markdown, rtf, wpd, doc, xls, ppt, epub, pdf)\n", + ); + }); + + it("reports a genuine read failure through the [outline]-prefixed catch, not the usage-error path", async () => { + const corruptPath = join(workspace, "corrupt.docx"); + await writeFile(corruptPath, "this is not a real docx package"); + + const { exitCode, stderr } = await runCli(["outline", corruptPath]); + + expect(exitCode).not.toBe(EXIT_SUCCESS); + expect(exitCode).not.toBe(EXIT_USAGE_ERROR); + expect(stderr).toMatch(/^\[outline\] error: /); + }); + + it("registers outline with its own description and every option's help text", () => { + const command = createProgram().commands.find( + (candidate) => candidate.name() === "outline", + ); + expect(command?.description()).toBe( + "print a document's outline -- headings, list items, and slide/sheet/page groups as indented text (docx, pptx, xlsx, odt, odp, ods, odg, svg, odf, csv, markdown, rtf, wpd, doc, xls, ppt, epub, pdf)", + ); + const descriptionOf = (long: string): string | undefined => + command?.options.find((option) => option.long === long)?.description; + expect(descriptionOf("--json")).toBe( + "emit the outline tree as JSON instead of indented text (diagnostics as NDJSON on stderr)", + ); + expect(descriptionOf("--from")).toBe( + "source format when it cannot be inferred from the input path, e.g. reading from stdin (docx, pptx, xlsx, odt, odp, ods, odg, svg, odf, csv, markdown, rtf, wpd, doc, xls, ppt, epub, pdf)", + ); + const longs = (command?.options ?? []).map((option) => option.long); + expect(longs).toEqual( + expect.arrayContaining([ + "--timeout", + "--json", + "--quiet", + "--verbose", + "--from", + ]), + ); }); it("prints nothing at all for a document with no outline content, rather than one stray blank line", async () => { From dcb6b0710da64d6072fa7548295d91b833316b18 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 20:01:00 +0100 Subject: [PATCH 056/101] fix(document-cli): drop the redundant '-' guard in inferFormatFromExtension The bare '-' stdin/stdout marker has no '.' character, so it already falls through to the "no extension" undefined return below -- the dedicated `if (path === "-") return undefined;` branch never changed the observable result for any input, it only duplicated logic the extension check already provides. Also covers the dotIndex<=0 guard with a leading-dot filename whose remainder spells a real format ('.docx'), which '.gitignore' alone couldn't distinguish since "gitignore" isn't a recognised extension either way. --- packages/document-cli/src/format.test.ts | 5 +++++ packages/document-cli/src/format.ts | 5 +---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/document-cli/src/format.test.ts b/packages/document-cli/src/format.test.ts index 8674a0352..0eb266195 100644 --- a/packages/document-cli/src/format.test.ts +++ b/packages/document-cli/src/format.test.ts @@ -58,6 +58,11 @@ describe("inferFormatFromExtension", () => { expect(inferFormatFromExtension(".gitignore")).toBeUndefined(); }); + it("treats a leading dot as the whole filename, not an extension, even when the remainder spells a real format", () => { + // ".docx" (a dotfile literally named that, with no further '.') must not extract "docx" as its extension -- dotIndex is 0 here, which is <= 0 (no extension) rather than a genuine split point. ".gitignore" above can't distinguish this on its own, since "gitignore" isn't a recognised format either way; this needs a leading-dot name whose remainder DOES match one. + expect(inferFormatFromExtension(".docx")).toBeUndefined(); + }); + it("returns undefined for an unrecognised extension", () => { expect(inferFormatFromExtension("archive.zip")).toBeUndefined(); }); diff --git a/packages/document-cli/src/format.ts b/packages/document-cli/src/format.ts index 12ae9fe54..9489d4597 100644 --- a/packages/document-cli/src/format.ts +++ b/packages/document-cli/src/format.ts @@ -59,13 +59,10 @@ export function isDocumentFormat(value: string): value is DocumentFormat { return value in FORMAT_TO_EXTENSION; } -// Reads the extension after the last '.' in the final path segment (so 'a.b/c.docx' -> 'docx', '.gitignore' -> undefined -- a leading dot with no further '.' is not an extension). Returns undefined for no recognised extension, an unrecognised one, a bare '-' (stdin/stdout marker), or a path with none at all -- callers decide how to react to an unresolved format, this module only classifies. +// Reads the extension after the last '.' in the final path segment (so 'a.b/c.docx' -> 'docx', '.gitignore' -> undefined -- a leading dot with no further '.' is not an extension). Returns undefined for no recognised extension, an unrecognised one, a bare '-' (stdin/stdout marker, which has no '.' of its own and so already falls out of the extension check below with no special-cased branch needed), or a path with none at all -- callers decide how to react to an unresolved format, this module only classifies. export function inferFormatFromExtension( path: string, ): DocumentFormat | undefined { - if (path === "-") { - return undefined; - } const lastSegment = path.split(/[/\\]/).pop() ?? path; const dotIndex = lastSegment.lastIndexOf("."); if (dotIndex <= 0) { From 8fffdc041883371453012c8e954b4242fc820091 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 20:01:38 +0100 Subject: [PATCH 057/101] test(document-cli): prove the empty/data:/scheme guards actually run Every existing case for createFilesystemMarkdownImageResolver's guard happened to return undefined whether the guard fired or the fallback readFileSync simply failed to find nothing, so a bypassed guard was unobservable through the return value alone. Real fixtures placed at the exact path a bypassed guard's fallback read would hit make the difference observable: an empty destination against a baseDir that is itself a file, a scheme URL that resolves through the literal "http:" path segment, and destinations where "data:"/"http://" appear after the first character rather than anchored at it. --- .../src/runtime/markdown-images.test.ts | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/document-cli/src/runtime/markdown-images.test.ts b/packages/document-cli/src/runtime/markdown-images.test.ts index c0f04ee2c..e2901780e 100644 --- a/packages/document-cli/src/runtime/markdown-images.test.ts +++ b/packages/document-cli/src/runtime/markdown-images.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -9,6 +9,14 @@ let workspace: string; beforeAll(async () => { workspace = await mkdtemp(join(tmpdir(), "document-cli-md-images-")); await writeFile(join(workspace, "image.png"), new Uint8Array([1, 2, 3, 4])); + // A real file at the exact path resolve(workspace, destination) lands on for a genuine scheme-prefixed destination ("http://x" resolves through the literal "http:" path segment produced by the double slash), so the guard's short-circuit is provable: bypassing it would find and read this fixture rather than merely failing to find nothing, which a plain "expect undefined" test can't distinguish from a guard that never ran. + await mkdir(join(workspace, "http:"), { recursive: true }); + await writeFile(join(workspace, "http:", "x"), new Uint8Array([5, 6, 7])); + // A destination containing the literal substring "data:" after its first character, so an unanchored data-URI match (rather than the real ^-anchored one) is the only thing that would treat it as a data URI. + await writeFile(join(workspace, "1data:x"), new Uint8Array([8, 9])); + // Likewise for the scheme regex: "http://" appears from the second character onward, so only an unanchored scheme match would exclude it. + await mkdir(join(workspace, "1http:"), { recursive: true }); + await writeFile(join(workspace, "1http:", "x"), new Uint8Array([10, 11])); }); afterAll(async () => { @@ -70,4 +78,29 @@ describe("createFilesystemMarkdownImageResolver", () => { const resolver = createFilesystemMarkdownImageResolver(workspace); expect(resolver("no-scheme:not-a-url")).toBeUndefined(); }); + + it("guards an empty destination before any filesystem read is attempted, not merely because the read would fail", () => { + // Pointing baseDir directly at a real FILE (not a directory) makes resolve(baseDir, "") equal that file's own path -- so if the guard's condition were bypassed for any reason, readFileSync would succeed and return real bytes instead of undefined. A guard that merely happens to fail the same way a broken guard would (both landing on undefined because the fallback read errors) can't tell these apart; this can. + const fileAsBaseDir = join(workspace, "image.png"); + const resolver = createFilesystemMarkdownImageResolver(fileAsBaseDir); + expect(resolver("")).toBeUndefined(); + }); + + it("guards a scheme-prefixed destination before any filesystem read is attempted, not merely because the read would fail", () => { + // "http://x" resolves, via path.resolve's own segment-joining, to the real fixture at workspace/http:/x -- so bypassing the scheme guard here returns real bytes, not undefined-by-coincidence. + const resolver = createFilesystemMarkdownImageResolver(workspace); + expect(resolver("http://x")).toBeUndefined(); + }); + + it("only excludes a scheme match anchored at the very start of the destination", () => { + // "1http://x" does not start with a letter, so the real ^-anchored scheme regex must not match it -- it falls through to an actual (successful) read of the fixture at workspace/1http:/x. An unanchored variant of the same regex would match the embedded "http://" and wrongly short-circuit to undefined. + const resolver = createFilesystemMarkdownImageResolver(workspace); + expect(resolver("1http://x")?.bytes).toEqual(new Uint8Array([10, 11])); + }); + + it("only excludes a data: match anchored at the very start of the destination", () => { + // "1data:x" does not start with "data:", so the real ^-anchored regex must not match it -- it falls through to an actual (successful) read of the fixture at workspace/1data:x. An unanchored variant would match the embedded "data:" and wrongly short-circuit to undefined. + const resolver = createFilesystemMarkdownImageResolver(workspace); + expect(resolver("1data:x")?.bytes).toEqual(new Uint8Array([8, 9])); + }); }); From 3c74330e9c86100a2518fc5d04c86963bc044f12 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 20:03:19 +0100 Subject: [PATCH 058/101] test(document-cli): cover docx-extras's own error path and signal threading No existing test exercised readDocxExtras throwing, so the "[docx-extras]" stderr prefix and the input-error exit-code mapping were never proven to name this command specifically rather than some other non-empty string. Separately, nothing proved createRuntimeSignal's AbortSignal actually reaches readInput's own options rather than an empty options object. --- .../src/commands/docx-extras-mocked.test.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 packages/document-cli/src/commands/docx-extras-mocked.test.ts diff --git a/packages/document-cli/src/commands/docx-extras-mocked.test.ts b/packages/document-cli/src/commands/docx-extras-mocked.test.ts new file mode 100644 index 000000000..03fb8fe56 --- /dev/null +++ b/packages/document-cli/src/commands/docx-extras-mocked.test.ts @@ -0,0 +1,116 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createDocx } from "documents.js"; +import type * as DocumentsJs from "documents.js"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { EXIT_INPUT_ERROR } from "../runtime/exit-codes"; +import type * as RuntimeIo from "../runtime/io"; + +// A dedicated file, isolated from docx-extras.test.ts's own real-fixture tests: mocking readDocxExtras here is file-wide, so it must never share a module with a test that needs the genuine implementation. Covers the error path no real fixture reaches (proving the "[docx-extras]" prefix names this command specifically, not merely some non-empty string, and that a thrown error maps to the input-error exit code) and proves the abort signal createRuntimeSignal builds is genuinely threaded through to readInput's own options, not dropped. +vi.mock("documents.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readDocxExtras: vi.fn(), + }; +}); + +vi.mock("../runtime/io", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readInput: vi.fn(actual.readInput), + }; +}); + +async function runDocxExtrasCommand(input: string): Promise<{ + readonly exitCode: typeof process.exitCode; + readonly stdout: string; + readonly stderr: string; +}> { + const { createProgram } = await import("../program"); + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + const stdoutSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk) => { + stdoutChunks.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }); + const stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation((chunk) => { + stderrChunks.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }); + const savedExitCode = process.exitCode; + try { + await createProgram().parseAsync([ + "node", + "document-cli", + "docx-extras", + input, + ]); + } finally { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + } + const result = { + exitCode: process.exitCode, + stdout: stdoutChunks.join(""), + stderr: stderrChunks.join(""), + }; + process.exitCode = savedExitCode; + return result; +} + +describe("docx-extras command against a mocked reader", () => { + let workspace: string; + let inputPath: string; + + beforeAll(async () => { + workspace = await mkdtemp(join(tmpdir(), "document-cli-docx-extras-mock-")); + inputPath = join(workspace, "fixture.docx"); + await writeFile(inputPath, createDocx().toBytes()); + }); + + afterAll(async () => { + await rm(workspace, { recursive: true, force: true }); + }); + + it("reports a thrown error with the '[docx-extras]' prefix naming this command specifically, mapped to the input-error exit code", async () => { + const { readDocxExtras } = await import("documents.js"); + vi.mocked(readDocxExtras).mockImplementation(() => { + throw new Error("boom"); + }); + + const { exitCode, stdout, stderr } = await runDocxExtrasCommand(inputPath); + + expect(stdout).toBe(""); + expect(stderr).toBe("[docx-extras] error: boom\n"); + expect(exitCode).toBe(EXIT_INPUT_ERROR); + }); + + it("threads a real AbortSignal through to readInput's own options, not an empty options object", async () => { + const { readDocxExtras } = await import("documents.js"); + vi.mocked(readDocxExtras).mockReturnValue({ + comments: [], + footnotes: [], + headerFooterParts: [], + sectionHeaderFooters: [], + numbering: {}, + }); + const { readInput } = await import("../runtime/io"); + + await runDocxExtrasCommand(inputPath); + + const call = vi.mocked(readInput).mock.calls[0]; + expect(call?.[0]).toBe(inputPath); + expect(call?.[1]?.signal).toBeInstanceOf(AbortSignal); + }); +}); From 6cc8919cbe18c4080eab20d08968ffd54ac772e6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 20:03:35 +0100 Subject: [PATCH 059/101] test(document-cli): accept agreeing --out and positional destinations The conflicting-destination check only rejects when the positional output and --out genuinely disagree; nothing proved that giving the identical path both ways is accepted rather than misclassified as a conflict. --- packages/document-cli/src/commands/convert.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/document-cli/src/commands/convert.test.ts b/packages/document-cli/src/commands/convert.test.ts index 58d52062d..e41e87931 100644 --- a/packages/document-cli/src/commands/convert.test.ts +++ b/packages/document-cli/src/commands/convert.test.ts @@ -160,6 +160,19 @@ describe("convert", () => { ); }); + it("accepts a positional output and --out when they name the identical destination, rather than treating agreement as a conflict", async () => { + const output = join(workspace, "same-destination.pdf"); + const { exitCode, stderr } = await runCli([ + "convert", + join(workspace, "input.docx"), + output, + "--out", + output, + ]); + expect(stderr).not.toContain("conflicting output destinations"); + expect(exitCode).toBe(EXIT_SUCCESS); + }); + it("prefers --to over the output path's own extension for the target format", async () => { const output = join(workspace, "explicit-to.pdf"); const { exitCode } = await runCli([ From 0d4ebaff658407c9dca949bdfbeb9524bec0361f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 20:08:35 +0100 Subject: [PATCH 060/101] test(document-cli): open a real .odb through openDocumentAtPath No test in this suite ever exercised the .odb branch of openDocumentAtPath at all -- every existing case covers the WritableFormat/read-only-preview switch below it, none reach the ODB_EXTENSION check or its eager tables/forms/reports decode. Also proves the extension check is genuinely case-insensitive, not merely happening to match because every fixture path in the suite is already lowercase. --- .../src/tui/format/open-document.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/document-cli/src/tui/format/open-document.test.ts b/packages/document-cli/src/tui/format/open-document.test.ts index 916b9d3ca..7d69c35d7 100644 --- a/packages/document-cli/src/tui/format/open-document.test.ts +++ b/packages/document-cli/src/tui/format/open-document.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createOds, odsToXlsx, openMarkdown } from "documents.js"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { FORM_AND_REPORT_ODB_PATH } from "../../test-support/odb-fixture.js"; import { appReducer, createInitialState } from "../state/reducer.js"; import type { AppState, Diagnostic } from "../state/types.js"; import { openDocumentAtPath, saveDocumentTo } from "./open-document.js"; @@ -26,6 +27,24 @@ afterEach(async () => { await rm(workspace, { recursive: true, force: true }); }); +describe("openDocumentAtPath for .odb", () => { + it("decodes the package once and reads tables, forms, and reports eagerly rather than opening a live-view editor", async () => { + const doc = await openDocumentAtPath(FORM_AND_REPORT_ODB_PATH); + if (doc.format !== "odb") throw new Error("expected odb"); + expect(doc.tables.length).toBeGreaterThan(0); + expect(doc.forms.length).toBeGreaterThan(0); + expect(doc.reports.length).toBeGreaterThan(0); + expect(doc.path).toBe(FORM_AND_REPORT_ODB_PATH); + }); + + it("recognises the .odb extension case-insensitively", async () => { + const upperCasePath = join(workspace, "FIXTURE.ODB"); + await writeFile(upperCasePath, await readFile(FORM_AND_REPORT_ODB_PATH)); + const doc = await openDocumentAtPath(upperCasePath); + expect(doc.format).toBe("odb"); + }); +}); + describe("openDocumentAtPath for .xlsx", () => { it("opens read-only as a converted PDF preview instead of throwing", async () => { const bytes = xlsxTestBytes(); From 5eea202f50a43a62e5687bb0e2919081cd157949 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 20:11:39 +0100 Subject: [PATCH 061/101] test(document-cli): cover open-document's unrecognised-extension and odf cases Neither the "Cannot tell what kind of document" throw (a path whose extension detectFormat cannot classify at all) nor the standalone .odf "has no editor" throw had any test reaching them. --- .../src/tui/format/open-document.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/document-cli/src/tui/format/open-document.test.ts b/packages/document-cli/src/tui/format/open-document.test.ts index 7d69c35d7..1755f70a2 100644 --- a/packages/document-cli/src/tui/format/open-document.test.ts +++ b/packages/document-cli/src/tui/format/open-document.test.ts @@ -45,6 +45,28 @@ describe("openDocumentAtPath for .odb", () => { }); }); +describe("openDocumentAtPath for an unrecognised extension", () => { + it("throws naming the path, rather than falling through to some format's own decoder", async () => { + const path = join(workspace, "mystery.unknownext"); + await writeFile(path, "irrelevant"); + + await expect(openDocumentAtPath(path)).rejects.toThrow( + `Cannot tell what kind of document ${path} is from its extension`, + ); + }); +}); + +describe("openDocumentAtPath for .odf", () => { + it("throws explaining a standalone formula document has no editor, rather than opening one", async () => { + const path = join(workspace, "formula.odf"); + await writeFile(path, "irrelevant"); + + await expect(openDocumentAtPath(path)).rejects.toThrow( + "A standalone .odf formula document has no editor; convert it to PDF (odfToPdf) instead", + ); + }); +}); + describe("openDocumentAtPath for .xlsx", () => { it("opens read-only as a converted PDF preview instead of throwing", async () => { const bytes = xlsxTestBytes(); From 04e4282f3fa3278b90b359896126949534308721 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 20:12:03 +0100 Subject: [PATCH 062/101] test(document-cli): prove renderOdbReportTo threads its report name through The existing unknown-report-name test asserted only that the error message happens to contain "SalesByRegion" -- true whether the report field was genuinely threaded or dropped and defaulted to the fixture's sole report, since that default's own error text names every declared report anyway. An empty report name can't coincide with any real report, so rejecting on it specifically proves the field survives the call. --- .../src/tui/format/render-odb-report.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/document-cli/src/tui/format/render-odb-report.test.ts b/packages/document-cli/src/tui/format/render-odb-report.test.ts index 6bebf47be..2bce6102c 100644 --- a/packages/document-cli/src/tui/format/render-odb-report.test.ts +++ b/packages/document-cli/src/tui/format/render-odb-report.test.ts @@ -112,6 +112,16 @@ describe("renderOdbReportTo", () => { ).rejects.toThrow(/SalesByRegion/); }); + it("genuinely threads the given report name through rather than dropping it, which the sole declared report would silently absorb as a default for any name including an unknown one", async () => { + // FORM_AND_REPORT_ODB_PATH declares exactly one report ("SalesByRegion"). If { report: options.reportName } lost its `report` field entirely (rather than merely being given the wrong value), readOdbReportContent would still succeed by defaulting to that sole report -- so an empty reportName, which cannot coincide with any real report name, is the input that specifically proves the field survives the call rather than being dropped. + await expect( + renderOdbReportTo(doc, join(workspace, "never-written-4.docx"), { + reportName: "", + onDiagnostic: () => undefined, + }), + ).rejects.toThrow(); + }); + it("rejects instead of reading a fontFiles entry once the given signal is already aborted", async () => { const fontPath = join(workspace, "aborted-font.ttf"); await writeFile(fontPath, fixtureCalibriFontBytes()); From a605f66ecc025f59b16404ee20a4d9e4e7587088 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 20:19:53 +0100 Subject: [PATCH 063/101] test(document-cli): prove metadataFor dispatches to its own case's reader Every existing per-format test compared metadataFor's result against the same reader called a second time -- equal on a genuine dispatch, but also equal if that case's own return were lost to a fall-through into the next one, since docx/pptx share OOXML's docProps/core.xml metadata convention and odt/odp/ods/odg share ODF's meta.xml one. Mocking each reader to return a distinct sentinel makes a fall-through observably wrong regardless of format compatibility. --- .../tui/format/read-metadata-mocked.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 packages/document-cli/src/tui/format/read-metadata-mocked.test.ts diff --git a/packages/document-cli/src/tui/format/read-metadata-mocked.test.ts b/packages/document-cli/src/tui/format/read-metadata-mocked.test.ts new file mode 100644 index 000000000..933a42993 --- /dev/null +++ b/packages/document-cli/src/tui/format/read-metadata-mocked.test.ts @@ -0,0 +1,58 @@ +import type * as DocumentsJs from "documents.js"; +import { describe, expect, it, vi } from "vitest"; +import { createNewDocument } from "./open-document.js"; +import { metadataFor } from "./read-metadata.js"; + +// A dedicated file, isolated from read-metadata.test.ts's own real-fixture tests: mocking every readXContent reader here is file-wide, so it must never share a module with a test that needs the genuine implementation. Proves metadataFor's switch dispatches to the ONE reader matching its own case, not a neighbouring one -- docx/pptx are adjacent OOXML cases sharing the identical docProps/core.xml metadata convention, and odt/odp/ods/odg are adjacent ODF cases sharing the identical meta.xml convention, so a case whose own `return` were lost to a fall-through into the next case would still read a structurally valid, often equal, LayoutMetadata object from the wrong reader -- real-fixture equality assertions comparing metadataFor's result against the SAME reader called a second time can't tell a genuine dispatch from a silent fall-through that happens to land on a compatible reader. Each mock returns a distinct, unmistakable sentinel instead, so only an exact reader match can satisfy the assertion. +vi.mock("documents.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readDocxContent: vi.fn(() => ({ metadata: { title: "docx-sentinel" } })), + readPptxContent: vi.fn(() => ({ metadata: { title: "pptx-sentinel" } })), + readOdtContent: vi.fn(() => ({ metadata: { title: "odt-sentinel" } })), + readOdpContent: vi.fn(() => ({ metadata: { title: "odp-sentinel" } })), + readOdsContent: vi.fn(() => ({ metadata: { title: "ods-sentinel" } })), + readOdgContent: vi.fn(() => ({ metadata: { title: "odg-sentinel" } })), + readMarkdownContent: vi.fn(() => ({ + metadata: { title: "markdown-sentinel" }, + })), + }; +}); + +describe("metadataFor dispatches to exactly its own case's reader", () => { + it("reads docx through readDocxContent alone", () => { + const doc = createNewDocument("docx"); + expect(metadataFor(doc).title).toBe("docx-sentinel"); + }); + + it("reads pptx through readPptxContent alone", () => { + const doc = createNewDocument("pptx"); + expect(metadataFor(doc).title).toBe("pptx-sentinel"); + }); + + it("reads odt through readOdtContent alone", () => { + const doc = createNewDocument("odt"); + expect(metadataFor(doc).title).toBe("odt-sentinel"); + }); + + it("reads odp through readOdpContent alone", () => { + const doc = createNewDocument("odp"); + expect(metadataFor(doc).title).toBe("odp-sentinel"); + }); + + it("reads ods through readOdsContent alone", () => { + const doc = createNewDocument("ods"); + expect(metadataFor(doc).title).toBe("ods-sentinel"); + }); + + it("reads odg through readOdgContent alone", () => { + const doc = createNewDocument("odg"); + expect(metadataFor(doc).title).toBe("odg-sentinel"); + }); + + it("reads markdown through readMarkdownContent alone", () => { + const doc = createNewDocument("markdown"); + expect(metadataFor(doc).title).toBe("markdown-sentinel"); + }); +}); From 3f25e3cd26e83ae4ba007d179bb71ecaf485bbf8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 20:20:04 +0100 Subject: [PATCH 064/101] test(document-cli): isolate loadProvidedFonts's own abort-signal wiring The existing abort test targeted a pdf destination, where an aborted signal also reaches toPdfOptions and the pdf render's own separate abort check -- satisfying the rejection regardless of whether loadProvidedFonts itself ever saw the signal. Targeting docx instead never reaches that second check at all, since fontFiles is a no-op for a non-pdf target, isolating the assertion to loadProvidedFonts's own signal wiring alone. --- .../src/tui/format/render-odb-report.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/document-cli/src/tui/format/render-odb-report.test.ts b/packages/document-cli/src/tui/format/render-odb-report.test.ts index 2bce6102c..02642a1a9 100644 --- a/packages/document-cli/src/tui/format/render-odb-report.test.ts +++ b/packages/document-cli/src/tui/format/render-odb-report.test.ts @@ -122,6 +122,23 @@ describe("renderOdbReportTo", () => { ).rejects.toThrow(); }); + it("threads the given signal into loadProvidedFonts specifically -- isolated from the pdf render's own separate abort check by targeting docx, which never reaches toPdfOptions at all", async () => { + const fontPath = join(workspace, "aborted-font-docx.ttf"); + await writeFile(fontPath, fixtureCalibriFontBytes()); + const output = join(workspace, "never-written-docx.docx"); + const controller = new AbortController(); + controller.abort(); + + await expect( + renderOdbReportTo(doc, output, { + reportName: "SalesByRegion", + onDiagnostic: () => undefined, + fontFiles: [fontPath], + signal: controller.signal, + }), + ).rejects.toThrow(/abort/i); + }); + it("rejects instead of reading a fontFiles entry once the given signal is already aborted", async () => { const fontPath = join(workspace, "aborted-font.ttf"); await writeFile(fontPath, fixtureCalibriFontBytes()); From 3c50010a64ddd0192d9130ca96f74401b89d91c1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 20:26:23 +0100 Subject: [PATCH 065/101] test(document-cli): type callback mocks as void-returning vi.fn() with no generic infers a return type of any, which @typescript-eslint/strict-void-return (added in eslint-config 2.12.1, pulled in by this branch's rebase onto main) now rejects wherever a callback prop's own type declares void. Pinning each mock's generic to a void-returning signature matches the real onConfirm/onCancel/onBack/onSelect/onAppend callback types these tests exercise, the same fix already applied to web's matchMedia listener mocks. --- .../src/tui/components/confirm-dialog.test.tsx | 16 ++++++++-------- .../src/tui/components/text-field.test.tsx | 4 ++-- .../keybindings/use-navigation-input.test.tsx | 8 ++++---- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/document-cli/src/tui/components/confirm-dialog.test.tsx b/packages/document-cli/src/tui/components/confirm-dialog.test.tsx index 8064cee4e..a867f5fda 100644 --- a/packages/document-cli/src/tui/components/confirm-dialog.test.tsx +++ b/packages/document-cli/src/tui/components/confirm-dialog.test.tsx @@ -19,7 +19,7 @@ describe("ConfirmDialog", () => { }); it('confirms on "y"', async () => { - const onConfirm = vi.fn(); + const onConfirm = vi.fn<() => void>(); const { lastFrame, stdin } = render( { }); it('confirms on "Y"', async () => { - const onConfirm = vi.fn(); + const onConfirm = vi.fn<() => void>(); const { lastFrame, stdin } = render( { }); it("confirms on Enter", async () => { - const onConfirm = vi.fn(); + const onConfirm = vi.fn<() => void>(); const { lastFrame, stdin } = render( { }); it('cancels on "n"', async () => { - const onCancel = vi.fn(); + const onCancel = vi.fn<() => void>(); const { lastFrame, stdin } = render( { }); it('cancels on "N"', async () => { - const onCancel = vi.fn(); + const onCancel = vi.fn<() => void>(); const { lastFrame, stdin } = render( { }); it("cancels on Escape", async () => { - const onCancel = vi.fn(); + const onCancel = vi.fn<() => void>(); const { lastFrame, stdin } = render( { }); it("does nothing on an unrelated key", async () => { - const onConfirm = vi.fn(); - const onCancel = vi.fn(); + const onConfirm = vi.fn<() => void>(); + const onCancel = vi.fn<() => void>(); const { lastFrame, stdin } = render( { it("calls onCancel on Escape while focused", async () => { - const onCancel = vi.fn(); + const onCancel = vi.fn<() => void>(); render( { }); it("ignores Escape while not focused, leaving onCancel uncalled", async () => { - const onCancel = vi.fn(); + const onCancel = vi.fn<() => void>(); render( { }); it("calls onBack on Escape, left arrow, or 'h'", async () => { - const onBack = vi.fn(); + const onBack = vi.fn<() => void>(); const { stdin } = render( undefined} onBack={onBack} />, ); @@ -140,7 +140,7 @@ describe("useNavigationInput", () => { }); it("calls onSelect with the current index on Enter, right arrow, or 'l'", async () => { - const onSelect = vi.fn(); + const onSelect = vi.fn<() => void>(); const { stdin } = render( undefined} />, ); @@ -154,7 +154,7 @@ describe("useNavigationInput", () => { }); it("never calls onSelect when the list is empty", async () => { - const onSelect = vi.fn(); + const onSelect = vi.fn<() => void>(); const { stdin } = render( undefined} />, ); @@ -163,7 +163,7 @@ describe("useNavigationInput", () => { }); it("calls onAppend on 'a' when provided", async () => { - const onAppend = vi.fn(); + const onAppend = vi.fn<() => void>(); const { stdin } = render( Date: Mon, 14 Sep 2026 21:56:00 +0100 Subject: [PATCH 066/101] test(document-cli): cover the reducer's close/overlay/undo-cap/merge-validation branches SAVE_SUCCESS now has coverage for the no-open-document warning and for documentWithPath's read-only-preview cases (xlsx/csv/svg/rtf), proving the path is rewritten while layout/bytes stay the identical object. REQUEST_CLOSE/CONFIRM_CLOSE/CANCEL_CLOSE and OPEN_OVERLAY/CLOSE_OVERLAY had no tests at all: add coverage for the no-document/unsaved-changes branches, the confirmClose overlay round trip, and CANCEL_CLOSE leaving the document and its edits untouched. pushSnapshot's UNDO_STACK_LIMIT trim had no test proving the cap is 20 or that it retains the most recent snapshots rather than the oldest. mergePptxTableCells' own validation guards (non-integer/non-positive rowSpan or colSpan, a colSpan that overruns the table's column count) were untested beyond the one row-overrun case already covered. --- .../src/tui/state/reducer.test.ts | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index aa25aa621..b1c06d385 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -316,6 +316,133 @@ describe("appReducer document lifecycle", () => { }); }); +describe("appReducer SAVE_SUCCESS", () => { + it("says so when there is no open document to record the path against", () => { + const result = appReducer(createInitialState(), { + type: "SAVE_SUCCESS", + path: "/tmp/orphan.docx", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.hasUnsavedChanges).toBe(false); + }); + + // documentWithPath's own read-only-preview branches (xlsx/csv/svg/rtf) are otherwise never reached by any other action -- SAVE_AS on one of these formats is the only path that dispatches SAVE_SUCCESS against them, so this proves the layout/bytes pair survives the rewrite untouched alongside the new path. + it("updates a read-only preview document's own path while keeping its layout and bytes untouched", () => { + const bytes = xlsxTestBytes(); + const layout = readPdf(xlsxToPdf(bytes)); + const cases: readonly ["xlsx" | "csv" | "svg" | "rtf", string][] = [ + ["xlsx", "/tmp/renamed.xlsx"], + ["csv", "/tmp/renamed.csv"], + ["svg", "/tmp/renamed.svg"], + ["rtf", "/tmp/renamed.rtf"], + ]; + for (const [format, newPath] of cases) { + const opened = appReducer(createInitialState(), { + type: "OPEN_FILE_SUCCESS", + path: "/tmp/original", + doc: { format, layout, bytes, path: "/tmp/original" }, + }); + const saved = appReducer(opened, { + type: "SAVE_SUCCESS", + path: newPath, + }); + const doc = saved.openDocument; + if (doc?.format !== format) { + throw new Error(`expected a ${format} document, got ${doc?.format}`); + } + expect(doc.path).toBe(newPath); + expect(doc.layout).toBe(layout); + expect(doc.bytes).toBe(bytes); + expect(saved.hasUnsavedChanges).toBe(false); + } + }); +}); + +describe("appReducer OPEN_OVERLAY / CLOSE_OVERLAY", () => { + it("opens and closes the confirmClose overlay without touching any other overlay", () => { + const opened = appReducer(createInitialState(), { + type: "OPEN_OVERLAY", + overlay: "confirmClose", + }); + expect(opened.overlays.confirmClose).toBe(true); + expect(opened.overlays.confirmQuit).toBe(false); + + const closed = appReducer(opened, { + type: "CLOSE_OVERLAY", + overlay: "confirmClose", + }); + expect(closed.overlays.confirmClose).toBe(false); + }); +}); + +describe("appReducer REQUEST_CLOSE / CONFIRM_CLOSE / CANCEL_CLOSE", () => { + it("says so when there is no open document to close", () => { + const result = appReducer(createInitialState(), { type: "REQUEST_CLOSE" }); + expect(result.status?.severity).toBe("info"); + }); + + it("closes immediately, with no confirmation overlay, when there are no unsaved changes", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const requested = appReducer(created, { type: "REQUEST_CLOSE" }); + expect(requested.openDocument).toBeUndefined(); + expect(requested.overlays.confirmClose).toBe(false); + }); + + it("opens the confirmClose overlay instead of closing outright when there are unsaved changes", () => { + const edited = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: "x", + styleId: undefined, + alignment: undefined, + }, + ]); + const requested = appReducer(edited, { type: "REQUEST_CLOSE" }); + expect(requested.overlays.confirmClose).toBe(true); + expect(requested.openDocument).toBeDefined(); + }); + + it("CONFIRM_CLOSE closes the document and its own confirmation overlay together", () => { + const edited = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: "x", + styleId: undefined, + alignment: undefined, + }, + { type: "REQUEST_CLOSE" }, + ]); + expect(edited.overlays.confirmClose).toBe(true); + + const confirmed = appReducer(edited, { type: "CONFIRM_CLOSE" }); + expect(confirmed.openDocument).toBeUndefined(); + expect(confirmed.overlays.confirmClose).toBe(false); + }); + + it("CANCEL_CLOSE dismisses the overlay and keeps the document open with its edits intact", () => { + const edited = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: "x", + styleId: undefined, + alignment: undefined, + }, + { type: "REQUEST_CLOSE" }, + ]); + + const cancelled = appReducer(edited, { type: "CANCEL_CLOSE" }); + expect(cancelled.overlays.confirmClose).toBe(false); + expect(cancelled.openDocument).toBe(edited.openDocument); + expect(cancelled.hasUnsavedChanges).toBe(true); + }); +}); + describe("appReducer SET_METADATA", () => { it("patches a real docx document's metadata through the live editor.metadata setter", () => { const created = appReducer(createInitialState(), { @@ -1422,6 +1549,36 @@ describe("appReducer undo", () => { expect(undone.status?.severity).toBe("info"); expect(undone.openDocument).toBe(created.openDocument); }); + + it("caps the undo stack at 20 snapshots, dropping the oldest ones first", () => { + let state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + for (let i = 0; i < 25; i++) { + state = appReducer(state, { + type: "APPEND_PARAGRAPH", + text: `p${i}`, + styleId: undefined, + alignment: undefined, + }); + } + expect(state.undoStack).toHaveLength(20); + + // Undoing 20 times empties the capped stack exactly -- proving the retained entries are the 20 MOST RECENT snapshots (the tail), not an arbitrary 20, since undoing keeps peeling paragraphs off the end down to a stable, non-empty prefix rather than running out early or restoring past the true starting point. + for (let i = 0; i < 20; i++) { + state = appReducer(state, { type: "UNDO" }); + } + expect(state.undoStack).toHaveLength(0); + expect(docxDocument(state).editor.paragraphs()).toHaveLength( + docxDocument( + appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }), + ).editor.paragraphs().length + 5, + ); + }); }); describe("appReducer ADD_SLIDE_TABLE", () => { @@ -1659,6 +1816,67 @@ describe("appReducer MERGE_SLIDE_TABLE_CELLS", () => { expect(result.status?.severity).toBe("warning"); expect(result.status?.text).toContain("pptx or odp"); }); + + it("rejects a non-integer or non-positive rowSpan/colSpan instead of merging anything", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const withTable = appReducer(opened, { + type: "ADD_SLIDE_TABLE", + slideIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + rows: 3, + columns: 3, + }); + + const cases: readonly [number, number][] = [ + [0, 1], // rowSpan below 1 + [1, 0], // colSpan below 1 + [1.5, 1], // rowSpan not an integer + [1, 1.5], // colSpan not an integer + ]; + for (const [rowSpan, colSpan] of cases) { + const result = appReducer(withTable, { + type: "MERGE_SLIDE_TABLE_CELLS", + slideIndex: 0, + tableIndex: 0, + startRow: 0, + startColumn: 0, + rowSpan, + colSpan, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("positive integers"); + // Rejected outright, before any cell was ever touched -- the same open document object survives untouched, not a partially-applied merge. + expect(result.openDocument).toBe(withTable.openDocument); + } + }); + + it("rejects a colSpan that overruns the table's own column count", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const withTable = appReducer(opened, { + type: "ADD_SLIDE_TABLE", + slideIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + rows: 2, + columns: 2, + }); + + const result = appReducer(withTable, { + type: "MERGE_SLIDE_TABLE_CELLS", + slideIndex: 0, + tableIndex: 0, + startRow: 0, + startColumn: 0, + rowSpan: 1, + colSpan: 3, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("exceeds this table's own 2 columns"); + expect(result.openDocument).toBe(withTable.openDocument); + }); }); describe("appReducer SET_SLIDE_NOTES on pptx", () => { From af5a9441417b04b6acff691e742afef2b32a074f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:02:13 +0100 Subject: [PATCH 067/101] refactor(document-cli): collapse pushSnapshot's undo-cap branch into one slice Array.prototype.slice(-UNDO_STACK_LIMIT) on an array no longer than the limit already returns every element unchanged, so the explicit length-check-then-slice conditional was redundant: both branches produce a new array with identical content at the one length where they could have differed, making the comparison operator itself unobservable to any test. --- packages/document-cli/src/tui/state/reducer.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/document-cli/src/tui/state/reducer.ts b/packages/document-cli/src/tui/state/reducer.ts index f4660fc44..650e2658d 100644 --- a/packages/document-cli/src/tui/state/reducer.ts +++ b/packages/document-cli/src/tui/state/reducer.ts @@ -135,14 +135,12 @@ function setOverlay( } } +// A negative slice bound is exactly as safe as the "if too long, slice; else return as-is" branch it replaces -- Array.prototype.slice(-N) on an array no longer than N returns every element, so this single expression covers both the truncating and non-truncating cases with no conditional to keep in sync with UNDO_STACK_LIMIT. function pushSnapshot( stack: readonly Uint8Array[], snapshot: Uint8Array, ): readonly Uint8Array[] { - const next = [...stack, snapshot]; - return next.length > UNDO_STACK_LIMIT - ? next.slice(next.length - UNDO_STACK_LIMIT) - : next; + return [...stack, snapshot].slice(-UNDO_STACK_LIMIT); } function documentWithPath(doc: OpenDocument, path: string): OpenDocument { From 9f787bd5554511c9fda623438526c8c41e959ff7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:03:05 +0100 Subject: [PATCH 068/101] fix(document-cli): allow doc/xls/ppt documents to undo through their own live-view editors UNDO's own format switch still treated doc/xls/ppt as the read-only, no-live-editor formats they used to be, so a document opened as one of these accumulated real undo snapshots via mutate() on every edit but could never pop one back off -- UNDO reported "read-only, so it has no history to undo" even immediately after a genuine, undoable edit. These three formats gained real live-view editors and their own reopenEditable case in the same change that widened EditableOpenDocument to include them; the UNDO exclusion list was never updated to match. --- packages/document-cli/src/tui/state/reducer.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/document-cli/src/tui/state/reducer.ts b/packages/document-cli/src/tui/state/reducer.ts index 650e2658d..64f23c6bb 100644 --- a/packages/document-cli/src/tui/state/reducer.ts +++ b/packages/document-cli/src/tui/state/reducer.ts @@ -2233,6 +2233,7 @@ export function appReducer(state: AppState, action: Action): AppState { if (doc === undefined) { return withStatus(state, "info", "There is nothing to undo"); } + // doc/xls/ppt are deliberately absent from this list: they gained real live-view editors (DocEditor/XlsEditor/PptEditor) and a reopenEditable case of their own in the same change that widened EditableOpenDocument to include them, so -- like every other EditableOpenDocument format -- they push real undo snapshots via mutate() and must be able to pop them back off here too. Only the genuinely read-only, no-live-editor formats belong in this list. if ( doc.format === "odb" || doc.format === "xlsx" || @@ -2240,9 +2241,6 @@ export function appReducer(state: AppState, action: Action): AppState { doc.format === "svg" || doc.format === "rtf" || doc.format === "wpd" || - doc.format === "doc" || - doc.format === "xls" || - doc.format === "ppt" || doc.format === "epub" ) { return withStatus( From c74d1cec9b0a175b331d84f7dba8256c72e7c7ed Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:03:18 +0100 Subject: [PATCH 069/101] test(document-cli): cover PDF item actions and every reopenEditable format branch Adds reducer-level coverage for the PDF ADD_PDF_ELLIPSE/LINE/PATH/IMAGE/ LINK actions, field edits on every non-text PDF item kind (rect, ellipse, line, path, image, link) including their own wrong-kind warning paths, the remaining SET_PDF_TEXT_* fields and the underline toggle's on/off round trip, and reopenEditable's pptx/odt/odp/ods/odg/doc/xls/ppt undo branches -- previously only docx and pdf were exercised there. --- .../src/tui/state/reducer.test.ts | 681 ++++++++++++++++++ 1 file changed, 681 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index b1c06d385..757995ae5 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -1,12 +1,16 @@ import { + createDoc, createOdg, createOdp, createOds, createOdt, createPdf, + createPpt, createPptx, + createXls, drawingOfBlock, odsToXlsx, + openDoc, openDocx, openMarkdown, openOdg, @@ -14,7 +18,9 @@ import { openOds, openOdt, openPdf, + openPpt, openPptx, + openXls, readDocxContent, readOdpContent, readOdsContent, @@ -37,12 +43,23 @@ import type { PdfOpenDocument, PptxOpenDocument, } from "./types.js"; +import { isEditableDocument } from "./types.js"; // A real, minimal PNG -- the signature bytes plus a few arbitrary trailing ones, matching docx/paragraph-detail.test.tsx's own fixture. ADD_SHEET_IMAGE only stores/embeds these bytes and declares the media part's type from the caller's own explicit `format`, so a genuine decodable pixel grid is not needed to prove the round trip. const PNG_BYTES = new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4, ]); +// A genuinely decodable 1x1 red PNG (real IHDR/IDAT/IEND chunks, truecolor, no filter). Unlike PNG_BYTES above, PdfPage.appendImage -> registerImageBytes DOES decode the pixel grid (to size the image asset it registers), so a fake signature-only PNG throws "PNG file does not begin with an IHDR chunk" here. +const REAL_PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, + 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, + 0x41, 0x54, 0x78, 0xda, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x03, 0x01, 0x01, + 0x00, 0xf7, 0x03, 0x41, 0x43, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, + 0xae, 0x42, 0x60, 0x82, +]); + function applyAll( actions: readonly Action[], from: AppState = createInitialState(), @@ -173,6 +190,39 @@ function openPdfDocument( }); } +function openDocDocument( + bytes: Uint8Array, + path = "/tmp/legacy.doc", +): AppState { + return appReducer(createInitialState(), { + type: "OPEN_FILE_SUCCESS", + path, + doc: { format: "doc", editor: openDoc(bytes), path }, + }); +} + +function openXlsDocument( + bytes: Uint8Array, + path = "/tmp/legacy.xls", +): AppState { + return appReducer(createInitialState(), { + type: "OPEN_FILE_SUCCESS", + path, + doc: { format: "xls", editor: openXls(bytes), path }, + }); +} + +function openPptDocument( + bytes: Uint8Array, + path = "/tmp/legacy.ppt", +): AppState { + return appReducer(createInitialState(), { + type: "OPEN_FILE_SUCCESS", + path, + doc: { format: "ppt", editor: openPpt(bytes), path }, + }); +} + // Real xlsx bytes with no XlsxEditor to build one directly: createOds() -> odsToXlsx() is documents.js's own PDF-bypassing bridge, reused here purely as a source of genuine xlsx bytes for the reducer tests below. function xlsxTestBytes(): Uint8Array { const editor = createOds(); @@ -1579,6 +1629,46 @@ describe("appReducer undo", () => { ).editor.paragraphs().length + 5, ); }); + + // reopenEditable's own switch has one case per EditableOpenDocument format -- docx and pdf are already exercised by the undo tests above, so this covers every remaining branch (pptx/odt/odp/ods/odg/doc/xls/ppt) the same way: mutate via SET_METADATA (the one action every editable format's own editor.metadata setter accepts identically), then undo, and prove the format survived the reopen and a genuinely fresh editor replaced the mutated one. + it.each([ + ["pptx", () => openPptxDocument(createPptx().toBytes())], + ["odt", () => openOdtDocument(createOdt().toBytes())], + ["odp", () => openOdpDocument(createOdp().toBytes())], + ["ods", () => openOdsDocument(createOds().toBytes())], + ["odg", () => openOdgDocument(createOdg().toBytes())], + ["doc", () => openDocDocument(createDoc().toBytes())], + ["xls", () => openXlsDocument(createXls().toBytes())], + ["ppt", () => openPptDocument(createPpt().toBytes())], + ] as const)( + "reopens a %s document from its undo snapshot with a fresh editor", + (format, open) => { + const opened = open(); + const mutated = appReducer(opened, { + type: "SET_METADATA", + overrides: { title: "Undo me" }, + }); + if ( + mutated.openDocument === undefined || + !isEditableDocument(mutated.openDocument) + ) { + throw new Error("expected an editable open document"); + } + expect(mutated.openDocument.format).toBe(format); + const mutatedEditor = mutated.openDocument.editor; + + const undone = appReducer(mutated, { type: "UNDO" }); + expect(undone.undoStack).toHaveLength(0); + if ( + undone.openDocument === undefined || + !isEditableDocument(undone.openDocument) + ) { + throw new Error("expected an editable open document"); + } + expect(undone.openDocument.format).toBe(format); + expect(undone.openDocument.editor).not.toBe(mutatedEditor); + }, + ); }); describe("appReducer ADD_SLIDE_TABLE", () => { @@ -2108,6 +2198,92 @@ describe("appReducer PDF item and page mutations", () => { expect(pdfDocument(undone).editor).not.toBe(pdfDocument(edited).editor); }); + it("edits a text item's font, size, rotation, width, and toggles underline on then off", () => { + const opened = openPdfDocument(pdfTestBytes()); + const withFont = appReducer(opened, { + type: "SET_PDF_TEXT_FONT", + pageIndex: 0, + itemIndex: 0, + font: { family: "Courier", weight: "bold", style: "italic" }, + }); + const withSize = appReducer(withFont, { + type: "SET_PDF_TEXT_SIZE", + pageIndex: 0, + itemIndex: 0, + sizePt: 24, + }); + const withRotation = appReducer(withSize, { + type: "SET_PDF_TEXT_ROTATION", + pageIndex: 0, + itemIndex: 0, + rotationDeg: 45, + }); + const withWidth = appReducer(withRotation, { + type: "SET_PDF_TEXT_WIDTH", + pageIndex: 0, + itemIndex: 0, + widthPt: 99, + }); + const underlineOn = appReducer(withWidth, { + type: "TOGGLE_PDF_TEXT_UNDERLINE", + pageIndex: 0, + itemIndex: 0, + }); + const onItem = pdfDocument(underlineOn).editor.page(0)?.items()[0]; + if (onItem?.kind !== "text") { + throw new Error("expected a live text item"); + } + expect(onItem.font).toStrictEqual({ + family: "Courier", + weight: "bold", + style: "italic", + }); + expect(onItem.sizePt).toBe(24); + expect(onItem.rotationDeg).toBe(45); + expect(onItem.widthPt).toBe(99); + expect(onItem.underline).toBe(true); + + const underlineOff = appReducer(underlineOn, { + type: "TOGGLE_PDF_TEXT_UNDERLINE", + pageIndex: 0, + itemIndex: 0, + }); + const offItem = pdfDocument(underlineOff).editor.page(0)?.items()[0]; + expect(offItem?.kind === "text" ? offItem.underline : undefined).toBe( + false, + ); + }); + + it("warns rather than crashing when SET_PDF_TEXT_FONT, SET_PDF_TEXT_SIZE, and SET_PDF_TEXT_ROTATION target an item of the wrong kind", () => { + const opened = openPdfDocument(pdfTestBytes()); + const withRect = appReducer(opened, { + type: "ADD_PDF_RECT", + pageIndex: 0, + init: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + }); + const fontResult = appReducer(withRect, { + type: "SET_PDF_TEXT_FONT", + pageIndex: 0, + itemIndex: 1, + font: { family: "Helvetica", weight: "normal", style: "normal" }, + }); + expect(fontResult.status?.text).toContain("not text"); + const sizeResult = appReducer(withRect, { + type: "SET_PDF_TEXT_SIZE", + pageIndex: 0, + itemIndex: 1, + sizePt: 10, + }); + expect(sizeResult.status?.text).toContain("not text"); + const rotationResult = appReducer(withRect, { + type: "SET_PDF_TEXT_ROTATION", + pageIndex: 0, + itemIndex: 1, + rotationDeg: 0, + }); + expect(rotationResult.status?.text).toContain("not text"); + }); + it("warns rather than crashing for a page index that does not exist", () => { const opened = openPdfDocument(pdfTestBytes()); const result = appReducer(opened, { @@ -2132,6 +2308,511 @@ describe("appReducer PDF item and page mutations", () => { expect(result.status?.text).toContain("not rect"); expect(result.hasUnsavedChanges).toBe(false); }); + + it("adds an ellipse via ADD_PDF_ELLIPSE, present after a toBytes()/openPdf() round trip", () => { + const opened = openPdfDocument(pdfTestBytes()); + const withEllipse = appReducer(opened, { + type: "ADD_PDF_ELLIPSE", + pageIndex: 0, + init: { + xPt: 5, + yPt: 5, + widthPt: 20, + heightPt: 10, + fill: { r: 1, g: 0, b: 0 }, + }, + }); + const reopened = openPdf(pdfDocument(withEllipse).editor.toBytes()); + const ellipse = (reopened.page(0)?.items() ?? []).find( + (item) => item.kind === "ellipse", + ); + expect(ellipse).toBeDefined(); + if (ellipse?.kind !== "ellipse") { + throw new Error("expected a real ellipse item after re-parsing"); + } + expect(ellipse.widthPt).toBeCloseTo(20, 0); + expect(ellipse.heightPt).toBeCloseTo(10, 0); + }); + + it("adds a line via ADD_PDF_LINE, present after a toBytes()/openPdf() round trip", () => { + const opened = openPdfDocument(pdfTestBytes()); + const withLine = appReducer(opened, { + type: "ADD_PDF_LINE", + pageIndex: 0, + init: { + x1Pt: 1, + y1Pt: 2, + x2Pt: 30, + y2Pt: 40, + color: { r: 0, g: 0, b: 1 }, + widthPt: 2, + }, + }); + const reopened = openPdf(pdfDocument(withLine).editor.toBytes()); + const line = (reopened.page(0)?.items() ?? []).find( + (item) => item.kind === "line", + ); + expect(line).toBeDefined(); + if (line?.kind !== "line") { + throw new Error("expected a real line item after re-parsing"); + } + expect(line.x2Pt).toBeCloseTo(30, 0); + expect(line.y2Pt).toBeCloseTo(40, 0); + }); + + it("adds a path via ADD_PDF_PATH, present after a toBytes()/openPdf() round trip", () => { + const opened = openPdfDocument(pdfTestBytes()); + const withPath = appReducer(opened, { + type: "ADD_PDF_PATH", + pageIndex: 0, + init: { + subpaths: [ + { + startXPt: 0, + startYPt: 0, + closed: true, + segments: [ + { kind: "line", xPt: 10, yPt: 0 }, + { kind: "line", xPt: 10, yPt: 10 }, + ], + }, + ], + fill: { r: 0, g: 1, b: 1 }, + }, + }); + const reopened = openPdf(pdfDocument(withPath).editor.toBytes()); + const path = (reopened.page(0)?.items() ?? []).find( + (item) => item.kind === "path", + ); + expect(path).toBeDefined(); + }); + + it("adds an image via ADD_PDF_IMAGE, present after a toBytes()/openPdf() round trip", () => { + const opened = openPdfDocument(pdfTestBytes()); + const withImage = appReducer(opened, { + type: "ADD_PDF_IMAGE", + pageIndex: 0, + init: { + xPt: 5, + yPt: 5, + widthPt: 30, + heightPt: 20, + bytes: REAL_PNG_BYTES, + format: "png", + }, + }); + const reopened = openPdf(pdfDocument(withImage).editor.toBytes()); + const image = (reopened.page(0)?.items() ?? []).find( + (item) => item.kind === "image", + ); + expect(image).toBeDefined(); + if (image?.kind !== "image") { + throw new Error("expected a real image item after re-parsing"); + } + expect(image.widthPt).toBeCloseTo(30, 0); + expect(image.heightPt).toBeCloseTo(20, 0); + }); + + it("adds a link via ADD_PDF_LINK, present after a toBytes()/openPdf() round trip", () => { + const opened = openPdfDocument(pdfTestBytes()); + const withLink = appReducer(opened, { + type: "ADD_PDF_LINK", + pageIndex: 0, + init: { + uri: "https://example.com", + xPt: 5, + yPt: 5, + widthPt: 40, + heightPt: 15, + }, + }); + const reopened = openPdf(pdfDocument(withLink).editor.toBytes()); + const link = (reopened.page(0)?.items() ?? []).find( + (item) => item.kind === "link", + ); + expect(link).toBeDefined(); + if (link?.kind !== "link") { + throw new Error("expected a real link item after re-parsing"); + } + expect(link.uri).toBe("https://example.com"); + }); + + describe("field edits on non-text pdf item kinds", () => { + // One page carrying one of every editable non-text item kind, each added through the reducer's own ADD_PDF_* actions so tests below index into a document built the same way a real session would build one. + function pdfMultiItemState(): AppState { + const opened = openPdfDocument(pdfTestBytes()); + const withRect = appReducer(opened, { + type: "ADD_PDF_RECT", + pageIndex: 0, + init: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + }); + const withEllipse = appReducer(withRect, { + type: "ADD_PDF_ELLIPSE", + pageIndex: 0, + init: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + }); + const withLine = appReducer(withEllipse, { + type: "ADD_PDF_LINE", + pageIndex: 0, + init: { + x1Pt: 0, + y1Pt: 0, + x2Pt: 10, + y2Pt: 10, + color: { r: 0, g: 0, b: 0 }, + widthPt: 1, + }, + }); + const withPath = appReducer(withLine, { + type: "ADD_PDF_PATH", + pageIndex: 0, + init: { + subpaths: [ + { + startXPt: 0, + startYPt: 0, + closed: false, + segments: [{ kind: "line", xPt: 5, yPt: 5 }], + }, + ], + }, + }); + const withImage = appReducer(withPath, { + type: "ADD_PDF_IMAGE", + pageIndex: 0, + init: { + xPt: 0, + yPt: 0, + widthPt: 10, + heightPt: 10, + bytes: REAL_PNG_BYTES, + format: "png", + }, + }); + return appReducer(withImage, { + type: "ADD_PDF_LINK", + pageIndex: 0, + init: { + uri: "https://before.example", + xPt: 0, + yPt: 0, + widthPt: 10, + heightPt: 10, + }, + }); + } + + // Item order matches pdfMultiItemState()'s own build sequence: 0 text, 1 rect, 2 ellipse, 3 line, 4 path, 5 image, 6 link. + const TEXT_INDEX = 0; + const RECT_INDEX = 1; + const ELLIPSE_INDEX = 2; + const LINE_INDEX = 3; + const PATH_INDEX = 4; + const IMAGE_INDEX = 5; + const LINK_INDEX = 6; + + it("edits a rect's frame, fill, and stroke in place", () => { + const state = pdfMultiItemState(); + const withFrame = appReducer(state, { + type: "SET_PDF_RECT_FRAME", + pageIndex: 0, + itemIndex: RECT_INDEX, + xPt: 1, + yPt: 2, + widthPt: 33, + heightPt: 44, + }); + const withFill = appReducer(withFrame, { + type: "SET_PDF_RECT_FILL", + pageIndex: 0, + itemIndex: RECT_INDEX, + fill: { r: 1, g: 0.5, b: 0 }, + }); + const withStroke = appReducer(withFill, { + type: "SET_PDF_RECT_STROKE", + pageIndex: 0, + itemIndex: RECT_INDEX, + stroke: { color: { r: 0, g: 0, b: 1 }, widthPt: 3 }, + }); + const item = pdfDocument(withStroke).editor.page(0)?.items()[RECT_INDEX]; + if (item?.kind !== "rect") { + throw new Error("expected a live rect item"); + } + expect(item.xPt).toBe(1); + expect(item.yPt).toBe(2); + expect(item.widthPt).toBe(33); + expect(item.heightPt).toBe(44); + expect(item.fill).toStrictEqual({ r: 1, g: 0.5, b: 0 }); + expect(item.stroke).toStrictEqual({ + color: { r: 0, g: 0, b: 1 }, + widthPt: 3, + }); + }); + + it("warns rather than crashing when a rect field-edit targets an item of the wrong kind", () => { + const state = pdfMultiItemState(); + const result = appReducer(state, { + type: "SET_PDF_RECT_FRAME", + pageIndex: 0, + itemIndex: ELLIPSE_INDEX, + xPt: 0, + yPt: 0, + widthPt: 1, + heightPt: 1, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("not rect"); + }); + + it("edits an ellipse's frame, fill, and stroke in place", () => { + const state = pdfMultiItemState(); + const withFrame = appReducer(state, { + type: "SET_PDF_ELLIPSE_FRAME", + pageIndex: 0, + itemIndex: ELLIPSE_INDEX, + xPt: 3, + yPt: 4, + widthPt: 22, + heightPt: 11, + }); + const withFill = appReducer(withFrame, { + type: "SET_PDF_ELLIPSE_FILL", + pageIndex: 0, + itemIndex: ELLIPSE_INDEX, + fill: { r: 0, g: 1, b: 0 }, + }); + const withStroke = appReducer(withFill, { + type: "SET_PDF_ELLIPSE_STROKE", + pageIndex: 0, + itemIndex: ELLIPSE_INDEX, + stroke: { color: { r: 1, g: 1, b: 0 }, widthPt: 2 }, + }); + const item = pdfDocument(withStroke).editor.page(0)?.items()[ + ELLIPSE_INDEX + ]; + if (item?.kind !== "ellipse") { + throw new Error("expected a live ellipse item"); + } + expect(item.widthPt).toBe(22); + expect(item.heightPt).toBe(11); + expect(item.fill).toStrictEqual({ r: 0, g: 1, b: 0 }); + expect(item.stroke).toStrictEqual({ + color: { r: 1, g: 1, b: 0 }, + widthPt: 2, + }); + }); + + it("warns rather than crashing when an ellipse field-edit targets an item of the wrong kind", () => { + const state = pdfMultiItemState(); + const result = appReducer(state, { + type: "SET_PDF_ELLIPSE_FILL", + pageIndex: 0, + itemIndex: LINE_INDEX, + fill: { r: 0, g: 0, b: 0 }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("not ellipse"); + }); + + it("edits a line's endpoints, color, and width in place", () => { + const state = pdfMultiItemState(); + const withFrom = appReducer(state, { + type: "SET_PDF_LINE_FROM", + pageIndex: 0, + itemIndex: LINE_INDEX, + x1Pt: 7, + y1Pt: 8, + }); + const withTo = appReducer(withFrom, { + type: "SET_PDF_LINE_TO", + pageIndex: 0, + itemIndex: LINE_INDEX, + x2Pt: 70, + y2Pt: 80, + }); + const withColor = appReducer(withTo, { + type: "SET_PDF_LINE_COLOR", + pageIndex: 0, + itemIndex: LINE_INDEX, + color: { r: 0.2, g: 0.3, b: 0.4 }, + }); + const withWidth = appReducer(withColor, { + type: "SET_PDF_LINE_WIDTH", + pageIndex: 0, + itemIndex: LINE_INDEX, + widthPt: 5, + }); + const item = pdfDocument(withWidth).editor.page(0)?.items()[LINE_INDEX]; + if (item?.kind !== "line") { + throw new Error("expected a live line item"); + } + expect(item.x1Pt).toBe(7); + expect(item.y1Pt).toBe(8); + expect(item.x2Pt).toBe(70); + expect(item.y2Pt).toBe(80); + expect(item.color).toStrictEqual({ r: 0.2, g: 0.3, b: 0.4 }); + expect(item.widthPt).toBe(5); + }); + + it("warns rather than crashing when a line field-edit targets an item of the wrong kind", () => { + const state = pdfMultiItemState(); + const result = appReducer(state, { + type: "SET_PDF_LINE_WIDTH", + pageIndex: 0, + itemIndex: PATH_INDEX, + widthPt: 1, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("not line"); + }); + + it("edits a path's fill, fill rule, and stroke in place", () => { + const state = pdfMultiItemState(); + const withFill = appReducer(state, { + type: "SET_PDF_PATH_FILL", + pageIndex: 0, + itemIndex: PATH_INDEX, + fill: { r: 1, g: 0, b: 1 }, + }); + const withRule = appReducer(withFill, { + type: "SET_PDF_PATH_FILL_RULE", + pageIndex: 0, + itemIndex: PATH_INDEX, + fillRule: "evenodd", + }); + const withStroke = appReducer(withRule, { + type: "SET_PDF_PATH_STROKE", + pageIndex: 0, + itemIndex: PATH_INDEX, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1.5 }, + }); + const item = pdfDocument(withStroke).editor.page(0)?.items()[PATH_INDEX]; + if (item?.kind !== "path") { + throw new Error("expected a live path item"); + } + expect(item.fill).toStrictEqual({ r: 1, g: 0, b: 1 }); + expect(item.fillRule).toBe("evenodd"); + expect(item.stroke).toStrictEqual({ + color: { r: 0, g: 0, b: 0 }, + widthPt: 1.5, + }); + }); + + it("warns rather than crashing when a path field-edit targets an item of the wrong kind", () => { + const state = pdfMultiItemState(); + const result = appReducer(state, { + type: "SET_PDF_PATH_FILL_RULE", + pageIndex: 0, + itemIndex: IMAGE_INDEX, + fillRule: "nonzero", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("not path"); + }); + + it("edits an image's frame, rotation, and source in place", () => { + const state = pdfMultiItemState(); + const withFrame = appReducer(state, { + type: "SET_PDF_IMAGE_FRAME", + pageIndex: 0, + itemIndex: IMAGE_INDEX, + xPt: 9, + yPt: 10, + widthPt: 15, + heightPt: 25, + }); + const withRotation = appReducer(withFrame, { + type: "SET_PDF_IMAGE_ROTATION", + pageIndex: 0, + itemIndex: IMAGE_INDEX, + rotationDeg: 90, + }); + const beforeItem = pdfDocument(withRotation).editor.page(0)?.items()[ + IMAGE_INDEX + ]; + if (beforeItem?.kind !== "image") { + throw new Error("expected a live image item"); + } + // Read the string value now, before the mutation below: beforeItem is a live view over the same underlying node, so its own .imageId getter would report the POST-mutation value if read only after withSource exists. + const beforeImageIdValue = beforeItem.imageId; + const withSource = appReducer(withRotation, { + type: "SET_PDF_IMAGE_SOURCE", + pageIndex: 0, + itemIndex: IMAGE_INDEX, + // A different real, decodable PNG (1x1 blue rather than REAL_PNG_BYTES' red) -- distinct content, so registerImageBytes' own dedup-by-content assigns it a genuinely different imageId, proving setImage repointed the item rather than leaving it unchanged. + bytes: new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, + 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, + 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, + 0x60, 0x60, 0xf8, 0x0f, 0x00, 0x01, 0x03, 0x01, 0x00, 0x36, 0x74, + 0x11, 0x40, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, + 0x42, 0x60, 0x82, + ]), + format: "png", + }); + const item = pdfDocument(withSource).editor.page(0)?.items()[IMAGE_INDEX]; + if (item?.kind !== "image") { + throw new Error("expected a live image item"); + } + expect(item.widthPt).toBe(15); + expect(item.heightPt).toBe(25); + expect(item.rotationDeg).toBe(90); + expect(item.imageId).not.toBe(beforeImageIdValue); + }); + + it("warns rather than crashing when an image field-edit targets an item of the wrong kind", () => { + const state = pdfMultiItemState(); + const result = appReducer(state, { + type: "SET_PDF_IMAGE_ROTATION", + pageIndex: 0, + itemIndex: LINK_INDEX, + rotationDeg: 0, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("not image"); + }); + + it("edits a link's uri and frame in place", () => { + const state = pdfMultiItemState(); + const withUri = appReducer(state, { + type: "SET_PDF_LINK_URI", + pageIndex: 0, + itemIndex: LINK_INDEX, + uri: "https://after.example", + }); + const withFrame = appReducer(withUri, { + type: "SET_PDF_LINK_FRAME", + pageIndex: 0, + itemIndex: LINK_INDEX, + xPt: 11, + yPt: 12, + widthPt: 50, + heightPt: 20, + }); + const item = pdfDocument(withFrame).editor.page(0)?.items()[LINK_INDEX]; + if (item?.kind !== "link") { + throw new Error("expected a live link item"); + } + expect(item.uri).toBe("https://after.example"); + expect(item.xPt).toBe(11); + expect(item.yPt).toBe(12); + expect(item.widthPt).toBe(50); + expect(item.heightPt).toBe(20); + }); + + it("warns rather than crashing when a link field-edit targets an item of the wrong kind", () => { + const state = pdfMultiItemState(); + const result = appReducer(state, { + type: "SET_PDF_LINK_URI", + pageIndex: 0, + itemIndex: TEXT_INDEX, + uri: "https://wrong.example", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("not link"); + }); + }); }); describe("appReducer ADD_RECT / ADD_ELLIPSE / ADD_LINE / ADD_PATH on odp", () => { From 074f24663452874be8c772271e54f1101c57b548 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:14:53 +0100 Subject: [PATCH 070/101] test(document-cli): cover odb-to-xlsx, odb-to-csv, and odb-tables These three commands had no test coverage at all: default and explicit output path resolution, the --out flag, the conflicting-destination usage error (including the equal-values non-conflict case), --table selection and its not-found error, and odb-tables' plain-text and --json report forms. --- .../document-cli/src/commands/odb.test.ts | 221 +++++++++++++++++- 1 file changed, 220 insertions(+), 1 deletion(-) diff --git a/packages/document-cli/src/commands/odb.test.ts b/packages/document-cli/src/commands/odb.test.ts index 16d12bbde..40823d450 100644 --- a/packages/document-cli/src/commands/odb.test.ts +++ b/packages/document-cli/src/commands/odb.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { openDocx, openOdt } from "documents.js"; @@ -80,6 +80,225 @@ beforeEach(() => { afterEach(() => { process.exitCode = savedExitCode; + // Every real runOdb* call registers its own SIGINT listener via createRuntimeSignal and never removes it -- harmless for a real one-shot CLI process, but this file alone now drives enough real invocations in one vitest worker to cross Node's default MaxListeners (10) and print a warning straight to the captured stderr some of the tests above assert is empty (the same fix outline.test.ts already applies for the identical reason). + process.removeAllListeners("SIGINT"); +}); + +describe("odb-to-xlsx", () => { + let workspace: string; + + beforeAll(async () => { + workspace = await mkdtemp(join(tmpdir(), "document-cli-odb-to-xlsx-")); + }); + + afterAll(async () => { + await rm(workspace, { recursive: true, force: true }); + }); + + it("extracts the fixture's own SALES table into one xlsx workbook sheet, at the default output path when neither a positional output nor --out is given", async () => { + const input = join(workspace, "default-name.odb"); + await writeFile(input, await readFile(FORM_AND_REPORT_ODB_PATH)); + const { exitCode, stderr } = await runCli(["odb-to-xlsx", input]); + + expect(exitCode).toBe(EXIT_SUCCESS); + expect(stderr).toContain("wrote"); + const output = join(workspace, "default-name.xlsx"); + const bytes = await readFile(output); + expect(bytes.byteLength).toBeGreaterThan(0); + }); + + it("writes to an explicit positional output path", async () => { + const output = join(workspace, "explicit.xlsx"); + const { exitCode } = await runCli([ + "odb-to-xlsx", + FORM_AND_REPORT_ODB_PATH, + output, + ]); + + expect(exitCode).toBe(EXIT_SUCCESS); + const bytes = await readFile(output); + expect(bytes.byteLength).toBeGreaterThan(0); + }); + + it("writes to the path named by --out when no positional output is given", async () => { + const output = join(workspace, "via-out-flag.xlsx"); + const { exitCode } = await runCli([ + "odb-to-xlsx", + FORM_AND_REPORT_ODB_PATH, + "--out", + output, + ]); + + expect(exitCode).toBe(EXIT_SUCCESS); + const bytes = await readFile(output); + expect(bytes.byteLength).toBeGreaterThan(0); + }); + + it("succeeds when the positional output and --out agree", async () => { + const output = join(workspace, "agreeing.xlsx"); + const { exitCode, stderr } = await runCli([ + "odb-to-xlsx", + FORM_AND_REPORT_ODB_PATH, + output, + "--out", + output, + ]); + + expect(exitCode).toBe(EXIT_SUCCESS); + expect(stderr).not.toContain("conflicting output destinations"); + }); + + it("fails with a usage error when the positional output and --out disagree", async () => { + const { exitCode, stderr } = await runCli([ + "odb-to-xlsx", + FORM_AND_REPORT_ODB_PATH, + join(workspace, "one.xlsx"), + "--out", + join(workspace, "other.xlsx"), + ]); + + expect(exitCode).toBe(EXIT_USAGE_ERROR); + expect(stderr).toContain("conflicting output destinations"); + expect(stderr).toContain("one.xlsx"); + expect(stderr).toContain("other.xlsx"); + }); +}); + +describe("odb-to-csv", () => { + let workspace: string; + + beforeAll(async () => { + workspace = await mkdtemp(join(tmpdir(), "document-cli-odb-to-csv-")); + }); + + afterAll(async () => { + await rm(workspace, { recursive: true, force: true }); + }); + + it("exports the fixture's own sole table to CSV at the default output path derived from the input's own name", async () => { + const input = join(workspace, "sales.odb"); + await writeFile(input, await readFile(FORM_AND_REPORT_ODB_PATH)); + const { exitCode, stderr } = await runCli(["odb-to-csv", input]); + + expect(exitCode).toBe(EXIT_SUCCESS); + expect(stderr).toContain("wrote"); + const csv = await readFile(join(workspace, "sales.csv"), "utf8"); + expect(csv).toContain("Acme Ltd"); + }); + + it("writes to an explicit positional output path", async () => { + const output = join(workspace, "explicit.csv"); + const { exitCode } = await runCli([ + "odb-to-csv", + FORM_AND_REPORT_ODB_PATH, + output, + ]); + + expect(exitCode).toBe(EXIT_SUCCESS); + const csv = await readFile(output, "utf8"); + expect(csv).toContain("Acme Ltd"); + }); + + it("writes to the path named by --out when no positional output is given", async () => { + const output = join(workspace, "via-out-flag.csv"); + const { exitCode } = await runCli([ + "odb-to-csv", + FORM_AND_REPORT_ODB_PATH, + "--out", + output, + ]); + + expect(exitCode).toBe(EXIT_SUCCESS); + const csv = await readFile(output, "utf8"); + expect(csv).toContain("Acme Ltd"); + }); + + it("succeeds when the positional output and --out agree", async () => { + const output = join(workspace, "agreeing.csv"); + const { exitCode, stderr } = await runCli([ + "odb-to-csv", + FORM_AND_REPORT_ODB_PATH, + output, + "--out", + output, + ]); + + expect(exitCode).toBe(EXIT_SUCCESS); + expect(stderr).not.toContain("conflicting output destinations"); + }); + + it("fails with a usage error when the positional output and --out disagree", async () => { + const { exitCode, stderr } = await runCli([ + "odb-to-csv", + FORM_AND_REPORT_ODB_PATH, + join(workspace, "one.csv"), + "--out", + join(workspace, "other.csv"), + ]); + + expect(exitCode).toBe(EXIT_USAGE_ERROR); + expect(stderr).toContain("conflicting output destinations"); + }); + + it("exports the table named by --table", async () => { + const output = join(workspace, "by-name.csv"); + const { exitCode } = await runCli([ + "odb-to-csv", + FORM_AND_REPORT_ODB_PATH, + output, + "--table", + "SALES", + ]); + + expect(exitCode).toBe(EXIT_SUCCESS); + const csv = await readFile(output, "utf8"); + expect(csv).toContain("Acme Ltd"); + }); + + it("fails naming the available tables when --table names one the .odb does not declare", async () => { + const { exitCode, stderr } = await runCli([ + "odb-to-csv", + FORM_AND_REPORT_ODB_PATH, + join(workspace, "never-written.csv"), + "--table", + "NO_SUCH_TABLE", + ]); + + expect(exitCode).not.toBe(EXIT_SUCCESS); + expect(stderr).toContain("NO_SUCH_TABLE"); + expect(stderr).toContain( + "run 'odb-tables' first to see the available tables", + ); + }); +}); + +describe("odb-tables", () => { + it("prints the fixture's own table name, column names/types, and row count as a human-readable report", async () => { + const { exitCode, stdout, stderr } = await runCli([ + "odb-tables", + FORM_AND_REPORT_ODB_PATH, + ]); + + expect(stderr).toBe(""); + expect(exitCode).toBe(EXIT_SUCCESS); + expect(stdout).toContain("SALES (6 rows)"); + expect(stdout).toContain("AMOUNT:"); + expect(stdout).toContain("CUSTOMER:"); + }); + + it("emits the same structure as parseable JSON under --json", async () => { + const { exitCode, stdout } = await runCli([ + "odb-tables", + FORM_AND_REPORT_ODB_PATH, + "--json", + ]); + + expect(exitCode).toBe(EXIT_SUCCESS); + const parsed: unknown = JSON.parse(stdout); + expect(Array.isArray(parsed)).toBe(true); + expect(stdout).toContain('"tableName":"SALES"'); + expect(stdout).toContain('"rowCount":6'); + }); }); describe("odb-forms", () => { From 6495c4e568ba90ef7b75b5300352c4a937b4ea5e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:15:04 +0100 Subject: [PATCH 071/101] test(document-cli): cover pdf-inspect's default and --json report modes Only --full had any coverage. Adds the plain-text report (page count singular/plural, the per-page item-kind histogram and its empty-page omission, the metadata and images-by-format sections and their all-empty omission), the --json summary shape, and the non-PDF-input error path. --- .../src/commands/pdf-inspect.test.ts | 112 +++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/packages/document-cli/src/commands/pdf-inspect.test.ts b/packages/document-cli/src/commands/pdf-inspect.test.ts index f20d18a7a..6a5ca8f0a 100644 --- a/packages/document-cli/src/commands/pdf-inspect.test.ts +++ b/packages/document-cli/src/commands/pdf-inspect.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createDocx, docxToPdf, readPdf } from "documents.js"; +import { createDocx, createPdf, docxToPdf, readPdf } from "documents.js"; import { afterAll, afterEach, @@ -82,6 +82,116 @@ afterEach(() => { process.exitCode = savedExitCode; }); +// A genuinely decodable 1x1 red PNG (real IHDR/IDAT/IEND chunks) -- appendImage decodes the pixel grid to size the image asset it registers, so a fake signature-only PNG would throw rather than produce a real "png" entry in imagesByFormat. +const REAL_PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, + 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, + 0x41, 0x54, 0x78, 0xda, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x03, 0x01, 0x01, + 0x00, 0xf7, 0x03, 0x41, 0x43, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, + 0xae, 0x42, 0x60, 0x82, +]); + +// Two pages, one text item and one rect on page 1 (a mixed item-kind histogram), one image on page 2 (a real, decodable PNG so it survives round-trip and populates imagesByFormat), plus document metadata -- enough to exercise every branch of runPdfInspect's default and --json report paths (multi-page pluralisation, a non-empty histogram, the metadata section, and the images section). +function multiPagePdfBytes(): Uint8Array { + const editor = createPdf(); + editor.metadata = { title: "Inspectable", author: "Test Suite" }; + const page0 = editor.pages()[0]; + if (page0 === undefined) { + throw new Error("createPdf() always seeds one page"); + } + page0.appendText({ + xPt: 10, + yPt: 20, + text: "Hello", + font: { family: "Helvetica", weight: "normal", style: "normal" }, + sizePt: 12, + color: { r: 0, g: 0, b: 0 }, + }); + page0.appendRect({ + xPt: 5, + yPt: 5, + widthPt: 20, + heightPt: 20, + fill: { r: 1, g: 0, b: 0 }, + }); + const page1 = editor.appendPage(); + page1.appendImage({ + xPt: 0, + yPt: 0, + widthPt: 30, + heightPt: 30, + bytes: REAL_PNG_BYTES, + format: "png", + }); + return editor.toBytes(); +} + +describe("pdf-inspect (default plain-text report)", () => { + it("reports the page count, per-page size and item-kind histogram, metadata, and images by format", async () => { + const pdfPath = join(workspace, "multi-page.pdf"); + await writeFile(pdfPath, multiPagePdfBytes()); + + const { exitCode, stdout, stderr } = await runCli(["pdf-inspect", pdfPath]); + + expect(stderr).toBe(""); + expect(exitCode).toBe(EXIT_SUCCESS); + expect(stdout).toContain("2 pages"); + expect(stdout).toMatch(/page 1: .*\(.*text=1.*rect=1.*\)/); + expect(stdout).toContain("page 2:"); + expect(stdout).toContain("metadata:"); + expect(stdout).toContain("Inspectable"); + expect(stdout).toContain("images:"); + expect(stdout).toContain("png: 1"); + }); + + it("uses the singular '1 page' and omits the histogram parenthetical for a page with no items, and omits the images section when the document embeds none", async () => { + const pdfPath = join(workspace, "single-empty-page.pdf"); + await writeFile(pdfPath, createPdf().toBytes()); + + const { exitCode, stdout } = await runCli(["pdf-inspect", pdfPath]); + + expect(exitCode).toBe(EXIT_SUCCESS); + expect(stdout).toContain("1 page\n"); + expect(stdout).toContain("page 1: 612pt x 792pt\n"); + expect(stdout).not.toContain("()"); + expect(stdout).not.toContain("images:"); + }); + + it("reports an error and a non-zero exit code for input that is not a real PDF", async () => { + const pdfPath = join(workspace, "not-a-pdf.pdf"); + await writeFile(pdfPath, new Uint8Array([1, 2, 3, 4])); + + const { exitCode, stderr } = await runCli(["pdf-inspect", pdfPath]); + + expect(exitCode).not.toBe(EXIT_SUCCESS); + expect(stderr).not.toBe(""); + }); +}); + +describe("pdf-inspect --json", () => { + it("emits the page/histogram/metadata/image summary as parseable JSON", async () => { + const pdfPath = join(workspace, "multi-page-json.pdf"); + await writeFile(pdfPath, multiPagePdfBytes()); + + const { exitCode, stdout, stderr } = await runCli([ + "pdf-inspect", + pdfPath, + "--json", + ]); + + expect(stderr).toBe(""); + expect(exitCode).toBe(EXIT_SUCCESS); + const parsed: unknown = JSON.parse(stdout); + expect(parsed).toMatchObject({ + pageCount: 2, + pages: [{ itemKinds: { text: 1, rect: 1 } }, { itemKinds: { image: 1 } }], + imagesByFormat: { png: 1 }, + }); + expect(stdout).toContain('"title":"Inspectable"'); + }); +}); + describe("pdf-inspect --full", () => { it("writes the complete parsed LayoutDocument as plain untagged JSON, matching a direct readPdf of the same bytes", async () => { const { exitCode, stdout, stderr } = await runCli([ From 690ded66cf01ad6753e2ab60ab9ac0ceefda2b98 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:31:24 +0100 Subject: [PATCH 072/101] test(document-cli): cover from-package's output-conflict, csv/svg targets, and version-gate errors Adds coverage for the positional/--out conflict and agreement checks, the --out flag on its own, a genuine SchemaVersionMismatchError (a document-tree.schema.json dump pinned to a major other than the installed one, distinct from the rename/demotion tombstones already covered), invalid-UTF-8 input bytes, and the csv/svg target branches with --delimiter/--page threaded through, plus --json/--quiet output. --- .../src/commands/from-package.test.ts | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/packages/document-cli/src/commands/from-package.test.ts b/packages/document-cli/src/commands/from-package.test.ts index a1a076af3..86378b4c9 100644 --- a/packages/document-cli/src/commands/from-package.test.ts +++ b/packages/document-cli/src/commands/from-package.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createDocx, + createOdg, createOds, decodeDocumentPackage, openDocx, @@ -258,6 +259,203 @@ describe("from-package", () => { }); }); + it("fails with a usage error when the positional output and --out disagree, and succeeds when they agree", async () => { + const packagePath = join(workspace, "dumped-for-conflict.package.json"); + await runCli([ + "docx-to-pdf", + join(workspace, "source.docx"), + join(workspace, "unused-conflict.pdf"), + "--dump-package", + packagePath, + ]); + + const disagreeing = await runCli([ + "from-package", + packagePath, + join(workspace, "one.docx"), + "--out", + join(workspace, "other.docx"), + ]); + expect(disagreeing.exitCode).not.toBe(EXIT_SUCCESS); + expect(disagreeing.stderr).toContain("[from-package]"); + expect(disagreeing.stderr).toContain("conflicting output destinations"); + + const agreedPath = join(workspace, "agreed.docx"); + const agreeing = await runCli([ + "from-package", + packagePath, + agreedPath, + "--out", + agreedPath, + ]); + expect(agreeing.exitCode).toBe(EXIT_SUCCESS); + expect(agreeing.stderr).not.toContain("conflicting output destinations"); + }); + + it("writes to the path named by --out when no positional output is given", async () => { + const packagePath = join(workspace, "dumped-for-out-flag.package.json"); + await runCli([ + "docx-to-pdf", + join(workspace, "source.docx"), + join(workspace, "unused-out-flag.pdf"), + "--dump-package", + packagePath, + ]); + + const output = join(workspace, "via-out-flag.docx"); + const { exitCode } = await runCli([ + "from-package", + packagePath, + "--out", + output, + ]); + expect(exitCode).toBe(EXIT_SUCCESS); + const rebuilt = openDocx(new Uint8Array(await readFile(output))); + expect( + rebuilt + .paragraphs() + .some((paragraph) => paragraph.text === PARAGRAPH_TEXT), + ).toBe(true); + }); + + it("builds real csv output from a spreadsheet-kind DocumentTree, threading --delimiter through", async () => { + const sheetPath = join(workspace, "source-for-csv.ods"); + const editor = createOds(); + const sheet = editor.sheets()[0]; + if (sheet === undefined) { + throw new Error("createOds() did not produce a default sheet"); + } + sheet.cell(0, 0).value = { kind: "string", value: "A" }; + sheet.cell(0, 1).value = { kind: "string", value: "B" }; + sheet.setColumnWidth(0, 72); + sheet.setColumnWidth(1, 72); + sheet.setRowHeight(0, 14); + await writeFile(sheetPath, editor.toBytes()); + + const packagePath = join(workspace, "dumped-for-csv.package.json"); + await runCli([ + "ods-to-pdf", + sheetPath, + join(workspace, "unused-csv.pdf"), + "--dump-package", + packagePath, + ]); + + const csvPath = join(workspace, "rebuilt.csv"); + const csvRun = await runCli([ + "from-package", + packagePath, + csvPath, + "--delimiter", + ";", + ]); + expect(csvRun.exitCode).toBe(EXIT_SUCCESS); + const csvText = await readFile(csvPath, "utf-8"); + expect(csvText).toContain("A;B"); + }); + + it("builds real svg output from a drawing-kind DocumentTree, threading --page through", async () => { + const drawingPath = join(workspace, "source-for-svg.odg"); + const editor = createOdg(); + editor.addPage(); + editor.pages()[0]?.addRect({ + frame: { xPt: 5, yPt: 5, widthPt: 40, heightPt: 30 }, + fill: { r: 1, g: 0, b: 0 }, + }); + await writeFile(drawingPath, editor.toBytes()); + + const packagePath = join(workspace, "dumped-for-svg.package.json"); + await runCli([ + "odg-to-pdf", + drawingPath, + join(workspace, "unused-svg.pdf"), + "--dump-package", + packagePath, + ]); + + const svgPath = join(workspace, "rebuilt.svg"); + const svgRun = await runCli([ + "from-package", + packagePath, + svgPath, + "--page", + "0", + ]); + expect(svgRun.exitCode).toBe(EXIT_SUCCESS); + const svgText = await readFile(svgPath, "utf-8"); + expect(svgText).toContain(" { + const packagePath = join(workspace, "dumped-for-json-quiet.package.json"); + await runCli([ + "docx-to-pdf", + join(workspace, "source.docx"), + join(workspace, "unused-json-quiet.pdf"), + "--dump-package", + packagePath, + ]); + + const jsonOutput = join(workspace, "via-json.docx"); + const jsonRun = await runCli([ + "from-package", + packagePath, + jsonOutput, + "--json", + ]); + expect(jsonRun.exitCode).toBe(EXIT_SUCCESS); + const summary: unknown = JSON.parse(jsonRun.stderr); + expect(summary).toMatchObject({ output: jsonOutput }); + + const quietOutput = join(workspace, "via-quiet.docx"); + const quietRun = await runCli([ + "from-package", + packagePath, + quietOutput, + "--quiet", + ]); + expect(quietRun.exitCode).toBe(EXIT_SUCCESS); + expect(quietRun.stderr).toBe(""); + }); + + it("rejects input bytes that are not valid UTF-8", async () => { + const invalidUtf8Path = join(workspace, "invalid-utf8.package.json"); + // A lone continuation byte (0x80) is never valid at the start of a UTF-8 sequence -- TextDecoder("utf-8", { fatal: true }) throws on it rather than silently substituting U+FFFD. + await writeFile(invalidUtf8Path, new Uint8Array([0x7b, 0x80, 0x7d])); + + const { exitCode, stderr } = await runCli([ + "from-package", + invalidUtf8Path, + join(workspace, "never-written-utf8.docx"), + ]); + + expect(exitCode).not.toBe(EXIT_SUCCESS); + expect(stderr).toContain("not valid"); + }); + + it("rejects a DocumentTree dump whose $schema pins a document-schema.js major other than the installed one", async () => { + const mismatchPath = join(workspace, "version-mismatch.package.json"); + // A real document-tree.schema.json $schema URI (so it clears the rename/demotion tombstones and reaches the version gate) pinned to major 6 -- a major this workspace's installed document-schema.js (7.x) never was, so it can never accidentally stop mismatching the way a hardcoded "installed - 1" could coincide with a real future install. + const mismatchDump = { + $schema: + "https://cdn.jsdelivr.net/npm/document-schema.js@6.0.0/schemas/document-tree.schema.json", + children: [], + }; + await writeFile(mismatchPath, JSON.stringify(mismatchDump, undefined, 2)); + + const { exitCode, stderr } = await runCli([ + "from-package", + mismatchPath, + join(workspace, "never-written-mismatch.docx"), + ]); + + expect(exitCode).not.toBe(EXIT_SUCCESS); + expect(stderr).toContain("document-schema.js@6.0.0"); + expect(stderr).toContain("reads only @"); + expect(stderr).toContain("-major dumps"); + expect(stderr).toContain("--dump-package"); + }); + it("rejects a plain JSON file with no recognised $schema", async () => { const plainPath = join(workspace, "plain.json"); await writeFile(plainPath, JSON.stringify({ hello: "world" })); From fe5e74d6ca88c03f7ceccef385b3dbf990eebcc4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:38:51 +0100 Subject: [PATCH 073/101] test(document-cli): cover cli-main's bare/tui/command dispatch cli-main.ts had no test coverage at all. Adds the bare-invocation TTY gate (launches the TUI vs. shows help), the explicit 'tui' token's own TTY refusal and start-path resolution (skipping leading flag tokens), launchTui's own error-to-exit-code mapping, and both the registered 'tui [file]' subcommand and the CommanderError-vs-genuine-bug split in the ordinary command dispatch path. --- packages/document-cli/src/cli-main.test.ts | 162 +++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 packages/document-cli/src/cli-main.test.ts diff --git a/packages/document-cli/src/cli-main.test.ts b/packages/document-cli/src/cli-main.test.ts new file mode 100644 index 000000000..46ef1bd39 --- /dev/null +++ b/packages/document-cli/src/cli-main.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + EXIT_INPUT_ERROR, + EXIT_SUCCESS, + EXIT_USAGE_ERROR, +} from "./runtime/exit-codes"; +import * as programModule from "./program"; +import { main } from "./cli-main"; + +// runTui itself (a real Ink render against a real terminal) is exercised by src/tui/*.test.tsx -- this file's own subject is cli-main.ts's dispatch logic around it: which of the three paths (bare invocation, an explicit 'tui' token, or an ordinary registered command) main() takes, how each computes the TUI's own startPath, TTY-gating, and how launchTui's own success/failure maps to an exit code. runTui is mocked throughout so no real Ink instance is ever rendered here. +const runTuiMock = + vi.fn<(options: { readonly startPath?: string }) => Promise>(); +vi.mock("./tui/index.js", () => ({ + runTui: (options: { readonly startPath?: string }) => runTuiMock(options), +})); + +describe("main", () => { + const originalArgv = process.argv; + const originalExitCode = process.exitCode; + const originalIsTTY = process.stdout.isTTY; + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; + + beforeEach(() => { + runTuiMock.mockReset(); + runTuiMock.mockResolvedValue(undefined); + stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + stdoutSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + }); + + afterEach(() => { + process.argv = originalArgv; + process.exitCode = originalExitCode; + process.stdout.isTTY = originalIsTTY; + process.removeAllListeners("SIGINT"); + vi.restoreAllMocks(); + }); + + it("launches the TUI on a bare invocation when stdout is a TTY, with no start path", async () => { + process.stdout.isTTY = true; + process.argv = ["node", "document-cli"]; + + await main(); + + expect(runTuiMock).toHaveBeenCalledTimes(1); + expect(runTuiMock).toHaveBeenCalledWith( + expect.objectContaining({ startPath: undefined }), + ); + expect(process.exitCode).toBe(EXIT_SUCCESS); + }); + + it("shows help and exits successfully on a bare invocation when stdout is not a TTY, without launching the TUI", async () => { + process.stdout.isTTY = false; + process.argv = ["node", "document-cli"]; + + await main(); + + expect(runTuiMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(EXIT_SUCCESS); + expect(stdoutSpy).toHaveBeenCalledWith( + expect.stringContaining("document-cli"), + ); + }); + + it("refuses an explicit 'tui' invocation with a usage error when stdout is not a TTY", async () => { + process.stdout.isTTY = false; + process.argv = ["node", "document-cli", "tui"]; + + await main(); + + expect(runTuiMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(EXIT_USAGE_ERROR); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining("requires an interactive terminal"), + ); + }); + + it("launches the TUI for an explicit 'tui' invocation with a TTY, resolving the start path from the first non-flag argument", async () => { + process.stdout.isTTY = true; + process.argv = ["node", "document-cli", "tui", "--foo", "somefile.docx"]; + + await main(); + + expect(runTuiMock).toHaveBeenCalledWith( + expect.objectContaining({ startPath: "somefile.docx" }), + ); + expect(process.exitCode).toBe(EXIT_SUCCESS); + }); + + it("launches the TUI for an explicit 'tui' invocation with no file argument, leaving the start path undefined", async () => { + process.stdout.isTTY = true; + process.argv = ["node", "document-cli", "tui"]; + + await main(); + + expect(runTuiMock).toHaveBeenCalledWith( + expect.objectContaining({ startPath: undefined }), + ); + }); + + it("reports EXIT_INPUT_ERROR and the formatted error when runTui itself rejects with a framework-level failure", async () => { + process.stdout.isTTY = true; + process.argv = ["node", "document-cli"]; + runTuiMock.mockRejectedValue(new Error("ink blew up")); + + await main(); + + expect(process.exitCode).toBe(EXIT_INPUT_ERROR); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining("ink blew up"), + ); + }); + + it("dispatches an ordinary registered command through the assembled program rather than the TUI", async () => { + process.argv = ["node", "document-cli", "--help"]; + + await main(); + + expect(runTuiMock).not.toHaveBeenCalled(); + expect(stdoutSpy).toHaveBeenCalledWith( + expect.stringContaining("Commands:"), + ); + }); + + it("registers a 'tui [file]' subcommand on the assembled program that also launches the TUI", async () => { + process.argv = ["node", "document-cli", "tui-registration-probe"]; + // dispatchToken is neither undefined nor "tui", so main() takes the else branch that registers 'tui [file]' on a fresh createProgram() result before parsing -- calling createProgram() directly afterwards, as this test does below, would build a SEPARATE program without that registration. Spy on it instead so this test observes the exact program instance main() itself builds and registers against. + const createProgramSpy = vi.spyOn(programModule, "createProgram"); + await main(); + const registeredProgram = createProgramSpy.mock.results[0]?.value as + ReturnType | undefined; + if (registeredProgram === undefined) { + throw new Error("expected main() to have called createProgram()"); + } + + await registeredProgram.parseAsync([ + "node", + "document-cli", + "tui", + "registered-file.docx", + ]); + + expect(runTuiMock).toHaveBeenCalledWith( + expect.objectContaining({ startPath: "registered-file.docx" }), + ); + }); + + it("propagates a non-CommanderError bug from a registered action instead of swallowing it", async () => { + const brokenProgram = programModule.createProgram(); + brokenProgram.command("boom").action(() => { + throw new Error("boom"); + }); + vi.spyOn(programModule, "createProgram").mockReturnValue(brokenProgram); + process.argv = ["node", "document-cli", "boom"]; + + await expect(main()).rejects.toThrow("boom"); + }); +}); From 7befd7b75d45b1f2c3c73ec51cfab2542e9e70e0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:46:40 +0100 Subject: [PATCH 074/101] test(document-cli): cover ADD_LIST_ITEM's docx/markdown paragraph-copy branch The odt branch (a real OdtList) already had its own describe block; this action's other branch -- appending a new paragraph and copying the anchor paragraph's own list membership, the docx/markdown path with no separate list object -- had no dispatch anywhere, including its two warning paths (no paragraph at blockIndex, and an anchor not part of a list). --- .../src/tui/state/reducer.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 757995ae5..28a30d39a 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -964,6 +964,69 @@ describe("appReducer SET_LIST_ITEM_TEXT on odt", () => { }); }); +describe("appReducer ADD_LIST_ITEM on docx", () => { + // docx (and markdown) have no separate "list" object the way odt does -- list membership is flat per-paragraph metadata, so ADD_LIST_ITEM's own docx/markdown branch appends a brand-new paragraph and copies the anchor paragraph's own ContentListMembership onto it, rather than extending an OdtList (the odt branch this file's own "ADD_LIST on odt"/"INDENT_LIST_ITEM on odt" describe blocks already cover). + it("appends a new paragraph copying the anchor paragraph's own list membership", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const doc = docxDocument(created); + const anchor = doc.editor.body.appendParagraph({ text: "First item" }); + anchor.list = { level: 0, numId: "7" }; + const anchorIndex = doc.editor.paragraphs().length - 1; + + const added = appReducer(created, { + type: "ADD_LIST_ITEM", + blockIndex: anchorIndex, + text: "Second item", + }); + + const paragraphs = docxDocument(added).editor.paragraphs(); + const appended = paragraphs[paragraphs.length - 1]; + expect(appended?.text).toBe("Second item"); + expect(appended?.list).toStrictEqual({ level: 0, numId: "7" }); + expect(added.hasUnsavedChanges).toBe(true); + }); + + it("warns rather than crashing when blockIndex names no paragraph", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + + const result = appReducer(created, { + type: "ADD_LIST_ITEM", + blockIndex: 999, + text: "x", + }); + + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("no paragraph"); + expect(result.hasUnsavedChanges).toBe(false); + }); + + it("warns rather than crashing when the anchor paragraph is not part of a list", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const doc = docxDocument(created); + doc.editor.body.appendParagraph({ text: "Not a list item" }); + const anchorIndex = doc.editor.paragraphs().length - 1; + + const result = appReducer(created, { + type: "ADD_LIST_ITEM", + blockIndex: anchorIndex, + text: "x", + }); + + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("not part of a list"); + expect(result.hasUnsavedChanges).toBe(false); + }); +}); + describe("appReducer ADD_LIST on odt", () => { it("creates a real, brand-new, empty list, navigable through the existing listEditor screen", () => { const created = appReducer(createInitialState(), { From 7af428ae763f91929e72ce9ea74ee22c9b235426 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:52:14 +0100 Subject: [PATCH 075/101] test(document-cli): cover odm-to-pdf's --out flag and font-substitution reporting --out on its own (only the positional-vs-flag conflict was covered before) and the reportFontSubstitution branch (a Calibri-styled chapter substituted to the vendored carlito face under --report-font-substitutions, and silent without it), mirroring the identical scenario convert-fonts.test.ts already covers for the other -to-pdf commands. --- .../document-cli/src/commands/odm.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/document-cli/src/commands/odm.test.ts b/packages/document-cli/src/commands/odm.test.ts index 2fca09bb4..1426eec5f 100644 --- a/packages/document-cli/src/commands/odm.test.ts +++ b/packages/document-cli/src/commands/odm.test.ts @@ -2,6 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createOdt } from "documents.js"; +import { FIXTURE_FONT_FAMILY } from "../test-support/font-fixture"; import { afterAll, afterEach, @@ -69,6 +70,20 @@ beforeAll(async () => { const chapter = createOdt(); chapter.body.appendParagraph().appendRun({ text: "Chapter content" }); await writeFile(join(workspace, "chapter1.odt"), chapter.toBytes()); + + await writeFile( + join(workspace, "book-calibri.odm"), + singleChapterOdmBytes("calibri-chapter.odt"), + ); + const calibriChapter = createOdt(); + calibriChapter.body.appendParagraph().appendRun({ + text: "A paragraph set in Calibri", + fontFamily: FIXTURE_FONT_FAMILY, + }); + await writeFile( + join(workspace, "calibri-chapter.odt"), + calibriChapter.toBytes(), + ); }); afterAll(async () => { @@ -181,6 +196,46 @@ describe("odm-to-pdf", () => { }); }); + it("writes to the path named by --out when no positional output is given", async () => { + const output = join(workspace, "via-out-flag.pdf"); + const { exitCode } = await runCli([ + "odm-to-pdf", + join(workspace, "book.odm"), + "--out", + output, + "--chapters-dir", + workspace, + ]); + expect(exitCode).toBe(EXIT_SUCCESS); + const bytes = new Uint8Array(await readFile(output)); + expect(bytes.byteLength).toBeGreaterThan(0); + }); + + it("prints a font-substitution event under --report-font-substitutions, and stays silent without it", async () => { + const reported = await runCli([ + "odm-to-pdf", + join(workspace, "book-calibri.odm"), + join(workspace, "reported.pdf"), + "--chapters-dir", + workspace, + "--report-font-substitutions", + ]); + expect(reported.exitCode).toBe(EXIT_SUCCESS); + expect(reported.stderr).toContain( + '[odm-to-pdf] font substitution: "Calibri" -> "carlito" (vendored-substitute)', + ); + + const silent = await runCli([ + "odm-to-pdf", + join(workspace, "book-calibri.odm"), + join(workspace, "silent.pdf"), + "--chapters-dir", + workspace, + ]); + expect(silent.exitCode).toBe(EXIT_SUCCESS); + expect(silent.stderr).not.toContain("font substitution"); + }); + it("registers odm-to-pdf with its own description and every conversion/font/chapter option", () => { const command = createProgram().commands.find( (candidate) => candidate.name() === "odm-to-pdf", From 90d4ef072ccddef8b081c9dee5b8d8ab95f3600d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:01:23 +0100 Subject: [PATCH 076/101] refactor(document-cli): rebuild formula presets per-call instead of a module constant Stryker's per-test coverage instrumentation attributes a module-scope constant's one-time initialisation to whichever test happens to trigger the very first import of the module across the whole suite, since module caching means every later importer just reads the already-built array. A mutant in the literal was therefore only ever re-verified against that unrelated first-importing test, never against formula-presets.test.ts's own assertions. Rebuilding the array inside a function makes each call site's own execution the thing coverage attributes to, so this file's exhaustive tests are what actually re-run against a mutant. --- .../src/tui/screens/shared/formula-picker.tsx | 4 +- .../screens/shared/formula-presets.test.ts | 22 ++--- .../src/tui/screens/shared/formula-presets.ts | 82 ++++++++++--------- 3 files changed, 57 insertions(+), 51 deletions(-) diff --git a/packages/document-cli/src/tui/screens/shared/formula-picker.tsx b/packages/document-cli/src/tui/screens/shared/formula-picker.tsx index fb88243cd..60ddb2bba 100644 --- a/packages/document-cli/src/tui/screens/shared/formula-picker.tsx +++ b/packages/document-cli/src/tui/screens/shared/formula-picker.tsx @@ -5,7 +5,7 @@ import { ListView, selectedColor } from "../../components/list-view.js"; import { TextField } from "../../components/text-field.js"; import { useNavigationInput } from "../../keybindings/use-navigation-input.js"; import { describeError } from "../../errors.js"; -import { FORMULA_PRESETS } from "./formula-presets.js"; +import { getFormulaPresets } from "./formula-presets.js"; const RAW_ENTRY_LABEL = "Raw MathML..."; @@ -16,7 +16,7 @@ interface PickerRow { } const PICKER_ROWS: readonly PickerRow[] = [ - ...FORMULA_PRESETS.map((preset) => ({ + ...getFormulaPresets().map((preset) => ({ label: preset.label, mathml: preset.mathml, })), diff --git a/packages/document-cli/src/tui/screens/shared/formula-presets.test.ts b/packages/document-cli/src/tui/screens/shared/formula-presets.test.ts index 1f33933d7..b6d0ccc11 100644 --- a/packages/document-cli/src/tui/screens/shared/formula-presets.test.ts +++ b/packages/document-cli/src/tui/screens/shared/formula-presets.test.ts @@ -1,20 +1,20 @@ import { describe, expect, it } from "vitest"; -import { FORMULA_PRESETS } from "./formula-presets"; +import { getFormulaPresets } from "./formula-presets"; -describe("FORMULA_PRESETS", () => { +describe("getFormulaPresets", () => { it("declares exactly six presets", () => { - expect(FORMULA_PRESETS).toHaveLength(6); + expect(getFormulaPresets()).toHaveLength(6); }); it("gives every preset a non-empty label and at least one MathML node", () => { - for (const preset of FORMULA_PRESETS) { + for (const preset of getFormulaPresets()) { expect(preset.label.length).toBeGreaterThan(0); expect(preset.mathml.length).toBeGreaterThan(0); } }); it("declares the exact labels, in order", () => { - expect(FORMULA_PRESETS.map((preset) => preset.label)).toEqual([ + expect(getFormulaPresets().map((preset) => preset.label)).toEqual([ "Fraction: x / 2", "Power: x^2", "Subscript: x_i", @@ -25,7 +25,7 @@ describe("FORMULA_PRESETS", () => { }); it("builds the fraction preset as mfrac(mi(x), mn(2))", () => { - expect(FORMULA_PRESETS[0]?.mathml).toEqual([ + expect(getFormulaPresets()[0]?.mathml).toEqual([ { type: "element", tag: "mfrac", @@ -49,7 +49,7 @@ describe("FORMULA_PRESETS", () => { }); it("builds the power preset as msup(mi(x), mn(2))", () => { - expect(FORMULA_PRESETS[1]?.mathml).toEqual([ + expect(getFormulaPresets()[1]?.mathml).toEqual([ { type: "element", tag: "msup", @@ -73,7 +73,7 @@ describe("FORMULA_PRESETS", () => { }); it("builds the subscript preset as msub(mi(x), mi(i))", () => { - expect(FORMULA_PRESETS[2]?.mathml).toEqual([ + expect(getFormulaPresets()[2]?.mathml).toEqual([ { type: "element", tag: "msub", @@ -97,7 +97,7 @@ describe("FORMULA_PRESETS", () => { }); it("builds the square-root preset as msqrt(mi(x))", () => { - expect(FORMULA_PRESETS[3]?.mathml).toEqual([ + expect(getFormulaPresets()[3]?.mathml).toEqual([ { type: "element", tag: "msqrt", @@ -115,7 +115,7 @@ describe("FORMULA_PRESETS", () => { }); it("builds the exact summation preset tree", () => { - expect(FORMULA_PRESETS[4]?.mathml).toEqual([ + expect(getFormulaPresets()[4]?.mathml).toEqual([ { type: "element", tag: "munderover", @@ -173,7 +173,7 @@ describe("FORMULA_PRESETS", () => { const mn = (value: string): unknown => element("mn", [text(value)]); const mo = (operator: string): unknown => element("mo", [text(operator)]); - expect(FORMULA_PRESETS[5]?.mathml).toEqual([ + expect(getFormulaPresets()[5]?.mathml).toEqual([ element("mrow", [ mi("x"), mo("="), diff --git a/packages/document-cli/src/tui/screens/shared/formula-presets.ts b/packages/document-cli/src/tui/screens/shared/formula-presets.ts index 2442844d0..8aa58e7bb 100644 --- a/packages/document-cli/src/tui/screens/shared/formula-presets.ts +++ b/packages/document-cli/src/tui/screens/shared/formula-presets.ts @@ -30,45 +30,51 @@ function mo(operator: string): MathMlNode { return element("mo", [text(operator)]); } -export const FORMULA_PRESETS: readonly FormulaPreset[] = [ - { label: "Fraction: x / 2", mathml: [element("mfrac", [mi("x"), mn("2")])] }, - { label: "Power: x^2", mathml: [element("msup", [mi("x"), mn("2")])] }, - { label: "Subscript: x_i", mathml: [element("msub", [mi("x"), mi("i")])] }, - { label: "Square root: sqrt(x)", mathml: [element("msqrt", [mi("x")])] }, - { - label: "Summation: sum(i=1..n) i", - mathml: [ - element("munderover", [ - mo("∑"), - element("mrow", [mi("i"), mo("="), mn("1")]), - mi("n"), - ]), - ], - }, - { - label: "Quadratic formula", - mathml: [ - element("mrow", [ - mi("x"), - mo("="), - element("mfrac", [ - element("mrow", [ - mo("-"), - mi("b"), - mo("±"), - element("msqrt", [ - element("mrow", [ - element("msup", [mi("b"), mn("2")]), - mo("-"), - mn("4"), - mi("a"), - mi("c"), +// A function rather than a module-scope constant, deliberately: Stryker's per-test coverage instrumentation attributes a top-level constant's own one-time initialisation to whichever test happens to trigger the very first import of this module in the whole suite (module caching means every later importer just reads the already-built array) -- so mutating a literal here would only ever be re-verified against that one unrelated test, never against this file's own exhaustive assertions below. Rebuilding the array fresh on every call makes each call site's own execution the thing coverage attributes, so formula-presets.test.ts's own calls are what get re-run against a mutant, not whichever screen's test happened to import the module first. +export function getFormulaPresets(): readonly FormulaPreset[] { + return [ + { + label: "Fraction: x / 2", + mathml: [element("mfrac", [mi("x"), mn("2")])], + }, + { label: "Power: x^2", mathml: [element("msup", [mi("x"), mn("2")])] }, + { label: "Subscript: x_i", mathml: [element("msub", [mi("x"), mi("i")])] }, + { label: "Square root: sqrt(x)", mathml: [element("msqrt", [mi("x")])] }, + { + label: "Summation: sum(i=1..n) i", + mathml: [ + element("munderover", [ + mo("∑"), + element("mrow", [mi("i"), mo("="), mn("1")]), + mi("n"), + ]), + ], + }, + { + label: "Quadratic formula", + mathml: [ + element("mrow", [ + mi("x"), + mo("="), + element("mfrac", [ + element("mrow", [ + mo("-"), + mi("b"), + mo("±"), + element("msqrt", [ + element("mrow", [ + element("msup", [mi("b"), mn("2")]), + mo("-"), + mn("4"), + mi("a"), + mi("c"), + ]), ]), ]), + element("mrow", [mn("2"), mi("a")]), ]), - element("mrow", [mn("2"), mi("a")]), ]), - ]), - ], - }, -]; + ], + }, + ]; +} From 74f6cf81d5f1d6d7ab9c86357efeaf7b945c7bf6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:01:31 +0100 Subject: [PATCH 077/101] test(document-cli): cover legacy binary and flowable formats in the format table isDocumentFormat, inferFormatFromExtension, and formatToExtension were only exercised for the earlier office/markup formats; wpd, doc, xls, ppt, and epub had no case asserting their own membership, extension inference, or round-trip extension. --- packages/document-cli/src/format.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/document-cli/src/format.test.ts b/packages/document-cli/src/format.test.ts index 0eb266195..5ae784429 100644 --- a/packages/document-cli/src/format.test.ts +++ b/packages/document-cli/src/format.test.ts @@ -21,6 +21,11 @@ describe("isDocumentFormat", () => { "markdown", "rtf", "pdf", + "wpd", + "doc", + "xls", + "ppt", + "epub", ]) { expect(isDocumentFormat(format)).toBe(true); } @@ -102,6 +107,14 @@ describe("inferFormatFromExtension", () => { it("infers rtf from its own extension", () => { expect(inferFormatFromExtension("letter.rtf")).toBe("rtf"); }); + + it("infers each legacy binary and flowable format from its own extension", () => { + expect(inferFormatFromExtension("draft.wpd")).toBe("wpd"); + expect(inferFormatFromExtension("legacy.doc")).toBe("doc"); + expect(inferFormatFromExtension("legacy.xls")).toBe("xls"); + expect(inferFormatFromExtension("legacy.ppt")).toBe("ppt"); + expect(inferFormatFromExtension("book.epub")).toBe("epub"); + }); }); describe("formatToExtension", () => { @@ -124,6 +137,11 @@ describe("formatToExtension", () => { ["markdown", "md"], ["rtf", "rtf"], ["pdf", "pdf"], + ["wpd", "wpd"], + ["doc", "doc"], + ["xls", "xls"], + ["ppt", "ppt"], + ["epub", "epub"], ]; for (const [format, extension] of cases) { expect(formatToExtension(format)).toBe(extension); From 900dc5652c111aa5017a7624ea59cdb429769781 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:01:45 +0100 Subject: [PATCH 078/101] test(document-cli): cover the ooxml-fixture test-support builders xmlDeclaration, txt, and el had no direct test of their own; every existing use only exercised them indirectly through the fixtures that call them, leaving each builder's own default-argument and attribute- ordering behaviour unasserted. --- .../src/test-support/ooxml-fixture.test.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 packages/document-cli/src/test-support/ooxml-fixture.test.ts diff --git a/packages/document-cli/src/test-support/ooxml-fixture.test.ts b/packages/document-cli/src/test-support/ooxml-fixture.test.ts new file mode 100644 index 000000000..313c5a88e --- /dev/null +++ b/packages/document-cli/src/test-support/ooxml-fixture.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { el, txt, xmlDeclaration } from "./ooxml-fixture"; + +describe("xmlDeclaration", () => { + it("builds the standard XML 1.0 UTF-8 standalone declaration", () => { + expect(xmlDeclaration()).toEqual({ + type: "declaration", + attributes: [ + { name: "version", value: "1.0" }, + { name: "encoding", value: "UTF-8" }, + { name: "standalone", value: "yes" }, + ], + }); + }); +}); + +describe("txt", () => { + it("builds a text node carrying the given value", () => { + expect(txt("hello")).toEqual({ type: "text", value: "hello" }); + }); +}); + +describe("el", () => { + it("builds an element with no attributes and no children by default", () => { + expect(el("w:p")).toEqual({ + type: "element", + tag: "w:p", + attributes: [], + children: [], + }); + }); + + it("converts an attributes record into an ordered name/value array", () => { + expect(el("w:comment", { "w:id": "0", "w:author": "Alice" })).toEqual({ + type: "element", + tag: "w:comment", + attributes: [ + { name: "w:id", value: "0" }, + { name: "w:author", value: "Alice" }, + ], + children: [], + }); + }); + + it("carries through the given children unchanged", () => { + const child = txt("body text"); + expect(el("w:t", {}, [child])).toEqual({ + type: "element", + tag: "w:t", + attributes: [], + children: [child], + }); + }); + + it("copies the children array rather than aliasing the one passed in", () => { + const children = [txt("a")]; + const element = el("w:r", {}, children); + children.push(txt("b")); + expect(element.children).toHaveLength(1); + }); +}); From 9f738abc2ee133fa9e75c37695952d14008a8aa4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:01:54 +0100 Subject: [PATCH 079/101] build(document-cli): serialise mutation test runs and extend the dry-run timeout document-cli's TUI suite spawns and manages child processes and pseudo- terminals; running mutants at the default concurrency let sibling workers race over the same terminal/process resources and produced flaky, environment-dependent failures unrelated to the mutated code. Concurrency of 1 and a longer dry-run timeout make a run reproducible at the cost of wall-clock time. --- packages/document-cli/stryker.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/document-cli/stryker.config.ts b/packages/document-cli/stryker.config.ts index 611b74d77..41d59efef 100644 --- a/packages/document-cli/stryker.config.ts +++ b/packages/document-cli/stryker.config.ts @@ -10,4 +10,6 @@ export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", // First CI-measured baseline: 33.33% of 6241 valid mutants, timeout share 0.02% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. breakThreshold: 32, + concurrency: 1, + dryRunTimeoutMinutes: 20, }); From 089db02518373b6969d1103912200bbed22c7dbe Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:37:09 +0100 Subject: [PATCH 080/101] refactor(document-cli): rebuild the format-to-extension table per call FORMAT_TO_EXTENSION was a module-scope object literal, so Stryker's per-test coverage instrumentation attributed its one-time initialisation to whichever test happened to trigger the first import of format.ts in the whole suite, never to the tests that actually call formatToExtension with a given format. A mutated extension value therefore survived even though formatToExtension's own exhaustive test asserts on every entry. Rebuilding the table inside a function called on every lookup makes each call site's own execution the thing coverage attributes to. --- packages/document-cli/src/format.test.ts | 2 +- packages/document-cli/src/format.ts | 49 +++++++++++++----------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/packages/document-cli/src/format.test.ts b/packages/document-cli/src/format.test.ts index 5ae784429..12695ce56 100644 --- a/packages/document-cli/src/format.test.ts +++ b/packages/document-cli/src/format.test.ts @@ -118,7 +118,7 @@ describe("inferFormatFromExtension", () => { }); describe("formatToExtension", () => { - // Not `formatToExtension(format) === format` any more: markdown breaks that identity (two extensions read as 'markdown', but only one -- 'md' -- is written), so this is an explicit lookup table instead, matching FORMAT_TO_EXTENSION's own canonical choice one entry at a time rather than asserting a shortcut that no longer holds for every format. A typed tuple array, not `Object.entries` over a Record, so each format literal narrows on its own -- no type assertion needed to hand it back to formatToExtension. + // Not `formatToExtension(format) === format` any more: markdown breaks that identity (two extensions read as 'markdown', but only one -- 'md' -- is written), so this is an explicit lookup table instead, matching getFormatToExtension's own canonical choice one entry at a time rather than asserting a shortcut that no longer holds for every format. A typed tuple array, not `Object.entries` over a Record, so each format literal narrows on its own -- no type assertion needed to hand it back to formatToExtension. it("maps every recognised format to its own canonical extension", () => { const cases: readonly (readonly [ Parameters[0], diff --git a/packages/document-cli/src/format.ts b/packages/document-cli/src/format.ts index 9489d4597..4c33dde81 100644 --- a/packages/document-cli/src/format.ts +++ b/packages/document-cli/src/format.ts @@ -1,6 +1,6 @@ import type { DocumentFormat } from "documents.js"; -// 'md' and 'markdown' both read as the 'markdown' DocumentFormat, and every ODF/OOXML template and macro-enabled variant reads as its base format -- the many-to-one entries this table carries, deliberately breaking what was previously a perfect mirror with FORMAT_TO_EXTENSION (every base format's own extension is also its canonical one). A template (.ott/.ots/.otp/.otg/.otf) is the same package as its non-template sibling with only the mimetype's own "-template" suffix differing, and a macro-enabled OOXML file (.docm/.xlsm/.pptm) is the same package with a vbaProject part this library reads past (macros are never executed or re-emitted); so both read through the base codec unchanged. FORMAT_TO_EXTENSION below still names exactly one extension per format, so writing always picks the canonical base extension. +// 'md' and 'markdown' both read as the 'markdown' DocumentFormat, and every ODF/OOXML template and macro-enabled variant reads as its base format -- the many-to-one entries this table carries, deliberately breaking what was previously a perfect mirror with getFormatToExtension's own table (every base format's own extension is also its canonical one). A template (.ott/.ots/.otp/.otg/.otf) is the same package as its non-template sibling with only the mimetype's own "-template" suffix differing, and a macro-enabled OOXML file (.docm/.xlsm/.pptm) is the same package with a vbaProject part this library reads past (macros are never executed or re-emitted); so both read through the base codec unchanged. getFormatToExtension below still names exactly one extension per format, so writing always picks the canonical base extension. const EXTENSION_TO_FORMAT: Readonly> = { docx: "docx", dotx: "docx", @@ -34,29 +34,32 @@ const EXTENSION_TO_FORMAT: Readonly> = { pdf: "pdf", }; -const FORMAT_TO_EXTENSION: Readonly> = { - docx: "docx", - pptx: "pptx", - xlsx: "xlsx", - odt: "odt", - odp: "odp", - ods: "ods", - odg: "odg", - odf: "odf", - csv: "csv", - svg: "svg", - markdown: "md", - rtf: "rtf", - wpd: "wpd", - doc: "doc", - xls: "xls", - ppt: "ppt", - epub: "epub", - pdf: "pdf", -}; +// A function rather than a module-scope constant, deliberately: Stryker's per-test coverage instrumentation attributes a module-scope object literal's own one-time initialisation to whichever test happens to trigger the very first import of this module in the whole suite (module caching means every later importer just reads the already-built object), so a mutated extension value here would only ever be re-verified against that one unrelated first-importing test, never against a test that actually calls formatToExtension with the mutated format. Rebuilding the object fresh on every call makes each call site's own execution the thing coverage attributes to, so this file's own exhaustive formatToExtension/isDocumentFormat tests are what get re-run against a mutant. +function getFormatToExtension(): Readonly> { + return { + docx: "docx", + pptx: "pptx", + xlsx: "xlsx", + odt: "odt", + odp: "odp", + ods: "ods", + odg: "odg", + odf: "odf", + csv: "csv", + svg: "svg", + markdown: "md", + rtf: "rtf", + wpd: "wpd", + doc: "doc", + xls: "xls", + ppt: "ppt", + epub: "epub", + pdf: "pdf", + }; +} export function isDocumentFormat(value: string): value is DocumentFormat { - return value in FORMAT_TO_EXTENSION; + return value in getFormatToExtension(); } // Reads the extension after the last '.' in the final path segment (so 'a.b/c.docx' -> 'docx', '.gitignore' -> undefined -- a leading dot with no further '.' is not an extension). Returns undefined for no recognised extension, an unrecognised one, a bare '-' (stdin/stdout marker, which has no '.' of its own and so already falls out of the extension check below with no special-cased branch needed), or a path with none at all -- callers decide how to react to an unresolved format, this module only classifies. @@ -73,5 +76,5 @@ export function inferFormatFromExtension( } export function formatToExtension(format: DocumentFormat): string { - return FORMAT_TO_EXTENSION[format]; + return getFormatToExtension()[format]; } From cfc8c5089eed97443430ce010133cd4c48027b60 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 03:43:45 +0100 Subject: [PATCH 081/101] fix(document-cli): let Esc dismiss the slide-table-detail screen when its table is gone The useInput handler returned before reaching any key check whenever resolveSlideTable came back undefined, so the fallback view's own "press Esc to go back" text was a lie: no key, Escape included, ever did anything. Esc now still pops the screen in that state; every other key remains a no-op since there is no grid left to act on. --- .../src/tui/screens/editors/pptx/slide-table-detail.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/document-cli/src/tui/screens/editors/pptx/slide-table-detail.tsx b/packages/document-cli/src/tui/screens/editors/pptx/slide-table-detail.tsx index f7fb65109..4f244738c 100644 --- a/packages/document-cli/src/tui/screens/editors/pptx/slide-table-detail.tsx +++ b/packages/document-cli/src/tui/screens/editors/pptx/slide-table-detail.tsx @@ -60,6 +60,10 @@ export function SlideTableDetailScreen( useInput( (input, key) => { if (table === undefined) { + // The fallback view below tells the user to press Esc to go back, so Esc must still work even with no table to navigate -- every other key is genuinely meaningless here (there is no grid to move a cursor over or merge cells in). + if (key.escape) { + dispatch({ type: "POP_SCREEN" }); + } return; } if (key.upArrow || input === "k") { From a3ed2366b906adaff84a24429a925d68e75e1db9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 03:43:49 +0100 Subject: [PATCH 082/101] test(document-cli): cover slide-table-detail's missing-table and edge-clamp paths Adds coverage for branches no existing test touched: the table-no-longer-exists fallback (and that Esc, uniquely, still works there), cursor clamping at every edge of the grid via individual h/j/k/l presses, and committing a pending merge with Enter rather than a second 'm'. --- .../editors/pptx/slide-table-detail.test.tsx | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/packages/document-cli/src/tui/screens/editors/pptx/slide-table-detail.test.tsx b/packages/document-cli/src/tui/screens/editors/pptx/slide-table-detail.test.tsx index 548d2f4a0..6c9696b83 100644 --- a/packages/document-cli/src/tui/screens/editors/pptx/slide-table-detail.test.tsx +++ b/packages/document-cli/src/tui/screens/editors/pptx/slide-table-detail.test.tsx @@ -165,6 +165,86 @@ function renderAtSlideDetail( ); } +// Pushes straight to slideTableDetail with the given tableIndex, skipping slide-detail's own Enter-key navigation -- the only way to reach an OUT-OF-RANGE tableIndex, since the real UI never offers one that doesn't already correspond to a live table. +function OpenAtSlideTableDetail({ + format, + bytes, + tableIndex, +}: { + readonly format: "pptx" | "odp"; + readonly bytes: Uint8Array; + readonly tableIndex: number; +}): ReactElement | undefined { + const dispatch = useAppDispatch(); + useEffect(() => { + if (format === "pptx") { + dispatch({ + type: "OPEN_FILE_SUCCESS", + path: "test.pptx", + doc: { format: "pptx", editor: openPptx(bytes), path: "test.pptx" }, + }); + } else { + dispatch({ + type: "OPEN_FILE_SUCCESS", + path: "test.odp", + doc: { format: "odp", editor: openOdp(bytes), path: "test.odp" }, + }); + } + dispatch({ + type: "PUSH_SCREEN", + screen: { kind: "slideDetail", slideIndex: 0 }, + }); + dispatch({ + type: "PUSH_SCREEN", + screen: { kind: "slideTableDetail", slideIndex: 0, tableIndex }, + }); + }, [format, bytes, tableIndex, dispatch]); + return undefined; +} + +function HarnessAtTableIndex({ + format, + bytes, + tableIndex, +}: { + readonly format: "pptx" | "odp"; + readonly bytes: Uint8Array; + readonly tableIndex: number; +}): ReactElement { + const state = useAppState(); + if (state.openDocument === undefined) { + return ( + + ); + } + return ( + + + + + ); +} + +function renderAtTableIndex( + format: "pptx" | "odp", + bytes: Uint8Array, + tableIndex: number, +): ReturnType { + return render( + + + , + ); +} + describe.each(["pptx", "odp"] as const)( "SlideTableDetailScreen on %s", (format) => { @@ -220,5 +300,99 @@ describe.each(["pptx", "odp"] as const)( await sendKey(stdin, ESCAPE_KEY); await waitForText(lastFrame, "Tables (1)"); }); + + it("reports the table as gone when tableIndex no longer resolves to a real table, and Esc still returns to slide-detail", async () => { + const bytes = + format === "pptx" + ? buildPptxTableDeckBytes() + : buildOdpTableDeckBytes(); + const { lastFrame, stdin } = renderAtTableIndex(format, bytes, 5); + const gone = await waitForText(lastFrame, "no longer exists"); + expect(gone).toContain("Slide 1, table 6"); + + await sendKey(stdin, ESCAPE_KEY); + await waitForText(lastFrame, "Tables (1)"); + }); + + it("moves the cursor with individual h/k/j/l keys and clamps at every edge of the grid", async () => { + const bytes = + format === "pptx" + ? buildPptxTableDeckBytes() + : buildOdpTableDeckBytes(); + const { lastFrame, stdin } = renderAtSlideDetail(format, bytes); + await waitForText(lastFrame, "Tables (1)"); + await sendKey(stdin, ENTER_KEY); + await waitForText(lastFrame, "table 1 (3x3)"); + + // Up/left from the starting (0,0) cell must clamp at zero rather than go negative. + await sendKey(stdin, "k"); + await sendKey(stdin, "h"); + await sendKey(stdin, "m"); + const stillAtOrigin = await waitForText(lastFrame, "m/Enter to merge"); + expect(stillAtOrigin).toContain("table 1 (3x3)"); + await sendKey(stdin, ESCAPE_KEY); + await waitForText(lastFrame, "anchor a merge"); + + // Down/right past the last row/column must clamp at the last index (row/column 2 of a 3x3 table), not run off the end. + for (let i = 0; i < 5; i += 1) { + await sendKey(stdin, "j"); + } + for (let i = 0; i < 5; i += 1) { + await sendKey(stdin, "l"); + } + await sendKey(stdin, "m"); + const atBottomRight = await waitForText(lastFrame, "m/Enter to merge"); + expect(atBottomRight).toContain("table 1 (3x3)"); + + // Committing the merge at the clamped bottom-right cell against itself as anchor is a 1x1 merge (a same-cell no-op) -- proves the clamp landed on the last real cell rather than an out-of-bounds one, since a stale unclamped index would target a cell resolveSlideTable's own bounds check would reject. + await sendKey(stdin, "m"); + const merged = await waitForText( + lastFrame, + "probe:anchorColSpan=1 anchorRowSpan=1", + ); + expect(merged).toContain("anchor a merge"); + }); + + it("commits a pending merge with Enter as well as 'm'", async () => { + const bytes = + format === "pptx" + ? buildPptxTableDeckBytes() + : buildOdpTableDeckBytes(); + const { lastFrame, stdin } = renderAtSlideDetail(format, bytes); + await waitForText(lastFrame, "Tables (1)"); + await sendKey(stdin, ENTER_KEY); + await waitForText(lastFrame, "table 1 (3x3)"); + + await sendKey(stdin, "m"); + await waitForText(lastFrame, "m/Enter to merge"); + await sendKey(stdin, "l"); + await sendKey(stdin, "j"); + await sendKey(stdin, ENTER_KEY); + + const merged = await waitForText( + lastFrame, + "probe:anchorColSpan=2 anchorRowSpan=2", + ); + expect(merged).toContain("anchor a merge"); + }); + + it("ignores every navigation and merge key once the table itself is gone, but Esc still works", async () => { + const bytes = + format === "pptx" + ? buildPptxTableDeckBytes() + : buildOdpTableDeckBytes(); + const { lastFrame, stdin } = renderAtTableIndex(format, bytes, 5); + const gone = await waitForText(lastFrame, "no longer exists"); + + await sendKey(stdin, "j"); + await sendKey(stdin, "l"); + await sendKey(stdin, "m"); + await sendKey(stdin, ENTER_KEY); + await settle(); + expect(lastFrame()).toBe(gone); + + await sendKey(stdin, ESCAPE_KEY); + await waitForText(lastFrame, "Tables (1)"); + }); }, ); From 41dc88383e5e41b5421024eaad628819f1ab439e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 03:47:27 +0100 Subject: [PATCH 083/101] test(document-cli): cover paragraph-detail's own three guard fallbacks Nothing exercised the outside-screen guard, the no-open-document guard, or the out-of-range blockIndex guard -- all three early returns above the screen's real render path were dead as far as the suite could tell. --- .../editors/docx/paragraph-detail.test.tsx | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/packages/document-cli/src/tui/screens/editors/docx/paragraph-detail.test.tsx b/packages/document-cli/src/tui/screens/editors/docx/paragraph-detail.test.tsx index 31dabd75c..1734a6e65 100644 --- a/packages/document-cli/src/tui/screens/editors/docx/paragraph-detail.test.tsx +++ b/packages/document-cli/src/tui/screens/editors/docx/paragraph-detail.test.tsx @@ -394,3 +394,73 @@ describe('ParagraphDetailScreen "m" formula insertion (docx paragraph-scoped)', expect(lastFrame()).not.toContain("Insert formula"); }); }); + +describe("ParagraphDetailScreen's own fallback renders", () => { + it("reports being rendered outside a paragraphDetail screen when mounted before any screen push", () => { + // No CREATE_DOCUMENT, no PUSH_SCREEN at all -- the app's own initial screen is never paragraphDetail, so mounting this screen component directly (as app.tsx's real router never would on its own) must hit its own outside-screen guard rather than crash or render nothing. + const { lastFrame } = render( + + + , + ); + expect(lastFrame()).toContain( + "ParagraphDetailScreen rendered outside a paragraphDetail screen", + ); + }); + + it("reports no open document when pushed to paragraphDetail with nothing open", async () => { + function PushWithNoDocument(): ReactElement | null { + const dispatch = useAppDispatch(); + useEffect(() => { + dispatch({ + type: "PUSH_SCREEN", + screen: { kind: "paragraphDetail", blockIndex: 0 }, + }); + }, [dispatch]); + return ; + } + const { lastFrame } = render( + + + , + ); + await vi.waitFor(() => { + expect(lastFrame()).toContain( + "ParagraphDetailScreen requires an open docx, odt or markdown document", + ); + }); + }); + + it.each(["docx", "odt"] as const)( + "reports no paragraph at the given index once blockIndex runs past the document's own paragraph count (%s)", + async (format) => { + function PushWithBadIndex(): ReactElement | null { + const state = useAppState(); + const dispatch = useAppDispatch(); + useEffect(() => { + dispatch({ type: "CREATE_DOCUMENT", format }); + }, [dispatch]); + useEffect(() => { + if (state.openDocument?.format === format) { + dispatch({ + type: "PUSH_SCREEN", + screen: { kind: "paragraphDetail", blockIndex: 99 }, + }); + } + }, [state.openDocument, dispatch]); + if (state.openDocument?.format !== format) { + return null; + } + return ; + } + const { lastFrame } = render( + + + , + ); + await vi.waitFor(() => { + expect(lastFrame()).toContain("There is no paragraph at index 99"); + }); + }, + ); +}); From 7d59e6ef902fc5042650c903b0c369980470e51a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:07:30 +0100 Subject: [PATCH 084/101] test(document-cli): cover UNDO's own per-format read-only warning UNDO's read-only branch names one of seven formats (odb/xlsx/csv/svg/ rtf/wpd/epub) in its warning text, but nothing dispatched UNDO against any of them -- every existing undo test opened an editable format instead, leaving the whole seven-way format check and its templated message unexercised. --- .../src/tui/state/reducer.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 28a30d39a..dc12e188a 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -34,14 +34,21 @@ import type { Action } from "./actions.js"; import { appReducer, createInitialState } from "./reducer.js"; import type { AppState, + CsvOpenDocument, DocxOpenDocument, + EpubOpenDocument, MarkdownOpenDocument, + OdbOpenDocument, OdgOpenDocument, OdpOpenDocument, OdsOpenDocument, OdtOpenDocument, PdfOpenDocument, PptxOpenDocument, + RtfOpenDocument, + SvgOpenDocument, + WpdOpenDocument, + XlsxOpenDocument, } from "./types.js"; import { isEditableDocument } from "./types.js"; @@ -243,6 +250,34 @@ function openXlsxDocument( }); } +// A minimal document for each of UNDO's own read-only formats: odb carries no layout/bytes at all (see OdbOpenDocument's own doc comment), the other six share the identical read-only-preview shape XlsxOpenDocument above already builds, populated through a real PdfEditor rather than a hand-authored LayoutDocument literal -- these tests only exercise UNDO's own per-format branch, never the layout content itself. +function readOnlyOpenDocument( + format: "odb" | "xlsx" | "csv" | "svg" | "rtf" | "wpd" | "epub", +): + | OdbOpenDocument + | XlsxOpenDocument + | CsvOpenDocument + | SvgOpenDocument + | RtfOpenDocument + | WpdOpenDocument + | EpubOpenDocument { + if (format === "odb") { + return { + format: "odb", + tables: [], + forms: [], + reports: [], + path: "/tmp/database.odb", + }; + } + return { + format, + layout: createPdf().toLayoutDocument(), + bytes: createPdf().toBytes(), + path: `/tmp/source.${format}`, + }; +} + function markdownDocument(state: AppState): MarkdownOpenDocument { const doc = state.openDocument; if (doc?.format !== "markdown") { @@ -1732,6 +1767,27 @@ describe("appReducer undo", () => { expect(undone.openDocument.editor).not.toBe(mutatedEditor); }, ); + + // The genuinely read-only formats (no live-view editor, so nothing ever pushes an undo snapshot for them) each get their own dedicated warning naming that exact format, rather than falling through to the "nothing to undo" info message every editable format's own empty undo stack produces above. + it.each(["odb", "xlsx", "csv", "svg", "rtf", "wpd", "epub"] as const)( + "says a %s document is read-only with nothing to undo", + (format) => { + const doc = readOnlyOpenDocument(format); + const opened = appReducer(createInitialState(), { + type: "OPEN_FILE_SUCCESS", + path: doc.path, + doc, + }); + + const undone = appReducer(opened, { type: "UNDO" }); + expect(undone.status?.severity).toBe("warning"); + expect(undone.status?.text).toBe( + `A ${format} document is read-only, so it has no history to undo`, + ); + expect(undone.openDocument).toBe(opened.openDocument); + expect(undone.undoStack).toHaveLength(0); + }, + ); }); describe("appReducer ADD_SLIDE_TABLE", () => { From 94a598052dd17b101b0ba609d095df71d9cc44e5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:23:25 +0100 Subject: [PATCH 085/101] test(document-cli): cover the odg page-vs-slide wording branch and remaining format/kind guards Covers withShape's "page" wording for an odg missing-shape warning, previously only exercised via the pptx "slide" branch, each of the nine formats OPEN_FILE_SUCCESS names individually in its read-only-PDF-preview note, and the wrong-kind guards for SET_PDF_INTERNAL_LINK_DESTINATION and SET_PDF_INTERNAL_LINK_FRAME. --- .../src/tui/state/reducer.test.ts | 108 +++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index dc12e188a..6730e0f56 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -2133,7 +2133,7 @@ describe("appReducer SET_SHAPE_ROTATION on pptx", () => { expect(reopened.slides()[0]?.shapes()[0]?.rotationDeg).toBeCloseTo(30, 5); }); - it("warns rather than crashing for a shape index that does not exist", () => { + it("warns rather than crashing for a shape index that does not exist, naming the slide it looked on", () => { const editor = createPptx(); editor.addSlide(); const opened = openPptxDocument(editor.toBytes()); @@ -2146,6 +2146,23 @@ describe("appReducer SET_SHAPE_ROTATION on pptx", () => { }); expect(result.status?.severity).toBe("warning"); expect(result.hasUnsavedChanges).toBe(false); + expect(result.status?.text).toBe("There is no shape 5 on slide 0"); + }); + + // withShape's own missing-shape message says "page" for odg specifically, "slide" for every other shape-host format -- the pptx test above only ever exercises the "slide" branch of that ternary. + it("warns with 'page' rather than 'slide' when the missing shape is on an odg page", () => { + const editor = createOdg(); + editor.addPage(); + const opened = openOdgDocument(editor.toBytes()); + + const result = appReducer(opened, { + type: "SET_SHAPE_TEXT", + containerIndex: 0, + shapeIndex: 3, + text: "unreachable", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no shape 3 on page 0"); }); }); @@ -2176,6 +2193,67 @@ describe("appReducer xlsx (read-only PDF-preview) documents", () => { }); }); +// OPEN_FILE_SUCCESS's own "opened as a read-only PDF preview" note names all nine of these formats individually in its own OR-chain (xlsx is exercised separately above, through its own dedicated describe block) -- each one needs its own dispatch to prove that exact branch, not just the shared behaviour the OR-chain produces once any one of them matches. +describe("appReducer OPEN_FILE_SUCCESS's read-only-PDF-preview note names every one of its own nine formats", () => { + it.each([ + ["csv", () => readOnlyOpenDocument("csv")], + ["svg", () => readOnlyOpenDocument("svg")], + ["rtf", () => readOnlyOpenDocument("rtf")], + ["wpd", () => readOnlyOpenDocument("wpd")], + [ + "doc", + () => ({ + format: "doc" as const, + editor: openDoc(createDoc().toBytes()), + path: "/tmp/legacy.doc", + }), + ], + [ + "xls", + () => ({ + format: "xls" as const, + editor: openXls(createXls().toBytes()), + path: "/tmp/legacy.xls", + }), + ], + [ + "ppt", + () => ({ + format: "ppt" as const, + editor: openPpt(createPpt().toBytes()), + path: "/tmp/legacy.ppt", + }), + ], + ["epub", () => readOnlyOpenDocument("epub")], + ] as const)( + "names %s specifically in the preview note", + (format, buildDoc) => { + const doc = buildDoc(); + const opened = appReducer(createInitialState(), { + type: "OPEN_FILE_SUCCESS", + path: doc.path, + doc, + }); + expect(opened.status?.text).toBe( + `Opened ${doc.path} as a read-only PDF preview -- press ':' then 'export pdf' to save it as a real PDF`, + ); + }, + ); + + it("does not add the preview note for a format with a real live-view editor of its own", () => { + const opened = appReducer(createInitialState(), { + type: "OPEN_FILE_SUCCESS", + path: "/tmp/notes.odt", + doc: { + format: "odt", + editor: openOdt(createOdt().toBytes()), + path: "/tmp/notes.odt", + }, + }); + expect(opened.status?.text).toBe("Opened /tmp/notes.odt"); + }); +}); + // A minimal real fixture: one page, one text item -- built through the real PdfEditor (createPdf/appendText), never a hand-authored LayoutDocument literal, so these tests exercise the exact writer/reader pair the reducer wires against. function pdfTestBytes(): Uint8Array { const editor = createPdf(); @@ -2931,6 +3009,34 @@ describe("appReducer PDF item and page mutations", () => { expect(result.status?.severity).toBe("warning"); expect(result.status?.text).toContain("not link"); }); + + // internalLink items arise only from reading a real PDF's own GoTo/Dest annotations (PdfPage has no appendInternalLink -- unlike every other item kind, there is no way to add one fresh through the editor), so only the wrong-kind guard is reachable here; the success path is exercised at the pdf-codec layer instead (see its own navigation.test.ts). + it("warns rather than crashing when an internal-link destination edit targets an item of the wrong kind", () => { + const state = pdfMultiItemState(); + const result = appReducer(state, { + type: "SET_PDF_INTERNAL_LINK_DESTINATION", + pageIndex: 0, + itemIndex: TEXT_INDEX, + destination: "dest1", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("not internalLink"); + }); + + it("warns rather than crashing when an internal-link frame edit targets an item of the wrong kind", () => { + const state = pdfMultiItemState(); + const result = appReducer(state, { + type: "SET_PDF_INTERNAL_LINK_FRAME", + pageIndex: 0, + itemIndex: TEXT_INDEX, + xPt: 1, + yPt: 2, + widthPt: 3, + heightPt: 4, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("not internalLink"); + }); }); }); From 8ed39daab5d44093b5ee4ef85c8e14553644a862 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:37:00 +0100 Subject: [PATCH 086/101] ci: resolve mutation-testing's --affected against the PR base, not the literal 'main' actions/checkout leaves a detached HEAD with no local main branch (only origin/main), so turbo's --affected fell back to "assume all files have changed" on every pull_request run, sharding the entire 23-package workspace regardless of how narrow the actual diff was. Setting TURBO_SCM_BASE to the PR's base commit sha, mirroring ci.yml's own working setup, lets turbo resolve the real diff range instead of guessing. --- .github/workflows/mutation.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 7451e04c7..74b48b095 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -17,6 +17,8 @@ permissions: env: # Mirrors ci.yml's own TURBO_FLAGS exactly: a PR scopes to the packages it actually touches (and their dependents, via turbo's own dependency-aware --affected), main runs the whole workspace. TURBO_FLAGS: ${{ github.event_name == 'pull_request' && '--affected' || '' }} + # Mirrors ci.yml's own TURBO_SCM_BASE exactly, and is required for --affected to mean anything on a pull_request: actions/checkout's detached-HEAD checkout has no local branch named `main` (only `origin/main`), so without an explicit base turbo cannot resolve the literal `main` ref it falls back to, warns "unable to detect git range, assuming all files have changed", and silently treats every package as affected -- confirmed directly (2026-09-15: a pull_request run's own Plan job logged that exact fallback and planned all 23 workspace packages into 8 shards for a PR whose diff touched a single package). + TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }} jobs: plan: From ce3c57a93c3c786fc1ae158c235ee60341d4a44a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:48:09 +0100 Subject: [PATCH 087/101] test(document-cli): cover mergePptxTableCells boundaries and the doc-format union arms Adds exact-boundary tests for mergePptxTableCells's row/column overrun checks (a merge landing exactly on the table's last row/column must succeed, not throw), direct hMerge/ vMerge attribute assertions distinguishing the row-loop and column-loop merge flags from each other, and loop-boundary checks proving cells past rowSpan/colSpan are never touched. Also covers wrongDocument's "no document" wording when nothing is open at all, and the doc-format arm of wordprocessingDocument/styledWordprocessingDocument, which nothing else in this suite previously exercised through APPEND_PARAGRAPH or TOGGLE_RUN_BOLD. --- .../src/tui/state/reducer.test.ts | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 6730e0f56..292a1f087 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -35,6 +35,7 @@ import { appReducer, createInitialState } from "./reducer.js"; import type { AppState, CsvOpenDocument, + DocOpenDocument, DocxOpenDocument, EpubOpenDocument, MarkdownOpenDocument, @@ -82,6 +83,14 @@ function docxDocument(state: AppState): DocxOpenDocument { return doc; } +function docDocument(state: AppState): DocOpenDocument { + const doc = state.openDocument; + if (doc?.format !== "doc") { + throw new Error("expected an open doc document"); + } + return doc; +} + function odsDocument(state: AppState): OdsOpenDocument { const doc = state.openDocument; if (doc?.format !== "ods") { @@ -1641,6 +1650,47 @@ describe("appReducer markdown mutations", () => { }); }); +describe("appReducer doc (legacy Word) mutations", () => { + // wordprocessingDocument's own format union admits doc alongside docx/odt/markdown -- covered here specifically because every other member is already exercised elsewhere by name, and a mutant collapsing this one arm of the union would only ever be caught by a doc-format dispatch. + it("appends a paragraph through the same generic action docx/odt/markdown already share", () => { + const opened = openDocDocument(createDoc().toBytes()); + const before = docDocument(opened).editor.paragraphs().length; + const appended = appReducer(opened, { + type: "APPEND_PARAGRAPH", + text: "New paragraph", + styleId: undefined, + alignment: undefined, + }); + expect(appended.status?.severity).not.toBe("warning"); + expect(docDocument(appended).editor.paragraphs()).toHaveLength(before + 1); + }); + + // styledWordprocessingDocument's own format union admits doc alongside docx/odt (never markdown, which has no such fields at all -- see the markdown describe block above) -- covered here for the identical reason: doc is the one arm nothing else in this file dispatches through this specific function. + it("toggles bold on a run through the same generic action docx/odt already share", () => { + const withRun = applyAll( + [ + { + type: "APPEND_PARAGRAPH", + text: undefined, + styleId: undefined, + alignment: undefined, + }, + { type: "APPEND_RUN", blockIndex: 0, text: "Hello" }, + ], + openDocDocument(createDoc().toBytes()), + ); + const bolded = appReducer(withRun, { + type: "TOGGLE_RUN_BOLD", + blockIndex: 0, + runIndex: 0, + }); + expect(bolded.status?.severity).not.toBe("warning"); + expect(docDocument(bolded).editor.paragraphs()[0]?.runs()[0]?.bold).toBe( + true, + ); + }); +}); + describe("appReducer undo", () => { // Proves undo generalises to markdown's own live-view MarkdownEditor with zero markdown-specific reducer code beyond toUndoSnapshot's own byte<->text branch -- the same encodeMarkdownText/decodeMarkdownText round trip through the shared undo stack every other mutating action already uses. it("restores a markdown document to its paragraphs before the last edit", () => { @@ -2026,6 +2076,23 @@ describe("appReducer MERGE_SLIDE_TABLE_CELLS", () => { expect(result.status?.text).toContain("pptx or odp"); }); + // wrongDocument's own "actual" half names the real open format when there is one (covered just above), but falls back to the literal words "no document" when state.openDocument is undefined -- distinct from any real format string, so it must come from its own ternary branch rather than always compute an actual format. + it("says 'no document' rather than a format name when nothing is open at all", () => { + const result = appReducer(createInitialState(), { + type: "MERGE_SLIDE_TABLE_CELLS", + slideIndex: 0, + tableIndex: 0, + startRow: 0, + startColumn: 0, + rowSpan: 1, + colSpan: 1, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a pptx or odp document; the open document is no document", + ); + }); + it("rejects a non-integer or non-positive rowSpan/colSpan instead of merging anything", () => { const editor = createPptx(); editor.addSlide(); @@ -2086,6 +2153,135 @@ describe("appReducer MERGE_SLIDE_TABLE_CELLS", () => { expect(result.status?.text).toContain("exceeds this table's own 2 columns"); expect(result.openDocument).toBe(withTable.openDocument); }); + + // mergePptxTableCells's own row/column bounds checks compare with a strict `>`, not `>=` -- a merge landing exactly on the table's own last row/column is valid, not an overrun. rowSpan=1/colSpan=1 here also proves the "must be positive integers" guard rejects only BELOW 1, not AT 1. + it("accepts a 1x1 merge landing exactly on the table's own last row and column", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const withTable = appReducer(opened, { + type: "ADD_SLIDE_TABLE", + slideIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + rows: 2, + columns: 2, + }); + + const result = appReducer(withTable, { + type: "MERGE_SLIDE_TABLE_CELLS", + slideIndex: 0, + tableIndex: 0, + startRow: 1, + startColumn: 1, + rowSpan: 1, + colSpan: 1, + }); + expect(result.status?.severity).not.toBe("warning"); + expect(result.hasUnsavedChanges).toBe(true); + }); + + // startRow=2/rowSpan=2 on a 3-row table overruns by exactly one row -- distinguishes the real check from a mutant that flips `+` to `-` (2-2=0, which would never exceed 3 and would fall through to a table access that is merely undefined rather than out of range) or drops the whole guard block outright, both of which would surface a DIFFERENT warning than this one. + it("names the exact rowSpan/startRow/row-count in the row-overrun message", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const withTable = appReducer(opened, { + type: "ADD_SLIDE_TABLE", + slideIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + rows: 3, + columns: 2, + }); + + const result = appReducer(withTable, { + type: "MERGE_SLIDE_TABLE_CELLS", + slideIndex: 0, + tableIndex: 0, + startRow: 2, + startColumn: 0, + rowSpan: 2, + colSpan: 1, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "mergeSlideTableCells: rowSpan 2 starting at row 2 exceeds this table's own 3 rows", + ); + expect(result.openDocument).toBe(withTable.openDocument); + }); + + // A rectangle taller than 1 row but only 1 column wide: the covered cell directly below the anchor must carry verticalMerge alone, never horizontalMerge (columnOffset is always 0 in a 1-wide merge, so the "columnOffset > 0" branch must never fire), and the row just past rowSpan must be left completely untouched by the row loop. + it("sets only verticalMerge on the covered cell of a 1-column-wide, multi-row merge, and never touches the row past rowSpan", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const withTable = appReducer(opened, { + type: "ADD_SLIDE_TABLE", + slideIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + rows: 3, + columns: 2, + }); + + const merged = appReducer(withTable, { + type: "MERGE_SLIDE_TABLE_CELLS", + slideIndex: 0, + tableIndex: 0, + startRow: 0, + startColumn: 0, + rowSpan: 2, + colSpan: 1, + }); + const rows = pptxDocument(merged).editor.slides()[0]?.tables()[0]?.rows(); + const coveredBelow = rows?.[1]?.cells()[0]; + const pastRowSpan = rows?.[2]?.cells()[0]; + expect(coveredBelow?.element.attributes).toContainEqual({ + name: "vMerge", + value: "1", + }); + expect( + coveredBelow?.element.attributes.some((a) => a.name === "hMerge"), + ).toBe(false); + expect(pastRowSpan?.element.attributes).toEqual([]); + }); + + // The column-wide counterpart: a rectangle wider than 1 column but only 1 row tall must set horizontalMerge alone on its covered cell (rowOffset is always 0, so "rowOffset > 0" must never fire), and the column just past colSpan must be left completely untouched by the column loop. + it("sets only horizontalMerge on the covered cell of a 1-row-tall, multi-column merge, and never touches the column past colSpan", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const withTable = appReducer(opened, { + type: "ADD_SLIDE_TABLE", + slideIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + rows: 2, + columns: 3, + }); + + const merged = appReducer(withTable, { + type: "MERGE_SLIDE_TABLE_CELLS", + slideIndex: 0, + tableIndex: 0, + startRow: 0, + startColumn: 0, + rowSpan: 1, + colSpan: 2, + }); + const firstRowCells = pptxDocument(merged) + .editor.slides()[0] + ?.tables()[0] + ?.rows()[0] + ?.cells(); + const coveredRight = firstRowCells?.[1]; + const pastColSpan = firstRowCells?.[2]; + expect(coveredRight?.element.attributes).toContainEqual({ + name: "hMerge", + value: "1", + }); + expect( + coveredRight?.element.attributes.some((a) => a.name === "vMerge"), + ).toBe(false); + expect(pastColSpan?.element.attributes).toEqual([]); + }); }); describe("appReducer SET_SLIDE_NOTES on pptx", () => { From df790b9ffc9c7360e13477f28532221f9c75ba3f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:59:03 +0100 Subject: [PATCH 088/101] test(document-cli): assert exact status text for four out-of-range and wrong-doc-type warnings Pins the literal warning text for the missing-paragraph-index, missing-page-index, and markdown-wrong-document-type branches so a StringLiteral mutant on any of these messages fails the assertion instead of surviving on severity alone. --- packages/document-cli/src/tui/state/reducer.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 292a1f087..017e4dbfe 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -646,6 +646,7 @@ describe("appReducer docx mutations", () => { runIndex: 0, }); expect(missed.status?.severity).toBe("warning"); + expect(missed.status?.text).toBe("There is no paragraph at index 7"); expect(missed.hasUnsavedChanges).toBe(false); }); @@ -727,6 +728,7 @@ describe.each(["docx", "odt"] as const)( fontFamily: "Georgia", }); expect(missed.status?.severity).toBe("warning"); + expect(missed.status?.text).toBe("There is no paragraph at index 7"); expect(missed.hasUnsavedChanges).toBe(false); }); }, @@ -1575,6 +1577,9 @@ describe("appReducer markdown mutations", () => { runIndex: 0, }); expect(underlineResult.status?.severity).toBe("warning"); + expect(underlineResult.status?.text).toBe( + "That action needs a docx, odt or doc document; the open document is markdown", + ); expect(underlineResult.hasUnsavedChanges).toBe(false); const colorResult = appReducer(opened, { @@ -2685,6 +2690,7 @@ describe("appReducer PDF item and page mutations", () => { init: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no page at index 5"); expect(result.hasUnsavedChanges).toBe(false); }); From 4905fa2e648f864091292ec6accf97e4536244aa Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:06:45 +0100 Subject: [PATCH 089/101] test(document-cli): cover every remaining PDF field-edit wrong-kind branch withPdfItemMatching's kindLabel string is a separate literal at every call site, so the existing FRAME/FILL/one-per-kind wrong-kind tests only killed the mutant at their own call site. Adds a data-driven case per action that had none (text's own text/position/color/ width/underline setters, rect's stroke, ellipse's frame/stroke, all three line setters, path's fill/stroke, image's frame/source, and link's frame), each asserting the exact kindLabel substring so a StringLiteral mutant on any of them fails the assertion. --- .../src/tui/state/reducer.test.ts | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 017e4dbfe..c3a618650 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -3239,6 +3239,192 @@ describe("appReducer PDF item and page mutations", () => { expect(result.status?.severity).toBe("warning"); expect(result.status?.text).toContain("not internalLink"); }); + + // Every other SET_PDF_*_* field-edit action routes through the identical withPdfItemMatching guard, but each call site carries its OWN copy of the kindLabel string literal -- exercising the wrong-kind path through the FRAME/FILL/one representative action per kind above does not cover the same literal at a sibling action's own call site (e.g. SET_PDF_RECT_FRAME's "rect" and SET_PDF_RECT_STROKE's "rect" are two distinct AST nodes). This table drives every remaining action through the wrong-kind branch once each. + const wrongKindCases: [string, Action, string][] = [ + [ + "SET_PDF_TEXT_TEXT", + { + type: "SET_PDF_TEXT_TEXT", + pageIndex: 0, + itemIndex: RECT_INDEX, + text: "x", + }, + "not text", + ], + [ + "SET_PDF_TEXT_POSITION", + { + type: "SET_PDF_TEXT_POSITION", + pageIndex: 0, + itemIndex: RECT_INDEX, + xPt: 0, + yPt: 0, + }, + "not text", + ], + [ + "SET_PDF_TEXT_COLOR", + { + type: "SET_PDF_TEXT_COLOR", + pageIndex: 0, + itemIndex: RECT_INDEX, + color: { r: 0, g: 0, b: 0 }, + }, + "not text", + ], + [ + "SET_PDF_TEXT_WIDTH", + { + type: "SET_PDF_TEXT_WIDTH", + pageIndex: 0, + itemIndex: RECT_INDEX, + widthPt: 1, + }, + "not text", + ], + [ + "TOGGLE_PDF_TEXT_UNDERLINE", + { + type: "TOGGLE_PDF_TEXT_UNDERLINE", + pageIndex: 0, + itemIndex: RECT_INDEX, + }, + "not text", + ], + [ + "SET_PDF_RECT_STROKE", + { + type: "SET_PDF_RECT_STROKE", + pageIndex: 0, + itemIndex: TEXT_INDEX, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + }, + "not rect", + ], + [ + "SET_PDF_ELLIPSE_FRAME", + { + type: "SET_PDF_ELLIPSE_FRAME", + pageIndex: 0, + itemIndex: TEXT_INDEX, + xPt: 0, + yPt: 0, + widthPt: 1, + heightPt: 1, + }, + "not ellipse", + ], + [ + "SET_PDF_ELLIPSE_STROKE", + { + type: "SET_PDF_ELLIPSE_STROKE", + pageIndex: 0, + itemIndex: TEXT_INDEX, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + }, + "not ellipse", + ], + [ + "SET_PDF_LINE_FROM", + { + type: "SET_PDF_LINE_FROM", + pageIndex: 0, + itemIndex: TEXT_INDEX, + x1Pt: 0, + y1Pt: 0, + }, + "not line", + ], + [ + "SET_PDF_LINE_TO", + { + type: "SET_PDF_LINE_TO", + pageIndex: 0, + itemIndex: TEXT_INDEX, + x2Pt: 0, + y2Pt: 0, + }, + "not line", + ], + [ + "SET_PDF_LINE_COLOR", + { + type: "SET_PDF_LINE_COLOR", + pageIndex: 0, + itemIndex: TEXT_INDEX, + color: { r: 0, g: 0, b: 0 }, + }, + "not line", + ], + [ + "SET_PDF_PATH_FILL", + { + type: "SET_PDF_PATH_FILL", + pageIndex: 0, + itemIndex: TEXT_INDEX, + fill: { r: 0, g: 0, b: 0 }, + }, + "not path", + ], + [ + "SET_PDF_PATH_STROKE", + { + type: "SET_PDF_PATH_STROKE", + pageIndex: 0, + itemIndex: TEXT_INDEX, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + }, + "not path", + ], + [ + "SET_PDF_IMAGE_FRAME", + { + type: "SET_PDF_IMAGE_FRAME", + pageIndex: 0, + itemIndex: TEXT_INDEX, + xPt: 0, + yPt: 0, + widthPt: 1, + heightPt: 1, + }, + "not image", + ], + [ + "SET_PDF_IMAGE_SOURCE", + { + type: "SET_PDF_IMAGE_SOURCE", + pageIndex: 0, + itemIndex: TEXT_INDEX, + format: "png", + bytes: REAL_PNG_BYTES, + }, + "not image", + ], + [ + "SET_PDF_LINK_FRAME", + { + type: "SET_PDF_LINK_FRAME", + pageIndex: 0, + itemIndex: TEXT_INDEX, + xPt: 0, + yPt: 0, + widthPt: 1, + heightPt: 1, + }, + "not link", + ], + ]; + + it.each(wrongKindCases)( + "warns rather than crashing when %s targets an item of the wrong kind", + (_name, action, expectedFragment) => { + const state = pdfMultiItemState(); + const result = appReducer(state, action); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain(expectedFragment); + }, + ); }); }); From ae3e9dbe829f0938db16b8cb2e3955ba83d7cdeb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:11:22 +0100 Subject: [PATCH 090/101] test(document-cli): cover INSERT_ODT_FORMULA's tree rebuild and wrong-doc guard mutableMathMlNode had no coverage at all before this: an element/text node is rebuilt as a fresh mutable tree so document-schema.js's mutable MathMlNode type-checks with no cast, and the four kinds MathML content never carries (cdata/comment/declaration/pi) collapse to the documented empty stand-in each schema variant requires. Reads the round-tripped formula back through readOdtContent/formulaOfBlock to assert on the real embedded package, plus a second case for the wrong-document-format warning. --- .../src/tui/state/reducer.test.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index c3a618650..b07c8a5d3 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -9,6 +9,8 @@ import { createPptx, createXls, drawingOfBlock, + formulaOfBlock, + type MathMlNode, odsToXlsx, openDoc, openDocx, @@ -3428,6 +3430,76 @@ describe("appReducer PDF item and page mutations", () => { }); }); +describe("appReducer INSERT_ODT_FORMULA", () => { + it("rebuilds an element/text tree as fresh mutable objects, and collapses cdata/comment/declaration/pi nodes to their documented empty stand-ins", () => { + const opened = openOdtDocument(createOdt().toBytes()); + const mathml: MathMlNode[] = [ + { + type: "element", + tag: "mrow", + attributes: [{ name: "class", value: "unit" }], + children: [ + { type: "text", value: "a" }, + { type: "cdata" }, + { type: "comment" }, + { type: "declaration" }, + { type: "pi" }, + ], + }, + ]; + const withFormula = appReducer(opened, { + type: "INSERT_ODT_FORMULA", + mathml, + frame: { xPt: 0, yPt: 0, widthPt: 20, heightPt: 10 }, + }); + expect(withFormula.hasUnsavedChanges).toBe(true); + + const content = readOdtContent(odtDocument(withFormula).editor.toPackage()); + if (content.kind !== "wordprocessing") { + throw new Error( + `expected a wordprocessing ContentDocument, got ${content.kind}`, + ); + } + const block = content.sections + .flatMap((section) => section.blocks) + .find((candidate) => candidate.kind === "embeddedObject"); + if (block?.kind !== "embeddedObject") { + throw new Error("expected an embedded formula block"); + } + const formula = formulaOfBlock(block); + if (formula === undefined) { + throw new Error("expected the embedded object to carry a formula"); + } + const root = formula.mathml[0]; + if (root?.type !== "element") { + throw new Error("expected the root node to survive as a real element"); + } + expect(root.tag).toBe("mrow"); + expect(root.attributes).toStrictEqual([{ name: "class", value: "unit" }]); + expect(root.children).toStrictEqual([ + { type: "text", value: "a" }, + { type: "cdata", value: "" }, + { type: "comment", value: "" }, + { type: "declaration", attributes: [] }, + { type: "pi", target: "", content: "" }, + ]); + }); + + it("warns rather than crashing when the open document is not odt", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "INSERT_ODT_FORMULA", + mathml: [{ type: "element", tag: "mi", attributes: [], children: [] }], + frame: { xPt: 0, yPt: 0, widthPt: 20, heightPt: 10 }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("an odt document"); + }); +}); + describe("appReducer ADD_RECT / ADD_ELLIPSE / ADD_LINE / ADD_PATH on odp", () => { it("adds each real vector kind to an odp slide, reachable through OdpSlide.addVector -- recovered by readOdpContent as a synthetic embedded drawing block, since ContentSlide itself has no vectors array", () => { const editor = createOdp(); From 2712128cecf1cd38d328531024e457cbf9bb8d8c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:13:30 +0100 Subject: [PATCH 091/101] test(document-cli): cover the odg ellipse/line/path vector-add cases The odg branch's own inner switch (addRect/addEllipse/addLine/addPath) had only ever been reached via ADD_RECT; each case is a separate switch-statement mutant, so nothing proved removing the ellipse/line/path cases would still pass. Adds each kind through the page's own vectors() accessor, mirroring the equivalent odp coverage already in this file. --- .../src/tui/state/reducer.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index b07c8a5d3..9eab00161 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -3616,6 +3616,49 @@ describe("appReducer ADD_RECT / ADD_ELLIPSE / ADD_LINE / ADD_PATH on odp", () => expect(withRect.hasUnsavedChanges).toBe(true); expect(odgDocument(withRect).editor.pages()[0]?.vectors()).toHaveLength(1); }); + + // The odg branch dispatches through its own inner switch (addRect/addEllipse/addLine/addPath), one case per real OdgPage method -- distinct from the ADD_RECT/ADD_ELLIPSE/ADD_LINE/ADD_PATH coverage above, which only ever reaches odg via ADD_RECT. Each case is its own switch-statement mutant, so proving the rect case works says nothing about whether removing the ellipse/line/path cases would still pass. + it("adds each real vector kind to an odg page via the page's own addEllipse/addLine/addPath", () => { + const editor = createOdg(); + editor.addPage(); + const opened = openOdgDocument(editor.toBytes()); + + const withEllipse = appReducer(opened, { + type: "ADD_ELLIPSE", + containerIndex: 0, + init: { frame: { xPt: 60, yPt: 10, widthPt: 40, heightPt: 30 } }, + }); + const withLine = appReducer(withEllipse, { + type: "ADD_LINE", + containerIndex: 0, + init: { + from: { xPt: 0, yPt: 100 }, + to: { xPt: 100, yPt: 100 }, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + }, + }); + const withPath = appReducer(withLine, { + type: "ADD_PATH", + containerIndex: 0, + init: { + frame: { xPt: 0, yPt: 150, widthPt: 50, heightPt: 50 }, + subpaths: [ + { + start: { xPt: 0, yPt: 50 }, + segments: [{ kind: "line", to: { xPt: 25, yPt: 0 } }], + closed: false, + }, + ], + }, + }); + expect(withPath.hasUnsavedChanges).toBe(true); + const vectors = odgDocument(withPath).editor.pages()[0]?.vectors(); + expect(vectors?.map((vector) => vector.kind)).toEqual([ + "ellipse", + "line", + "path", + ]); + }); }); describe("appReducer SET_VECTOR_FILL / SET_VECTOR_STROKE on odg", () => { From dca2956062cd2f9eba9849911bdd8b3f723efdeb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:16:25 +0100 Subject: [PATCH 092/101] test(document-cli): cover CANCEL_QUIT and SAVE_ERROR CANCEL_QUIT had no coverage at all -- its own confirmQuit-overlay-false write and isExiting-stays-false behaviour were unproven, and CONFIRM_QUIT's sibling write to the same overlay field was reached but never asserted. SAVE_ERROR likewise had no test: it now asserts the exact error message surfaces as status text with hasUnsavedChanges left untouched. --- .../src/tui/state/reducer.test.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 9eab00161..8350ea4de 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -359,7 +359,13 @@ describe("appReducer navigation", () => { const asked = appReducer(dirty, { type: "REQUEST_QUIT" }); expect(asked.overlays.confirmQuit).toBe(true); expect(asked.isExiting).toBe(false); - expect(appReducer(asked, { type: "CONFIRM_QUIT" }).isExiting).toBe(true); + const confirmed = appReducer(asked, { type: "CONFIRM_QUIT" }); + expect(confirmed.isExiting).toBe(true); + expect(confirmed.overlays.confirmQuit).toBe(false); + + const cancelled = appReducer(asked, { type: "CANCEL_QUIT" }); + expect(cancelled.isExiting).toBe(false); + expect(cancelled.overlays.confirmQuit).toBe(false); }); }); @@ -454,6 +460,22 @@ describe("appReducer SAVE_SUCCESS", () => { }); }); +describe("appReducer SAVE_ERROR", () => { + it("surfaces the failure message as an error status, without touching hasUnsavedChanges", () => { + const dirty = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(dirty, { + type: "SAVE_ERROR", + message: "disk is full", + }); + expect(result.status?.severity).toBe("error"); + expect(result.status?.text).toBe("disk is full"); + expect(result.hasUnsavedChanges).toBe(dirty.hasUnsavedChanges); + }); +}); + describe("appReducer OPEN_OVERLAY / CLOSE_OVERLAY", () => { it("opens and closes the confirmClose overlay without touching any other overlay", () => { const opened = appReducer(createInitialState(), { From cbd754ac435cb121cce750ecb9c64dc71b495f18 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:19:10 +0100 Subject: [PATCH 093/101] test(document-cli): cover withShape's own odg page-vs-slide wording withWideShape's identical ternary was already covered via SET_SHAPE_TEXT, but withShape is a separate function with its own copy of the same literal, reached only by SET_SHAPE_ROTATION. Adds the missing odg case so removing withShape's own page/slide branch fails too. --- .../document-cli/src/tui/state/reducer.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 8350ea4de..0c9fde667 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -2389,6 +2389,22 @@ describe("appReducer SET_SHAPE_ROTATION on pptx", () => { expect(result.status?.severity).toBe("warning"); expect(result.status?.text).toBe("There is no shape 3 on page 0"); }); + + // SET_SHAPE_TEXT above resolves through withWideShape, a DIFFERENT function from withShape (used only by SET_SHAPE_ROTATION) -- each has its own copy of the identical "page"/"slide" ternary, so covering one says nothing about the other. + it("warns with 'page' rather than 'slide' when SET_SHAPE_ROTATION targets a missing shape on an odg page", () => { + const editor = createOdg(); + editor.addPage(); + const opened = openOdgDocument(editor.toBytes()); + + const result = appReducer(opened, { + type: "SET_SHAPE_ROTATION", + containerIndex: 0, + shapeIndex: 3, + rotationDeg: 10, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no shape 3 on page 0"); + }); }); describe("appReducer xlsx (read-only PDF-preview) documents", () => { From 92d4812edeb2aa6a2ccd5cd66777a2dc8f66318e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:24:39 +0100 Subject: [PATCH 094/101] test(document-cli): cover the standalone status/stack/run actions OPEN_FILE_ERROR, SAVE_AS_REQUEST, SET_SEARCH_QUERY, CLEAR_STATUS, DISMISS_ERROR_DETAIL and SET_RUN_TEXT had no coverage anywhere in the suite -- each is a plain state-field write with no format branching, so one direct dispatch per action proves the write. --- .../src/tui/state/reducer.test.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 0c9fde667..2a70d97c1 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -476,6 +476,64 @@ describe("appReducer SAVE_ERROR", () => { }); }); +describe("appReducer OPEN_FILE_ERROR", () => { + it("records the failure as an error status and populates errorDetail", () => { + const result = appReducer(createInitialState(), { + type: "OPEN_FILE_ERROR", + message: "not a valid docx", + detail: "unexpected end of zip central directory", + }); + expect(result.status?.severity).toBe("error"); + expect(result.status?.text).toBe("not a valid docx"); + expect(result.errorDetail).toStrictEqual({ + message: "not a valid docx", + detail: "unexpected end of zip central directory", + }); + }); +}); + +describe("appReducer SAVE_AS_REQUEST / SET_SEARCH_QUERY / CLEAR_STATUS / DISMISS_ERROR_DETAIL", () => { + it("pushes the saveAsPrompt screen onto the stack", () => { + const result = appReducer(createInitialState(), { + type: "SAVE_AS_REQUEST", + }); + expect(result.stack.map((screen) => screen.kind)).toEqual([ + "launcher", + "saveAsPrompt", + ]); + }); + + it("replaces the search query verbatim", () => { + const result = appReducer(createInitialState(), { + type: "SET_SEARCH_QUERY", + query: "invoice", + }); + expect(result.searchQuery).toBe("invoice"); + }); + + it("clears an existing status message", () => { + const withStatus = appReducer(createInitialState(), { + type: "OPEN_FILE_ERROR", + message: "boom", + detail: undefined, + }); + expect(withStatus.status).toBeDefined(); + const cleared = appReducer(withStatus, { type: "CLEAR_STATUS" }); + expect(cleared.status).toBeUndefined(); + }); + + it("dismisses errorDetail without touching the status message", () => { + const withError = appReducer(createInitialState(), { + type: "OPEN_FILE_ERROR", + message: "boom", + detail: "trace", + }); + const dismissed = appReducer(withError, { type: "DISMISS_ERROR_DETAIL" }); + expect(dismissed.errorDetail).toBeUndefined(); + expect(dismissed.status).toStrictEqual(withError.status); + }); +}); + describe("appReducer OPEN_OVERLAY / CLOSE_OVERLAY", () => { it("opens and closes the confirmClose overlay without touching any other overlay", () => { const opened = appReducer(createInitialState(), { @@ -659,6 +717,29 @@ describe("appReducer docx mutations", () => { expect(unbolded.hasUnsavedChanges).toBe(true); }); + it("replaces a run's text via SET_RUN_TEXT", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: undefined, + styleId: undefined, + alignment: undefined, + }, + { type: "APPEND_RUN", blockIndex: 0, text: "Hello" }, + ]); + const retyped = appReducer(state, { + type: "SET_RUN_TEXT", + blockIndex: 0, + runIndex: 0, + text: "Goodbye", + }); + expect(retyped.hasUnsavedChanges).toBe(true); + expect(docxDocument(retyped).editor.paragraphs()[0]?.runs()[0]?.text).toBe( + "Goodbye", + ); + }); + it("reports a missing run rather than throwing", () => { const state = appReducer(createInitialState(), { type: "CREATE_DOCUMENT", From c9b9bd95994edd54c6a0d6029ffc7863b96d0fe3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:28:37 +0100 Subject: [PATCH 095/101] test(document-cli): cover SET_TABLE_CELL_TEXT's happy path and both guards Nothing in the suite ever dispatched SET_TABLE_CELL_TEXT before this: the missing-table and missing-cell warnings, and the actual cell-text write itself (verified through a real readDocxContent round trip), all had zero coverage. --- .../src/tui/state/reducer.test.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 2a70d97c1..2a5865cfc 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -1040,6 +1040,72 @@ describe("appReducer APPEND_TABLE and MERGE_TABLE_CELLS on docx/odt", () => { }); }); +describe("appReducer SET_TABLE_CELL_TEXT", () => { + it("replaces a real docx table cell's text in place", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { type: "APPEND_TABLE", rows: 2, columns: 2 }, + ]); + const edited = appReducer(state, { + type: "SET_TABLE_CELL_TEXT", + tableIndex: 0, + row: 1, + column: 1, + text: "Total", + }); + expect(edited.hasUnsavedChanges).toBe(true); + const content = readDocxContent(docxDocument(edited).editor.toPackage()); + if (content.kind !== "wordprocessing") { + throw new Error( + `expected a wordprocessing ContentDocument, got ${content.kind}`, + ); + } + const tableBlock = content.sections[0]?.blocks[0]; + if (tableBlock?.kind !== "table") { + throw new Error(`expected a table block, got ${tableBlock?.kind}`); + } + const cellText = tableBlock.rows[1]?.cells[1]?.blocks + .flatMap((block) => (block.kind === "paragraph" ? block.runs : [])) + .map((run) => run.text) + .join(""); + expect(cellText).toBe("Total"); + }); + + it("warns rather than crashing for a table index that does not exist", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "SET_TABLE_CELL_TEXT", + tableIndex: 0, + row: 0, + column: 0, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no table at index 0"); + }); + + it("warns rather than crashing for a row/column that does not exist", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { type: "APPEND_TABLE", rows: 2, columns: 2 }, + ]); + const result = appReducer(state, { + type: "SET_TABLE_CELL_TEXT", + tableIndex: 0, + row: 5, + column: 0, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "There is no cell at row 5, column 0 of table 0", + ); + }); +}); + describe("appReducer SET_LIST_ITEM_TEXT on odt", () => { it("replaces a real list item's text and the change round-trips through re-decoding the package", () => { const editor = createOdt(); From bcc5b32064390c6c6f7da92e31962cdbc9b869c9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:34:18 +0100 Subject: [PATCH 096/101] test(document-cli): cover ADD_PDF_TEXT and INSERT_DOCX_FORMULA ADD_PDF_TEXT (add a second real text item, round-tripped through toBytes()/openPdf()) and INSERT_DOCX_FORMULA (both the written-OMML happy path, read back through readDocxContent's own embedded-formula splice, and the no-OMML-content warning branch) had no coverage anywhere in the suite before this, plus INSERT_DOCX_FORMULA's missing-paragraph and wrong-document-format guards. --- .../src/tui/state/reducer.test.ts | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 2a5865cfc..387c1b43e 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -2708,6 +2708,31 @@ describe("appReducer PDF item and page mutations", () => { expect(reopenedItem.yPt).toBeCloseTo(60, 0); }); + it("adds a text item via ADD_PDF_TEXT, present after a toBytes()/openPdf() round trip", () => { + const opened = openPdfDocument(pdfTestBytes()); + + const withText = appReducer(opened, { + type: "ADD_PDF_TEXT", + pageIndex: 0, + init: { + xPt: 15, + yPt: 25, + text: "Second", + font: { family: "Helvetica", weight: "normal", style: "normal" }, + sizePt: 14, + color: { r: 0, g: 0, b: 0 }, + }, + }); + expect(pdfDocument(withText).editor.page(0)?.items()).toHaveLength(2); + + const reopened = openPdf(pdfDocument(withText).editor.toBytes()); + const items = reopened.page(0)?.items() ?? []; + const second = items.find( + (item) => item.kind === "text" && item.text === "Second", + ); + expect(second).toBeDefined(); + }); + it("adds a rect via ADD_PDF_RECT, present after a toBytes()/openPdf() round trip", () => { const opened = openPdfDocument(pdfTestBytes()); @@ -3685,6 +3710,120 @@ describe("appReducer INSERT_ODT_FORMULA", () => { }); }); +describe("appReducer INSERT_DOCX_FORMULA", () => { + it("writes a real OMML equation, read back as an embedded formula block through readDocxContent", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: undefined, + styleId: undefined, + alignment: undefined, + }, + ]); + const mathml: MathMlNode[] = [ + { + type: "element", + tag: "mi", + attributes: [], + children: [{ type: "text", value: "x" }], + }, + ]; + const withFormula = appReducer(state, { + type: "INSERT_DOCX_FORMULA", + blockIndex: 0, + mathml, + }); + expect(withFormula.hasUnsavedChanges).toBe(true); + expect(withFormula.status?.severity).not.toBe("warning"); + + const content = readDocxContent( + docxDocument(withFormula).editor.toPackage(), + ); + if (content.kind !== "wordprocessing") { + throw new Error( + `expected a wordprocessing ContentDocument, got ${content.kind}`, + ); + } + const block = content.sections + .flatMap((section) => section.blocks) + .find((candidate) => candidate.kind === "embeddedObject"); + if (block?.kind !== "embeddedObject") { + throw new Error("expected an embedded formula block"); + } + const formula = formulaOfBlock(block); + expect(formula?.mathml[0]).toStrictEqual({ + type: "element", + tag: "mi", + attributes: [], + children: [{ type: "text", value: "x" }], + }); + }); + + it("warns instead of writing an empty paragraph when the formula produces no OMML content", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: undefined, + styleId: undefined, + alignment: undefined, + }, + ]); + const withFormula = appReducer(state, { + type: "INSERT_DOCX_FORMULA", + blockIndex: 0, + mathml: [], + }); + expect(withFormula.status?.severity).toBe("warning"); + expect(withFormula.status?.text).toBe( + "The formula produced no OMML content and was not written", + ); + + const content = readDocxContent( + docxDocument(withFormula).editor.toPackage(), + ); + if (content.kind !== "wordprocessing") { + throw new Error( + `expected a wordprocessing ContentDocument, got ${content.kind}`, + ); + } + expect( + content.sections + .flatMap((section) => section.blocks) + .some((candidate) => candidate.kind === "embeddedObject"), + ).toBe(false); + }); + + it("warns rather than crashing for a paragraph index that does not exist", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "INSERT_DOCX_FORMULA", + blockIndex: 7, + mathml: [{ type: "element", tag: "mi", attributes: [], children: [] }], + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no paragraph at index 7"); + }); + + it("warns rather than crashing when the open document is not docx", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "odt", + }); + const result = appReducer(created, { + type: "INSERT_DOCX_FORMULA", + blockIndex: 0, + mathml: [{ type: "element", tag: "mi", attributes: [], children: [] }], + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("a docx document"); + }); +}); + describe("appReducer ADD_RECT / ADD_ELLIPSE / ADD_LINE / ADD_PATH on odp", () => { it("adds each real vector kind to an odp slide, reachable through OdpSlide.addVector -- recovered by readOdpContent as a synthetic embedded drawing block, since ContentSlide itself has no vectors array", () => { const editor = createOdp(); From fe6bbcb9c2fda33b52426dc14d8629a0ddee1346 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:39:57 +0100 Subject: [PATCH 097/101] test(document-cli): cover six previously untested slide/page/shape actions None of ADD_SLIDE, ADD_PAGE, ADD_TEXTBOX, ADD_IMAGE, SET_SHAPE_FRAME or SET_SHEET_PRINT_SETTINGS had any coverage anywhere in the suite. Adds a real happy-path dispatch per action on pptx and/or odg, plus the missing-page/slide and wrong-document-format guards for the two dual-branch actions (ADD_TEXTBOX, ADD_IMAGE). --- .../src/tui/state/reducer.test.ts | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 387c1b43e..6be3e0ca9 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -4088,3 +4088,249 @@ describe("appReducer diagnostics and selection", () => { expect(state.selection).toEqual({ bodyList: 4, "slideDetail:2": 1 }); }); }); + +describe("appReducer ADD_SLIDE / ADD_PAGE", () => { + it("appends a real slide to a pptx presentation", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const withSlide = appReducer(opened, { type: "ADD_SLIDE" }); + expect(withSlide.hasUnsavedChanges).toBe(true); + expect(pptxDocument(withSlide).editor.slides()).toHaveLength(2); + }); + + it("warns rather than crashing when the open document is neither pptx, odp nor ppt", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { type: "ADD_SLIDE" }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("a pptx or odp document"); + }); + + it("appends a real page to an odg drawing", () => { + const editor = createOdg(); + editor.addPage(); + const opened = openOdgDocument(editor.toBytes()); + const withPage = appReducer(opened, { type: "ADD_PAGE" }); + expect(withPage.hasUnsavedChanges).toBe(true); + expect(odgDocument(withPage).editor.pages()).toHaveLength(2); + }); + + it("warns rather than crashing when ADD_PAGE targets a non-odg document", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { type: "ADD_PAGE" }); + expect(result.status?.severity).toBe("warning"); + }); +}); + +describe("appReducer ADD_TEXTBOX / ADD_IMAGE / SET_SHAPE_FRAME on pptx and odg", () => { + it("adds a real text box to a pptx slide", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const withBox = appReducer(opened, { + type: "ADD_TEXTBOX", + containerIndex: 0, + frame: { xPt: 10, yPt: 10, widthPt: 100, heightPt: 30 }, + text: "Caption", + }); + expect(withBox.hasUnsavedChanges).toBe(true); + const shape = pptxDocument(withBox).editor.slides()[0]?.shapes()[0]; + expect(shape?.text).toBe("Caption"); + }); + + it("adds a real text box to an odg page", () => { + const editor = createOdg(); + editor.addPage(); + const opened = openOdgDocument(editor.toBytes()); + const withBox = appReducer(opened, { + type: "ADD_TEXTBOX", + containerIndex: 0, + frame: { xPt: 10, yPt: 10, widthPt: 100, heightPt: 30 }, + text: "Caption", + }); + expect(withBox.hasUnsavedChanges).toBe(true); + const shape = odgDocument(withBox).editor.pages()[0]?.shapes()[0]; + expect(shape?.text).toBe("Caption"); + }); + + it("warns rather than crashing when ADD_TEXTBOX targets a missing odg page", () => { + const editor = createOdg(); + editor.addPage(); + const opened = openOdgDocument(editor.toBytes()); + const result = appReducer(opened, { + type: "ADD_TEXTBOX", + containerIndex: 4, + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no page at index 4"); + }); + + it("warns rather than crashing when ADD_TEXTBOX targets a missing pptx slide", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const result = appReducer(opened, { + type: "ADD_TEXTBOX", + containerIndex: 4, + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no slide at index 4"); + }); + + it("warns rather than crashing when ADD_TEXTBOX targets a document with no shape host at all", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "ADD_TEXTBOX", + containerIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("a pptx, odp, ppt or odg document"); + }); + + it("adds a real image to a pptx slide", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const withImage = appReducer(opened, { + type: "ADD_IMAGE", + containerIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 20, heightPt: 20 }, + format: "png", + bytes: PNG_BYTES, + altText: undefined, + }); + expect(withImage.hasUnsavedChanges).toBe(true); + expect(pptxDocument(withImage).editor.slides()[0]?.shapes()).toHaveLength( + 1, + ); + }); + + it("adds a real image to an odg page", () => { + const editor = createOdg(); + editor.addPage(); + const opened = openOdgDocument(editor.toBytes()); + const withImage = appReducer(opened, { + type: "ADD_IMAGE", + containerIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 20, heightPt: 20 }, + format: "png", + bytes: PNG_BYTES, + altText: undefined, + }); + expect(withImage.hasUnsavedChanges).toBe(true); + expect(odgDocument(withImage).editor.pages()[0]?.shapes()).toHaveLength(1); + }); + + it("warns rather than crashing when ADD_IMAGE targets a missing odg page", () => { + const editor = createOdg(); + editor.addPage(); + const opened = openOdgDocument(editor.toBytes()); + const result = appReducer(opened, { + type: "ADD_IMAGE", + containerIndex: 4, + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + format: "png", + bytes: PNG_BYTES, + altText: undefined, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no page at index 4"); + }); + + it("warns rather than crashing when ADD_IMAGE targets a document with no shape host at all", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "ADD_IMAGE", + containerIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + format: "png", + bytes: PNG_BYTES, + altText: undefined, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("a pptx, odp or odg document"); + }); + + it("moves a real pptx shape via SET_SHAPE_FRAME", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const withBox = appReducer(opened, { + type: "ADD_TEXTBOX", + containerIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + text: "x", + }); + const withFrame = appReducer(withBox, { + type: "SET_SHAPE_FRAME", + containerIndex: 0, + shapeIndex: 0, + frame: { xPt: 5, yPt: 6, widthPt: 20, heightPt: 30 }, + }); + expect(withFrame.hasUnsavedChanges).toBe(true); + const shape = pptxDocument(withFrame).editor.slides()[0]?.shapes()[0]; + expect(shape?.frame).toStrictEqual({ + xPt: 5, + yPt: 6, + widthPt: 20, + heightPt: 30, + }); + }); +}); + +describe("appReducer SET_SHEET_PRINT_SETTINGS", () => { + it("sets a real sheet's print settings on an ods document", () => { + const opened = openOdsDocument(createOds().toBytes()); + const settings = { + pageSize: { widthPt: 595, heightPt: 842 }, + margins: { topPt: 20, rightPt: 20, bottomPt: 20, leftPt: 20 }, + gridlines: true, + headers: true, + pageOrder: "downThenOver" as const, + }; + const withSettings = appReducer(opened, { + type: "SET_SHEET_PRINT_SETTINGS", + sheetIndex: 0, + printSettings: settings, + }); + expect(withSettings.hasUnsavedChanges).toBe(true); + expect( + odsDocument(withSettings).editor.sheets()[0]?.printSettings, + ).toStrictEqual(settings); + }); + + it("warns rather than crashing for a sheet index that does not exist", () => { + const opened = openOdsDocument(createOds().toBytes()); + const result = appReducer(opened, { + type: "SET_SHEET_PRINT_SETTINGS", + sheetIndex: 5, + printSettings: { + pageSize: { widthPt: 595, heightPt: 842 }, + margins: { topPt: 20, rightPt: 20, bottomPt: 20, leftPt: 20 }, + gridlines: false, + headers: false, + pageOrder: "downThenOver", + }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no sheet at index 5"); + }); +}); From 94d8eadc67d3ca0084d4f2320cbe9cef939a168d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:10:08 +0100 Subject: [PATCH 098/101] refactor(document-cli): drop the redundant markdown branch from APPEND_PARAGRAPH MarkdownEditor.body.appendParagraph accepts the identical wordprocessing ParagraphInit shape docx/odt already pass, silently ignoring the `alignment` field it has no concept of, so the markdown-specific early return that built a narrower init object was dead code producing an identical result either way. Also replaces INSERT_DOCX_FORMULA's `written` holder with a definite assignment declaration: mutate()'s own `apply` callback always runs synchronously before the flag is read, so the previous placeholder initial value could never actually be observed. --- packages/document-cli/src/tui/state/reducer.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/document-cli/src/tui/state/reducer.ts b/packages/document-cli/src/tui/state/reducer.ts index 64f23c6bb..124dbb632 100644 --- a/packages/document-cli/src/tui/state/reducer.ts +++ b/packages/document-cli/src/tui/state/reducer.ts @@ -1012,20 +1012,13 @@ export function appReducer(state: AppState, action: Action): AppState { selection: { ...state.selection, [action.key]: action.index }, }; - // `alignment` is set through the shared body.appendParagraph call for docx/odt, but MarkdownParagraphInit has no alignment field at all (CommonMark/GFM has no per-paragraph alignment construct) -- so a markdown document drops it here rather than the wordprocessing union call silently disagreeing about which ParagraphInit shape it is. + // MarkdownParagraphInit has no alignment field at all (CommonMark/GFM has no per-paragraph alignment construct), but MarkdownEditor.body.appendParagraph accepts the identical wordprocessing ParagraphInit shape as docx/odt and simply ignores the field it does not model -- so one call, with `alignment` always present, covers every wordprocessingDocument format with no format-specific branch. case "APPEND_PARAGRAPH": { const doc = wordprocessingDocument(state); if (doc === undefined) { return wrongDocument(state, "a docx, odt or markdown document"); } return mutate(state, doc, () => { - if (doc.format === "markdown") { - doc.editor.body.appendParagraph({ - text: action.text, - styleId: action.styleId, - }); - return; - } doc.editor.body.appendParagraph({ text: action.text, styleId: action.styleId, @@ -1358,12 +1351,12 @@ export function appReducer(state: AppState, action: Action): AppState { `There is no paragraph at index ${action.blockIndex}`, ); } - // Same holder reason as `merge` above: the write happens inside the mutate callback. - const omml = { written: true }; + // Unlike `merge` above, this assignment is unconditional -- mutate()'s own `apply` always runs synchronously before it returns, so `written` is always set by the time it is read below. A definite-assignment declaration (no initial value at all) says so directly, rather than giving it a placeholder literal that can never actually be observed. + let written!: boolean; const nextState = mutate(state, doc, () => { - omml.written = paragraph.appendOfficeMath(action.mathml).written; + written = paragraph.appendOfficeMath(action.mathml).written; }); - return omml.written + return written ? nextState : withStatus( nextState, From 815b6eaa9637c8c02b273a9d21110f28b5b1af7f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:10:34 +0100 Subject: [PATCH 099/101] test(document-cli): close mutation-coverage gaps across reducer.ts's action handlers Adds real assertions for warning paths and edge cases that previously ran with no test at all, or only checked severity without pinning the exact message: withRun/withStyledRun's own wrongDocument and missing-run branches, SET_PARAGRAPH_ALIGNMENT and APPEND_RUN's own warning paths, TOGGLE_RUN_UNDERLINE/SET_RUN_COLOR's real mutation behaviour, a table cell that already carries more than one run, withShape/withWideShape/withSheet's own wrongDocument branches, every odt-only list action's non-wordprocessing wrongDocument branch and ADD_LIST_ITEM's own odt code path, withPdfPage/withPdfItemMatching and REMOVE_PDF_ITEM's own wrongDocument and missing-item branches, a real internalLink PDF item built through pdf-codec's writePdf/openPdf round trip (PdfPage has no way to append one directly), the odg ADD_RECT case falling through into ADD_ELLIPSE's own vector kind, ADD_SLIDE_TABLE/MERGE_SLIDE_TABLE_CELLS/SET_CELL_FORMULA/MERGE_CELLS/ ADD_RECT's own exact out-of-range messages, SET_VECTOR_STROKE's own wrongDocument branch, SET_METADATA's exact wrongDocument text, UNDO's two distinct "nothing to undo" code paths and its own status text, and REQUEST_CLOSE resetting searchQuery back to empty. --- .../src/tui/state/reducer.test.ts | 589 +++++++++++++++++- 1 file changed, 587 insertions(+), 2 deletions(-) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 6be3e0ca9..8674c49a6 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -10,6 +10,8 @@ import { createXls, drawingOfBlock, formulaOfBlock, + LAYOUT_FORMAT_VERSION, + type LayoutDocument, type MathMlNode, odsToXlsx, openDoc, @@ -29,6 +31,7 @@ import { readOdtContent, readPdf, readPptxContent, + writePdf, xlsxToPdf, } from "documents.js"; import { describe, expect, it } from "vitest"; @@ -383,6 +386,7 @@ describe("appReducer document lifecycle", () => { expect(state.stack.map((screen) => screen.kind)).toEqual([expectedKind]); expect(state.openDocument?.format).toBe(action.format); expect(state.hasUnsavedChanges).toBe(false); + expect(state.status?.text).toBe(`New ${action.format} document`); } }); @@ -425,6 +429,9 @@ describe("appReducer SAVE_SUCCESS", () => { path: "/tmp/orphan.docx", }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "Saved, but there is no open document to record the path against", + ); expect(result.hasUnsavedChanges).toBe(false); }); @@ -555,6 +562,7 @@ describe("appReducer REQUEST_CLOSE / CONFIRM_CLOSE / CANCEL_CLOSE", () => { it("says so when there is no open document to close", () => { const result = appReducer(createInitialState(), { type: "REQUEST_CLOSE" }); expect(result.status?.severity).toBe("info"); + expect(result.status?.text).toBe("There is no open document to close"); }); it("closes immediately, with no confirmation overlay, when there are no unsaved changes", () => { @@ -562,9 +570,15 @@ describe("appReducer REQUEST_CLOSE / CONFIRM_CLOSE / CANCEL_CLOSE", () => { type: "CREATE_DOCUMENT", format: "docx", }); - const requested = appReducer(created, { type: "REQUEST_CLOSE" }); + const withQuery = appReducer(created, { + type: "SET_SEARCH_QUERY", + query: "leftover search", + }); + const requested = appReducer(withQuery, { type: "REQUEST_CLOSE" }); expect(requested.openDocument).toBeUndefined(); expect(requested.overlays.confirmClose).toBe(false); + // closeDocument resets searchQuery back to empty rather than carrying a stale search over into whatever gets opened next. + expect(requested.searchQuery).toBe(""); }); it("opens the confirmClose overlay instead of closing outright when there are unsaved changes", () => { @@ -670,6 +684,9 @@ describe("appReducer SET_METADATA", () => { overrides: { title: "x" }, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs an editable document; the open document is no document", + ); expect(result.hasUnsavedChanges).toBe(false); }); }); @@ -769,6 +786,176 @@ describe("appReducer docx mutations", () => { expect(warned.status?.severity).toBe("warning"); expect(warned.hasUnsavedChanges).toBe(false); }); + + // withRun's own wrongDocument path -- distinct from withStyledRun's (exercised elsewhere against markdown), since SET_RUN_TEXT/TOGGLE_RUN_BOLD/TOGGLE_RUN_ITALIC resolve through the wider wordprocessingDocument union, not styledWordprocessingDocument. + it("warns rather than mutating when SET_RUN_TEXT targets a non-wordprocessing document", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "ods", + }); + const result = appReducer(state, { + type: "SET_RUN_TEXT", + blockIndex: 0, + runIndex: 0, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); + }); + + // withRun's own "no run at index" path: the paragraph exists (created via APPEND_PARAGRAPH) but has no runs at all, unlike the "no paragraph" case already covered above. + it("reports a missing run, not a missing paragraph, when the paragraph exists but has no runs", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: undefined, + styleId: undefined, + alignment: undefined, + }, + ]); + const result = appReducer(state, { + type: "TOGGLE_RUN_ITALIC", + blockIndex: 0, + runIndex: 0, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("Paragraph 0 has no run at index 0"); + // The warning path returns the state unchanged rather than a new mutated copy. + expect(result.openDocument).toBe(state.openDocument); + }); + + // withStyledRun's own "no run at index" path (TOGGLE_RUN_UNDERLINE/SET_RUN_COLOR/etc resolve through styledWordprocessingDocument, not wordprocessingDocument, so this is a genuinely separate code path from the withRun test above). + it("reports a missing run for TOGGLE_RUN_UNDERLINE when the paragraph has no runs", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: undefined, + styleId: undefined, + alignment: undefined, + }, + ]); + const result = appReducer(state, { + type: "TOGGLE_RUN_UNDERLINE", + blockIndex: 0, + runIndex: 0, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("Paragraph 0 has no run at index 0"); + expect(result.openDocument).toBe(state.openDocument); + }); + + // TOGGLE_RUN_UNDERLINE and SET_RUN_COLOR have no real, positive test anywhere else -- the markdown describe below only exercises their wrongDocument branch. + it("toggles a real run's underline and sets its colour through the live editor", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: undefined, + styleId: undefined, + alignment: undefined, + }, + { type: "APPEND_RUN", blockIndex: 0, text: "Hello" }, + ]); + const run = docxDocument(state).editor.paragraphs()[0]?.runs()[0]; + if (run === undefined) { + throw new Error("expected an appended run"); + } + expect(run.underline).toBe(false); + + const underlined = appReducer(state, { + type: "TOGGLE_RUN_UNDERLINE", + blockIndex: 0, + runIndex: 0, + }); + expect(underlined.hasUnsavedChanges).toBe(true); + expect(run.underline).toBe(true); + + const coloured = appReducer(underlined, { + type: "SET_RUN_COLOR", + blockIndex: 0, + runIndex: 0, + color: { r: 1, g: 0, b: 0 }, + }); + expect(coloured.hasUnsavedChanges).toBe(true); + expect(run.color).toStrictEqual({ r: 1, g: 0, b: 0 }); + }); + + // SET_PARAGRAPH_ALIGNMENT has no positive test anywhere else -- the markdown describe block only ever exercises its wrongDocument branch (MarkdownParagraph has no alignment field at all). + it("sets a real paragraph's alignment through the live editor", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: "Hello", + styleId: undefined, + alignment: undefined, + }, + ]); + const paragraph = docxDocument(state).editor.paragraphs()[0]; + if (paragraph === undefined) { + throw new Error("expected an appended paragraph"); + } + expect(paragraph.alignment).toBeUndefined(); + + const aligned = appReducer(state, { + type: "SET_PARAGRAPH_ALIGNMENT", + blockIndex: 0, + alignment: "center", + }); + expect(aligned.hasUnsavedChanges).toBe(true); + expect(paragraph.alignment).toBe("center"); + }); + + it("reports a missing paragraph for SET_PARAGRAPH_ALIGNMENT rather than throwing", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(state, { + type: "SET_PARAGRAPH_ALIGNMENT", + blockIndex: 7, + alignment: "center", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no paragraph at index 7"); + expect(result.openDocument).toBe(state.openDocument); + }); + + // APPEND_RUN's own wrongDocument/no-paragraph paths -- every other use of APPEND_RUN in this file is setup for a further action, never a direct assertion on its own warning paths. + it("warns rather than mutating when APPEND_RUN targets a non-wordprocessing document", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "ods", + }); + const result = appReducer(state, { + type: "APPEND_RUN", + blockIndex: 0, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); + }); + + it("reports a missing paragraph for APPEND_RUN rather than throwing", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(state, { + type: "APPEND_RUN", + blockIndex: 7, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no paragraph at index 7"); + expect(result.openDocument).toBe(state.openDocument); + }); }); describe.each(["docx", "odt"] as const)( @@ -852,6 +1039,8 @@ describe("appReducer APPEND_TABLE and MERGE_TABLE_CELLS on docx/odt", () => { merge: { startRow: 0, startColumn: 0, rowSpan: 2, colSpan: 2 }, }); expect(withTable.hasUnsavedChanges).toBe(true); + // A docx table genuinely supports merging, unlike markdown's own -- this must not carry the "unsupported" warning markdown's APPEND_TABLE+merge gets below. + expect(withTable.status?.severity).not.toBe("warning"); const content = readDocxContent(docxDocument(withTable).editor.toPackage()); if (content.kind !== "wordprocessing") { @@ -1036,6 +1225,7 @@ describe("appReducer APPEND_TABLE and MERGE_TABLE_CELLS on docx/odt", () => { colSpan: 1, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no table at index 3"); expect(result.hasUnsavedChanges).toBe(false); }); }); @@ -1104,6 +1294,35 @@ describe("appReducer SET_TABLE_CELL_TEXT", () => { "There is no cell at row 5, column 0 of table 0", ); }); + + // setTextContainerText's "already has a first run" branch: a freshly-appended table cell has zero runs, so the tests above only ever exercise the "no first run yet" branch (paragraph.appendRun). Giving the cell two runs up front proves the write path replaces the first run's text AND removes every extra run, rather than just setting the first and leaving the rest stale. + it("replaces the first run's text and removes every extra run when a cell already has more than one", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { type: "APPEND_TABLE", rows: 1, columns: 1 }, + ]); + const table = docxDocument(state).editor.tables()[0]; + const cell = table?.rows()[0]?.cells()[0]; + if (cell === undefined) { + throw new Error("expected the appended cell"); + } + const paragraph = cell.paragraphs()[0] ?? cell.appendParagraph(); + paragraph.appendRun({ text: "one" }); + paragraph.appendRun({ text: "two" }); + paragraph.appendRun({ text: "three" }); + expect(paragraph.runs()).toHaveLength(3); + + const edited = appReducer(state, { + type: "SET_TABLE_CELL_TEXT", + tableIndex: 0, + row: 0, + column: 0, + text: "Replaced", + }); + expect(edited.hasUnsavedChanges).toBe(true); + expect(paragraph.runs()).toHaveLength(1); + expect(paragraph.runs()[0]?.text).toBe("Replaced"); + }); }); describe("appReducer SET_LIST_ITEM_TEXT on odt", () => { @@ -1145,6 +1364,7 @@ describe("appReducer SET_LIST_ITEM_TEXT on odt", () => { text: "x", }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no list at index 5"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -1162,6 +1382,9 @@ describe("appReducer SET_LIST_ITEM_TEXT on odt", () => { text: "x", }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + `List ${blockIndex} has no item at index 3`, + ); expect(result.hasUnsavedChanges).toBe(false); }); @@ -1177,8 +1400,29 @@ describe("appReducer SET_LIST_ITEM_TEXT on odt", () => { text: "x", }); expect(warned.status?.severity).toBe("warning"); + expect(warned.status?.text).toBe( + "That action needs an odt document (lists are an odt-only concept); the open document is docx", + ); expect(warned.hasUnsavedChanges).toBe(false); }); + + // The docx test above is wordprocessing but not odt, so it only ever reaches the SECOND, odt-only wrongDocument check. This one is not wordprocessing at all, reaching the FIRST, wider check instead. + it("warns instead of mutating when the open document is not a wordprocessing document at all", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "ods", + }); + const warned = appReducer(state, { + type: "SET_LIST_ITEM_TEXT", + blockIndex: 0, + itemIndex: 0, + text: "x", + }); + expect(warned.status?.severity).toBe("warning"); + expect(warned.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); + }); }); describe("appReducer ADD_LIST_ITEM on docx", () => { @@ -1242,6 +1486,54 @@ describe("appReducer ADD_LIST_ITEM on docx", () => { expect(result.status?.text).toContain("not part of a list"); expect(result.hasUnsavedChanges).toBe(false); }); + + it("warns instead of mutating when the open document is not a wordprocessing document at all", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "ods", + }); + const result = appReducer(state, { + type: "ADD_LIST_ITEM", + blockIndex: 0, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); + }); + + // odt's own ADD_LIST_ITEM branch is genuinely separate code from the docx/markdown branch tested above (a real OdtList.addItem(), not a flat paragraph-copy) -- despite the docx describe block's own comment claiming the sibling ADD_LIST/INDENT_LIST_ITEM tests already cover it, neither of those ever dispatches ADD_LIST_ITEM itself. + it("appends a real item to an existing odt list", () => { + const editor = createOdt(); + editor.body.appendList().addItem().appendParagraph({ text: "first" }); + const opened = openOdtDocument(editor.toBytes()); + const blockIndex = odtDocument(opened).editor.lists().length - 1; + + const added = appReducer(opened, { + type: "ADD_LIST_ITEM", + blockIndex, + text: "second", + }); + expect(added.hasUnsavedChanges).toBe(true); + const items = odtDocument(added).editor.lists()[blockIndex]?.items(); + expect(items?.map((item) => item.text)).toEqual(["first", "second"]); + }); + + it("warns rather than crashing when ADD_LIST_ITEM targets an odt list index that does not exist", () => { + const editor = createOdt(); + editor.body.appendList().addItem().appendParagraph({ text: "only" }); + const opened = openOdtDocument(editor.toBytes()); + + const result = appReducer(opened, { + type: "ADD_LIST_ITEM", + blockIndex: 5, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no list at index 5"); + expect(result.hasUnsavedChanges).toBe(false); + }); }); describe("appReducer ADD_LIST on odt", () => { @@ -1276,6 +1568,18 @@ describe("appReducer ADD_LIST on odt", () => { expect(result.status?.text).toContain("odt"); expect(result.hasUnsavedChanges).toBe(false); }); + + it("warns instead of mutating when the open document is not a wordprocessing document at all", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "ods", + }); + const result = appReducer(state, { type: "ADD_LIST" }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); + }); }); describe("appReducer INDENT_LIST_ITEM on odt", () => { @@ -1342,6 +1646,7 @@ describe("appReducer INDENT_LIST_ITEM on odt", () => { itemIndex: 0, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no list at index 5"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -1356,8 +1661,27 @@ describe("appReducer INDENT_LIST_ITEM on odt", () => { itemIndex: 0, }); expect(warned.status?.severity).toBe("warning"); + expect(warned.status?.text).toBe( + "That action needs an odt document (lists are an odt-only concept); the open document is docx", + ); expect(warned.hasUnsavedChanges).toBe(false); }); + + it("warns instead of mutating when the open document is not a wordprocessing document at all", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "ods", + }); + const result = appReducer(state, { + type: "INDENT_LIST_ITEM", + blockIndex: 0, + itemIndex: 0, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); + }); }); describe("appReducer ods mutations", () => { @@ -1383,6 +1707,25 @@ describe("appReducer ods mutations", () => { } expect(sheet.cell(2, 3).value).toEqual({ kind: "string", value: "Total" }); }); + + // withSheet's own wrongDocument path -- every other withSheet test (SET_CELL_VALUE, SET_SHEET_PRINT_SETTINGS) only exercises the "no sheet at that index" branch against an already-open spreadsheet, never the "not a spreadsheet at all" branch. + it("warns rather than crashing when SET_CELL_VALUE targets a non-spreadsheet document", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "SET_CELL_VALUE", + sheetIndex: 0, + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs an ods or xls document; the open document is docx", + ); + }); }); describe("appReducer SET_CELL_FORMULA on ods", () => { @@ -1459,6 +1802,7 @@ describe("appReducer SET_CELL_FORMULA on ods", () => { formula: "of:=1", }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no sheet at index 4"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -1664,6 +2008,7 @@ describe("appReducer MERGE_CELLS on ods", () => { colSpan: 1, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no sheet at index 4"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -1783,6 +2128,9 @@ describe("appReducer markdown mutations", () => { alignment: "center", }); expect(alignmentResult.status?.severity).toBe("warning"); + expect(alignmentResult.status?.text).toBe( + "That action needs a docx or odt document; the open document is markdown", + ); const imageResult = appReducer(opened, { type: "INSERT_PARAGRAPH_IMAGE", @@ -1794,6 +2142,9 @@ describe("appReducer markdown mutations", () => { altText: undefined, }); expect(imageResult.status?.severity).toBe("warning"); + expect(imageResult.status?.text).toBe( + "That action needs a docx or odt document; the open document is markdown", + ); }); // GFM tables have no cell-merge concept at all -- MarkdownTable has no mergeCells -- so a merge requested alongside table creation still creates the (unmerged) table and reports why the merge didn't happen, rather than silently dropping the merge or refusing to create the table. @@ -1822,6 +2173,9 @@ describe("appReducer markdown mutations", () => { alignment: undefined, }); expect(warned.status?.severity).toBe("warning"); + expect(warned.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); expect(warned.hasUnsavedChanges).toBe(false); }); }); @@ -1885,6 +2239,7 @@ describe("appReducer undo", () => { expect(undone.undoStack).toHaveLength(0); expect(markdownDocument(undone).editor.paragraphs()[1]?.text).toBe("Two"); expect(undone.hasUnsavedChanges).toBe(true); + expect(undone.status?.text).toBe("Undone"); }); it("restores the snapshot taken before the last mutation", () => { @@ -1921,9 +2276,17 @@ describe("appReducer undo", () => { }); const undone = appReducer(created, { type: "UNDO" }); expect(undone.status?.severity).toBe("info"); + expect(undone.status?.text).toBe("There is nothing to undo"); expect(undone.openDocument).toBe(created.openDocument); }); + // A genuinely separate code path from the test above: that one has an open document with an empty undo stack (the `snapshot === undefined` branch); this one has no open document at all (the earlier `doc === undefined` branch), which produces the identical message through different code. + it("says there is nothing to undo when no document is open at all", () => { + const undone = appReducer(createInitialState(), { type: "UNDO" }); + expect(undone.status?.severity).toBe("info"); + expect(undone.status?.text).toBe("There is nothing to undo"); + }); + it("caps the undo stack at 20 snapshots, dropping the oldest ones first", () => { let state = appReducer(createInitialState(), { type: "CREATE_DOCUMENT", @@ -2091,6 +2454,7 @@ describe("appReducer ADD_SLIDE_TABLE", () => { columns: 2, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no slide at index 5"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -2231,6 +2595,7 @@ describe("appReducer MERGE_SLIDE_TABLE_CELLS", () => { colSpan: 1, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no table at index 0 on slide 0"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -2552,6 +2917,38 @@ describe("appReducer SET_SHAPE_ROTATION on pptx", () => { expect(result.status?.severity).toBe("warning"); expect(result.status?.text).toBe("There is no shape 3 on page 0"); }); + + // withShape's own wrongDocument path (used only by SET_SHAPE_ROTATION) -- distinct from withWideShape's own copy below, which SET_SHAPE_TEXT/SET_SHAPE_FRAME resolve through instead. + it("warns rather than crashing when SET_SHAPE_ROTATION targets a document with no shape host at all", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "SET_SHAPE_ROTATION", + containerIndex: 0, + shapeIndex: 0, + rotationDeg: 10, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("a pptx, odp or odg document"); + }); + + // withWideShape's own wrongDocument path -- SET_SHAPE_TEXT/SET_SHAPE_FRAME resolve through it, not withShape, so this is a genuinely separate code path from the SET_SHAPE_ROTATION test above. + it("warns rather than crashing when SET_SHAPE_TEXT targets a document with no shape host at all", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "SET_SHAPE_TEXT", + containerIndex: 0, + shapeIndex: 0, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("a pptx, odp, ppt or odg document"); + }); }); describe("appReducer xlsx (read-only PDF-preview) documents", () => { @@ -2783,6 +3180,35 @@ describe("appReducer PDF item and page mutations", () => { expect(items[0]?.kind).toBe("text"); }); + // REMOVE_PDF_ITEM has its own inline wrongDocument/no-item checks, not shared with withPdfPage or withPdfItemMatching above. + it("warns rather than crashing when REMOVE_PDF_ITEM targets a non-PDF document", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "REMOVE_PDF_ITEM", + pageIndex: 0, + itemIndex: 0, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a pdf document; the open document is docx", + ); + }); + + it("warns rather than crashing when REMOVE_PDF_ITEM targets an item index that does not exist", () => { + const opened = openPdfDocument(pdfTestBytes()); + const result = appReducer(opened, { + type: "REMOVE_PDF_ITEM", + pageIndex: 0, + itemIndex: 9, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("Page 0 has no item at index 9"); + expect(result.hasUnsavedChanges).toBe(false); + }); + it("undoes a PDF text edit, restoring the snapshot taken before the mutation", () => { const opened = openPdfDocument(pdfTestBytes()); const edited = appReducer(opened, { @@ -2920,6 +3346,55 @@ describe("appReducer PDF item and page mutations", () => { expect(result.hasUnsavedChanges).toBe(false); }); + // withPdfPage's own wrongDocument path -- every ADD_PDF_* test above only exercises the "no page at that index" branch against an already-open PDF, never the "not a PDF at all" branch. + it("warns rather than crashing when ADD_PDF_RECT targets a non-PDF document", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "ADD_PDF_RECT", + pageIndex: 0, + init: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a pdf document; the open document is docx", + ); + }); + + // withPdfItemMatching's own wrongDocument path -- a genuinely separate function from withPdfPage above, so covering one says nothing about the other. + it("warns rather than crashing when SET_PDF_RECT_FILL targets a non-PDF document", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "SET_PDF_RECT_FILL", + pageIndex: 0, + itemIndex: 0, + fill: { r: 1, g: 0, b: 0 }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a pdf document; the open document is docx", + ); + }); + + // withPdfItemMatching's own "no item at that index" path -- the wrong-kind test above needs a real item at itemIndex 0 to check its kind against, so it can never reach this branch; this needs a valid page with an item count too low for the requested index instead. + it("warns rather than crashing when SET_PDF_RECT_FILL targets an item index that does not exist", () => { + const opened = openPdfDocument(pdfTestBytes()); + const result = appReducer(opened, { + type: "SET_PDF_RECT_FILL", + pageIndex: 0, + itemIndex: 9, + fill: { r: 1, g: 0, b: 0 }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("Page 0 has no item at index 9"); + expect(result.hasUnsavedChanges).toBe(false); + }); + it("adds an ellipse via ADD_PDF_ELLIPSE, present after a toBytes()/openPdf() round trip", () => { const opened = openPdfDocument(pdfTestBytes()); const withEllipse = appReducer(opened, { @@ -3452,6 +3927,81 @@ describe("appReducer PDF item and page mutations", () => { expect(result.status?.text).toContain("not internalLink"); }); + // A real internalLink item, unlike every other PDF item kind, cannot be created through PdfPage's own append* API (see the comment above) -- so this builds one the only other way a genuine internalLink ever arises: writing a LayoutDocument with a real internal-link annotation through pdf-codec's own writePdf, then re-reading it, proving the isPdfInternalLinkItem guard's TRUE branch (not just its wrong-kind rejection) actually matches a real internalLink item. + it("edits a real internal link's destination and frame through the live editor", () => { + const layoutDoc: LayoutDocument = { + formatVersion: LAYOUT_FORMAT_VERSION, + metadata: {}, + images: {}, + destinations: [ + { name: "target", pageIndex: 0, target: { kind: "fit" } }, + { name: "other", pageIndex: 0, target: { kind: "fit" } }, + ], + pages: [ + { + widthPt: 200, + heightPt: 200, + items: [ + { + kind: "internalLink", + destination: "target", + xPt: 10, + yPt: 10, + widthPt: 50, + heightPt: 20, + }, + // A second internalLink referencing the second destination: writePdf only carries a destination through into the saved document's own /Names tree when something actually references it, so an unreferenced destination is silently dropped on the way back in -- this one needs a real referrer to survive the round trip. + { + kind: "internalLink", + destination: "other", + xPt: 70, + yPt: 10, + widthPt: 50, + heightPt: 20, + }, + ], + }, + ], + }; + const opened = openPdfDocument(writePdf(layoutDoc)); + + const item = pdfDocument(opened).editor.page(0)?.items()[0]; + if (item?.kind !== "internalLink") { + throw new Error("expected a real internalLink item"); + } + const otherItem = pdfDocument(opened).editor.page(0)?.items()[1]; + if (otherItem?.kind !== "internalLink") { + throw new Error("expected a second real internalLink item"); + } + // The reader mints its own destination names on the way back in rather than necessarily preserving the writer's own names verbatim, so the destination this edit switches to is read from the second item's own round-tripped destination rather than assumed. + const otherDestination = otherItem.destination; + + const withDestination = appReducer(opened, { + type: "SET_PDF_INTERNAL_LINK_DESTINATION", + pageIndex: 0, + itemIndex: 0, + destination: otherDestination, + }); + expect(withDestination.status?.severity).not.toBe("warning"); + expect(withDestination.hasUnsavedChanges).toBe(true); + expect(item.destination).toBe(otherDestination); + + const withFrame = appReducer(withDestination, { + type: "SET_PDF_INTERNAL_LINK_FRAME", + pageIndex: 0, + itemIndex: 0, + xPt: 1, + yPt: 2, + widthPt: 3, + heightPt: 4, + }); + expect(withFrame.status?.severity).not.toBe("warning"); + expect(item.xPt).toBe(1); + expect(item.yPt).toBe(2); + expect(item.widthPt).toBe(3); + expect(item.heightPt).toBe(4); + }); + // Every other SET_PDF_*_* field-edit action routes through the identical withPdfItemMatching guard, but each call site carries its OWN copy of the kindLabel string literal -- exercising the wrong-kind path through the FRAME/FILL/one representative action per kind above does not cover the same literal at a sibling action's own call site (e.g. SET_PDF_RECT_FRAME's "rect" and SET_PDF_RECT_STROKE's "rect" are two distinct AST nodes). This table drives every remaining action through the wrong-kind branch once each. const wrongKindCases: [string, Action, string][] = [ [ @@ -3909,6 +4459,7 @@ describe("appReducer ADD_RECT / ADD_ELLIPSE / ADD_LINE / ADD_PATH on odp", () => init: { frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 } }, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no slide at index 5"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -3938,7 +4489,10 @@ describe("appReducer ADD_RECT / ADD_ELLIPSE / ADD_LINE / ADD_PATH on odp", () => init: { frame: { xPt: 10, yPt: 10, widthPt: 40, heightPt: 30 } }, }); expect(withRect.hasUnsavedChanges).toBe(true); - expect(odgDocument(withRect).editor.pages()[0]?.vectors()).toHaveLength(1); + const vectors = odgDocument(withRect).editor.pages()[0]?.vectors(); + expect(vectors).toHaveLength(1); + // A rect and an ellipse share the identical OdgBoxVectorInit shape (frame/fill/stroke), so a mutation that lets ADD_RECT's own case fall through into ADD_ELLIPSE's addEllipse call would still add exactly one vector -- just the wrong kind. The length check above alone cannot catch that. + expect(vectors?.[0]?.kind).toBe("rect"); }); // The odg branch dispatches through its own inner switch (addRect/addEllipse/addLine/addPath), one case per real OdgPage method -- distinct from the ADD_RECT/ADD_ELLIPSE/ADD_LINE/ADD_PATH coverage above, which only ever reaches odg via ADD_RECT. Each case is its own switch-statement mutant, so proving the rect case works says nothing about whether removing the ellipse/line/path cases would still pass. @@ -4050,7 +4604,22 @@ describe("appReducer SET_VECTOR_FILL / SET_VECTOR_STROKE on odg", () => { fill: { r: 1, g: 1, b: 1 }, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs an odg document; the open document is docx", + ); expect(result.hasUnsavedChanges).toBe(false); + + // SET_VECTOR_STROKE has its own, separate copy of the identical wrongDocument call -- covering SET_VECTOR_FILL's above says nothing about this one. + const strokeResult = appReducer(state, { + type: "SET_VECTOR_STROKE", + vector: rect, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + }); + expect(strokeResult.status?.severity).toBe("warning"); + expect(strokeResult.status?.text).toBe( + "That action needs an odg document; the open document is docx", + ); + expect(strokeResult.hasUnsavedChanges).toBe(false); }); }); @@ -4294,6 +4863,22 @@ describe("appReducer ADD_TEXTBOX / ADD_IMAGE / SET_SHAPE_FRAME on pptx and odg", heightPt: 30, }); }); + + // withWideShape's own "page"/"slide" ternary: the odg describe block elsewhere only ever exercises the "page" branch via SET_SHAPE_TEXT, so this proves the "slide" branch specifically, on a pptx document. + it("warns with 'slide' rather than 'page' when SET_SHAPE_FRAME targets a missing shape on a pptx slide", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + + const result = appReducer(opened, { + type: "SET_SHAPE_FRAME", + containerIndex: 0, + shapeIndex: 5, + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no shape 5 on slide 0"); + }); }); describe("appReducer SET_SHEET_PRINT_SETTINGS", () => { From a0de9ff5e777828827d06391804c26a19b756037 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 11:24:47 +0100 Subject: [PATCH 100/101] test(document-cli): add direct coverage for SaveAsPromptScreen Covers the no-open-document message, the pre-filled destination for a fresh document, SAVE_SUCCESS popping the screen, typed destinations reaching saveDocumentTo, SAVE_ERROR leaving the screen open, and Escape popping without saving. Also fixes defaultDestinationFor's unreachable non-writable fallback: formatToExtension only needed isWritableDocument narrowing because its own DocumentFormat parameter has no odb entry, not because the branch is real -- every OpenDocument variant whose path can be undefined is writable by construction (every non-writable variant requires path: string). Replaces the silent "bin" guess with an explicit invariant check that throws if that structural guarantee is ever violated, matching this file's own existing precedent for its document === undefined branch. --- .../src/tui/screens/save-as-prompt.test.tsx | 247 ++++++++++++++++++ .../src/tui/screens/save-as-prompt.tsx | 18 +- 2 files changed, 257 insertions(+), 8 deletions(-) create mode 100644 packages/document-cli/src/tui/screens/save-as-prompt.test.tsx diff --git a/packages/document-cli/src/tui/screens/save-as-prompt.test.tsx b/packages/document-cli/src/tui/screens/save-as-prompt.test.tsx new file mode 100644 index 000000000..9a26cacf8 --- /dev/null +++ b/packages/document-cli/src/tui/screens/save-as-prompt.test.tsx @@ -0,0 +1,247 @@ +import { Box, Text } from "ink"; +import { render } from "ink-testing-library"; +import type { ReactElement } from "react"; +import { Component, useEffect, useRef } from "react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { saveDocumentTo } from "../format/open-document.js"; +import { + AppStateProvider, + useAppDispatch, + useAppState, +} from "../state/context.js"; +import type { OpenDocument } from "../state/types.js"; +import { settle, waitForFrame } from "../test-support.js"; +import { NewDocumentPickerScreen } from "./new-document-picker.js"; +import { SaveAsPromptScreen } from "./save-as-prompt.js"; + +// Catches a render-phase throw (SaveAsPromptScreen's own defensive "should be unreachable" check, exercised deliberately below) so it surfaces as an observable frame instead of an unhandled error killing the whole ink-testing-library render. +class CaughtError extends Component< + { readonly children: ReactNode }, + { readonly message: string | undefined } +> { + constructor(props: { readonly children: ReactNode }) { + super(props); + this.state = { message: undefined }; + } + + static getDerivedStateFromError(error: unknown): { message: string } { + return { message: error instanceof Error ? error.message : String(error) }; + } + + override render(): ReactNode { + if (this.state.message !== undefined) { + return caughtError:{this.state.message}; + } + return this.props.children; + } +} + +// A type guard against the already-imported binding's own real type, not an inline `import(...)` type query -- mirrors export-options.test.tsx's own isExportPdfModule for the identical reason (avoids a project-wide consistent-type-imports exception for this one file, and is a genuine runtime check besides). +function isOpenDocumentModule( + value: unknown, +): value is { saveDocumentTo: typeof saveDocumentTo } { + return ( + typeof value === "object" && value !== null && "saveDocumentTo" in value + ); +} + +vi.mock("../format/open-document.js", async (importOriginal) => { + const actual = await importOriginal(); + if (!isOpenDocumentModule(actual)) { + throw new Error( + "../format/open-document.js mock: importOriginal() returned an unexpected shape", + ); + } + return { ...actual, saveDocumentTo: vi.fn(() => Promise.resolve()) }; +}); + +const CWD = "/work/docs"; + +// Mirrors the app shell's own Ctrl+S/:saveas handler: pushes the real SAVE_AS_REQUEST transition exactly once a document exists, so `state.stack` genuinely grows by one saveAsPrompt entry the way it does in the real app -- POP_SCREEN is a no-op at stack length 1 (see reducer.ts), so asserting a cancel/save actually pops requires a real prior push, not just rendering SaveAsPromptScreen standalone. A `useRef` guard rather than a reactive "push if not already there" effect: once the screen under test itself dispatches POP_SCREEN, the stack top reverts to non-saveAsPrompt, which would otherwise make a reactive effect fire a second, unwanted SAVE_AS_REQUEST and mask every pop this suite exists to observe. +function Harness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + const document = state.openDocument; + const onSaveAsPrompt = state.stack.at(-1)?.kind === "saveAsPrompt"; + const requested = useRef(false); + + useEffect(() => { + if (document !== undefined && !requested.current) { + requested.current = true; + dispatch({ type: "SAVE_AS_REQUEST" }); + } + }, [document, dispatch]); + + return ( + + {document === undefined ? ( + + ) : onSaveAsPrompt ? ( + + ) : ( + pushing saveAsPrompt... + )} + stackLen:{state.stack.length} + + status: + {state.status === undefined + ? "none" + : `${state.status.severity}:${state.status.text}`} + + + ); +} + +function stackLenOf(frame: string | undefined): number { + const match = /stackLen:(\d+)/.exec(frame ?? ""); + if (match?.[1] === undefined) { + throw new Error( + `No stackLen: marker found in frame:\n${frame ?? "(none)"}`, + ); + } + return Number(match[1]); +} + +async function renderOnSaveAsPrompt(): Promise> { + const rendered = render( + + + , + ); + // Selects the first creatable format (docx), which has no path yet. + rendered.stdin.write("\r"); + await waitForFrame(rendered.lastFrame, (frame) => frame.includes("Save as")); + // SaveAsPromptScreen mounted via an effect-driven conditional swap (NewDocumentPickerScreen -> SaveAsPromptScreen) -- per test-support.ts's own settle() doc comment, its useInput listener is not guaranteed attached the instant its text appears, so a stdin.write() sent immediately after this point can be silently dropped. + await settle(); + return rendered; +} + +describe("SaveAsPromptScreen", () => { + beforeEach(() => { + vi.mocked(saveDocumentTo).mockClear(); + }); + + it("shows a message and nothing else when there is no open document", () => { + const { lastFrame } = render( + + + , + ); + expect(lastFrame()).toContain("There is no open document to save."); + expect(lastFrame()).not.toContain("Save as"); + }); + + it("pre-fills the destination with untitled. in cwd for a freshly created document with no path", async () => { + const rendered = await renderOnSaveAsPrompt(); + expect(rendered.lastFrame()).toContain(`${CWD}/untitled.docx`); + }); + + it("dispatches SAVE_SUCCESS and pops the screen when the save succeeds", async () => { + vi.mocked(saveDocumentTo).mockResolvedValueOnce(undefined); + const rendered = await renderOnSaveAsPrompt(); + const stackLenBefore = stackLenOf(rendered.lastFrame()); + + rendered.stdin.write("\r"); + + // Waits for BOTH the status update AND the stack pop together: onSubmit's handler dispatches SAVE_SUCCESS and, once that resolves, a separate POP_SCREEN -- two renders, not one -- so a predicate that only checks the status text can resolve on the first render, before the second dispatch's pop has actually committed. + await vi.waitFor(() => { + expect(rendered.lastFrame()).toMatch(/status:info:Saved/); + expect(stackLenOf(rendered.lastFrame())).toBe(stackLenBefore - 1); + }); + expect(vi.mocked(saveDocumentTo)).toHaveBeenCalledTimes(1); + }); + + it("saves to the path built from what the user typed, appended after the pre-filled default", async () => { + vi.mocked(saveDocumentTo).mockResolvedValueOnce(undefined); + const rendered = await renderOnSaveAsPrompt(); + + // Appends rather than clearing first: ink-text-input's own backspace/cursor handling is exercised by text-field.test.ts, not re-tested here -- this only needs to prove the destination field's live value (not some other value) is what reaches saveDocumentTo. + rendered.stdin.write("-v2"); + await vi.waitFor(() => { + expect(rendered.lastFrame()).toContain(`${CWD}/untitled.docx-v2`); + }); + rendered.stdin.write("\r"); + + await vi.waitFor(() => { + expect(vi.mocked(saveDocumentTo)).toHaveBeenCalledTimes(1); + }); + expect(vi.mocked(saveDocumentTo).mock.calls[0]?.[1]).toBe( + `${CWD}/untitled.docx-v2`, + ); + }); + + it("surfaces a SAVE_ERROR status and leaves the screen open (does not pop) when the save fails", async () => { + vi.mocked(saveDocumentTo).mockRejectedValueOnce(new Error("disk full")); + const rendered = await renderOnSaveAsPrompt(); + const stackLenBefore = stackLenOf(rendered.lastFrame()); + + rendered.stdin.write("\r"); + + await vi.waitFor(() => { + expect(rendered.lastFrame()).toMatch( + /status:error:Could not save .*disk full/, + ); + }); + expect(rendered.lastFrame()).toContain("Save as"); + expect(stackLenOf(rendered.lastFrame())).toBe(stackLenBefore); + }); + + it("pops the screen on Escape without calling saveDocumentTo", async () => { + const rendered = await renderOnSaveAsPrompt(); + const stackLenBefore = stackLenOf(rendered.lastFrame()); + + rendered.stdin.write(""); + + await vi.waitFor(() => { + expect(stackLenOf(rendered.lastFrame())).toBe(stackLenBefore - 1); + }); + expect(vi.mocked(saveDocumentTo)).not.toHaveBeenCalled(); + }); + + it("throws its own exact invariant-violation message for a document with no path that isn't writable (a state the OpenDocument type itself guarantees can't happen)", async () => { + // Deliberately bypasses OpenDocument's own type guarantee (every non-writable variant -- OdbOpenDocument, XlsxOpenDocument, etc -- requires path: string) to prove defaultDestinationFor's defensive check actually fires when that invariant is violated, the same way a real bug in a future OpenDocument variant might violate it. Cast is unavoidable here: there is no way to construct this object through OpenDocument's own real type. + const impossibleDocument = { + format: "odb", + path: undefined, + tables: [], + forms: [], + reports: [], + } as unknown as OpenDocument; + + function BadDocHarness(): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + const requested = useRef(false); + useEffect(() => { + if (!requested.current) { + requested.current = true; + dispatch({ + type: "OPEN_FILE_SUCCESS", + path: "unused", + doc: impossibleDocument, + }); + } + }, [dispatch]); + if (state.openDocument === undefined) { + return loading...; + } + return ; + } + + const rendered = render( + + + + + , + ); + + await waitForFrame(rendered.lastFrame, (frame) => + frame.startsWith("caughtError:"), + ); + expect(rendered.lastFrame()).toContain( + "A document with no path is always a WritableOpenDocument", + ); + }); +}); diff --git a/packages/document-cli/src/tui/screens/save-as-prompt.tsx b/packages/document-cli/src/tui/screens/save-as-prompt.tsx index a9f7b893a..27c61fd39 100644 --- a/packages/document-cli/src/tui/screens/save-as-prompt.tsx +++ b/packages/document-cli/src/tui/screens/save-as-prompt.tsx @@ -13,14 +13,16 @@ import { // The suggested destination: the app's own current working directory (state.cwd, seeded from RunTuiOptions.cwd at startup) plus a sensible, extension-matched filename -- the document's own basename if it already has one (an `.odb`/`.pdf` document opened read-only always does; an editable one might not, if it was created fresh and never saved), otherwise "untitled" with the open document's own format extension. function defaultDestinationFor(document: OpenDocument, cwd: string): string { - const extension = isWritableDocument(document) - ? formatToExtension(document.format) - : "bin"; - const suggestedName = - document.path === undefined - ? `untitled.${extension}` - : basename(document.path); - return join(cwd, suggestedName); + if (document.path !== undefined) { + return join(cwd, basename(document.path)); + } + // Every OpenDocument variant whose path can be undefined is a WritableOpenDocument -- every non-writable variant (OdbOpenDocument, XlsxOpenDocument, CsvOpenDocument, SvgOpenDocument, RtfOpenDocument, WpdOpenDocument, EpubOpenDocument) requires path: string, per each one's own doc comment in types.ts. TypeScript's structural union can't express that cross-field invariant on its own, so isWritableDocument narrows document here purely so formatToExtension gets a format its own DocumentFormat parameter actually accepts (it has no "odb" entry) -- this guard is not reachable as false in practice, mirroring the identical situation this file already accepts for SaveAsPromptScreen's own `document === undefined` branch below. + if (!isWritableDocument(document)) { + throw new Error( + "A document with no path is always a WritableOpenDocument, per OpenDocument's own type structure -- this should be unreachable.", + ); + } + return join(cwd, `untitled.${formatToExtension(document.format)}`); } export function SaveAsPromptScreen(): ReactElement { From bd8dd0cd7738daf2abbdd2579d34d2fc1748412e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 11:24:57 +0100 Subject: [PATCH 101/101] test(document-cli): add direct coverage for FilePickerScreen Covers directory listing order and trailing slash, the .. entry's presence/absence, search-query filtering, navigating into and back out of a subdirectory, purpose=open selecting and opening a file (success and failure), purpose=saveAs/exportTarget's name-entry mode and its own pre-filled defaults, blank and no-open-document submission warnings, save/export success and failure paths including the diagnostics-panel side effect, Escape in both modes, and a read failure surfaced inline instead of crashing. --- .../src/tui/screens/file-picker.test.tsx | 571 ++++++++++++++++++ 1 file changed, 571 insertions(+) create mode 100644 packages/document-cli/src/tui/screens/file-picker.test.tsx diff --git a/packages/document-cli/src/tui/screens/file-picker.test.tsx b/packages/document-cli/src/tui/screens/file-picker.test.tsx new file mode 100644 index 000000000..ec3c71478 --- /dev/null +++ b/packages/document-cli/src/tui/screens/file-picker.test.tsx @@ -0,0 +1,571 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Box, Text } from "ink"; +import { render } from "ink-testing-library"; +import type { ReactElement } from "react"; +import { useEffect, useRef } from "react"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import type { ExportToPdfOptions } from "../format/export-pdf.js"; +import { exportToPdf } from "../format/export-pdf.js"; +import { openDocumentAtPath, saveDocumentTo } from "../format/open-document.js"; +import { + AppStateProvider, + useAppDispatch, + useAppState, +} from "../state/context.js"; +import type { OpenDocument, Screen } from "../state/types.js"; +import { settle, waitForFrame } from "../test-support.js"; +import { FilePickerScreen } from "./file-picker.js"; +import { NewDocumentPickerScreen } from "./new-document-picker.js"; + +// Type guards against the already-imported bindings' own real types, mirroring export-options.test.tsx's own isExportPdfModule for the identical reason. +function isOpenDocumentModule(value: unknown): value is { + openDocumentAtPath: typeof openDocumentAtPath; + saveDocumentTo: typeof saveDocumentTo; +} { + return ( + typeof value === "object" && + value !== null && + "openDocumentAtPath" in value && + "saveDocumentTo" in value + ); +} + +function isExportPdfModule( + value: unknown, +): value is { exportToPdf: typeof exportToPdf } { + return typeof value === "object" && value !== null && "exportToPdf" in value; +} + +vi.mock("../format/open-document.js", async (importOriginal) => { + const actual = await importOriginal(); + if (!isOpenDocumentModule(actual)) { + throw new Error( + "../format/open-document.js mock: importOriginal() returned an unexpected shape", + ); + } + return { + ...actual, + openDocumentAtPath: vi.fn( + actual.openDocumentAtPath, + ), + saveDocumentTo: vi.fn(() => Promise.resolve()), + }; +}); + +vi.mock("../format/export-pdf.js", async (importOriginal) => { + const actual = await importOriginal(); + if (!isExportPdfModule(actual)) { + throw new Error( + "../format/export-pdf.js mock: importOriginal() returned an unexpected shape", + ); + } + return { ...actual, exportToPdf: vi.fn(() => Promise.resolve()) }; +}); + +let workspace: string; + +beforeAll(async () => { + workspace = await mkdtemp(join(tmpdir(), "document-cli-file-picker-")); + await mkdir(join(workspace, "sub")); + await writeFile(join(workspace, "sub", "nested.txt"), "nested"); + await writeFile(join(workspace, "b-file.txt"), "b"); + await writeFile(join(workspace, "a-file.txt"), "a"); +}); + +afterAll(async () => { + await rm(workspace, { recursive: true, force: true }); +}); + +// Pushes a real filePicker screen onto the stack exactly once (mirroring the app's own :open/:saveas/:export handlers), matching save-as-prompt.test.tsx's own one-shot-ref pattern: FilePickerScreen throws if rendered while the stack top is not a filePicker screen, so once POP_SCREEN moves the stack top away this harness must stop rendering it rather than re-request it. +function Harness({ + purpose, + cwd, + withDocument = false, + searchQuery, +}: { + readonly purpose: "open" | "saveAs" | "exportTarget"; + readonly cwd: string; + readonly withDocument?: boolean; + readonly searchQuery?: string; +}): ReactElement { + const state = useAppState(); + const dispatch = useAppDispatch(); + const document = state.openDocument; + const top = state.stack.at(-1); + const onFilePicker = top?.kind === "filePicker"; + const requested = useRef(false); + const needsDocumentFirst = withDocument && document === undefined; + + useEffect(() => { + if (!needsDocumentFirst && !requested.current) { + requested.current = true; + const screen: Screen = { kind: "filePicker", purpose, cwd }; + dispatch({ type: "PUSH_SCREEN", screen }); + if (searchQuery !== undefined) { + dispatch({ type: "SET_SEARCH_QUERY", query: searchQuery }); + } + } + }, [needsDocumentFirst, dispatch, purpose, cwd, searchQuery]); + + if (needsDocumentFirst) { + return ; + } + return ( + + {onFilePicker ? : pushing filePicker...} + stackLen:{state.stack.length} + + status: + {state.status === undefined + ? "none" + : `${state.status.severity}:${state.status.text}`} + + + openDocument:{document === undefined ? "none" : document.format} + + diagnosticsPanel:{String(state.overlays.diagnosticsPanel)} + diagnosticsCount:{state.diagnostics.length} + + errorDetail: + {state.errorDetail === undefined + ? "none" + : `${state.errorDetail.message} | ${state.errorDetail.detail ?? ""}`} + + + ); +} + +function stackLenOf(frame: string | undefined): number { + const match = /stackLen:(\d+)/.exec(frame ?? ""); + if (match?.[1] === undefined) { + throw new Error(`No stackLen: marker in frame:\n${frame ?? "(none)"}`); + } + return Number(match[1]); +} + +// Ink wraps long lines to the test terminal's own (narrow) width, so a status/errorDetail line built from a long path routinely breaks across two or more physical lines. Collapsing all whitespace runs to a single space before matching makes a regex assertion robust to exactly where Ink happened to wrap it, without weakening what the assertion actually checks. +function flat(frame: string | undefined): string { + return (frame ?? "").replace(/\s+/g, " "); +} + +// ink-text-input drops a burst of unawaited backspace writes almost entirely (confirmed empirically): each keypress's own state update must commit before the next is sent, exactly as new-document-picker.test.tsx's own comment on repeated "j" presses already documents for navigation keys. +async function clearField( + rendered: ReturnType, + currentLength: number, +): Promise { + for (let step = 0; step < currentLength; step += 1) { + rendered.stdin.write(""); + await settle(); + } +} + +async function renderOnFilePicker(options: { + readonly purpose: "open" | "saveAs" | "exportTarget"; + readonly cwd: string; + readonly withDocument?: boolean; + readonly searchQuery?: string; +}): Promise> { + const rendered = render( + + + , + ); + if (options.withDocument === true) { + // Selects the first creatable format (docx) before the effect pushes filePicker. + rendered.stdin.write("\r"); + } + await waitForFrame(rendered.lastFrame, (frame) => + frame.includes(options.cwd), + ); + // FilePickerScreen mounted via an effect-driven conditional swap -- per test-support.ts's own settle() doc comment, its useInput listener is not guaranteed attached the instant its own title text appears. + await settle(); + return rendered; +} + +describe("FilePickerScreen", () => { + beforeEach(() => { + vi.mocked(saveDocumentTo).mockClear(); + vi.mocked(exportToPdf).mockClear(); + vi.mocked(openDocumentAtPath).mockClear(); + }); + + it("lists directories before files, both alphabetically, with a trailing / on directories only", async () => { + const rendered = await renderOnFilePicker({ + purpose: "open", + cwd: workspace, + }); + const frame = rendered.lastFrame() ?? ""; + const subIndex = frame.indexOf("sub/"); + const aIndex = frame.indexOf("a-file.txt"); + const bIndex = frame.indexOf("b-file.txt"); + expect(subIndex).toBeGreaterThanOrEqual(0); + expect(aIndex).toBeGreaterThan(subIndex); + expect(bIndex).toBeGreaterThan(aIndex); + expect(frame).not.toContain("a-file.txt/"); + }); + + it("shows a .. entry to go up when not at the filesystem root, and none at the root", async () => { + const nested = await renderOnFilePicker({ + purpose: "open", + cwd: workspace, + }); + expect(nested.lastFrame()).toContain(".."); + + const atRoot = await renderOnFilePicker({ purpose: "open", cwd: "/" }); + expect(atRoot.lastFrame()).not.toMatch(/^\.\.$/m); + }); + + it("filters the listing by the shared search query, case-insensitively", async () => { + const rendered = await renderOnFilePicker({ + purpose: "open", + cwd: workspace, + searchQuery: "B-FILE", + }); + expect(rendered.lastFrame()).toContain("b-file.txt"); + expect(rendered.lastFrame()).not.toContain("a-file.txt"); + expect(rendered.lastFrame()).not.toContain("sub/"); + }); + + it("navigates into a subdirectory on Enter, then back up on ..", async () => { + const rendered = await renderOnFilePicker({ + purpose: "open", + cwd: workspace, + }); + // "sub" sorts before both files, and is the first entry after ".." (hasParent is true here). + rendered.stdin.write("j"); // move off ".." onto "sub" + await settle(); + rendered.stdin.write("\r"); // enter "sub" + await waitForFrame(rendered.lastFrame, (frame) => + frame.includes(join(workspace, "sub")), + ); + expect(rendered.lastFrame()).toContain("nested.txt"); + + // Selection state (rawIndex) is not reset by a directory change -- it carried "1" in from the "j" press above, which clamps onto "nested.txt" (index 1 of [.., nested.txt]) inside sub rather than "..". Move back up to index 0 first. + await settle(); + rendered.stdin.write("k"); + await settle(); + rendered.stdin.write("\r"); // ".." is now selected + await waitForFrame(rendered.lastFrame, (frame) => { + const title = frame.split("\n")[0] ?? ""; + return title.includes(workspace) && !title.includes("sub"); + }); + }); + + it("opens the selected file on Enter for purpose=open, dispatching OPEN_FILE_SUCCESS", async () => { + const fakeDoc = { format: "markdown" } as unknown as OpenDocument; + vi.mocked(openDocumentAtPath).mockResolvedValueOnce(fakeDoc); + const rendered = await renderOnFilePicker({ + purpose: "open", + cwd: workspace, + }); + + // Entries in order: "..", "sub" (dir), "a-file.txt", "b-file.txt" -- two "j" from the initial index 0 (on "..") lands on "a-file.txt" (index 2). + rendered.stdin.write("j"); + await settle(); + rendered.stdin.write("j"); + await settle(); + rendered.stdin.write("\r"); + + await vi.waitFor(() => { + expect(rendered.lastFrame()).toContain("openDocument:markdown"); + }); + const call = vi.mocked(openDocumentAtPath).mock.calls[0]; + expect(call?.[0]).toBe(join(workspace, "a-file.txt")); + expect(typeof call?.[1]?.onDiagnostic).toBe("function"); + }); + + it("dispatches OPEN_FILE_ERROR with the underlying error's own detail when opening fails", async () => { + vi.mocked(openDocumentAtPath).mockRejectedValueOnce(new Error("bad zip")); + const rendered = await renderOnFilePicker({ + purpose: "open", + cwd: workspace, + }); + + rendered.stdin.write("j"); + await settle(); + rendered.stdin.write("j"); + await settle(); + rendered.stdin.write("\r"); + + await vi.waitFor(() => { + expect(flat(rendered.lastFrame())).toMatch( + /errorDetail:Could not open .*a-file\.txt \| bad zip/, + ); + }); + }); + + it("enters name-entry mode pre-filled with the clicked file's own name for purpose=saveAs", async () => { + const rendered = await renderOnFilePicker({ + purpose: "saveAs", + cwd: workspace, + withDocument: true, + }); + + rendered.stdin.write("j"); + await settle(); + rendered.stdin.write("j"); + await settle(); + rendered.stdin.write("\r"); + + await vi.waitFor(() => { + expect(rendered.lastFrame()).toContain("a-file.txt"); + }); + // The pre-filled TextField's value alone (a-file.txt, asserted above) is the real proof of enterName mode -- the "Enter a directory..." hint is browse-mode-only and correctly absent here. + }); + + it("'a' enters name-entry mode pre-filled with defaultBasenameFor's own suggestion", async () => { + const rendered = await renderOnFilePicker({ + purpose: "exportTarget", + cwd: workspace, + withDocument: true, + }); + + rendered.stdin.write("a"); + + await vi.waitFor(() => { + // A freshly created docx with no path defaults to "untitled", and exportTarget always swaps to .pdf regardless of the source format. + expect(rendered.lastFrame()).toContain("untitled.pdf"); + }); + }); + + it("warns and does not save when the typed name is blank", async () => { + const rendered = await renderOnFilePicker({ + purpose: "saveAs", + cwd: workspace, + withDocument: true, + }); + rendered.stdin.write("a"); + await vi.waitFor(() => { + expect(rendered.lastFrame()).toContain("untitled.docx"); + }); + await clearField(rendered, "untitled.docx".length); + expect(rendered.lastFrame()).not.toContain("untitled.docx"); + // A field cleared down to nothing but whitespace still submits "blank": the real onSubmit path trims before checking, so this proves the check is a genuine trim, not a bare === "" on the raw value. + rendered.stdin.write(" "); + await settle(); + + rendered.stdin.write("\r"); + + await vi.waitFor(() => { + expect(rendered.lastFrame()).toMatch( + /status:warning:Enter a filename first/, + ); + }); + expect(vi.mocked(saveDocumentTo)).not.toHaveBeenCalled(); + }); + + it("warns and does not save when there is no open document", async () => { + const rendered = await renderOnFilePicker({ + purpose: "saveAs", + cwd: workspace, + }); + rendered.stdin.write("a"); + await vi.waitFor(() => { + expect(rendered.lastFrame()).toContain("untitled"); + }); + + rendered.stdin.write("x.docx"); + await vi.waitFor(() => { + expect(rendered.lastFrame()).toContain("x.docx"); + }); + rendered.stdin.write("\r"); + + await vi.waitFor(() => { + expect(rendered.lastFrame()).toMatch( + /status:warning:There is no open document/, + ); + }); + expect(vi.mocked(saveDocumentTo)).not.toHaveBeenCalled(); + }); + + it("saves successfully then pops the screen for purpose=saveAs", async () => { + vi.mocked(saveDocumentTo).mockResolvedValueOnce(undefined); + const rendered = await renderOnFilePicker({ + purpose: "saveAs", + cwd: workspace, + withDocument: true, + }); + const stackLenBefore = stackLenOf(rendered.lastFrame()); + + rendered.stdin.write("a"); + await vi.waitFor(() => { + expect(rendered.lastFrame()).toContain("untitled.docx"); + }); + rendered.stdin.write("\r"); + + await vi.waitFor(() => { + expect(rendered.lastFrame()).toMatch(/status:info:Saved/); + expect(stackLenOf(rendered.lastFrame())).toBe(stackLenBefore - 1); + }); + expect(vi.mocked(saveDocumentTo)).toHaveBeenCalledWith( + expect.objectContaining({ format: "docx" }), + join(workspace, "untitled.docx"), + ); + }); + + it("surfaces SAVE_ERROR and leaves the screen open when the save fails", async () => { + vi.mocked(saveDocumentTo).mockRejectedValueOnce(new Error("disk full")); + const rendered = await renderOnFilePicker({ + purpose: "saveAs", + cwd: workspace, + withDocument: true, + }); + const stackLenBefore = stackLenOf(rendered.lastFrame()); + + rendered.stdin.write("a"); + await vi.waitFor(() => { + expect(rendered.lastFrame()).toContain("untitled.docx"); + }); + rendered.stdin.write("\r"); + + await vi.waitFor(() => { + expect(flat(rendered.lastFrame())).toMatch( + /status:error:Could not save .*disk full/, + ); + }); + expect(stackLenOf(rendered.lastFrame())).toBe(stackLenBefore); + }); + + it("exports successfully with no diagnostics: pops the screen, leaves the diagnostics panel closed", async () => { + vi.mocked(exportToPdf).mockResolvedValueOnce(undefined); + const rendered = await renderOnFilePicker({ + purpose: "exportTarget", + cwd: workspace, + withDocument: true, + }); + const stackLenBefore = stackLenOf(rendered.lastFrame()); + + rendered.stdin.write("a"); + await vi.waitFor(() => { + expect(rendered.lastFrame()).toContain("untitled.pdf"); + }); + rendered.stdin.write("\r"); + + await vi.waitFor(() => { + expect(rendered.lastFrame()).toMatch(/status:info:Exported/); + expect(stackLenOf(rendered.lastFrame())).toBe(stackLenBefore - 1); + }); + expect(rendered.lastFrame()).toContain("diagnosticsPanel:false"); + }); + + it("exports with a diagnostic: opens the diagnostics panel and still pops the screen", async () => { + vi.mocked(exportToPdf).mockImplementationOnce( + (_doc: OpenDocument, _dest: string, options: ExportToPdfOptions) => { + options.onDiagnostic({ + severity: "info", + message: "substituted a font", + }); + return Promise.resolve(); + }, + ); + const rendered = await renderOnFilePicker({ + purpose: "exportTarget", + cwd: workspace, + withDocument: true, + }); + const stackLenBefore = stackLenOf(rendered.lastFrame()); + + rendered.stdin.write("a"); + await vi.waitFor(() => { + expect(rendered.lastFrame()).toContain("untitled.pdf"); + }); + rendered.stdin.write("\r"); + + await vi.waitFor(() => { + expect(rendered.lastFrame()).toContain("diagnosticsCount:1"); + expect(rendered.lastFrame()).toContain("diagnosticsPanel:true"); + expect(stackLenOf(rendered.lastFrame())).toBe(stackLenBefore - 1); + }); + }); + + it("dispatches OPEN_FILE_ERROR when the export fails", async () => { + vi.mocked(exportToPdf).mockRejectedValueOnce(new Error("no fonts")); + const rendered = await renderOnFilePicker({ + purpose: "exportTarget", + cwd: workspace, + withDocument: true, + }); + + rendered.stdin.write("a"); + await vi.waitFor(() => { + expect(rendered.lastFrame()).toContain("untitled.pdf"); + }); + rendered.stdin.write("\r"); + + await vi.waitFor(() => { + expect(flat(rendered.lastFrame())).toMatch( + /errorDetail:Could not export to .*untitled\.pdf \| no fonts/, + ); + }); + }); + + it("Escape in name-entry mode returns to browse mode without dispatching anything", async () => { + const rendered = await renderOnFilePicker({ + purpose: "saveAs", + cwd: workspace, + withDocument: true, + }); + rendered.stdin.write("a"); + await vi.waitFor(() => { + expect(rendered.lastFrame()).toContain("untitled.docx"); + }); + + rendered.stdin.write(""); + + await vi.waitFor(() => { + expect(rendered.lastFrame()).toContain("Enter a directory"); + }); + expect(vi.mocked(saveDocumentTo)).not.toHaveBeenCalled(); + }); + + it("Escape in browse mode pops the screen", async () => { + const rendered = await renderOnFilePicker({ + purpose: "open", + cwd: workspace, + }); + const stackLenBefore = stackLenOf(rendered.lastFrame()); + + rendered.stdin.write(""); + + await vi.waitFor(() => { + expect(stackLenOf(rendered.lastFrame())).toBe(stackLenBefore - 1); + }); + }); + + it("shows the read error inline when the directory cannot be listed, instead of crashing", async () => { + const rendered = await renderOnFilePicker({ + purpose: "open", + cwd: join(workspace, "does-not-exist"), + }); + expect(rendered.lastFrame()).toMatch(/ENOENT|no such file/i); + }); + + it("titles each purpose distinctly: Open a document / Save as / Export destination", async () => { + const open = await renderOnFilePicker({ purpose: "open", cwd: workspace }); + expect(open.lastFrame()).toContain("Open a document"); + + const saveAs = await renderOnFilePicker({ + purpose: "saveAs", + cwd: workspace, + withDocument: true, + }); + expect(saveAs.lastFrame()).toContain("Save as"); + + const exportTarget = await renderOnFilePicker({ + purpose: "exportTarget", + cwd: workspace, + withDocument: true, + }); + expect(exportTarget.lastFrame()).toContain("Export destination"); + }); +});