test(dng): close the crate's first mutation survey - #495
Merged
Conversation
`decode_levels` accepts WhiteLevel at exactly two counts -- one value per sample plane, or a single value it broadcasts -- and rejects everything else. Both accepted counts were tested and the rejection was not, so relaxing the `v.len() == 1` guard to `true` swallowed every wrong count into the broadcast arm, silently reading `v[0]` and ignoring the rest. Two values across three planes is neither count, and now fails. The scaffolding for these fixtures is long -- encode the sample LinearRaw image, pull its strip out, re-emit a minimal single-IFD file in two passes to learn the IFD's size, append the data -- and all of it is identical between the two tests. It moves into `rebuilt_with_raw_ifd`, which takes a closure to mutate the raw IFD; what remains in each test is the tags it writes and what it expects. Refs #110
`floats` guards on `!v.is_empty()`, and only the well-formed and wrong-type cases were tested. Relaxing the guard to `true` let an empty array through as `Some(vec![])`, and downstream that is not inert: `tone_curve` chunks it into zero points, finds the "strictly increasing" condition vacuously true, and returns an empty curve rather than none. Asserted from both sides, because a `floats` that always returned `None` would satisfy the rejection on its own. Refs #110
Deleting `Predictor::None => Some(0)` drops it to `_ => None`, and neither caller can tell those apart. `validate` returns early at `if predictor == Predictor::None`, several lines above its `x_factor(predictor).is_none()` check, so the one site that would distinguish `None` from `Some(_)` is unreachable with that predictor -- it would otherwise report it as a floating-point predictor. The other site is `x_factor(predictor).filter(|&f| f > 0)`, where `Some(0)` and `None` both become `None` and take the same early return. Equivalent on every reachable call. The arm stays rather than being deleted, which is the choice worth defending. Deleting it would remove the mutant too, but it would also make the function's doc false -- `Predictor::None` really does difference against zero pixels to the left -- and it would leave a trap: if that early return ever moved, `_ => None` would silently misreport the ordinary no-predictor case as a floating-point one. Same shape and justification as the defensive clamps already excluded here. Verified against `--list`: only this arm goes, while the other three arms and all three whole-function replacements stay in the survey. Refs #110
RFC 1321 prints F as `(b & c) | (!b & d)` and G as `(d & b) | (!d & c)`, and the two halves of each are disjoint by construction: where `b` is set the second term is zero, where it is clear the first is. So `|` and `^` agree on every input, and the printed form carries a mutant no test can kill. Both become the standard XOR formulations -- `d ^ (b & (c ^ d))` and `c ^ (d & (b ^ c))` -- which are the same function with no such twin. Only F was reported as a survivor, from the shard that happened to run first. G has the identical structure and would have come back as one later; fixing the pair now rather than waiting for the survey to find the second is the point of recognising the shape. This is the twelfth instance of disjoint-lane `|` in the workspace and the fifth crate, after gamut-tiff, gamut-deflate, gamut-avif and gamut-png. A hash is the one place to be careful about "obviously equivalent" algebra, so this leans on the RFC 1321 vectors already in the module rather than on the argument above: they pass unchanged, and the three operator mutations of the new forms are each confirmed caught. Refs #110
… count Three more survivors, all the same omission in different places: a rejection path with no fixture that reaches it. `white_level_value` refuses a level that is fractional *or* past `u32::MAX`, and had only ever been handed values that pass, so neither disjunct was exercised. Relaxing the `||` to `&&` accepted 100.5, and narrowing the `>` to `==` accepted `u32::MAX + 1`. Each needs its own case: a value that trips one does not trip the other, so one fixture cannot close both. Its SHORT/LONG choice is pinned too, at the two values that straddle the boundary. Both widths are legal DNG and every reader takes either, so nothing but the file's length records which was chosen -- the same blind spot as gamut-tiff's `dim_value`. `BlackLevelRepeatDim` is `(rows, cols)`, so any other count is malformed, and only the well-formed and absent cases were tested. Relaxing `v.len() == 2` to `true` read the first two values and ignored the rest rather than refusing the file. Refs #110
…ards Five more survivors, four closed by test and one by an exclusion that carries its proof. `Compression::is_lossy` could be replaced by either constant, so nothing called it. It decides which compressions route through the compressed-chunk digest path, and closing it needs cases on both sides. `unsigned_f64s` accepts the three types `BlackLevel` allows, but only `SHORT` and `RATIONAL` were ever reached, so deleting the `LONG` arm dropped it to `_ => None` with the suite green -- a black level stored as `LONG`, which the spec permits, would have been refused as a bad type. `black_level_value` has its own copy of the `<= u16::MAX` SHORT/LONG comparison, tested now at the same two straddling values as its `white_level_value` twin. `BlackLevelRepeatDim` filters rows and columns for non-zero, and neither guard had a fixture. Both orientations are asserted, which the first version of this test did not do: it set `[1, 0]`, exercised only the column guard, and the row mutant survived the check. Rows and columns are filtered separately, so one zero cannot stand in for the other. The exclusion is lossless JPEG's `if pt > 0` around the point-transform upshift. `pt` is a `u16`, so `pt >= 0` is unconditionally true and the loop it guards then runs with a shift of zero -- `*sample <<= 0` leaves every sample exactly as it was. The mutant does strictly more work for byte-identical output, so no test can separate them: an optimisation, not a behaviour. Refs #110
The decoder falls back to IFD 0 when the raw IFD carries no `NoiseProfile`, guarded by `raw_index != 0` so it does not pointlessly re-read the same directory. Every fixture in the suite put the tag where the spec says, so inverting that guard to `== 0` disabled the fallback in precisely the case it exists for, and left it firing only where it is a no-op. Nothing failed. The fixture builder already assembles a two-directory file, so it takes a `NoiseAt` argument rather than gaining a second copy; the existing entry point keeps its signature and its meaning. Worth naming what this one is: not a missing boundary or an unreachable branch, but a documented *compatibility* path -- "some writers put it in IFD 0 instead" -- with no file that exercises it. Those are the easiest to leave untested, because the code reads as though the comment were the evidence. Refs #110
`BlackLevelDeltaV` must carry one finite value per active-area row, and only well-formed deltas were ever passed. Relaxing the guard's `||` to `&&` then accepted a delta that failed one condition but not both. Both malformations are asserted -- wrong length, and a NaN at the right length -- because either alone leaves the other disjunct unexercised. Its `BlackLevelDeltaH` twin, checked immediately above it in the same function, was already covered; only the V side had no fixture. The `black_level_value` LONG-arm comparison also came back a survivor from the same shard, but it is already dead: the SHORT/LONG test added earlier in this branch covers it. Confirmed by applying the mutant rather than assuming, since a survivor list produced against master says nothing about a branch that has moved since. Refs #110
`is_fully_classified` survived replacement by `true`. Unlike gamut-tiff's copy of
this defect, the wrapper *is* called here -- but every call expects `true`, so
the suite pinned that clean files are clean and never that a broken one is not.
A constant `true` satisfies all of them.
The fixture is the one that worked for gamut-tiff: a strip declared at offset
100000 in a file of a few hundred bytes. The walk can name the segment but not
place it, which is `out_of_bounds`, one of the five conditions the verdict is
built from. Trailing junk does not work -- the walk classifies that as a trailer,
and `trailing_junk_is_classified_as_a_trailer` above already relies on it.
Also excluded here: `undo`'s `if channels == 0 || group_cols < 2 { return; }`
relaxed to `&&`. `factor` reaches that line only through
`x_factor(predictor).filter(|&f| f > 0)`, and in every case the guard skips, the
loop it guards is already empty -- `row_step = group_cols * channels`, and the
body runs `for i in channels..row_step`, an empty range whenever
`group_cols < 2` or `channels == 0`. The mutant does strictly more work and
writes exactly the same bytes: an early return for speed, not for behaviour.
Refs #110
…grid A fractional black level is stored as `(v * 65536).round() / 65536`, so a value of exactly 65536 would need a numerator of 2^32 -- one past `u32::MAX`, where the `as u32` cast wraps. The bound has to be exclusive, and relaxing `<` to `<=` admits precisely the value whose numerator does not fit. Reaching that arm at all needs a *mixed* slice. 65536 on its own is integral and is taken by the LONG arm above, so it never gets to the fractional path; the fixture pairs it with a 0.5 that forces the branch. Refs #110
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #110.
gamut-dng's first mutation survey: 1796 caught, 23 survivors, 3 timeouts. All 23 are closedhere — 20 by test, 1 by rewriting an equivalent mutant out of existence, and 2 by exclusions that
carry their proof.
One shape accounts for most of them: a rejection path with no file that reaches it
The crate validates carefully and the happy paths are well covered, so nearly every survivor sits
on the refusal side of a guard.
WhiteLevelcount issppor 1v[0]BlackLevelRepeatDimhas 2 values> 0floatsis non-emptyFLOATarrayBlackLevelDeltaVlength and finitenessTwo of those needed two fixtures rather than one, and that is the part worth stating:
white_level_valuerefuses a level that is fractional or pastu32::MAX, and a value trippingone disjunct does not trip the other —
100.5kills the||→&&mutant,u32::MAX + 1kills the>→==mutant, and neither closes both. Same forBlackLevelDeltaV.Boundaries where both answers are legal
black_level_valueandwhite_level_valuepick SHORT or LONG by width, and every DNG readeraccepts either — so nothing but the file's length records which was chosen. Both boundaries are now
pinned at the two straddling values, the same blind spot gamut-tiff's
dim_valuehad.The RATIONAL grid's bound is sharper than it looks: a fractional level is stored as
(v * 65536).round() / 65536, so exactly 65536 needs a numerator of 2³² — one pastu32::MAX, wherethe
as u32cast wraps. The bound has to be exclusive. Reaching that arm also needs a mixedslice, since 65536 alone is integral and is taken by the LONG arm above.
A compatibility path that only a comment vouched for
The decoder falls back to IFD 0 when the raw IFD carries no
NoiseProfile— "some writers put itthere instead" — guarded by
raw_index != 0. Every fixture put the tag where the spec says, soinverting that guard disabled the fallback in exactly the case it exists for, and left it firing
only where it is a no-op. These are the easiest gaps to leave open, because the code reads as
though the comment were the evidence.
The archival verdict, again
DeconstructReport::is_fully_classifiedsurvived replacement bytrue. Unlike gamut-tiff's copyof this defect the wrapper is called here — but every call expects
true, so the suitepinned that clean files are clean and never that a broken one is not.
The fixture transferred from gamut-tiff, including the negative result: trailing junk does not
work, because the walk classifies it as a trailer (and
trailing_junk_is_classified_as_a_traileralready relies on that). A strip declared past EOF is
out_of_bounds, one of the five conditionsthe verdict is built from.
MD5, and one fix made ahead of the survey
RFC 1321 prints F as
(b & c) | (!b & d), whose halves are disjoint by construction, so|and^agree on every input. Both F and G are now their standard XOR forms. Only F was reported;G has identical structure, and a later shard duly reported it — fixing the pair on sight is the
point of recognising the shape. This is the twelfth instance of disjoint-lane
|in the workspaceand the fifth crate.
A hash is the last place to trust "obviously equivalent" algebra, so this leans on the RFC 1321
vectors already in the module rather than on that argument: they pass unchanged, and the operator
mutations of the new forms are each confirmed caught.
Two exclusions, both optimisations rather than behaviour
x_factor'sPredictor::Nonearm.validatereturns early for that predictor above itsx_factor(...).is_none()check, and the other call site filtersf > 0, whereSome(0)andNonetake the same path. The arm stays rather than being deleted: it keeps the match total, andif that early return ever moved,
_ => Nonewould misreport the ordinary no-predictor case as afloating-point one.
undo'schannels == 0 || group_cols < 2early return, and lossless JPEG'spt > 0skip. Inboth, the guarded work is already a no-op — an empty
channels..row_steprange, and<<= 0respectively — so the mutant does strictly more work for byte-identical output.
Validation
cargo test -p gamut-dng --all-featuresclean,mise run fmt-check,mise run check-testsandclippy clean. Every mutant was applied by hand and confirmed caught. That caught a flaw in one
of my own tests: the repeat-dim fixture used
[1, 0], which exercises only the column guard, andthe row mutant survived — rows and columns are filtered separately, so one zero cannot stand in for
the other. Both orientations are asserted now.
Note on completeness
Four shards (7, 8, 11, 12) were truncated — one by the filesystem hitting 100%, three by a
per-shard timeout that was too tight for this crate under load. They are queued for re-run, so 23
is a floor. Any further survivors will follow in a separate PR rather than holding this one.