// 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") } }) } } }