// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "bytes" "context" "encoding/json" "fmt" "log/slog" "strings" "testing" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/logging" ) type echoRoutingSpeaker struct{ orderedSpeaker } func (speaker *echoRoutingSpeaker) SpeakVoice(_ context.Context, text, voice string) error { speaker.calls = append(speaker.calls, voice+":"+text) return nil } func (speaker *echoRoutingSpeaker) SpeakCharacter(ctx context.Context, text string) error { return speaker.SpeakVoice(ctx, text, "character") } func (speaker *echoRoutingSpeaker) SpeakEcho(ctx context.Context, text string) error { return speaker.SpeakVoice(ctx, text, "echo") } func (speaker *echoRoutingSpeaker) SpeakEchoCharacter(ctx context.Context, text string) error { return speaker.SpeakVoice(ctx, text, "echo-character") } func TestTypingEchoVoiceCombinations(t *testing.T) { for modes := 0; modes < 8; modes++ { for voices := 0; voices < 16; voices++ { for _, custom := range []bool{false, true} { t.Run(fmt.Sprintf("modes=%03b/voices=%04b/custom=%v", modes, voices, custom), func(t *testing.T) { settings := config.DefaultSettings() settings.EnableEchoByCharacter = modes&1 != 0 settings.EnableEchoByWord = modes&2 != 0 settings.EnableEchoBySentence = modes&4 != 0 settings.UseCustomEchoVoice = custom settings.UseCustomEchoForKey = voices&1 != 0 settings.UseCustomEchoForCharacter = voices&2 != 0 settings.UseCustomEchoForWord = voices&4 != 0 settings.UseCustomEchoForSentence = voices&8 != 0 live := &liveSettings{} live.Store(settings) queue := &remoteSpeechQueue{} presenter := eventPresenter{state: &eventLoopState{}, live: live, speech: queue, logger: slog.New(slog.DiscardHandler)} for _, tc := range []struct { payload, context, basic, word, sentence string }{ {"é", "Café", "é", "", ""}, {" ", "Café ", "space", "Café", ""}, {",", "Café,", ",", "Café", ""}, {" ", "First. Second. ", "space", "", "Second."}, {"pasted text", "pasted text", "pasted text", "", ""}, } { want, voiceBit, character := tc.basic, 1, true if settings.EnableEchoByCharacter { voiceBit = 2 } if settings.EnableEchoByWord && tc.word != "" { want, voiceBit, character = tc.word, 4, false } if settings.EnableEchoBySentence && tc.sentence != "" { want, voiceBit, character = tc.sentence, 8, false } if tc.payload == "pasted text" { voiceBit, character = 0, false } queue.requests = nil presenter.speak(atspi.Event{Type: atspi.TextInsertedEvent, Payload: tc.payload, Source: atspi.Snapshot{Role: "entry", Editable: true, TextBeforeCaret: tc.context}}) if len(queue.requests) != 1 { t.Fatalf("payload=%q: requests=%+v", tc.payload, queue.requests) } request := queue.requests[0] wantCustom := custom && voices&voiceBit != 0 if request.Text != want || request.CustomEcho != wantCustom || request.Character != character { t.Fatalf("payload=%q: request=%+v; want text=%q custom=%v character=%v", tc.payload, request, want, wantCustom, character) } speaker := &echoRoutingSpeaker{} var output bytes.Buffer worker := newSpeechWorker(logging.New(&output, logging.Options{Level: slog.LevelDebug}), speaker) if err := worker.deliver(t.Context(), request); err != nil { t.Fatal(err) } voice := "" if wantCustom { voice = "echo" } if character { if voice != "" { voice += "-" } voice += "character" } if got := fmt.Sprint(speaker.calls); got != "["+voice+":"+want+"]" { t.Fatalf("backend calls=%s", got) } var record struct { Utterance string `json:"utterance"` CustomEcho *bool `json:"custom_echo"` } if err := json.Unmarshal(bytes.TrimSpace(output.Bytes()), &record); err != nil { t.Fatalf("delivery log: %v: %s", err, output.String()) } if record.Utterance != want || record.CustomEcho == nil || *record.CustomEcho != wantCustom { t.Fatalf("incorrect voice in delivery log: %s", output.String()) } } }) } } } } func TestTypingEchoLineBreakKeepsPriorCaretContext(t *testing.T) { for _, mode := range []string{"word", "sentence"} { for _, before := range []string{"Hello", ""} { t.Run(mode+"/before="+before, func(t *testing.T) { settings := config.DefaultSettings() settings.EnableEchoByWord = mode == "word" settings.EnableEchoBySentence = mode == "sentence" settings.UseCustomEchoVoice = true settings.UseCustomEchoForWord, settings.UseCustomEchoForSentence = true, true live := &liveSettings{} live.Store(settings) queue := &remoteSpeechQueue{} presenter := eventPresenter{state: &eventLoopState{}, live: live, speech: queue, logger: slog.New(slog.DiscardHandler)} source := atspi.Snapshot{BusName: ":1.42", Path: "/editor", Role: "entry", Editable: true, Text: before + "following text", TextBeforeCaret: before, TextCaretKnown: true} presenter.speak(atspi.NewEvent(atspi.FocusEvent, source)) queue.requests = nil source.Text, source.TextBeforeCaret = "following text", "" presenter.speak(atspi.NewEventWithPayload(atspi.TextInsertedEvent, source, "\n")) if before == "" { if len(queue.requests) != 0 { t.Fatalf("following text echoed at start of line: %+v", queue.requests) } return } if len(queue.requests) != 1 || queue.requests[0].Text != before || !queue.requests[0].CustomEcho { t.Fatalf("completed %s: %+v", mode, queue.requests) } if strings.Contains(presenter.state.typingText, "following") { t.Fatalf("following line entered prior-caret cache: %q", presenter.state.typingText) } }) } } }