Skip to content

test(ooxml.js): work toward a 100% mutation score - #1259

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

test(ooxml.js): work toward a 100% mutation score#1259
Mearman wants to merge 72 commits into
mainfrom
feat/100-percent-mutation-ooxml.js

Conversation

@Mearman

@Mearman Mearman commented Sep 12, 2026

Copy link
Copy Markdown
Member

Part of the workspace-wide effort to bring every package's Stryker mutation score to a genuine 100% with zero disable comments (see the sibling PRs already merged for byte-codec, excel-number-format, document-compute.js, pdf-raster-cpu).

Measured baseline for ooxml.js: 65.05% of 7126 valid mutants (killed 4604, timeout 31, survived 2081, no-coverage 410).

This PR is a work in progress. It has landed, in order:

  • The package's smaller, previously-untested foundation modules: base64, XML build/parse, the XmlNode structural guard, package classification, image signature sniffing.
  • A batch of small-to-medium gaps across xlsx/docx/pptx typed modules: shading's unrecognised-fill-kind guard, figure-caption association's image gate and run-join separator, numbering's non-canonical sort keys and undefined-level handling, threaded-comment id sequencing, table-relationship type filtering, the embedded-object decode's root-entry precedence and Package-stream-by-name lookup, and pptx inherit's relationship-type filtering, placeholder idx/type fallback, and run-property/level-clamp coverage.
  • Full, direct structural coverage for the embedded-object test fixture builder (test-support/cfb.ts), including byte-level assertions for header fields archive-codec's own reader deliberately never cross-checks, and a fixture sized to the builder's own one-FAT-sector boundary.
  • xlsx/defined-names.ts and xlsx/data-validation.ts brought to 100% from zero direct coverage, plus a handful of redundant-guard removals proven equivalent by hand-mutation (a name-then-type check that already excluded an absent name, a no-op ternary branch, an indexOf/slice split removing a possibly-undefined array index, a letters regex made redundant by columnLettersToIndex's own character-class check, and a print-area builder rewritten to construct its dollared reference directly from row/column indices instead of formatting then re-parsing a plain reference through a regex).
  • xlsx/print-settings.ts brought to 100%: margin/break/scale coverage plus one redundant scale-presence guard removed (Number(undefined) is NaN, already caught by the isFinite check beside it).
  • docx/numbering.ts, pptx/chart.ts, and pptx/diagram.ts brought to 100%: direct unit coverage for readChartTable/readChartResidue and readDiagramText/readDiagramResidue (previously exercised only indirectly through full xlsx/pptx round trips), plus the numbering override's no-w:val case.
  • docx/styles.ts brought to 100%: style-cascade type discrimination (a same-styleId style of the wrong type, a default-style flag on the wrong type), the default paragraph style's own w:pPr merge, strike's basedOn inheritance, majorAscii/minorAscii alongside their HAnsi spellings, an unrecognised asciiTheme resolving to no font, a bare w:u with no w:val, w:ind/@w:start as w:left's fallback, "distribute"/"atLeast" alongside their siblings, a themeTint byte with stray characters before or after its two hex digits, plus one redundant guard removed (readToggle's absent-value check, subsumed by the three inequality comparisons beside it).
  • pptx/reading-order.ts from 74.63% to 89.47%: an exact axis-ratio tie breaking to rows, overlapping shapes whose primary/secondary sort keys disagree, a row needing its own internal column cut once split out, extentAlong's true-span computation, and two provably-redundant guards removed (a shapes.length<=1 early return the algorithm's own fallback already makes a no-op, and a columns.groups.length>1 check implied by the ratio comparison already being positive). Three touching-boundary mutants in splitOnGap remain genuinely difficult to distinguish through the exported function's output alone and are left for a follow-up pass.

Current measured score: 72.05% of the package's valid mutants (up from the 68.17% this PR previously reported, and the 65.05% original baseline). Zero Stryker disable comments anywhere in the package (grep -rn "Stryker disable" src/ returns no matches).

Files now at a genuine 100% mutation score in this PR: util/base64.ts, xml/build.ts, xml/parse.ts, model/node.ts, typed/util.ts, typed/xlsx/util.ts, typed/xlsx/defined-names.ts, typed/xlsx/data-validation.ts, typed/xlsx/print-settings.ts, typed/docx/numbering.ts, typed/docx/shading.ts, typed/docx/styles.ts, typed/pptx/chart.ts, typed/pptx/diagram.ts, typed/pptx/inherit.ts, typed/xlsx/serial.ts, typed/xlsx/definitions.ts, typed/xlsx/rule-residue.ts, typed/xlsx/comments-write.ts, typed/xlsx/shared-strings.ts, typed/xlsx/sqref.ts, typed/xlsx/units.ts, typed/shared/units.ts, typed/embedded.ts, typed/document-tree.ts, typed/figure-captions.ts, plus image/sniff.ts and package-io/read.ts/write.ts.

