162 lines
5.9 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|