diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 7451e04c7b..74b48b0952 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.test.ts b/packages/document-cli/src/cli-main.test.ts new file mode 100644 index 0000000000..46ef1bd39d --- /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/from-package.test.ts b/packages/document-cli/src/commands/from-package.test.ts index a1a076af39..86378b4c95 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/odb.test.ts b/packages/document-cli/src/commands/odb.test.ts index 16d12bbde7..40823d450c 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 index 2fca09bb49..1426eec5f2 100644 --- a/packages/document-cli/src/commands/odm.test.ts +++ b/packages/document-cli/src/commands/odm.test.ts @@ -2,6 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createOdt } from "documents.js"; +import { FIXTURE_FONT_FAMILY } from "../test-support/font-fixture"; import { afterAll, afterEach, @@ -69,6 +70,20 @@ beforeAll(async () => { const chapter = createOdt(); chapter.body.appendParagraph().appendRun({ text: "Chapter content" }); await writeFile(join(workspace, "chapter1.odt"), chapter.toBytes()); + + await writeFile( + join(workspace, "book-calibri.odm"), + singleChapterOdmBytes("calibri-chapter.odt"), + ); + const calibriChapter = createOdt(); + calibriChapter.body.appendParagraph().appendRun({ + text: "A paragraph set in Calibri", + fontFamily: FIXTURE_FONT_FAMILY, + }); + await writeFile( + join(workspace, "calibri-chapter.odt"), + calibriChapter.toBytes(), + ); }); afterAll(async () => { @@ -181,6 +196,46 @@ describe("odm-to-pdf", () => { }); }); + it("writes to the path named by --out when no positional output is given", async () => { + const output = join(workspace, "via-out-flag.pdf"); + const { exitCode } = await runCli([ + "odm-to-pdf", + join(workspace, "book.odm"), + "--out", + output, + "--chapters-dir", + workspace, + ]); + expect(exitCode).toBe(EXIT_SUCCESS); + const bytes = new Uint8Array(await readFile(output)); + expect(bytes.byteLength).toBeGreaterThan(0); + }); + + it("prints a font-substitution event under --report-font-substitutions, and stays silent without it", async () => { + const reported = await runCli([ + "odm-to-pdf", + join(workspace, "book-calibri.odm"), + join(workspace, "reported.pdf"), + "--chapters-dir", + workspace, + "--report-font-substitutions", + ]); + expect(reported.exitCode).toBe(EXIT_SUCCESS); + expect(reported.stderr).toContain( + '[odm-to-pdf] font substitution: "Calibri" -> "carlito" (vendored-substitute)', + ); + + const silent = await runCli([ + "odm-to-pdf", + join(workspace, "book-calibri.odm"), + join(workspace, "silent.pdf"), + "--chapters-dir", + workspace, + ]); + expect(silent.exitCode).toBe(EXIT_SUCCESS); + expect(silent.stderr).not.toContain("font substitution"); + }); + it("registers odm-to-pdf with its own description and every conversion/font/chapter option", () => { const command = createProgram().commands.find( (candidate) => candidate.name() === "odm-to-pdf", diff --git a/packages/document-cli/src/commands/pdf-inspect.test.ts b/packages/document-cli/src/commands/pdf-inspect.test.ts index f20d18a7ad..6a5ca8f0a7 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/format.test.ts b/packages/document-cli/src/format.test.ts index 0eb2661958..12695ce56d 100644 --- a/packages/document-cli/src/format.test.ts +++ b/packages/document-cli/src/format.test.ts @@ -21,6 +21,11 @@ describe("isDocumentFormat", () => { "markdown", "rtf", "pdf", + "wpd", + "doc", + "xls", + "ppt", + "epub", ]) { expect(isDocumentFormat(format)).toBe(true); } @@ -102,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], @@ -124,6 +137,11 @@ describe("formatToExtension", () => { ["markdown", "md"], ["rtf", "rtf"], ["pdf", "pdf"], + ["wpd", "wpd"], + ["doc", "doc"], + ["xls", "xls"], + ["ppt", "ppt"], + ["epub", "epub"], ]; for (const [format, extension] of cases) { expect(formatToExtension(format)).toBe(extension); diff --git a/packages/document-cli/src/format.ts b/packages/document-cli/src/format.ts index 9489d45971..4c33dde811 100644 --- a/packages/document-cli/src/format.ts +++ b/packages/document-cli/src/format.ts @@ -1,6 +1,6 @@ import type { DocumentFormat } from "documents.js"; -// 'md' and 'markdown' both read as the 'markdown' DocumentFormat, and every ODF/OOXML template and macro-enabled variant reads as its base format -- the many-to-one entries this table carries, deliberately breaking what was previously a perfect mirror with FORMAT_TO_EXTENSION (every base format's own extension is also its canonical one). A template (.ott/.ots/.otp/.otg/.otf) is the same package as its non-template sibling with only the mimetype's own "-template" suffix differing, and a macro-enabled OOXML file (.docm/.xlsm/.pptm) is the same package with a vbaProject part this library reads past (macros are never executed or re-emitted); so both read through the base codec unchanged. FORMAT_TO_EXTENSION below still names exactly one extension per format, so writing always picks the canonical base extension. +// 'md' and 'markdown' both read as the 'markdown' DocumentFormat, and every ODF/OOXML template and macro-enabled variant reads as its base format -- the many-to-one entries this table carries, deliberately breaking what was previously a perfect mirror with getFormatToExtension's own table (every base format's own extension is also its canonical one). A template (.ott/.ots/.otp/.otg/.otf) is the same package as its non-template sibling with only the mimetype's own "-template" suffix differing, and a macro-enabled OOXML file (.docm/.xlsm/.pptm) is the same package with a vbaProject part this library reads past (macros are never executed or re-emitted); so both read through the base codec unchanged. getFormatToExtension below still names exactly one extension per format, so writing always picks the canonical base extension. const EXTENSION_TO_FORMAT: Readonly> = { docx: "docx", dotx: "docx", @@ -34,29 +34,32 @@ const EXTENSION_TO_FORMAT: Readonly> = { pdf: "pdf", }; -const FORMAT_TO_EXTENSION: Readonly> = { - docx: "docx", - pptx: "pptx", - xlsx: "xlsx", - odt: "odt", - odp: "odp", - ods: "ods", - odg: "odg", - odf: "odf", - csv: "csv", - svg: "svg", - markdown: "md", - rtf: "rtf", - wpd: "wpd", - doc: "doc", - xls: "xls", - ppt: "ppt", - epub: "epub", - pdf: "pdf", -}; +// A function rather than a module-scope constant, deliberately: Stryker's per-test coverage instrumentation attributes a module-scope object literal's own one-time initialisation to whichever test happens to trigger the very first import of this module in the whole suite (module caching means every later importer just reads the already-built object), so a mutated extension value here would only ever be re-verified against that one unrelated first-importing test, never against a test that actually calls formatToExtension with the mutated format. Rebuilding the object fresh on every call makes each call site's own execution the thing coverage attributes to, so this file's own exhaustive formatToExtension/isDocumentFormat tests are what get re-run against a mutant. +function getFormatToExtension(): Readonly> { + return { + docx: "docx", + pptx: "pptx", + xlsx: "xlsx", + odt: "odt", + odp: "odp", + ods: "ods", + odg: "odg", + odf: "odf", + csv: "csv", + svg: "svg", + markdown: "md", + rtf: "rtf", + wpd: "wpd", + doc: "doc", + xls: "xls", + ppt: "ppt", + epub: "epub", + pdf: "pdf", + }; +} export function isDocumentFormat(value: string): value is DocumentFormat { - return value in FORMAT_TO_EXTENSION; + return value in getFormatToExtension(); } // Reads the extension after the last '.' in the final path segment (so 'a.b/c.docx' -> 'docx', '.gitignore' -> undefined -- a leading dot with no further '.' is not an extension). Returns undefined for no recognised extension, an unrecognised one, a bare '-' (stdin/stdout marker, which has no '.' of its own and so already falls out of the extension check below with no special-cased branch needed), or a path with none at all -- callers decide how to react to an unresolved format, this module only classifies. @@ -73,5 +76,5 @@ export function inferFormatFromExtension( } export function formatToExtension(format: DocumentFormat): string { - return FORMAT_TO_EXTENSION[format]; + return getFormatToExtension()[format]; } diff --git a/packages/document-cli/src/runtime/abort.ts b/packages/document-cli/src/runtime/abort.ts index 7b844cebbc..108d4c1ff3 100644 --- a/packages/document-cli/src/runtime/abort.ts +++ b/packages/document-cli/src/runtime/abort.ts @@ -1,4 +1,4 @@ -// 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. +// 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) passes the signals of two AbortControllers it constructed itself, with no await between either construction and this call, so neither can have 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(); 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 0000000000..313c5a88e2 --- /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/screens/editors/docx/paragraph-detail.test.tsx b/packages/document-cli/src/tui/screens/editors/docx/paragraph-detail.test.tsx index 31dabd75c5..1734a6e650 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/pptx/slide-table-detail.test.tsx b/packages/document-cli/src/tui/screens/editors/pptx/slide-table-detail.test.tsx index 548d2f4a03..6c9696b831 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 f7fb65109e..4f244738c5 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/shared/formula-picker.tsx b/packages/document-cli/src/tui/screens/shared/formula-picker.tsx index fb88243cd5..60ddb2bba3 100644 --- a/packages/document-cli/src/tui/screens/shared/formula-picker.tsx +++ b/packages/document-cli/src/tui/screens/shared/formula-picker.tsx @@ -5,7 +5,7 @@ import { ListView, selectedColor } from "../../components/list-view.js"; import { TextField } from "../../components/text-field.js"; import { useNavigationInput } from "../../keybindings/use-navigation-input.js"; import { describeError } from "../../errors.js"; -import { FORMULA_PRESETS } from "./formula-presets.js"; +import { getFormulaPresets } from "./formula-presets.js"; const RAW_ENTRY_LABEL = "Raw MathML..."; @@ -16,7 +16,7 @@ interface PickerRow { } const PICKER_ROWS: readonly PickerRow[] = [ - ...FORMULA_PRESETS.map((preset) => ({ + ...getFormulaPresets().map((preset) => ({ label: preset.label, mathml: preset.mathml, })), diff --git a/packages/document-cli/src/tui/screens/shared/formula-presets.test.ts b/packages/document-cli/src/tui/screens/shared/formula-presets.test.ts index 1f33933d77..b6d0ccc11c 100644 --- a/packages/document-cli/src/tui/screens/shared/formula-presets.test.ts +++ b/packages/document-cli/src/tui/screens/shared/formula-presets.test.ts @@ -1,20 +1,20 @@ import { describe, expect, it } from "vitest"; -import { FORMULA_PRESETS } from "./formula-presets"; +import { getFormulaPresets } from "./formula-presets"; -describe("FORMULA_PRESETS", () => { +describe("getFormulaPresets", () => { it("declares exactly six presets", () => { - expect(FORMULA_PRESETS).toHaveLength(6); + expect(getFormulaPresets()).toHaveLength(6); }); it("gives every preset a non-empty label and at least one MathML node", () => { - for (const preset of FORMULA_PRESETS) { + for (const preset of getFormulaPresets()) { expect(preset.label.length).toBeGreaterThan(0); expect(preset.mathml.length).toBeGreaterThan(0); } }); it("declares the exact labels, in order", () => { - expect(FORMULA_PRESETS.map((preset) => preset.label)).toEqual([ + expect(getFormulaPresets().map((preset) => preset.label)).toEqual([ "Fraction: x / 2", "Power: x^2", "Subscript: x_i", @@ -25,7 +25,7 @@ describe("FORMULA_PRESETS", () => { }); it("builds the fraction preset as mfrac(mi(x), mn(2))", () => { - expect(FORMULA_PRESETS[0]?.mathml).toEqual([ + expect(getFormulaPresets()[0]?.mathml).toEqual([ { type: "element", tag: "mfrac", @@ -49,7 +49,7 @@ describe("FORMULA_PRESETS", () => { }); it("builds the power preset as msup(mi(x), mn(2))", () => { - expect(FORMULA_PRESETS[1]?.mathml).toEqual([ + expect(getFormulaPresets()[1]?.mathml).toEqual([ { type: "element", tag: "msup", @@ -73,7 +73,7 @@ describe("FORMULA_PRESETS", () => { }); it("builds the subscript preset as msub(mi(x), mi(i))", () => { - expect(FORMULA_PRESETS[2]?.mathml).toEqual([ + expect(getFormulaPresets()[2]?.mathml).toEqual([ { type: "element", tag: "msub", @@ -97,7 +97,7 @@ describe("FORMULA_PRESETS", () => { }); it("builds the square-root preset as msqrt(mi(x))", () => { - expect(FORMULA_PRESETS[3]?.mathml).toEqual([ + expect(getFormulaPresets()[3]?.mathml).toEqual([ { type: "element", tag: "msqrt", @@ -115,7 +115,7 @@ describe("FORMULA_PRESETS", () => { }); it("builds the exact summation preset tree", () => { - expect(FORMULA_PRESETS[4]?.mathml).toEqual([ + expect(getFormulaPresets()[4]?.mathml).toEqual([ { type: "element", tag: "munderover", @@ -173,7 +173,7 @@ describe("FORMULA_PRESETS", () => { const mn = (value: string): unknown => element("mn", [text(value)]); const mo = (operator: string): unknown => element("mo", [text(operator)]); - expect(FORMULA_PRESETS[5]?.mathml).toEqual([ + expect(getFormulaPresets()[5]?.mathml).toEqual([ element("mrow", [ mi("x"), mo("="), diff --git a/packages/document-cli/src/tui/screens/shared/formula-presets.ts b/packages/document-cli/src/tui/screens/shared/formula-presets.ts index 2442844d07..8aa58e7bb0 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/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index aa25aa621d..8674c49a64 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -1,12 +1,20 @@ import { + createDoc, createOdg, createOdp, createOds, createOdt, createPdf, + createPpt, createPptx, + createXls, drawingOfBlock, + formulaOfBlock, + LAYOUT_FORMAT_VERSION, + type LayoutDocument, + type MathMlNode, odsToXlsx, + openDoc, openDocx, openMarkdown, openOdg, @@ -14,13 +22,16 @@ import { openOds, openOdt, openPdf, + openPpt, openPptx, + openXls, readDocxContent, readOdpContent, readOdsContent, readOdtContent, readPdf, readPptxContent, + writePdf, xlsxToPdf, } from "documents.js"; import { describe, expect, it } from "vitest"; @@ -28,21 +39,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 +88,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 +211,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 +264,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 +362,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); }); }); @@ -281,6 +386,7 @@ describe("appReducer document lifecycle", () => { expect(state.stack.map((screen) => screen.kind)).toEqual([expectedKind]); expect(state.openDocument?.format).toBe(action.format); expect(state.hasUnsavedChanges).toBe(false); + expect(state.status?.text).toBe(`New ${action.format} document`); } }); @@ -316,6 +422,217 @@ 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.status?.text).toBe( + "Saved, but there is no open document to record the path against", + ); + 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"); + expect(result.status?.text).toBe("There is no open document to close"); + }); + + it("closes immediately, with no confirmation overlay, when there are no unsaved changes", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const withQuery = appReducer(created, { + type: "SET_SEARCH_QUERY", + query: "leftover search", + }); + const requested = appReducer(withQuery, { type: "REQUEST_CLOSE" }); + expect(requested.openDocument).toBeUndefined(); + expect(requested.overlays.confirmClose).toBe(false); + // closeDocument resets searchQuery back to empty rather than carrying a stale search over into whatever gets opened next. + expect(requested.searchQuery).toBe(""); + }); + + it("opens the confirmClose overlay instead of closing outright when there are unsaved changes", () => { + 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(), { @@ -367,6 +684,9 @@ describe("appReducer SET_METADATA", () => { overrides: { title: "x" }, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs an editable document; the open document is no document", + ); expect(result.hasUnsavedChanges).toBe(false); }); }); @@ -414,6 +734,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 +768,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); }); @@ -442,112 +786,285 @@ describe("appReducer docx mutations", () => { expect(warned.status?.severity).toBe("warning"); expect(warned.hasUnsavedChanges).toBe(false); }); -}); - -describe.each(["docx", "odt"] as const)( - "appReducer SET_RUN_FONT_FAMILY / SET_RUN_FONT_SIZE on %s", - (format) => { - it("sets a real font family and size through the live DocxRun/OdtRun setters, verified by re-decoding the package", () => { - const state = applyAll([ - { type: "CREATE_DOCUMENT", format }, - { - type: "APPEND_PARAGRAPH", - text: undefined, - styleId: undefined, - alignment: undefined, - }, - { type: "APPEND_RUN", blockIndex: 0, text: "Hello" }, - ]); - - const withFamily = appReducer(state, { - type: "SET_RUN_FONT_FAMILY", - blockIndex: 0, - runIndex: 0, - fontFamily: "Georgia", - }); - const withSize = appReducer(withFamily, { - type: "SET_RUN_FONT_SIZE", - blockIndex: 0, - runIndex: 0, - sizePt: 18, - }); - expect(withSize.hasUnsavedChanges).toBe(true); - - const doc = state.openDocument; - if (doc?.format !== format) { - throw new Error(`expected an open ${format} document`); - } - const content = - format === "docx" - ? readDocxContent(doc.editor.toPackage()) - : readOdtContent(doc.editor.toPackage()); - if (content.kind !== "wordprocessing") { - throw new Error( - `expected a wordprocessing ContentDocument, got ${content.kind}`, - ); - } - const paragraph = content.sections[0]?.blocks[0]; - if (paragraph?.kind !== "paragraph") { - throw new Error(`expected a paragraph block, got ${paragraph?.kind}`); - } - expect(paragraph.runs[0]?.fontFamily).toBe("Georgia"); - expect(paragraph.runs[0]?.sizePt).toBe(18); - }); - - it("reports a missing run rather than throwing", () => { - const state = appReducer(createInitialState(), { - type: "CREATE_DOCUMENT", - format, - }); - const missed = appReducer(state, { - type: "SET_RUN_FONT_FAMILY", - blockIndex: 7, - runIndex: 0, - fontFamily: "Georgia", - }); - expect(missed.status?.severity).toBe("warning"); - expect(missed.hasUnsavedChanges).toBe(false); - }); - }, -); -describe("appReducer APPEND_TABLE and MERGE_TABLE_CELLS on docx/odt", () => { - it("appends a real docx table with cells pre-merged in one pass, verified through readDocxContent", () => { - const created = appReducer(createInitialState(), { + // withRun's own wrongDocument path -- distinct from withStyledRun's (exercised elsewhere against markdown), since SET_RUN_TEXT/TOGGLE_RUN_BOLD/TOGGLE_RUN_ITALIC resolve through the wider wordprocessingDocument union, not styledWordprocessingDocument. + it("warns rather than mutating when SET_RUN_TEXT targets a non-wordprocessing document", () => { + const state = appReducer(createInitialState(), { type: "CREATE_DOCUMENT", - format: "docx", + format: "ods", }); - const withTable = appReducer(created, { - type: "APPEND_TABLE", - rows: 3, - columns: 3, - merge: { startRow: 0, startColumn: 0, rowSpan: 2, colSpan: 2 }, + const result = appReducer(state, { + type: "SET_RUN_TEXT", + blockIndex: 0, + runIndex: 0, + text: "x", }); - expect(withTable.hasUnsavedChanges).toBe(true); - - const content = readDocxContent(docxDocument(withTable).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 anchor = tableBlock.rows[0]?.cells[0]; - expect(anchor?.colSpan).toBe(2); - expect(anchor?.rowSpan).toBe(2); - // docx collapses a horizontal merge into one real w:tc -- row 0 now has 2 real cells (the merged one plus the untouched third column), not 3. - expect(tableBlock.rows[0]?.cells).toHaveLength(2); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); }); - it("appends a real odt table with cells pre-merged in one pass, verified through readOdtContent", () => { - const created = appReducer(createInitialState(), { - type: "CREATE_DOCUMENT", - format: "odt", - }); - const withTable = appReducer(created, { + // withRun's own "no run at index" path: the paragraph exists (created via APPEND_PARAGRAPH) but has no runs at all, unlike the "no paragraph" case already covered above. + it("reports a missing run, not a missing paragraph, when the paragraph exists but has no runs", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: undefined, + styleId: undefined, + alignment: undefined, + }, + ]); + const result = appReducer(state, { + type: "TOGGLE_RUN_ITALIC", + blockIndex: 0, + runIndex: 0, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("Paragraph 0 has no run at index 0"); + // The warning path returns the state unchanged rather than a new mutated copy. + expect(result.openDocument).toBe(state.openDocument); + }); + + // withStyledRun's own "no run at index" path (TOGGLE_RUN_UNDERLINE/SET_RUN_COLOR/etc resolve through styledWordprocessingDocument, not wordprocessingDocument, so this is a genuinely separate code path from the withRun test above). + it("reports a missing run for TOGGLE_RUN_UNDERLINE when the paragraph has no runs", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: undefined, + styleId: undefined, + alignment: undefined, + }, + ]); + const result = appReducer(state, { + type: "TOGGLE_RUN_UNDERLINE", + blockIndex: 0, + runIndex: 0, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("Paragraph 0 has no run at index 0"); + expect(result.openDocument).toBe(state.openDocument); + }); + + // TOGGLE_RUN_UNDERLINE and SET_RUN_COLOR have no real, positive test anywhere else -- the markdown describe below only exercises their wrongDocument branch. + it("toggles a real run's underline and sets its colour through the live editor", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: undefined, + styleId: undefined, + alignment: undefined, + }, + { type: "APPEND_RUN", blockIndex: 0, text: "Hello" }, + ]); + const run = docxDocument(state).editor.paragraphs()[0]?.runs()[0]; + if (run === undefined) { + throw new Error("expected an appended run"); + } + expect(run.underline).toBe(false); + + const underlined = appReducer(state, { + type: "TOGGLE_RUN_UNDERLINE", + blockIndex: 0, + runIndex: 0, + }); + expect(underlined.hasUnsavedChanges).toBe(true); + expect(run.underline).toBe(true); + + const coloured = appReducer(underlined, { + type: "SET_RUN_COLOR", + blockIndex: 0, + runIndex: 0, + color: { r: 1, g: 0, b: 0 }, + }); + expect(coloured.hasUnsavedChanges).toBe(true); + expect(run.color).toStrictEqual({ r: 1, g: 0, b: 0 }); + }); + + // SET_PARAGRAPH_ALIGNMENT has no positive test anywhere else -- the markdown describe block only ever exercises its wrongDocument branch (MarkdownParagraph has no alignment field at all). + it("sets a real paragraph's alignment through the live editor", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: "Hello", + styleId: undefined, + alignment: undefined, + }, + ]); + const paragraph = docxDocument(state).editor.paragraphs()[0]; + if (paragraph === undefined) { + throw new Error("expected an appended paragraph"); + } + expect(paragraph.alignment).toBeUndefined(); + + const aligned = appReducer(state, { + type: "SET_PARAGRAPH_ALIGNMENT", + blockIndex: 0, + alignment: "center", + }); + expect(aligned.hasUnsavedChanges).toBe(true); + expect(paragraph.alignment).toBe("center"); + }); + + it("reports a missing paragraph for SET_PARAGRAPH_ALIGNMENT rather than throwing", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(state, { + type: "SET_PARAGRAPH_ALIGNMENT", + blockIndex: 7, + alignment: "center", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no paragraph at index 7"); + expect(result.openDocument).toBe(state.openDocument); + }); + + // APPEND_RUN's own wrongDocument/no-paragraph paths -- every other use of APPEND_RUN in this file is setup for a further action, never a direct assertion on its own warning paths. + it("warns rather than mutating when APPEND_RUN targets a non-wordprocessing document", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "ods", + }); + const result = appReducer(state, { + type: "APPEND_RUN", + blockIndex: 0, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); + }); + + it("reports a missing paragraph for APPEND_RUN rather than throwing", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(state, { + type: "APPEND_RUN", + blockIndex: 7, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no paragraph at index 7"); + expect(result.openDocument).toBe(state.openDocument); + }); +}); + +describe.each(["docx", "odt"] as const)( + "appReducer SET_RUN_FONT_FAMILY / SET_RUN_FONT_SIZE on %s", + (format) => { + it("sets a real font family and size through the live DocxRun/OdtRun setters, verified by re-decoding the package", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format }, + { + type: "APPEND_PARAGRAPH", + text: undefined, + styleId: undefined, + alignment: undefined, + }, + { type: "APPEND_RUN", blockIndex: 0, text: "Hello" }, + ]); + + const withFamily = appReducer(state, { + type: "SET_RUN_FONT_FAMILY", + blockIndex: 0, + runIndex: 0, + fontFamily: "Georgia", + }); + const withSize = appReducer(withFamily, { + type: "SET_RUN_FONT_SIZE", + blockIndex: 0, + runIndex: 0, + sizePt: 18, + }); + expect(withSize.hasUnsavedChanges).toBe(true); + + const doc = state.openDocument; + if (doc?.format !== format) { + throw new Error(`expected an open ${format} document`); + } + const content = + format === "docx" + ? readDocxContent(doc.editor.toPackage()) + : readOdtContent(doc.editor.toPackage()); + if (content.kind !== "wordprocessing") { + throw new Error( + `expected a wordprocessing ContentDocument, got ${content.kind}`, + ); + } + const paragraph = content.sections[0]?.blocks[0]; + if (paragraph?.kind !== "paragraph") { + throw new Error(`expected a paragraph block, got ${paragraph?.kind}`); + } + expect(paragraph.runs[0]?.fontFamily).toBe("Georgia"); + expect(paragraph.runs[0]?.sizePt).toBe(18); + }); + + it("reports a missing run rather than throwing", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format, + }); + const missed = appReducer(state, { + type: "SET_RUN_FONT_FAMILY", + blockIndex: 7, + runIndex: 0, + 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); + }); + }, +); + +describe("appReducer APPEND_TABLE and MERGE_TABLE_CELLS on docx/odt", () => { + it("appends a real docx table with cells pre-merged in one pass, verified through readDocxContent", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const withTable = appReducer(created, { + type: "APPEND_TABLE", + rows: 3, + columns: 3, + merge: { startRow: 0, startColumn: 0, rowSpan: 2, colSpan: 2 }, + }); + expect(withTable.hasUnsavedChanges).toBe(true); + // A docx table genuinely supports merging, unlike markdown's own -- this must not carry the "unsupported" warning markdown's APPEND_TABLE+merge gets below. + expect(withTable.status?.severity).not.toBe("warning"); + + const content = readDocxContent(docxDocument(withTable).editor.toPackage()); + if (content.kind !== "wordprocessing") { + 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 anchor = tableBlock.rows[0]?.cells[0]; + expect(anchor?.colSpan).toBe(2); + expect(anchor?.rowSpan).toBe(2); + // docx collapses a horizontal merge into one real w:tc -- row 0 now has 2 real cells (the merged one plus the untouched third column), not 3. + expect(tableBlock.rows[0]?.cells).toHaveLength(2); + }); + + it("appends a real odt table with cells pre-merged in one pass, verified through readOdtContent", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "odt", + }); + const withTable = appReducer(created, { type: "APPEND_TABLE", rows: 3, columns: 3, @@ -708,34 +1225,130 @@ describe("appReducer APPEND_TABLE and MERGE_TABLE_CELLS on docx/odt", () => { colSpan: 1, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no table at index 3"); expect(result.hasUnsavedChanges).toBe(false); }); }); -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(); - const list = editor.body.appendList(); - list.addItem().appendParagraph({ text: "first" }); - list.addItem().appendParagraph({ text: "second" }); - const opened = openOdtDocument(editor.toBytes()); - const blockIndex = odtDocument(opened).editor.lists().length - 1; - - const edited = appReducer(opened, { - type: "SET_LIST_ITEM_TEXT", - blockIndex, - itemIndex: 1, - text: "SECOND, EDITED", +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 items = odtDocument(edited).editor.lists()[blockIndex]?.items(); - expect(items?.[0]?.text).toBe("first"); - expect(items?.[1]?.text).toBe("SECOND, EDITED"); + 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"); + }); - // Re-decoding the saved bytes as a completely fresh package proves the edit was written into the real text:list-item tree, not just held on the live in-memory object. - const reopened = openOdt(odtDocument(edited).editor.toBytes()); - const reopenedItems = reopened.lists()[blockIndex]?.items(); - expect(reopenedItems?.[0]?.text).toBe("first"); + 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", + ); + }); + + // setTextContainerText's "already has a first run" branch: a freshly-appended table cell has zero runs, so the tests above only ever exercise the "no first run yet" branch (paragraph.appendRun). Giving the cell two runs up front proves the write path replaces the first run's text AND removes every extra run, rather than just setting the first and leaving the rest stale. + it("replaces the first run's text and removes every extra run when a cell already has more than one", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { type: "APPEND_TABLE", rows: 1, columns: 1 }, + ]); + const table = docxDocument(state).editor.tables()[0]; + const cell = table?.rows()[0]?.cells()[0]; + if (cell === undefined) { + throw new Error("expected the appended cell"); + } + const paragraph = cell.paragraphs()[0] ?? cell.appendParagraph(); + paragraph.appendRun({ text: "one" }); + paragraph.appendRun({ text: "two" }); + paragraph.appendRun({ text: "three" }); + expect(paragraph.runs()).toHaveLength(3); + + const edited = appReducer(state, { + type: "SET_TABLE_CELL_TEXT", + tableIndex: 0, + row: 0, + column: 0, + text: "Replaced", + }); + expect(edited.hasUnsavedChanges).toBe(true); + expect(paragraph.runs()).toHaveLength(1); + expect(paragraph.runs()[0]?.text).toBe("Replaced"); + }); +}); + +describe("appReducer SET_LIST_ITEM_TEXT on odt", () => { + it("replaces a real list item's text and the change round-trips through re-decoding the package", () => { + const editor = createOdt(); + const list = editor.body.appendList(); + list.addItem().appendParagraph({ text: "first" }); + list.addItem().appendParagraph({ text: "second" }); + const opened = openOdtDocument(editor.toBytes()); + const blockIndex = odtDocument(opened).editor.lists().length - 1; + + const edited = appReducer(opened, { + type: "SET_LIST_ITEM_TEXT", + blockIndex, + itemIndex: 1, + text: "SECOND, EDITED", + }); + expect(edited.hasUnsavedChanges).toBe(true); + const items = odtDocument(edited).editor.lists()[blockIndex]?.items(); + expect(items?.[0]?.text).toBe("first"); + expect(items?.[1]?.text).toBe("SECOND, EDITED"); + + // Re-decoding the saved bytes as a completely fresh package proves the edit was written into the real text:list-item tree, not just held on the live in-memory object. + const reopened = openOdt(odtDocument(edited).editor.toBytes()); + const reopenedItems = reopened.lists()[blockIndex]?.items(); + expect(reopenedItems?.[0]?.text).toBe("first"); expect(reopenedItems?.[1]?.text).toBe("SECOND, EDITED"); }); @@ -751,6 +1364,7 @@ describe("appReducer SET_LIST_ITEM_TEXT on odt", () => { text: "x", }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no list at index 5"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -768,6 +1382,9 @@ describe("appReducer SET_LIST_ITEM_TEXT on odt", () => { text: "x", }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + `List ${blockIndex} has no item at index 3`, + ); expect(result.hasUnsavedChanges).toBe(false); }); @@ -783,8 +1400,140 @@ describe("appReducer SET_LIST_ITEM_TEXT on odt", () => { text: "x", }); expect(warned.status?.severity).toBe("warning"); + expect(warned.status?.text).toBe( + "That action needs an odt document (lists are an odt-only concept); the open document is docx", + ); expect(warned.hasUnsavedChanges).toBe(false); }); + + // The docx test above is wordprocessing but not odt, so it only ever reaches the SECOND, odt-only wrongDocument check. This one is not wordprocessing at all, reaching the FIRST, wider check instead. + it("warns instead of mutating when the open document is not a wordprocessing document at all", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "ods", + }); + const warned = appReducer(state, { + type: "SET_LIST_ITEM_TEXT", + blockIndex: 0, + itemIndex: 0, + text: "x", + }); + expect(warned.status?.severity).toBe("warning"); + expect(warned.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); + }); +}); + +describe("appReducer ADD_LIST_ITEM on docx", () => { + // 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); + }); + + it("warns instead of mutating when the open document is not a wordprocessing document at all", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "ods", + }); + const result = appReducer(state, { + type: "ADD_LIST_ITEM", + blockIndex: 0, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); + }); + + // odt's own ADD_LIST_ITEM branch is genuinely separate code from the docx/markdown branch tested above (a real OdtList.addItem(), not a flat paragraph-copy) -- despite the docx describe block's own comment claiming the sibling ADD_LIST/INDENT_LIST_ITEM tests already cover it, neither of those ever dispatches ADD_LIST_ITEM itself. + it("appends a real item to an existing odt list", () => { + const editor = createOdt(); + editor.body.appendList().addItem().appendParagraph({ text: "first" }); + const opened = openOdtDocument(editor.toBytes()); + const blockIndex = odtDocument(opened).editor.lists().length - 1; + + const added = appReducer(opened, { + type: "ADD_LIST_ITEM", + blockIndex, + text: "second", + }); + expect(added.hasUnsavedChanges).toBe(true); + const items = odtDocument(added).editor.lists()[blockIndex]?.items(); + expect(items?.map((item) => item.text)).toEqual(["first", "second"]); + }); + + it("warns rather than crashing when ADD_LIST_ITEM targets an odt list index that does not exist", () => { + const editor = createOdt(); + editor.body.appendList().addItem().appendParagraph({ text: "only" }); + const opened = openOdtDocument(editor.toBytes()); + + const result = appReducer(opened, { + type: "ADD_LIST_ITEM", + blockIndex: 5, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no list at index 5"); + expect(result.hasUnsavedChanges).toBe(false); + }); }); describe("appReducer ADD_LIST on odt", () => { @@ -819,6 +1568,18 @@ describe("appReducer ADD_LIST on odt", () => { expect(result.status?.text).toContain("odt"); expect(result.hasUnsavedChanges).toBe(false); }); + + it("warns instead of mutating when the open document is not a wordprocessing document at all", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "ods", + }); + const result = appReducer(state, { type: "ADD_LIST" }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); + }); }); describe("appReducer INDENT_LIST_ITEM on odt", () => { @@ -885,6 +1646,7 @@ describe("appReducer INDENT_LIST_ITEM on odt", () => { itemIndex: 0, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no list at index 5"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -899,8 +1661,27 @@ describe("appReducer INDENT_LIST_ITEM on odt", () => { itemIndex: 0, }); expect(warned.status?.severity).toBe("warning"); + expect(warned.status?.text).toBe( + "That action needs an odt document (lists are an odt-only concept); the open document is docx", + ); expect(warned.hasUnsavedChanges).toBe(false); }); + + it("warns instead of mutating when the open document is not a wordprocessing document at all", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "ods", + }); + const result = appReducer(state, { + type: "INDENT_LIST_ITEM", + blockIndex: 0, + itemIndex: 0, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); + }); }); describe("appReducer ods mutations", () => { @@ -926,6 +1707,25 @@ describe("appReducer ods mutations", () => { } expect(sheet.cell(2, 3).value).toEqual({ kind: "string", value: "Total" }); }); + + // withSheet's own wrongDocument path -- every other withSheet test (SET_CELL_VALUE, SET_SHEET_PRINT_SETTINGS) only exercises the "no sheet at that index" branch against an already-open spreadsheet, never the "not a spreadsheet at all" branch. + it("warns rather than crashing when SET_CELL_VALUE targets a non-spreadsheet document", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "SET_CELL_VALUE", + sheetIndex: 0, + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs an ods or xls document; the open document is docx", + ); + }); }); describe("appReducer SET_CELL_FORMULA on ods", () => { @@ -1002,6 +1802,7 @@ describe("appReducer SET_CELL_FORMULA on ods", () => { formula: "of:=1", }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no sheet at index 4"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -1207,6 +2008,7 @@ describe("appReducer MERGE_CELLS on ods", () => { colSpan: 1, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no sheet at index 4"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -1291,6 +2093,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, { @@ -1323,6 +2128,9 @@ describe("appReducer markdown mutations", () => { alignment: "center", }); expect(alignmentResult.status?.severity).toBe("warning"); + expect(alignmentResult.status?.text).toBe( + "That action needs a docx or odt document; the open document is markdown", + ); const imageResult = appReducer(opened, { type: "INSERT_PARAGRAPH_IMAGE", @@ -1334,6 +2142,9 @@ describe("appReducer markdown mutations", () => { altText: undefined, }); expect(imageResult.status?.severity).toBe("warning"); + expect(imageResult.status?.text).toBe( + "That action needs a docx or odt document; the open document is markdown", + ); }); // GFM tables have no cell-merge concept at all -- MarkdownTable has no mergeCells -- so a merge requested alongside table creation still creates the (unmerged) table and reports why the merge didn't happen, rather than silently dropping the merge or refusing to create the table. @@ -1362,10 +2173,54 @@ describe("appReducer markdown mutations", () => { alignment: undefined, }); expect(warned.status?.severity).toBe("warning"); + expect(warned.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); expect(warned.hasUnsavedChanges).toBe(false); }); }); +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", () => { @@ -1384,6 +2239,7 @@ describe("appReducer undo", () => { expect(undone.undoStack).toHaveLength(0); expect(markdownDocument(undone).editor.paragraphs()[1]?.text).toBe("Two"); expect(undone.hasUnsavedChanges).toBe(true); + expect(undone.status?.text).toBe("Undone"); }); it("restores the snapshot taken before the last mutation", () => { @@ -1420,8 +2276,107 @@ describe("appReducer undo", () => { }); const undone = appReducer(created, { type: "UNDO" }); expect(undone.status?.severity).toBe("info"); + expect(undone.status?.text).toBe("There is nothing to undo"); expect(undone.openDocument).toBe(created.openDocument); }); + + // A genuinely separate code path from the test above: that one has an open document with an empty undo stack (the `snapshot === undefined` branch); this one has no open document at all (the earlier `doc === undefined` branch), which produces the identical message through different code. + it("says there is nothing to undo when no document is open at all", () => { + const undone = appReducer(createInitialState(), { type: "UNDO" }); + expect(undone.status?.severity).toBe("info"); + expect(undone.status?.text).toBe("There is nothing to undo"); + }); + + it("caps the undo stack at 20 snapshots, dropping the oldest ones first", () => { + let state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + 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", () => { @@ -1499,6 +2454,7 @@ describe("appReducer ADD_SLIDE_TABLE", () => { columns: 2, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no slide at index 5"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -1639,6 +2595,7 @@ describe("appReducer MERGE_SLIDE_TABLE_CELLS", () => { colSpan: 1, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no table at index 0 on slide 0"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -1659,16 +2616,223 @@ describe("appReducer MERGE_SLIDE_TABLE_CELLS", () => { expect(result.status?.severity).toBe("warning"); expect(result.status?.text).toContain("pptx or odp"); }); -}); -describe("appReducer SET_SLIDE_NOTES on pptx", () => { - it("sets real speaker notes on a pptx slide, not just an odp one", () => { + // 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 withNotes = appReducer(opened, { - type: "SET_SLIDE_NOTES", + 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", () => { + it("sets real speaker notes on a pptx slide, not just an odp one", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + + const withNotes = appReducer(opened, { + type: "SET_SLIDE_NOTES", slideIndex: 0, notes: "Remember to mention Q3 growth", }); @@ -1706,7 +2870,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 +2883,71 @@ 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"); + }); + + // withShape's own wrongDocument path (used only by SET_SHAPE_ROTATION) -- distinct from withWideShape's own copy below, which SET_SHAPE_TEXT/SET_SHAPE_FRAME resolve through instead. + it("warns rather than crashing when SET_SHAPE_ROTATION targets a document with no shape host at all", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "SET_SHAPE_ROTATION", + containerIndex: 0, + shapeIndex: 0, + rotationDeg: 10, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("a pptx, odp or odg document"); + }); + + // withWideShape's own wrongDocument path -- SET_SHAPE_TEXT/SET_SHAPE_FRAME resolve through it, not withShape, so this is a genuinely separate code path from the SET_SHAPE_ROTATION test above. + it("warns rather than crashing when SET_SHAPE_TEXT targets a document with no shape host at all", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "SET_SHAPE_TEXT", + containerIndex: 0, + shapeIndex: 0, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("a pptx, odp, ppt or odg document"); }); }); @@ -1749,6 +2978,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 +3105,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()); @@ -1865,6 +3180,35 @@ describe("appReducer PDF item and page mutations", () => { expect(items[0]?.kind).toBe("text"); }); + // REMOVE_PDF_ITEM has its own inline wrongDocument/no-item checks, not shared with withPdfPage or withPdfItemMatching above. + it("warns rather than crashing when REMOVE_PDF_ITEM targets a non-PDF document", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "REMOVE_PDF_ITEM", + pageIndex: 0, + itemIndex: 0, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a pdf document; the open document is docx", + ); + }); + + it("warns rather than crashing when REMOVE_PDF_ITEM targets an item index that does not exist", () => { + const opened = openPdfDocument(pdfTestBytes()); + const result = appReducer(opened, { + type: "REMOVE_PDF_ITEM", + pageIndex: 0, + itemIndex: 9, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("Page 0 has no item at index 9"); + expect(result.hasUnsavedChanges).toBe(false); + }); + it("undoes a PDF text edit, restoring the snapshot taken before the mutation", () => { const opened = openPdfDocument(pdfTestBytes()); const edited = appReducer(opened, { @@ -1873,46 +3217,1160 @@ describe("appReducer PDF item and page mutations", () => { itemIndex: 0, text: "Changed", }); - expect(edited.undoStack).toHaveLength(1); - const liveEdited = pdfDocument(edited).editor.page(0)?.items()[0]; - expect(liveEdited?.kind === "text" ? liveEdited.text : undefined).toBe( - "Changed", + expect(edited.undoStack).toHaveLength(1); + const liveEdited = pdfDocument(edited).editor.page(0)?.items()[0]; + expect(liveEdited?.kind === "text" ? liveEdited.text : undefined).toBe( + "Changed", + ); + + const undone = appReducer(edited, { type: "UNDO" }); + expect(undone.undoStack).toHaveLength(0); + expect(undone.hasUnsavedChanges).toBe(true); + const restoredItem = pdfDocument(undone).editor.page(0)?.items()[0]; + expect(restoredItem?.kind === "text" ? restoredItem.text : undefined).toBe( + "Hello", + ); + // Undo replaces the editor wholesale by re-opening the snapshot bytes, matching every other editable format's own UNDO behaviour. + 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, { + type: "ADD_PDF_RECT", + pageIndex: 5, + 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); + }); + + it("warns rather than crashing when a field-edit action targets an item of the wrong kind", () => { + const opened = openPdfDocument(pdfTestBytes()); + // Item 0 is the fixture's own text item, not a rect. + const result = appReducer(opened, { + type: "SET_PDF_RECT_FILL", + pageIndex: 0, + itemIndex: 0, + fill: { r: 1, g: 0, b: 0 }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("not rect"); + expect(result.hasUnsavedChanges).toBe(false); + }); + + // withPdfPage's own wrongDocument path -- every ADD_PDF_* test above only exercises the "no page at that index" branch against an already-open PDF, never the "not a PDF at all" branch. + it("warns rather than crashing when ADD_PDF_RECT targets a non-PDF document", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "ADD_PDF_RECT", + pageIndex: 0, + init: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a pdf document; the open document is docx", + ); + }); + + // withPdfItemMatching's own wrongDocument path -- a genuinely separate function from withPdfPage above, so covering one says nothing about the other. + it("warns rather than crashing when SET_PDF_RECT_FILL targets a non-PDF document", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "SET_PDF_RECT_FILL", + pageIndex: 0, + itemIndex: 0, + fill: { r: 1, g: 0, b: 0 }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a pdf document; the open document is docx", + ); + }); + + // withPdfItemMatching's own "no item at that index" path -- the wrong-kind test above needs a real item at itemIndex 0 to check its kind against, so it can never reach this branch; this needs a valid page with an item count too low for the requested index instead. + it("warns rather than crashing when SET_PDF_RECT_FILL targets an item index that does not exist", () => { + const opened = openPdfDocument(pdfTestBytes()); + const result = appReducer(opened, { + type: "SET_PDF_RECT_FILL", + pageIndex: 0, + itemIndex: 9, + fill: { r: 1, g: 0, b: 0 }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("Page 0 has no item at index 9"); + expect(result.hasUnsavedChanges).toBe(false); + }); + + it("adds an ellipse via ADD_PDF_ELLIPSE, present after a toBytes()/openPdf() round trip", () => { + const opened = openPdfDocument(pdfTestBytes()); + const withEllipse = appReducer(opened, { + 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"); + }); + + // A real internalLink item, unlike every other PDF item kind, cannot be created through PdfPage's own append* API (see the comment above) -- so this builds one the only other way a genuine internalLink ever arises: writing a LayoutDocument with a real internal-link annotation through pdf-codec's own writePdf, then re-reading it, proving the isPdfInternalLinkItem guard's TRUE branch (not just its wrong-kind rejection) actually matches a real internalLink item. + it("edits a real internal link's destination and frame through the live editor", () => { + const layoutDoc: LayoutDocument = { + formatVersion: LAYOUT_FORMAT_VERSION, + metadata: {}, + images: {}, + destinations: [ + { name: "target", pageIndex: 0, target: { kind: "fit" } }, + { name: "other", pageIndex: 0, target: { kind: "fit" } }, + ], + pages: [ + { + widthPt: 200, + heightPt: 200, + items: [ + { + kind: "internalLink", + destination: "target", + xPt: 10, + yPt: 10, + widthPt: 50, + heightPt: 20, + }, + // A second internalLink referencing the second destination: writePdf only carries a destination through into the saved document's own /Names tree when something actually references it, so an unreferenced destination is silently dropped on the way back in -- this one needs a real referrer to survive the round trip. + { + kind: "internalLink", + destination: "other", + xPt: 70, + yPt: 10, + widthPt: 50, + heightPt: 20, + }, + ], + }, + ], + }; + const opened = openPdfDocument(writePdf(layoutDoc)); + + const item = pdfDocument(opened).editor.page(0)?.items()[0]; + if (item?.kind !== "internalLink") { + throw new Error("expected a real internalLink item"); + } + const otherItem = pdfDocument(opened).editor.page(0)?.items()[1]; + if (otherItem?.kind !== "internalLink") { + throw new Error("expected a second real internalLink item"); + } + // The reader mints its own destination names on the way back in rather than necessarily preserving the writer's own names verbatim, so the destination this edit switches to is read from the second item's own round-tripped destination rather than assumed. + const otherDestination = otherItem.destination; + + const withDestination = appReducer(opened, { + type: "SET_PDF_INTERNAL_LINK_DESTINATION", + pageIndex: 0, + itemIndex: 0, + destination: otherDestination, + }); + expect(withDestination.status?.severity).not.toBe("warning"); + expect(withDestination.hasUnsavedChanges).toBe(true); + expect(item.destination).toBe(otherDestination); + + const withFrame = appReducer(withDestination, { + type: "SET_PDF_INTERNAL_LINK_FRAME", + pageIndex: 0, + itemIndex: 0, + xPt: 1, + yPt: 2, + widthPt: 3, + heightPt: 4, + }); + expect(withFrame.status?.severity).not.toBe("warning"); + expect(item.xPt).toBe(1); + expect(item.yPt).toBe(2); + expect(item.widthPt).toBe(3); + expect(item.heightPt).toBe(4); + }); + + // Every other SET_PDF_*_* field-edit action routes through the identical withPdfItemMatching guard, but each call site carries its OWN copy of the kindLabel string literal -- exercising the wrong-kind path through the FRAME/FILL/one representative action per kind above does not cover the same literal at a sibling action's own call site (e.g. SET_PDF_RECT_FRAME's "rect" and SET_PDF_RECT_STROKE's "rect" are two distinct AST nodes). This table drives every remaining action through the wrong-kind branch once each. + const wrongKindCases: [string, Action, string][] = [ + [ + "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 undone = appReducer(edited, { type: "UNDO" }); - expect(undone.undoStack).toHaveLength(0); - expect(undone.hasUnsavedChanges).toBe(true); - const restoredItem = pdfDocument(undone).editor.page(0)?.items()[0]; - expect(restoredItem?.kind === "text" ? restoredItem.text : undefined).toBe( - "Hello", + const content = readDocxContent( + docxDocument(withFormula).editor.toPackage(), ); - // Undo replaces the editor wholesale by re-opening the snapshot bytes, matching every other editable format's own UNDO behaviour. - expect(pdfDocument(undone).editor).not.toBe(pdfDocument(edited).editor); + 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 page index that does not exist", () => { - const opened = openPdfDocument(pdfTestBytes()); - const result = appReducer(opened, { - type: "ADD_PDF_RECT", - pageIndex: 5, - init: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + 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.hasUnsavedChanges).toBe(false); + expect(result.status?.text).toBe("There is no paragraph at index 7"); }); - it("warns rather than crashing when a field-edit action targets an item of the wrong kind", () => { - const opened = openPdfDocument(pdfTestBytes()); - // Item 0 is the fixture's own text item, not a rect. - const result = appReducer(opened, { - type: "SET_PDF_RECT_FILL", - pageIndex: 0, - itemIndex: 0, - fill: { r: 1, g: 0, b: 0 }, + 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("not rect"); - expect(result.hasUnsavedChanges).toBe(false); + expect(result.status?.text).toContain("a docx document"); }); }); @@ -2001,6 +4459,7 @@ describe("appReducer ADD_RECT / ADD_ELLIPSE / ADD_LINE / ADD_PATH on odp", () => init: { frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 } }, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no slide at index 5"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -2030,7 +4489,53 @@ describe("appReducer ADD_RECT / ADD_ELLIPSE / ADD_LINE / ADD_PATH on odp", () => init: { frame: { xPt: 10, yPt: 10, widthPt: 40, heightPt: 30 } }, }); expect(withRect.hasUnsavedChanges).toBe(true); - expect(odgDocument(withRect).editor.pages()[0]?.vectors()).toHaveLength(1); + const vectors = odgDocument(withRect).editor.pages()[0]?.vectors(); + expect(vectors).toHaveLength(1); + // A rect and an ellipse share the identical OdgBoxVectorInit shape (frame/fill/stroke), so a mutation that lets ADD_RECT's own case fall through into ADD_ELLIPSE's addEllipse call would still add exactly one vector -- just the wrong kind. The length check above alone cannot catch that. + expect(vectors?.[0]?.kind).toBe("rect"); + }); + + // The odg branch dispatches through its own inner switch (addRect/addEllipse/addLine/addPath), one case per real OdgPage method -- distinct from the ADD_RECT/ADD_ELLIPSE/ADD_LINE/ADD_PATH coverage above, which only ever reaches odg via ADD_RECT. Each case is its own switch-statement mutant, so proving the rect case works says nothing about whether removing the ellipse/line/path cases would still pass. + 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", + ]); }); }); @@ -2099,7 +4604,22 @@ describe("appReducer SET_VECTOR_FILL / SET_VECTOR_STROKE on odg", () => { fill: { r: 1, g: 1, b: 1 }, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs an odg document; the open document is docx", + ); expect(result.hasUnsavedChanges).toBe(false); + + // SET_VECTOR_STROKE has its own, separate copy of the identical wrongDocument call -- covering SET_VECTOR_FILL's above says nothing about this one. + const strokeResult = appReducer(state, { + type: "SET_VECTOR_STROKE", + vector: rect, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + }); + expect(strokeResult.status?.severity).toBe("warning"); + expect(strokeResult.status?.text).toBe( + "That action needs an odg document; the open document is docx", + ); + expect(strokeResult.hasUnsavedChanges).toBe(false); }); }); @@ -2137,3 +4657,265 @@ 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, + }); + }); + + // withWideShape's own "page"/"slide" ternary: the odg describe block elsewhere only ever exercises the "page" branch via SET_SHAPE_TEXT, so this proves the "slide" branch specifically, on a pptx document. + it("warns with 'slide' rather than 'page' when SET_SHAPE_FRAME targets a missing shape on a pptx slide", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + + const result = appReducer(opened, { + type: "SET_SHAPE_FRAME", + containerIndex: 0, + shapeIndex: 5, + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no shape 5 on slide 0"); + }); +}); + +describe("appReducer SET_SHEET_PRINT_SETTINGS", () => { + 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 f4660fc449..124dbb6329 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 { @@ -1014,20 +1012,13 @@ export function appReducer(state: AppState, action: Action): AppState { selection: { ...state.selection, [action.key]: action.index }, }; - // `alignment` is set through the shared body.appendParagraph call for docx/odt, but MarkdownParagraphInit has no alignment field at all (CommonMark/GFM has no per-paragraph alignment construct) -- so a markdown document drops it here rather than the wordprocessing union call silently disagreeing about which ParagraphInit shape it is. + // MarkdownParagraphInit has no alignment field at all (CommonMark/GFM has no per-paragraph alignment construct), but MarkdownEditor.body.appendParagraph accepts the identical wordprocessing ParagraphInit shape as docx/odt and simply ignores the field it does not model -- so one call, with `alignment` always present, covers every wordprocessingDocument format with no format-specific branch. case "APPEND_PARAGRAPH": { const doc = wordprocessingDocument(state); if (doc === undefined) { return wrongDocument(state, "a docx, odt or markdown document"); } return mutate(state, doc, () => { - if (doc.format === "markdown") { - doc.editor.body.appendParagraph({ - text: action.text, - styleId: action.styleId, - }); - return; - } doc.editor.body.appendParagraph({ text: action.text, styleId: action.styleId, @@ -1360,12 +1351,12 @@ export function appReducer(state: AppState, action: Action): AppState { `There is no paragraph at index ${action.blockIndex}`, ); } - // Same holder reason as `merge` above: the write happens inside the mutate callback. - const omml = { written: true }; + // Unlike `merge` above, this assignment is unconditional -- mutate()'s own `apply` always runs synchronously before it returns, so `written` is always set by the time it is read below. A definite-assignment declaration (no initial value at all) says so directly, rather than giving it a placeholder literal that can never actually be observed. + let written!: boolean; const nextState = mutate(state, doc, () => { - omml.written = paragraph.appendOfficeMath(action.mathml).written; + written = paragraph.appendOfficeMath(action.mathml).written; }); - return omml.written + return written ? nextState : withStatus( nextState, @@ -2235,6 +2226,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 +2234,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/stryker.config.ts b/packages/document-cli/stryker.config.ts index 611b74d77e..7967e80231 100644 --- a/packages/document-cli/stryker.config.ts +++ b/packages/document-cli/stryker.config.ts @@ -8,6 +8,8 @@ export default packageStrykerConfig({ "!src/**/*.test.tsx", ], 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, + // Measured by a cold run (no incremental report present) of the whole mutate scope: 58.02% of 6246 valid mutants, timeout share 0.08% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. The package is not yet at the workspace's eventual 100% target; the remaining survived and no-coverage mutants are tracked separately, and this number rises as they are closed. + breakThreshold: 57, + concurrency: 1, + dryRunTimeoutMinutes: 20, });