// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package app import ( "context" "log/slog" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" ) func TestProcessEventsCoalescesTerminalDeletionWithReplacementInsertion(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 3)} adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan struct{}) go func() { processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) close(completed) }() terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "") output := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Command output") deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "old prompt") deletion.Detail1 = 12 prompt := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "new prompt") prompt.Detail1 = 12 adapter.events <- output adapter.events <- deletion adapter.events <- prompt for _, expected := range []string{"Command output", "new prompt"} { select { case text := <-speech.spoken: if text != expected { t.Fatalf("spoken text = %q, want %q", text, expected) } case <-time.After(time.Second): t.Fatalf("timed out waiting for terminal speech %q", expected) } } select { case text := <-speech.spoken: t.Fatalf("terminal redraw deletion was presented as %q", text) case <-time.After(2 * terminalMutationDebounce): } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("event loop did not stop") } } func TestProcessEventsPresentsStandaloneTerminalDeletion(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 1)} adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan struct{}) go func() { processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) close(completed) }() terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "") adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "x") select { case text := <-speech.spoken: if text != "x" { t.Fatalf("spoken deletion = %q, want %q", text, "x") } case <-time.After(time.Second): t.Fatal("standalone terminal deletion was not presented") } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("event loop did not stop") } } func TestProcessEventsDoesNotCoalesceTerminalChangesAtDifferentOffsets(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 2)} adapter := &fakeAdapter{events: make(chan atspi.Event, 2)} ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan struct{}) go func() { processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) close(completed) }() terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "") deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "x") deletion.Detail1 = 12 insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "y") insertion.Detail1 = 13 adapter.events <- deletion adapter.events <- insertion for _, expected := range []string{"x", "y"} { select { case text := <-speech.spoken: if text != expected { t.Fatalf("spoken text = %q, want %q", text, expected) } case <-time.After(time.Second): t.Fatalf("timed out waiting for terminal speech %q", expected) } } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("event loop did not stop") } } func TestProcessEventsCoalescesTerminalFocusWithFollowingInsertion(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 2)} adapter := &fakeAdapter{events: make(chan atspi.Event, 2)} ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan struct{}) go func() { processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, time.Hour) close(completed) }() terminal := atspi.NewSnapshot("/accessible/terminal", "Terminal", "terminal", "", "Old prompt") focused := atspi.NewEvent(atspi.FocusedStateEvent, terminal) focused.Detail1 = 1 adapter.events <- focused adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "New prompt") select { case text := <-speech.spoken: if text != "New prompt" { t.Fatalf("speech = %q, want only following insertion", text) } case <-time.After(time.Second): t.Fatal("following terminal insertion was not presented") } select { case text := <-speech.spoken: t.Fatalf("redundant terminal focus was presented as %q", text) default: } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("event loop did not stop") } } func TestProcessEventsPresentsTerminalFocusWithoutFollowingInsertion(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 1)} adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan struct{}) go func() { processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) close(completed) }() terminal := atspi.NewSnapshot("/accessible/terminal", "Terminal", "terminal", "", "Prompt") terminal.ApplicationName = "TestTerminal" focused := atspi.NewEvent(atspi.FocusedStateEvent, terminal) focused.Detail1 = 1 adapter.events <- focused select { case text := <-speech.spoken: if text != "Terminal, terminal, Prompt" { t.Fatalf("terminal focus speech = %q", text) } case <-time.After(time.Second): t.Fatal("terminal focus was not presented after debounce") } cancel() select { case <-completed: case <-time.After(time.Second): t.Fatal("event loop did not stop") } }