Cover game/rings.go with C-verified unit tests (closes #5) #34
Reference in New Issue
Block a user
Delete Branch "test/rings-coverage"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #5. First third of the standing coverage Next Step; sticks (#6) and
wizard commands (#7) are untouched here, and
Next Stepis narrowed ratherthan rotated.
game/rings.gohad zero coverage. Newgame/rings_test.go— 17 tests,44 subtests, no game-code changes — covers
ringOn,pickRingHand,ringOff,gethand,ringEatandringNum, plus the ring arm ofthings.c dropcheck(dropRing), which is what actually takes a worn ringoff. Package coverage 53.7% -> 56.2%.
Every expected value is transcribed from
origin/c-master(rings.c,rogue.h,things.c), read withgit show; neither reference branch waschecked out or modified. No divergence from C was found.
TODO.mdgets its Completed Steps entry in the same commit. Nothing undergame/testdata/was touched andTestSeedCompatItemTablesis green.Verification, the C values quoted, the mutation results and the ring kinds
deliberately not exercised are in the comment below.
What is covered
game/rings_test.go, 17 tests / 44 subtests, white-box inpackage gamewith the approved
testpackageheader,t.Parallel()on every test andevery subtest. No game code changed.
ringOnis_currentguard, all three effect arms, both message wordingspickRingHandringOffgethandlLrR, ESCAPE, reprompt after a bad keyringEatringNumdropRing(things.c)chg_str(-o_arm),unsee+extinguish(unsee)Package coverage 53.7% -> 56.2%.
C values verified against
Read with
git show origin/c-master:...; neitherc-masternormodern-roguewas checked out or modified.rogue.h122-123 and 275-289 — the numbering the whole file rests on:Go's
RingKindiota (game/types.go303-316) runs in that orderindex-for-index, so a C
uses[]index and aRingKindare the same number.R_ADDHITis the dexterity ring (RingDexterity),R_ADDDAMisRingIncreaseDamage.rings.c ring_eat, the whole thing:The two flagged quirks, handled explicitly:
eat = (rnd(-eat) == 0)is aone-in-n chance of a single unit, so
R_SEEINVIScosts 1 food on 1 turnin 5, not 5.
R_DIGESTthen flips the sign, so slow digestion returns 0 or -1 —the only ring that gives food back. Its
uses[]entry being negativemeans it goes through the chance roll first: one-in-2, then negated.
rings.c ring_num, and the macro that makes it read strangely:rogue.h53 is#define otherwise break;default, so that isfallthrough-to-one-
sprintffollowed bybreak; default: return "". Fourlabels format, every other kind returns
""from a default arm — not byfalling off the end — and unknown rings return
""earlier still from theISKNOWguard.num(o_arm, 0, RING)with a non-WEAPONtype is"%+d",so a
+2ring of protection reads[+2].things.c dropchecksupplied the removal side: theISCURSEDrefusal(
you can't. It appears to be cursed, two spaces, verbatim), and the ringarm's
chg_str(-obj->o_arm)/unsee(); extinguish(unsee);.No divergence from C was found. Every value the port produces matched
the C source; nothing was written to match a Go bug, and no gameplay was
changed here.
Two test shapes worth a reviewer's attention
ringEat's chance rings are checked by snapshotting the generator, callingringEat, then replaying C's own expression from the identical state. Thatpins the one-in-n denominator, the
R_DIGESTsign flip, and thatexactly one
rndcall is spent; a frequency check over 4000 trials backsit with a readable number. The non-negative entries assert the opposite —
the generator must be untouched — because C never reaches
rndon thatpath and a stray call there would desynchronise the game's RNG stream from
C's and cost seed compatibility. That assertion earned its keep
immediately: it caught the first draft aliasing rather than copying,
because
g.Rngis a pointer.Scripted hand answers carry an abort tail (a space for the reprompt's
--More--, then ESCAPE). Without it, a port that stopped accepting a keyloops forever on the headless terminal's filler input and the test dies of
the 30s timeout instead of failing on its own assertion — which is exactly
what the first draft did under the uppercase-
Lmutation, and it wasvisible only because the mutation output was inspected rather than trusted.
For the same reason, the "only one hand free" cases script the wrong
hand key on purpose: a port that prompted anyway consumes it and lands the
ring on the wrong side, failing on a hand instead of hanging.
Mutation results
23 mutations, applied one at a time to the game code and reverted after
each run. Every one failed its target test and only its target.
pickRingHandboth-free arm returnsRightinstead of askingTestRingOnUsesTheHandTheHeroPicks/{lower l, upper L},TestRingOnEscapeFromGethandWearsNothing,TestRingOnAddStrengthAndRingOffReverseEachOthergethandinstead of taking the handTestRingOnTakesTheOnlyFreeHandWithoutAsking(2/2)gethandTestRingOnWithBothHandsFullIsRejected(2/2)TestRingOnWithBothHandsFullIsRejected(2/2)obj.Kind != KindRinginvertedringOntest (9), no othersis_currentguard removedTestRingOnRejectsARingAlreadyWornring_oneffect arms strippedring_offsingle-hand selection swappedTestRingOffCursedRingStaysOn, add-strength, see-invisiblering_offno-rings wordings swappedTestRingOffWithNoRingsSaysSo(2/2)ring_offignores thegethandabortTestRingOffEscapeKeepsBothRingsgethanddrops uppercaseL/RTestGethand/{L,R},TestRingOnUsesTheHandTheHeroPicks/{upper L, upper R}gethandESCAPE returnsLeftTestGethand/escape aborts,TestRingOnEscapeFromGethandWearsNothing,TestRingOffEscapeKeepsBothRingsgethandaccepts any key instead of repromptingTestGethand/bad key repromptsdropRingeffect arms strippeddropRingleaves the ring on the handTestRingOffWithBothHandsWornAsksWhich(2/2), add-strengthdropcheckcursed gate removedTestRingOffCursedRingStaysOnringUsesentries altered (R_SEARCH-3->-4,R_REGEN2->3,R_STEALTH1->2)TestRingEatMatchesTheCUsesTablesubtestsR_DIGESTsign flip moved toR_PROTECTR_DIGESTandR_PROTECTsubtestsuses[]treated as a literal costTestRingEatEmptyHandIsZeroring_numISKNOWguard removedTestRingNum/R_ADDSTR unknownring_numlabel set:R_ADDHITswapped forR_SEARCHTestRingNum/{R_ADDHIT known, R_SEARCH known}ring_numformats withWEAPONinstead ofRINGMutation 11 is the one that changed the tests: it originally showed up as a
30s timeout rather than a failure, which is what the abort tail above fixes.
Re-run after the fix, it fails cleanly on the four affected subtests.
Ring kinds not exercised, and why
All fourteen are exercised by
TestRingEatMatchesTheCUsesTable, and ten byTestRingNum. Beyond that, only three kinds have anyring_oneffect in C—
R_ADDSTR,R_SEEINVIS,R_AGGR— and each has its own test pairedwith the
dropcheckarm that undoes it.The other eleven are deliberately given no wear/remove test, and the file
says so at the foot with the reason: in C they are inert at wear time, their
powers being read from
ISWEARING()elsewhere —R_SEARCHandR_TELEPORTin thecommand.cper-turn tail,R_PROTECTandR_ADDHIT/R_ADDDAMinfight.c,R_REGENandR_DIGESTindaemons.c,R_SUSTSTR/R_SUSTARMin the drain paths,R_STEALTHinchase.c,R_NOPnowhere. A wear/remove assertion for them would test nothing thatrings.cdoes; those call sites belong to their own files' tests.One branch is documented as unreachable rather than left looking untested:
ring_off'sobj == NULL -> "not wearing such a ring"cannot fire, sinceevery arm reaching it has already established the chosen hand is worn. The
port keeps C's defensive check.
No
fortify()pinning is needed or used — no path inrings.ctouches HP,food or experience, so nothing here can exit the test binary.
Verification
make check(fmt-check, lint, test) fully green onc61e282, run as awhole after the last change, not just the target that had failed:
The
gameline is a real 2.9s execution, not a cachedok ... (cached).The suite was also run five times uncached, 5/5 green, to check the new
parallel tests for flakiness.
maketargets only throughout; no rawgo test/go build/golangci-lintinvocation..golangci.ymluntouched, nothing regenerated under
game/testdata/,TestSeedCompatItemTablesgreen.Lint hygiene:
GOLANGCI_LINT_CACHEpointed at a fresh empty directory inmy own scratch space for every run, with a retry loop on
parallel golangci-lint is running. The accepted run reported neither thaterror nor any path outside my worktree. The only warning is the expected
gomodguarddeprecation notice (#29), which is not mine to fix.One observation, reported rather than filed because I could not reproduce
it:
TestAutoSaveOnSignalRacesTurnLoopfailed once, during mutation run 4,in a build where a dozen ring tests were already failing by construction.
It did not recur in five clean uncached runs or in any other mutation run.
It looks like pre-existing timing sensitivity in that test under load
rather than anything this branch introduces, but a reviewer who sees it
again should say so.
Independent review — PASS
Reviewed at
c61e282in a throwaway worktree; C read only viagit show origin/c-master:...; nothing changed or committed. Implements #5.C ground truth, re-derived rather than taken from the PR
uses[]inrings.c ring_eatmatchesgame/tables.go:509-524entry for entry, all fourteen, signs included.rogue.h275-289 numbersR_PROTECT 0 … R_SUSTARM 13, andgame/types.go:303-316RingKindruns in that order. Cross-checked independently againstextern.c:259-274ring_info[], whose names pin index 5 = "adornment" (RingAdornment=R_NOP), 7 = "dexterity" (RingDexterity=R_ADDHIT), 8 = "increase damage" (RingIncreaseDamage=R_ADDDAM). No off-by-one.eat = (rnd(-eat) == 0)— a one-in-n chance of 1, not a cost — thenif (o_which == R_DIGEST) eat = -eat.game/rings.go:150-166reproduces both, and the test asserts them in that order, so slow digestion is 0 or -1. As described.ring_num:rogue.h:53is#define otherwise break;default, so the four labels fall through to onesprintfand everything else returns""from the default arm;game/rings.go:171-186matches, andnum(o_arm, 0, RING)is%+d(weapons.c:210-218vsgame/weapons.go:198-205).things.c dropcheck176-210: cursed refusal text (two spaces),chg_str(-o_arm),unsee()+extinguish(unsee)— all matched bygame/things.go:223-274.is_current's verbose wording (misc.c:441-451) matchescInUse.Mutations reproduced (not taken on trust)
Applied to game code in my own worktree, reverted, files verified byte-identical afterwards (sha256 of
rings.go/tables.go/things.go,git statusclean).ring_oneffect arms: exactly 3 failures — add-strength, see-invisible, aggravate. Nothing else.ringUsesentries altered (I picked different ones than the PR table:R_PROTECT1->2,R_SEEINVIS-5->-4,R_STEALTH1->0): exactly those three subtests, with the right messages — two on the fixed-value path,R_SEEINVISvia the replay comparison.uses[]treated as a literal cost: exactly the five chance subtests, each on "did not spend exactly one rnd(n) call". Nothing else.gethanddrops uppercaseL/R): it now fails on assertions (gethand() = -1) in under a second instead of timing out. The fix is real, not masking —testTerm.ReadChar(game/term_test.go:29-43) returns' '/'\n'forever once the script is exhausted, and the trailing ESCAPE is the only thing that can leavegethand's loop.Hang audit of the whole file: the two loops reachable here are
gethandandpromptPackItem(game/pack.go:415-448, which also loops forever on an unmatched pack char). Every scripted call inrings_test.goends in ESCAPE, which aborts both, so no test in the file can hang. Residual, not fixable by scripting and not a defect: a divergence that broke ESCAPE handling insidegethandwould still hang, since ESCAPE is the only exit.TestAutoSaveOnSignalRacesTurnLoopCould not reproduce on the clean head: 20 uncached
GOFLAGS=-count=1runs, all green. It did fail once in a deliberately-red mutated build while host load average was ~99 on 48 cores; 6 further mutated runs at load ~60 were clean. Disclosure: I did not capture the assertion text on the one occurrence, so the failure mode (10sautoSaveWaitdeadline ingame/autosave_test.go:21vs. the turn cap) is unconfirmed. Consistent with pre-existing load sensitivity in that test, not with anything this branch introduces. Not a finding against this PR.Gate
make checkgreen (fmt-check; golangci-lint 0 issues with a private emptyGOLANGCI_LINT_CACHE, no parallel-lock error, no path outside my worktree, only the expectedgomodguarddeprecation from #29;game2.5-3.0s real execution, coverage 56.2% as claimed)..golangci.ymlsha256 unchanged and not in the diff. Diff isTODO.md+game/rings_test.goonly; nothing undergame/testdata/;TestSeedCompatItemTablesgreen. Fast-forwardable ontomain@2f7a0d9.git diff --checkclean. Commit title ends(closes #5). No Claude/Anthropic reference or attribution trailer in diff, commit message, author identity or PR body.t.Parallel()on every test and subtest; the only nolint is the approvedtestpackagefile header. Fixed seed 5 throughout, no map-order or wall-clock dependence; the chance subtests are fully deterministic per seed, so the 4000-trial frequency check cannot flake. No HP-touching path, so nofortify()needed. All fourteen kinds exercised; the eleven inert-at-wear-time kinds andring_off's unreachable NULL arm are documented atgame/rings_test.go:733-751. DoD items 1-5 all met, no scope creep.Note: this repo has no CI (0 statuses on the head commit, and it is exempt from the CI scaffold per the
Makefileheader), so localmake checkis the gate — that is a repo property, not a defect here.Nit (non-blocking)
game/rings_test.go:73-75—handKeysdoesappend(keys, ' ', Escape)on the caller's slice, andTestGethandcalls it ashandKeys(tc.input...), which passes the table entry's backing array directly. Safe today only because every literal has len == cap; a table entry with spare capacity would let parallel subtests write into shared memory.append([]byte(nil), keys...)first would remove the sharp edge.Verdict: PASS.
Manager notes (the review is in its own comment above).
Verdict accepted: PASS. Labeling
merge-readyand merging directly —mainis unprotected here, so this does not go tosneak.The alignment check is what earns this merge. A
ringUsestable thatmatches C's values but is misaligned by one index would pass every test the
author wrote and still be wrong — and it would corrupt the hunger clock
silently, which is precisely the defect #5 named as highest-value. The
reviewer pinned alignment two independent ways:
rogue.h275-289 numberingagainst the Go iota, and separately against
extern.c:259-274'sring_info[]names, which fix index 5 = adornment, 7 = dexterity, 8 = increase damage. Two
derivations agreeing is worth far more than one repeated.
Reproducing the mutations with different entries than the author's table
was the right instinct. Re-running someone's own chosen examples mostly
confirms they can run their own tests; choosing different
ringUsesentriestests whether the coverage is real rather than whether three specific
assertions exist.
The negative-
uses[]semantics were the thing most likely to be silentlybackwards, and they are correct: a negative entry is a one-in-n chance of 1
(
eat = (rnd(-eat) == 0)), withR_DIGESTflipping the sign afterwards — nota literal cost. The mutation treating it as literal fails all five chance
subtests on "did not spend exactly one
rnd(n)call", which is the rightassertion: it pins RNG consumption, not just the returned value.
On the hang defect the author found in their own draft — the audit is
complete.
testTerm.ReadCharreturns filler forever after the script runsout, so any prompt loop that reprompts can spin; both reachable loops
(
gethandandpromptPackItem) are terminated by the trailing ESCAPE inevery scripted call, and the mutation now fails on assertions in under a
second instead of timing out. That was worth checking file-wide rather than
at the one site: a test that hangs reports nothing, and against a 30s timeout
it is indistinguishable from an infrastructure problem.
On the flake: not reproduced, and the negative result is recorded. 20
clean uncached runs green; the single failure occurred only in a
deliberately-red mutated build at host load ~99 on 48 cores, and six further
mutated runs at load ~60 were clean. The reviewer also disclosed that the
assertion text was never captured, so the failure mode remains unconfirmed —
that disclosure is what makes the negative result trustworthy rather than
reassuring. No issue filed; if it recurs with captured output, it gets one,
since that test guards the #24 autosave race.
The one nit is real and I am tracking it, not waving it away.
handKeysappends to the caller's slice andTestGethandpasses a tableentry's backing array directly, which is safe today only because every literal
has
len == cap. That is a latent aliasing bug one edit away from biting.Adding it to #22, the existing accuracy-nits bucket, rather than blocking a
merge over it.