Skip to content

test(documents.js): work toward 100% mutation score - #1272

Draft
Mearman wants to merge 91 commits into
mainfrom
feat/100-percent-mutation-documents.js
Draft

Mearman wants to merge 91 commits into
mainfrom
feat/100-percent-mutation-documents.js

Conversation

@Mearman

@Mearman Mearman commented Sep 13, 2026

Copy link
Copy Markdown
Member

Working through survived/no-coverage mutants in packages/documents.js toward a genuine 100% mutation score, with no Stryker disable comments anywhere.

Progress tracked in commits as it lands. Will mark ready once the mutation score is 100% with the break threshold raised accordingly.

Comment on lines +299 to +301
cell.borders = {
top: { color: { r: 1, g: 0, b: 0 }, widthPt: 2, style: "dashed" },
};
for (let column = 0; column < 6; column++) {
sheet.cell(0, column).value = { kind: "number", value: column };
}
sheet.printSettings = { ...BASE, repeatColumns: { start: 0, end: 1 } };
@Mearman
Mearman force-pushed the feat/100-percent-mutation-documents.js branch from 75198d8 to d098188 Compare September 15, 2026 10:34
The 200,000-synthetic-row-divider test in lattice.test.ts completes in well
under a second uncontended, but under Stryker's per-statement instrumentation
combined with heavy concurrent host load it can exceed vitest's default
5000ms wall-clock timeout despite doing the same real work. Raise the timeout
to 60 seconds, matching the same wall-clock-dominated-by-scheduling pattern
already documented for read-graph.test.ts's docxToPdf conversion and its
sibling ODS mergeCells test.
…and header labels

