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