Files

201 lines
7.1 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"
"testing"
"git.stormux.org/storm/fenrir/internal/command"
"git.stormux.org/storm/fenrir/internal/presentation"
"git.stormux.org/storm/fenrir/internal/screen"
)
func TestCursorNavigationWithStatusChanges(t *testing.T) {
r := New()
before := fixture("Message one", "Message two", "1/2 messages")
r.Update(before)
// The reported Mutt failure began after line review, then application
// arrows moved over unchanged messages while only status/attributes changed.
r.Command(command.CurrentLine)
r.Command(command.NextLine)
after := fixture("Message one", "Message two", "2/2 messages")
after.Y = 1
r.Note_input(true, false, false)
if got := r.Update(after); !reflect.DeepEqual(got, []presentation.Message{{Text: "Message two"}}) {
t.Fatal("status line hid focused message", got)
}
if r.review.Active() {
t.Fatal("message navigation retained detached review")
}
// Cursor feedback disabled still leaves incoming text available.
r.Configure(true, false)
if got := r.Update(before); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('1')}) {
t.Fatal("cursor setting suppressed status output", got)
}
// New command output commonly leaves the cursor on an unchanged blank row.
// That row is not a TUI focus target and must not replace the output.
before = fixture("$ command", "", "")
after = fixture("$ command", "program output", "")
after.Y = 2
if got := changes(before, after, true); !reflect.DeepEqual(got, []presentation.Message{{Text: "program output"}}) {
t.Fatal("blank cursor row hid command output", got)
}
}
func TestTrackingCycleDefaultsAndReview(t *testing.T) {
r := New()
if r.Tracking_mode() != "cursor" {
t.Fatal(r.Tracking_mode())
}
r.Update(fixture("one | two"))
for range 2 {
for _, want := range []string{"table mode", "highlight tracking", "cursor tracking"} {
if got := r.Command(command.ToggleTracking).Text; got != want {
t.Fatal(got, want)
}
}
}
r.Configure_tracking("table")
if got := r.Command(command.CurrentWord).Text; got != "one Column 1" {
t.Fatal("table command was not dispatched", got)
}
r.Configure_tracking("cursor")
if got := r.Command(command.CurrentWord).Text; got != "one" {
t.Fatal("leaving table mode broke ordinary review", got)
}
}
func TestTableHeadersAfterCommandOutputAndExplicitClear(t *testing.T) {
r := New()
r.Update(fixture("$ free -h", "", "", "", ""))
r.Configure_tracking("table")
next := fixture("$ free -h", " total used", "Mem: 12 3", "Swap: 4 0", "$ ")
next.Y = 4
if output := r.Update(next); len(output) == 0 || r.review.Active() {
t.Fatal("table placement suppressed automatic output or entered review early", output)
}
if got := r.Command(command.CurrentWord).Text; got != "Mem: Column 1" {
t.Fatal("review did not start at first data cell", got)
}
if got := r.Command(command.NextWord).Text; got != "12 total" {
t.Fatal("command output headers were not available through reader dispatch", got)
}
r.Clear_marks()
if got := r.Command(command.CurrentWord).Text; got != "12 Column 2" {
t.Fatal("reader clear did not clear headers", got)
}
r.Command(command.PreviousLine)
r.Set_mark()
r.Command(command.NextLine)
if got := r.Command(command.CurrentWord).Text; got != "12 total" {
t.Fatal("manual selection after clear failed through reader dispatch", got)
}
}
func TestHighlightSelectionAndSuppression(t *testing.T) {
selected := screen.Attribute{Foreground: 7, Background: 4}
base := fixture("First message", "Second message", "Third message", "status")
base.Attributes = make([]screen.Attribute, len(base.Cells))
selectRow := func(row int) screen.Snapshot {
next := base
next.Attributes = make([]screen.Attribute, len(base.Cells))
for col := range next.Cols {
next.Attributes[row*next.Cols+col] = selected
}
return next
}
r := New()
r.Configure_tracking("highlight")
r.Update(selectRow(0))
next := selectRow(1)
if got := r.Update(next); !reflect.DeepEqual(got, []presentation.Message{{Text: "Second message"}}) {
t.Fatal("attribute-only selection not read once", got)
}
if got := r.Update(next); len(got) > 0 {
t.Fatal("unchanged highlight repeated", got)
}
if got := r.Update(base); len(got) > 0 {
t.Fatal("deselection read as selection", got)
}
r.Configure(false, true)
if got := r.Update(selectRow(2)); len(got) > 0 {
t.Fatal("automatic off still read highlight", got)
}
r.Configure(true, true)
r.Command(command.CurrentLine)
if got := r.Update(selectRow(1)); len(got) > 0 {
t.Fatal("highlight interrupted detached review", got)
}
r.Command(command.FollowCursor)
if got := r.Update(selectRow(0)); !reflect.DeepEqual(got, []presentation.Message{{Text: "First message"}}) {
t.Fatal("follow cursor did not restore highlights", got)
}
// Lost attribute data must not replay a previous highlight or suppress text.
noAttributes := fixture("New program output", "Second message", "Third message", "status")
if got := r.Update(noAttributes); len(got) == 0 || got[0].Text == "First message" {
t.Fatal("missing attributes left stale tracking", got)
}
}
func TestReviewCursorExitPolicyPreservesMuttWorkflowAndPassiveReview(t *testing.T) {
for _, leave := range []bool{false, true} {
for _, id := range []command.ID{command.CurrentLine, command.CurrentWord, command.NextChar, command.LastText, command.Top, command.Bottom} {
r := New()
r.Configure_review(true, true, leave, true)
before := fixture("First message", "Second message", "status")
r.Update(before)
r.Command(id)
next := before
next.Y = 1
r.Update(next) // Passive cursor movement must keep review.
if !r.review.Active() {
t.Fatal("passive movement left review", id)
}
next.Y = 0
r.Note_input(true, false, false)
r.Update(next)
if r.review.Active() == leave {
t.Fatal("cursor exit policy ignored", id, leave)
}
if !leave {
r.Command(command.FollowCursor)
if r.review.Active() {
t.Fatal("explicit exit ignored")
}
}
}
}
}
func TestScreenExitPolicyIsIndependentOfRecentApplicationInput(t *testing.T) {
r := New()
r.Configure_review(true, true, true, false)
before := fixture("first", "second")
r.Update(before)
r.Command(command.Bottom)
r.Note_input(true, false, false)
next := before
next.Terminal++
next.Y = 1
r.Update(next)
if !r.review.Active() {
t.Fatal("cursor policy overrode explicit screen policy")
}
}