Files
cthulhu/internal/app/phonetic_hints_test.go

133 lines
5.2 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"log/slog"
"reflect"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/review"
)
func TestPhoneticHintsDelayAndEligibility(t *testing.T) {
for _, test := range []struct {
name, text string
enabled, arrow, eligible bool
want []string
}{
{"enabled", "a", true, true, true, []string{"a", "alpha"}},
{"capital", "A", true, true, true, []string{"A", "alpha"}},
{"disabled", "a", false, true, true, []string{"a"}},
{"modified arrow or Home", "a", true, false, true, []string{"a"}},
{"typing or flat review", "a", true, true, false, []string{"a"}},
{"space", "space", true, true, true, []string{"space"}},
{"punctuation", "!", true, true, true, []string{"!"}},
{"non Latin letter", "猫", true, true, true, []string{"猫"}},
} {
t.Run(test.name, func(t *testing.T) {
speaker := &spellingTestSpeaker{}
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
worker.reading = func() config.ReadingSettings { return config.ReadingSettings{PhoneticCharacterHints: test.enabled} }
worker.inputActivity = func() input.Activity { return input.Activity{PlainCharacterArrow: test.arrow, InputSequence: 1} }
start := time.Now()
if err := worker.deliver(context.Background(), speechRequest{Text: test.text, Character: true, PhoneticHint: test.eligible}); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(speaker.parts, test.want) {
t.Fatalf("speech = %v, want %v", speaker.parts, test.want)
}
if len(test.want) == 2 && time.Since(start) < phoneticHintDelay {
t.Fatal("hint arrived before its delay")
}
if !reflect.DeepEqual(speaker.characters, []string{test.text}) {
t.Fatalf("character speech changed: %v", speaker.characters)
}
})
}
}
type phoneticHintSpeaker struct{ parts chan string }
func (*phoneticHintSpeaker) Connect(context.Context) error { return nil }
func (*phoneticHintSpeaker) Close(context.Context) error { return nil }
func (*phoneticHintSpeaker) Cancel(context.Context) error { return nil }
func (speaker *phoneticHintSpeaker) Speak(_ context.Context, text string) error {
speaker.parts <- text
return nil
}
func (speaker *phoneticHintSpeaker) SpeakCharacter(ctx context.Context, text string) error {
return speaker.Speak(ctx, text)
}
func TestPhoneticHintFreshInputDoesNotWaitForDelay(t *testing.T) {
speaker := &phoneticHintSpeaker{parts: make(chan string, 4)}
worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker)
worker.reading = func() config.ReadingSettings { return config.ReadingSettings{PhoneticCharacterHints: true} }
worker.inputActivity = func() input.Activity { return input.Activity{PlainCharacterArrow: true, InputSequence: 1} }
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := worker.Start(ctx); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
worker.Enqueue(speechRequest{Text: "a", Character: true, PhoneticHint: true})
if got := <-speaker.parts; got != "a" {
t.Fatal(got)
}
// Wait for the delay to be owned, then exercise the real input barrier.
deadline := time.After(time.Second)
for {
worker.mutex.Lock()
waiting := worker.hintCancel != nil
worker.mutex.Unlock()
if waiting {
break
}
select {
case <-deadline:
t.Fatal("hint delay was never registered")
case <-time.After(time.Millisecond):
}
}
if !worker.InterruptForInput(input.NewKeyEvent(1, true, "Right")) {
t.Fatal("fresh arrow did not interrupt pending hint")
}
worker.Enqueue(speechRequest{Text: "next"})
select {
case got := <-speaker.parts:
if got != "next" {
t.Fatalf("stale hint survived fresh input: %q", got)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("fresh input waited for the phonetic delay")
}
}
func TestCharacterNavigationMarksHintEligibility(t *testing.T) {
queue := &fakeSpeechQueue{requests: make(chan speechRequest, 8), spoken: make(chan string, 8)}
state := &eventLoopState{}
state.presentationOptions = presentation.DefaultOptions()
presenter := eventPresenter{state: state, logger: slog.New(slog.DiscardHandler), speech: queue}
presenter.speak(atspi.Event{Type: atspi.CaretEvent, Source: atspi.Snapshot{Path: "/entry", Role: "entry", CaretCharacter: "a"}})
if request := <-queue.requests; !request.PhoneticHint || !request.Character {
t.Fatalf("native character = %+v", request)
}
for _, command := range []review.DocumentCaretCommand{review.CaretNextCharacter, review.CaretPreviousCharacter, review.CaretLineStart, review.CaretNextWord} {
presentDocumentCaretResult(slog.New(slog.DiscardHandler), queue, review.Result{Moved: true, DocumentCaretCommand: command, DocumentCaretText: "a"}, presentation.DefaultOptions())
request := <-queue.requests
want := command == review.CaretNextCharacter || command == review.CaretPreviousCharacter
if request.PhoneticHint != want {
t.Fatalf("%v hint = %t", command, request.PhoneticHint)
}
}
}