// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "bufio" "context" "fmt" "log/slog" "os" "os/exec" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/config" ) func TestNativeProgressBars(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_PROGRESS") != "1" { t.Skip("requires private X11/AT-SPI progress fixture") } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() adapter := atspi.New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer func() { stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) defer stopCancel() if err := adapter.Stop(stopCtx); err != nil { t.Error(err) } }() fixture := exec.CommandContext(ctx, "python3", "../../tests/gtk_progress.py") fixture.Stderr = os.Stderr commands, err := fixture.StdinPipe() if err != nil { t.Fatal(err) } ready, err := fixture.StdoutPipe() if err != nil { t.Fatal(err) } if err := fixture.Start(); err != nil { t.Fatal(err) } defer func() { commands.Close(); fixture.Process.Kill(); fixture.Wait() }() line, err := bufio.NewReader(ready).ReadString('\n') if err != nil || strings.TrimSpace(line) != "ready" { t.Fatalf("fixture: %q %v", line, err) } focus := func(title string) { t.Helper() windows, err := exec.Command("xdotool", "search", "--onlyvisible", "--name", "^"+title+"$").Output() if err != nil { t.Fatal(err) } if err := exec.Command("xdotool", "windowfocus", "--sync", strings.Fields(string(windows))[0]).Run(); err != nil { t.Fatal(err) } } focus("Progress probe") settings := config.DefaultSettings() settings.ProgressBar.OutputMode = "both" live := &liveSettings{} live.Store(settings) speech := &fakeSpeechQueue{spoken: make(chan string, 16), requests: make(chan speechRequest, 16)} state := &eventLoopState{} handler := &accessibilityEventHandler{state: state, live: live, speech: speech, logger: slog.New(slog.DiscardHandler)} update := func(value float64, hidden ...bool) atspi.Event { t.Helper() command := fmt.Sprint(value) if len(hidden) != 0 && hidden[0] { command = "hide " + command } if _, err := fmt.Fprintln(commands, command); err != nil { t.Fatal(err) } for { select { case <-ctx.Done(): t.Fatal("progress value event missing") return atspi.Event{} case event := <-adapter.Events(): if isValueChangeEvent(event.Type) && event.Source.Role == "progress bar" && event.Source.CurrentValue == value { return event } } } } event := update(0.5) if event.Source.Focused || !event.Source.Visible || !event.Source.Showing || event.ForegroundMismatch { t.Fatalf("foreground progress capture: %+v", event) } state.owner.window, state.owner.applicationBus = event.Window, event.Source.BusName if !handler.handle_progress_bar(event) { t.Fatal("foreground progress was lost") } request := <-speech.requests if request.Text != "50 percent" || len(request.Sound) != 1 || request.Sound[0].Name != "generated-progress-tone:50" { t.Fatalf("foreground progress output: %+v", request) } focus("Other probe") event = update(0.75) if !event.ForegroundMismatch { t.Fatal("native background progress was not identified") } handler.handle_progress_bar(event) if len(speech.requests) != 0 { t.Fatal("background output enabled by default") } settings.ProgressBar.ReportBackground = true live.Store(settings) handler.handle_progress_bar(event) request = <-speech.requests if request.Text != "75 percent" || len(request.Sound) != 1 { t.Fatalf("background output: %+v", request) } event = update(0.9, true) if event.Source.Showing || handler.handle_progress_bar(event) { t.Fatal("hidden progress generated automatic output") } }