// Fenrir console screen reader. // Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . //go:build linux && cgo package terminal import ( "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "syscall" "testing" "time" "git.stormux.org/storm/fenrir/internal/command" "git.stormux.org/storm/fenrir/internal/reader" ) // Uses an isolated local mailbox and no personal/system mutt configuration. // This verifies the actual application's terminal output, not audible speech. func TestMuttCursorNavigation(t *testing.T) { if os.Getenv("FENRIR_MUTT_TEST") != "1" { t.Skip("set FENRIR_MUTT_TEST=1 to exercise installed mutt with a private mailbox") } for _, mode := range []string{"cursor", "highlight"} { t.Run(mode, func(t *testing.T) { test_mutt_navigation(t, mode) }) } } func test_mutt_navigation(t *testing.T, mode string) { program, err := exec.LookPath("mutt") if err != nil { t.Fatal(err) } root := t.TempDir() if err := os.Mkdir(filepath.Join(root, "Mail"), 0700); err != nil { t.Fatal(err) } mailbox := filepath.Join(root, "mailbox") var messages strings.Builder for index := 1; index <= 30; index++ { fmt.Fprintf(&messages, "From sender@example.invalid Mon Jan 1 00:00:00 2024\nFrom: Sender \nTo: Username \nDate: Mon, 1 Jan 2024 00:%02d:00 +0000\nSubject: Fixture message %02d\nMessage-ID: \n\nBody %d\n\n", index, index, index, index) } if err := os.WriteFile(mailbox, []byte(messages.String()), 0600); err != nil { t.Fatal(err) } master, slave, err := open_pty(12, 80) if err != nil { t.Fatal(err) } defer syscall.Close(master) slaveFile := os.NewFile(uintptr(slave), "mutt-pty") defer slaveFile.Close() child := exec.Command(program, "-n", "-F", "/dev/null", "-R", "-f", mailbox, "-e", "set sort=date; set arrow_cursor=no; set index_format='%4C %s'; set timeout=60") child.Env = []string{"HOME=" + root, "TERM=xterm-256color", "LC_ALL=C", "PATH=/usr/bin:/bin"} 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) } emulator, err := new_emulator(12, 80) if err != nil { t.Fatal(err) } defer emulator.close() drain := func() { t.Helper() deadline := time.Now().Add(3 * time.Second) quietAt := time.Now() var buffer [32768]byte for time.Now().Before(deadline) { count, err := syscall.Read(master, buffer[:]) if count > 0 { emulator.feed(buffer[:count]) quietAt = time.Now() } else if err != nil && !errors.Is(err, syscall.EAGAIN) { t.Fatal(err) } else if time.Since(quietAt) > 150*time.Millisecond { return } time.Sleep(5 * time.Millisecond) } t.Fatal("mutt did not settle") } drain() initial := emulator.snapshot(true, false) if !strings.Contains(string(string(initial.Cells)), "Fixture message 01") { t.Fatalf("mailbox did not open: %q", string(initial.Cells)) } engine := reader.New() engine.Configure_tracking(mode) engine.Update(initial) var selections []int for index := 2; index <= 15; index++ { selections = append(selections, index) } for index := 14; index >= 1; index-- { selections = append(selections, index) } previousIndex := 1 for _, index := range selections { // Reviewing a message must not suppress the next application arrow. engine.Command(command.CurrentLine) engine.Note_input(true, false, false) arrow := "\x1bOB" // ncurses enables application cursor keys if index < previousIndex { arrow = "\x1bOA" } if _, err := syscall.Write(master, []byte(arrow)); err != nil { t.Fatal(err) } drain() next := emulator.snapshot(true, false) output := engine.Update(next) var spoken strings.Builder for _, message := range output { spoken.WriteString(message.Text) } if !strings.Contains(spoken.String(), fmt.Sprintf("Fixture message %02d", index)) { t.Fatalf("selection %d cursor (%d,%d) -> (%d,%d): output %v", index, initial.X, initial.Y, next.X, next.Y, output) } initial = next previousIndex = index } }