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