213 lines
6.5 KiB
Go
213 lines
6.5 KiB
Go
// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
|
|
|
|
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")
|
|
}
|
|
}
|