Files
cthulhu/internal/app/typing_echo_matrix_test.go
2026-10-27 00:40:49 -04:00

162 lines
5.9 KiB
Go

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