From 1cb5a9f04caf71fb4007a53173cf5b3787f1a31e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 21:56:00 +0100 Subject: [PATCH 01/36] test(document-cli): cover the reducer's close/overlay/undo-cap/merge-validation branches SAVE_SUCCESS now has coverage for the no-open-document warning and for documentWithPath's read-only-preview cases (xlsx/csv/svg/rtf), proving the path is rewritten while layout/bytes stay the identical object. REQUEST_CLOSE/CONFIRM_CLOSE/CANCEL_CLOSE and OPEN_OVERLAY/CLOSE_OVERLAY had no tests at all: add coverage for the no-document/unsaved-changes branches, the confirmClose overlay round trip, and CANCEL_CLOSE leaving the document and its edits untouched. pushSnapshot's UNDO_STACK_LIMIT trim had no test proving the cap is 20 or that it retains the most recent snapshots rather than the oldest. mergePptxTableCells' own validation guards (non-integer/non-positive rowSpan or colSpan, a colSpan that overruns the table's column count) were untested beyond the one row-overrun case already covered. --- .../src/tui/state/reducer.test.ts | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index aa25aa621d..b1c06d3858 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -316,6 +316,133 @@ describe("appReducer document lifecycle", () => { }); }); +describe("appReducer SAVE_SUCCESS", () => { + it("says so when there is no open document to record the path against", () => { + const result = appReducer(createInitialState(), { + type: "SAVE_SUCCESS", + path: "/tmp/orphan.docx", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.hasUnsavedChanges).toBe(false); + }); + + // documentWithPath's own read-only-preview branches (xlsx/csv/svg/rtf) are otherwise never reached by any other action -- SAVE_AS on one of these formats is the only path that dispatches SAVE_SUCCESS against them, so this proves the layout/bytes pair survives the rewrite untouched alongside the new path. + it("updates a read-only preview document's own path while keeping its layout and bytes untouched", () => { + const bytes = xlsxTestBytes(); + const layout = readPdf(xlsxToPdf(bytes)); + const cases: readonly ["xlsx" | "csv" | "svg" | "rtf", string][] = [ + ["xlsx", "/tmp/renamed.xlsx"], + ["csv", "/tmp/renamed.csv"], + ["svg", "/tmp/renamed.svg"], + ["rtf", "/tmp/renamed.rtf"], + ]; + for (const [format, newPath] of cases) { + const opened = appReducer(createInitialState(), { + type: "OPEN_FILE_SUCCESS", + path: "/tmp/original", + doc: { format, layout, bytes, path: "/tmp/original" }, + }); + const saved = appReducer(opened, { + type: "SAVE_SUCCESS", + path: newPath, + }); + const doc = saved.openDocument; + if (doc?.format !== format) { + throw new Error(`expected a ${format} document, got ${doc?.format}`); + } + expect(doc.path).toBe(newPath); + expect(doc.layout).toBe(layout); + expect(doc.bytes).toBe(bytes); + expect(saved.hasUnsavedChanges).toBe(false); + } + }); +}); + +describe("appReducer OPEN_OVERLAY / CLOSE_OVERLAY", () => { + it("opens and closes the confirmClose overlay without touching any other overlay", () => { + const opened = appReducer(createInitialState(), { + type: "OPEN_OVERLAY", + overlay: "confirmClose", + }); + expect(opened.overlays.confirmClose).toBe(true); + expect(opened.overlays.confirmQuit).toBe(false); + + const closed = appReducer(opened, { + type: "CLOSE_OVERLAY", + overlay: "confirmClose", + }); + expect(closed.overlays.confirmClose).toBe(false); + }); +}); + +describe("appReducer REQUEST_CLOSE / CONFIRM_CLOSE / CANCEL_CLOSE", () => { + it("says so when there is no open document to close", () => { + const result = appReducer(createInitialState(), { type: "REQUEST_CLOSE" }); + expect(result.status?.severity).toBe("info"); + }); + + it("closes immediately, with no confirmation overlay, when there are no unsaved changes", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const requested = appReducer(created, { type: "REQUEST_CLOSE" }); + expect(requested.openDocument).toBeUndefined(); + expect(requested.overlays.confirmClose).toBe(false); + }); + + it("opens the confirmClose overlay instead of closing outright when there are unsaved changes", () => { + const edited = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: "x", + styleId: undefined, + alignment: undefined, + }, + ]); + const requested = appReducer(edited, { type: "REQUEST_CLOSE" }); + expect(requested.overlays.confirmClose).toBe(true); + expect(requested.openDocument).toBeDefined(); + }); + + it("CONFIRM_CLOSE closes the document and its own confirmation overlay together", () => { + const edited = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: "x", + styleId: undefined, + alignment: undefined, + }, + { type: "REQUEST_CLOSE" }, + ]); + expect(edited.overlays.confirmClose).toBe(true); + + const confirmed = appReducer(edited, { type: "CONFIRM_CLOSE" }); + expect(confirmed.openDocument).toBeUndefined(); + expect(confirmed.overlays.confirmClose).toBe(false); + }); + + it("CANCEL_CLOSE dismisses the overlay and keeps the document open with its edits intact", () => { + const edited = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: "x", + styleId: undefined, + alignment: undefined, + }, + { type: "REQUEST_CLOSE" }, + ]); + + const cancelled = appReducer(edited, { type: "CANCEL_CLOSE" }); + expect(cancelled.overlays.confirmClose).toBe(false); + expect(cancelled.openDocument).toBe(edited.openDocument); + expect(cancelled.hasUnsavedChanges).toBe(true); + }); +}); + describe("appReducer SET_METADATA", () => { it("patches a real docx document's metadata through the live editor.metadata setter", () => { const created = appReducer(createInitialState(), { @@ -1422,6 +1549,36 @@ describe("appReducer undo", () => { expect(undone.status?.severity).toBe("info"); expect(undone.openDocument).toBe(created.openDocument); }); + + it("caps the undo stack at 20 snapshots, dropping the oldest ones first", () => { + let state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + for (let i = 0; i < 25; i++) { + state = appReducer(state, { + type: "APPEND_PARAGRAPH", + text: `p${i}`, + styleId: undefined, + alignment: undefined, + }); + } + expect(state.undoStack).toHaveLength(20); + + // Undoing 20 times empties the capped stack exactly -- proving the retained entries are the 20 MOST RECENT snapshots (the tail), not an arbitrary 20, since undoing keeps peeling paragraphs off the end down to a stable, non-empty prefix rather than running out early or restoring past the true starting point. + for (let i = 0; i < 20; i++) { + state = appReducer(state, { type: "UNDO" }); + } + expect(state.undoStack).toHaveLength(0); + expect(docxDocument(state).editor.paragraphs()).toHaveLength( + docxDocument( + appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }), + ).editor.paragraphs().length + 5, + ); + }); }); describe("appReducer ADD_SLIDE_TABLE", () => { @@ -1659,6 +1816,67 @@ describe("appReducer MERGE_SLIDE_TABLE_CELLS", () => { expect(result.status?.severity).toBe("warning"); expect(result.status?.text).toContain("pptx or odp"); }); + + it("rejects a non-integer or non-positive rowSpan/colSpan instead of merging anything", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const withTable = appReducer(opened, { + type: "ADD_SLIDE_TABLE", + slideIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + rows: 3, + columns: 3, + }); + + const cases: readonly [number, number][] = [ + [0, 1], // rowSpan below 1 + [1, 0], // colSpan below 1 + [1.5, 1], // rowSpan not an integer + [1, 1.5], // colSpan not an integer + ]; + for (const [rowSpan, colSpan] of cases) { + const result = appReducer(withTable, { + type: "MERGE_SLIDE_TABLE_CELLS", + slideIndex: 0, + tableIndex: 0, + startRow: 0, + startColumn: 0, + rowSpan, + colSpan, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("positive integers"); + // Rejected outright, before any cell was ever touched -- the same open document object survives untouched, not a partially-applied merge. + expect(result.openDocument).toBe(withTable.openDocument); + } + }); + + it("rejects a colSpan that overruns the table's own column count", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const withTable = appReducer(opened, { + type: "ADD_SLIDE_TABLE", + slideIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + rows: 2, + columns: 2, + }); + + const result = appReducer(withTable, { + type: "MERGE_SLIDE_TABLE_CELLS", + slideIndex: 0, + tableIndex: 0, + startRow: 0, + startColumn: 0, + rowSpan: 1, + colSpan: 3, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("exceeds this table's own 2 columns"); + expect(result.openDocument).toBe(withTable.openDocument); + }); }); describe("appReducer SET_SLIDE_NOTES on pptx", () => { From 1f1685e1db0d267f6250925e767c32fab23bafa1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:02:13 +0100 Subject: [PATCH 02/36] refactor(document-cli): collapse pushSnapshot's undo-cap branch into one slice Array.prototype.slice(-UNDO_STACK_LIMIT) on an array no longer than the limit already returns every element unchanged, so the explicit length-check-then-slice conditional was redundant: both branches produce a new array with identical content at the one length where they could have differed, making the comparison operator itself unobservable to any test. --- packages/document-cli/src/tui/state/reducer.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/document-cli/src/tui/state/reducer.ts b/packages/document-cli/src/tui/state/reducer.ts index f4660fc449..650e2658d4 100644 --- a/packages/document-cli/src/tui/state/reducer.ts +++ b/packages/document-cli/src/tui/state/reducer.ts @@ -135,14 +135,12 @@ function setOverlay( } } +// A negative slice bound is exactly as safe as the "if too long, slice; else return as-is" branch it replaces -- Array.prototype.slice(-N) on an array no longer than N returns every element, so this single expression covers both the truncating and non-truncating cases with no conditional to keep in sync with UNDO_STACK_LIMIT. function pushSnapshot( stack: readonly Uint8Array[], snapshot: Uint8Array, ): readonly Uint8Array[] { - const next = [...stack, snapshot]; - return next.length > UNDO_STACK_LIMIT - ? next.slice(next.length - UNDO_STACK_LIMIT) - : next; + return [...stack, snapshot].slice(-UNDO_STACK_LIMIT); } function documentWithPath(doc: OpenDocument, path: string): OpenDocument { From d165ddda8bbe0dc8072fb82b5ea8f0393856cfef Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:03:05 +0100 Subject: [PATCH 03/36] fix(document-cli): allow doc/xls/ppt documents to undo through their own live-view editors UNDO's own format switch still treated doc/xls/ppt as the read-only, no-live-editor formats they used to be, so a document opened as one of these accumulated real undo snapshots via mutate() on every edit but could never pop one back off -- UNDO reported "read-only, so it has no history to undo" even immediately after a genuine, undoable edit. These three formats gained real live-view editors and their own reopenEditable case in the same change that widened EditableOpenDocument to include them; the UNDO exclusion list was never updated to match. --- packages/document-cli/src/tui/state/reducer.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/document-cli/src/tui/state/reducer.ts b/packages/document-cli/src/tui/state/reducer.ts index 650e2658d4..64f23c6bbe 100644 --- a/packages/document-cli/src/tui/state/reducer.ts +++ b/packages/document-cli/src/tui/state/reducer.ts @@ -2233,6 +2233,7 @@ export function appReducer(state: AppState, action: Action): AppState { if (doc === undefined) { return withStatus(state, "info", "There is nothing to undo"); } + // doc/xls/ppt are deliberately absent from this list: they gained real live-view editors (DocEditor/XlsEditor/PptEditor) and a reopenEditable case of their own in the same change that widened EditableOpenDocument to include them, so -- like every other EditableOpenDocument format -- they push real undo snapshots via mutate() and must be able to pop them back off here too. Only the genuinely read-only, no-live-editor formats belong in this list. if ( doc.format === "odb" || doc.format === "xlsx" || @@ -2240,9 +2241,6 @@ export function appReducer(state: AppState, action: Action): AppState { doc.format === "svg" || doc.format === "rtf" || doc.format === "wpd" || - doc.format === "doc" || - doc.format === "xls" || - doc.format === "ppt" || doc.format === "epub" ) { return withStatus( From 6f85984634d212dcb0f14ac738dc04b85491d784 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:03:18 +0100 Subject: [PATCH 04/36] test(document-cli): cover PDF item actions and every reopenEditable format branch Adds reducer-level coverage for the PDF ADD_PDF_ELLIPSE/LINE/PATH/IMAGE/ LINK actions, field edits on every non-text PDF item kind (rect, ellipse, line, path, image, link) including their own wrong-kind warning paths, the remaining SET_PDF_TEXT_* fields and the underline toggle's on/off round trip, and reopenEditable's pptx/odt/odp/ods/odg/doc/xls/ppt undo branches -- previously only docx and pdf were exercised there. --- .../src/tui/state/reducer.test.ts | 681 ++++++++++++++++++ 1 file changed, 681 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index b1c06d3858..757995ae5d 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -1,12 +1,16 @@ import { + createDoc, createOdg, createOdp, createOds, createOdt, createPdf, + createPpt, createPptx, + createXls, drawingOfBlock, odsToXlsx, + openDoc, openDocx, openMarkdown, openOdg, @@ -14,7 +18,9 @@ import { openOds, openOdt, openPdf, + openPpt, openPptx, + openXls, readDocxContent, readOdpContent, readOdsContent, @@ -37,12 +43,23 @@ import type { PdfOpenDocument, PptxOpenDocument, } from "./types.js"; +import { isEditableDocument } from "./types.js"; // A real, minimal PNG -- the signature bytes plus a few arbitrary trailing ones, matching docx/paragraph-detail.test.tsx's own fixture. ADD_SHEET_IMAGE only stores/embeds these bytes and declares the media part's type from the caller's own explicit `format`, so a genuine decodable pixel grid is not needed to prove the round trip. const PNG_BYTES = new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4, ]); +// A genuinely decodable 1x1 red PNG (real IHDR/IDAT/IEND chunks, truecolor, no filter). Unlike PNG_BYTES above, PdfPage.appendImage -> registerImageBytes DOES decode the pixel grid (to size the image asset it registers), so a fake signature-only PNG throws "PNG file does not begin with an IHDR chunk" here. +const REAL_PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, + 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, + 0x41, 0x54, 0x78, 0xda, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x03, 0x01, 0x01, + 0x00, 0xf7, 0x03, 0x41, 0x43, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, + 0xae, 0x42, 0x60, 0x82, +]); + function applyAll( actions: readonly Action[], from: AppState = createInitialState(), @@ -173,6 +190,39 @@ function openPdfDocument( }); } +function openDocDocument( + bytes: Uint8Array, + path = "/tmp/legacy.doc", +): AppState { + return appReducer(createInitialState(), { + type: "OPEN_FILE_SUCCESS", + path, + doc: { format: "doc", editor: openDoc(bytes), path }, + }); +} + +function openXlsDocument( + bytes: Uint8Array, + path = "/tmp/legacy.xls", +): AppState { + return appReducer(createInitialState(), { + type: "OPEN_FILE_SUCCESS", + path, + doc: { format: "xls", editor: openXls(bytes), path }, + }); +} + +function openPptDocument( + bytes: Uint8Array, + path = "/tmp/legacy.ppt", +): AppState { + return appReducer(createInitialState(), { + type: "OPEN_FILE_SUCCESS", + path, + doc: { format: "ppt", editor: openPpt(bytes), path }, + }); +} + // Real xlsx bytes with no XlsxEditor to build one directly: createOds() -> odsToXlsx() is documents.js's own PDF-bypassing bridge, reused here purely as a source of genuine xlsx bytes for the reducer tests below. function xlsxTestBytes(): Uint8Array { const editor = createOds(); @@ -1579,6 +1629,46 @@ describe("appReducer undo", () => { ).editor.paragraphs().length + 5, ); }); + + // reopenEditable's own switch has one case per EditableOpenDocument format -- docx and pdf are already exercised by the undo tests above, so this covers every remaining branch (pptx/odt/odp/ods/odg/doc/xls/ppt) the same way: mutate via SET_METADATA (the one action every editable format's own editor.metadata setter accepts identically), then undo, and prove the format survived the reopen and a genuinely fresh editor replaced the mutated one. + it.each([ + ["pptx", () => openPptxDocument(createPptx().toBytes())], + ["odt", () => openOdtDocument(createOdt().toBytes())], + ["odp", () => openOdpDocument(createOdp().toBytes())], + ["ods", () => openOdsDocument(createOds().toBytes())], + ["odg", () => openOdgDocument(createOdg().toBytes())], + ["doc", () => openDocDocument(createDoc().toBytes())], + ["xls", () => openXlsDocument(createXls().toBytes())], + ["ppt", () => openPptDocument(createPpt().toBytes())], + ] as const)( + "reopens a %s document from its undo snapshot with a fresh editor", + (format, open) => { + const opened = open(); + const mutated = appReducer(opened, { + type: "SET_METADATA", + overrides: { title: "Undo me" }, + }); + if ( + mutated.openDocument === undefined || + !isEditableDocument(mutated.openDocument) + ) { + throw new Error("expected an editable open document"); + } + expect(mutated.openDocument.format).toBe(format); + const mutatedEditor = mutated.openDocument.editor; + + const undone = appReducer(mutated, { type: "UNDO" }); + expect(undone.undoStack).toHaveLength(0); + if ( + undone.openDocument === undefined || + !isEditableDocument(undone.openDocument) + ) { + throw new Error("expected an editable open document"); + } + expect(undone.openDocument.format).toBe(format); + expect(undone.openDocument.editor).not.toBe(mutatedEditor); + }, + ); }); describe("appReducer ADD_SLIDE_TABLE", () => { @@ -2108,6 +2198,92 @@ describe("appReducer PDF item and page mutations", () => { expect(pdfDocument(undone).editor).not.toBe(pdfDocument(edited).editor); }); + it("edits a text item's font, size, rotation, width, and toggles underline on then off", () => { + const opened = openPdfDocument(pdfTestBytes()); + const withFont = appReducer(opened, { + type: "SET_PDF_TEXT_FONT", + pageIndex: 0, + itemIndex: 0, + font: { family: "Courier", weight: "bold", style: "italic" }, + }); + const withSize = appReducer(withFont, { + type: "SET_PDF_TEXT_SIZE", + pageIndex: 0, + itemIndex: 0, + sizePt: 24, + }); + const withRotation = appReducer(withSize, { + type: "SET_PDF_TEXT_ROTATION", + pageIndex: 0, + itemIndex: 0, + rotationDeg: 45, + }); + const withWidth = appReducer(withRotation, { + type: "SET_PDF_TEXT_WIDTH", + pageIndex: 0, + itemIndex: 0, + widthPt: 99, + }); + const underlineOn = appReducer(withWidth, { + type: "TOGGLE_PDF_TEXT_UNDERLINE", + pageIndex: 0, + itemIndex: 0, + }); + const onItem = pdfDocument(underlineOn).editor.page(0)?.items()[0]; + if (onItem?.kind !== "text") { + throw new Error("expected a live text item"); + } + expect(onItem.font).toStrictEqual({ + family: "Courier", + weight: "bold", + style: "italic", + }); + expect(onItem.sizePt).toBe(24); + expect(onItem.rotationDeg).toBe(45); + expect(onItem.widthPt).toBe(99); + expect(onItem.underline).toBe(true); + + const underlineOff = appReducer(underlineOn, { + type: "TOGGLE_PDF_TEXT_UNDERLINE", + pageIndex: 0, + itemIndex: 0, + }); + const offItem = pdfDocument(underlineOff).editor.page(0)?.items()[0]; + expect(offItem?.kind === "text" ? offItem.underline : undefined).toBe( + false, + ); + }); + + it("warns rather than crashing when SET_PDF_TEXT_FONT, SET_PDF_TEXT_SIZE, and SET_PDF_TEXT_ROTATION target an item of the wrong kind", () => { + const opened = openPdfDocument(pdfTestBytes()); + const withRect = appReducer(opened, { + type: "ADD_PDF_RECT", + pageIndex: 0, + init: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + }); + const fontResult = appReducer(withRect, { + type: "SET_PDF_TEXT_FONT", + pageIndex: 0, + itemIndex: 1, + font: { family: "Helvetica", weight: "normal", style: "normal" }, + }); + expect(fontResult.status?.text).toContain("not text"); + const sizeResult = appReducer(withRect, { + type: "SET_PDF_TEXT_SIZE", + pageIndex: 0, + itemIndex: 1, + sizePt: 10, + }); + expect(sizeResult.status?.text).toContain("not text"); + const rotationResult = appReducer(withRect, { + type: "SET_PDF_TEXT_ROTATION", + pageIndex: 0, + itemIndex: 1, + rotationDeg: 0, + }); + expect(rotationResult.status?.text).toContain("not text"); + }); + it("warns rather than crashing for a page index that does not exist", () => { const opened = openPdfDocument(pdfTestBytes()); const result = appReducer(opened, { @@ -2132,6 +2308,511 @@ describe("appReducer PDF item and page mutations", () => { expect(result.status?.text).toContain("not rect"); expect(result.hasUnsavedChanges).toBe(false); }); + + it("adds an ellipse via ADD_PDF_ELLIPSE, present after a toBytes()/openPdf() round trip", () => { + const opened = openPdfDocument(pdfTestBytes()); + const withEllipse = appReducer(opened, { + type: "ADD_PDF_ELLIPSE", + pageIndex: 0, + init: { + xPt: 5, + yPt: 5, + widthPt: 20, + heightPt: 10, + fill: { r: 1, g: 0, b: 0 }, + }, + }); + const reopened = openPdf(pdfDocument(withEllipse).editor.toBytes()); + const ellipse = (reopened.page(0)?.items() ?? []).find( + (item) => item.kind === "ellipse", + ); + expect(ellipse).toBeDefined(); + if (ellipse?.kind !== "ellipse") { + throw new Error("expected a real ellipse item after re-parsing"); + } + expect(ellipse.widthPt).toBeCloseTo(20, 0); + expect(ellipse.heightPt).toBeCloseTo(10, 0); + }); + + it("adds a line via ADD_PDF_LINE, present after a toBytes()/openPdf() round trip", () => { + const opened = openPdfDocument(pdfTestBytes()); + const withLine = appReducer(opened, { + type: "ADD_PDF_LINE", + pageIndex: 0, + init: { + x1Pt: 1, + y1Pt: 2, + x2Pt: 30, + y2Pt: 40, + color: { r: 0, g: 0, b: 1 }, + widthPt: 2, + }, + }); + const reopened = openPdf(pdfDocument(withLine).editor.toBytes()); + const line = (reopened.page(0)?.items() ?? []).find( + (item) => item.kind === "line", + ); + expect(line).toBeDefined(); + if (line?.kind !== "line") { + throw new Error("expected a real line item after re-parsing"); + } + expect(line.x2Pt).toBeCloseTo(30, 0); + expect(line.y2Pt).toBeCloseTo(40, 0); + }); + + it("adds a path via ADD_PDF_PATH, present after a toBytes()/openPdf() round trip", () => { + const opened = openPdfDocument(pdfTestBytes()); + const withPath = appReducer(opened, { + type: "ADD_PDF_PATH", + pageIndex: 0, + init: { + subpaths: [ + { + startXPt: 0, + startYPt: 0, + closed: true, + segments: [ + { kind: "line", xPt: 10, yPt: 0 }, + { kind: "line", xPt: 10, yPt: 10 }, + ], + }, + ], + fill: { r: 0, g: 1, b: 1 }, + }, + }); + const reopened = openPdf(pdfDocument(withPath).editor.toBytes()); + const path = (reopened.page(0)?.items() ?? []).find( + (item) => item.kind === "path", + ); + expect(path).toBeDefined(); + }); + + it("adds an image via ADD_PDF_IMAGE, present after a toBytes()/openPdf() round trip", () => { + const opened = openPdfDocument(pdfTestBytes()); + const withImage = appReducer(opened, { + type: "ADD_PDF_IMAGE", + pageIndex: 0, + init: { + xPt: 5, + yPt: 5, + widthPt: 30, + heightPt: 20, + bytes: REAL_PNG_BYTES, + format: "png", + }, + }); + const reopened = openPdf(pdfDocument(withImage).editor.toBytes()); + const image = (reopened.page(0)?.items() ?? []).find( + (item) => item.kind === "image", + ); + expect(image).toBeDefined(); + if (image?.kind !== "image") { + throw new Error("expected a real image item after re-parsing"); + } + expect(image.widthPt).toBeCloseTo(30, 0); + expect(image.heightPt).toBeCloseTo(20, 0); + }); + + it("adds a link via ADD_PDF_LINK, present after a toBytes()/openPdf() round trip", () => { + const opened = openPdfDocument(pdfTestBytes()); + const withLink = appReducer(opened, { + type: "ADD_PDF_LINK", + pageIndex: 0, + init: { + uri: "https://example.com", + xPt: 5, + yPt: 5, + widthPt: 40, + heightPt: 15, + }, + }); + const reopened = openPdf(pdfDocument(withLink).editor.toBytes()); + const link = (reopened.page(0)?.items() ?? []).find( + (item) => item.kind === "link", + ); + expect(link).toBeDefined(); + if (link?.kind !== "link") { + throw new Error("expected a real link item after re-parsing"); + } + expect(link.uri).toBe("https://example.com"); + }); + + describe("field edits on non-text pdf item kinds", () => { + // One page carrying one of every editable non-text item kind, each added through the reducer's own ADD_PDF_* actions so tests below index into a document built the same way a real session would build one. + function pdfMultiItemState(): AppState { + const opened = openPdfDocument(pdfTestBytes()); + const withRect = appReducer(opened, { + type: "ADD_PDF_RECT", + pageIndex: 0, + init: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + }); + const withEllipse = appReducer(withRect, { + type: "ADD_PDF_ELLIPSE", + pageIndex: 0, + init: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + }); + const withLine = appReducer(withEllipse, { + type: "ADD_PDF_LINE", + pageIndex: 0, + init: { + x1Pt: 0, + y1Pt: 0, + x2Pt: 10, + y2Pt: 10, + color: { r: 0, g: 0, b: 0 }, + widthPt: 1, + }, + }); + const withPath = appReducer(withLine, { + type: "ADD_PDF_PATH", + pageIndex: 0, + init: { + subpaths: [ + { + startXPt: 0, + startYPt: 0, + closed: false, + segments: [{ kind: "line", xPt: 5, yPt: 5 }], + }, + ], + }, + }); + const withImage = appReducer(withPath, { + type: "ADD_PDF_IMAGE", + pageIndex: 0, + init: { + xPt: 0, + yPt: 0, + widthPt: 10, + heightPt: 10, + bytes: REAL_PNG_BYTES, + format: "png", + }, + }); + return appReducer(withImage, { + type: "ADD_PDF_LINK", + pageIndex: 0, + init: { + uri: "https://before.example", + xPt: 0, + yPt: 0, + widthPt: 10, + heightPt: 10, + }, + }); + } + + // Item order matches pdfMultiItemState()'s own build sequence: 0 text, 1 rect, 2 ellipse, 3 line, 4 path, 5 image, 6 link. + const TEXT_INDEX = 0; + const RECT_INDEX = 1; + const ELLIPSE_INDEX = 2; + const LINE_INDEX = 3; + const PATH_INDEX = 4; + const IMAGE_INDEX = 5; + const LINK_INDEX = 6; + + it("edits a rect's frame, fill, and stroke in place", () => { + const state = pdfMultiItemState(); + const withFrame = appReducer(state, { + type: "SET_PDF_RECT_FRAME", + pageIndex: 0, + itemIndex: RECT_INDEX, + xPt: 1, + yPt: 2, + widthPt: 33, + heightPt: 44, + }); + const withFill = appReducer(withFrame, { + type: "SET_PDF_RECT_FILL", + pageIndex: 0, + itemIndex: RECT_INDEX, + fill: { r: 1, g: 0.5, b: 0 }, + }); + const withStroke = appReducer(withFill, { + type: "SET_PDF_RECT_STROKE", + pageIndex: 0, + itemIndex: RECT_INDEX, + stroke: { color: { r: 0, g: 0, b: 1 }, widthPt: 3 }, + }); + const item = pdfDocument(withStroke).editor.page(0)?.items()[RECT_INDEX]; + if (item?.kind !== "rect") { + throw new Error("expected a live rect item"); + } + expect(item.xPt).toBe(1); + expect(item.yPt).toBe(2); + expect(item.widthPt).toBe(33); + expect(item.heightPt).toBe(44); + expect(item.fill).toStrictEqual({ r: 1, g: 0.5, b: 0 }); + expect(item.stroke).toStrictEqual({ + color: { r: 0, g: 0, b: 1 }, + widthPt: 3, + }); + }); + + it("warns rather than crashing when a rect field-edit targets an item of the wrong kind", () => { + const state = pdfMultiItemState(); + const result = appReducer(state, { + type: "SET_PDF_RECT_FRAME", + pageIndex: 0, + itemIndex: ELLIPSE_INDEX, + xPt: 0, + yPt: 0, + widthPt: 1, + heightPt: 1, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("not rect"); + }); + + it("edits an ellipse's frame, fill, and stroke in place", () => { + const state = pdfMultiItemState(); + const withFrame = appReducer(state, { + type: "SET_PDF_ELLIPSE_FRAME", + pageIndex: 0, + itemIndex: ELLIPSE_INDEX, + xPt: 3, + yPt: 4, + widthPt: 22, + heightPt: 11, + }); + const withFill = appReducer(withFrame, { + type: "SET_PDF_ELLIPSE_FILL", + pageIndex: 0, + itemIndex: ELLIPSE_INDEX, + fill: { r: 0, g: 1, b: 0 }, + }); + const withStroke = appReducer(withFill, { + type: "SET_PDF_ELLIPSE_STROKE", + pageIndex: 0, + itemIndex: ELLIPSE_INDEX, + stroke: { color: { r: 1, g: 1, b: 0 }, widthPt: 2 }, + }); + const item = pdfDocument(withStroke).editor.page(0)?.items()[ + ELLIPSE_INDEX + ]; + if (item?.kind !== "ellipse") { + throw new Error("expected a live ellipse item"); + } + expect(item.widthPt).toBe(22); + expect(item.heightPt).toBe(11); + expect(item.fill).toStrictEqual({ r: 0, g: 1, b: 0 }); + expect(item.stroke).toStrictEqual({ + color: { r: 1, g: 1, b: 0 }, + widthPt: 2, + }); + }); + + it("warns rather than crashing when an ellipse field-edit targets an item of the wrong kind", () => { + const state = pdfMultiItemState(); + const result = appReducer(state, { + type: "SET_PDF_ELLIPSE_FILL", + pageIndex: 0, + itemIndex: LINE_INDEX, + fill: { r: 0, g: 0, b: 0 }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("not ellipse"); + }); + + it("edits a line's endpoints, color, and width in place", () => { + const state = pdfMultiItemState(); + const withFrom = appReducer(state, { + type: "SET_PDF_LINE_FROM", + pageIndex: 0, + itemIndex: LINE_INDEX, + x1Pt: 7, + y1Pt: 8, + }); + const withTo = appReducer(withFrom, { + type: "SET_PDF_LINE_TO", + pageIndex: 0, + itemIndex: LINE_INDEX, + x2Pt: 70, + y2Pt: 80, + }); + const withColor = appReducer(withTo, { + type: "SET_PDF_LINE_COLOR", + pageIndex: 0, + itemIndex: LINE_INDEX, + color: { r: 0.2, g: 0.3, b: 0.4 }, + }); + const withWidth = appReducer(withColor, { + type: "SET_PDF_LINE_WIDTH", + pageIndex: 0, + itemIndex: LINE_INDEX, + widthPt: 5, + }); + const item = pdfDocument(withWidth).editor.page(0)?.items()[LINE_INDEX]; + if (item?.kind !== "line") { + throw new Error("expected a live line item"); + } + expect(item.x1Pt).toBe(7); + expect(item.y1Pt).toBe(8); + expect(item.x2Pt).toBe(70); + expect(item.y2Pt).toBe(80); + expect(item.color).toStrictEqual({ r: 0.2, g: 0.3, b: 0.4 }); + expect(item.widthPt).toBe(5); + }); + + it("warns rather than crashing when a line field-edit targets an item of the wrong kind", () => { + const state = pdfMultiItemState(); + const result = appReducer(state, { + type: "SET_PDF_LINE_WIDTH", + pageIndex: 0, + itemIndex: PATH_INDEX, + widthPt: 1, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("not line"); + }); + + it("edits a path's fill, fill rule, and stroke in place", () => { + const state = pdfMultiItemState(); + const withFill = appReducer(state, { + type: "SET_PDF_PATH_FILL", + pageIndex: 0, + itemIndex: PATH_INDEX, + fill: { r: 1, g: 0, b: 1 }, + }); + const withRule = appReducer(withFill, { + type: "SET_PDF_PATH_FILL_RULE", + pageIndex: 0, + itemIndex: PATH_INDEX, + fillRule: "evenodd", + }); + const withStroke = appReducer(withRule, { + type: "SET_PDF_PATH_STROKE", + pageIndex: 0, + itemIndex: PATH_INDEX, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1.5 }, + }); + const item = pdfDocument(withStroke).editor.page(0)?.items()[PATH_INDEX]; + if (item?.kind !== "path") { + throw new Error("expected a live path item"); + } + expect(item.fill).toStrictEqual({ r: 1, g: 0, b: 1 }); + expect(item.fillRule).toBe("evenodd"); + expect(item.stroke).toStrictEqual({ + color: { r: 0, g: 0, b: 0 }, + widthPt: 1.5, + }); + }); + + it("warns rather than crashing when a path field-edit targets an item of the wrong kind", () => { + const state = pdfMultiItemState(); + const result = appReducer(state, { + type: "SET_PDF_PATH_FILL_RULE", + pageIndex: 0, + itemIndex: IMAGE_INDEX, + fillRule: "nonzero", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("not path"); + }); + + it("edits an image's frame, rotation, and source in place", () => { + const state = pdfMultiItemState(); + const withFrame = appReducer(state, { + type: "SET_PDF_IMAGE_FRAME", + pageIndex: 0, + itemIndex: IMAGE_INDEX, + xPt: 9, + yPt: 10, + widthPt: 15, + heightPt: 25, + }); + const withRotation = appReducer(withFrame, { + type: "SET_PDF_IMAGE_ROTATION", + pageIndex: 0, + itemIndex: IMAGE_INDEX, + rotationDeg: 90, + }); + const beforeItem = pdfDocument(withRotation).editor.page(0)?.items()[ + IMAGE_INDEX + ]; + if (beforeItem?.kind !== "image") { + throw new Error("expected a live image item"); + } + // Read the string value now, before the mutation below: beforeItem is a live view over the same underlying node, so its own .imageId getter would report the POST-mutation value if read only after withSource exists. + const beforeImageIdValue = beforeItem.imageId; + const withSource = appReducer(withRotation, { + type: "SET_PDF_IMAGE_SOURCE", + pageIndex: 0, + itemIndex: IMAGE_INDEX, + // A different real, decodable PNG (1x1 blue rather than REAL_PNG_BYTES' red) -- distinct content, so registerImageBytes' own dedup-by-content assigns it a genuinely different imageId, proving setImage repointed the item rather than leaving it unchanged. + bytes: new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, + 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, + 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, + 0x60, 0x60, 0xf8, 0x0f, 0x00, 0x01, 0x03, 0x01, 0x00, 0x36, 0x74, + 0x11, 0x40, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, + 0x42, 0x60, 0x82, + ]), + format: "png", + }); + const item = pdfDocument(withSource).editor.page(0)?.items()[IMAGE_INDEX]; + if (item?.kind !== "image") { + throw new Error("expected a live image item"); + } + expect(item.widthPt).toBe(15); + expect(item.heightPt).toBe(25); + expect(item.rotationDeg).toBe(90); + expect(item.imageId).not.toBe(beforeImageIdValue); + }); + + it("warns rather than crashing when an image field-edit targets an item of the wrong kind", () => { + const state = pdfMultiItemState(); + const result = appReducer(state, { + type: "SET_PDF_IMAGE_ROTATION", + pageIndex: 0, + itemIndex: LINK_INDEX, + rotationDeg: 0, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("not image"); + }); + + it("edits a link's uri and frame in place", () => { + const state = pdfMultiItemState(); + const withUri = appReducer(state, { + type: "SET_PDF_LINK_URI", + pageIndex: 0, + itemIndex: LINK_INDEX, + uri: "https://after.example", + }); + const withFrame = appReducer(withUri, { + type: "SET_PDF_LINK_FRAME", + pageIndex: 0, + itemIndex: LINK_INDEX, + xPt: 11, + yPt: 12, + widthPt: 50, + heightPt: 20, + }); + const item = pdfDocument(withFrame).editor.page(0)?.items()[LINK_INDEX]; + if (item?.kind !== "link") { + throw new Error("expected a live link item"); + } + expect(item.uri).toBe("https://after.example"); + expect(item.xPt).toBe(11); + expect(item.yPt).toBe(12); + expect(item.widthPt).toBe(50); + expect(item.heightPt).toBe(20); + }); + + it("warns rather than crashing when a link field-edit targets an item of the wrong kind", () => { + const state = pdfMultiItemState(); + const result = appReducer(state, { + type: "SET_PDF_LINK_URI", + pageIndex: 0, + itemIndex: TEXT_INDEX, + uri: "https://wrong.example", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("not link"); + }); + }); }); describe("appReducer ADD_RECT / ADD_ELLIPSE / ADD_LINE / ADD_PATH on odp", () => { From 1a2bf32491c98ae476414a74bb9d77ce248e45df Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:14:53 +0100 Subject: [PATCH 05/36] test(document-cli): cover odb-to-xlsx, odb-to-csv, and odb-tables These three commands had no test coverage at all: default and explicit output path resolution, the --out flag, the conflicting-destination usage error (including the equal-values non-conflict case), --table selection and its not-found error, and odb-tables' plain-text and --json report forms. --- .../document-cli/src/commands/odb.test.ts | 221 +++++++++++++++++- 1 file changed, 220 insertions(+), 1 deletion(-) diff --git a/packages/document-cli/src/commands/odb.test.ts b/packages/document-cli/src/commands/odb.test.ts index 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", () => { From b048246fb4ecd7d14b23e0bd8bfcf720708688e1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:15:04 +0100 Subject: [PATCH 06/36] test(document-cli): cover pdf-inspect's default and --json report modes Only --full had any coverage. Adds the plain-text report (page count singular/plural, the per-page item-kind histogram and its empty-page omission, the metadata and images-by-format sections and their all-empty omission), the --json summary shape, and the non-PDF-input error path. --- .../src/commands/pdf-inspect.test.ts | 112 +++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/packages/document-cli/src/commands/pdf-inspect.test.ts b/packages/document-cli/src/commands/pdf-inspect.test.ts index 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([ From 8af58e0229f7a7b029db89c9f4b10742b11073e1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:31:24 +0100 Subject: [PATCH 07/36] test(document-cli): cover from-package's output-conflict, csv/svg targets, and version-gate errors Adds coverage for the positional/--out conflict and agreement checks, the --out flag on its own, a genuine SchemaVersionMismatchError (a document-tree.schema.json dump pinned to a major other than the installed one, distinct from the rename/demotion tombstones already covered), invalid-UTF-8 input bytes, and the csv/svg target branches with --delimiter/--page threaded through, plus --json/--quiet output. --- .../src/commands/from-package.test.ts | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/packages/document-cli/src/commands/from-package.test.ts b/packages/document-cli/src/commands/from-package.test.ts index 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" })); From 72854c4fbfbfa4f827ba808f359098cdfa4ed2b0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:38:51 +0100 Subject: [PATCH 08/36] test(document-cli): cover cli-main's bare/tui/command dispatch cli-main.ts had no test coverage at all. Adds the bare-invocation TTY gate (launches the TUI vs. shows help), the explicit 'tui' token's own TTY refusal and start-path resolution (skipping leading flag tokens), launchTui's own error-to-exit-code mapping, and both the registered 'tui [file]' subcommand and the CommanderError-vs-genuine-bug split in the ordinary command dispatch path. --- packages/document-cli/src/cli-main.test.ts | 162 +++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 packages/document-cli/src/cli-main.test.ts diff --git a/packages/document-cli/src/cli-main.test.ts b/packages/document-cli/src/cli-main.test.ts new file mode 100644 index 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"); + }); +}); From 067fa33d9e9d24404c32c0b43980661ff8de0e1c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:46:40 +0100 Subject: [PATCH 09/36] test(document-cli): cover ADD_LIST_ITEM's docx/markdown paragraph-copy branch The odt branch (a real OdtList) already had its own describe block; this action's other branch -- appending a new paragraph and copying the anchor paragraph's own list membership, the docx/markdown path with no separate list object -- had no dispatch anywhere, including its two warning paths (no paragraph at blockIndex, and an anchor not part of a list). --- .../src/tui/state/reducer.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 757995ae5d..28a30d39a3 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -964,6 +964,69 @@ describe("appReducer SET_LIST_ITEM_TEXT on odt", () => { }); }); +describe("appReducer ADD_LIST_ITEM on docx", () => { + // docx (and markdown) have no separate "list" object the way odt does -- list membership is flat per-paragraph metadata, so ADD_LIST_ITEM's own docx/markdown branch appends a brand-new paragraph and copies the anchor paragraph's own ContentListMembership onto it, rather than extending an OdtList (the odt branch this file's own "ADD_LIST on odt"/"INDENT_LIST_ITEM on odt" describe blocks already cover). + it("appends a new paragraph copying the anchor paragraph's own list membership", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const doc = docxDocument(created); + const anchor = doc.editor.body.appendParagraph({ text: "First item" }); + anchor.list = { level: 0, numId: "7" }; + const anchorIndex = doc.editor.paragraphs().length - 1; + + const added = appReducer(created, { + type: "ADD_LIST_ITEM", + blockIndex: anchorIndex, + text: "Second item", + }); + + const paragraphs = docxDocument(added).editor.paragraphs(); + const appended = paragraphs[paragraphs.length - 1]; + expect(appended?.text).toBe("Second item"); + expect(appended?.list).toStrictEqual({ level: 0, numId: "7" }); + expect(added.hasUnsavedChanges).toBe(true); + }); + + it("warns rather than crashing when blockIndex names no paragraph", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + + const result = appReducer(created, { + type: "ADD_LIST_ITEM", + blockIndex: 999, + text: "x", + }); + + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("no paragraph"); + expect(result.hasUnsavedChanges).toBe(false); + }); + + it("warns rather than crashing when the anchor paragraph is not part of a list", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const doc = docxDocument(created); + doc.editor.body.appendParagraph({ text: "Not a list item" }); + const anchorIndex = doc.editor.paragraphs().length - 1; + + const result = appReducer(created, { + type: "ADD_LIST_ITEM", + blockIndex: anchorIndex, + text: "x", + }); + + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("not part of a list"); + expect(result.hasUnsavedChanges).toBe(false); + }); +}); + describe("appReducer ADD_LIST on odt", () => { it("creates a real, brand-new, empty list, navigable through the existing listEditor screen", () => { const created = appReducer(createInitialState(), { From eea0e3c497487d9324b98171669d7f647f0c9a93 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:52:14 +0100 Subject: [PATCH 10/36] test(document-cli): cover odm-to-pdf's --out flag and font-substitution reporting --out on its own (only the positional-vs-flag conflict was covered before) and the reportFontSubstitution branch (a Calibri-styled chapter substituted to the vendored carlito face under --report-font-substitutions, and silent without it), mirroring the identical scenario convert-fonts.test.ts already covers for the other -to-pdf commands. --- .../document-cli/src/commands/odm.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/document-cli/src/commands/odm.test.ts b/packages/document-cli/src/commands/odm.test.ts index 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", From b52dad032b7e6fbb7633321eb9f69a7605e89a72 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:01:23 +0100 Subject: [PATCH 11/36] refactor(document-cli): rebuild formula presets per-call instead of a module constant Stryker's per-test coverage instrumentation attributes a module-scope constant's one-time initialisation to whichever test happens to trigger the very first import of the module across the whole suite, since module caching means every later importer just reads the already-built array. A mutant in the literal was therefore only ever re-verified against that unrelated first-importing test, never against formula-presets.test.ts's own assertions. Rebuilding the array inside a function makes each call site's own execution the thing coverage attributes to, so this file's exhaustive tests are what actually re-run against a mutant. --- .../src/tui/screens/shared/formula-picker.tsx | 4 +- .../screens/shared/formula-presets.test.ts | 22 ++--- .../src/tui/screens/shared/formula-presets.ts | 82 ++++++++++--------- 3 files changed, 57 insertions(+), 51 deletions(-) diff --git a/packages/document-cli/src/tui/screens/shared/formula-picker.tsx b/packages/document-cli/src/tui/screens/shared/formula-picker.tsx index 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")]), ]), - ]), - ], - }, -]; + ], + }, + ]; +} From 9ce787100011bd8da740995872ed1fd5bac3f15d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:01:31 +0100 Subject: [PATCH 12/36] test(document-cli): cover legacy binary and flowable formats in the format table isDocumentFormat, inferFormatFromExtension, and formatToExtension were only exercised for the earlier office/markup formats; wpd, doc, xls, ppt, and epub had no case asserting their own membership, extension inference, or round-trip extension. --- packages/document-cli/src/format.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/document-cli/src/format.test.ts b/packages/document-cli/src/format.test.ts index 0eb2661958..5ae784429e 100644 --- a/packages/document-cli/src/format.test.ts +++ b/packages/document-cli/src/format.test.ts @@ -21,6 +21,11 @@ describe("isDocumentFormat", () => { "markdown", "rtf", "pdf", + "wpd", + "doc", + "xls", + "ppt", + "epub", ]) { expect(isDocumentFormat(format)).toBe(true); } @@ -102,6 +107,14 @@ describe("inferFormatFromExtension", () => { it("infers rtf from its own extension", () => { expect(inferFormatFromExtension("letter.rtf")).toBe("rtf"); }); + + it("infers each legacy binary and flowable format from its own extension", () => { + expect(inferFormatFromExtension("draft.wpd")).toBe("wpd"); + expect(inferFormatFromExtension("legacy.doc")).toBe("doc"); + expect(inferFormatFromExtension("legacy.xls")).toBe("xls"); + expect(inferFormatFromExtension("legacy.ppt")).toBe("ppt"); + expect(inferFormatFromExtension("book.epub")).toBe("epub"); + }); }); describe("formatToExtension", () => { @@ -124,6 +137,11 @@ describe("formatToExtension", () => { ["markdown", "md"], ["rtf", "rtf"], ["pdf", "pdf"], + ["wpd", "wpd"], + ["doc", "doc"], + ["xls", "xls"], + ["ppt", "ppt"], + ["epub", "epub"], ]; for (const [format, extension] of cases) { expect(formatToExtension(format)).toBe(extension); From 27c9df6a4a17b057f3b2316b1541b3aad98a86ed Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:01:45 +0100 Subject: [PATCH 13/36] test(document-cli): cover the ooxml-fixture test-support builders xmlDeclaration, txt, and el had no direct test of their own; every existing use only exercised them indirectly through the fixtures that call them, leaving each builder's own default-argument and attribute- ordering behaviour unasserted. --- .../src/test-support/ooxml-fixture.test.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 packages/document-cli/src/test-support/ooxml-fixture.test.ts diff --git a/packages/document-cli/src/test-support/ooxml-fixture.test.ts b/packages/document-cli/src/test-support/ooxml-fixture.test.ts new file mode 100644 index 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); + }); +}); From 6c8babe02f3f71d9002aa657a79d338a6c1dd709 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:01:54 +0100 Subject: [PATCH 14/36] build(document-cli): serialise mutation test runs and extend the dry-run timeout document-cli's TUI suite spawns and manages child processes and pseudo- terminals; running mutants at the default concurrency let sibling workers race over the same terminal/process resources and produced flaky, environment-dependent failures unrelated to the mutated code. Concurrency of 1 and a longer dry-run timeout make a run reproducible at the cost of wall-clock time. --- packages/document-cli/stryker.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/document-cli/stryker.config.ts b/packages/document-cli/stryker.config.ts index 611b74d77e..41d59efef5 100644 --- a/packages/document-cli/stryker.config.ts +++ b/packages/document-cli/stryker.config.ts @@ -10,4 +10,6 @@ export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", // First CI-measured baseline: 33.33% of 6241 valid mutants, timeout share 0.02% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. breakThreshold: 32, + concurrency: 1, + dryRunTimeoutMinutes: 20, }); From 2ba7dee3f43180bd4ec3e47ba396f74c9298e966 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:37:09 +0100 Subject: [PATCH 15/36] refactor(document-cli): rebuild the format-to-extension table per call FORMAT_TO_EXTENSION was a module-scope object literal, so Stryker's per-test coverage instrumentation attributed its one-time initialisation to whichever test happened to trigger the first import of format.ts in the whole suite, never to the tests that actually call formatToExtension with a given format. A mutated extension value therefore survived even though formatToExtension's own exhaustive test asserts on every entry. Rebuilding the table inside a function called on every lookup makes each call site's own execution the thing coverage attributes to. --- packages/document-cli/src/format.test.ts | 2 +- packages/document-cli/src/format.ts | 49 +++++++++++++----------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/packages/document-cli/src/format.test.ts b/packages/document-cli/src/format.test.ts index 5ae784429e..12695ce56d 100644 --- a/packages/document-cli/src/format.test.ts +++ b/packages/document-cli/src/format.test.ts @@ -118,7 +118,7 @@ describe("inferFormatFromExtension", () => { }); describe("formatToExtension", () => { - // Not `formatToExtension(format) === format` any more: markdown breaks that identity (two extensions read as 'markdown', but only one -- 'md' -- is written), so this is an explicit lookup table instead, matching FORMAT_TO_EXTENSION's own canonical choice one entry at a time rather than asserting a shortcut that no longer holds for every format. A typed tuple array, not `Object.entries` over a Record, so each format literal narrows on its own -- no type assertion needed to hand it back to formatToExtension. + // Not `formatToExtension(format) === format` any more: markdown breaks that identity (two extensions read as 'markdown', but only one -- 'md' -- is written), so this is an explicit lookup table instead, matching getFormatToExtension's own canonical choice one entry at a time rather than asserting a shortcut that no longer holds for every format. A typed tuple array, not `Object.entries` over a Record, so each format literal narrows on its own -- no type assertion needed to hand it back to formatToExtension. it("maps every recognised format to its own canonical extension", () => { const cases: readonly (readonly [ Parameters[0], diff --git a/packages/document-cli/src/format.ts b/packages/document-cli/src/format.ts index 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]; } From 83a03f11f399fa50f86acc3d2067d0ce1ab2c73d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 03:43:45 +0100 Subject: [PATCH 16/36] fix(document-cli): let Esc dismiss the slide-table-detail screen when its table is gone The useInput handler returned before reaching any key check whenever resolveSlideTable came back undefined, so the fallback view's own "press Esc to go back" text was a lie: no key, Escape included, ever did anything. Esc now still pops the screen in that state; every other key remains a no-op since there is no grid left to act on. --- .../src/tui/screens/editors/pptx/slide-table-detail.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/document-cli/src/tui/screens/editors/pptx/slide-table-detail.tsx b/packages/document-cli/src/tui/screens/editors/pptx/slide-table-detail.tsx index 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") { From ab3cb69e8e813dec49f4f84fcec5316feb794d3a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 03:43:49 +0100 Subject: [PATCH 17/36] test(document-cli): cover slide-table-detail's missing-table and edge-clamp paths Adds coverage for branches no existing test touched: the table-no-longer-exists fallback (and that Esc, uniquely, still works there), cursor clamping at every edge of the grid via individual h/j/k/l presses, and committing a pending merge with Enter rather than a second 'm'. --- .../editors/pptx/slide-table-detail.test.tsx | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/packages/document-cli/src/tui/screens/editors/pptx/slide-table-detail.test.tsx b/packages/document-cli/src/tui/screens/editors/pptx/slide-table-detail.test.tsx index 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)"); + }); }, ); From 9addc10fe56c4abdd7a15c6f297ae0dffa4fb3e4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 03:47:27 +0100 Subject: [PATCH 18/36] test(document-cli): cover paragraph-detail's own three guard fallbacks Nothing exercised the outside-screen guard, the no-open-document guard, or the out-of-range blockIndex guard -- all three early returns above the screen's real render path were dead as far as the suite could tell. --- .../editors/docx/paragraph-detail.test.tsx | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/packages/document-cli/src/tui/screens/editors/docx/paragraph-detail.test.tsx b/packages/document-cli/src/tui/screens/editors/docx/paragraph-detail.test.tsx index 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"); + }); + }, + ); +}); From 76d7382ba30084c8122912437ee896968ac24b27 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:07:30 +0100 Subject: [PATCH 19/36] test(document-cli): cover UNDO's own per-format read-only warning UNDO's read-only branch names one of seven formats (odb/xlsx/csv/svg/ rtf/wpd/epub) in its warning text, but nothing dispatched UNDO against any of them -- every existing undo test opened an editable format instead, leaving the whole seven-way format check and its templated message unexercised. --- .../src/tui/state/reducer.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 28a30d39a3..dc12e188aa 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -34,14 +34,21 @@ import type { Action } from "./actions.js"; import { appReducer, createInitialState } from "./reducer.js"; import type { AppState, + CsvOpenDocument, DocxOpenDocument, + EpubOpenDocument, MarkdownOpenDocument, + OdbOpenDocument, OdgOpenDocument, OdpOpenDocument, OdsOpenDocument, OdtOpenDocument, PdfOpenDocument, PptxOpenDocument, + RtfOpenDocument, + SvgOpenDocument, + WpdOpenDocument, + XlsxOpenDocument, } from "./types.js"; import { isEditableDocument } from "./types.js"; @@ -243,6 +250,34 @@ function openXlsxDocument( }); } +// A minimal document for each of UNDO's own read-only formats: odb carries no layout/bytes at all (see OdbOpenDocument's own doc comment), the other six share the identical read-only-preview shape XlsxOpenDocument above already builds, populated through a real PdfEditor rather than a hand-authored LayoutDocument literal -- these tests only exercise UNDO's own per-format branch, never the layout content itself. +function readOnlyOpenDocument( + format: "odb" | "xlsx" | "csv" | "svg" | "rtf" | "wpd" | "epub", +): + | OdbOpenDocument + | XlsxOpenDocument + | CsvOpenDocument + | SvgOpenDocument + | RtfOpenDocument + | WpdOpenDocument + | EpubOpenDocument { + if (format === "odb") { + return { + format: "odb", + tables: [], + forms: [], + reports: [], + path: "/tmp/database.odb", + }; + } + return { + format, + layout: createPdf().toLayoutDocument(), + bytes: createPdf().toBytes(), + path: `/tmp/source.${format}`, + }; +} + function markdownDocument(state: AppState): MarkdownOpenDocument { const doc = state.openDocument; if (doc?.format !== "markdown") { @@ -1732,6 +1767,27 @@ describe("appReducer undo", () => { expect(undone.openDocument.editor).not.toBe(mutatedEditor); }, ); + + // The genuinely read-only formats (no live-view editor, so nothing ever pushes an undo snapshot for them) each get their own dedicated warning naming that exact format, rather than falling through to the "nothing to undo" info message every editable format's own empty undo stack produces above. + it.each(["odb", "xlsx", "csv", "svg", "rtf", "wpd", "epub"] as const)( + "says a %s document is read-only with nothing to undo", + (format) => { + const doc = readOnlyOpenDocument(format); + const opened = appReducer(createInitialState(), { + type: "OPEN_FILE_SUCCESS", + path: doc.path, + doc, + }); + + const undone = appReducer(opened, { type: "UNDO" }); + expect(undone.status?.severity).toBe("warning"); + expect(undone.status?.text).toBe( + `A ${format} document is read-only, so it has no history to undo`, + ); + expect(undone.openDocument).toBe(opened.openDocument); + expect(undone.undoStack).toHaveLength(0); + }, + ); }); describe("appReducer ADD_SLIDE_TABLE", () => { From 68acf4081029a71096f6e8a9fc10e43e9bffc014 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:23:25 +0100 Subject: [PATCH 20/36] test(document-cli): cover the odg page-vs-slide wording branch and remaining format/kind guards Covers withShape's "page" wording for an odg missing-shape warning, previously only exercised via the pptx "slide" branch, each of the nine formats OPEN_FILE_SUCCESS names individually in its read-only-PDF-preview note, and the wrong-kind guards for SET_PDF_INTERNAL_LINK_DESTINATION and SET_PDF_INTERNAL_LINK_FRAME. --- .../src/tui/state/reducer.test.ts | 108 +++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index dc12e188aa..6730e0f56d 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -2133,7 +2133,7 @@ describe("appReducer SET_SHAPE_ROTATION on pptx", () => { expect(reopened.slides()[0]?.shapes()[0]?.rotationDeg).toBeCloseTo(30, 5); }); - it("warns rather than crashing for a shape index that does not exist", () => { + it("warns rather than crashing for a shape index that does not exist, naming the slide it looked on", () => { const editor = createPptx(); editor.addSlide(); const opened = openPptxDocument(editor.toBytes()); @@ -2146,6 +2146,23 @@ describe("appReducer SET_SHAPE_ROTATION on pptx", () => { }); expect(result.status?.severity).toBe("warning"); expect(result.hasUnsavedChanges).toBe(false); + expect(result.status?.text).toBe("There is no shape 5 on slide 0"); + }); + + // withShape's own missing-shape message says "page" for odg specifically, "slide" for every other shape-host format -- the pptx test above only ever exercises the "slide" branch of that ternary. + it("warns with 'page' rather than 'slide' when the missing shape is on an odg page", () => { + const editor = createOdg(); + editor.addPage(); + const opened = openOdgDocument(editor.toBytes()); + + const result = appReducer(opened, { + type: "SET_SHAPE_TEXT", + containerIndex: 0, + shapeIndex: 3, + text: "unreachable", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no shape 3 on page 0"); }); }); @@ -2176,6 +2193,67 @@ describe("appReducer xlsx (read-only PDF-preview) documents", () => { }); }); +// OPEN_FILE_SUCCESS's own "opened as a read-only PDF preview" note names all nine of these formats individually in its own OR-chain (xlsx is exercised separately above, through its own dedicated describe block) -- each one needs its own dispatch to prove that exact branch, not just the shared behaviour the OR-chain produces once any one of them matches. +describe("appReducer OPEN_FILE_SUCCESS's read-only-PDF-preview note names every one of its own nine formats", () => { + it.each([ + ["csv", () => readOnlyOpenDocument("csv")], + ["svg", () => readOnlyOpenDocument("svg")], + ["rtf", () => readOnlyOpenDocument("rtf")], + ["wpd", () => readOnlyOpenDocument("wpd")], + [ + "doc", + () => ({ + format: "doc" as const, + editor: openDoc(createDoc().toBytes()), + path: "/tmp/legacy.doc", + }), + ], + [ + "xls", + () => ({ + format: "xls" as const, + editor: openXls(createXls().toBytes()), + path: "/tmp/legacy.xls", + }), + ], + [ + "ppt", + () => ({ + format: "ppt" as const, + editor: openPpt(createPpt().toBytes()), + path: "/tmp/legacy.ppt", + }), + ], + ["epub", () => readOnlyOpenDocument("epub")], + ] as const)( + "names %s specifically in the preview note", + (format, buildDoc) => { + const doc = buildDoc(); + const opened = appReducer(createInitialState(), { + type: "OPEN_FILE_SUCCESS", + path: doc.path, + doc, + }); + expect(opened.status?.text).toBe( + `Opened ${doc.path} as a read-only PDF preview -- press ':' then 'export pdf' to save it as a real PDF`, + ); + }, + ); + + it("does not add the preview note for a format with a real live-view editor of its own", () => { + const opened = appReducer(createInitialState(), { + type: "OPEN_FILE_SUCCESS", + path: "/tmp/notes.odt", + doc: { + format: "odt", + editor: openOdt(createOdt().toBytes()), + path: "/tmp/notes.odt", + }, + }); + expect(opened.status?.text).toBe("Opened /tmp/notes.odt"); + }); +}); + // A minimal real fixture: one page, one text item -- built through the real PdfEditor (createPdf/appendText), never a hand-authored LayoutDocument literal, so these tests exercise the exact writer/reader pair the reducer wires against. function pdfTestBytes(): Uint8Array { const editor = createPdf(); @@ -2931,6 +3009,34 @@ describe("appReducer PDF item and page mutations", () => { expect(result.status?.severity).toBe("warning"); expect(result.status?.text).toContain("not link"); }); + + // internalLink items arise only from reading a real PDF's own GoTo/Dest annotations (PdfPage has no appendInternalLink -- unlike every other item kind, there is no way to add one fresh through the editor), so only the wrong-kind guard is reachable here; the success path is exercised at the pdf-codec layer instead (see its own navigation.test.ts). + it("warns rather than crashing when an internal-link destination edit targets an item of the wrong kind", () => { + const state = pdfMultiItemState(); + const result = appReducer(state, { + type: "SET_PDF_INTERNAL_LINK_DESTINATION", + pageIndex: 0, + itemIndex: TEXT_INDEX, + destination: "dest1", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("not internalLink"); + }); + + it("warns rather than crashing when an internal-link frame edit targets an item of the wrong kind", () => { + const state = pdfMultiItemState(); + const result = appReducer(state, { + type: "SET_PDF_INTERNAL_LINK_FRAME", + pageIndex: 0, + itemIndex: TEXT_INDEX, + xPt: 1, + yPt: 2, + widthPt: 3, + heightPt: 4, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("not internalLink"); + }); }); }); From 4eb74d06d77356231dfd77637352cbfcf0271946 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:37:00 +0100 Subject: [PATCH 21/36] ci: resolve mutation-testing's --affected against the PR base, not the literal 'main' actions/checkout leaves a detached HEAD with no local main branch (only origin/main), so turbo's --affected fell back to "assume all files have changed" on every pull_request run, sharding the entire 23-package workspace regardless of how narrow the actual diff was. Setting TURBO_SCM_BASE to the PR's base commit sha, mirroring ci.yml's own working setup, lets turbo resolve the real diff range instead of guessing. --- .github/workflows/mutation.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 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: From ea12d449dae8b0be3968b994b8b3339427b705d7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:48:09 +0100 Subject: [PATCH 22/36] test(document-cli): cover mergePptxTableCells boundaries and the doc-format union arms Adds exact-boundary tests for mergePptxTableCells's row/column overrun checks (a merge landing exactly on the table's last row/column must succeed, not throw), direct hMerge/ vMerge attribute assertions distinguishing the row-loop and column-loop merge flags from each other, and loop-boundary checks proving cells past rowSpan/colSpan are never touched. Also covers wrongDocument's "no document" wording when nothing is open at all, and the doc-format arm of wordprocessingDocument/styledWordprocessingDocument, which nothing else in this suite previously exercised through APPEND_PARAGRAPH or TOGGLE_RUN_BOLD. --- .../src/tui/state/reducer.test.ts | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 6730e0f56d..292a1f087b 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -35,6 +35,7 @@ import { appReducer, createInitialState } from "./reducer.js"; import type { AppState, CsvOpenDocument, + DocOpenDocument, DocxOpenDocument, EpubOpenDocument, MarkdownOpenDocument, @@ -82,6 +83,14 @@ function docxDocument(state: AppState): DocxOpenDocument { return doc; } +function docDocument(state: AppState): DocOpenDocument { + const doc = state.openDocument; + if (doc?.format !== "doc") { + throw new Error("expected an open doc document"); + } + return doc; +} + function odsDocument(state: AppState): OdsOpenDocument { const doc = state.openDocument; if (doc?.format !== "ods") { @@ -1641,6 +1650,47 @@ describe("appReducer markdown mutations", () => { }); }); +describe("appReducer doc (legacy Word) mutations", () => { + // wordprocessingDocument's own format union admits doc alongside docx/odt/markdown -- covered here specifically because every other member is already exercised elsewhere by name, and a mutant collapsing this one arm of the union would only ever be caught by a doc-format dispatch. + it("appends a paragraph through the same generic action docx/odt/markdown already share", () => { + const opened = openDocDocument(createDoc().toBytes()); + const before = docDocument(opened).editor.paragraphs().length; + const appended = appReducer(opened, { + type: "APPEND_PARAGRAPH", + text: "New paragraph", + styleId: undefined, + alignment: undefined, + }); + expect(appended.status?.severity).not.toBe("warning"); + expect(docDocument(appended).editor.paragraphs()).toHaveLength(before + 1); + }); + + // styledWordprocessingDocument's own format union admits doc alongside docx/odt (never markdown, which has no such fields at all -- see the markdown describe block above) -- covered here for the identical reason: doc is the one arm nothing else in this file dispatches through this specific function. + it("toggles bold on a run through the same generic action docx/odt already share", () => { + const withRun = applyAll( + [ + { + type: "APPEND_PARAGRAPH", + text: undefined, + styleId: undefined, + alignment: undefined, + }, + { type: "APPEND_RUN", blockIndex: 0, text: "Hello" }, + ], + openDocDocument(createDoc().toBytes()), + ); + const bolded = appReducer(withRun, { + type: "TOGGLE_RUN_BOLD", + blockIndex: 0, + runIndex: 0, + }); + expect(bolded.status?.severity).not.toBe("warning"); + expect(docDocument(bolded).editor.paragraphs()[0]?.runs()[0]?.bold).toBe( + true, + ); + }); +}); + describe("appReducer undo", () => { // Proves undo generalises to markdown's own live-view MarkdownEditor with zero markdown-specific reducer code beyond toUndoSnapshot's own byte<->text branch -- the same encodeMarkdownText/decodeMarkdownText round trip through the shared undo stack every other mutating action already uses. it("restores a markdown document to its paragraphs before the last edit", () => { @@ -2026,6 +2076,23 @@ describe("appReducer MERGE_SLIDE_TABLE_CELLS", () => { expect(result.status?.text).toContain("pptx or odp"); }); + // wrongDocument's own "actual" half names the real open format when there is one (covered just above), but falls back to the literal words "no document" when state.openDocument is undefined -- distinct from any real format string, so it must come from its own ternary branch rather than always compute an actual format. + it("says 'no document' rather than a format name when nothing is open at all", () => { + const result = appReducer(createInitialState(), { + type: "MERGE_SLIDE_TABLE_CELLS", + slideIndex: 0, + tableIndex: 0, + startRow: 0, + startColumn: 0, + rowSpan: 1, + colSpan: 1, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a pptx or odp document; the open document is no document", + ); + }); + it("rejects a non-integer or non-positive rowSpan/colSpan instead of merging anything", () => { const editor = createPptx(); editor.addSlide(); @@ -2086,6 +2153,135 @@ describe("appReducer MERGE_SLIDE_TABLE_CELLS", () => { expect(result.status?.text).toContain("exceeds this table's own 2 columns"); expect(result.openDocument).toBe(withTable.openDocument); }); + + // mergePptxTableCells's own row/column bounds checks compare with a strict `>`, not `>=` -- a merge landing exactly on the table's own last row/column is valid, not an overrun. rowSpan=1/colSpan=1 here also proves the "must be positive integers" guard rejects only BELOW 1, not AT 1. + it("accepts a 1x1 merge landing exactly on the table's own last row and column", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const withTable = appReducer(opened, { + type: "ADD_SLIDE_TABLE", + slideIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + rows: 2, + columns: 2, + }); + + const result = appReducer(withTable, { + type: "MERGE_SLIDE_TABLE_CELLS", + slideIndex: 0, + tableIndex: 0, + startRow: 1, + startColumn: 1, + rowSpan: 1, + colSpan: 1, + }); + expect(result.status?.severity).not.toBe("warning"); + expect(result.hasUnsavedChanges).toBe(true); + }); + + // startRow=2/rowSpan=2 on a 3-row table overruns by exactly one row -- distinguishes the real check from a mutant that flips `+` to `-` (2-2=0, which would never exceed 3 and would fall through to a table access that is merely undefined rather than out of range) or drops the whole guard block outright, both of which would surface a DIFFERENT warning than this one. + it("names the exact rowSpan/startRow/row-count in the row-overrun message", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const withTable = appReducer(opened, { + type: "ADD_SLIDE_TABLE", + slideIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + rows: 3, + columns: 2, + }); + + const result = appReducer(withTable, { + type: "MERGE_SLIDE_TABLE_CELLS", + slideIndex: 0, + tableIndex: 0, + startRow: 2, + startColumn: 0, + rowSpan: 2, + colSpan: 1, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "mergeSlideTableCells: rowSpan 2 starting at row 2 exceeds this table's own 3 rows", + ); + expect(result.openDocument).toBe(withTable.openDocument); + }); + + // A rectangle taller than 1 row but only 1 column wide: the covered cell directly below the anchor must carry verticalMerge alone, never horizontalMerge (columnOffset is always 0 in a 1-wide merge, so the "columnOffset > 0" branch must never fire), and the row just past rowSpan must be left completely untouched by the row loop. + it("sets only verticalMerge on the covered cell of a 1-column-wide, multi-row merge, and never touches the row past rowSpan", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const withTable = appReducer(opened, { + type: "ADD_SLIDE_TABLE", + slideIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + rows: 3, + columns: 2, + }); + + const merged = appReducer(withTable, { + type: "MERGE_SLIDE_TABLE_CELLS", + slideIndex: 0, + tableIndex: 0, + startRow: 0, + startColumn: 0, + rowSpan: 2, + colSpan: 1, + }); + const rows = pptxDocument(merged).editor.slides()[0]?.tables()[0]?.rows(); + const coveredBelow = rows?.[1]?.cells()[0]; + const pastRowSpan = rows?.[2]?.cells()[0]; + expect(coveredBelow?.element.attributes).toContainEqual({ + name: "vMerge", + value: "1", + }); + expect( + coveredBelow?.element.attributes.some((a) => a.name === "hMerge"), + ).toBe(false); + expect(pastRowSpan?.element.attributes).toEqual([]); + }); + + // The column-wide counterpart: a rectangle wider than 1 column but only 1 row tall must set horizontalMerge alone on its covered cell (rowOffset is always 0, so "rowOffset > 0" must never fire), and the column just past colSpan must be left completely untouched by the column loop. + it("sets only horizontalMerge on the covered cell of a 1-row-tall, multi-column merge, and never touches the column past colSpan", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const withTable = appReducer(opened, { + type: "ADD_SLIDE_TABLE", + slideIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + rows: 2, + columns: 3, + }); + + const merged = appReducer(withTable, { + type: "MERGE_SLIDE_TABLE_CELLS", + slideIndex: 0, + tableIndex: 0, + startRow: 0, + startColumn: 0, + rowSpan: 1, + colSpan: 2, + }); + const firstRowCells = pptxDocument(merged) + .editor.slides()[0] + ?.tables()[0] + ?.rows()[0] + ?.cells(); + const coveredRight = firstRowCells?.[1]; + const pastColSpan = firstRowCells?.[2]; + expect(coveredRight?.element.attributes).toContainEqual({ + name: "hMerge", + value: "1", + }); + expect( + coveredRight?.element.attributes.some((a) => a.name === "vMerge"), + ).toBe(false); + expect(pastColSpan?.element.attributes).toEqual([]); + }); }); describe("appReducer SET_SLIDE_NOTES on pptx", () => { From e56885f4f3d9b7b96e7f3e1a15690ffc4d6421ea Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:59:03 +0100 Subject: [PATCH 23/36] test(document-cli): assert exact status text for four out-of-range and wrong-doc-type warnings Pins the literal warning text for the missing-paragraph-index, missing-page-index, and markdown-wrong-document-type branches so a StringLiteral mutant on any of these messages fails the assertion instead of surviving on severity alone. --- packages/document-cli/src/tui/state/reducer.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 292a1f087b..017e4dbfee 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -646,6 +646,7 @@ describe("appReducer docx mutations", () => { runIndex: 0, }); expect(missed.status?.severity).toBe("warning"); + expect(missed.status?.text).toBe("There is no paragraph at index 7"); expect(missed.hasUnsavedChanges).toBe(false); }); @@ -727,6 +728,7 @@ describe.each(["docx", "odt"] as const)( fontFamily: "Georgia", }); expect(missed.status?.severity).toBe("warning"); + expect(missed.status?.text).toBe("There is no paragraph at index 7"); expect(missed.hasUnsavedChanges).toBe(false); }); }, @@ -1575,6 +1577,9 @@ describe("appReducer markdown mutations", () => { runIndex: 0, }); expect(underlineResult.status?.severity).toBe("warning"); + expect(underlineResult.status?.text).toBe( + "That action needs a docx, odt or doc document; the open document is markdown", + ); expect(underlineResult.hasUnsavedChanges).toBe(false); const colorResult = appReducer(opened, { @@ -2685,6 +2690,7 @@ describe("appReducer PDF item and page mutations", () => { init: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no page at index 5"); expect(result.hasUnsavedChanges).toBe(false); }); From 81a85503198c572141407425d7214bda4ce8fee6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:06:45 +0100 Subject: [PATCH 24/36] test(document-cli): cover every remaining PDF field-edit wrong-kind branch withPdfItemMatching's kindLabel string is a separate literal at every call site, so the existing FRAME/FILL/one-per-kind wrong-kind tests only killed the mutant at their own call site. Adds a data-driven case per action that had none (text's own text/position/color/ width/underline setters, rect's stroke, ellipse's frame/stroke, all three line setters, path's fill/stroke, image's frame/source, and link's frame), each asserting the exact kindLabel substring so a StringLiteral mutant on any of them fails the assertion. --- .../src/tui/state/reducer.test.ts | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 017e4dbfee..c3a618650a 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -3239,6 +3239,192 @@ describe("appReducer PDF item and page mutations", () => { expect(result.status?.severity).toBe("warning"); expect(result.status?.text).toContain("not internalLink"); }); + + // Every other SET_PDF_*_* field-edit action routes through the identical withPdfItemMatching guard, but each call site carries its OWN copy of the kindLabel string literal -- exercising the wrong-kind path through the FRAME/FILL/one representative action per kind above does not cover the same literal at a sibling action's own call site (e.g. SET_PDF_RECT_FRAME's "rect" and SET_PDF_RECT_STROKE's "rect" are two distinct AST nodes). This table drives every remaining action through the wrong-kind branch once each. + const wrongKindCases: [string, Action, string][] = [ + [ + "SET_PDF_TEXT_TEXT", + { + type: "SET_PDF_TEXT_TEXT", + pageIndex: 0, + itemIndex: RECT_INDEX, + text: "x", + }, + "not text", + ], + [ + "SET_PDF_TEXT_POSITION", + { + type: "SET_PDF_TEXT_POSITION", + pageIndex: 0, + itemIndex: RECT_INDEX, + xPt: 0, + yPt: 0, + }, + "not text", + ], + [ + "SET_PDF_TEXT_COLOR", + { + type: "SET_PDF_TEXT_COLOR", + pageIndex: 0, + itemIndex: RECT_INDEX, + color: { r: 0, g: 0, b: 0 }, + }, + "not text", + ], + [ + "SET_PDF_TEXT_WIDTH", + { + type: "SET_PDF_TEXT_WIDTH", + pageIndex: 0, + itemIndex: RECT_INDEX, + widthPt: 1, + }, + "not text", + ], + [ + "TOGGLE_PDF_TEXT_UNDERLINE", + { + type: "TOGGLE_PDF_TEXT_UNDERLINE", + pageIndex: 0, + itemIndex: RECT_INDEX, + }, + "not text", + ], + [ + "SET_PDF_RECT_STROKE", + { + type: "SET_PDF_RECT_STROKE", + pageIndex: 0, + itemIndex: TEXT_INDEX, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + }, + "not rect", + ], + [ + "SET_PDF_ELLIPSE_FRAME", + { + type: "SET_PDF_ELLIPSE_FRAME", + pageIndex: 0, + itemIndex: TEXT_INDEX, + xPt: 0, + yPt: 0, + widthPt: 1, + heightPt: 1, + }, + "not ellipse", + ], + [ + "SET_PDF_ELLIPSE_STROKE", + { + type: "SET_PDF_ELLIPSE_STROKE", + pageIndex: 0, + itemIndex: TEXT_INDEX, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + }, + "not ellipse", + ], + [ + "SET_PDF_LINE_FROM", + { + type: "SET_PDF_LINE_FROM", + pageIndex: 0, + itemIndex: TEXT_INDEX, + x1Pt: 0, + y1Pt: 0, + }, + "not line", + ], + [ + "SET_PDF_LINE_TO", + { + type: "SET_PDF_LINE_TO", + pageIndex: 0, + itemIndex: TEXT_INDEX, + x2Pt: 0, + y2Pt: 0, + }, + "not line", + ], + [ + "SET_PDF_LINE_COLOR", + { + type: "SET_PDF_LINE_COLOR", + pageIndex: 0, + itemIndex: TEXT_INDEX, + color: { r: 0, g: 0, b: 0 }, + }, + "not line", + ], + [ + "SET_PDF_PATH_FILL", + { + type: "SET_PDF_PATH_FILL", + pageIndex: 0, + itemIndex: TEXT_INDEX, + fill: { r: 0, g: 0, b: 0 }, + }, + "not path", + ], + [ + "SET_PDF_PATH_STROKE", + { + type: "SET_PDF_PATH_STROKE", + pageIndex: 0, + itemIndex: TEXT_INDEX, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + }, + "not path", + ], + [ + "SET_PDF_IMAGE_FRAME", + { + type: "SET_PDF_IMAGE_FRAME", + pageIndex: 0, + itemIndex: TEXT_INDEX, + xPt: 0, + yPt: 0, + widthPt: 1, + heightPt: 1, + }, + "not image", + ], + [ + "SET_PDF_IMAGE_SOURCE", + { + type: "SET_PDF_IMAGE_SOURCE", + pageIndex: 0, + itemIndex: TEXT_INDEX, + format: "png", + bytes: REAL_PNG_BYTES, + }, + "not image", + ], + [ + "SET_PDF_LINK_FRAME", + { + type: "SET_PDF_LINK_FRAME", + pageIndex: 0, + itemIndex: TEXT_INDEX, + xPt: 0, + yPt: 0, + widthPt: 1, + heightPt: 1, + }, + "not link", + ], + ]; + + it.each(wrongKindCases)( + "warns rather than crashing when %s targets an item of the wrong kind", + (_name, action, expectedFragment) => { + const state = pdfMultiItemState(); + const result = appReducer(state, action); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain(expectedFragment); + }, + ); }); }); From 040aaa45bc9940def5d6f22f8b63e9c0ffe82390 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:11:22 +0100 Subject: [PATCH 25/36] test(document-cli): cover INSERT_ODT_FORMULA's tree rebuild and wrong-doc guard mutableMathMlNode had no coverage at all before this: an element/text node is rebuilt as a fresh mutable tree so document-schema.js's mutable MathMlNode type-checks with no cast, and the four kinds MathML content never carries (cdata/comment/declaration/pi) collapse to the documented empty stand-in each schema variant requires. Reads the round-tripped formula back through readOdtContent/formulaOfBlock to assert on the real embedded package, plus a second case for the wrong-document-format warning. --- .../src/tui/state/reducer.test.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index c3a618650a..b07c8a5d36 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -9,6 +9,8 @@ import { createPptx, createXls, drawingOfBlock, + formulaOfBlock, + type MathMlNode, odsToXlsx, openDoc, openDocx, @@ -3428,6 +3430,76 @@ describe("appReducer PDF item and page mutations", () => { }); }); +describe("appReducer INSERT_ODT_FORMULA", () => { + it("rebuilds an element/text tree as fresh mutable objects, and collapses cdata/comment/declaration/pi nodes to their documented empty stand-ins", () => { + const opened = openOdtDocument(createOdt().toBytes()); + const mathml: MathMlNode[] = [ + { + type: "element", + tag: "mrow", + attributes: [{ name: "class", value: "unit" }], + children: [ + { type: "text", value: "a" }, + { type: "cdata" }, + { type: "comment" }, + { type: "declaration" }, + { type: "pi" }, + ], + }, + ]; + const withFormula = appReducer(opened, { + type: "INSERT_ODT_FORMULA", + mathml, + frame: { xPt: 0, yPt: 0, widthPt: 20, heightPt: 10 }, + }); + expect(withFormula.hasUnsavedChanges).toBe(true); + + const content = readOdtContent(odtDocument(withFormula).editor.toPackage()); + if (content.kind !== "wordprocessing") { + throw new Error( + `expected a wordprocessing ContentDocument, got ${content.kind}`, + ); + } + const block = content.sections + .flatMap((section) => section.blocks) + .find((candidate) => candidate.kind === "embeddedObject"); + if (block?.kind !== "embeddedObject") { + throw new Error("expected an embedded formula block"); + } + const formula = formulaOfBlock(block); + if (formula === undefined) { + throw new Error("expected the embedded object to carry a formula"); + } + const root = formula.mathml[0]; + if (root?.type !== "element") { + throw new Error("expected the root node to survive as a real element"); + } + expect(root.tag).toBe("mrow"); + expect(root.attributes).toStrictEqual([{ name: "class", value: "unit" }]); + expect(root.children).toStrictEqual([ + { type: "text", value: "a" }, + { type: "cdata", value: "" }, + { type: "comment", value: "" }, + { type: "declaration", attributes: [] }, + { type: "pi", target: "", content: "" }, + ]); + }); + + it("warns rather than crashing when the open document is not odt", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "INSERT_ODT_FORMULA", + mathml: [{ type: "element", tag: "mi", attributes: [], children: [] }], + frame: { xPt: 0, yPt: 0, widthPt: 20, heightPt: 10 }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("an odt document"); + }); +}); + describe("appReducer ADD_RECT / ADD_ELLIPSE / ADD_LINE / ADD_PATH on odp", () => { it("adds each real vector kind to an odp slide, reachable through OdpSlide.addVector -- recovered by readOdpContent as a synthetic embedded drawing block, since ContentSlide itself has no vectors array", () => { const editor = createOdp(); From 028e337e20e0800a9cf63e5802901658fb81293d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:13:30 +0100 Subject: [PATCH 26/36] test(document-cli): cover the odg ellipse/line/path vector-add cases The odg branch's own inner switch (addRect/addEllipse/addLine/addPath) had only ever been reached via ADD_RECT; each case is a separate switch-statement mutant, so nothing proved removing the ellipse/line/path cases would still pass. Adds each kind through the page's own vectors() accessor, mirroring the equivalent odp coverage already in this file. --- .../src/tui/state/reducer.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index b07c8a5d36..9eab001619 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -3616,6 +3616,49 @@ describe("appReducer ADD_RECT / ADD_ELLIPSE / ADD_LINE / ADD_PATH on odp", () => expect(withRect.hasUnsavedChanges).toBe(true); expect(odgDocument(withRect).editor.pages()[0]?.vectors()).toHaveLength(1); }); + + // The odg branch dispatches through its own inner switch (addRect/addEllipse/addLine/addPath), one case per real OdgPage method -- distinct from the ADD_RECT/ADD_ELLIPSE/ADD_LINE/ADD_PATH coverage above, which only ever reaches odg via ADD_RECT. Each case is its own switch-statement mutant, so proving the rect case works says nothing about whether removing the ellipse/line/path cases would still pass. + it("adds each real vector kind to an odg page via the page's own addEllipse/addLine/addPath", () => { + const editor = createOdg(); + editor.addPage(); + const opened = openOdgDocument(editor.toBytes()); + + const withEllipse = appReducer(opened, { + type: "ADD_ELLIPSE", + containerIndex: 0, + init: { frame: { xPt: 60, yPt: 10, widthPt: 40, heightPt: 30 } }, + }); + const withLine = appReducer(withEllipse, { + type: "ADD_LINE", + containerIndex: 0, + init: { + from: { xPt: 0, yPt: 100 }, + to: { xPt: 100, yPt: 100 }, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + }, + }); + const withPath = appReducer(withLine, { + type: "ADD_PATH", + containerIndex: 0, + init: { + frame: { xPt: 0, yPt: 150, widthPt: 50, heightPt: 50 }, + subpaths: [ + { + start: { xPt: 0, yPt: 50 }, + segments: [{ kind: "line", to: { xPt: 25, yPt: 0 } }], + closed: false, + }, + ], + }, + }); + expect(withPath.hasUnsavedChanges).toBe(true); + const vectors = odgDocument(withPath).editor.pages()[0]?.vectors(); + expect(vectors?.map((vector) => vector.kind)).toEqual([ + "ellipse", + "line", + "path", + ]); + }); }); describe("appReducer SET_VECTOR_FILL / SET_VECTOR_STROKE on odg", () => { From a4a56e66409bdf65331687f04d14415dfa3f8bfd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:16:25 +0100 Subject: [PATCH 27/36] test(document-cli): cover CANCEL_QUIT and SAVE_ERROR CANCEL_QUIT had no coverage at all -- its own confirmQuit-overlay-false write and isExiting-stays-false behaviour were unproven, and CONFIRM_QUIT's sibling write to the same overlay field was reached but never asserted. SAVE_ERROR likewise had no test: it now asserts the exact error message surfaces as status text with hasUnsavedChanges left untouched. --- .../src/tui/state/reducer.test.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 9eab001619..8350ea4de7 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -359,7 +359,13 @@ describe("appReducer navigation", () => { const asked = appReducer(dirty, { type: "REQUEST_QUIT" }); expect(asked.overlays.confirmQuit).toBe(true); expect(asked.isExiting).toBe(false); - expect(appReducer(asked, { type: "CONFIRM_QUIT" }).isExiting).toBe(true); + const confirmed = appReducer(asked, { type: "CONFIRM_QUIT" }); + expect(confirmed.isExiting).toBe(true); + expect(confirmed.overlays.confirmQuit).toBe(false); + + const cancelled = appReducer(asked, { type: "CANCEL_QUIT" }); + expect(cancelled.isExiting).toBe(false); + expect(cancelled.overlays.confirmQuit).toBe(false); }); }); @@ -454,6 +460,22 @@ describe("appReducer SAVE_SUCCESS", () => { }); }); +describe("appReducer SAVE_ERROR", () => { + it("surfaces the failure message as an error status, without touching hasUnsavedChanges", () => { + const dirty = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(dirty, { + type: "SAVE_ERROR", + message: "disk is full", + }); + expect(result.status?.severity).toBe("error"); + expect(result.status?.text).toBe("disk is full"); + expect(result.hasUnsavedChanges).toBe(dirty.hasUnsavedChanges); + }); +}); + describe("appReducer OPEN_OVERLAY / CLOSE_OVERLAY", () => { it("opens and closes the confirmClose overlay without touching any other overlay", () => { const opened = appReducer(createInitialState(), { From 5f69eebdac0b26503ce1524e131c85bc7e6d0f19 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:19:10 +0100 Subject: [PATCH 28/36] test(document-cli): cover withShape's own odg page-vs-slide wording withWideShape's identical ternary was already covered via SET_SHAPE_TEXT, but withShape is a separate function with its own copy of the same literal, reached only by SET_SHAPE_ROTATION. Adds the missing odg case so removing withShape's own page/slide branch fails too. --- .../document-cli/src/tui/state/reducer.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 8350ea4de7..0c9fde6670 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -2389,6 +2389,22 @@ describe("appReducer SET_SHAPE_ROTATION on pptx", () => { expect(result.status?.severity).toBe("warning"); expect(result.status?.text).toBe("There is no shape 3 on page 0"); }); + + // SET_SHAPE_TEXT above resolves through withWideShape, a DIFFERENT function from withShape (used only by SET_SHAPE_ROTATION) -- each has its own copy of the identical "page"/"slide" ternary, so covering one says nothing about the other. + it("warns with 'page' rather than 'slide' when SET_SHAPE_ROTATION targets a missing shape on an odg page", () => { + const editor = createOdg(); + editor.addPage(); + const opened = openOdgDocument(editor.toBytes()); + + const result = appReducer(opened, { + type: "SET_SHAPE_ROTATION", + containerIndex: 0, + shapeIndex: 3, + rotationDeg: 10, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no shape 3 on page 0"); + }); }); describe("appReducer xlsx (read-only PDF-preview) documents", () => { From 64f79dbeda9e9edf4d3c083672429d800229a127 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:24:39 +0100 Subject: [PATCH 29/36] test(document-cli): cover the standalone status/stack/run actions OPEN_FILE_ERROR, SAVE_AS_REQUEST, SET_SEARCH_QUERY, CLEAR_STATUS, DISMISS_ERROR_DETAIL and SET_RUN_TEXT had no coverage anywhere in the suite -- each is a plain state-field write with no format branching, so one direct dispatch per action proves the write. --- .../src/tui/state/reducer.test.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 0c9fde6670..2a70d97c1c 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -476,6 +476,64 @@ describe("appReducer SAVE_ERROR", () => { }); }); +describe("appReducer OPEN_FILE_ERROR", () => { + it("records the failure as an error status and populates errorDetail", () => { + const result = appReducer(createInitialState(), { + type: "OPEN_FILE_ERROR", + message: "not a valid docx", + detail: "unexpected end of zip central directory", + }); + expect(result.status?.severity).toBe("error"); + expect(result.status?.text).toBe("not a valid docx"); + expect(result.errorDetail).toStrictEqual({ + message: "not a valid docx", + detail: "unexpected end of zip central directory", + }); + }); +}); + +describe("appReducer SAVE_AS_REQUEST / SET_SEARCH_QUERY / CLEAR_STATUS / DISMISS_ERROR_DETAIL", () => { + it("pushes the saveAsPrompt screen onto the stack", () => { + const result = appReducer(createInitialState(), { + type: "SAVE_AS_REQUEST", + }); + expect(result.stack.map((screen) => screen.kind)).toEqual([ + "launcher", + "saveAsPrompt", + ]); + }); + + it("replaces the search query verbatim", () => { + const result = appReducer(createInitialState(), { + type: "SET_SEARCH_QUERY", + query: "invoice", + }); + expect(result.searchQuery).toBe("invoice"); + }); + + it("clears an existing status message", () => { + const withStatus = appReducer(createInitialState(), { + type: "OPEN_FILE_ERROR", + message: "boom", + detail: undefined, + }); + expect(withStatus.status).toBeDefined(); + const cleared = appReducer(withStatus, { type: "CLEAR_STATUS" }); + expect(cleared.status).toBeUndefined(); + }); + + it("dismisses errorDetail without touching the status message", () => { + const withError = appReducer(createInitialState(), { + type: "OPEN_FILE_ERROR", + message: "boom", + detail: "trace", + }); + const dismissed = appReducer(withError, { type: "DISMISS_ERROR_DETAIL" }); + expect(dismissed.errorDetail).toBeUndefined(); + expect(dismissed.status).toStrictEqual(withError.status); + }); +}); + describe("appReducer OPEN_OVERLAY / CLOSE_OVERLAY", () => { it("opens and closes the confirmClose overlay without touching any other overlay", () => { const opened = appReducer(createInitialState(), { @@ -659,6 +717,29 @@ describe("appReducer docx mutations", () => { expect(unbolded.hasUnsavedChanges).toBe(true); }); + it("replaces a run's text via SET_RUN_TEXT", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: undefined, + styleId: undefined, + alignment: undefined, + }, + { type: "APPEND_RUN", blockIndex: 0, text: "Hello" }, + ]); + const retyped = appReducer(state, { + type: "SET_RUN_TEXT", + blockIndex: 0, + runIndex: 0, + text: "Goodbye", + }); + expect(retyped.hasUnsavedChanges).toBe(true); + expect(docxDocument(retyped).editor.paragraphs()[0]?.runs()[0]?.text).toBe( + "Goodbye", + ); + }); + it("reports a missing run rather than throwing", () => { const state = appReducer(createInitialState(), { type: "CREATE_DOCUMENT", From 5d802fdd3bf2387ce596375cadef62e997c4904a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:28:37 +0100 Subject: [PATCH 30/36] test(document-cli): cover SET_TABLE_CELL_TEXT's happy path and both guards Nothing in the suite ever dispatched SET_TABLE_CELL_TEXT before this: the missing-table and missing-cell warnings, and the actual cell-text write itself (verified through a real readDocxContent round trip), all had zero coverage. --- .../src/tui/state/reducer.test.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 2a70d97c1c..2a5865cfc7 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -1040,6 +1040,72 @@ describe("appReducer APPEND_TABLE and MERGE_TABLE_CELLS on docx/odt", () => { }); }); +describe("appReducer SET_TABLE_CELL_TEXT", () => { + it("replaces a real docx table cell's text in place", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { type: "APPEND_TABLE", rows: 2, columns: 2 }, + ]); + const edited = appReducer(state, { + type: "SET_TABLE_CELL_TEXT", + tableIndex: 0, + row: 1, + column: 1, + text: "Total", + }); + expect(edited.hasUnsavedChanges).toBe(true); + const content = readDocxContent(docxDocument(edited).editor.toPackage()); + if (content.kind !== "wordprocessing") { + throw new Error( + `expected a wordprocessing ContentDocument, got ${content.kind}`, + ); + } + const tableBlock = content.sections[0]?.blocks[0]; + if (tableBlock?.kind !== "table") { + throw new Error(`expected a table block, got ${tableBlock?.kind}`); + } + const cellText = tableBlock.rows[1]?.cells[1]?.blocks + .flatMap((block) => (block.kind === "paragraph" ? block.runs : [])) + .map((run) => run.text) + .join(""); + expect(cellText).toBe("Total"); + }); + + it("warns rather than crashing for a table index that does not exist", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "SET_TABLE_CELL_TEXT", + tableIndex: 0, + row: 0, + column: 0, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no table at index 0"); + }); + + it("warns rather than crashing for a row/column that does not exist", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { type: "APPEND_TABLE", rows: 2, columns: 2 }, + ]); + const result = appReducer(state, { + type: "SET_TABLE_CELL_TEXT", + tableIndex: 0, + row: 5, + column: 0, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "There is no cell at row 5, column 0 of table 0", + ); + }); +}); + describe("appReducer SET_LIST_ITEM_TEXT on odt", () => { it("replaces a real list item's text and the change round-trips through re-decoding the package", () => { const editor = createOdt(); From 9a1757b80cae65cfece276142063c17e33cd4fba Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:34:18 +0100 Subject: [PATCH 31/36] test(document-cli): cover ADD_PDF_TEXT and INSERT_DOCX_FORMULA ADD_PDF_TEXT (add a second real text item, round-tripped through toBytes()/openPdf()) and INSERT_DOCX_FORMULA (both the written-OMML happy path, read back through readDocxContent's own embedded-formula splice, and the no-OMML-content warning branch) had no coverage anywhere in the suite before this, plus INSERT_DOCX_FORMULA's missing-paragraph and wrong-document-format guards. --- .../src/tui/state/reducer.test.ts | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 2a5865cfc7..387c1b43e0 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -2708,6 +2708,31 @@ describe("appReducer PDF item and page mutations", () => { expect(reopenedItem.yPt).toBeCloseTo(60, 0); }); + it("adds a text item via ADD_PDF_TEXT, present after a toBytes()/openPdf() round trip", () => { + const opened = openPdfDocument(pdfTestBytes()); + + const withText = appReducer(opened, { + type: "ADD_PDF_TEXT", + pageIndex: 0, + init: { + xPt: 15, + yPt: 25, + text: "Second", + font: { family: "Helvetica", weight: "normal", style: "normal" }, + sizePt: 14, + color: { r: 0, g: 0, b: 0 }, + }, + }); + expect(pdfDocument(withText).editor.page(0)?.items()).toHaveLength(2); + + const reopened = openPdf(pdfDocument(withText).editor.toBytes()); + const items = reopened.page(0)?.items() ?? []; + const second = items.find( + (item) => item.kind === "text" && item.text === "Second", + ); + expect(second).toBeDefined(); + }); + it("adds a rect via ADD_PDF_RECT, present after a toBytes()/openPdf() round trip", () => { const opened = openPdfDocument(pdfTestBytes()); @@ -3685,6 +3710,120 @@ describe("appReducer INSERT_ODT_FORMULA", () => { }); }); +describe("appReducer INSERT_DOCX_FORMULA", () => { + it("writes a real OMML equation, read back as an embedded formula block through readDocxContent", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: undefined, + styleId: undefined, + alignment: undefined, + }, + ]); + const mathml: MathMlNode[] = [ + { + type: "element", + tag: "mi", + attributes: [], + children: [{ type: "text", value: "x" }], + }, + ]; + const withFormula = appReducer(state, { + type: "INSERT_DOCX_FORMULA", + blockIndex: 0, + mathml, + }); + expect(withFormula.hasUnsavedChanges).toBe(true); + expect(withFormula.status?.severity).not.toBe("warning"); + + const content = readDocxContent( + docxDocument(withFormula).editor.toPackage(), + ); + if (content.kind !== "wordprocessing") { + throw new Error( + `expected a wordprocessing ContentDocument, got ${content.kind}`, + ); + } + const block = content.sections + .flatMap((section) => section.blocks) + .find((candidate) => candidate.kind === "embeddedObject"); + if (block?.kind !== "embeddedObject") { + throw new Error("expected an embedded formula block"); + } + const formula = formulaOfBlock(block); + expect(formula?.mathml[0]).toStrictEqual({ + type: "element", + tag: "mi", + attributes: [], + children: [{ type: "text", value: "x" }], + }); + }); + + it("warns instead of writing an empty paragraph when the formula produces no OMML content", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: undefined, + styleId: undefined, + alignment: undefined, + }, + ]); + const withFormula = appReducer(state, { + type: "INSERT_DOCX_FORMULA", + blockIndex: 0, + mathml: [], + }); + expect(withFormula.status?.severity).toBe("warning"); + expect(withFormula.status?.text).toBe( + "The formula produced no OMML content and was not written", + ); + + const content = readDocxContent( + docxDocument(withFormula).editor.toPackage(), + ); + if (content.kind !== "wordprocessing") { + throw new Error( + `expected a wordprocessing ContentDocument, got ${content.kind}`, + ); + } + expect( + content.sections + .flatMap((section) => section.blocks) + .some((candidate) => candidate.kind === "embeddedObject"), + ).toBe(false); + }); + + it("warns rather than crashing for a paragraph index that does not exist", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "INSERT_DOCX_FORMULA", + blockIndex: 7, + mathml: [{ type: "element", tag: "mi", attributes: [], children: [] }], + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no paragraph at index 7"); + }); + + it("warns rather than crashing when the open document is not docx", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "odt", + }); + const result = appReducer(created, { + type: "INSERT_DOCX_FORMULA", + blockIndex: 0, + mathml: [{ type: "element", tag: "mi", attributes: [], children: [] }], + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("a docx document"); + }); +}); + describe("appReducer ADD_RECT / ADD_ELLIPSE / ADD_LINE / ADD_PATH on odp", () => { it("adds each real vector kind to an odp slide, reachable through OdpSlide.addVector -- recovered by readOdpContent as a synthetic embedded drawing block, since ContentSlide itself has no vectors array", () => { const editor = createOdp(); From 4c1aa228f9d7a2555558746da3f5da13f9d3ab0f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:39:57 +0100 Subject: [PATCH 32/36] test(document-cli): cover six previously untested slide/page/shape actions None of ADD_SLIDE, ADD_PAGE, ADD_TEXTBOX, ADD_IMAGE, SET_SHAPE_FRAME or SET_SHEET_PRINT_SETTINGS had any coverage anywhere in the suite. Adds a real happy-path dispatch per action on pptx and/or odg, plus the missing-page/slide and wrong-document-format guards for the two dual-branch actions (ADD_TEXTBOX, ADD_IMAGE). --- .../src/tui/state/reducer.test.ts | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 387c1b43e0..6be3e0ca93 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -4088,3 +4088,249 @@ describe("appReducer diagnostics and selection", () => { expect(state.selection).toEqual({ bodyList: 4, "slideDetail:2": 1 }); }); }); + +describe("appReducer ADD_SLIDE / ADD_PAGE", () => { + it("appends a real slide to a pptx presentation", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const withSlide = appReducer(opened, { type: "ADD_SLIDE" }); + expect(withSlide.hasUnsavedChanges).toBe(true); + expect(pptxDocument(withSlide).editor.slides()).toHaveLength(2); + }); + + it("warns rather than crashing when the open document is neither pptx, odp nor ppt", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { type: "ADD_SLIDE" }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("a pptx or odp document"); + }); + + it("appends a real page to an odg drawing", () => { + const editor = createOdg(); + editor.addPage(); + const opened = openOdgDocument(editor.toBytes()); + const withPage = appReducer(opened, { type: "ADD_PAGE" }); + expect(withPage.hasUnsavedChanges).toBe(true); + expect(odgDocument(withPage).editor.pages()).toHaveLength(2); + }); + + it("warns rather than crashing when ADD_PAGE targets a non-odg document", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { type: "ADD_PAGE" }); + expect(result.status?.severity).toBe("warning"); + }); +}); + +describe("appReducer ADD_TEXTBOX / ADD_IMAGE / SET_SHAPE_FRAME on pptx and odg", () => { + it("adds a real text box to a pptx slide", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const withBox = appReducer(opened, { + type: "ADD_TEXTBOX", + containerIndex: 0, + frame: { xPt: 10, yPt: 10, widthPt: 100, heightPt: 30 }, + text: "Caption", + }); + expect(withBox.hasUnsavedChanges).toBe(true); + const shape = pptxDocument(withBox).editor.slides()[0]?.shapes()[0]; + expect(shape?.text).toBe("Caption"); + }); + + it("adds a real text box to an odg page", () => { + const editor = createOdg(); + editor.addPage(); + const opened = openOdgDocument(editor.toBytes()); + const withBox = appReducer(opened, { + type: "ADD_TEXTBOX", + containerIndex: 0, + frame: { xPt: 10, yPt: 10, widthPt: 100, heightPt: 30 }, + text: "Caption", + }); + expect(withBox.hasUnsavedChanges).toBe(true); + const shape = odgDocument(withBox).editor.pages()[0]?.shapes()[0]; + expect(shape?.text).toBe("Caption"); + }); + + it("warns rather than crashing when ADD_TEXTBOX targets a missing odg page", () => { + const editor = createOdg(); + editor.addPage(); + const opened = openOdgDocument(editor.toBytes()); + const result = appReducer(opened, { + type: "ADD_TEXTBOX", + containerIndex: 4, + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no page at index 4"); + }); + + it("warns rather than crashing when ADD_TEXTBOX targets a missing pptx slide", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const result = appReducer(opened, { + type: "ADD_TEXTBOX", + containerIndex: 4, + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no slide at index 4"); + }); + + it("warns rather than crashing when ADD_TEXTBOX targets a document with no shape host at all", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "ADD_TEXTBOX", + containerIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("a pptx, odp, ppt or odg document"); + }); + + it("adds a real image to a pptx slide", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const withImage = appReducer(opened, { + type: "ADD_IMAGE", + containerIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 20, heightPt: 20 }, + format: "png", + bytes: PNG_BYTES, + altText: undefined, + }); + expect(withImage.hasUnsavedChanges).toBe(true); + expect(pptxDocument(withImage).editor.slides()[0]?.shapes()).toHaveLength( + 1, + ); + }); + + it("adds a real image to an odg page", () => { + const editor = createOdg(); + editor.addPage(); + const opened = openOdgDocument(editor.toBytes()); + const withImage = appReducer(opened, { + type: "ADD_IMAGE", + containerIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 20, heightPt: 20 }, + format: "png", + bytes: PNG_BYTES, + altText: undefined, + }); + expect(withImage.hasUnsavedChanges).toBe(true); + expect(odgDocument(withImage).editor.pages()[0]?.shapes()).toHaveLength(1); + }); + + it("warns rather than crashing when ADD_IMAGE targets a missing odg page", () => { + const editor = createOdg(); + editor.addPage(); + const opened = openOdgDocument(editor.toBytes()); + const result = appReducer(opened, { + type: "ADD_IMAGE", + containerIndex: 4, + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + format: "png", + bytes: PNG_BYTES, + altText: undefined, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no page at index 4"); + }); + + it("warns rather than crashing when ADD_IMAGE targets a document with no shape host at all", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "ADD_IMAGE", + containerIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + format: "png", + bytes: PNG_BYTES, + altText: undefined, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("a pptx, odp or odg document"); + }); + + it("moves a real pptx shape via SET_SHAPE_FRAME", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + const withBox = appReducer(opened, { + type: "ADD_TEXTBOX", + containerIndex: 0, + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + text: "x", + }); + const withFrame = appReducer(withBox, { + type: "SET_SHAPE_FRAME", + containerIndex: 0, + shapeIndex: 0, + frame: { xPt: 5, yPt: 6, widthPt: 20, heightPt: 30 }, + }); + expect(withFrame.hasUnsavedChanges).toBe(true); + const shape = pptxDocument(withFrame).editor.slides()[0]?.shapes()[0]; + expect(shape?.frame).toStrictEqual({ + xPt: 5, + yPt: 6, + widthPt: 20, + heightPt: 30, + }); + }); +}); + +describe("appReducer SET_SHEET_PRINT_SETTINGS", () => { + it("sets a real sheet's print settings on an ods document", () => { + const opened = openOdsDocument(createOds().toBytes()); + const settings = { + pageSize: { widthPt: 595, heightPt: 842 }, + margins: { topPt: 20, rightPt: 20, bottomPt: 20, leftPt: 20 }, + gridlines: true, + headers: true, + pageOrder: "downThenOver" as const, + }; + const withSettings = appReducer(opened, { + type: "SET_SHEET_PRINT_SETTINGS", + sheetIndex: 0, + printSettings: settings, + }); + expect(withSettings.hasUnsavedChanges).toBe(true); + expect( + odsDocument(withSettings).editor.sheets()[0]?.printSettings, + ).toStrictEqual(settings); + }); + + it("warns rather than crashing for a sheet index that does not exist", () => { + const opened = openOdsDocument(createOds().toBytes()); + const result = appReducer(opened, { + type: "SET_SHEET_PRINT_SETTINGS", + sheetIndex: 5, + printSettings: { + pageSize: { widthPt: 595, heightPt: 842 }, + margins: { topPt: 20, rightPt: 20, bottomPt: 20, leftPt: 20 }, + gridlines: false, + headers: false, + pageOrder: "downThenOver", + }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no sheet at index 5"); + }); +}); From f38153ff4fea50616e927cda4468922867cb79ec Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:10:08 +0100 Subject: [PATCH 33/36] refactor(document-cli): drop the redundant markdown branch from APPEND_PARAGRAPH MarkdownEditor.body.appendParagraph accepts the identical wordprocessing ParagraphInit shape docx/odt already pass, silently ignoring the `alignment` field it has no concept of, so the markdown-specific early return that built a narrower init object was dead code producing an identical result either way. Also replaces INSERT_DOCX_FORMULA's `written` holder with a definite assignment declaration: mutate()'s own `apply` callback always runs synchronously before the flag is read, so the previous placeholder initial value could never actually be observed. --- packages/document-cli/src/tui/state/reducer.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/document-cli/src/tui/state/reducer.ts b/packages/document-cli/src/tui/state/reducer.ts index 64f23c6bbe..124dbb6329 100644 --- a/packages/document-cli/src/tui/state/reducer.ts +++ b/packages/document-cli/src/tui/state/reducer.ts @@ -1012,20 +1012,13 @@ export function appReducer(state: AppState, action: Action): AppState { selection: { ...state.selection, [action.key]: action.index }, }; - // `alignment` is set through the shared body.appendParagraph call for docx/odt, but MarkdownParagraphInit has no alignment field at all (CommonMark/GFM has no per-paragraph alignment construct) -- so a markdown document drops it here rather than the wordprocessing union call silently disagreeing about which ParagraphInit shape it is. + // MarkdownParagraphInit has no alignment field at all (CommonMark/GFM has no per-paragraph alignment construct), but MarkdownEditor.body.appendParagraph accepts the identical wordprocessing ParagraphInit shape as docx/odt and simply ignores the field it does not model -- so one call, with `alignment` always present, covers every wordprocessingDocument format with no format-specific branch. case "APPEND_PARAGRAPH": { const doc = wordprocessingDocument(state); if (doc === undefined) { return wrongDocument(state, "a docx, odt or markdown document"); } return mutate(state, doc, () => { - if (doc.format === "markdown") { - doc.editor.body.appendParagraph({ - text: action.text, - styleId: action.styleId, - }); - return; - } doc.editor.body.appendParagraph({ text: action.text, styleId: action.styleId, @@ -1358,12 +1351,12 @@ export function appReducer(state: AppState, action: Action): AppState { `There is no paragraph at index ${action.blockIndex}`, ); } - // Same holder reason as `merge` above: the write happens inside the mutate callback. - const omml = { written: true }; + // Unlike `merge` above, this assignment is unconditional -- mutate()'s own `apply` always runs synchronously before it returns, so `written` is always set by the time it is read below. A definite-assignment declaration (no initial value at all) says so directly, rather than giving it a placeholder literal that can never actually be observed. + let written!: boolean; const nextState = mutate(state, doc, () => { - omml.written = paragraph.appendOfficeMath(action.mathml).written; + written = paragraph.appendOfficeMath(action.mathml).written; }); - return omml.written + return written ? nextState : withStatus( nextState, From deb93d4e900ae1f70aa6e07bc646e2469bf8c991 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:10:34 +0100 Subject: [PATCH 34/36] test(document-cli): close mutation-coverage gaps across reducer.ts's action handlers Adds real assertions for warning paths and edge cases that previously ran with no test at all, or only checked severity without pinning the exact message: withRun/withStyledRun's own wrongDocument and missing-run branches, SET_PARAGRAPH_ALIGNMENT and APPEND_RUN's own warning paths, TOGGLE_RUN_UNDERLINE/SET_RUN_COLOR's real mutation behaviour, a table cell that already carries more than one run, withShape/withWideShape/withSheet's own wrongDocument branches, every odt-only list action's non-wordprocessing wrongDocument branch and ADD_LIST_ITEM's own odt code path, withPdfPage/withPdfItemMatching and REMOVE_PDF_ITEM's own wrongDocument and missing-item branches, a real internalLink PDF item built through pdf-codec's writePdf/openPdf round trip (PdfPage has no way to append one directly), the odg ADD_RECT case falling through into ADD_ELLIPSE's own vector kind, ADD_SLIDE_TABLE/MERGE_SLIDE_TABLE_CELLS/SET_CELL_FORMULA/MERGE_CELLS/ ADD_RECT's own exact out-of-range messages, SET_VECTOR_STROKE's own wrongDocument branch, SET_METADATA's exact wrongDocument text, UNDO's two distinct "nothing to undo" code paths and its own status text, and REQUEST_CLOSE resetting searchQuery back to empty. --- .../src/tui/state/reducer.test.ts | 589 +++++++++++++++++- 1 file changed, 587 insertions(+), 2 deletions(-) diff --git a/packages/document-cli/src/tui/state/reducer.test.ts b/packages/document-cli/src/tui/state/reducer.test.ts index 6be3e0ca93..8674c49a64 100644 --- a/packages/document-cli/src/tui/state/reducer.test.ts +++ b/packages/document-cli/src/tui/state/reducer.test.ts @@ -10,6 +10,8 @@ import { createXls, drawingOfBlock, formulaOfBlock, + LAYOUT_FORMAT_VERSION, + type LayoutDocument, type MathMlNode, odsToXlsx, openDoc, @@ -29,6 +31,7 @@ import { readOdtContent, readPdf, readPptxContent, + writePdf, xlsxToPdf, } from "documents.js"; import { describe, expect, it } from "vitest"; @@ -383,6 +386,7 @@ describe("appReducer document lifecycle", () => { expect(state.stack.map((screen) => screen.kind)).toEqual([expectedKind]); expect(state.openDocument?.format).toBe(action.format); expect(state.hasUnsavedChanges).toBe(false); + expect(state.status?.text).toBe(`New ${action.format} document`); } }); @@ -425,6 +429,9 @@ describe("appReducer SAVE_SUCCESS", () => { path: "/tmp/orphan.docx", }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "Saved, but there is no open document to record the path against", + ); expect(result.hasUnsavedChanges).toBe(false); }); @@ -555,6 +562,7 @@ describe("appReducer REQUEST_CLOSE / CONFIRM_CLOSE / CANCEL_CLOSE", () => { it("says so when there is no open document to close", () => { const result = appReducer(createInitialState(), { type: "REQUEST_CLOSE" }); expect(result.status?.severity).toBe("info"); + expect(result.status?.text).toBe("There is no open document to close"); }); it("closes immediately, with no confirmation overlay, when there are no unsaved changes", () => { @@ -562,9 +570,15 @@ describe("appReducer REQUEST_CLOSE / CONFIRM_CLOSE / CANCEL_CLOSE", () => { type: "CREATE_DOCUMENT", format: "docx", }); - const requested = appReducer(created, { type: "REQUEST_CLOSE" }); + const withQuery = appReducer(created, { + type: "SET_SEARCH_QUERY", + query: "leftover search", + }); + const requested = appReducer(withQuery, { type: "REQUEST_CLOSE" }); expect(requested.openDocument).toBeUndefined(); expect(requested.overlays.confirmClose).toBe(false); + // closeDocument resets searchQuery back to empty rather than carrying a stale search over into whatever gets opened next. + expect(requested.searchQuery).toBe(""); }); it("opens the confirmClose overlay instead of closing outright when there are unsaved changes", () => { @@ -670,6 +684,9 @@ describe("appReducer SET_METADATA", () => { overrides: { title: "x" }, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs an editable document; the open document is no document", + ); expect(result.hasUnsavedChanges).toBe(false); }); }); @@ -769,6 +786,176 @@ describe("appReducer docx mutations", () => { expect(warned.status?.severity).toBe("warning"); expect(warned.hasUnsavedChanges).toBe(false); }); + + // withRun's own wrongDocument path -- distinct from withStyledRun's (exercised elsewhere against markdown), since SET_RUN_TEXT/TOGGLE_RUN_BOLD/TOGGLE_RUN_ITALIC resolve through the wider wordprocessingDocument union, not styledWordprocessingDocument. + it("warns rather than mutating when SET_RUN_TEXT targets a non-wordprocessing document", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "ods", + }); + const result = appReducer(state, { + type: "SET_RUN_TEXT", + blockIndex: 0, + runIndex: 0, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); + }); + + // withRun's own "no run at index" path: the paragraph exists (created via APPEND_PARAGRAPH) but has no runs at all, unlike the "no paragraph" case already covered above. + it("reports a missing run, not a missing paragraph, when the paragraph exists but has no runs", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: undefined, + styleId: undefined, + alignment: undefined, + }, + ]); + const result = appReducer(state, { + type: "TOGGLE_RUN_ITALIC", + blockIndex: 0, + runIndex: 0, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("Paragraph 0 has no run at index 0"); + // The warning path returns the state unchanged rather than a new mutated copy. + expect(result.openDocument).toBe(state.openDocument); + }); + + // withStyledRun's own "no run at index" path (TOGGLE_RUN_UNDERLINE/SET_RUN_COLOR/etc resolve through styledWordprocessingDocument, not wordprocessingDocument, so this is a genuinely separate code path from the withRun test above). + it("reports a missing run for TOGGLE_RUN_UNDERLINE when the paragraph has no runs", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: undefined, + styleId: undefined, + alignment: undefined, + }, + ]); + const result = appReducer(state, { + type: "TOGGLE_RUN_UNDERLINE", + blockIndex: 0, + runIndex: 0, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("Paragraph 0 has no run at index 0"); + expect(result.openDocument).toBe(state.openDocument); + }); + + // TOGGLE_RUN_UNDERLINE and SET_RUN_COLOR have no real, positive test anywhere else -- the markdown describe below only exercises their wrongDocument branch. + it("toggles a real run's underline and sets its colour through the live editor", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: undefined, + styleId: undefined, + alignment: undefined, + }, + { type: "APPEND_RUN", blockIndex: 0, text: "Hello" }, + ]); + const run = docxDocument(state).editor.paragraphs()[0]?.runs()[0]; + if (run === undefined) { + throw new Error("expected an appended run"); + } + expect(run.underline).toBe(false); + + const underlined = appReducer(state, { + type: "TOGGLE_RUN_UNDERLINE", + blockIndex: 0, + runIndex: 0, + }); + expect(underlined.hasUnsavedChanges).toBe(true); + expect(run.underline).toBe(true); + + const coloured = appReducer(underlined, { + type: "SET_RUN_COLOR", + blockIndex: 0, + runIndex: 0, + color: { r: 1, g: 0, b: 0 }, + }); + expect(coloured.hasUnsavedChanges).toBe(true); + expect(run.color).toStrictEqual({ r: 1, g: 0, b: 0 }); + }); + + // SET_PARAGRAPH_ALIGNMENT has no positive test anywhere else -- the markdown describe block only ever exercises its wrongDocument branch (MarkdownParagraph has no alignment field at all). + it("sets a real paragraph's alignment through the live editor", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { + type: "APPEND_PARAGRAPH", + text: "Hello", + styleId: undefined, + alignment: undefined, + }, + ]); + const paragraph = docxDocument(state).editor.paragraphs()[0]; + if (paragraph === undefined) { + throw new Error("expected an appended paragraph"); + } + expect(paragraph.alignment).toBeUndefined(); + + const aligned = appReducer(state, { + type: "SET_PARAGRAPH_ALIGNMENT", + blockIndex: 0, + alignment: "center", + }); + expect(aligned.hasUnsavedChanges).toBe(true); + expect(paragraph.alignment).toBe("center"); + }); + + it("reports a missing paragraph for SET_PARAGRAPH_ALIGNMENT rather than throwing", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(state, { + type: "SET_PARAGRAPH_ALIGNMENT", + blockIndex: 7, + alignment: "center", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no paragraph at index 7"); + expect(result.openDocument).toBe(state.openDocument); + }); + + // APPEND_RUN's own wrongDocument/no-paragraph paths -- every other use of APPEND_RUN in this file is setup for a further action, never a direct assertion on its own warning paths. + it("warns rather than mutating when APPEND_RUN targets a non-wordprocessing document", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "ods", + }); + const result = appReducer(state, { + type: "APPEND_RUN", + blockIndex: 0, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); + }); + + it("reports a missing paragraph for APPEND_RUN rather than throwing", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(state, { + type: "APPEND_RUN", + blockIndex: 7, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no paragraph at index 7"); + expect(result.openDocument).toBe(state.openDocument); + }); }); describe.each(["docx", "odt"] as const)( @@ -852,6 +1039,8 @@ describe("appReducer APPEND_TABLE and MERGE_TABLE_CELLS on docx/odt", () => { merge: { startRow: 0, startColumn: 0, rowSpan: 2, colSpan: 2 }, }); expect(withTable.hasUnsavedChanges).toBe(true); + // A docx table genuinely supports merging, unlike markdown's own -- this must not carry the "unsupported" warning markdown's APPEND_TABLE+merge gets below. + expect(withTable.status?.severity).not.toBe("warning"); const content = readDocxContent(docxDocument(withTable).editor.toPackage()); if (content.kind !== "wordprocessing") { @@ -1036,6 +1225,7 @@ describe("appReducer APPEND_TABLE and MERGE_TABLE_CELLS on docx/odt", () => { colSpan: 1, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no table at index 3"); expect(result.hasUnsavedChanges).toBe(false); }); }); @@ -1104,6 +1294,35 @@ describe("appReducer SET_TABLE_CELL_TEXT", () => { "There is no cell at row 5, column 0 of table 0", ); }); + + // setTextContainerText's "already has a first run" branch: a freshly-appended table cell has zero runs, so the tests above only ever exercise the "no first run yet" branch (paragraph.appendRun). Giving the cell two runs up front proves the write path replaces the first run's text AND removes every extra run, rather than just setting the first and leaving the rest stale. + it("replaces the first run's text and removes every extra run when a cell already has more than one", () => { + const state = applyAll([ + { type: "CREATE_DOCUMENT", format: "docx" }, + { type: "APPEND_TABLE", rows: 1, columns: 1 }, + ]); + const table = docxDocument(state).editor.tables()[0]; + const cell = table?.rows()[0]?.cells()[0]; + if (cell === undefined) { + throw new Error("expected the appended cell"); + } + const paragraph = cell.paragraphs()[0] ?? cell.appendParagraph(); + paragraph.appendRun({ text: "one" }); + paragraph.appendRun({ text: "two" }); + paragraph.appendRun({ text: "three" }); + expect(paragraph.runs()).toHaveLength(3); + + const edited = appReducer(state, { + type: "SET_TABLE_CELL_TEXT", + tableIndex: 0, + row: 0, + column: 0, + text: "Replaced", + }); + expect(edited.hasUnsavedChanges).toBe(true); + expect(paragraph.runs()).toHaveLength(1); + expect(paragraph.runs()[0]?.text).toBe("Replaced"); + }); }); describe("appReducer SET_LIST_ITEM_TEXT on odt", () => { @@ -1145,6 +1364,7 @@ describe("appReducer SET_LIST_ITEM_TEXT on odt", () => { text: "x", }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no list at index 5"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -1162,6 +1382,9 @@ describe("appReducer SET_LIST_ITEM_TEXT on odt", () => { text: "x", }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + `List ${blockIndex} has no item at index 3`, + ); expect(result.hasUnsavedChanges).toBe(false); }); @@ -1177,8 +1400,29 @@ describe("appReducer SET_LIST_ITEM_TEXT on odt", () => { text: "x", }); expect(warned.status?.severity).toBe("warning"); + expect(warned.status?.text).toBe( + "That action needs an odt document (lists are an odt-only concept); the open document is docx", + ); expect(warned.hasUnsavedChanges).toBe(false); }); + + // The docx test above is wordprocessing but not odt, so it only ever reaches the SECOND, odt-only wrongDocument check. This one is not wordprocessing at all, reaching the FIRST, wider check instead. + it("warns instead of mutating when the open document is not a wordprocessing document at all", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "ods", + }); + const warned = appReducer(state, { + type: "SET_LIST_ITEM_TEXT", + blockIndex: 0, + itemIndex: 0, + text: "x", + }); + expect(warned.status?.severity).toBe("warning"); + expect(warned.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); + }); }); describe("appReducer ADD_LIST_ITEM on docx", () => { @@ -1242,6 +1486,54 @@ describe("appReducer ADD_LIST_ITEM on docx", () => { expect(result.status?.text).toContain("not part of a list"); expect(result.hasUnsavedChanges).toBe(false); }); + + it("warns instead of mutating when the open document is not a wordprocessing document at all", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "ods", + }); + const result = appReducer(state, { + type: "ADD_LIST_ITEM", + blockIndex: 0, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); + }); + + // odt's own ADD_LIST_ITEM branch is genuinely separate code from the docx/markdown branch tested above (a real OdtList.addItem(), not a flat paragraph-copy) -- despite the docx describe block's own comment claiming the sibling ADD_LIST/INDENT_LIST_ITEM tests already cover it, neither of those ever dispatches ADD_LIST_ITEM itself. + it("appends a real item to an existing odt list", () => { + const editor = createOdt(); + editor.body.appendList().addItem().appendParagraph({ text: "first" }); + const opened = openOdtDocument(editor.toBytes()); + const blockIndex = odtDocument(opened).editor.lists().length - 1; + + const added = appReducer(opened, { + type: "ADD_LIST_ITEM", + blockIndex, + text: "second", + }); + expect(added.hasUnsavedChanges).toBe(true); + const items = odtDocument(added).editor.lists()[blockIndex]?.items(); + expect(items?.map((item) => item.text)).toEqual(["first", "second"]); + }); + + it("warns rather than crashing when ADD_LIST_ITEM targets an odt list index that does not exist", () => { + const editor = createOdt(); + editor.body.appendList().addItem().appendParagraph({ text: "only" }); + const opened = openOdtDocument(editor.toBytes()); + + const result = appReducer(opened, { + type: "ADD_LIST_ITEM", + blockIndex: 5, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no list at index 5"); + expect(result.hasUnsavedChanges).toBe(false); + }); }); describe("appReducer ADD_LIST on odt", () => { @@ -1276,6 +1568,18 @@ describe("appReducer ADD_LIST on odt", () => { expect(result.status?.text).toContain("odt"); expect(result.hasUnsavedChanges).toBe(false); }); + + it("warns instead of mutating when the open document is not a wordprocessing document at all", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "ods", + }); + const result = appReducer(state, { type: "ADD_LIST" }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); + }); }); describe("appReducer INDENT_LIST_ITEM on odt", () => { @@ -1342,6 +1646,7 @@ describe("appReducer INDENT_LIST_ITEM on odt", () => { itemIndex: 0, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no list at index 5"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -1356,8 +1661,27 @@ describe("appReducer INDENT_LIST_ITEM on odt", () => { itemIndex: 0, }); expect(warned.status?.severity).toBe("warning"); + expect(warned.status?.text).toBe( + "That action needs an odt document (lists are an odt-only concept); the open document is docx", + ); expect(warned.hasUnsavedChanges).toBe(false); }); + + it("warns instead of mutating when the open document is not a wordprocessing document at all", () => { + const state = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "ods", + }); + const result = appReducer(state, { + type: "INDENT_LIST_ITEM", + blockIndex: 0, + itemIndex: 0, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); + }); }); describe("appReducer ods mutations", () => { @@ -1383,6 +1707,25 @@ describe("appReducer ods mutations", () => { } expect(sheet.cell(2, 3).value).toEqual({ kind: "string", value: "Total" }); }); + + // withSheet's own wrongDocument path -- every other withSheet test (SET_CELL_VALUE, SET_SHEET_PRINT_SETTINGS) only exercises the "no sheet at that index" branch against an already-open spreadsheet, never the "not a spreadsheet at all" branch. + it("warns rather than crashing when SET_CELL_VALUE targets a non-spreadsheet document", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "SET_CELL_VALUE", + sheetIndex: 0, + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs an ods or xls document; the open document is docx", + ); + }); }); describe("appReducer SET_CELL_FORMULA on ods", () => { @@ -1459,6 +1802,7 @@ describe("appReducer SET_CELL_FORMULA on ods", () => { formula: "of:=1", }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no sheet at index 4"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -1664,6 +2008,7 @@ describe("appReducer MERGE_CELLS on ods", () => { colSpan: 1, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no sheet at index 4"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -1783,6 +2128,9 @@ describe("appReducer markdown mutations", () => { alignment: "center", }); expect(alignmentResult.status?.severity).toBe("warning"); + expect(alignmentResult.status?.text).toBe( + "That action needs a docx or odt document; the open document is markdown", + ); const imageResult = appReducer(opened, { type: "INSERT_PARAGRAPH_IMAGE", @@ -1794,6 +2142,9 @@ describe("appReducer markdown mutations", () => { altText: undefined, }); expect(imageResult.status?.severity).toBe("warning"); + expect(imageResult.status?.text).toBe( + "That action needs a docx or odt document; the open document is markdown", + ); }); // GFM tables have no cell-merge concept at all -- MarkdownTable has no mergeCells -- so a merge requested alongside table creation still creates the (unmerged) table and reports why the merge didn't happen, rather than silently dropping the merge or refusing to create the table. @@ -1822,6 +2173,9 @@ describe("appReducer markdown mutations", () => { alignment: undefined, }); expect(warned.status?.severity).toBe("warning"); + expect(warned.status?.text).toBe( + "That action needs a docx, odt or markdown document; the open document is ods", + ); expect(warned.hasUnsavedChanges).toBe(false); }); }); @@ -1885,6 +2239,7 @@ describe("appReducer undo", () => { expect(undone.undoStack).toHaveLength(0); expect(markdownDocument(undone).editor.paragraphs()[1]?.text).toBe("Two"); expect(undone.hasUnsavedChanges).toBe(true); + expect(undone.status?.text).toBe("Undone"); }); it("restores the snapshot taken before the last mutation", () => { @@ -1921,9 +2276,17 @@ describe("appReducer undo", () => { }); const undone = appReducer(created, { type: "UNDO" }); expect(undone.status?.severity).toBe("info"); + expect(undone.status?.text).toBe("There is nothing to undo"); expect(undone.openDocument).toBe(created.openDocument); }); + // A genuinely separate code path from the test above: that one has an open document with an empty undo stack (the `snapshot === undefined` branch); this one has no open document at all (the earlier `doc === undefined` branch), which produces the identical message through different code. + it("says there is nothing to undo when no document is open at all", () => { + const undone = appReducer(createInitialState(), { type: "UNDO" }); + expect(undone.status?.severity).toBe("info"); + expect(undone.status?.text).toBe("There is nothing to undo"); + }); + it("caps the undo stack at 20 snapshots, dropping the oldest ones first", () => { let state = appReducer(createInitialState(), { type: "CREATE_DOCUMENT", @@ -2091,6 +2454,7 @@ describe("appReducer ADD_SLIDE_TABLE", () => { columns: 2, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no slide at index 5"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -2231,6 +2595,7 @@ describe("appReducer MERGE_SLIDE_TABLE_CELLS", () => { colSpan: 1, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no table at index 0 on slide 0"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -2552,6 +2917,38 @@ describe("appReducer SET_SHAPE_ROTATION on pptx", () => { expect(result.status?.severity).toBe("warning"); expect(result.status?.text).toBe("There is no shape 3 on page 0"); }); + + // withShape's own wrongDocument path (used only by SET_SHAPE_ROTATION) -- distinct from withWideShape's own copy below, which SET_SHAPE_TEXT/SET_SHAPE_FRAME resolve through instead. + it("warns rather than crashing when SET_SHAPE_ROTATION targets a document with no shape host at all", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "SET_SHAPE_ROTATION", + containerIndex: 0, + shapeIndex: 0, + rotationDeg: 10, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("a pptx, odp or odg document"); + }); + + // withWideShape's own wrongDocument path -- SET_SHAPE_TEXT/SET_SHAPE_FRAME resolve through it, not withShape, so this is a genuinely separate code path from the SET_SHAPE_ROTATION test above. + it("warns rather than crashing when SET_SHAPE_TEXT targets a document with no shape host at all", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "SET_SHAPE_TEXT", + containerIndex: 0, + shapeIndex: 0, + text: "x", + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toContain("a pptx, odp, ppt or odg document"); + }); }); describe("appReducer xlsx (read-only PDF-preview) documents", () => { @@ -2783,6 +3180,35 @@ describe("appReducer PDF item and page mutations", () => { expect(items[0]?.kind).toBe("text"); }); + // REMOVE_PDF_ITEM has its own inline wrongDocument/no-item checks, not shared with withPdfPage or withPdfItemMatching above. + it("warns rather than crashing when REMOVE_PDF_ITEM targets a non-PDF document", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "REMOVE_PDF_ITEM", + pageIndex: 0, + itemIndex: 0, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a pdf document; the open document is docx", + ); + }); + + it("warns rather than crashing when REMOVE_PDF_ITEM targets an item index that does not exist", () => { + const opened = openPdfDocument(pdfTestBytes()); + const result = appReducer(opened, { + type: "REMOVE_PDF_ITEM", + pageIndex: 0, + itemIndex: 9, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("Page 0 has no item at index 9"); + expect(result.hasUnsavedChanges).toBe(false); + }); + it("undoes a PDF text edit, restoring the snapshot taken before the mutation", () => { const opened = openPdfDocument(pdfTestBytes()); const edited = appReducer(opened, { @@ -2920,6 +3346,55 @@ describe("appReducer PDF item and page mutations", () => { expect(result.hasUnsavedChanges).toBe(false); }); + // withPdfPage's own wrongDocument path -- every ADD_PDF_* test above only exercises the "no page at that index" branch against an already-open PDF, never the "not a PDF at all" branch. + it("warns rather than crashing when ADD_PDF_RECT targets a non-PDF document", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "ADD_PDF_RECT", + pageIndex: 0, + init: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a pdf document; the open document is docx", + ); + }); + + // withPdfItemMatching's own wrongDocument path -- a genuinely separate function from withPdfPage above, so covering one says nothing about the other. + it("warns rather than crashing when SET_PDF_RECT_FILL targets a non-PDF document", () => { + const created = appReducer(createInitialState(), { + type: "CREATE_DOCUMENT", + format: "docx", + }); + const result = appReducer(created, { + type: "SET_PDF_RECT_FILL", + pageIndex: 0, + itemIndex: 0, + fill: { r: 1, g: 0, b: 0 }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs a pdf document; the open document is docx", + ); + }); + + // withPdfItemMatching's own "no item at that index" path -- the wrong-kind test above needs a real item at itemIndex 0 to check its kind against, so it can never reach this branch; this needs a valid page with an item count too low for the requested index instead. + it("warns rather than crashing when SET_PDF_RECT_FILL targets an item index that does not exist", () => { + const opened = openPdfDocument(pdfTestBytes()); + const result = appReducer(opened, { + type: "SET_PDF_RECT_FILL", + pageIndex: 0, + itemIndex: 9, + fill: { r: 1, g: 0, b: 0 }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("Page 0 has no item at index 9"); + expect(result.hasUnsavedChanges).toBe(false); + }); + it("adds an ellipse via ADD_PDF_ELLIPSE, present after a toBytes()/openPdf() round trip", () => { const opened = openPdfDocument(pdfTestBytes()); const withEllipse = appReducer(opened, { @@ -3452,6 +3927,81 @@ describe("appReducer PDF item and page mutations", () => { expect(result.status?.text).toContain("not internalLink"); }); + // A real internalLink item, unlike every other PDF item kind, cannot be created through PdfPage's own append* API (see the comment above) -- so this builds one the only other way a genuine internalLink ever arises: writing a LayoutDocument with a real internal-link annotation through pdf-codec's own writePdf, then re-reading it, proving the isPdfInternalLinkItem guard's TRUE branch (not just its wrong-kind rejection) actually matches a real internalLink item. + it("edits a real internal link's destination and frame through the live editor", () => { + const layoutDoc: LayoutDocument = { + formatVersion: LAYOUT_FORMAT_VERSION, + metadata: {}, + images: {}, + destinations: [ + { name: "target", pageIndex: 0, target: { kind: "fit" } }, + { name: "other", pageIndex: 0, target: { kind: "fit" } }, + ], + pages: [ + { + widthPt: 200, + heightPt: 200, + items: [ + { + kind: "internalLink", + destination: "target", + xPt: 10, + yPt: 10, + widthPt: 50, + heightPt: 20, + }, + // A second internalLink referencing the second destination: writePdf only carries a destination through into the saved document's own /Names tree when something actually references it, so an unreferenced destination is silently dropped on the way back in -- this one needs a real referrer to survive the round trip. + { + kind: "internalLink", + destination: "other", + xPt: 70, + yPt: 10, + widthPt: 50, + heightPt: 20, + }, + ], + }, + ], + }; + const opened = openPdfDocument(writePdf(layoutDoc)); + + const item = pdfDocument(opened).editor.page(0)?.items()[0]; + if (item?.kind !== "internalLink") { + throw new Error("expected a real internalLink item"); + } + const otherItem = pdfDocument(opened).editor.page(0)?.items()[1]; + if (otherItem?.kind !== "internalLink") { + throw new Error("expected a second real internalLink item"); + } + // The reader mints its own destination names on the way back in rather than necessarily preserving the writer's own names verbatim, so the destination this edit switches to is read from the second item's own round-tripped destination rather than assumed. + const otherDestination = otherItem.destination; + + const withDestination = appReducer(opened, { + type: "SET_PDF_INTERNAL_LINK_DESTINATION", + pageIndex: 0, + itemIndex: 0, + destination: otherDestination, + }); + expect(withDestination.status?.severity).not.toBe("warning"); + expect(withDestination.hasUnsavedChanges).toBe(true); + expect(item.destination).toBe(otherDestination); + + const withFrame = appReducer(withDestination, { + type: "SET_PDF_INTERNAL_LINK_FRAME", + pageIndex: 0, + itemIndex: 0, + xPt: 1, + yPt: 2, + widthPt: 3, + heightPt: 4, + }); + expect(withFrame.status?.severity).not.toBe("warning"); + expect(item.xPt).toBe(1); + expect(item.yPt).toBe(2); + expect(item.widthPt).toBe(3); + expect(item.heightPt).toBe(4); + }); + // Every other SET_PDF_*_* field-edit action routes through the identical withPdfItemMatching guard, but each call site carries its OWN copy of the kindLabel string literal -- exercising the wrong-kind path through the FRAME/FILL/one representative action per kind above does not cover the same literal at a sibling action's own call site (e.g. SET_PDF_RECT_FRAME's "rect" and SET_PDF_RECT_STROKE's "rect" are two distinct AST nodes). This table drives every remaining action through the wrong-kind branch once each. const wrongKindCases: [string, Action, string][] = [ [ @@ -3909,6 +4459,7 @@ describe("appReducer ADD_RECT / ADD_ELLIPSE / ADD_LINE / ADD_PATH on odp", () => init: { frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 } }, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no slide at index 5"); expect(result.hasUnsavedChanges).toBe(false); }); @@ -3938,7 +4489,10 @@ describe("appReducer ADD_RECT / ADD_ELLIPSE / ADD_LINE / ADD_PATH on odp", () => init: { frame: { xPt: 10, yPt: 10, widthPt: 40, heightPt: 30 } }, }); expect(withRect.hasUnsavedChanges).toBe(true); - expect(odgDocument(withRect).editor.pages()[0]?.vectors()).toHaveLength(1); + const vectors = odgDocument(withRect).editor.pages()[0]?.vectors(); + expect(vectors).toHaveLength(1); + // A rect and an ellipse share the identical OdgBoxVectorInit shape (frame/fill/stroke), so a mutation that lets ADD_RECT's own case fall through into ADD_ELLIPSE's addEllipse call would still add exactly one vector -- just the wrong kind. The length check above alone cannot catch that. + expect(vectors?.[0]?.kind).toBe("rect"); }); // The odg branch dispatches through its own inner switch (addRect/addEllipse/addLine/addPath), one case per real OdgPage method -- distinct from the ADD_RECT/ADD_ELLIPSE/ADD_LINE/ADD_PATH coverage above, which only ever reaches odg via ADD_RECT. Each case is its own switch-statement mutant, so proving the rect case works says nothing about whether removing the ellipse/line/path cases would still pass. @@ -4050,7 +4604,22 @@ describe("appReducer SET_VECTOR_FILL / SET_VECTOR_STROKE on odg", () => { fill: { r: 1, g: 1, b: 1 }, }); expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe( + "That action needs an odg document; the open document is docx", + ); expect(result.hasUnsavedChanges).toBe(false); + + // SET_VECTOR_STROKE has its own, separate copy of the identical wrongDocument call -- covering SET_VECTOR_FILL's above says nothing about this one. + const strokeResult = appReducer(state, { + type: "SET_VECTOR_STROKE", + vector: rect, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + }); + expect(strokeResult.status?.severity).toBe("warning"); + expect(strokeResult.status?.text).toBe( + "That action needs an odg document; the open document is docx", + ); + expect(strokeResult.hasUnsavedChanges).toBe(false); }); }); @@ -4294,6 +4863,22 @@ describe("appReducer ADD_TEXTBOX / ADD_IMAGE / SET_SHAPE_FRAME on pptx and odg", heightPt: 30, }); }); + + // withWideShape's own "page"/"slide" ternary: the odg describe block elsewhere only ever exercises the "page" branch via SET_SHAPE_TEXT, so this proves the "slide" branch specifically, on a pptx document. + it("warns with 'slide' rather than 'page' when SET_SHAPE_FRAME targets a missing shape on a pptx slide", () => { + const editor = createPptx(); + editor.addSlide(); + const opened = openPptxDocument(editor.toBytes()); + + const result = appReducer(opened, { + type: "SET_SHAPE_FRAME", + containerIndex: 0, + shapeIndex: 5, + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + }); + expect(result.status?.severity).toBe("warning"); + expect(result.status?.text).toBe("There is no shape 5 on slide 0"); + }); }); describe("appReducer SET_SHEET_PRINT_SETTINGS", () => { From 179f6313970f1cd56b50c168f4a9b799a5c690df Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 20 Sep 2026 04:55:35 +0100 Subject: [PATCH 35/36] docs(document-cli): state combineSignals' no-await precondition precisely The comment claimed both controllers were constructed on the line above. That is true of the timeout controller but not the interrupt one. What actually makes an aborted-input pre-check dead code is that no await separates either construction from the call. --- packages/document-cli/src/runtime/abort.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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(); From 6d86141e97a4d676b4a66bf48293bc75e36331b0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 20 Sep 2026 08:20:33 +0100 Subject: [PATCH 36/36] chore(document-cli): raise the mutation break threshold to 57 A cold full-scope Stryker run over the whole package scores 58.02% across 6246 valid mutants, with a 0.08% timeout share. Flooring that and taking the timeout margin off gives 57, replacing a gate derived from the 33.33% baseline the package started at. --- packages/document-cli/stryker.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/document-cli/stryker.config.ts b/packages/document-cli/stryker.config.ts index 41d59efef5..7967e80231 100644 --- a/packages/document-cli/stryker.config.ts +++ b/packages/document-cli/stryker.config.ts @@ -8,8 +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, });