Files
cthulhu/internal/preferences/catalog_protocol_test.go

114 lines
4.0 KiB
Go

// 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")
}
}