bridges.test.ts's ods<->xlsx round-trip test read only each cell's numeric/typed
`.value`, never the source ODS fixture's own rendered `.displayText`
(buildRichFixturePackage's per-cell text:p run) or the two unchecked header labels
("Amount", "Active") -- so a header cell or a cell's rendered text could silently
go blank without any assertion catching it.

Also remove gridOdsPackage/richOdsPackage/decoratedOdsPackage from test-support/ods.ts:
dead exports with no caller anywhere in the suite (only their *Bytes counterparts are
ever used), each one a whole function body with no test coverage.
embeddedHsqldbMultiIndexOdbPackage (odb.ts), odfFormulaPackage (odf.ts),
sheetFormulaOdsPackage (ods-formula.ts), and pdfWithForeignHiddenAnnotationPdf
(pdf.ts) had no caller anywhere in the suite -- each one a whole function body
(plus, for the first three, a now-unused Package/decodePackage import) that
existed purely as NoCoverage mutation surface with nothing exercising it.

pdfWithForeignHiddenAnnotationPdf's own comment ties it to a readPageNotes
/T-marker check in pdf/read.ts, but no such file or function exists anywhere
in this package's current src -- the reader it was meant to exercise appears
to have moved or been removed elsewhere, leaving this fixture orphaned.
…pdate/clear

The borders getter (top/left/bottom/right resolution, nil/none exclusion,
auto-color and missing-sz fallbacks, and the empty-map-to-undefined
collapse) was only ever exercised indirectly through a docx-odt bridge
round trip that reads back via ooxml.js's own separate reader, never
through this getter itself. Add direct tests for the getter's full
edge/style/color/width matrix, the nil/none exclusion path, the
auto-color and missing-attribute fallbacks, and the all-edges-excluded
case. Also cover DocxTableRow.heightPt being updated and cleared on a
row that already carries a w:trHeight, which the existing round-trip
test never exercised since it only ever set the value once.
…, background, and borders

Each of DocxTableCell's colSpan/background/borders setters filters out
any prior element of the same kind before inserting the new one, but
every existing test only ever set each property once starting from an
empty w:tcPr, so that filter's own predicate never ran against a real
element -- setting a value while one already exists was untested.
createEmptyDocxPackage's own tests only checked that the right parts
and elements existed, never the actual namespace URIs, content-type
strings, relationship targets, page/margin dimensions, or style
attributes createEmptyDocxPackage hardcodes -- so a corrupted
namespace, content type, or page dimension would still pass every
existing assertion. Add exact-value checks for every literal: the
version/encoding/standalone declaration on every part, both Default
extensions and Override content types in [Content_Types].xml, both
relationship parts' Id/Type/Target, the US-Letter w:sectPr's pgSz/pgMar
values, and the Normal style's type/id/name.
…ight's no-op branch

Both helpers stamp DEFAULT_COLUMN_WIDTH_PT/DEFAULT_ROW_HEIGHT_PT only
when the column/row has no width/height style yet, but no existing
test ever called cell() (which triggers them) on a column/row that
already had an explicit width/height set -- so the "already set, leave
it alone" branch never actually ran with a real value to compare
against.
…ts gridlines/headers parsing

readSheetPrintSettings/writeSheetPrintSettings had no dedicated test
at all; every existing test only ever set printSettings.gridlines
through a single shallow property check elsewhere, leaving pageSize,
margins, pageOrder, printRange, scalePercent, fitToPages, manualBreaks,
and repeatColumns/repeatRows entirely unexercised. Add direct coverage
for each field's round trip through the real OdsSheet.printSettings
getter/setter, the wrapRepeatRange gap-fill and stale-wrapper
dissolution behaviour, hasManualBreak's own break-detection, and
parsePrintRanges/parseScalePercentage/parseNonNegativeInteger's
malformed-input handling (poking the underlying XML directly for
shapes the writer itself never produces).

Also simplify the gridlines/headers reader: the previous
`new Set(...).filter((token) => token.length > 0)` guarded against
empty tokens from stray whitespace, but the only two things ever read
from that set are `.has("grid")`/`.has("headers")`, which an empty
token can never satisfy either way -- the filter's outcome was
unobservable through any real behaviour. Replaced with a plain
`.split(" ").includes(...)` check, which is equally robust to stray
whitespace without carrying an untestable branch.
createEmptyOdsPackage had no dedicated test at all -- every existing
test only exercised it indirectly through createOds(), checking
structural existence (a table exists, a cell can be written) without
ever asserting the actual namespace URIs, the of: namespace LibreOffice
needs to recalculate table:formula on open, media type, page-layout
geometry, calculation-settings defaults, or office:meta field mapping
this scaffold hardcodes. Add exact-value checks for the mimetype and
manifest root entry, every part's declaration, content.xml's of:
namespace and default sheet/style chain, styles.xml's page-layout/
master-page chain, and every office:meta field buildOfficeMeta writes
(and omits) for a given LayoutMetadata.
… the ods scaffold test

tsconfig.node.json's stricter typecheck (run separately from the
default tsconfig by the pre-push hook) caught what tsconfig.json's own
run missed: Array.prototype.find can return undefined, and attr()
requires a real XmlElement.
…back in detectPackageManager

userAgent ?? "" only mattered for the startsWith checks below it, and none of
yarn/pnpm/bun's prefixes match an empty string either, so the fallback
literal's own value was unobservable and the branch always returned npm
regardless. Replace it with an explicit early return for the undefined case
so the mutation-prone fallback literal no longer exists as an AST node.
…ectly

No test in the package called applyOdfGeometry directly; its 33 covering
tests all went through higher-level editors that never distinguished
rotationDeg undefined from rotationDeg 0, so the combined
"undefined || === 0" guard survived a mutation to a bare false. Add direct
unit coverage for all three branches (undefined, exactly 0, and a real
rotation) plus a sanity check on buildTransformAttr's own composition.
UnsupportedFontSourceFormatError, UnsupportedConversionError, and
UnsupportedPackageFormatError were only ever checked by instanceof/toThrow(),
so each constructor's own this.name assignment had no test observing its
value and survived a mutation to an empty string.
…ion guard

Neither constructor guard's error message had a test calling it with a
wrong-kind ContentDocument, so both throw strings survived being mutated to
an empty template literal.
…te string

