270 lines
9.2 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|