// 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" "time" "git.stormux.org/storm/fenrir/internal/command" "git.stormux.org/storm/fenrir/internal/presentation" ) func TestTypingEchoBooleanCombinations(t *testing.T) { for _, character := range []bool{false, true} { for _, word := range []bool{false, true} { for _, capsOnly := range []bool{false, true} { for _, capsLock := range []bool{false, true} { r := New() r.Configure_echo(character, word, capsOnly) before := fixture("$ ") before.X, before.CapsLock = 2, capsLock r.Update(before) r.Note_input(true, true, false) after := fixture("$ a") after.X, after.CapsLock = 3, capsLock var want []presentation.Message if character || capsOnly && capsLock { want = []presentation.Message{presentation.Character_message('a')} } if got := r.Update(after); !reflect.DeepEqual(got, want) { t.Fatal("character policy", character, word, capsOnly, capsLock, got, want) } r.Note_input(true, true, true) after.X = 4 // Space advances over an already-blank cell. want = nil if word { want = []presentation.Message{{Text: "a"}} } if got := r.Update(after); !reflect.DeepEqual(got, want) { t.Fatal("word policy", character, word, capsOnly, capsLock, got, want) } } } } } } func TestEchoSeparatesTypingNavigationDeletionAndIncomingOutput(t *testing.T) { r := New() r.Configure_echo(false, false, false) before := fixture("$ a", "output") before.X = 3 r.Update(before) r.Note_input(true, false, false) after := fixture("$ ", "output") after.X = 2 if got := r.Update(after); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('a')}) { t.Fatal("typing settings disabled deletion feedback", got) } after.X = 1 if got := r.Update(after); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message(' ')}) { t.Fatal("typing settings disabled cursor navigation", got) } r.Configure(true, false) after.Y = 1 if got := r.Update(after); len(got) != 0 { t.Fatal("cursor echo setting ignored", got) } changed := fixture("$ ", "output!") changed.Y, changed.X = 1, 7 if got := r.Update(changed); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('!')}) { t.Fatal("incoming output was suppressed as typing", got) } r.Note_input(true, true, false) if got := r.Update(changed); len(got) != 0 { t.Fatal("un-echoed input spoke raw keys", got) } r.inputAt = time.Now().Add(-time.Second) changed = fixture("$ ", "output!x") changed.Y, changed.X = 1, 8 if got := r.Update(changed); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('x')}) { t.Fatal("stale input suppressed later output", got) } } func TestWordEchoBatchUnicodeMidlineAndSpaceNavigation(t *testing.T) { for _, character := range []bool{false, true} { r := New() r.Configure_echo(character, true, false) before := fixture("$ ") before.X = 2 r.Update(before) for _, char := range "café two " { r.Note_input(true, true, char == ' ') } after := fixture("$ café two ") after.X = 11 want := []presentation.Message{{Text: "café"}, {Text: "two"}} if character { want = []presentation.Message{presentation.Character_message('c'), presentation.Character_message('a'), presentation.Character_message('f'), presentation.Character_message('é'), {Text: "café"}, presentation.Character_message('t'), presentation.Character_message('w'), presentation.Character_message('o'), {Text: "two"}} } if got := r.Update(after); !reflect.DeepEqual(got, want) { t.Fatal("batched typing", character, got, want) } r.Note_input(true, false, false) after.X = 12 if got := r.Update(after); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message(' ')}) { t.Fatal("navigation triggered word echo", got) } } r := New() r.Configure_echo(false, true, false) before := fixture("$ one two") before.X = 4 r.Update(before) r.Note_input(true, true, false) after := fixture("$ onXe two") after.X = 5 if got := r.Update(after); len(got) != 0 { t.Fatal("midline insertion escaped disabled character echo", got) } r.Note_input(true, true, true) after = fixture("$ onX e two") after.X = 6 if got := r.Update(after); !reflect.DeepEqual(got, []presentation.Message{{Text: "onX"}}) { t.Fatal("midline word completion", got) } } func TestTypingEchoIndependentOfAutomaticReadingAndReview(t *testing.T) { r := New() r.Configure(false, false) before := fixture("$ ") before.X = 2 r.Update(before) r.Command(command.CurrentLine) r.Note_input(true, true, false) after := fixture("$ a") after.X = 3 if got := r.Update(after); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('a')}) { t.Fatal("automatic reading or review disabled typing echo", got) } }