The presentation-slide-shape test only checked the recovered formula, never
the locate path built from the slide/shape indices, so that template literal
survived being mutated to an empty string.
…g count

No test read sheet.rows at all, so table.rows.length + 1 (one sizing entry
per data row plus the header row) survived being mutated to - 1.
The "single-length level whose slot is undefined" branch had no covering
test at all (not reachable through any real odf.js-decoded report), so its
error message survived being mutated to an empty string. Exercise it
directly with a hand-built groups array carrying an explicit hole.
The only existing test's container held a single element that always
matched regardless of the tag check, so node.tag === tag survived being
mutated to true. Add a container whose one element has a different tag.
… option forwarding

Neither the markdown reader's images/signal object nor readDocumentLayout's
signal object had a test observing an effect from either field, so both
ObjectLiteral mutations to {} survived. Assert the images resolver is
actually invoked and that an aborted signal is checked before parsing (the
markdown reader's own signal check, and readPdf's own pre-page-loop check).
Also assert throwIfAborted's DOMException carries the exact name/message
rather than only its type.
…uards

xls.content.write's non-spreadsheet refusal message and pdf.layout.write's
signal forwarding to writePdf had no test observing either, so a StringLiteral
mutation to the error message and an ObjectLiteral mutation of the options
object both survived.
inferCellValue("") already returns undefined on its own (its own
text.length === 0 check), so field === "" ? undefined : inferCellValue(field)
restated that in a second place for no observable difference -- an empty
field's own type-inference path was unkillable by any test because both
branches always produced the same undefined. Call inferCellValue(field)
directly, and assert onCellTypeInference fires only for the populated
fields in a row that also carries empty ones.
Mirrors the identical CsvInvalidUtf8Error gap: only instanceof/toThrow(Class)
was checked, so the constructor's own name/message string literals survived
being mutated to empty strings.
…case

Number.prototype.toFixed already normalizes -0 to "0.000000" on its own, so
the trailing-zero trim below collapses any zero-valued coordinate (positive
or negative) to a bare "0" through the exact same path every other value
takes -- the early return produced no output any test could ever distinguish
from the general path, for any input.
…no fonts

No test called treeEmbeddedFontsOf directly, so its "return undefined rather
than an empty array" branch (the whole point of the function per its own
comment: a splice site can spread undefined without minting an empty table)
had no test observing the difference between the two.
…-placeholder branches

The existing table test's nested cell content never needed transforming, so
recursing markdownTableCell into it versus not (a BlockStatement mutation
skipping the whole branch) produced identical output. Add a case with a
pageBreak nested in a cell, which only survives as marker text if the
recursion genuinely ran. Also cover formulaParagraph's own placeholder-run
branch (no presentation LaTeX), whose ArrayDeclaration mutation to an empty
runs array had no test asserting the placeholder text reached the output.
…sting

odf.js's own single shared paintOrder counter guarantees a shape and a
vector can never collide on the same value, which made the "order <
vectorOrder" boundary check unkillable through any real odf.js-decoded
package -- the "<=" mutant produces identical grouping for every reachable
input. Split the pure grouping algorithm out of collectSlideVectorGroups so
it takes plain paintOrder-bearing data directly, and test the boundary (and
the paintOrderOf throw guard) against a hand-built collision no real package
can produce but the function's own contract still needs to hold for.
…e guard short-circuits

Every legacy reader independently rejects non-CFB bytes too (each one's own
first step is archive-codec's readCompoundFile), so the outcome alone
(undefined either way) could never distinguish the outer isCompoundFile
guard existing from it being skipped. Spy on readDocContent to prove it is
never even invoked for bytes that fail the guard, rather than merely
happening to fall through all three readers to the identical result.
…mutant

Slicing from indexOf(":") + 1 already returns the whole string when
there is no colon (indexOf yields -1, so the slice starts at 0), so
the colonIndex === -1 ternary guard was redundant: every input was
already handled correctly by the slice alone. That redundancy made a
ConditionalExpression mutation on the guard permanently equivalent
(unkillable by any test), since the branch it toggled produced the
same output either way. Dropping the guard removes the mutation
opportunity outright.
reduceRational(0n, 0n) exercises gcd's own 0/0 special case (defined
as 1 to avoid a BigInt division-by-zero crash) directly, rather than
only through a path decimalToRational can actually reach.

