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: 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"); + }); }); 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"); + }); +}); 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..e41e87931 --- /dev/null +++ b/packages/document-cli/src/commands/convert.test.ts @@ -0,0 +1,251 @@ +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).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("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([ + "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); + }); +}); + +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", + ); + }); +}); 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); + }); +}); 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", + ); + }); }); 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..496adff74 --- /dev/null +++ b/packages/document-cli/src/commands/formats.test.ts @@ -0,0 +1,78 @@ +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"); + }); + + 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/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" })); 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", + ); + }); }); 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", () => { 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..1426eec5f --- /dev/null +++ b/packages/document-cli/src/commands/odm.test.ts @@ -0,0 +1,273 @@ +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, + 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()); + + 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 () => { + 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("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", + 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, naming both under the odm-to-pdf command", 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).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 () => { + 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, + }); + }); + + 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", + ); + 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", + ); + }); +}); 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..010b1aeba --- /dev/null +++ b/packages/document-cli/src/commands/options.test.ts @@ -0,0 +1,223 @@ +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); + }); +}); + +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", + ); + }); +}); diff --git a/packages/document-cli/src/commands/outline.test.ts b/packages/document-cli/src/commands/outline.test.ts index 38858a2e9..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 () => { @@ -278,6 +343,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, ""); 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([ 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([ 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"); + }); +}); 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) { diff --git a/packages/document-cli/src/format.test.ts b/packages/document-cli/src/format.test.ts index 8674a0352..12695ce56 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); } @@ -58,6 +63,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(); }); @@ -97,10 +107,18 @@ 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", () => { - // 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], @@ -119,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); diff --git a/packages/document-cli/src/format.ts b/packages/document-cli/src/format.ts index 12ae9fe54..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,38 +34,38 @@ 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), 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) { @@ -76,5 +76,5 @@ export function inferFormatFromExtension( } export function formatToExtension(format: DocumentFormat): string { - return FORMAT_TO_EXTENSION[format]; + return getFormatToExtension()[format]; } 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)", + ]); + }); +}); 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; + }); +}); 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..184cc9c2d --- /dev/null +++ b/packages/document-cli/src/runtime/abort.test.ts @@ -0,0 +1,106 @@ +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"); + }); + + 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; } 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..e8e472155 --- /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: "missing-face", + }); + 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/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; } 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..d98d55eee --- /dev/null +++ b/packages/document-cli/src/runtime/fonts.test.ts @@ -0,0 +1,98 @@ +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/, + ); + }); + + 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 new file mode 100644 index 000000000..4647e9c23 --- /dev/null +++ b/packages/document-cli/src/runtime/io.test.ts @@ -0,0 +1,202 @@ +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("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("-"); + 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..e2901780e --- /dev/null +++ b/packages/document-cli/src/runtime/markdown-images.test.ts @@ -0,0 +1,106 @@ +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"; +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])); + // 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 () => { + 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(); + }); + + 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])); + }); +}); 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%"]); + }); +}); 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, + }); +} 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); + }); +}); 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"), + ); + }); +}); 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..a867f5fda --- /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<() => void>(); + 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<() => void>(); + 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<() => void>(); + 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<() => void>(); + 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<() => void>(); + 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<() => void>(); + 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<() => void>(); + const onCancel = vi.fn<() => void>(); + 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/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/error-detail.test.tsx b/packages/document-cli/src/tui/components/error-detail.test.tsx new file mode 100644 index 000000000..49281c0cb --- /dev/null +++ b/packages/document-cli/src/tui/components/error-detail.test.tsx @@ -0,0 +1,151 @@ +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 { 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 { + 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("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) => + 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/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 ); 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/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/components/search-overlay.test.tsx b/packages/document-cli/src/tui/components/search-overlay.test.tsx new file mode 100644 index 000000000..2790df956 --- /dev/null +++ b/packages/document-cli/src/tui/components/search-overlay.test.tsx @@ -0,0 +1,138 @@ +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("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) => + 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..8fca4f198 --- /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 + 15000; +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"; 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..5f2c9f015 --- /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<() => void>(); + 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<() => void>(); + 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"); + }); +}); 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/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/open-document.test.ts b/packages/document-cli/src/tui/format/open-document.test.ts index 916b9d3ca..1755f70a2 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,46 @@ 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 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(); 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"); + }); +}); 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/); + }); +}); 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..02642a1a9 --- /dev/null +++ b/packages/document-cli/src/tui/format/render-odb-report.test.ts @@ -0,0 +1,158 @@ +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"; + +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/); + }); + + 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("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()); + 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); + }); +}); 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..fc1746732 --- /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<() => void>(); + 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<() => void>(); + 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<() => void>(); + const { stdin } = render( + undefined} />, + ); + await pressKey(stdin, ENTER); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("calls onAppend on 'a' when provided", async () => { + const onAppend = vi.fn<() => void>(); + 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"); + }); +}); 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..198251595 --- /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 | undefined { + 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/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/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"); + }); + }, + ); +}); 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"); + }); +}); 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/markdown/index.test.tsx b/packages/document-cli/src/tui/screens/editors/markdown/index.test.tsx new file mode 100644 index 000000000..c91cd3443 --- /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 | undefined { + 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/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/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/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/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"); + }); +}); 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/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.tsx b/packages/document-cli/src/tui/screens/editors/odp/rotation-field.tsx index 3a400adf8..3523054d0 100644 --- a/packages/document-cli/src/tui/screens/editors/odp/rotation-field.tsx +++ b/packages/document-cli/src/tui/screens/editors/odp/rotation-field.tsx @@ -1,6 +1,7 @@ import { Box, Text } from "ink"; import type { ReactElement } from "react"; import { TextField } from "../../../components/text-field.js"; +import { selectedColor } from "../../../components/list-view.js"; export interface RotationFieldProps { readonly rotationDeg: number | undefined; @@ -38,10 +39,7 @@ export function RotationField(props: RotationFieldProps): ReactElement { } return ( - + [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/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(); + }); +}); 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/index.test.tsx b/packages/document-cli/src/tui/screens/editors/odt/index.test.tsx new file mode 100644 index 000000000..4879ee9b4 --- /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 | undefined { + 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.", + ); + }); +}); 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/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(); + }); +}); 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; } 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"), + ); + }); +}); diff --git a/packages/document-cli/src/tui/screens/editors/pptx/index.test.tsx b/packages/document-cli/src/tui/screens/editors/pptx/index.test.tsx new file mode 100644 index 000000000..bc72a20ab --- /dev/null +++ b/packages/document-cli/src/tui/screens/editors/pptx/index.test.tsx @@ -0,0 +1,18 @@ +import { renderToString } from "ink"; +import { describe, expect, it } from "vitest"; +import { AppStateProvider } from "../../../state/context.js"; +import { PptxSlideListScreen } from "./index.js"; + +describe("PptxSlideListScreen", () => { + 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\./, + ); + }); +}); 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/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)"); + }); }, ); 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") { 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)"); + }); +}); 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/color.test.ts b/packages/document-cli/src/tui/screens/shared/color.test.ts new file mode 100644 index 000000000..7219abe64 --- /dev/null +++ b/packages/document-cli/src/tui/screens/shared/color.test.ts @@ -0,0 +1,84 @@ +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"); + }); + + 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 }); + }); +}); 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 ( 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..60ddb2bba 100644 --- a/packages/document-cli/src/tui/screens/shared/formula-picker.tsx +++ b/packages/document-cli/src/tui/screens/shared/formula-picker.tsx @@ -1,11 +1,11 @@ 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"; -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, })), @@ -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/formula-presets.test.ts b/packages/document-cli/src/tui/screens/shared/formula-presets.test.ts new file mode 100644 index 000000000..b6d0ccc11 --- /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 { getFormulaPresets } from "./formula-presets"; + +describe("getFormulaPresets", () => { + it("declares exactly six presets", () => { + expect(getFormulaPresets()).toHaveLength(6); + }); + + it("gives every preset a non-empty label and at least one MathML node", () => { + 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(getFormulaPresets().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(getFormulaPresets()[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(getFormulaPresets()[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(getFormulaPresets()[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(getFormulaPresets()[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(getFormulaPresets()[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(getFormulaPresets()[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/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")]), ]), - ]), - ], - }, -]; + ], + }, + ]; +} 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" : ""}) 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 new file mode 100644 index 000000000..a90bba8b3 --- /dev/null +++ b/packages/document-cli/src/tui/screens/shared/slide-table.test.ts @@ -0,0 +1,140 @@ +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(); + }); + + 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", () => { + 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); + }); +}); 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 }); + }); +}); 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).", + ); + }); +}); diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index aa25aa621..6be3e0ca9 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -1,12 +1,18 @@ import { + createDoc, createOdg, createOdp, createOds, createOdt, createPdf, + createPpt, createPptx, + createXls, drawingOfBlock, + formulaOfBlock, + type MathMlNode, odsToXlsx, + openDoc, openDocx, openMarkdown, openOdg, @@ -14,7 +20,9 @@ import { openOds, openOdt, openPdf, + openPpt, openPptx, + openXls, readDocxContent, readOdpContent, readOdsContent, @@ -28,21 +36,40 @@ import type { Action } from "./actions.js"; import { appReducer, createInitialState } from "./reducer.js"; import type { AppState, + CsvOpenDocument, + DocOpenDocument, DocxOpenDocument, + EpubOpenDocument, MarkdownOpenDocument, + OdbOpenDocument, OdgOpenDocument, OdpOpenDocument, OdsOpenDocument, OdtOpenDocument, PdfOpenDocument, PptxOpenDocument, + RtfOpenDocument, + SvgOpenDocument, + WpdOpenDocument, + XlsxOpenDocument, } 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(), @@ -58,6 +85,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") { @@ -173,6 +208,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(); @@ -193,6 +261,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") { @@ -263,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); }); }); @@ -316,6 +418,207 @@ 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 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_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(), { + 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(), { @@ -414,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", @@ -425,6 +751,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); }); @@ -506,6 +833,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); }); }, @@ -712,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(); @@ -787,6 +1181,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(), { @@ -1291,6 +1748,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, { @@ -1366,6 +1826,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", () => { @@ -1422,11 +1923,102 @@ describe("appReducer undo", () => { expect(undone.status?.severity).toBe("info"); expect(undone.openDocument).toBe(created.openDocument); }); -}); -describe("appReducer ADD_SLIDE_TABLE", () => { - it("adds a real table to a pptx slide, reachable through documents.js own PptxSlide.addTable", () => { - const editor = createPptx(); + 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, + ); + }); + + // 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); + }, + ); + + // 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", () => { + it("adds a real table to a pptx slide, reachable through documents.js own PptxSlide.addTable", () => { + const editor = createPptx(); editor.addSlide(); const opened = openPptxDocument(editor.toBytes()); @@ -1659,6 +2251,213 @@ describe("appReducer MERGE_SLIDE_TABLE_CELLS", () => { expect(result.status?.severity).toBe("warning"); 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(); + 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); + }); + + // 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", () => { @@ -1706,7 +2505,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()); @@ -1719,6 +2518,39 @@ 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"); + }); + + // 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"); }); }); @@ -1749,6 +2581,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(); @@ -1815,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()); @@ -1890,6 +2808,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, { @@ -1898,6 +2902,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); }); @@ -1914,11 +2919,914 @@ describe("appReducer PDF item and page mutations", () => { expect(result.status?.text).toContain("not rect"); expect(result.hasUnsavedChanges).toBe(false); }); -}); -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(); + 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"); + }); + + // 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"); + }); + + // 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); + }, + ); + }); +}); + +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 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(); editor.addSlide(); const opened = openOdpDocument(editor.toBytes()); @@ -2032,6 +3940,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", () => { @@ -2137,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"); + }); +}); diff --git a/packages/document-cli/src/tui/state/reducer.ts b/packages/document-cli/src/tui/state/reducer.ts index f4660fc44..64f23c6bb 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 { @@ -2235,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" || @@ -2242,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( 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"); + }); +}); 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); + }); +}); 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) => { 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, }); 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, }, });