Every fix is either a real test proving a genuine behavioural difference, or a small refactor removing code whose mutation was verified equivalent by hand (mutating the source directly and confirming the existing suite still passed before the fix, and failed after it) -- never a Stryker disable comment.

Substantial work remains: the package's largest modules are still far below 100% -- typed/docx/write.ts (57.81%, 2124 lines), typed/docx/read.ts (75.98%, 1896 lines), typed/xlsx/build.ts (33.58%, 1235 lines), typed/pptx/read.ts (77.23%, 1048 lines), typed/xlsx/conditional-format.ts (62.09%), typed/xlsx/styles.ts (68.83%), typed/xlsx/content.ts (57.00%), typed/xlsx/drawings-write.ts (40.82%), typed/xlsx/drawings.ts (65.13%), typed/xlsx/comments.ts (75.00%), typed/docx/constructs.ts (80.77%), typed/pptx/reading-order.ts (89.47%, close), typed/shared/color.ts (66.42%), typed/shared/drawingml.ts (76.88%), typed/shared/metadata.ts (76.00%), typed/compact.ts (47.95%), typed/xlsx.ts (60.00%), typed/xlsx/definitions-write.ts (54.00%), and test-support/embedded.ts (65.00%). stryker.config.ts's breakThreshold is left at its original measured-baseline value (63) rather than raised, since the package is not close to the genuine 100% that value would need to reflect.

Left as a draft while this continues.

@Mearman
Mearman force-pushed the feat/100-percent-mutation-ooxml.js branch from 9f8c81f to ac15596 Compare September 13, 2026 18:38
…code buffer sizing

Adds direct coverage for bytesToBase64/base64ToBytes across every
input-length remainder (0, 1, 2 bytes past a full 3-byte group), the
invalid-base64 throw for each of the two positions a malformed
character can occupy in a 4-character group, and whitespace stripping
before decode.

base64ToBytes now builds its output as a plain number[] converted via
Uint8Array.from rather than pre-sizing a Uint8Array from a `len * 3 /
4` estimate: that estimate is only ever an upper bound, so any formula
that never under-counts is behaviourally identical to any other once
the result is trimmed to its real length -- removing the sizing
arithmetic as an AST node rather than leaving an unobservable estimate
for a mutation to hide behind.
…lder scaffolding

Adds direct coverage for buildXml across every XmlNode variant (text,
comment, cdata, pi, declaration, attribute-less and attributed
elements, nested children, multiple root nodes) and for
assertBuiltString's own throw, extracted from buildXml so the "did the
builder return a string" guard is directly testable with a non-string
literal rather than left uncovered forever (XMLBuilder, given this
module's fixed options, never actually returns anything else).

Simplifies two spots verified directly against fast-xml-parser to be
unobservable: a processing instruction's and a declaration's own child
array is never rendered by the builder under this configuration (`{
"?custom": [{ "#text": "x" }] }` and `{ "?custom": [] }` build to the
byte-identical `<?custom?>`), so neither carries a value the builder
ever reads; and an element's own `:@` attributes object is set
unconditionally rather than gated on whether any attribute exists,
since an empty `:@": {}` builds identically to the key being absent
and parseAttributes already reads both back to the same empty array.
Exports and directly unit-tests every one of parseXml's own structural
guards and error paths (isRecord, isUnknownArray, asString, parseNodes,
parseNode, parseAttributes, scalarText) against synthetic
fast-xml-parser-shaped input: a node that is not an object, a node
with no tag key or more than one, an attribute value or scalar-text
wrapper of the wrong shape. Real fast-xml-parser output never produces
these malformed shapes, so none of these branches was ever exercised
through parseXml's own public entry point alone.
…variant

Adds direct coverage for isXmlNode's own structural guard across
non-record inputs (null, an array, a primitive -- each a distinct
branch of typeof/null/Array.isArray that real Zod-validated input
never separately exercises), every XmlNode variant's own required
fields, malformed attribute entries, and a recursive check that a
child element's own children are validated the same way rather than
only its own direct fields.
…edundant bounds check

Adds direct coverage, via packageFromEntries's own xml/binary
classification, for a UTF-8 BOM prefix (alone and combined with
leading whitespace), every individual whitespace byte the format
permits, a run of several in a row, an all-whitespace part with no
non-whitespace byte at all, and a part whose first three bytes only
partially match the BOM (isolating each of the three signature bytes'
own necessity) -- none of which any existing test exercised.

Drops looksLikeXml's own `bytes.length >= 3` BOM guard: it is provably
redundant given how out-of-range Uint8Array indexing behaves -- an
index at or past a real array's own length always reads `undefined`,
which can never equal a real BOM byte, so a short array already fails
the byte-by-byte comparison on its own. The main scan loop is likewise
rebounded on `bytes[i] !== undefined` rather than a separately tracked
`i < bytes.length`, for the identical reason.
…ant bounds check

Adds direct coverage for sniffImageFormat across every recognised
signature (PNG, JPEG, both GIF header versions), near-miss prefixes
that diverge partway through or on the final byte, and SVG detection
by its own XML-prolog and bare-root-tag spellings, leading whitespace
before either, and the 1024-byte sniff window's own boundary (a real
'<svg' tag placed well past the window must not be found there).

