Preserve terminal feedback while coalescing repaints

This commit is contained in:
Storm Dragon committed 2026-11-04 12:22:11 -05:00
1 parent 8c26aad128
commit e222d336a5
13 files changed
+321 -39

No files matched your search

+114 -6
View File
@@ -323,7 +323,7 @@ func TestProcessEventsTerminalRepaintsInDifferentRegionsBothSpeak(t *testing.T)
}
}
func TestProcessEventsDropsTrivialTerminalRepaintFragments(t *testing.T) {
func TestProcessEventsPreservesShortTerminalRepaints(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
ctx, cancel := context.WithCancel(context.Background())
@@ -338,11 +338,16 @@ func TestProcessEventsDropsTrivialTerminalRepaintFragments(t *testing.T) {
terminal.TextCaretKnown = true
terminal.BrailleLineStart = 200
terminal.BrailleCaret = 205
repaintSequence(adapter, terminal, 100, "40")
select {
case text := <-speech.spoken:
t.Fatalf("trivial repaint fragment was presented as %q", text)
case <-time.After(2 * terminalRepaintDebounce):
for _, expected := range []string{"40", "OK", "猫"} {
repaintSequence(adapter, terminal, 100, expected)
select {
case text := <-speech.spoken:
if text != expected {
t.Fatalf("short repaint = %q, want %q", text, expected)
}
case <-time.After(3 * time.Second):
t.Fatalf("short repaint %q was lost", expected)
}
}
repaintSequence(adapter, terminal, 100, "Thinking (4s)")
select {
@@ -576,3 +581,106 @@ func TestProcessEventsPresentsTerminalFocusWithoutFollowingInsertion(t *testing.
t.Fatal("event loop did not stop")
}
}
// Run the real event handler synchronously so coalescing assertions do not
// depend on a scheduler processing an entire burst before a timer fires.
func terminalHandlerFixture(t *testing.T) (*accessibilityEventHandler, *[]string) {
t.Helper()
state := &eventLoopState{}
t.Cleanup(state.pending.clear)
spoken := []string{}
handler := &accessibilityEventHandler{
state: state, logger: slog.New(slog.DiscardHandler), adapter: &fakeAdapter{},
syncForeground: func() {}, webScripts: &webScriptController{},
speak: func(event atspi.Event, _ ...input.Intent) { spoken = append(spoken, event.Payload) },
}
handler.flushTerminalDeletion = func() {
event, _ := state.pending.terminalDeletion.take()
handler.speak(event)
}
return handler, &spoken
}
func TestTerminalRepaintsKeepTerminalIdentityAndFirstDeadline(t *testing.T) {
for _, differentTerminal := range []bool{false, true} {
t.Run(map[bool]string{false: "same terminal", true: "different bus"}[differentTerminal], func(t *testing.T) {
handler, spoken := terminalHandlerFixture(t)
terminal := atspi.Snapshot{BusName: ":1.10", Path: "/terminal", Role: "terminal"}
repaint := func(text string) {
deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Old status")
insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, text)
handler.handle(deletion)
handler.handle(insertion)
}
repaint("First status")
firstTimer := handler.state.pending.terminalRepaint.timer
if differentTerminal {
terminal.BusName = ":1.11"
}
repaint("Second status")
pending := &handler.state.pending.terminalRepaint
if differentTerminal {
if len(*spoken) != 1 || (*spoken)[0] != "First status" {
t.Fatalf("different terminal discarded earlier repaint: %v", *spoken)
}
} else if pending.timer != firstTimer {
t.Fatal("continuous repaint postponed the first deadline")
}
event, _ := pending.take()
if event.Payload != "Second status" {
t.Fatalf("latest repaint = %q", event.Payload)
}
})
}
}
func TestTerminalSeparateDeletionsPreserveEarlierFeedback(t *testing.T) {
handler, spoken := terminalHandlerFixture(t)
terminal := atspi.Snapshot{BusName: ":1.10", Path: "/terminal", Role: "terminal"}
first := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "First deletion")
first.Detail1 = 100
second := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Second deletion")
second.Detail1 = 300
handler.handle(first)
handler.handle(second)
if len(*spoken) != 1 || (*spoken)[0] != first.Payload {
t.Fatalf("unrelated deletion discarded earlier feedback: %v", *spoken)
}
if handler.state.pending.terminalDeletion.event.Payload != second.Payload {
t.Fatal("later deletion was lost")
}
}
func TestTerminalHeldRepaintPreservesOrderAndRetiresOnFocusChange(t *testing.T) {
for _, next := range []string{"append", "deletion", "focus"} {
t.Run(next, func(t *testing.T) {
handler, spoken := terminalHandlerFixture(t)
terminal := atspi.Snapshot{BusName: ":1.10", Path: "/terminal", Role: "terminal"}
deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Old status")
insertion := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Latest status")
handler.handle(deletion)
handler.handle(insertion)
event := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Appended output")
event.Detail1 = 300
switch next {
case "deletion":
event.Type, event.Payload = atspi.TextDeletedEvent, "Later deletion"
case "focus":
event = atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.10", Path: "/entry", Role: "text", Focused: true})
}
handler.handle(event)
if handler.state.pending.terminalRepaint.event.Type != "" {
t.Fatal("old repaint remains pending")
}
if next == "focus" {
for _, text := range *spoken {
if text == "Latest status" {
t.Fatal("old terminal spoke after focus moved to another control")
}
}
} else if len(*spoken) == 0 || (*spoken)[0] != "Latest status" {
t.Fatalf("later output overtook pending repaint: %v", *spoken)
}
})
}
}