decimalToRational("-5") pins that the ^\d+$ digits check rejects a
literal whose only non-digit character sits before its digits, not
just one with non-digit characters at the end (already covered by the
existing "12a" case).
The existing test only exercised title/author being kept when
overrides omits them; subject and keywords had the identical
conditional-spread shape with no coverage of their own absent-field
case.
…ent mutants

compareCellKeys checked equality first, then used a nested
less-than ternary for the remainder. Since the equality guard
already rules out left === right before that nested comparison
runs, a < -to- <= mutation on it produced identical output for
every reachable input: the equal case never reaches that branch,
and < versus <= only differ on equality. Reordering to check < (then
>, then implicit equal) puts the equal-values input back in reach of
that comparison, so a relational-operator mutation there is
observable again.
The existing "outside Pictures/" case used a path whose Pictures/-
prefix-length slice happens not to look like an image filename, so it
could not distinguish a real prefix check from one that never
actually skipped. Adds a path deliberately crafted so slicing at the
Pictures/ prefix length spells a valid image filename by coincidence,
pinning that the function only follows the real path prefix.
The early return for an empty vector-groups array only skipped
allocating a shapes array that the rebuild loop below would otherwise
reconstruct with identical contents (the insertion while-loop never
runs when groups is empty, so every shapeIndex iteration just
re-pushes the shape already at that index). The guard changed nothing
observable, making its ConditionalExpression mutation permanently
equivalent. Removing it drops the mutation opportunity.
The presence loop matched on element tag AND attribute value, but no
test distinguished that from matching on the attribute value alone.
Adds a case where an Override element carries an Extension attribute
equal to the value being ensured, confirming ensureDefaultContentType
still adds a genuine Default entry rather than mistaking the Override
for one.
escapeRegExp's replacement text turning a special character into a
literal match, rather than deleting it outright, needs a part whose
extension contains one to distinguish -- the existing "p.g" case only
proved over-matching was prevented, not that a genuine "p+g" match
still works.

Also mirrors src/odf-package/media.test.ts's own prefix-length
coincidence case for this sibling OOXML-side implementation.
…r guard

siblingRank !== -1 was always true by the time siblingRank > childRank
runs, since -1 can never be greater than childRank (already known
non-negative from the earlier childRank === -1 return above) -- the
guard changed nothing observable, an equivalent mutant. Also adds
insertBefore's own not-found-append-at-end case (insertAfter already
had one) and a same-schema-rank case for insertInSchemaOrder, both
previously unexercised.
Indexing a string past its end yields undefined in JavaScript, and
undefined === " " is already false, so the i + runLength < text.length
check could never change the loop's outcome on its own -- it only
ever agreed with what the character comparison already decided,
making three separate mutations on it (the bound itself, its
operator, and its arithmetic) all permanently equivalent. Relying on
the character comparison alone removes the redundant check.
… content

None of content.xml/styles.xml/meta.xml's own root element tag was
directly asserted -- every existing check reached into a child by tag
name, which would still succeed even with an empty root tag. Also
pins that CONTENT_NS_PREFIXES is genuinely spread into content.xml's
attributes (xmlns:table specifically, distinct from the hand-declared
xmlns:of already checked) and that styles.xml carries its own empty
office:styles sibling.
…kers

MATH_INLINE_SOURCE, MATH_INLINE_FONT_MARKER, and MATH_BLOCK_STYLE_ID
had no test exercising formulaParagraph at all: a formula whose
provenance source matches the inline marker renders as a \( \) span,
and any other source renders as a $$ display block instead.
…utes

