// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "bytes" "context" "errors" "log/slog" "strings" "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/logging" ) type continuousSpeaker struct { started chan string finish chan struct{} } func TestReadAllSentencePartsKeepLineProgress(t *testing.T) { progress := 0 parts := readAllLineParts("First sentence. Second sentence! Last words", func() { progress++ }, "sentence") if len(parts) != 3 || parts[0].Text != "First sentence." || parts[1].Text != "Second sentence!" || parts[2].Text != "Last words" { t.Fatalf("sentence parts = %#v", parts) } if parts[0].Began == nil || parts[1].Began != nil || parts[2].Began != nil { t.Fatalf("line progress callbacks = %#v", parts) } parts[0].Began() if progress != 1 { t.Fatalf("progress = %d", progress) } line := readAllLineParts("First sentence. Second sentence!", nil, "line") if len(line) != 1 || line[0].Text != "First sentence. Second sentence!" { t.Fatalf("line part = %#v", line) } } func TestReadAllContextArrivesAtItsLine(t *testing.T) { speaker := &continuousSpeaker{started: make(chan string, 6), finish: make(chan struct{}, 2)} speaker.finish <- struct{}{} speaker.finish <- struct{}{} reading := config.DefaultSettings().Reading worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) worker.reading = func() config.ReadingSettings { return reading } list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Items"} request := speechRequest{ReadAll: []readAllPart{ {Text: "Before", Context: []atspi.Snapshot{}}, {Text: "Inside", Context: []atspi.Snapshot{list}}, }} if err := worker.deliver(context.Background(), request); err != nil { t.Fatal(err) } for _, want := range []string{"Before", "Items list", "Inside"} { if got := <-speaker.started; got != want { t.Fatalf("spoken %q, want %q", got, want) } } reading.SayAllContextList = false worker.speechContext = nil speaker.finish <- struct{}{} if err := worker.deliver(context.Background(), speechRequest{ReadAll: []readAllPart{{Text: "Inside again", Context: []atspi.Snapshot{list}}}}); err != nil { t.Fatal(err) } if got := <-speaker.started; got != "Inside again" || len(speaker.started) != 0 { t.Fatalf("disabled list context speech = %q; queued = %d", got, len(speaker.started)) } } func TestReadAllDiagnosticsIncludeOnlyDeliveredParts(t *testing.T) { var output bytes.Buffer speaker := &continuousSpeaker{started: make(chan string, 3), finish: make(chan struct{}, 1)} speaker.finish <- struct{}{} worker := newSpeechWorker(logging.New(&output, logging.Options{Level: slog.LevelDebug}), speaker) ctx, cancel := context.WithCancel(context.Background()) defer cancel() request := speechRequest{Cause: "read-all", Role: "system", ReadAll: []readAllPart{ {Text: "First line"}, {Text: "Second line", Began: cancel}, {Text: "Unread line"}, }} if err := worker.deliver(ctx, request); !errors.Is(err, context.Canceled) { t.Fatalf("interrupted reading: %v", err) } for _, expected := range []string{`"utterance":"First line"`, `"utterance":"Second line"`, `"read_all_index":1`} { if !strings.Contains(output.String(), expected) { t.Fatalf("reading diagnostics missing %s: %s", expected, output.String()) } } if strings.Contains(output.String(), "Unread line") { t.Fatalf("logged reading which never reached the backend: %s", output.String()) } } func (*continuousSpeaker) Connect(context.Context) error { return nil } func (*continuousSpeaker) Close(context.Context) error { return nil } func (*continuousSpeaker) Cancel(context.Context) error { return nil } func (speaker *continuousSpeaker) Speak(_ context.Context, text string) error { speaker.started <- text return nil } func (speaker *continuousSpeaker) SpeakAndWait(ctx context.Context, text string, began func()) error { if began != nil { began() } speaker.started <- text select { case <-speaker.finish: return nil case <-ctx.Done(): return ctx.Err() } } func TestReadAllPacesSpeechAndInterruptsOnNewInputOrFocus(t *testing.T) { for _, cause := range []string{"key", "focus"} { t.Run(cause, func(t *testing.T) { speaker := &continuousSpeaker{started: make(chan string, 4), finish: make(chan struct{})} worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) worker.Start(context.Background()) defer worker.Stop(context.Background()) ctx := worker.BeginReadAll(context.Background(), 100) // The initiating key can reach XRecord after command dispatch. if worker.InterruptForInput(input.Event{Pressed: true, Timestamp: 100}) || ctx.Err() != nil { t.Fatal("read all canceled by its initiating key") } progress := make(chan int, 3) worker.Enqueue(speechRequest{Cause: "read-all", context: ctx, ReadAll: []readAllPart{ {Text: "First", Began: func() { progress <- 1 }}, {Text: "Second", Began: func() { progress <- 2 }}, {Text: "Third", Began: func() { progress <- 3 }}, }}) waitText := func(want string) { t.Helper() select { case got := <-speaker.started: if got != want { t.Fatalf("speech %q, want %q", got, want) } case <-time.After(time.Second): t.Fatalf("missing %q", want) } } waitText("First") if <-progress != 1 { t.Fatal("wrong starting position") } select { case <-speaker.started: t.Fatal("advanced before speech completed") default: } speaker.finish <- struct{}{} waitText("Second") if <-progress != 2 { t.Fatal("wrong second position") } if cause == "key" { if !worker.InterruptForInput(input.Event{Pressed: true, Timestamp: 101}) { t.Fatal("new key ignored") } } worker.Enqueue(speechRequest{Text: "New focus", Cause: "focus:"}) waitText("New focus") if ctx.Err() == nil { t.Fatal("read all was not canceled") } select { case <-progress: t.Fatal("reading position advanced after interruption") default: } }) } }