// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "log/slog" "os" "testing" "time" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/remote" "github.com/godbus/dbus/v5" ) type wineTestOutput struct { speech chan speechRequest braille chan string } func (output *wineTestOutput) Enqueue(request speechRequest) { output.speech <- request } func (output *wineTestOutput) Interrupt() {} func (output *wineTestOutput) EnqueueMessage(message string) bool { output.braille <- message return true } func TestNativeSessionBusWineEvents(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_DBUS_TEST") != "1" { t.Skip("set CTHULHU_NATIVE_DBUS_TEST=1 inside a private session bus") } output := &wineTestOutput{speech: make(chan speechRequest, 32), braille: make(chan string, 32)} settings := config.DefaultSettings() live := &liveSettings{} live.Store(settings) handler := remoteSpeech{speech: output, braille: output, live: live} owner := newWineEventPresenter(func(message string) bool { return handler.presentMessage(message, "wine-accessible-event") }) defer owner.Close() handler.wine = owner service := remote.New(slog.New(slog.DiscardHandler), handler) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := service.Start(ctx); err != nil { t.Fatal(err) } defer service.Stop(context.Background()) client, err := dbus.ConnectSessionBus() if err != nil { t.Fatal(err) } defer client.Close() object := client.Object(remote.BusName, remote.ObjectPath) send := func(event remote.AccessibleEvent, want bool) { t.Helper() var accepted bool if err := object.CallWithContext(ctx, remote.InterfaceName+".PresentAccessibleEvent", 0, event.SourceID, event.EventType, event.Name, event.Role, event.Value, event.States, event.Description, event.Position, event.Count, event.WindowTitle).Store(&accepted); err != nil { t.Fatal(err) } if accepted != want { t.Fatalf("%s accepted=%v want=%v", event.EventType, accepted, want) } } send(remote.AccessibleEvent{SourceID: "42:text", EventType: "focus", Role: "edit", Value: "hello"}, true) owner.flush() for _, text := range []string{"l", "l", "e selected", "e deleted"} { send(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: text, States: "start=1 end=2 kind=5"}, true) } for _, text := range []string{"A", "a", "É", "é"} { send(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: text, States: "start=1 end=1 kind=8"}, true) } settings.EnableKeyEcho = false live.Store(settings) for _, text := range []string{"A", "space", "tab", "new line"} { send(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: text, States: "kind=8"}, false) } send(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: "e", States: "kind=7"}, true) settings.EnableKeyEcho = true settings.EnableAlphabeticKeys = false live.Store(settings) send(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: "A", States: "kind=8"}, false) settings.EnableAlphabeticKeys = true live.Store(settings) send(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: "A", States: "kind=8"}, true) send(remote.AccessibleEvent{SourceID: "check", EventType: "focus", Name: "Option", Role: "check box", States: "not checked"}, true) owner.flush() send(remote.AccessibleEvent{SourceID: "check", EventType: "name", Name: "Renamed", Role: "check box", States: "not checked"}, true) send(remote.AccessibleEvent{SourceID: "check", EventType: "state", Name: "Renamed", Role: "check box", States: "checked, unavailable"}, true) send(remote.AccessibleEvent{SourceID: "42:text", EventType: "text", Name: "stale"}, false) owner.flush() send(remote.AccessibleEvent{SourceID: "yes", EventType: "focus", Name: "Yes", Role: "client"}, true) owner.flush() owner.mu.Lock() owner.lastTime = time.Now().Add(-time.Second) owner.mu.Unlock() send(remote.AccessibleEvent{SourceID: "no", EventType: "state", Name: "No", Role: "client"}, false) send(remote.AccessibleEvent{SourceID: "yes", EventType: "state", Name: "Yes", Role: "client"}, false) send(remote.AccessibleEvent{SourceID: "no", EventType: "focus", Name: "No", Role: "client"}, true) owner.flush() owner.Close() for _, want := range []string{"edit, hello", "l", "l", "e selected", "e deleted", "A", "a", "É", "é", "e", "A", "Option, check box, not checked", "Renamed, check box, not checked", "Renamed, check box, checked, unavailable", "Yes", "No"} { select { case request := <-output.speech: if request.Text != want || !request.Append { t.Fatalf("speech=%+v want=%q", request, want) } if (want == "A" || want == "a" || want == "É" || want == "é") && !request.Character { t.Fatalf("Wine typing lost character routing: %+v", request) } case <-ctx.Done(): t.Fatal("timed out awaiting speech queue") } select { case message := <-output.braille: if message != want { t.Fatalf("braille=%q want=%q", message, want) } case <-ctx.Done(): t.Fatal("timed out awaiting braille queue") } } if len(output.speech) != 0 || len(output.braille) != 0 { t.Fatal("unexpected extra Wine output") } }