// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "errors" "log/slog" "testing" "time" "git.stormux.org/storm/cthulhu/internal/braille" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/platform/xorg" ) type modeTestSpeaker struct{ spoken chan string } func (*modeTestSpeaker) Connect(context.Context) error { return nil } func (*modeTestSpeaker) Close(context.Context) error { return nil } func (*modeTestSpeaker) Cancel(context.Context) error { return nil } func (speaker *modeTestSpeaker) Speak(_ context.Context, text string) error { speaker.spoken <- text return nil } type modeTestBraille struct{ messages chan string } func (*modeTestBraille) EnqueueFrame(braille.Frame) {} func (display *modeTestBraille) Flash(text string, _ config.BrailleOptions) bool { display.messages <- text return true } func TestSpeechModesCoverEveryDeliveryPath(t *testing.T) { for _, mode := range []speechMode{speechModeBeeps, speechModeOff} { t.Run(mode.announcement(), func(t *testing.T) { speaker := &fakeSpeaker{spoken: make(chan string, 10)} worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) worker.mode = mode beeps, began := 0, false worker.beep = func(context.Context) error { beeps++; return nil } for _, request := range []speechRequest{ {Text: "Focused button"}, {Text: "A", Character: true}, {Text: "book", Spelling: []string{"b", "o", "o", "k"}, Character: true}, {Text: "word", CustomEcho: true}, {Text: "External message", Cause: "remote-speak"}, {ReadAll: []readAllPart{{Text: "First line", Began: func() { began = true }}}}, } { if err := worker.deliver(context.Background(), request); err != nil { t.Fatal(err) } } if len(speaker.spoken) != 0 || speaker.canceled || began { t.Fatal("silent/beeps mode used synth, canceled the mode announcement, or advanced Say All") } wantBeeps := 0 if mode == speechModeBeeps { wantBeeps = 6 } if beeps != wantBeeps { t.Fatalf("beeps = %d, want %d", beeps, wantBeeps) } }) } } func TestSpeechModeCommandAnnouncesAndPreservesBraille(t *testing.T) { speaker := &modeTestSpeaker{spoken: make(chan string, 5)} worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) beeps := make(chan struct{}, 5) worker.beep = func(context.Context) error { beeps <- struct{}{}; return nil } if err := worker.Start(context.Background()); err != nil { t.Fatal(err) } defer worker.Stop(context.Background()) settings := config.DefaultSettings() display := &modeTestBraille{messages: make(chan string, 10)} live := &liveSettings{} live.Store(settings) output := outputQueue{speech: worker, braille: display, live: live} commands := &fakeCommands{commands: make(chan input.CommandEvent)} ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan error, 1) go func() { done <- (commandRuntime{logger: slog.New(slog.DiscardHandler), commands: commands, focusGate: &focusGate{}, focusChanges: make(chan xorg.FocusState), speechMessages: worker, speechOutput: output}).run(ctx) }() for index, want := range []string{"Speech Mode Beeps", "Speech off", "Speech Mode Speech"} { commands.commands <- input.CommandEvent{Command: input.CycleSpeechMode} select { case got := <-speaker.spoken: if got != want { t.Fatalf("announcement = %q, want %q", got, want) } case <-time.After(time.Second): t.Fatal("mode announcement missing") } select { case got := <-display.messages: if got != want { t.Fatalf("Braille = %q, want %q", got, want) } case <-time.After(time.Second): t.Fatal("Braille mode announcement missing") } result := make(chan error, 1) output.Enqueue(speechRequest{Text: "Ordinary message", Role: "system", Append: true, result: result}) select { case err := <-result: if err != nil { t.Fatal(err) } case <-time.After(time.Second): t.Fatal("ordinary message did not complete") } if got := <-display.messages; got != "Ordinary message" { t.Fatalf("ordinary Braille = %q", got) } switch index { case 0: if len(beeps) != 1 { t.Fatalf("beeps = %d, want 1", len(beeps)) } case 1: if len(beeps) != 1 || len(speaker.spoken) != 0 { t.Fatal("off mode emitted output") } case 2: if got := <-speaker.spoken; got != "Ordinary message" { t.Fatalf("speech did not resume: %q", got) } } } cancel() <-done } func TestSpeechModeSwitchCancelsActiveBeepAndDropsPendingSpeech(t *testing.T) { speaker := &modeTestSpeaker{spoken: make(chan string, 2)} worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) worker.mode = speechModeBeeps started := make(chan struct{}) worker.beep = func(ctx context.Context) error { close(started); <-ctx.Done(); return ctx.Err() } if err := worker.Start(context.Background()); err != nil { t.Fatal(err) } defer worker.Stop(context.Background()) ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() done := make(chan error, 1) go func() { done <- worker.EnqueueAndWait(ctx, speechRequest{Text: "Active"}) }() select { case <-started: case <-ctx.Done(): t.Fatal("beep did not start") } worker.Enqueue(speechRequest{Text: "Queued"}) if got := worker.cycle_speech_mode(); got != speechModeOff { t.Fatalf("mode = %v", got) } select { case err := <-done: if !errors.Is(err, context.Canceled) { t.Fatalf("beep result = %v", err) } case <-ctx.Done(): t.Fatal("mode switch did not cancel beep") } if err := worker.EnqueueAndWait(ctx, speechRequest{Text: "Speech off", ForceSpeech: true}); err != nil { t.Fatal(err) } if got := <-speaker.spoken; got != "Speech off" { t.Fatalf("retained stale speech: %q", got) } }