// Fenrir console screen reader. // Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "path/filepath" "reflect" "strings" "testing" "git.stormux.org/storm/fenrir/internal/command" "git.stormux.org/storm/fenrir/internal/config" "git.stormux.org/storm/fenrir/internal/input" "git.stormux.org/storm/fenrir/internal/punctuation" "git.stormux.org/storm/fenrir/internal/screen" ) func TestSharedSpeechPunctuationAndPreparedCharacterFeedback(t *testing.T) { processor, _, err := punctuation.Load([]string{"../../config/punctuation/default.toml"}) if err != nil { t.Fatal(err) } voice := &fakeVoice{} echo := &echoSpeaker{Speaker: voice, punctuation: processor, punctuationLevel: "some"} echo.Say("a/b") if echo.last != "a/b" { t.Fatal("clipboard echo lost original text", echo.last) } echo.punctuationLevel = "none" echo.say_character('?') echo.Say("a/b") echo.punctuationLevel = "all" echo.say_character('?') echo.punctuationLevel = "some" echo.say_character('?', " first character in cell path/to") if echo.last != "question? first character in cell path/to" { t.Fatal("clipboard echo lost raw table header", echo.last) } got := strings.Join(voice.messages, "|") if got != "a slash b|question?|a b|question?|question? first character in cell path slash to" { t.Fatal(got) } } func TestSpeechKeepsEmbeddedPunctuationAndOriginalClipboardText(t *testing.T) { processor, _, err := punctuation.Load([]string{"../../config/punctuation/default.toml"}) if err != nil { t.Fatal(err) } for _, substitutions := range []bool{false, true} { settings := config.Defaults() settings.Reading.Emoticons = substitutions settings.Reading.RepeatedSymbols = substitutions voice := &fakeVoice{} echo := &echoSpeaker{Speaker: voice, punctuation: processor, punctuationLevel: "some", settings: &settings} for _, trial := range []struct{ input, want string }{ {"https://stormux.org", "https colon slash slash stormux dot org"}, {"2026.10.31", "2026 dot 10 dot 31"}, {"3.14", "3.14"}, } { voice.messages = nil echo.Say(trial.input) if !reflect.DeepEqual(voice.messages, []string{trial.want}) { t.Fatal("speech lost punctuation", trial.input, substitutions, voice.messages) } if echo.last != trial.input { t.Fatal("clipboard echo lost original text", echo.last) } } } } func TestDiffstatSpeechKeepsOriginalClipboardText(t *testing.T) { processor, _, err := punctuation.Load([]string{"../../config/punctuation/default.toml"}) if err != nil { t.Fatal(err) } settings := config.Defaults() settings.Reading.RepeatedSymbols = true settings.Reading.RepeatedSymbolsMin = 4 voice := &fakeVoice{} echo := &echoSpeaker{Speaker: voice, punctuation: processor, punctuationLevel: "some", settings: &settings} text := "docs/user.md | 7 +++++--" echo.Say(text) if len(voice.messages) != 1 { t.Fatal("missing diffstat speech", voice.messages) } if got := strings.Join(strings.Fields(voice.messages[0]), " "); got != "docs slash user dot md 7 5 plus dash dash" { t.Fatal("diffstat did not summarize", got) } if echo.last != text { t.Fatal("clipboard echo lost original diffstat", echo.last) } } func TestPackageProgressBarSpeechKeepsOriginalClipboardText(t *testing.T) { processor, _, err := punctuation.Load([]string{"../../config/punctuation/default.toml"}) if err != nil { t.Fatal(err) } settings := config.Defaults() settings.Reading.RepeatedSymbols = true voice := &fakeVoice{} echo := &echoSpeaker{Speaker: voice, punctuation: processor, punctuationLevel: "some", settings: &settings} for _, trial := range []struct{ bar, want string }{ {strings.Repeat("#", 120), "120 number"}, {strings.Repeat("#", 110) + strings.Repeat("-", 10), "110 number 10 dash"}, {strings.Repeat("#", 90) + strings.Repeat("-", 10), "90 number 10 dash"}, } { voice.messages = nil text := "(123/189) upgrading nodejs-lts-jod" + strings.Repeat(" ", 157) + "[" + trial.bar + "] 100%" echo.Say(text) if len(voice.messages) != 1 { t.Fatal("missing progress speech", voice.messages) } want := "123 slash 189 upgrading nodejs dash lts dash jod " + trial.want + " 100 percent" if got := strings.Join(strings.Fields(voice.messages[0]), " "); got != want { t.Fatal("progress bar did not summarize", got) } if echo.last != text { t.Fatal("clipboard echo lost original progress bar", echo.last) } } } func TestReviewCharactersUseProfileRegardlessOfLevelAndKeepBoundaries(t *testing.T) { processor, _, err := punctuation.Load([]string{"../../config/punctuation/fr.toml"}) if err != nil { t.Fatal(err) } for _, level := range punctuation.Levels() { source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 2, Cells: []rune("?/")}} voice := &fakeVoice{} keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error { voice.messages = nil // Inspect command feedback after startup. for _, id := range []command.ID{command.CurrentChar, command.NextChar, command.NextChar, command.Quit} { handle(input.Decision{Command: id, Interrupt: true}) } return nil }} settings := config.Defaults() settings.Reading.Automatic = false settings.Reading.PunctuationLevel = level settings.Presentation.Sound.Enabled = false if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, Punctuation: processor}); err != nil { t.Fatal(err) } want := "point d'interrogation|barre oblique|end of screen" if level == "all" { want = "point d'interrogation|barre oblique|end espace of espace screen" } if got := strings.Join(voice.messages, "|"); got != want { t.Fatal(level, got) } } } func TestQuickPunctuationAppliesLiveAndSavesWhileMuted(t *testing.T) { processor, _, err := punctuation.Load([]string{"../../config/punctuation/default.toml"}) if err != nil { t.Fatal(err) } path := filepath.Join(t.TempDir(), "settings.toml") settings := config.Defaults() settings.Reading.Automatic = false voice := &fakeVoice{} source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 3, Cells: []rune("a/b")}} keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error { handle(input.Decision{Command: command.NextQuickCategory}) handle(input.Decision{Command: command.PreviousQuickValue}) voice.messages = nil handle(input.Decision{Command: command.CurrentLine}) if !reflect.DeepEqual(voice.messages, []string{"a b"}) { t.Fatal("punctuation did not apply live", voice.messages) } handle(input.Decision{Command: command.Mute}) handle(input.Decision{Command: command.NextQuickValue}) handle(input.Decision{Command: command.NextQuickValue}) handle(input.Decision{Command: command.SaveSettings}) handle(input.Decision{Command: command.Quit}) return nil }} if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, Punctuation: processor, ConfigPath: path}); err != nil { t.Fatal(err) } saved, _, err := config.Load(path, nil) if err != nil || saved.Reading.PunctuationLevel != "most" || saved.Reading.Automatic || !saved.Presentation.Speech.Enabled { t.Fatal(saved, err) } }