Neither the XML declaration node nor the four xmlns attributes on
cp:coreProperties were directly asserted anywhere -- every existing
check reached into a child by tag name or attribute, which would
still succeed with an empty declaration/attributes object.
Two independently-ANDed length checks (common < fromDirs.length &&
common < toDirs.length) meant relaxing either one in isolation never
changed the loop's outcome: the sibling, still-correct check kept
stopping the loop at the same common, and wherever the two paths'
directories genuinely differ, fromDirs[common] === toDirs[common]
already fails once one side runs out (a real segment can never equal
undefined). That made both length checks, their operators, and the
&& joining them all permanently equivalent mutation targets. A single
combined bound has no sibling clause left to mask a boundary
mutation, observable via two identically-deep directory chains.

Also drops relsPathFor's own redundant lastSlash === -1 guard for the
filename split, the same "slicing from -1 + 1 already returns the
whole string" equivalence already fixed in src/mathml/nodes.ts's
localName.
…etry passthrough

paragraphs() had no test proving it excludes non-paragraph blocks
(a table specifically) from the fixture it already round-trips.
startList's own task default had no case exercising the unset (false)
path, only the explicit task: true one. Neither constructor guard
(non-wordprocessing kind, an empty sections array) nor the
pageSize/margins/clock passthrough into createMarkdownEditor had any
coverage at all.
…line stamping

readDocumentMetadata and readNativeDocumentTree had no coverage at all
for abort-signal forwarding on either their pdf or non-pdf dispatch
branch, nor for the markdown images resolver readNativeDocumentTree
also forwards. The stampPdfPackageTables call had no test able to
distinguish it running from being skipped either: the existing pdf
fixture (docxToPdf of a plain docx) carries no outline or destinations
of its own, so stamping is a no-op either way for that input. Building
a small PDF directly through pdf-codec's own writePdf with a bare
outline entry, bypassing every documents.js writer, gives the call
something to actually stamp.
…message shapes

The odf -> pdf special case checked source.format === "odf" &&
targetFormat === "pdf", but nothing exercised the targetFormat half:
an odf source to any OTHER target had no test, so a request that
should reject as unsupported (odf has no composition-engine route
except to pdf) could have silently succeeded through odfToPdf instead.
Also adds direct coverage for the char/substituted diagnostic's exact
message text, the bold/italic weight suffixes in a font-substitution
message, odfToPdf's own onDocument/signal forwarding, and that the
odf->pdf route's options object genuinely reaches the call.
…ar from stripping it

nextPictureIndex's escapeRegExp replaces a matched special character with
an escaped copy ("\\$&"), but the existing regex-special-character test
used an extension/filename pair that stayed a non-match whether the
character was escaped or dropped entirely, so a replacement of "" survived.
Adding a case where the escaped form matches but the stripped form does not
makes the escaping itself observable.
…g, not just PartName

The existing-override scan matched on PartName alone in its covering
tests, so relaxing the tag check to always-true never changed the
outcome. Adding a sibling element that carries a matching PartName but
isn't an Override makes the tag comparison itself load-bearing: without
it, the scan would mistake the decoy for a real entry and add nothing.
… bound

The common-prefix scan bounded itself with Math.min(fromDirs.length,
toDirs.length), but that bound is provably redundant: the instant the
scan passes the end of the shorter array, indexing it yields undefined,
which can never strictly equal a real path segment, so the comparison
already stops the loop there on its own. That made a Math.min/Math.max
swap on the bound an equivalent mutation no test could ever kill. Relying
on the undefined comparison directly removes the mutation opportunity
rather than leaving it unkillable.
…ling after, not before

The prior same-rank test compared only the resulting tag sequence, which
reads identically whether the new node lands before or after an existing
sibling sharing its own tag -- so relaxing the rank comparison from
strictly-greater to greater-or-equal never changed the assertion's
outcome. Distinguishing the two elements by a marker attribute makes
which physical node ended up first observable.
…bound