Drops startsWith's own `bytes.length < signature.length` guard: it is
provably redundant given how out-of-range Uint8Array indexing behaves
-- an index at or past a real array's own length always reads
`undefined`, which can never equal a real signature byte, so a shorter
array already fails the byte-by-byte comparison on its own.
Adds direct unit coverage for relsPathFor (a slash-free part path, and
a nested one where only the LAST slash may split it) and
resolveRelTarget (a package-rooted target, a relative target against
both an empty and a real subject directory, a '../' segment popping
the enclosing directory, a '.' segment, and a doubled-slash empty
segment) -- neither function was reachable from any existing test
except through a much larger relationship-resolution fixture that
never varied these specific shapes.
… redundant date checks

Adds direct coverage for serialToIsoTime/serialToIsoDateTime's own
non-finite and negative-serial rejections, and for
utcMsOfCalendarDate's own year/month rollover rejections -- including
a day value large enough to roll a whole leap year forward, the one
shape that makes the year check's own necessity observable (the
public isoDateToSerial entry point never passes a day outside 0-99,
which alone never triggers it).

isoDateOfDayCount now switches on the sign of the offset from the
phantom leap day rather than pairing an equality check (excluding day
60 itself) with a separate `<` comparison against the identical
threshold: with 60 excluded by the `0` case, the remaining two cases
are Math.sign's only other outputs, leaving no inequality boundary for
a mutation to hide behind.

utcMsOfCalendarDate drops its own third, day-level equality check:
Date.UTC(year, month-1, day) maps onto exactly one real calendar date,
so whenever a re-read year and month both already match what was
asked for, day is necessarily inside that month's own valid range and
is therefore already forced to match too (confirmed by exhaustive
search over every realistic year/month/day combination) -- a third
check here could only ever restate a fact the first two already
guarantee.
…space split

Adds direct coverage for parseSqref (absent/empty input, a single bare
cell, a real span, several ranges, a malformed token skipped among
well-formed ones), formatSqrefRange (bare cell vs. row-only vs.
column-only vs. full spans), and formatSqref's own join -- none of
which this shared helper had a dedicated test file for at all.

Simplifies the token split from `/\s+/` to `/\s/`: splitting on each
individual whitespace character rather than a run of them only ever
inserts extra empty strings between adjacent whitespace characters,
which the loop's own `token === ""` skip already discards, so both
forms produce the identical final token list regardless of how many
consecutive whitespace characters separate two ranges.
… directly

Adds a dedicated test file for the xlsx rule-residue helpers: capturing
zero, some, and every attribute as unmanaged, and reading residue back
for an absent source, a wrong-format source, a source that fails to
parse as exactly one element, and one whose tag mismatches the
expected rule kind -- none of which had direct coverage before.
Adds a dedicated test file: an absent sharedStrings part reads back as
exactly an empty array (not a placeholder value), multi-run <si>
entries concatenate in document order, and SharedStringTable assigns
sequential indices while deduplicating a value interned twice.
…re no tables

A plain property read cannot distinguish a genuinely absent key from
one spread on with an explicit undefined value -- both read back as
undefined. Adds an Object.hasOwn check alongside the existing
toBeUndefined() assertion so readXlsx's own conditional spread is
actually exercised, not just its value.
… directly

Adds a dedicated test file: a sheet with no table relationship at all
reads no definitions, a non-table relationship among several is
skipped in favour of the genuine table one, and a table part missing
its own name or ref attribute is skipped rather than promoted with a
missing field.
…at all

Adds a case where neither of an image's own neighbours is a paragraph
(two more images either side), which no existing fixture in this file
exercised -- every prior case had at least one paragraph candidate,
matching or not.
…r patterns' own absent key

Adds a "none" w:fill and a "none" w:color case (only "auto" was
previously exercised for either), and strengthens the existing
single-colour pattern tests with an Object.hasOwn check: a plain
toEqual cannot distinguish an omitted foregroundColor/backgroundColor
key from one spread on with an explicit undefined value, so a genuinely
one-sided pattern read needs the stricter check to prove the other
key is truly absent.
…nt, and reply linkage

Adds a dedicated test file: threadedCommentId's own uppercase-hex
formatting (a counter of 10 exercises the digit-vs-letter distinction
0-9 alone cannot), sequential ids increasing across two separately
commented cells (not just within one thread), a reply immediately
following its own root with the root's real id as parentId while the
root itself carries none, and the threaded-comments root's own
declared namespace.

Exports threadedCommentId, previously module-private, purely for this
direct coverage.
… a dead type-narrowing check

Adds a test at exactly the half-point tolerance boundary (not just
comfortably inside it), and four tests each isolating one dimension's
own necessity in pageSizeToPaperSizeCode's Letter/A4 checks (a width
match with a mismatched height, and vice versa, for both page sizes) --
none of which any existing test distinguished from the other.

parseUniversalMeasureToPt no longer runs an `amountRaw === undefined ||
unit === undefined` check after a successful regex match: neither of
UNIVERSAL_MEASURE_RE's two capture groups is optional (neither has a
trailing `?`), so a successful match always populates both --
TypeScript's own RegExpExecArray typing just cannot express that a
specific pattern's own groups are mandatory. Non-null assertions state
that directly instead of a runtime check no real regex match can ever
fail.
…and numeric level ordering

Adds a w:startOverride whose own ilvl names a level the base
abstractNum never defined (must be skipped, not fabricated), a
declared-namespace assertion for the built w:numbering root, and a
level ordering case proving ilvl sorts numerically ('10' after '2'),
none of which the existing round-trip-only fixtures distinguished from
a passing but coincidentally-correct result.
readEmbeddedOoxmlPayload's outer catch swallows a wrongly-detected
flavour's own read failure exactly as gracefully as a genuinely
undetected one, so testing hasDocxBody/detectFlavour only through that
public entry point cannot tell "correctly found no flavour" apart from
"wrongly matched one, then threw reading it" -- both produce the same
undefined result. Exports both functions and adds direct coverage: a
w:body present/absent, and each of the three entry-part flavours
detected (or none) independent of the read that would follow.
…cell

ContentSheetCellSchema requires displayText, absent from the plain
number-cell literals comments-write.test.ts built by hand -- caught by
tsconfig.node.json's own typecheck (which includes test files, unlike
the base tsconfig.json a plain tsc run checks). Introduces a
numberCell helper that always sets it alongside the numeric value.
…tes.length

Uint8Array.prototype.subarray already clamps its end argument to the
array's own length, so requesting SVG_SNIFF_WINDOW bytes from a
shorter buffer already yields exactly the bytes that exist -- the
Math.min was never observably different from omitting it.
…hape

A value shaped exactly like a valid element (tag/attributes/children
all present) under an unrecognised type name must still fall through
to the final `return false` -- nothing previously drove the value
into the "element" arm by an unrelated type name alone.
…wards

A comment thread with one reply, followed by a second cell's own
comment, needs the second root's id to continue at 2 -- a reply-loop
increment that ran backwards would instead collide it with the first
cell's own root id.
A distractor relationship whose type is not the table relationship
type, but whose target happens to be a genuinely well-formed table
element (name and ref both present), must still be skipped -- the
existing distractor test's target failed the name/ref check anyway,
so it could not by itself distinguish the type guard from an absent
one.
… guard

When indexOf finds no 'T', the date half slices to length
iso.length - 1 and the time half to the whole iso.length characters.
ISO_DATE_PATTERN and ISO_TIME_PATTERN are anchored to exactly 10 and 8
characters respectively, so matching both at once would need
iso.length to be both 11 and 8 -- impossible. With no separator, at
least one half always fails to parse, so the existing undefined
fallthrough already covers it with no separate check needed.
parseRangeReference("") always returns undefined -- its own
parseCellReference requires at least one letter and one digit, which
an empty string can never supply -- so the loop's existing
`range !== undefined` check already discards an empty token with no
separate skip needed.
fast-xml-parser's own builder ignores the array's content entirely
for both the "pi" and "declaration" ordered-node shapes (verified
directly against the library), so a fresh per-call [] literal there is
a live mutation target with no test able to observe a difference.
Hoisting it to one array built once at import time keeps the exact
same runtime value while making it a static mutant instead, which the
workspace's shared Stryker config already excludes from the
valid-mutant count for exactly this reason.
…sPathFor

textContent's own cdata half was never exercised by any existing
fixture (every one used only <t:text> nodes); adds a mixed text+cdata
element proving both node kinds concatenate into one string.

relsPathFor's fileName ternary is redundant in the same way its own
sibling functions elsewhere in this package already are:
slice(-1 + 1) is slice(0), which returns the whole string unchanged --
exactly what a slash-free path needs -- so partPath.slice(lastSlash + 1)
alone already covers both cases correctly.
…anch

ContentCellFillSchema only ever produces 'solid' or 'pattern' through
normal validated input, so the writer's own defensive default branch
naming the actual kind was never exercised. Passes a fill shaped like
neither, past the type system, and checks the thrown message names it.
walk, elementsWithTag, childrenWithTag, attr, rootElement, and
resolveRelationships had no test exercising them directly -- util.test.ts
covers only relsPathFor/resolveRelTarget/textContent. Adds cases for
depth-first descent order, direct-vs-descendant tag matching, a missing or
binary part, External vs internal relationship targets, a Relationship
element missing a required attribute, and entity-decoding both the Target
and Type attributes before resolution.
…gate

Extends the existing page-size-only suite with the module's remaining
branches: per-side margin fallback, pageOrder's default/overThenDown split,
gridlines/headers booleans, row/col break index reading (including a
non-numeric or negative id being skipped), the fitToPage/scale mutual
exclusion, and readPrintSettings' own print-area/print-titles integration
against defined-names.ts -- including the wrong-sheet-index and
fails-to-parse cases that were previously untested.
…parse

readDefinedNamesBySheet's own name-then-type check already excludes an
absent name, making the separate name===undefined arm dead. stripSheetPrefix's
ternary is a no-op in its own -1 branch, since slice(-1+1) is slice(0).
parsePrintAreaValue's split-then-undefined-check is replaced by an
indexOf/slice split that is never possibly undefined, dropping the
now-redundant length guard too (an empty first segment already parses to no
range on its own). The column half of parsePrintTitlesValue drops its letters
regex in favour of trying columnLettersToIndex directly, which already
rejects exactly the same inputs. buildPrintAreaValue now builds its dollared
reference straight from the range's own row/column indices instead of
formatting then re-parsing a plain reference with a regex, which also removes
a genuinely equivalent quantifier mutant the regex approach could never have
been made to fail on a multi-digit row.

Adds the direct-unit coverage this uncovered was missing along the way:
whitespace trimming around a printTitles segment, multi-digit row bands,
prefix/suffix garbage rejected on both sides of a row band, a mixed
digit/letter segment rejected as neither band, and a multi-letter column in
buildPrintAreaValue.
isSheetRuleOperator's own OR chain only had a couple of its eight literal
branches exercised, leaving the rest (notBetween, notEqual,
greaterThanOrEqual, lessThan, lessThanOrEqual) unproven. Adds a
parameterised test over every member, an explicit notBetween-with-formula2
case (formula2 is read for that operator too, not only between), and a
case proving formula1 is genuinely omitted, not written as undefined, when
the element carries no <formula1> child at all.
Number(undefined) is NaN, and the isFinite check right below already
rejects that exactly as it rejects any other non-numeric scale attribute,
so the separate scaleRaw!==undefined guard around it was dead weight.

Adds the margin/break/scale coverage this uncovered was missing: all four
margin sides read from distinct values (proving multiplication, not
division, and each attribute's own name), the top and left per-side
defaults specifically, a break at index 0, manualBreaks reporting when only
column breaks are present, and scalePercent staying omitted when the
attribute is absent entirely.
readChartTable and readChartResidue had no unit test exercising them
directly, only indirect coverage through a full xlsx round trip. Covers the
no-chart/no-plotArea/no-series early returns, cached points read via
c:numRef, a scatter series' c:xVal/c:yVal fallback and its precedence
against c:cat/c:val, a series name from either an inline c:v or a cached
string reference, the multi-level cached string reference's deepest-level
selection, points sitting directly on the source with no ref wrapper, a
c:pt missing idx or c:v being skipped, the numeric (not lexicographic)
category ordering, a shared category index keeping its first series' label,
and the chart residue cache's own per-element identity.
readDiagramText and readDiagramResidue had no unit test exercising them
directly, only indirect coverage through a full pptx round trip. Covers the
no-ptLst/no-doc-point early returns, node vs asst vs parTrans point-type
filtering, a:r/a:fld/a:br run handling, a paragraph list being kept whole
once any of its runs is non-empty (blank paragraphs included) and dropped
entirely when none are, srcOrd-based sibling ordering with a missing
srcOrd sorting as zero, depth-first traversal order, the parOf-only cxn
filter, a cxn missing srcId/destId, the visited-set cycle guard, and the
residue cache's own per-triple identity.
readLevelOverrides' startOverrideVal!==undefined guard had no test for its
own false side while base was genuinely defined: every existing case either
supplied a real w:val or targeted a level the abstractNum did not define at
all, so a w:startOverride element present with no w:val attribute was never
proven to leave the base level's startAt untouched.
… second

.at(-1) and .at(+1) coincide on a two-level cache, so the earlier test
proved nothing about which end readCachedPoints actually reads from. A
three-level fixture makes the two genuinely differ. Also adds a case for a
cached point whose c:v is present but genuinely empty, proving labelCell's
own text===\"\" branch is exercised, not merely its text===undefined one.
The a:br test alone let its own condition mutate to an unconditional true
survive undetected, since every other child in that fixture is a:r/a:fld
and never reaches the elseif branch at all. Adds a paragraph carrying a
genuinely unrecognised child tag between two real runs, proving it
contributes neither text nor a stray newline.
When w:val is genuinely absent, each of the three inequality checks is
already true on its own (undefined !== "0", etc.), so the combined check
already reads absence as on -- the separate val===undefined arm changed
nothing.
…erge

findStyle/findDefaultStyle only ever ran against fixtures with one style
type present, so a same-styleId style of the wrong type, or a default-style
flag on the wrong type, was never proven to be rejected -- both checks in
each function's AND could silently degrade to always-true without a test
noticing. Adds: type-vs-styleId and type-vs-default discrimination, the
default paragraph style's own w:pPr being merged in above docDefaults,
strike's inheritance through a basedOn chain (mergeRunLayer's ?? fallback
on strike specifically, not just the sibling fields other tests already
cover), majorAscii/minorAscii alongside their HAnsi spellings, a bare
w:u with no w:val, w:ind/@w:start as w:left's fallback, "distribute"
alongside "both" for justify, atLeast alongside exact for the
lineRule guard, and a themeTint byte with a stray character before or
after its two hex digits.
readRunFontFamily's minorHAnsi/minorAscii check had no test for a value
matching neither branch, so its own condition could degrade to an
unconditional true (always returning the minor theme font) without any
existing test catching it.
Two guards in cut() never change its observable output for any input:
shapes.length<=1 short-circuits an early return, but with at most one
shape splitOnGap always yields a single group and a zero widestGap on
both axes, so both ratios are 0, neither group-count check can pass, and
the function falls through to the final sort -- a no-op on an array that
short -- returning the input untouched regardless. columns.groups.length>1
alongside the ratio comparison is implied by it: splitOnGap only raises
widestGap above 0 by actually pushing a second group, so a positive ratio
already guarantees at least two groups exist.
… math

Adds cases the existing geometry fixtures never exercised: an exact tie
between the two axes' relative gaps breaking to rows rather than columns,
overlapping shapes sorted correctly when the primary (y) and secondary (x)
keys point opposite ways, a genuine y-tie broken by x, a row needing its
own internal column cut once split out (rather than the whole set's flat
sort coincidentally landing on the same order), and extentAlong computing
a true span rather than a start+end sum (exposed by shifting one axis's
coordinates far from zero while leaving the other near it).
…metic gaps

Proves two real behavioural distinctions splitOnGap's own boundary math
depends on: a strictly-greater comparison is required so two shapes touching
exactly at a shared edge are grouped together rather than wrongly split
apart, and the gap itself must be a subtraction (distance) rather than a
sum, since summing a large preceding reach into the gap can inflate the
wrong axis's ratio and flip which axis wins the cut.

Also drops two of the function's own remaining guards once their necessity
is disproved: ratio's extent-zero branch, since extentAlong being exactly
zero forces every gap on that axis to be zero too, so the unguarded
division's NaN loses every comparison exactly as the guarded zero already
did; and splitOnGap's trailing current-length guard, since a non-empty
input always leaves current non-empty at that point regardless, and an
empty input's resulting phantom group is never inspected by its only
caller.
…re builders

Unzips each of minimalXlsxBytes/minimalDocxBytes/minimalPptxBytes and decodes
its content-types override and root relationship target back to text,
asserting on the exact markup rather than relying on downstream readers --
every consuming suite tolerates a malformed embedded payload by falling back
to the plain picture, so a mutant collapsing any of these strings to empty
still passed every test that merely used the fixture rather than inspecting
its own bytes.
…r-field gaps

Adds direct coverage for firstElementText's empty-text branch (a present
but textless element must read back as undefined, not ""), readKeywords'
blank-entry filtering (a doubled or trailing comma, or comma/whitespace-only
text, must never leave an empty string in the array, and must collapse an
all-blank result to undefined), removeChildrenWithTag's own selectivity
(removing cp:keywords must leave every other element untouched), the
no-root-element throw, and author/subject each being set independently of
one another and of title.

Also proves patchCoreProperties genuinely removes an emptied cp:keywords
element from the XML rather than writing an empty one, asserting on the
serialized markup directly rather than through the entity-decoding reader.

Drops namespacePrefixOf's unreachable "no colon" branch: every real caller
(ensureNamespaceDeclared, for one of the four always-prefixed tags this
module ever creates) only ever passes a colon-qualified tag, so the branch
handling its absence, and the caller's own dead check for an undefined
prefix, can never actually run.
…finitions writing

Covers collectTableEntries' own filtering (a non-table entry is skipped
without ever validating its fields) and per-field validation (each of
name/ref/sheet/columns throws naming itself and the entry kind when absent,
and a columns array is rejected the moment even one entry isn't a string,
not only when none of them are), buildNameDefinedNameElements' own
scopeSheetIndex encoding (a defined, truthy scope is carried as itself, an
absent one falls back to an empty suffix, not a placeholder), and
buildTablePart's exact CT_Table shape: its own required attributes, an
autoFilter over the entry's ref, and one 1-based tableColumn per column in
order.
Adds direct rgbToHsl/hslToRgb coverage: every max===r/g/b hue branch (with
the g<b wrap term isolated from a g===b tie, which must NOT add it), and
lightness both below and at-or-above the 0.5 saturation/lightness pivot.
Also covers the sRGB gamma functions' own thresholds and arithmetic through
a 100% shade (an identity transform on the linearised value), including
boundary-exact inputs at 0.04045 and its 0.0031308 linear preimage.

Removes three redundant branches once their non-effect was proven exact
rather than assumed: hslToRgb's s===0 achromatic shortcut (the general
formula already collapses to l on every hueToRgbComponent path once
p===q===l, which s===0 forces regardless), rgbToHsl's saturation piecewise
split (1 - |2l-1| equals both the below- and at-or-above-midpoint formulas
by construction, including exactly at their shared boundary), and
hslToRgb's own l<0.5 piecewise split (l + s*Math.min(l, 1-l) likewise
matches both, without ever comparing l to 0.5).

Restructures hueToRgbComponent's hue wrap and its final two pieces for the
same reason: a floor-based mod (hue - Math.floor(hue)) replaces the
explicit "< 0 add 1" / "> 1 subtract 1" guards, whose own boundary values
(hue exactly 0 or 1) reach the identical result either way, and unlike the
more familiar double-mod form it leaves an already-in-range value bit-
exact, preserving the two piece boundaries (t === 1/6, t === 1/2) that are
NOT equivalent for a real, floating-point-exact boundary test. The final
"t < 2/3" piece and its "else return p" fallback are folded into one
Math.max(0, 2/3 - t)-clamped expression, since (2/3 - t) is exactly 0 at
their shared boundary regardless of which side "< 2/3" includes.
Adds a workbook rels Target with no leading slash and one carrying a
leading slash, each naming its sheet something other than the filename-
derived Sheet<N> fallback -- every existing fixture named its sheet
"Sheet1", indistinguishable from what a completely broken rels correlation
would fall back to on its own, so a bug in resolveRelTarget or relTargets
could silently coincide with the right answer.

Also proves worksheets are ordered by their own numeric suffix rather than
the package's part insertion order, inserting sheet3/sheet1/sheet2 out of
sequence and asserting the read-back order is 1, 2, 3.
…oundary

The previous (s=0.8, l=0.6) pair happened to round-trip the low-piece
formula back to q exactly at t === 1/6, coincidentally matching the correct
(q-branch) result and leaving the boundary comparison unkilled. s=0.73/
l=0.29 is one of the pairs where that rounding measurably misses q instead.
@Mearman
Mearman force-pushed the feat/100-percent-mutation-ooxml.js branch from 7e4ae1a to 01b9d39 Compare September 13, 2026 20:54
…sform gaps

Adds per-attribute coverage for readXfrm/readGroupXfrm's required-field
checks: each of x/y/cx/cy (and chOff/chExt's own cx/cy/ccx/ccy) missing on
its own, isolating every OR clause from the others and from the earlier
"element itself absent" guard, which the existing tests only ever exercise.

Covers readThemeSlotColor/readClrScheme directly: a colour-scheme child
that is neither a:srgbClr nor a:sysClr resolves to no colour at all, a
non-element child (whitespace text) is skipped to find the real colour
element, and a sysClr's lastClr is read over the windowText/window
fallback even when they would otherwise coincide (every existing fixture's
lastClr happened to already match its own fallback). Also proves a
transform child with no val attribute is skipped rather than included.

Covers canonicalizeGroupRotation's own flipH+flipV (cancels to a pure
180deg-shifted rotation, not a mirror) and lone-flipV (a 180deg-shifted
mirror) cases via composeGroupTransform, and applyGroupTransform's own
child-offset subtraction (previously only ever exercised with childOffXPt/
childOffYPt at zero, where addition and subtraction coincide) and its
identity-shortcut boundary (a mirrored group with zero rotation must still
take the centre-rotation path, not the unrotated shortcut).

