149 lines
4.9 KiB
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
|
|
}
|
|
}
|