Files

270 lines
9.2 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>.
package reader
import (
"reflect"
"strings"
"testing"
"time"
"git.stormux.org/storm/fenrir/internal/command"
"git.stormux.org/storm/fenrir/internal/presentation"
"git.stormux.org/storm/fenrir/internal/screen"
)
func fixture(lines ...string) screen.Snapshot {
const width = 20
s := screen.Snapshot{Terminal: 2, Active: true, Rows: len(lines), Cols: width}
for _, text := range lines {
line := []rune(text)
s.Cells = append(s.Cells, line...)
s.Cells = append(s.Cells, []rune(strings.Repeat(" ", width-len(line)))...)
}
return s
}
func TestAutomaticTypingDeletionCursorAndScroll(t *testing.T) {
before, after := fixture("$ ", ""), fixture("$ a", "")
before.X, after.X = 2, 3
if got := changes(before, after, true); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('a')}) {
t.Fatal(got)
}
if got := changes(after, before, true); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('a')}) {
t.Fatal(got)
}
before, after = fixture("first", "second", "third"), fixture("second", "third", "fourth")
if got := changes(before, after, true); !reflect.DeepEqual(got, []presentation.Message{{Text: "fourth"}}) {
t.Fatal(got)
}
after = before
after.Y = 1
if got := changes(before, after, true); !reflect.DeepEqual(got, []presentation.Message{{Text: "second"}}) {
t.Fatal(got)
}
if got := changes(before, after, false); len(got) != 0 {
t.Fatal("cursor echo disrupted review", got)
}
r := New()
r.Update(before)
r.Command(command.AutoRead)
if got := r.Update(fixture("changed", "second", "third")); len(got) != 0 {
t.Fatal(got)
}
}
func TestReviewSurvivesStationaryUpdatesAndExitsOnCursorMovement(t *testing.T) {
r := New()
s := fixture("alpha", "omega")
r.Update(s)
r.Command(command.CurrentLine)
r.Command(command.NextLine)
if got := r.Update(s); len(got) != 0 {
t.Fatal("unchanged screen repeated output", got)
}
if got := r.Command(command.CurrentLine).Text; got != "omega" {
t.Fatal("stationary application cursor reset review", got)
}
next := fixture("alpha", "omega!")
if got := r.Update(next); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('!')}) {
t.Fatal("review suppressed new output", got)
}
if !r.review.Active() {
t.Fatal("text change without cursor movement left review")
}
next.Y = 1
r.Note_input(true, false, false)
if got := r.Update(next); !reflect.DeepEqual(got, []presentation.Message{{Text: "omega!"}}) || r.review.Active() {
t.Fatal("vertical cursor movement did not restore line feedback", got)
}
r.Command(command.CurrentLine)
next.X = 1
r.Note_input(true, false, false)
if got := r.Update(next); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('m')}) || r.review.Active() {
t.Fatal("horizontal cursor movement did not restore character feedback", got)
}
}
func TestCursorMovementLeavesReviewWithFeedbackDisabled(t *testing.T) {
for _, options := range [][2]bool{{false, true}, {true, false}, {false, false}} {
r := New()
r.Configure(options[0], options[1])
s := fixture("alpha", "omega")
r.Update(s)
r.Command(command.CurrentLine)
s.Y = 1
r.Note_input(true, false, false)
if got := r.Update(s); len(got) != 0 || r.review.Active() {
t.Fatal("disabled feedback changed review exit behavior", options, got, r.review.Active())
}
if got := r.Command(command.CurrentLine).Text; got != "omega" {
t.Fatal("review did not resume at the new application cursor", got)
}
}
}
func TestCursorMovementPreservesReviewWithoutRecentApplicationInput(t *testing.T) {
for _, testCase := range []string{"passive output", "reader command", "expired input"} {
t.Run(testCase, func(t *testing.T) {
r := New()
s := fixture("alpha", "omega")
r.Update(s)
r.Command(command.CurrentLine)
switch testCase {
case "reader command":
r.Note_input(true, false, false)
r.Note_input(false, false, false) // A reader paste is not application input.
case "expired input":
r.Note_input(true, false, false)
r.applicationInputAt = time.Now().Add(-time.Second)
}
s.Y = 1
if got := r.Update(s); len(got) != 0 || !r.review.Active() {
t.Fatal("cursor movement without recent application input left review", got)
}
if got := r.Command(command.CurrentLine).Text; got != "alpha" {
t.Fatal("passive cursor movement displaced review", got)
}
})
}
}
func TestAutomaticReadingToggleKeepsReviewCurrent(t *testing.T) {
r := New()
if got := r.Command(command.Unbound).Text; got != "unbound" {
t.Fatal(got)
}
if got := r.Command(command.AutoRead).Text; got != "autoread disabled" {
t.Fatal(got)
}
s := fixture("alpha", "omega")
s.Y = 1
if got := r.Update(s); len(got) != 0 {
t.Fatal("automatic output while disabled", got)
}
if got := r.Command(command.CurrentLine).Text; got != "omega" {
t.Fatal("disabled automatic reading left review stale", got)
}
if got := r.Command(command.AutoRead).Text; got != "autoread enabled" {
t.Fatal(got)
}
r.Command(command.FollowCursor)
s.Y = 0
if got := r.Update(s); !reflect.DeepEqual(got, []presentation.Message{{Text: "alpha"}}) {
t.Fatal("automatic reading did not resume", got)
}
}
func TestReadingSettingsPreserveReviewAndNewOutput(t *testing.T) {
r := New()
r.Configure(false, false)
before := fixture("alpha", "omega")
if got := r.Update(before); len(got) != 0 {
t.Fatal("automatic setting ignored", got)
}
if got := r.Command(command.CurrentLine).Text; got != "alpha" {
t.Fatal("disabled automatic reading broke review", got)
}
r.Command(command.FollowCursor)
r.Configure(true, false)
after := before
after.Y = 1
if got := r.Update(after); len(got) != 0 {
t.Fatal("cursor echo setting ignored", got)
}
changed := fixture("alpha", "omega!")
changed.Y = 1
if got := r.Update(changed); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('!')}) {
t.Fatal("cursor echo setting suppressed new output", got)
}
}
func TestBackspaceSpeaksRemovedTextWithoutDeletedLabel(t *testing.T) {
for _, testCase := range []struct {
text string
want string
}{
{"e", "e"},
{"é", "é"},
{".", "dot"},
{"E", "E"},
{"ab", "ab"},
} {
t.Run(testCase.text, func(t *testing.T) {
before, after := fixture("$ "+testCase.text), fixture("$ ")
before.X, after.X = 2+len([]rune(testCase.text)), 2
want := []presentation.Message{{Text: testCase.want}}
if chars := []rune(testCase.text); len(chars) == 1 {
want = []presentation.Message{presentation.Character_message(chars[0])}
}
if got := changes(before, after, true); !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %q", got, testCase.want)
}
})
}
}
func TestScrollWithBlankAndUnicodeWhitespaceRows(t *testing.T) {
for _, testCase := range []struct {
name string
before, after []string
want []presentation.Message
}{
{"leading blank", []string{"gone", "", "keep", "tail"}, []string{"", "keep", "tail", "fresh"}, []presentation.Message{{Text: "fresh"}}},
{"two rows", []string{"gone", "", "keep", "tail"}, []string{"keep", "tail", "fresh", "last"}, []presentation.Message{{Text: "fresh"}, {Text: "last"}}},
{"whitespace overlap", []string{"gone", "\u2003", ""}, []string{"\u2003", "", "fresh"}, []presentation.Message{{Text: "fresh"}}},
} {
t.Run(testCase.name, func(t *testing.T) {
got := changes(fixture(testCase.before...), fixture(testCase.after...), true)
if !reflect.DeepEqual(got, testCase.want) {
t.Fatalf("got %v, want %v", got, testCase.want)
}
})
}
before, after := fixture("a", "", ""), fixture("", "", "")
before.X = 1
if got := changes(before, after, true); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('a')}) {
t.Fatalf("deleting last character on blank screen: %v", got)
}
}
func TestAutomaticBlankLinesCarryCuesWithoutMatchingWords(t *testing.T) {
for _, line := range []string{"", "\u2003", "blank", "start of screen"} {
t.Run(line, func(t *testing.T) {
r := New()
snapshot := fixture("hello", line)
var wantCue presentation.Cue
wantText := line
if strings.TrimSpace(line) == "" {
wantCue, wantText = presentation.Blank, "blank"
}
want := presentation.Message{Text: wantText, Cue: wantCue}
snapshot.Y = 1
if got := r.Update(snapshot); len(got) != 2 || got[1] != want {
t.Fatal("initial line", got, want)
}
snapshot.Y = 0
r.Update(snapshot)
snapshot.Y = 1
if got := r.Update(snapshot); !reflect.DeepEqual(got, []presentation.Message{want}) {
t.Fatal("cursor line", got, want)
}
})
}
}