// 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 . 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) } }) } }