201 lines
7.1 KiB
Go
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")
|
|
}
|
|
}
|