195 lines
6.8 KiB
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")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|