Files
fenrir/internal/terminal/rogue_live_linux_test.go

195 lines
6.8 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
//go:build linux && cgo
package terminal
import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"testing"
"time"
"git.stormux.org/storm/fenrir/internal/applicationoverrides"
"git.stormux.org/storm/fenrir/internal/applicationoverrides/rogue"
"git.stormux.org/storm/fenrir/internal/command"
"git.stormux.org/storm/fenrir/internal/reader"
)
// Opt in with FENRIR_ROGUE_TEST_BINARY=/usr/bin/rogue. Each game has a private
// PTY and working/save directory. SEED disables scoring in classic Rogue.
// This exercises real ncurses output, without touching the active reader.
func TestRogueLiveOverride(t *testing.T) {
program := os.Getenv("FENRIR_ROGUE_TEST_BINARY")
if program == "" {
t.Skip("set FENRIR_ROGUE_TEST_BINARY to a classic Rogue executable")
}
for _, rows := range []int{24, 32} {
for _, style := range []string{"Clear", "Overwrite", "Slow"} {
t.Run(fmt.Sprintf("%d/%s", rows, style), func(t *testing.T) {
cols := 80
if rows > 24 {
cols = 100
}
master, slave, err := open_pty(rows, cols)
if err != nil {
t.Fatal(err)
}
defer syscall.Close(master)
slaveFile := os.NewFile(uintptr(slave), "private Rogue PTY")
defer slaveFile.Close()
child := exec.Command(program)
child.Dir = t.TempDir()
child.Env = append(os.Environ(), "HOME="+child.Dir, "TERM=xterm-256color", "LANG=C", "LC_ALL=C", "SEED=1234", "ROGUEOPTS=name=FenrirFixture,noterse,inven="+style)
child.Stdin, child.Stdout, child.Stderr = slaveFile, slaveFile, slaveFile
child.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0}
if err := child.Start(); err != nil {
t.Fatal(err)
}
defer func() { _ = child.Process.Kill(); _ = child.Wait() }()
if err := syscall.SetNonblock(master, true); err != nil {
t.Fatal(err)
}
e, err := new_emulator(rows, cols)
if err != nil {
t.Fatal(err)
}
defer e.close()
a, r := &rogue.Adapter{}, reader.New()
active := false
var frame applicationoverrides.Frame
var output []string
pump := func() {
var buffer [32768]byte
for total := 0; total < 131072; {
n, err := syscall.Read(master, buffer[:])
if n > 0 {
total += n
e.feed(buffer[:n])
}
if errors.Is(err, syscall.EAGAIN) || errors.Is(err, syscall.EIO) || n == 0 {
break
}
if err != nil && !errors.Is(err, syscall.EINTR) {
t.Fatal(err)
}
}
if active {
s := e.snapshot(true, false)
frame = a.Observe(s)
for _, message := range r.Update_application(s, frame) {
if message.Text != "" {
output = append(output, message.Text)
}
}
}
}
screenText := func() string { return string(e.snapshot(true, false).Cells) }
waitFor := func(label string, predicate func() bool) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
pump()
if predicate() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("%s not observed; screen=%q; speech=%q", label, screenText(), output)
}
send := func(text string) {
t.Helper()
r.Note_input(true, false, false)
if _, err := syscall.Write(master, []byte(text)); err != nil {
t.Fatal(err)
}
}
waitFor("initial game", func() bool { return strings.Contains(screenText(), "Hp:") && strings.Contains(screenText(), "@") })
active = true
pump()
if !frame.Recognized || !frame.OwnsPresentation {
t.Fatal("real game not recognized", screenText())
}
for index, id := range []command.ID{command.ReadLiveRegion1, command.ReadLiveRegion2, command.ReadLiveRegion3, command.ReadLiveRegion4, command.ReadLiveRegion5, command.ReadLiveRegion6} {
result, handled := a.Command(id)
if !handled || !strings.HasPrefix(result.Text, frame.Regions[index].Name+": ") {
t.Fatal("unnamed region", result)
}
}
initialHealth, _ := a.Command(command.ReadLiveRegion1)
send("i")
if style == "Slow" {
waitFor("slow inventory", func() bool { return strings.Contains(e.snapshot(true, false).Text(0), "a)") })
// Five initial items; each intervening message waits for Space.
for _, letter := range []string{"b)", "c)", "d)", "e)"} {
send(" ")
waitFor("next inventory item", func() bool { return strings.Contains(e.snapshot(true, false).Text(0), letter) })
}
} else {
waitFor("inventory page", func() bool { return strings.Contains(screenText(), "--Press space to continue--") })
if !frame.Recognized || frame.OwnsPresentation {
t.Fatal("inventory interpreted as dungeon")
}
send(" ")
waitFor("return from inventory", func() bool {
return frame.OwnsPresentation && !strings.Contains(screenText(), "--Press space to continue--")
})
}
send("?")
waitFor("help prompt", func() bool { return strings.Contains(e.snapshot(true, false).Text(0), "Character you want help for") })
send("*")
waitFor("help page", func() bool { return strings.Contains(screenText(), "--Press space to continue--") })
if !frame.Recognized || frame.OwnsPresentation {
t.Fatal("help not readable")
}
send(" ")
waitFor("return from help", func() bool { return frame.OwnsPresentation })
send("o")
waitFor("options", func() bool { return strings.Contains(screenText(), `Terse output ("terse"):`) })
if !frame.Recognized || frame.OwnsPresentation {
t.Fatal("options not readable")
}
send("\x1b")
waitFor("options exit prompt", func() bool { return strings.Contains(screenText(), "--Press space to continue--") })
send(" ")
waitFor("return from options", func() bool { return frame.OwnsPresentation })
start := len(output)
send("v")
waitFor("version announcement", func() bool { return strings.Contains(strings.Join(output[start:], " "), "Version 5.4") })
t.Log(initialHealth.Text, strings.Join(output[start:], " "))
// Move onto an empty adjacent floor cell. Keystrokes must not be
// echoed as letters, and the moving @ must not read out a map row.
s := e.snapshot(true, false)
moved := false
for _, step := range []struct {
dx, dy int
key string
}{{1, 0, "l"}, {-1, 0, "h"}, {0, 1, "j"}, {0, -1, "k"}} {
x, y := s.X+step.dx, s.Y+step.dy
if x < 0 || x >= 80 || y < 1 || y > 22 || s.Line(y)[x] != '.' {
continue
}
start = len(output)
send(step.key)
waitFor("player movement", func() bool { current := e.snapshot(true, false); return current.X == x && current.Y == y })
for _, text := range output[start:] {
if text == step.key || strings.Contains(text, "@") {
t.Fatal("map or key chatter", text)
}
}
moved = true
break
}
if !moved {
t.Fatal("fixture has no adjacent empty floor")
}
})
}
}
}