// Fenrir console screen reader. // Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later 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 dialog_snapshot(lines map[int]string, x, y int) screen.Snapshot { snapshot := screen.Snapshot{Terminal: 1, Active: true, Rows: 25, Cols: 80, X: x, Y: y} snapshot.Cells = make([]rune, snapshot.Rows*snapshot.Cols) for index := range snapshot.Cells { snapshot.Cells[index] = ' ' } for row, text := range lines { copy(snapshot.Line(row), []rune(text)) } return snapshot } func TestDialogBodyWithUnchangedCursorRow(t *testing.T) { // These are the two split redraws captured during the I38 audit: the // button row is already present before the dialog body finishes drawing. for _, testCase := range []struct { name string before, after screen.Snapshot want []presentation.Message }{ { name: "saved preferences body", before: dialog_snapshot(map[int]string{ 8: "I38", 14: "< OK >", }, 0, 19), after: dialog_snapshot(map[int]string{ 8: "I38", 9: "Loaded saved preferences from", 10: "/tmp/I38_preferences.conf", 12: "Missing or invalid values will be prompted.", 14: "< OK >", }, 2, 14), want: []presentation.Message{ {Text: "Loaded saved preferences from"}, {Text: "/tmp/I38_preferences.conf"}, {Text: "Missing or invalid values will be prompted."}, {Text: "< OK >"}, }, }, { name: "last warning line", before: dialog_snapshot(map[int]string{ 0: "I38", 1: "I38Lock is not available with the current screen reader setup.", 2: "Requires i38lock and either Cthulhu or Orca 50 or newer.", 22: "< OK >", }, 2, 3), after: dialog_snapshot(map[int]string{ 0: "I38", 1: "I38Lock is not available with the current screen reader setup.", 2: "Requires i38lock and either Cthulhu or Orca 50 or newer.", 3: "Please choose another screen lock method.", 22: "< OK >", }, 2, 22), want: []presentation.Message{ {Text: "Please choose another screen lock method."}, {Text: "< OK >"}, }, }, } { t.Run(testCase.name, func(t *testing.T) { r := New() r.Update(testCase.before) if got := r.Update(testCase.after); !reflect.DeepEqual(got, testCase.want) { t.Fatalf("got %v, want %v", got, testCase.want) } if got := r.Update(testCase.after); len(got) != 0 { t.Fatal("unchanged dialog repeated speech", got) } }) } } func TestCursorFeedbackPreservesConcurrentOutput(t *testing.T) { for _, testCase := range []struct { name string before, after screen.Snapshot want []presentation.Message }{ { "output below focus", fixture("first", "focus", ""), fixture("first", "focus", "new output"), []presentation.Message{{Text: "new output"}, {Text: "focus"}}, }, { "scroll with fixed focus", fixture("old", "keep one", "keep two", "last", "focus"), fixture("keep one", "keep two", "last", "new output", "focus"), []presentation.Message{{Text: "new output"}, {Text: "focus"}}, }, { "changed focused row", fixture("first", "old focus"), fixture("first", "new focus"), []presentation.Message{{Text: "new"}}, }, { "cleared body", fixture("old body", "focus"), fixture("", "focus"), []presentation.Message{{Text: "focus"}}, }, } { t.Run(testCase.name, func(t *testing.T) { testCase.after.Y = 1 if testCase.name == "scroll with fixed focus" { testCase.after.Y = 4 } if got := changes(testCase.before, testCase.after, true); !reflect.DeepEqual(got, testCase.want) { t.Fatalf("got %v, want %v", got, testCase.want) } }) } } func TestDialogRedrawRespectsReadingModes(t *testing.T) { for _, mode := range []string{"cursor", "cursor off", "table", "highlight", "review", "automatic off"} { t.Run(mode, func(t *testing.T) { r := New() before := fixture("I38", "", "< OK >") r.Update(before) switch mode { case "cursor off": r.Configure(true, false) case "table", "highlight": r.Configure_tracking(mode) case "review": r.Command(command.CurrentLine) case "automatic off": r.Configure(false, true) } after := fixture("I38", "New dialog body", "< OK >") after.Y = 2 var want []presentation.Message if mode != "automatic off" { want = []presentation.Message{{Text: "New dialog body"}} if mode == "cursor" { want = append(want, presentation.Message{Text: "< OK >"}) } } if got := r.Update(after); !reflect.DeepEqual(got, want) { t.Fatalf("got %v, want %v", got, want) } if mode == "review" && (!r.review.Active() || r.Command(command.CurrentLine).Text != "I38") { t.Fatal("passive dialog redraw displaced review") } }) } }