// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package preferences import ( "bytes" "context" "encoding/json" "errors" "io" "reflect" "strings" "testing" "git.stormux.org/storm/cthulhu/internal/speech" ) func TestInitialCatalogRoundTrip(t *testing.T) { for _, size := range []struct{ count, nameBytes int }{{1, 100}, {6000, 100}, {4, 250000}} { count := size.count catalog := speech.Catalog{ "espeak-ng": make([]speech.Voice, count), "other": {{Name: "Other voice", Language: "es"}}, "empty": nil, } for index := range catalog["espeak-ng"] { catalog["espeak-ng"][index] = speech.Voice{Name: strings.Repeat("v", size.nameBytes), Language: "de"} } // The last entry must survive even when it lies beyond the old cutoff. catalog["espeak-ng"][count-1] = speech.Voice{Name: "English voice", Language: "en"} initial := Response{Catalog: catalog, Message: "Preferences ready.", Application: "Example", CanSave: true, Accepted: true} var wire bytes.Buffer encoder := json.NewEncoder(&wire) if err := encoder.Encode(Response{ChooseApplication: true}); err != nil { t.Fatal(err) } if err := EncodeInitial(encoder, initial); err != nil { t.Fatal(err) } for _, line := range bytes.Split(bytes.TrimSpace(wire.Bytes()), []byte{'\n'}) { if len(line)+1 > initialMessageLimit { t.Fatalf("protocol line has %d bytes", len(line)+1) } } // Reopening after the chooser uses the same response reader. Subsequent // present requests must not be absorbed into the assembled catalog. if err := encoder.Encode(Response{Present: true}); err != nil { t.Fatal(err) } updates := make(chan Response, 3) errorsIn := make(chan error, 1) read_responses(t.Context(), &wire, updates, errorsIn) if err := <-errorsIn; err != nil { t.Fatal(err) } if got := <-updates; !got.ChooseApplication { t.Fatal("application chooser response was mixed into the catalog") } if got := <-updates; !reflect.DeepEqual(got, initial) { t.Fatalf("catalog or session metadata changed for %d voices", count) } if got := <-updates; !got.Present || got.Catalog != nil { t.Fatal("present response was mixed into the initial catalog") } } } func TestInitialCatalogRejectsOversizedEntry(t *testing.T) { for _, initial := range []Response{ {Message: strings.Repeat("x", initialMessageLimit)}, {Catalog: speech.Catalog{"espeak-ng": {{Name: strings.Repeat("x", initialMessageLimit)}}}}, } { var wire bytes.Buffer if err := EncodeInitial(json.NewEncoder(&wire), initial); err == nil { t.Fatal("oversized settings or voice accepted") } } } func TestInitialCatalogIncompleteAndCancellation(t *testing.T) { updates := make(chan Response, 1) errorsIn := make(chan error, 1) read_responses(t.Context(), strings.NewReader(`{"CatalogPending":true}`+"\n"), updates, errorsIn) if err := <-errorsIn; !errors.Is(err, io.ErrUnexpectedEOF) || len(updates) != 0 { t.Fatalf("incomplete catalog reached the UI: %v", err) } ctx, cancel := context.WithCancel(t.Context()) cancel() read_responses(ctx, strings.NewReader("{}\n"), make(chan Response), errorsIn) } func TestDefaultVoicesFiltersESpeakAndDefaultAlias(t *testing.T) { t.Setenv("LC_ALL", "en_US.UTF-8") voices := []speech.Voice{{Name: "German", Language: "de"}, {Name: "English", Language: "en-US"}} session := &uiSession{catalog: speech.Catalog{"": voices, "espeak-ng": voices, "other": voices}} for _, module := range []string{"", "espeak-ng"} { if got := session.default_voices(module); !reflect.DeepEqual(got, voices[1:]) { t.Fatalf("module %q: %v", module, got) } } if got := session.default_voices("other"); !reflect.DeepEqual(got, voices) { t.Fatal("other synthesizer was filtered") } if !reflect.DeepEqual(session.catalog["espeak-ng"], voices) { t.Fatal("language selection lost the full catalog") } session.catalog[""] = voices[:1] if got := session.default_voices(""); !reflect.DeepEqual(got, voices[:1]) { t.Fatal("unidentified default synthesizer was filtered") } }