// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "log/slog" "slices" "testing" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/input" ) func TestSubmittedMessageIsAnnouncedOnce(t *testing.T) { for _, key := range []string{"Return", "KP_Enter"} { t.Run(key, func(t *testing.T) { entry := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/entry") entry.Source.Role, entry.Source.ApplicationProcess = "text", "pidgin" entry.Source.Editable, entry.Source.Focused, entry.Source.TextCaretKnown = true, true, true deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry.Source, "A sample message.") deletion.Window = entry.Window message := pidginMessageEvent("Username: A sample message.") tracker := &input.IntentTracker{} tracker.Observe(input.NewKeyEvent(0, true, key)) tracker.Observe(input.NewKeyEvent(0, false, key)) got := submitted_text_speech(t, tracker, entry, deletion, atspi.NewEvent(atspi.CaretEvent, entry.Source), message) want := []string{"text", "Username: A sample message."} if !slices.Equal(got, want) { t.Fatalf("submission speech = %q, want %q", got, want) } }) } } func TestSubmissionClearPreservesEditingFeedback(t *testing.T) { for _, test := range []struct { name string keys []string selected bool removeFirst bool insert string remainingText string caretKnown bool want []string }{ {name: "plain Enter clear", keys: []string{"Return"}, caretKnown: true}, {name: "Backspace", keys: []string{"BackSpace"}, caretKnown: true, want: []string{"old"}}, {name: "Delete", keys: []string{"Delete"}, caretKnown: true, want: []string{"old"}}, {name: "word deletion", keys: []string{"Control_L", "BackSpace"}, caretKnown: true, want: []string{"old"}}, {name: "cut", keys: []string{"Control_L", "x"}, caretKnown: true, want: []string{"cut"}}, {name: "paste replacement", keys: []string{"Control_L", "v"}, insert: "new", caretKnown: true, want: []string{"old", "pasted from clipboard"}}, {name: "undo", keys: []string{"Control_L", "z"}, caretKnown: true, want: []string{"old"}}, {name: "redo", keys: []string{"Control_L", "Shift_L", "z"}, caretKnown: true, want: []string{"old"}}, {name: "typing replaces selection", keys: []string{"x"}, selected: true, insert: "x", caretKnown: true, want: []string{"old", "x"}}, {name: "Enter replaces selection", keys: []string{"Return"}, selected: true, insert: "\n", caretKnown: true, want: []string{"old"}}, {name: "selection removal precedes replacement", keys: []string{"Return"}, selected: true, removeFirst: true, caretKnown: true, want: []string{"selection removed", "old"}}, {name: "Shift Enter", keys: []string{"Shift_L", "Return"}, caretKnown: true, want: []string{"old"}}, {name: "unknown caret", keys: []string{"Return"}, want: []string{"old"}}, {name: "no keyboard evidence", caretKnown: true, want: []string{"old"}}, {name: "Enter leaves text", keys: []string{"Return"}, caretKnown: true, remainingText: "remaining", want: []string{"old"}}, } { t.Run(test.name, func(t *testing.T) { tracker := &input.IntentTracker{} for _, key := range test.keys { tracker.Observe(input.NewKeyEvent(0, true, key)) } entry := atspi.Snapshot{BusName: ":1.42", Path: "/entry", Role: "entry", Editable: true, Focused: true, TextCaretKnown: test.caretKnown} if test.selected { entry.SelectionStart, entry.SelectionEnd, entry.SelectionText = 0, 3, "old" } events := []atspi.Event{atspi.NewEvent(atspi.FocusEvent, entry)} entry.SelectionStart, entry.SelectionEnd, entry.SelectionText = 0, 0, "" entry.Text = test.remainingText if test.removeFirst { events = append(events, atspi.NewEvent(atspi.TextSelectionChangedEvent, entry)) } events = append(events, atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry, "old")) if test.insert != "" { events = append(events, atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, test.insert)) } got := submitted_text_speech(t, tracker, events...) // Initial focus also presents an existing selection. got = got[1:] if !slices.Equal(got, test.want) { t.Fatalf("editing speech = %q, want %q", got, test.want) } }) } } func submitted_text_speech(t *testing.T, inputSource any, events ...atspi.Event) []string { t.Helper() adapter := &fakeAdapter{events: make(chan atspi.Event, len(events))} for _, event := range events { adapter.events <- event } close(adapter.events) queue := &remoteSpeechQueue{} if err := processEventsWithInput(context.Background(), slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, inputSource, nil, nil); err != nil { t.Fatal(err) } texts := make([]string, 0, len(queue.requests)) for _, request := range queue.requests { texts = append(texts, request.Text) } return texts }