Cover game/rings.go with C-verified unit tests (closes #5)

game/rings.go had no test coverage at all: not one of the suite's tests
touched wearing a ring, taking one off, choosing a hand, or the ring
contribution to the hunger clock. New game/rings_test.go covers ringOn,
pickRingHand, ringOff, gethand, ringEat and ringNum, plus the ring arm of
things.c dropcheck (dropRing), which is what actually removes a worn ring.
17 tests, 44 subtests; package coverage 53.7% -> 56.2%. No game code
changes.

Every expected value is transcribed from the C reference on origin/c-master
(rings.c, rogue.h, things.c) and quoted in the file, rather than from what
the port currently returns. No divergence from C was found.

ringEat is the reason this matters most: it feeds daemons.c's hunger clock,
so a wrong entry is a slow, silent drift in when the hero starves. All
fourteen ring kinds are pinned to C's uses[] table, both hands. The three C
subtleties are handled explicitly: a negative uses[] entry is a one-in-n
chance of a single unit and not a literal cost; R_DIGEST then flips the
sign, so slow digestion returns 0 or -1; and ring_num's switch closes with
the otherwise macro (rogue.h 53: break;default), so its four labels fall
through to one sprintf and every other kind returns "" from a default arm.

The chance rings are checked by snapshotting the generator, calling
ringEat, and replaying C's own expression from the identical state, which
pins the one-in-n denominator, the sign flip and the fact that exactly one
rnd call is spent; a frequency check over 4000 trials backs it. The
non-negative entries assert the opposite, that the generator is untouched,
because C never reaches rnd on that path and a stray call there would
desynchronise the game's RNG stream from C's.

Scripted hand answers carry an abort tail (a space for the reprompt's
--More--, then ESCAPE) so that a port which stopped accepting a key fails
on its assertion instead of looping forever on the headless terminal's
filler input. The "only one hand free" cases script the wrong hand key on
purpose: a port that prompted anyway would consume it and land the ring on
the wrong side.

Mutation-proved with 23 mutations, each reverted, each failing its own test
and only its own. All fourteen kinds are exercised; the eleven with no
wear-time effect in C are documented at the foot of the file as
deliberately not given a wear/remove test, and ring_off's unreachable "not
wearing such a ring" arm is documented as unreachable.
This commit is contained in:
2026-08-09 14:53:46 +00:00
parent 2f7a0d980d
commit c61e2827c5
2 changed files with 820 additions and 2 deletions

71
TODO.md
View File

@@ -29,11 +29,78 @@ Refactor ground rules:
# Next Step
Broaden unit test coverage where playtesting finds thin spots (rings, sticks,
wizard commands).
Broaden unit test coverage where playtesting finds thin spots — sticks (#6) and
wizard commands (#7). Rings, the first third of this step, is done; see the top
of Completed Steps.
# Completed Steps
- 2026-08-09 Ring unit-test coverage (`test/rings-coverage`, closes #5): the
first third of the standing coverage step. `game/rings.go` had **zero** tests
— not one of the 32 in the suite touched wear, removal, hand choice, or the
ring contribution to the hunger clock. New `game/rings_test.go` (17 tests, 44
subtests) covers `ringOn`, `pickRingHand`, `ringOff`, `gethand`, `ringEat` and
`ringNum`, plus the ring arm of `things.c dropcheck` (`dropRing`), which is
what actually takes a ring off. Package coverage 53.7% -> 56.2%. `Next Step`
narrowed rather than rotated: #6 and #7 are the other two thirds.
Every expected value is transcribed from `origin/c-master` (`rings.c`,
`rogue.h`, `things.c`), never from what the port returns, and the C is
quoted in the file. **No divergence from C was found**, which is the result
and is worth recording as a negative: `ringEat` is the one function here
whose being wrong would be invisible — it feeds `daemons.c`'s hunger clock,
so a bad entry is a slow drift in when the hero starves rather than anything
a playtest would notice — and it now has all fourteen ring kinds pinned to
C's table.
Three C details the tests were written around. (1) `ring_eat`'s `uses[]`
holds negatives, and a negative is **not** a cost: C computes
`eat = (rnd(-eat) == 0)`, a one-in-n chance of a single unit. (2) `R_DIGEST`
then flips the sign, so slow digestion returns 0 or **-1** and is the only
ring that gives food back. (3) `ring_num`'s switch closes with the
`otherwise` macro, which `rogue.h` 53 defines as `break;default` — so its
four labels fall through to one `sprintf` and every other kind returns `""`
from a default arm, not by falling off the end. The `RingKind` iota matches
C's `R_` numbering index-for-index, so a `uses[]` index and a `RingKind` are
the same number; `R_ADDHIT` is `RingDexterity` and `R_ADDDAM` is
`RingIncreaseDamage`.
The chance rings are checked two ways at once. Each call snapshots the
generator, runs `ringEat`, and replays C's own expression from the identical
state — which pins the one-in-n denominator, the sign flip, and the fact
that exactly one `rnd` call is spent — and a frequency check over 4000
trials backs it with a number a human can read. The non-negative entries
assert the reverse: the generator must be **untouched**, because C never
reaches `rnd` on that path and a stray call there would desynchronise the
whole game's RNG stream from C's and cost seed compatibility. That assertion
is what caught the one real bug in this work, which was in the test and not
the game: `g.Rng` is a pointer, so the first draft's snapshots aliased
instead of copying.
Two shapes worth keeping. Scripted hand answers carry an abort tail (a space
for the reprompt's `--More--`, then ESCAPE): without it a port that stopped
accepting a key would loop forever on the headless terminal's filler input
and the test would die of the 30s timeout instead of failing on its
assertion — which is exactly what the first draft did, and it was only
visible because the mutation run was inspected rather than trusted. And the
"only one hand free" case scripts the _wrong_ hand key deliberately: a port
that asked anyway consumes it and lands the ring on the wrong side, so the
test fails on a hand rather than on a hang.
Mutation-proved, 23 mutations, each reverted: breaking `pickRingHand`'s
ask/auto/reject arms, `ring_on`'s type guard, `is_current` guard and all
three effect arms, `ring_off`'s no-rings message, hand selection and ESCAPE
abort, `gethand`'s uppercase keys, ESCAPE and reprompt, `dropRing`'s hand
clearing and both effect arms, `dropcheck`'s cursed gate, three `ringUses`
entries, the `R_DIGEST` sign flip, the one-in-n roll, the empty-hand zero,
and `ring_num`'s `ISKNOW` guard, label set and `RING`-vs-`WEAPON`
formatting. Each failed its own test and only its own; stripping all three
`ring_on` effect arms failed 3 of 3. All fourteen ring kinds are exercised;
the eleven with no wear-time effect in C are documented at the foot of the
file as deliberately not given a wear/remove test, with the files their
powers actually live in, and `ring_off`'s unreachable "not wearing such a
ring" arm is documented as unreachable rather than left looking untested.
- 2026-08-09 Command dispatch audit (`audit/command-switch-coverage`, closes
#31): checked every case label in C's `command.c` against this port's
dispatch, and left the audit behind as a standing test