Files
fenrir/internal/terminal/mutt_linux_test.go

149 lines
4.9 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
//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 <sender@example.invalid>\nTo: Username <recipient@example.invalid>\nDate: Mon, 1 Jan 2024 00:%02d:00 +0000\nSubject: Fixture message %02d\nMessage-ID: <fixture-%d@example.invalid>\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
}
}