The main character-walking loop's i < text.length bound admits a mutation
(i <= text.length) that is unobservable: charAt past a string's end
already returns "", and appending "" never changes the accumulated
literal buffer, so no test could ever kill it. Comparing i directly
against text.length with !== keeps the identical behaviour (i only ever
advances by positive steps that land exactly on an untouched index or on
text.length itself) while making the loop's own termination condition a
real mutation target: flipping !== to === inverts it outright.
…ssert their own arguments

readNativeDocumentTree's pdf branch forwards signal (and, for readPdf
alone, sink) into two separate calls, but reconstructWordprocessing's own
independent signal check already throws AbortError for an aborted
signal, so a black-box test asserting the outer function throws could
not tell whether readPdf itself ever received the signal or sink at all
-- both an intact and a stripped call to readPdf produced the identical
outer throw. Spying on each call directly makes its own argument object
observable.
fontSubstitutionDiagnostic's else branch (the "missing-face" reason,
reached when a caller-supplied family exists but not the exact
bold/italic combination requested) had no test reaching it at all --
every existing substitution test exercised only "vendored-substitute".
Requesting bold text while supplying just the family's regular face
triggers the family-fallback path instead of the vendored table.
…tion branch

odfFormulaBytes' mimetype part name, its stored-uncompressed requirement,
and the presence/absence of the StarMath annotation element were never
independently verified: every consuming test only reads content.xml back
through a real ODF/MathML reader, which never inspects the mimetype
entry's name or compression method and would tolerate either annotation
branch's exact text unnoticed. A direct test on the fixture's own raw zip
bytes and content.xml is what makes both properties observable.
… own tag guard

Neither branch had any direct coverage: nothing exercised an a:ln-bearing
shape the production reader can't recognise (the throw this test-support
oracle exists to surface), and every consuming test's fixtures only ever
built shapes whose tag already matched spPrTag, so the tag comparison
itself was never load-bearing in any covering test.
… property-presence guard

rotationsOf's vector.kind === "line" comparison was a genuine equivalent-
mutant trap: forcing that comparison to always-true still type-narrows on
the original condition text, so accessing rotationDeg in that branch
stays valid, and a line vector's own missing key reports undefined
either way -- indistinguishable from the correct branch's own explicit
undefined, so no test could ever kill it. A "rotationDeg" in vector check
narrows identically for every real input but has no such loophole: an
always-true mutation of it fails to compile outright (accessing
rotationDeg on the now-unnarrowed union), leaving only an always-false
mutation, which a real rotationDeg value does kill.

withoutRotation's own analogous survivors were a toEqual gap rather than
an equivalent mutant: spreading an explicit rotationDeg: undefined onto a
line vector (which never carries that key at all) still toEqual's the
untouched original, since toEqual treats an undefined-valued key as
indistinguishable from an absent one. toStrictEqual does not.
@Mearman
Mearman force-pushed the feat/100-percent-mutation-documents.js branch from 5df1bf6 to 951f3af Compare September 15, 2026 18:08
…olResolver

The LaTeX symbol table's own command-to-glyph lookup, id-minting scheme,
and curated/minted resolver class had no direct test coverage at all --
only the prose scanner built on top of them was tested. Add per-command
assertions for every entry in the glyph map, the plain-character
passthrough and unmapped-command cases, and SymbolResolver's curated
lookup, first-entry-wins duplicate handling, minting with reuse on
repeat lookups, and first-mint ordering.
…ship target

remove() previously spliced the p:sld root element itself out of sldIdLst, but
sldIdLst holds p:sldId entries referencing each slide by r:id, not the slide
part's own root element -- so the splice never matched anything and the
presentation kept a dangling reference to the removed slide's part. Resolve
the presentation's own relationships and match each p:sldId's r:id target
against this slide's part path instead, mirroring the same lookup
PptxEditor.removeSlideAt already does by index, then delete the slide part
itself from the package.

Export PRESENTATION_PART_PATH from scaffold.ts as the single source of truth
instead of duplicating the literal in editor.ts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant