Skip to content

test(odf.js): work toward a 100% Stryker mutation score - #1260

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

test(odf.js): work toward a 100% Stryker mutation score#1260
Mearman wants to merge 68 commits into
mainfrom
feat/100-percent-mutation-odf.js

Conversation

@Mearman

@Mearman Mearman commented Sep 12, 2026

Copy link
Copy Markdown
Member

Works through odf.js's mutation survivors from its own first CI-measured baseline (71.31% of 8442 valid mutants, timeout share 1.3%). This is the largest package under this effort so far by a wide margin, so this PR lands real, verified progress rather than a finished 100% run.

So far:

  • Pins the ods writer's cell-run/cell-value boundaries that had no direct coverage (isBareNewlineRun's nine-field predicate, formatOdfDuration's regex and fraction branches, writeCellValueAttributes' boolean/currency/exactValue branches, cellSourceRuns' empty-displayText fallback), and exports and directly unit-tests every canonical* helper normaliseOdsContent is built from.
  • Pins typed/shared/canonicalise.ts (the odt/odp/odg/draw-vector writers' own round-trip canonical form) the same way: every field-by-field branch of canonicalParagraph/canonicalCell/canonicalTable/canonicalMetadata/canonicalImage, plus the colSpan/rowSpan covered-grid loop's own off-by-one boundaries and the per-cell list-run scoping. Also removes two closeListPlan calls proven dead (each unconditionally overwritten by a later call before anything could ever observe their own effect) and simplifies a boundary-set construction that carried two inert elements.
  • Adds the first direct unit coverage for typed/shared/units.ts (isLengthUnit, expandExponential, parseOdfAngleDeg -- the last had no coverage at all), typed/shared/metadata.ts (buildOdfMetaNodes/writeOdfMetadata/ensureNamespaceDeclared), typed/shared/list.ts (had no test file at all), typed/shared/expression.ts (likewise no test file), and typed/shared/forms.ts (also no test file, only ever exercised indirectly through odt/odb round-trip fixtures) -- forms.ts now sits at 100% with zero survived and zero no-coverage mutants of its own.

A full re-run after this batch confirms 76.52% of 8451 valid mutants, up from the 71.31% baseline (timeout share still ~1.3%). breakThreshold is raised to 74 (the derived floor for this measured score), still provisional.

Remaining work, tracked honestly rather than hidden: the bulk of odf.js's other files still carry their own share of the roughly 2000 currently-unkilled mutants (Survived + NoCoverage) and have not been touched yet, ordered by rough size: typed/ods/write.ts (~213), ooo1/transform.ts (~197), typed/shared/constructs.ts (~155), typed/shared/paragraph.ts (~126), typed/ods/conditional-format.ts (~111), typed/draw/shapes.ts (~105), typed/odt/write.ts (~94), typed/ods/read.ts (~84), typed/odb/report.ts (~83), typed/shared/table.ts (~64), typed/odt/read.ts (~61), typed/odp/write.ts (~53), typed/draw/write-shapes.ts (~50), typed/ods/data-validation.ts (~50), manifest.ts (~38), model/node.ts (~36), typed/odb/read.ts (~34), xml/parse.ts (~33), package-io/read.ts (~27), typed/odb/write.ts (~25), and many smaller files.

No Stryker disable comments anywhere in this package (verified by grep before every commit and again before this update).

@Mearman
Mearman force-pushed the feat/100-percent-mutation-odf.js branch 5 times, most recently from 4bfbb1c to fee163d Compare September 14, 2026 10:03
…boundaries

isBareNewlineRun's own nine-field AND-chain (deciding whether a "\n" run
is a genuine text:p paragraph break or a formatted text:line-break) had
no direct coverage at all: existing tests only exercised the "all fields
absent" and "bold + hyperlink together" shapes, leaving every other
field's own undefined-check untested. Adds one test per field plus the
bare-newline case.

formatOdfDuration's regex and fraction branches, writeCellValueAttributes'
boolean/currency/exactValue branches, and cellSourceRuns' empty-displayText
fallback get the same direct coverage.

Exports canonicalColor/canonicalCellFill/canonicalRun/canonicalCellValue/
canonicalCell/canonicalCells/canonicalColumns/canonicalRows/
canonicalSheetImage/canonicalImages/canonicalPrintSettings/
canonicalDataValidations/canonicalConditionalFormatStyle/
canonicalConditionalFormats and pins each directly against a literal
expected value: normaliseOdsContent applies every one of these to BOTH
sides of the write-round-trip suite's own equality check (the real
reader's output and the original document, each normalised the same
way), so a mutation confined to one of these helpers changes both sides
in lockstep and was invisible to that comparison -- only a direct,
one-sided assertion can observe it.
canonicalCellFill's own pattern-fill fixtures used "gray50", which is
not a member of ContentCellPatternTypeSchema (the enum spells Excel's
own fixed-density greys as "mediumGray"/"darkGray"/"lightGray" through
"gray0625", never a percentage-style name). canonicalPrintSettings'
fitToPages/repeatRows/repeatColumns fixtures used field names from a
different schema shape (widthPages/heightPages, startRow/endRow,
startColumn/endColumn) rather than the real {width, height} and
{start, end} shapes ContentSheetPrintSettingsSchema actually declares.
…r-kind fill fields

regularPolygonSubpath/fixedPresetSubpath's own trig and midpoint arithmetic
had only a vertex-count assertion (it.each over diamond/triangle/pentagon/
hexagon/octagon), never the actual coordinates, so every arithmetic operator
in the angle/radius formulas could flip with nothing to catch it. Adds exact
coordinates for isosceles-triangle, right-triangle, and a full six-point
hexagon check against the same trig formula the source uses.

roundedRectSubpath's own eight segments (four straight edges, four cubic
corner arcs) were pinned only at the start point; the other seven segments'
own w-radiusPt/h-radiusPt/radiusPt-k/radiusPt+k arithmetic had no coverage
at all. Adds the full segment-by-segment coordinate check, plus the two
boundary cases (readRoundRectangleRadiusPt degrading to a plain rect for a
zero-width viewBox and for a resolved radius of zero) neither had a test.

fillPattern/fillOpacity's own "carry through only when defined" pair is
duplicated identically across readDrawRectVector, readDrawEllipseVector,
readDrawPathVector, and readCustomShapeVector, but every existing test
exercised it through draw:rect alone -- the other three vector kinds'
own copies were untested. Adds one fillOpacity case per remaining kind.
…op two dead closeListPlan calls

canonicalParagraph/canonicalCell/canonicalTable/canonicalMetadata/
canonicalImage are applied identically to both sides of every
odt/odp/odg/draw-vector writer's own round-trip equality check, so a
mutation confined to one of them changes both sides in lockstep and
was invisible to that comparison -- exactly the odf.js/typed/ods/
write.ts precedent this mirrors for the odt/odp/odg-facing sibling
module. Adds direct, one-sided unit coverage for every field-by-field
branch, the colSpan/rowSpan covered-grid loop's own off-by-one
boundaries, and the per-cell list-run scoping canonicalTable threads
through a shared ListPlanState.

Also removes two closeListPlan(listState) calls (canonicalTable's own
leading call, and the one immediately after canonicalCell inside the
per-cell loop): both are unconditionally overwritten by a later
closeListPlan before anything could ever observe their own effect --
the leading call by the per-cell close that precedes every cell's
canonicalCell invocation (including the first), and the post-cell
call by either the next cell's own leading close or, for the last
cell, the table's own trailing close. Neither was reachable by any
test because neither can ever change behaviour; deleting them removes
the mutation opportunity rather than chasing an equivalent mutant.

Simplifies canonicalParagraph's own protected-boundary set for
segmentOdfParagraphRunsMapped: the merge loop only ever tests
protectedBoundaries.has(index) for index in [0, runs.length), so a
stated {0, runs.length} pair contributed nothing to either branch's
outcome (index 0 never merges regardless, having no preceding group
yet; index === runs.length is never reached by that loop). The false
branch now passes an empty Set with no array literal to mutate; the
true branch keeps only the construct extents' own interior
boundaries, which are the one real source of boundary positions the
merge decision ever consults.
…ial directly

parseOdfAngleDeg had no test coverage at all -- deg/grad/rad conversion,
the bare-number-defaults-to-degrees case, and the malformed-input path
were all unexercised. Adds direct coverage for every branch, including
the grad/rad conversion constants.

Exports and directly tests isLengthUnit and expandExponential rather
than only reaching them through parseOdfLength/formatOdfNumber: both
are pure predicates/formatters whose own internal branches (the six-way
unit check, expandExponential's pointIndex <= 0 / >= digits.length
boundaries) were only ever exercised with values the caller's own
regex had already narrowed, leaving several branches equivalent in
practice. Direct string-input tests pin the exact pointIndex boundary
cases (0 exactly, digits.length exactly, strictly between, negative)
that were previously unreachable through formatOdfLength's own
floating-point call sites.

Replaces expandExponential's and parseOdfAngleDeg's own
sign/integerDigits/exponent === undefined re-checks with a plain
comment plus non-null assertions: each of those regex groups has no
`?` quantifier of its own (only the alternation inside them does), so
none can ever actually be undefined once the enclosing match is
non-null -- the re-check was dead code no input could reach, not a
guard against a real failure mode.
…t.ts

list.ts had no test file of its own at all: resolveOdfListKind,
mintOdfListNumId, buildOdfListStyle, writeOdfList, listKindOf,
canonicalNumId, planListMembership/closeListPlan, and
readOdfListParagraphs were exercised only incidentally through odt/odp
round-trip fixtures, which never happened to hit several of their own
branches (a level-1-only style-kind check, the nested-list
write-side's own level-jump/dedent handling, the run-boundary sentinel
planListMembership mints for a membership with no incoming numId).

Adds a dedicated suite covering every exported function directly:
each of the three list-kind resolutions (ordered/bullet/bullet-via-
image) and their negative cases, the ten-level style builder's own
per-level indent arithmetic, writeOdfList's nesting/dedent/level-clamp
behaviour, and the list-plan run-boundary semantics closeListPlan and
a changed incoming numId both need to preserve.
…ression.ts

skipExpression/takeExpression (the balanced-paren/brace/quote-aware
expression splitter both typed/ods/data-validation.ts's table:condition
reading and typed/ods/conditional-format.ts's calcext:condition reading
share) had no test file of its own -- both readers exercised it only
through their own real-world condition-string fixtures, which never
happened to cover brace nesting, single-quoted strings, an unterminated
quoted string running to the end of the text, or a whitespace-only
span correctly yielding undefined rather than an empty string.
…floor

A full run after this batch's own survivor kills (canonicalise.ts,
units.ts, metadata.ts, list.ts, expression.ts) measures 75.72% of 8444
valid mutants, up from the 71.31% baseline this threshold was
originally derived from. Still provisional -- the bulk of this
package's own files carry their own unkilled mutants and have not
been touched yet.
…ckage's XML sniff

hasUtf8Bom's compound bytes.length >= 3 && bytes[0] === 0xef && ...
condition mutated to several sub-clause "true"/"false" variants that
survived: through looksLikeXml alone, a wrongly-detected BOM and a
correctly-rejected one routinely land on the same XML/binary verdict
downstream (a too-short array ends the scan at the same byte either
way), so no test built only on the final classification could
distinguish them. Extracting it into its own exported, directly
tested function pins every boundary (a too-short array, each byte
individually mismatched) against its actual boolean return value
instead.

Also replaces the four-way whitespace disjunction (b === 0x20 ||
b === 0x09 || ...) with a Set.has() check, and the manual
index/while loop with a for...of over bytes.subarray(start):
both removed a class of survivable sub-expression and loop-boundary
mutants outright rather than chasing them through indirect
byte-array engineering, since a byte either belongs to the
whitespace set or doesn't and a subarray iteration carries no
explicit index comparison to mutate.
…ms.ts

readOdfFormDefinitions and readOdfFormControlConstructs had no dedicated
test file at all, only indirect exercise through odt/odb round-trip
fixtures that never varied every optional attribute, control-tag
mapping, or checkbox/listbox branch independently. Adds direct coverage
for every branch: each optional field on a form definition and on a
control (present and absent), nested form:form as subForm vs control,
form:properties/text-node exclusion from both control and subForm
scanning, every CONTROL_TYPE_BY_TAG mapping, the unmapped-tag richText
degrade with whole-element residue, current-value/value precedence,
checkbox/radio's current-state-derived checked field (and its absence
on every other control type), listbox option label/value precedence
and the neither-present skip, and per-control form:properties residue.

Switches this file's toEqual assertions on absence to toStrictEqual:
toEqual treats an explicitly-set `field: undefined` the same as the
field being absent, so an `if (x !== undefined) descriptor.field = x`
mutated to `if (true)` was invisible to a toEqual comparison even
though it changes the object's own own-property set.

Raises odf.js's mutation break threshold to the re-measured floor now
that typed/shared/forms.ts has zero survived and zero no-coverage
mutants of its own.
…eaders

Adds direct unit coverage for readUint16LE/readUint32LE/localFileHeaderNames/
assertMimetypeEntryLayout: truncated-input throws (including negative-offset
isolation of the first missing byte), exact-boundary reads, multi-entry
offset arithmetic through a non-zero extra field and compressed size, and
each of assertMimetypeEntryLayout's six field checks.

Also replaces the per-byte "b0 === undefined || b1 === undefined || ..."
guards in both readers with a single [offset, offset + byteCount) range
check. The per-byte form could never be killed in full: a real Uint8Array's
undefined region is always a contiguous prefix or suffix, so no input can
isolate an interior byte (b1 of 4, say) as the sole missing one, leaving
that comparison an unreachable, equivalent mutant. The range check has no
interior case to isolate.
…lpers

Adds direct unit coverage for the five kind-narrowing functions
(wordprocessingPackage/presentationPackage/spreadsheetPackage/
drawingPackage/formulaPackage), each checked both on a matching-kind
package (returns it unchanged, no throw) and a wrong-kind one (throws
the exact "expected a ... package, got ..." message) -- neither path
was ever exercised by odf.js's own reader suites, which only ever hand
these functions a correctly-kinded result.

Also covers assertPackageRoundTrip's three checks individually, each
isolated so exactly one fails while the other two still pass: a
schema-invalid tree via a malformed `fonts` field (schema-checked but
read by neither flattenTree nor factorStyles, which carries an
existing value through verbatim rather than recomputing it); a tree
that flattens to something other than the given content; and a tree
carrying an extra, unreferenced styles-table entry that a fresh mint
of its own flattened content would not reproduce.
Pins buildXml's pi/declaration/element/text/comment/cdata node mapping
directly, including the empty-array shape for pi and declaration nodes
that fast-xml-parser's ordered builder reads only from a node's own
":@" attributes rather than its array value, and the throw path when
the underlying XMLBuilder does not return a string.

Extends the package's existing no-deprecated exemption for the
deprecated XMLBuilder class from build.ts to its own test file, which
necessarily references the identical class to reach BUILDER's shared
prototype.
Factors the pi/declaration cases' shared "ignored by fast-xml-builder"
array literal into one constant typed as the empty tuple `readonly
[]`, turning a content mutation there into a type error rather than a
silent, unobservable survivor (fast-xml-builder never reads either
node shape's own array value).

Exports toOrderedNode so a test can pin the exact intermediate
ordered-node shape directly, in particular that an attribute-less
element's object carries no ":@" key at all rather than one holding
an empty object, a distinction the built XML string never renders
differently, so no test on buildXml's own output could observe it.
Covers PNG/JPEG/GIF87a/GIF89a magic-byte detection, the too-short and
empty-input cases, and SVG sniffing from either an XML prolog or a
bare root tag with leading whitespace, including the deliberate
window-size cap that lets a root element sitting past the first
kilobyte go undetected rather than scanning an unboundedly large file.

Removes startsWith's separate "bytes too short" guard: an
out-of-bounds Uint8Array read is undefined, which never strictly
equals a real signature byte, so the comparison loop already returns
false for a too-short input on its own, the guard produced no outcome
the loop didn't already produce.
Pins bytesToBase64 and base64ToBytes against the classic Wikipedia
"Man"/"Many hands..." progressive vectors, one per length mod 4 so
every padding branch is exercised both true and false, plus the
whitespace-stripping clean-up regex, the invalid-padding-position
throw, and the fixed-size scratch buffer silently bounding a
malformed, non-4-multiple-length decode rather than growing to fit it.
…text

columnLettersToIndex had no direct test at all: adds cases for a valid
uppercase reference and the three invalid shapes (lowercase, a
trailing digit, empty) that must return undefined rather than
delegate to the schema helper.

Sharpens TableCursor's repeat-count error assertions from a generic
/positive integer/ pattern match to the caller name itself
(TableCursor.nextCell / TableCursor.nextRow), since the generic
pattern alone can't tell the two call sites' own error text apart.
… mixed case

document-schema.js's own columnLettersToIndex uppercases its input
before validating, so it alone can't distinguish "aA" or "Aa" from
"AA"; each of odf.js's own ^ and $ anchors, if dropped, would let
exactly one of those two mixed-case strings reach the schema helper
undetected instead of being rejected up front.
Adds negative, non-canonical-spelling (leading zero), and exact-zero
text:c cases to getOdfSpaceCount's own guard, each isolating one of
its three disjuncts; adds a child-carrying bookmark/marker case to
measureOdfNodeLength and decodeOdfText, since an empty-children marker
can't tell "recursed into nothing" apart from "never recursed".

Drops the space-run scanner's redundant `end < text.length` bound: an
out-of-range string index is undefined, which is never `=== SPACE`, so
the comparison loop already stops there on its own.
…old.ts

Pins createOdfPackage's exact XML declaration, office:version stamping
(both the given version and the DEFAULT_ODF_VERSION default), the body
element nesting inside office:body, and the mimetype part it writes.

Covers both of odfPartContainer's throw paths directly: a part path
that resolves to a non-XML part, and an XML part with no container
matching the requested tag, alongside the already-implicit success
path returning a real container.
…der.ts

Covers parseBorderEdge's whitespace tolerance (leading/trailing, and a
run of several spaces collapsing to one separator), its wrong-token-
count/unparseable-length/unparseable-colour/zero-or-negative-width
rejections, the none/hidden marker, an unmapped style token leaving
style unset, and formatBorderEdge's own solid-style default.
Pins resolveOdfListKind's undefined-style-name short-circuit against a
package carrying a real, matchable, ordered list-style whose own
style:name attribute is absent (so attrValue coincidentally also
resolves to undefined), and extends the existing "only a level-1
child counts" case from the ordered path to the bullet and image
paths, each previously untested.

Pins buildOdfListStyle's ordered branch to also carry the indent
properties every level already carries on the bullet branch, and adds
a readOdfListParagraphs case where an item child's tag is neither
text:p/text:h nor text:list, carrying its own text:list-item/text:p
descendants specifically so a wrongly-permissive recursion would
surface them.

Drops writeOdfList's redundant tag check on an existing host: every
element this function ever pushes onto an "enclosing" list's children
is already a text:list-item via its own construction, so an element
found there carries no other tag to distinguish from it.
Adds cases for rotate() called with no argument, an unmodelled
function's own args never being parsed as translate's just because
they happen to look like valid lengths, and a run of several spaces
between translate's two arguments collapsing to one separator.

Drops parseOdfTransform's redundant name/argsRaw undefined guard:
FUNCTION_PATTERN's two capture groups are both plain, non-optional
captures, so a successful match always populates both. Replaces the
split-then-filter empty-string removal with an explicit empty-argsRaw
check, the only case split can actually misbehave on given
FUNCTION_PATTERN's own surrounding whitespace trim.
Types the shared "no arguments" empty array as the tuple `readonly
[]`, matching build.ts's own NO_ORDERED_CONTENT pattern: a content
mutation there is now a type error, not a silent, unobservable
survivor.

Drops rotate's separate angleArg-undefined guard: Number(undefined) is
NaN, which the isFinite check right below already rejects identically
to a genuinely present but unparseable angle.
Switches the empty-office:meta case from toEqual to toStrictEqual:
toEqual ignores explicit undefined-valued properties, so it couldn't
tell a genuinely absent metadata field apart from one of the six
per-field guards wrongly firing and setting metadata.<field> =
undefined.

Adds a dedicated xmlns:meta-declaration case for the keywords loop's
own ensureNamespaceDeclared call (title/author/subject instead go
through setElementText, already covered), and a case for meta.xml
being a well-formed XML part with no root element at all.
Adds a nested "math:math"-prefixed root case that requires
findMathRoot's descendant search to continue past "math" (tried
first, absent here) rather than stopping there -- the existing nested
case only ever needed the first tag to succeed.
Adds a non-element text node and a differently-tagged element among
office:text's own children, confirming both are skipped rather than
passed to readSection.
Drops the b1/b2 fallback-to-0 ternaries in the encode loop: past the
input array's own end, bytes[i+1]/bytes[i+2] are undefined, and
undefined >> n coerces to 0 identically to the explicit fallback --
the trailing "=" ternaries already decide whether this position ever
renders at all, so the fallback produced no outcome the shift
operator's own coercion didn't already produce.
serializePackage's own returned zip bytes can never reveal a
duplicated manifest push: zipPackage builds a plain object keyed by
path, which silently collapses two same-path entries into one
regardless of whether the duplicate happened. Extracts the ordering
logic into its own orderedPackagePartPaths function, returning bare
paths a test can inspect directly, with the hoisted-path exclusion now
a filter predicate rather than a delete-then-iterate step some future
change to this function could skip without any test noticing.
serializePackage's new per-part stored flag (path === MIMETYPE_PART)
had no test confirming a non-mimetype part is genuinely NOT also
stored; adds a compression-method reader alongside
localFileHeaderNames and checks a real content.xml part deflates.
The prior decoy (a stray text:p with no text:section-source) can't
tell "skipped because its tag isn't text:section" apart from "reached
readSection, which found nothing to read there anyway". Adds a
second decoy that DOES carry a genuine text:section-source child, so
a version that reached readSection on it would produce a spurious
section.
…mutants

Removes three provably-dead branches rather than testing them:
splitNode's per-half undefined checks when splitting a text:span (an
offset strictly between 0 and a node's own length can never make the
recursive splitChildrenAt come back with an empty before or after, so
the object is always constructed), and splitChildrenAt's own
offset<=0 early return (offset is never negative given every caller's
own bounds, and offset===0 is already handled identically by the
loop's own remaining===0 check). Rewrites the loop's indexed for as a
for...of over children.entries(), removing the index<children.length
comparison entirely rather than leaving it for a length-boundary
mutant to survive against.

Adds tests for the two remaining genuine behaviours: ensureSpan
wrapping more than one middle node must carry every one of them into
the new outer span, not just the first (verified by asserting the
inner split-off span's own content alongside the trailing text node);
setStyleName finds its target attribute by name, not by position, so
an unrelated attribute preceding text:style-name is left untouched; a
text:s split into two count=1 halves omits text:c on both rather than
writing it out for the implicit default. Exports splitNode so its own
fractional-offset defensive throw -- unreachable through ensureSpan's
public entry point, which validates every offset as an integer -- can
be exercised directly.
…red mutants

Removes three genuinely equivalent statements rather than testing
them: gc's `index !== -1` guard before splicing (every element it
ever splices was placed into automaticStyles.children by this same
class, and nothing removes a name from knownStyles without also
splicing its element in the same step, so indexOf can never actually
return -1 here); mintName's own reservedByFamily registration for a
name it just minted (the per-family counter only ever advances, so
no later mintName call for the same instance can revisit a counter
value it already produced); and gc's nameToFingerprint cleanup (that
map is only ever read for a name still in knownStyles, and this same
gc step just removed it from knownStyles for good, so nothing will
ever read the entry again).

Adds tests for the two remaining genuine behaviours: prefixesForPart
resolves a part path by its own base name, not the full path, so a
nested embedded-object content.xml is still recognised; automatic-
styles insertion respects office:master-styles and office:settings as
insertion boundaries, not only office:body; adoption and the office:
styles/otherPart reservation scan both check a candidate child's own
tag, not merely whether it carries style:name/style:family-shaped
attributes; an adopted style's own style:parent-style-name feeds its
fingerprint, distinguishing it from a request with no parent; and
gc'ing either a minted or an adopted fingerprint-matchable style
forgets its fingerprintToName entry, so a later identical request
mints fresh instead of returning a name that no longer exists.
Drops the dead starMath spread from writeOdfFormulaContent's
document object: writeOdfFormulaMathMl never reads document.starMath
(see its own top-of-file note -- the StarMath annotation round-trips
verbatim as part of the mathml nodes, never re-synthesised from this
field), so carrying it through the request object was inert either
way and the whole conditional spread was mutation-invisible.

Adds tests for the remaining genuine behaviours: the XML declaration
actually carries version 1.0 and encoding UTF-8, not an empty
attribute set; the given metadata and version option actually reach
meta.xml and the manifest's root entry, not just the defaults; and
contentUsesMathPrefix recurses into a math:-prefixed tag nested two
levels deep, not only one carried at the mathml array's own top
level.
Drops the otherPart cross-check from the content.xml StyleRegistry
construction: createOdfPackage just built this exact package from
scratch a few lines above, so styles.xml's own office:automatic-
styles is always freshly empty at this point -- there is no
pre-existing style:style anywhere in it for a scan to find, since
nothing (this call included) has written to styles.xml yet.

Adds tests for the remaining genuine behaviours: normaliseOdgContent
refuses a non-drawing document by its exact kind-naming message;
style:print-orientation is landscape for a page wider than tall, not
only ever portrait; a page's own master-page/page-layout names are
asserted at their exact 1-indexed spelling, not merely "defined and
distinct"; a footnote anchor in shape text only writes when writeOdg
actually threads the tree's own definitions table through to the
shape writer; and a custom version option reaches the manifest both
from writeOdgContent directly and via writeOdg's own separate final
sync, each distinct from the DEFAULT_ODF_VERSION they'd otherwise
silently share.
Exports normaliseObjectHref for direct unit testing: every one of its
four rejection clauses (empty, "..".-prefixed, "/"-prefixed, a "://"
substring), if silently skipped, still leaves subDocumentPackage's own
lookup failing for a different reason against any package a black-box
readDrawObjectReference test could build (a mismatched or empty
re-keyed part set) -- making a mutation there unobservable through
that entry point alone, so the function is tested directly instead.

Adds a dedicated describe block for readEmbeddedObjectDocument
covering every ContentEmbeddedObjectKind by name (wordprocessing,
presentation, drawing, spreadsheet, formula, chart) -- previously
untested altogether, despite readDrawObjectReference's own suite
covering only which KIND a sub-document resolves to, never that
readEmbeddedObjectDocument actually dispatches each one to its own
typed reader.
Recompute the real-fixture report in beforeEach rather than directly in
the describe body. Stryker's per-test mutation coverage only attributes
an executed statement to a specific test when that statement runs
inside that test's own tracked window; code that runs once at
describe-collection time (or even in beforeAll, which is also not tied
to any single test's execution) is permanently unattributed, so every
mutant only that fixture's assertions could kill showed as an
unkillable survivor regardless of how thorough those assertions were.

Add synthetic cases for every optional field's genuinely-absent path
(no groupExpression/sortExpression/sortAscending/header/footer on a
group, no command/commandType/caption/mimeType/reportHeader/pageHeader/
detail/pageFooter/reportFooter on the report, no table:name on a band,
no rpt:report-component/rpt:formula on a control), a false boolean
attribute, a direct text-node child of a control, an rpt:report-element
carrying its own stray text (which must never leak into the control's
text), a non-rpt: element that happens to nest an rpt:report-element
(must never be mistaken for a control), a non-rpt:function element that
happens to carry rpt:name/rpt:formula (must never be mistaken for a
function), and a binary (non-XML) sub-document part. Switch the
label-splitting assertion to toStrictEqual so an explicitly-undefined
name/formula can't hide behind toEqual's undefined-key equivalence.
…ormat.ts

Add direct unit coverage for readTargetRangeList, parseA1WithOptionalSheetPrefix,
readConditionalFormatStyle, and synthesiseConditionValue across every branch and
operator, plus their surrounding condition-parsing and rule-serialising helpers.

Simplify three redundant guards that duplicated a fallthrough the next line or
case already handled identically: the empty-part check in readTargetRangeList
(no ':' to find either), the sheet-prefix ternary in
parseA1WithOptionalSheetPrefix (lastIndexOf's -1 already slices correctly), and
the empty-chain check in readConditionalFormatStyle (an empty chain already
yields no background/color). Collapse containsBlanks/notContainsBlanks into the
same fallthrough as the non-calcext condition kinds in
synthesiseConditionValue, since a duplicate `return undefined` on its own case
label produced no observable difference from falling through to the next
label's identical return.
Cover a duplicate-values rule read back through readConditionalFormats
end to end (the earlier "unique/duplicate" test never actually fed a
duplicate calcext:value through it), and every top-elements/bottom-elements/
top-percent/bottom-percent condition with no parenthesised operand at all.

Simplify the rank branch's own redundant undefined guard: Number(undefined)
is NaN, which the following Number.isFinite check already rejects, so a
missing expr1 and a non-numeric one already degrade to the identical
"not a valid rank" outcome -- the earlier explicit check caught nothing
the next line didn't already catch on its own.
…on.ts

Add direct coverage for parseToken's malformed-input branches across every
condition kind (function0/comparison/function1/function2), operand trimming,
the secondary-clause kind check, and synthesiseContentValidationCondition's
full write-side grammar. Several of the malformed-input tests specifically
target a fallthrough where a missing "()"/operand check alone wasn't enough
to observe a difference (a downstream check happened to produce the same
final result by coincidence) -- these use a secondary-clause context, or a
character sequence that would otherwise be mis-parsed as valid content, so
the wrong branch's own output is actually distinguishable from the correct
one.

Cover every table:display/table:title/text:p-body combination on both
table:help-message and table:error-message, checking key presence (not just
value) since promptTitle/errorTitle are only ever conditionally assigned --
an absent key and an explicitly-undefined one read identically through `?.`
but are not the same object shape.

Narrow betweenClause's own operator parameter to "between" | "notBetween":
both call sites already guard to that pair before invoking it, so the
ternary's own third branch (an operator that's neither) was never reachable
through the public API -- removing it drops the return type's undefined
case along with the now-pointless suffix-undefined check.

Simplify parseToken's leading-whitespace skip to drop its own redundant
`searchStart < text.length` bound: text[searchStart] for an out-of-range
index is always undefined, never " ", so the character check alone already
stops the loop at the end of the string.
…eddedObjectPart

insertOdfConstructMarkers' early return for an empty extents list is
redundant: with nested and openingAt both empty, the general loop
already reduces to copying every defined block in document order,
exactly what the guard's own [...blocks] produced -- the two paths are
behaviourally identical, so the guard is dead weight, not a real
branch.

isEmbeddedObjectPart is exported so its regex's three boundary facts
(must start with "Object ", digits-only after that, must end there)
can be pinned directly -- black-box testing through its only two
callers can observe "quarantined or not" but never distinguish a
loosened anchor from the correct one.
Every function in this module was previously exercised only
indirectly, through odt/read.ts's readOdtContent and various write
paths, which left a large share of its own branches -- write-side
functions with no reader at all (writeOdfTrackedChanges,
odfRunConstructWriteKind, writeOdfDivision, writeOdfIndexWrapper),
private helpers reachable only through several layers of indirection
(findSectionStyleElement, isContentBearingNode, readOdfFieldMasterEntry),
and edge-detection/pairing logic whose OdfMarkerHalf/OdfMarkerEvent
inputs are otherwise only ever produced by a real paragraph walk
(pairOdfMarkerHalves, resolveOdfMarkerEvents) -- with no direct test
observing them at all.

Builds every fixture as plain XmlElement/Package literals, or as
directly-constructed OdfMarkerHalf/OdfMarkerEvent objects for the
pairing functions, rather than routing through a full paragraph/
document read just to reach one function's own boundary conditions.
"sdt" is not a member of ContentControlType; use "richText", a real
non-index control type, for the pass-through-unchanged test case.
A first verification pass against the earlier direct-coverage batch
left 20 mutants standing, every one masked by an inner guard, a
default-descriptor fallback, or vitest's own toEqual treating an
explicit undefined property the same as an absent one, rather than by
missing coverage outright:

- toHaveProperty checks replace toEqual wherever the assertion needs
  to distinguish a genuinely absent optional field from one explicitly
  set to undefined.
- The paragraph-edge guard test for a mismatched half.parent now uses
  a parent that genuinely contains the half element, so a bypassed
  guard would actually reach edgePosition instead of being caught by
  the unrelated indexOf === -1 check right after it.
- Every pairOdfMarkerHalves/resolveOdfMarkerEvents start/end-count test
  now gives its halves a real resolving descriptor, so a bypassed
  length check would actually build an extent rather than being masked
  by the unrelated "descriptor resolved to undefined" guard further
  down.
- insertOdfConstructMarkers' sort-order test now passes its two
  extents in the wrong initial order, so a collapsed comparator (a
  stable sort's no-op) is distinguishable from a real sort.
- collectOdfNamedExpressions' missing-name test now supplies a real
  cell-range-address, and gains the named-expression-side counterpart
  of its existing named-range baseCellAddress-absent test.
- canonicalOdfConstructDescriptor and odfRunConstructWriteKind each
  gain a test proving their own compound guard's clauses are
  independently load-bearing, not merely redundant with each other.
- odfIndexWrapperTag's -source suffix check gains a case (a tag whose
  last seven characters are NOT "-source" but whose first characters,
  once blindly sliced, spell a real wrapper tag by coincidence) that a
  weakened endsWith check would silently accept.
…e branches

The second verification pass left one Survived mutant and nine
NoCoverage ones -- code paths no existing test (direct or indirect)
ever exercised at all:

- compareOdfExtents' start-index comparison had no test proving it is
  the PRIMARY sort key rather than merely correlated with the
  end-index one the existing outermost-first test already covers;
  adds a case where the two clauses would sort oppositely.
- addOdfPackageResidue's concatenation branch (a key that already
  holds a value) had no test at all -- every existing caller only ever
  added a fresh key.
- isContentBearingNode's catch-all false for a non-text, non-element
  node (a comment, in practice) had no test -- every existing fixture
  built only text and element nodes.
- parseOdfFieldInstruction's own throw, writeOdfChangePoint, and
  writeOdfAnnotationHalf's dateIso branch had no direct test at all.
- writeOdfTrackedChanges' defensive throw for a change kind with no
  ODF region spelling (moveFrom/moveTo, refused by every real caller
  before reaching this function) is exercised directly, past the
  type's own restriction to insertion/deletion/formatChange.
…Threshold comment

Keeps the comment's own inventory of packages still carrying unkilled
mutants accurate now that shared/constructs.ts has reached zero
survivors and zero no-coverage mutants of its own.
A db:server-database with no db:type attribute now has direct
coverage: readOdbInventory resolves its connection as a bare
{ type: "external" } with no url, exercising the early return this
RNG-derived, never-empirically-observed code path previously had no
test for at all.
Two constructs had no test-observable difference between their two
branches, so no test could ever kill a mutant on them:

- walkDrawShapes and walkDrawPageContent each special-cased an empty
  draw:g transform-function list to reuse groupFunctions unchanged
  rather than spreading it; spreading an empty ownFunctions ahead of
  groupFunctions produces identical content either way, so the
  special case was a pure allocation micro-optimisation with no
  behavioural effect. Both now always spread.

- parseOdfPercentUnit's PERCENT_PATTERN capturing group has no `?`
  quantifier, so it always matches once the regex itself matches;
  the undefined check on match[1] was unreachable at runtime and
  existed only to satisfy noUncheckedIndexedAccess. Replaced with
  the same match[1]! non-null assertion typed/shared/units.ts's
  parseOdfLength and parseOdfAngleDeg already use for the identical
  mandatory-capturing-group guarantee.
Direct unit coverage for every previously-untested branch in
readFrameAltText, readFloatPosition/readDrawImageBlock,
readDrawFrameContent's text-box child dispatch, embedded-object
residue, readDrawFrame's flowPositioning opt-in, the gradient/hatch
draw:style validation and optional angle/rotation fields, and the
dash-pattern dots1/dots1-length/distance/dots2 boundary conditions:

- An svg:title or svg:desc present but empty falls through exactly
  like an absent one, distinguishing the length>0 check from a
  bare presence check on both elements.
- A frame's text:anchor-type resolves into a real floatPosition
  (kept the attribute name itself observable), and is genuinely
  absent as a key, not present-but-undefined, when there is none.
- A draw:text-box child that is neither text:p nor text:list is
  skipped without consuming a list numId, proven by the numId a
  following genuine text:list gets.
- A chart embedded object's own residue lands on the block's
  source; every other embedded kind carries no source key at all.
- readDrawFrame's flowPositioning parameter defaults to false and
  only applies when the caller opts in; walkDrawShapes never passes
  true, so an odp frame with no svg:x/svg:y is dropped rather than
  read at its own box origin.
- A resolved gradient/hatch definition with a missing or
  unrecognised draw:style still yields no fillPattern, matching an
  unresolvable name; angleDeg/rotationDeg are omitted as keys, not
  set to undefined, when draw:angle/draw:rotation are absent.
- A dash definition's dots1/dots1-length/distance boundary values
  (non-positive, negative) each independently blank the whole
  pattern; a present-but-non-positive dots2 keeps the single-length
  pattern with dots2/dots2LengthPt genuinely absent.
…/table gaps

Adds coverage the mutation suite's own incremental cache had been silently
skipping for several rounds, spanning formatServerDatabaseUrl, the
component-collection walker, and table-name collection in typed/odb/read.ts:

- formatServerDatabaseUrl: a hostname with no port omits the port suffix
  rather than always including one, and a db:server-database with neither a
  hostname nor a local-socket-name falls through to the bare
  scheme[:///name] forms rather than the hostname branch.
- readConnectionInfo's db:file-based-database and db:server-database
  branches genuinely omit `url` (not merely leave it undefined) when the
  underlying href/format helper returns undefined -- asserted with
  toStrictEqual, since toEqual treats a present-but-undefined property as
  equivalent to an absent one and so could not actually prove this.
- collectTableNames deduplicates a table name that appears in both
  db:table-representations and db:schema-definition/db:table-definitions,
  and reads db:schema-definition's own table-definitions path at all --
  previously exercised by no test whatsoever.
- collectQueryDefinitions' db:escape-processing reads a real "true" value,
  not just a "false" one indistinguishable from a hardcoded default.
- collectComponents skips a stray child that is neither db:component nor
  db:component-collection instead of misreading it as one.
- resolveOdbComponent's thrown message names "(none)" rather than a bare
  empty string when a .odb declares zero components of the requested kind.
…ndant self-mapping guard

renameQName now tests indexOf's own "not found" sentinel with colon === -1
instead of colon < 0, since indexOf never returns another negative value --
the looser relational check only left an unreachable colon <= 0 case for a
genuinely empty declared prefix, which the prefixes map never produces.

prefixRenames no longer skips recording a prefix that is already canonical:
renaming a declared prefix onto itself is a no-op wherever the map is read
(renameQName's own canonical === undefined branch is the only consumer), so
the extra comparison bought nothing but an allocation skip.
…-direction gaps

Forward direction: an inch-shaped token inside a name-suffixed or
xlink:href attribute, a default (unprefixed) xmlns declaration, a
non-xmlns attribute whose value coincidentally matches a namespace URI,
office:class dropped only on a document root, a paragraph's text:level,
a cell's validation-name and a text-value element's value attributes
(plus their negative cases outside those elements), a style:column's
margin pair, table:sub-table, form:property's boolean flag, an unsplit
style:properties for an unclassified family, an unwrapped office:body
with no office:class, an XML part with no root element, a manifest
entry resolved by skipping a decoy element/non-root entry/attribute,
a same-shaped element outside the manifest part itself, mimetype
synthesis only when resolvable and only when not already present, and
an already-ODF package left untouched by the OpenOffice.org 1.x rules.

Reverse direction: the mirrored default-xmlns, rootless-part, and
manifest-decoy cases; office:body recursing into its own children when
unwrapped by no recognised genre; fo:keep-with-next passed through
unmodified for a value outside always/auto; the plain RENAMED_ATTRIBUTES
table, form:control-implementation/form:text-style-name, and
office:value-type's form:property special case, all reversed by name;
text:note-ref and text:notes-configuration split by their own
note-class, and a missing note-class defaulting to footnote; a cell's
content-validation-name, a text-value element's attributes, and a
style:column's indent pair reversed, each with its own negative case;
a draw:frame with more than one frame-shaped child only unwrapping the
first; a package-internal href that already starts with # never being
double-prefixed; an unresolvable text:list left unrenamed; and
office:meta's children left alone with no meta:keyword to rewrap.
@Mearman
Mearman force-pushed the feat/100-percent-mutation-odf.js branch from ed21689 to 787e714 Compare September 14, 2026 15:00
…guard already makes redundant

reverseNoteTag and reverseNoteBodyOrCitation are each only ever called
with a tag already narrowed to one of their own checked values, so the
final if-check and its "return undefined" fallback can never fire --
both now return the last branch unconditionally, and the caller no
longer needs a `?? renamedTag` for a value that can't be undefined.

reverseTransformElement's own explicit office:body return produced
byte-identical output to the generic tag/attribute handling at the
bottom of the same function (office:body has no REVERSE_RENAMED_ELEMENTS
entry and is not a DOCUMENT_ROOT_ELEMENTS member), so the branch is
gone and office:body now falls through to that shared path instead.

wrapMetaKeywords's own length-0 early return produced the same result
as the general loop below it (every node fails the meta:keyword check
and is pushed through unchanged), so the guard is gone too.

reverseTransformElement's own genre-child lookup and documentClassOf's
now share one firstGenreElement helper instead of two separate inline
type predicates, since both wanted the identical "first child that is
an element with a recognised genre tag" query.
…everse-direction gaps

Forward: office:class resolved from its own literal attribute when no
prefix aliases the office namespace, never through a decoy prefix bound
to something else or a coincidentally class-shaped attribute name, and
a decoy manifest:file-entry left alone when synthesising the mimetype
media type.

Reverse: a genre child unwrapped only for office:body itself, not any
element whose own first child happens to share a genre tag; listKind
threaded down only from a genuine enclosing text:list; an inch-shaped
token still protected inside a name-suffixed or xlink:href attribute;
text:outline-level, table:is-sub-table, and the style:column indent
pair each reversed only on their own real element, never a same-shaped
attribute elsewhere; and a decoy manifest entry, tag, or attribute left
alone by both the media-type-resolution and rewrite passes.
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