// 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 preferences import ( "bufio" "errors" "fmt" "net" "path/filepath" "testing" "git.stormux.org/storm/fenrir/internal/config" "git.stormux.org/storm/fenrir/internal/speech" ) func TestModuleDiscoveryAndOfflinePreservation(t *testing.T) { base := config.Defaults() base.Presentation.Speech.Module = "saved-module" t.Setenv("SPEECHD_ADDRESS", "unix_socket:"+filepath.Join(t.TempDir(), "missing.sock")) offline := Discover_choices(base).Choices(base) choices := offline["presentation.speech.module"] if len(choices) != 2 || choices[1].ID != "saved-module" || choices[1].Unavailable == "" { t.Fatal(choices) } session, err := New_session(base, nil, offline) if err != nil { t.Fatal(err) } draft, err := Settings(session, base) if err != nil || draft.Presentation.Speech.Module != "saved-module" { t.Fatal(draft, err) } path := filepath.Join(t.TempDir(), "speech.sock") listener, err := net.Listen("unix", path) if err != nil { t.Fatal(err) } defer listener.Close() done := make(chan string, 1) go func() { conn, err := listener.Accept() if err != nil { done <- err.Error() return } defer conn.Close() request, err := bufio.NewReader(conn).ReadString('\n') if err != nil { done <- err.Error() return } fmt.Fprint(conn, "200-fixture-module\r\n200-saved-module\r\n200 OK\r\n") done <- request }() t.Setenv("SPEECHD_ADDRESS", "unix_socket:"+path) online := Discover_choices(base).Choices(base)["presentation.speech.module"] if request := <-done; request != "LIST OUTPUT_MODULES\r\n" { t.Fatal(request) } if len(online) != 3 || online[1].ID != "fixture-module" || online[2].ID != "saved-module" || online[2].Unavailable != "" { t.Fatal(online) } } func TestDependentSpeechChoicesKeepSavedVoicesAndFilterEspeak(t *testing.T) { t.Setenv("LC_ALL", "en_US.UTF-8") base := config.Defaults() base.Presentation.Speech.Module = "espeak-ng" catalog := speech.Catalog{Modules: []string{"espeak-ng", "fixture"}, Voices: map[string][]speech.Voice{ "espeak-ng": {{Name: "US+Variant", Language: "en-US"}, {Name: "German", Language: "de"}, {Name: "US", Language: "en-US"}}, "fixture": {{Name: "Fixture", Language: "en"}}, }} discovery := Discovery{Catalog: catalog} choices := discovery.Choices(base) if got := choices["presentation.speech.voice"]; len(got) != 3 || got[1].ID != "US" || got[2].ID != "US+Variant" { t.Fatal(got) } base.Presentation.Speech.Voice = "German" choices = discovery.Choices(base) if got := choices["presentation.speech.voice"]; len(got) != 4 || got[3].ID != "German" || got[3].Unavailable != "" { t.Fatal("valid saved voice lost", got) } session, err := New_session(base, nil, choices) if err != nil { t.Fatal(err) } previous := base session.Replace_choices("presentation", "speech.language", interaction_choices(choices["presentation.speech.language"]), "de") discovery.Refresh_speech(session, &previous) values := session.Values() if got := values["presentation.speech.voice"]; len(got.Choices) != 2 || got.Choices[1].ID != "German" || got.Selected != 0 { t.Fatal(got) } session.Replace_choices("presentation", "speech.module", interaction_choices(choices["presentation.speech.module"]), "fixture") discovery.Refresh_speech(session, &previous) values = session.Values() if values["presentation.speech.language"].Selected != 0 || values["presentation.speech.voice"].Selected != 0 || len(values["presentation.speech.voice"].Choices) != 2 { t.Fatal(values) } base.Presentation.Speech.Language = "zz" base.Presentation.Speech.Voice = "Missing" offline := Discovery{Err: errors.New("fixture offline")}.Choices(base) for _, path := range []string{"presentation.speech.module", "presentation.speech.language", "presentation.speech.voice"} { if got := offline[path]; len(got) != 2 || got[1].Unavailable == "" { t.Fatal(path, got) } } session, err = New_session(base, nil, offline) if err != nil { t.Fatal(err) } saved, err := Settings(session, base) if err != nil || saved.Presentation.Speech != base.Presentation.Speech { t.Fatal(saved, err) } } func TestHardwareDriverChangesHideDispatcherChoicesAndPreserveDevice(t *testing.T) { base := config.Defaults() base.Presentation.Speech.HardwareDevice = "/dev/fixture-synth" base.Presentation.Speech.HardwareBaudRate = 19200 base.Presentation.Speech.Module = "fixture" base.Presentation.Speech.Language = "en" base.Presentation.Speech.Voice = "Fixture Voice" discovery := Discovery{Driver: "speech-dispatcher", Catalog: speech.Catalog{Modules: []string{"fixture"}, Voices: map[string][]speech.Voice{"fixture": {{Name: "Fixture Voice", Language: "en"}}}}} session, err := New_session(base, nil, discovery.Choices(base)) if err != nil { t.Fatal(err) } previous := base for _, id := range []string{"dectalk", "litetalk", "doubletalk", "tripletalk", "speech-dispatcher"} { var choices []config.Choice for _, definition := range config.Definitions() { if definition.Path == "presentation.speech.driver" { choices = definition.Choices(base) } } session.Replace_choices("presentation", "speech.driver", interaction_choices(choices), id) discovery.Refresh_speech(session, &previous) draft, err := Settings(session, base) if err != nil { t.Fatal(id, err) } selected := draft.Presentation.Speech if selected.Driver != id || selected.HardwareDevice != base.Presentation.Speech.HardwareDevice || selected.HardwareBaudRate != 19200 { t.Fatal(selected) } if id != "speech-dispatcher" { if selected.Module != "" || selected.Language != "" || selected.Voice != "" { t.Fatal("inactive selections saved", selected) } for _, path := range []string{"presentation.speech.module", "presentation.speech.language", "presentation.speech.voice"} { if got := session.Values()[path]; !got.Hidden { t.Fatal("dispatcher choices leaked", id, path, got) } } } else if selected.Module != base.Presentation.Speech.Module || selected.Language != base.Presentation.Speech.Language || selected.Voice != base.Presentation.Speech.Voice { t.Fatal("returning to Speech Dispatcher lost the draft", selected) } } }