// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "bufio" "context" "log/slog" "os" "os/exec" "path/filepath" "strconv" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/config" ) func TestNativeTypingEchoLineSplit(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_TYPING_ECHO") != "1" { t.Skip("requires native_test_session.py private GTK session") } ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) defer cancel() adapter := atspi.New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer adapter.Stop(context.Background()) previousBus := "" for _, mode := range []string{"word", "sentence"} { for _, offset := range []string{"5", "0"} { t.Run(mode+"/offset="+offset, func(t *testing.T) { fixture := exec.CommandContext(ctx, "/usr/bin/python3", filepath.Join("..", "..", "tests", "gtk_typing_echo.py"), offset) output, err := fixture.StdoutPipe() if err != nil { t.Fatal(err) } fixture.Stderr = os.Stderr if err := fixture.Start(); err != nil { t.Fatal(err) } defer func() { fixture.Process.Kill(); fixture.Wait() }() if line, err := bufio.NewReader(output).ReadString('\n'); err != nil || line != "READY\n" { t.Fatalf("GTK fixture readiness: %q %v", line, err) } keys := func(args ...string) { t.Helper() if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { t.Fatalf("xdotool: %v: %s", err, out) } } keys("search", "--sync", "--onlyvisible", "--pid", strconv.Itoa(fixture.Process.Pid), "--name", "^Typing echo probe$", "windowfocus", "--sync") var focus atspi.Event for focus.Source.Name != "Echo editor" || focus.Source.BusName == previousBus || !isFocusGainEvent(focus) { select { case focus = <-adapter.Events(): case <-ctx.Done(): t.Fatal(ctx.Err()) } } previousBus = focus.Source.BusName want := "Hello" if offset == "0" { want = "" } if !focus.Source.TextCaretKnown || focus.Source.TextBeforeCaret != want { t.Fatalf("initial native caret context: %+v", focus.Source) } settings := config.DefaultSettings() settings.EnableEchoByWord = mode == "word" settings.EnableEchoBySentence = mode == "sentence" live := &liveSettings{} live.Store(settings) queue := &remoteSpeechQueue{} presenter := eventPresenter{state: &eventLoopState{}, live: live, speech: queue, logger: slog.New(slog.DiscardHandler)} presenter.speak(focus) queue.requests = nil keys("key", "--clearmodifiers", "Return") for { select { case event := <-adapter.Events(): if event.Source.ID() != focus.Source.ID() || event.Type != atspi.TextInsertedEvent || event.Payload != "\n" { continue } presenter.speak(event) if want == "" { if len(queue.requests) != 0 { t.Fatalf("start of line echoed following text: %+v", queue.requests) } } else if len(queue.requests) != 1 || queue.requests[0].Text != want { t.Fatalf("line split echo=%+v, want %q; native line=%q before=%q", queue.requests, want, event.Source.Text, event.Source.TextBeforeCaret) } return case <-ctx.Done(): t.Fatal(ctx.Err()) } } }) } } }