Extracts composeAngleDeg out of composeRotation so composeShapeRotationDeg
can compute its own angle directly: the function only ever read the
angleDeg half of composeRotation's result, so the `mirrored: false` it had
to fabricate for the unused inner-mirrored input never affected anything
composeShapeRotationDeg actually returned.
lastIndexOf returns -1 for an unprefixed tag, and tag.slice(-1 + 1) is
tag.slice(0), the whole string unchanged -- exactly what the branch
existed to return, for every possible tag rather than merely the ones
this file happens to see. The ternary's own comparison is never actually
reachable as a distinct outcome, so the unconditional slice already
computes the same result on its own.
…rtcut

With rotationDeg 0 and no mirror, Math.cos(0) and Math.sin(0) are exactly 1
and 0 (multiplying/dividing by zero introduces no floating-point error), so
the general rotate/mirror path already reduces algebraically back to the
plain canonical box the shortcut returned directly. The shortcut only ever
skipped work that was going to produce the identical answer.

Also merges canonicalizeGroupRotation's flipH-and-flipV and flipV-only
branches into one: both add the identical 180deg shift, differing only in
mirrored (exactly !flipH either way), so the same "+ 180" no longer needs
to appear twice.

Adds a negative-subtraction composeGroupTransform case (every existing
mirrored-parent test lands on the positive side of normalizeDeg's own
wraparound) and, for the removed shortcut, a mirrored/zero-rotation case
proving the general path is exercised rather than short-circuited.
… mutant

Every caller normalises the returned angleDeg modulo 360 eventually, and
(x + 180) mod 360 equals (x - 180) mod 360 for every x since the two
differ by exactly 360 -- no test built on this function's own observable
contract can ever tell the two apart here, for any input, not just the
ones a test happens to try. Recorded explicitly rather than left
unexplained.
…irectly

CompactXmlNodeSchema was only ever exercised through round-trip package
fixtures built from real docx/pptx XML, so every well-formed shape the
guard accepts was covered but none of its rejection branches were: a
malformed length, a wrong-typed slot, an unrecognised leading type code,
or an element whose attr pairs or children fail their own nested check.
Test CompactXmlNodeSchema.safeParse directly against the full positive
and negative shape for every CompactXmlNode variant (text/cdata/comment,
declaration, pi, element), including a code that satisfies the element
shape by coincidence so the code===0 branch guard itself is exercised.

Also close the remaining gaps in compact.ts's package-level codec: a
round-trip through a cdata node and a processing-instruction node (never
exercised via decodePackage/zipPackage's own XML sources), and the two
error paths in fromCompact -- an out-of-range string-table index and an
odd-length attribute index-pairs array -- via directly constructed
CompactPackage fixtures rather than only ever-valid ones.
…ssignment

entry.author/createdAt/parentId and comment.author/createdAt/comment.replies'
per-item author are optional fields; every consumer (ContentSheetCellCommentSchema,
this codebase's toEqual-based tests, and JSON serialisation) treats an explicit
undefined value identically to the key being absent altogether, so a presence
guard before each assignment was only ever a no-op. Also simplify
relatedPartPaths' accumulation loop to a filter/map chain and drop
readThreadedComments' early return on an empty partPaths list, since the loop
below already does nothing when there is nothing to iterate.
…d thread-ordering gaps

relatedPartPaths' relType filter had no test proving it actually excludes a
wrong-typed relationship whose target happens to be a validly-shaped legacy
comments part; childrenWithLocalName's own filter had no sibling of a
different tag to exclude. readLegacyCommentText's <t>-run concatenation had
no case where it differs from the text element's own whole-subtree content
(a stray text node outside any run). The empty authors-list fallback had no
case where a comment references an authorId with no <authors> element at
all. readThreadedComments' root-detection (find by parentId undefined, ??
group.at(0) fallback) had no case where a reply is written before its root
in document order -- every existing thread fixture already had its root
first, so document order alone happened to pick the right entry regardless
of whether parentId was read correctly.

Document normalizeGuid's toLowerCase as a genuinely irreducible equivalent
mutation opportunity: its only observable effect anywhere in this file is
guid equality, which folding to either case produces identically.
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