Incomplete checkpoint, preserved after the implementing session hit a capacity limit partway through mutation verification. NOT reviewed and NOT gate-clean: make fmt has not been run, so fmt-check currently fails on ARCHITECTURE.md and TODO.md. Work still outstanding before this is fit for review: - run make fmt and fold the result in - finish mutation-proving each of the three behaviors - verify every message string byte-for-byte against the C sources - confirm TestSeedCompatItemTables passes with its golden untouched Refs #13.
516 lines
14 KiB
Go
516 lines
14 KiB
Go
//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) }
|