//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07) package game // Tests for the signal-triggered autosave handoff (issue #24): the signal // goroutine must never encode game state itself, and the game goroutine // must answer wherever it is parked. import ( "io" "os" "path/filepath" "strings" "testing" "time" ) // autoSaveWait is the deadline the tests hand AutoSaveOnSignal when they // expect the save to be taken. It is long enough that a loaded machine // cannot turn a working handoff into a spurious failure, and it is never // actually waited out on a passing run. const autoSaveWait = 10 * time.Second // TestAutoSaveOnSignalRacesTurnLoop is the test issue #24 exists for: it // drives the real turn loop on one goroutine while another asks for a // signal-triggered autosave over and over, which is the interleaving no // test in the suite used to produce. `make test` runs with -race, so a // save that encodes the live game tree from the asking goroutine — what // the old AutoSave did straight from the signal handler — is reported as // a data race and fails this test. // // Non-vacuity: with AutoSaveOnSignal's body replaced by a direct // g.autoSave() call, i.e. exactly the pre-#24 behavior, this test fails // under -race with the encoder reading state that command() is writing. func TestAutoSaveOnSignalRacesTurnLoop(t *testing.T) { t.Parallel() // Same mix as TestTurnLoopCrashSweep: the spaces answer any --More-- // prompt, and the script is long enough that the drive never runs it // out. script := []byte(strings.Repeat("h j k l y u b n s . ", 400)) g := New(Params{Seed: 20260809, Term: &testTerm{input: script}}) g.FileName = filepath.Join(t.TempDir(), "rogue.save") g.startLevel() g.prePlay() const wantSaves = 25 var taken int done := make(chan struct{}) go func() { defer close(done) for range wantSaves { if g.AutoSaveOnSignal(autoSaveWait) { taken++ } } }() driveUntilDone(t, g, done) // The close of done orders that goroutine's writes before this read. if taken != wantSaves { t.Errorf("saves taken = %d, want %d", taken, wantSaves) } // Every request was answered by the turn loop, so the file is the // work of the game goroutine and must be a whole save. assertRestorable(t, g.FileName) } // driveUntilDone runs turns until the saving goroutine is finished, // fortifying the hero each turn so no death exits the test binary. The // turn cap keeps a broken handoff from hanging the suite instead of // failing it. func driveUntilDone(t *testing.T, g *RogueGame, done <-chan struct{}) { t.Helper() const maxTurns = 1000 for range maxTurns { select { case <-done: return default: } fortify(g) g.command() } t.Fatal("the turn loop ran out of turns before the saves were taken") } // TestAutoSaveOnSignalWhileBlockedOnInput is the case the fix is really // for: the connection drops while the player is staring at the screen, // so the game goroutine is parked in ReadChar and will not reach the // between-turns check on its own. A flag checked only between turns would // never be looked at here. func TestAutoSaveOnSignalWhileBlockedOnInput(t *testing.T) { t.Parallel() bt := newBlockingTerm() g := mkBlockedGame(t, bt) read := make(chan byte) go func() { read <- g.readchar() }() // The wake is buffered, so this is correct whether or not the reader // has reached ReadChar yet. if !g.AutoSaveOnSignal(autoSaveWait) { t.Fatal("the save was not taken while the game was blocked on input") } assertRestorable(t, g.FileName) // The interrupt must not have been mistaken for a keystroke: the // reader is still waiting, and still returns the real key. bt.keys <- 'x' if ch := <-read; ch != 'x' { t.Errorf("readchar() = %q, want 'x'", ch) } } // TestAutoSaveOnSignalWhileInShellEscape covers the other place the game // goroutine parks for an unbounded time: the `!` shell escape, where it // used to sit inside the shell call with no way to answer. A dropped line // while the player is off in a shell is as much a hangup as any other. func TestAutoSaveOnSignalWhileInShellEscape(t *testing.T) { t.Parallel() st := &shellTerm{ blockingTerm: newBlockingTerm(), entered: make(chan struct{}), release: make(chan struct{}), } g := mkBlockedGame(t, st) left := make(chan struct{}) go func() { defer close(left) g.shell() }() <-st.entered if !g.AutoSaveOnSignal(autoSaveWait) { t.Fatal("the save was not taken while the game was in the shell escape") } assertRestorable(t, g.FileName) close(st.release) <-left } // TestShellEscapePanicUnwindsTheGameGoroutine pins the reason // runShellEscape recovers its helper's panic. // // term.Tcell.ShellEscape panics when Screen.Resume fails, and the shell // now runs on a helper goroutine. A panic reaching the top of that helper // would kill the process without running the deferred calls of any other // goroutine — including cmd/rogue/main.go's `defer t.Fini()`, which is // the only thing that takes the tty back out of raw mode. That is issue // #12's failure, and it would land on the one path where the terminal is // already broken. // // So the panic has to arrive on the goroutine that runs the game, with // that goroutine's deferred restore still on the stack. This test stands // in for main: a Fini deferred around the g.shell() call, and the panic // caught after it, asserting both that the restore ran and that the // original value came through. Against the unrecovered version there is // nothing to assert — the panic escapes a helper goroutine and takes the // whole test binary down, which is the failure being prevented. func TestShellEscapePanicUnwindsTheGameGoroutine(t *testing.T) { t.Parallel() pt := &panickingShellTerm{blockingTerm: newBlockingTerm()} g := mkBlockedGame(t, pt) caught := make(chan any, 1) go func() { // Registered first, so it runs last: it sees the terminal // already restored, exactly as the runtime would have printed // the trace after main's Fini. defer func() { caught <- recover() }() // Stands in for cmd/rogue/main.go's `defer t.Fini()`. defer pt.Fini() g.shell() }() got := <-caught if got == nil { t.Fatal("the resume failure did not reach the game goroutine") } if msg, ok := got.(string); !ok || msg != errShellResume { t.Errorf("recovered %v, want %q", got, errShellResume) } if !pt.restored { t.Error("the terminal was not restored on the way out") } // shell() must not have resumed into its InShell reset and refresh: // there is no screen left to draw into. if !g.InShell { t.Error("shell() carried on drawing after the resume failed") } } // TestAutoSaveOnSignalTimesOutLeavingTheOldSave pins the backstop: a game // goroutine that never reaches a service point must not hold the process // open, and giving up must cost the player nothing. The old save is still // there, byte for byte — which is the whole point of renaming over the // target instead of removing it first. func TestAutoSaveOnSignalTimesOutLeavingTheOldSave(t *testing.T) { t.Parallel() g := mkGame(t, 77) g.FileName = filepath.Join(t.TempDir(), "rogue.save") const old = "an older save nobody is allowed to destroy" writeErr := os.WriteFile(g.FileName, []byte(old), 0o600) if writeErr != nil { t.Fatal(writeErr) } // Nothing drives the turn loop, so nothing will ever answer. start := time.Now() if g.AutoSaveOnSignal(100 * time.Millisecond) { t.Error("AutoSaveOnSignal reported a save that nobody took") } if waited := time.Since(start); waited > time.Second { t.Errorf("waited %v for an unanswered save, want the deadline to bound it", waited) } got, readErr := os.ReadFile(g.FileName) if readErr != nil { t.Fatalf("the previous save was destroyed: %v", readErr) } if string(got) != old { t.Error("the previous save was overwritten by a save that never ran") } } // TestAutoSaveOnSignalWithoutASaveFile covers the death demo's terminal // case: a game with no file name has nothing to write, and must say so // rather than reporting a save that did not happen. func TestAutoSaveOnSignalWithoutASaveFile(t *testing.T) { t.Parallel() g := New(Params{Seed: 5, Term: &testTerm{ input: []byte(strings.Repeat("s . ", 200)), }}) g.FileName = "" g.startLevel() g.prePlay() var answered bool done := make(chan struct{}) go func() { defer close(done) answered = g.AutoSaveOnSignal(autoSaveWait) }() driveUntilDone(t, g, done) if answered { t.Error("AutoSaveOnSignal = true with no save file name") } } // TestSaveFileReplacesTargetAtomically pins the write discipline: the new // save arrives by rename, so the file the player already had is never // written into, and the temporary file it came from is not left lying in // the save directory. // // The load-bearing assertion is the handle opened before the save. A // rename leaves the old file whole and merely stops it being reachable by // name, so that handle still reads the old save; the truncate-in-place // write this replaced would empty it under the reader — the same // in-place write that, interrupted, left the player with a file that // could no longer be restored. func TestSaveFileReplacesTargetAtomically(t *testing.T) { t.Parallel() g := mkGame(t, 11) dir := t.TempDir() path := filepath.Join(dir, "rogue.save") const old = "an older save" writeErr := os.WriteFile(path, []byte(old), 0o600) if writeErr != nil { t.Fatal(writeErr) } held, openErr := os.Open(path) //nolint:gosec // G304: test temp path if openErr != nil { t.Fatal(openErr) } defer func() { _ = held.Close() }() saveErr := g.saveFile(path) if saveErr != nil { t.Fatalf("saveFile: %v", saveErr) } kept, readErr := io.ReadAll(held) if readErr != nil { t.Fatalf("reading the file that was there before the save: %v", readErr) } if string(kept) != old { t.Errorf("the previous save was written into rather than replaced: %q", string(kept)) } entries, readErr := os.ReadDir(dir) if readErr != nil { t.Fatal(readErr) } if len(entries) != 1 || entries[0].Name() != "rogue.save" { t.Errorf("save directory = %v, want just the save file", names(entries)) } info, statErr := os.Stat(path) if statErr != nil { t.Fatal(statErr) } if perm := info.Mode().Perm(); perm != 0o400 { t.Errorf("save file mode = %v, want 0400", perm) } assertRestorable(t, path) } // TestSaveFileLeavesTargetWhenTheRenameFails is the other half of the // same discipline: a save that cannot be completed must leave what the // player already had. The target here is a non-empty directory, which no // rename can replace — the one write failure that can be forced without // depending on file permissions, and therefore on not being root. func TestSaveFileLeavesTargetWhenTheRenameFails(t *testing.T) { t.Parallel() g := mkGame(t, 12) dir := t.TempDir() path := filepath.Join(dir, "rogue.save") mkErr := os.Mkdir(path, 0o700) if mkErr != nil { t.Fatal(mkErr) } keep := filepath.Join(path, "keep") writeErr := os.WriteFile(keep, []byte("still here"), 0o600) if writeErr != nil { t.Fatal(writeErr) } saveErr := g.saveFile(path) if saveErr == nil { t.Error("saveFile over an unreplaceable target reported success") } _, statErr := os.Stat(keep) if statErr != nil { t.Errorf("the target was damaged by a failed save: %v", statErr) } entries, readErr := os.ReadDir(dir) if readErr != nil { t.Fatal(readErr) } if len(entries) != 1 { t.Errorf("save directory = %v, want no temporary file left behind", names(entries)) } } // names lists directory entry names for a failure message. func names(entries []os.DirEntry) []string { out := make([]string, 0, len(entries)) for _, e := range entries { out = append(out, e.Name()) } return out } // assertRestorable checks that path holds a save this program can load, // which is what "the save was taken" has to mean: a file of the right // size proves nothing about a torn encode. func assertRestorable(t *testing.T, path string) { t.Helper() _, err := Restore(path, Params{Term: &testTerm{}}) if err != nil { t.Errorf("the saved file does not restore: %v", err) } } // mkBlockedGame builds a game with a save file name and a terminal whose // reads block, for the tests that park the game goroutine. func mkBlockedGame(t *testing.T, term Terminal) *RogueGame { t.Helper() g := New(Params{Seed: 4242, Term: term}) g.NewLevel() g.FileName = filepath.Join(t.TempDir(), "rogue.save") return g } // blockingTerm is a Terminal that genuinely blocks in ReadChar until a // key is pushed or Interrupt wakes it — which testTerm, whose reads never // block, cannot reproduce. type blockingTerm struct { keys chan byte wake chan struct{} } func newBlockingTerm() *blockingTerm { return &blockingTerm{ keys: make(chan byte), // Buffered by one and posted to without blocking, the same // contract term.Tcell.Interrupt has with tcell's event queue: an // interrupt that arrives before the read still wakes it. wake: make(chan struct{}, 1), } } func (t *blockingTerm) Render(*Window) {} // Repaint has nothing to redraw: this terminal exists for its input // behaviour, and no autosave test types CTRL-R. func (t *blockingTerm) Repaint() {} func (t *blockingTerm) Fini() {} // Interrupt wakes a blocked ReadChar; called from the saving goroutine. func (t *blockingTerm) Interrupt() { select { case t.wake <- struct{}{}: default: } } // ReadChar blocks until a key arrives or Interrupt wakes it. func (t *blockingTerm) ReadChar() (byte, bool) { select { case ch := <-t.keys: return ch, true case <-t.wake: return 0, false } } // shellTerm is a blockingTerm that also offers a shell escape which stays // in the shell until the test lets it out. type shellTerm struct { *blockingTerm entered chan struct{} release chan struct{} } // ShellEscape parks the caller in the "shell" until released. func (t *shellTerm) ShellEscape() { close(t.entered) <-t.release } // errShellResume is what panickingShellTerm panics with, standing in for // the value term.Tcell.ShellEscape raises when Screen.Resume fails. const errShellResume = "resume failed" // panickingShellTerm is a blockingTerm whose shell escape panics on the // way out, the way term.Tcell.ShellEscape does when the screen cannot be // resumed. It records whether Fini ran, which is the thing that must // still happen. type panickingShellTerm struct { *blockingTerm restored bool } func (t *panickingShellTerm) Fini() { t.restored = true } func (t *panickingShellTerm) ShellEscape() { panic(errShellResume) }