319 lines
13 KiB
Go
319 lines
13 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/braille"
|
|
"git.stormux.org/storm/cthulhu/internal/config"
|
|
"git.stormux.org/storm/cthulhu/internal/i3ipc"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/preferences"
|
|
)
|
|
|
|
func TestI3MessageWording(t *testing.T) {
|
|
settings := config.DefaultSettings().Reading
|
|
for _, tc := range []struct {
|
|
event i3ipc.Event
|
|
want string
|
|
}{
|
|
{i3ipc.Event{Kind: i3ipc.Mode, Name: "default"}, "Default mode"},
|
|
{i3ipc.Event{Kind: i3ipc.Mode, Name: "personal"}, "personal mode"},
|
|
{i3ipc.Event{Kind: i3ipc.Mode, Name: "ratpoison"}, "ratpoison mode"},
|
|
{i3ipc.Event{Kind: i3ipc.Mode, Name: "panel"}, "panel mode"},
|
|
{i3ipc.Event{Kind: i3ipc.Mode, Name: "<b>résumé & keys</b>", Markup: true}, "résumé & keys mode"},
|
|
{i3ipc.Event{Kind: i3ipc.Mode, Name: "<raw & name>"}, "<raw & name> mode"},
|
|
{i3ipc.Event{Kind: i3ipc.Mode, Name: "<broken>", Markup: true}, ""},
|
|
{i3ipc.Event{Kind: i3ipc.Workspace, Name: "99: arbitrary & empty"}, "Workspace 99: arbitrary & empty"},
|
|
{i3ipc.Event{Kind: i3ipc.Fullscreen, Focused: true, Enabled: true}, "Fullscreen on"},
|
|
{i3ipc.Event{Kind: i3ipc.Fullscreen, Window: "Browser"}, "Browser: Fullscreen off"},
|
|
{i3ipc.Event{Kind: i3ipc.Fullscreen, Focused: true, Enabled: true, FullscreenMode: 2}, "Global fullscreen on"},
|
|
{i3ipc.Event{Kind: i3ipc.Floating, Focused: true, Enabled: true}, "Floating on"},
|
|
{i3ipc.Event{Kind: i3ipc.Floating, Window: "Editor"}, "Editor: Floating off"},
|
|
{i3ipc.Event{Kind: i3ipc.Urgent, Window: "Mail", Workspace: "communications"}, "Urgent: Mail, workspace communications"},
|
|
{i3ipc.Event{Kind: i3ipc.Urgent}, "Urgent"},
|
|
{i3ipc.Event{Kind: i3ipc.MarkAdded, Name: "bookmark", Focused: true}, "Mark added: bookmark"},
|
|
{i3ipc.Event{Kind: i3ipc.MarkRemoved, Name: "bookmark", Window: "Editor"}, "Editor: Mark removed: bookmark"},
|
|
{i3ipc.Event{Kind: i3ipc.WorkspaceMove, Workspace: "Notes", Focused: true}, "Moved to workspace Notes"},
|
|
} {
|
|
t.Run(tc.want, func(t *testing.T) {
|
|
if got := i3Message(tc.event, settings); got != tc.want {
|
|
t.Fatalf("got %q want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func TestI3QueueHandoffAndSuppression(t *testing.T) {
|
|
live := &liveSettings{}
|
|
settings := config.DefaultSettings()
|
|
live.Store(settings)
|
|
queue := &remoteSpeechQueue{}
|
|
display := &flashRecorder{}
|
|
gate := newFocusGate(nil)
|
|
controller := i3Announcements{live: live, output: gatedSpeech{gate: gate, speech: outputQueue{speech: queue, braille: display, live: live}}}
|
|
gate.suspended.Store(true)
|
|
for _, name := range []string{"personal", "panel", "default"} {
|
|
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: name})
|
|
}
|
|
if len(queue.requests) != 3 || len(display.messages) != 3 {
|
|
t.Fatalf("handoff: %+v %+v", queue.requests, display.messages)
|
|
}
|
|
for i, request := range queue.requests {
|
|
if !request.Append || !request.BypassFocusGate || request.Role != "system" || request.Text != display.messages[i] {
|
|
t.Fatal(request)
|
|
}
|
|
}
|
|
gate.secure.Store(true)
|
|
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "locked"})
|
|
gate.secure.Store(false)
|
|
ui := &fakeInternalUIState{active: true}
|
|
gate.internalUI = ui
|
|
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "internal"})
|
|
ui.active = false
|
|
settings.Reading.I3Modes = false
|
|
live.Store(settings)
|
|
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "disabled"})
|
|
settings.Reading.I3Modes = true
|
|
live.Store(settings)
|
|
if len(queue.requests) != 3 || len(display.messages) != 3 {
|
|
t.Fatal("suppressed output was presented")
|
|
}
|
|
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "resumed"})
|
|
if len(queue.requests) != 4 || queue.requests[3].Text != "resumed mode" {
|
|
t.Fatal(queue.requests)
|
|
}
|
|
}
|
|
|
|
type fakeInternalUIState struct{ active bool }
|
|
|
|
func (ui *fakeInternalUIState) InternalUIActive() bool { return ui.active }
|
|
|
|
func TestI3PreferencesRuntimeAndPersistence(t *testing.T) {
|
|
settings := config.DefaultSettings()
|
|
settings.Path = filepath.Join(t.TempDir(), "settings.toml")
|
|
store, err := config.NewStore(settings.Path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = store.Save(settings); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil)
|
|
worker.live = &liveSettings{}
|
|
worker.live.Store(settings)
|
|
worker.beginEdit(context.Background(), "")
|
|
values := preferences.FromSettings(worker.editing)
|
|
values.Reading.I3Modes = false
|
|
values.Reading.I3Workspaces = false
|
|
values.Reading.I3WindowStates = false
|
|
values.Reading.I3Urgency = false
|
|
values.Reading.I3Marks = false
|
|
values.Reading.I3WorkspaceMoves = false
|
|
if response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}); !response.Accepted {
|
|
t.Fatal(response.Message)
|
|
}
|
|
loaded, err := config.Load(settings.Path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, reading := range []config.ReadingSettings{loaded.Reading, worker.live.Load().Reading} {
|
|
for _, kind := range []i3ipc.Kind{i3ipc.Mode, i3ipc.Workspace, i3ipc.Fullscreen, i3ipc.Floating, i3ipc.Urgent, i3ipc.MarkAdded, i3ipc.MarkRemoved, i3ipc.WorkspaceMove} {
|
|
if got := i3Message(i3ipc.Event{Kind: kind, Name: "test", Workspace: "B"}, reading); got != "" {
|
|
t.Fatalf("disabled preference presented %q", got)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestI3FlashRestoresApplicationContent(t *testing.T) {
|
|
worker := &brailleWorker{wake: make(chan struct{}, 1), cancel: func() {}, connected: true}
|
|
live := &liveSettings{}
|
|
settings := config.DefaultSettings()
|
|
settings.Braille.FlashTime = 20
|
|
settings.Braille.FlashPersistent = false
|
|
live.Store(settings)
|
|
worker.EnqueueFrame(braille.Frame{Text: "Application content", Cursor: 2})
|
|
controller := i3Announcements{live: live, output: outputQueue{speech: &remoteSpeechQueue{}, braille: worker, live: live}}
|
|
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "panel"})
|
|
if got := worker.current(); got.Text != "panel mode" {
|
|
t.Fatal(got)
|
|
}
|
|
deadline := time.Now().Add(time.Second)
|
|
for worker.current().Text != "Application content" && time.Now().Before(deadline) {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if got := worker.current(); got.Text != "Application content" || got.Cursor != 2 {
|
|
t.Fatal(got)
|
|
}
|
|
}
|
|
|
|
func TestI3UsesSpeechFIFOInterruptionAndDisablePolicy(t *testing.T) {
|
|
live := &liveSettings{}
|
|
live.Store(config.DefaultSettings())
|
|
speech := newSpeechWorker(slog.New(slog.DiscardHandler), nil)
|
|
// Hold the delivery worker so admission order and cancellation are observable.
|
|
speech.running = true
|
|
controller := i3Announcements{live: live, output: outputQueue{speech: speech, live: live}}
|
|
speech.Enqueue(speechRequest{Text: "Application output"})
|
|
generation := speech.generation
|
|
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "personal"})
|
|
controller.present(i3ipc.Event{Kind: i3ipc.Workspace, Name: "Notes"})
|
|
if len(speech.pending) != 3 || speech.pending[0].Text != "Application output" || speech.pending[1].Text != "personal mode" || speech.pending[2].Text != "Workspace Notes" || speech.generation != generation {
|
|
t.Fatalf("incoming events changed FIFO or interrupted: %+v", speech.pending)
|
|
}
|
|
speech.InterruptForUser()
|
|
if len(speech.pending) != 0 {
|
|
t.Fatal("user interruption did not clear desktop messages")
|
|
}
|
|
speech.SetEnabled(false)
|
|
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "suppressed"})
|
|
speech.SetEnabled(true)
|
|
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "new"})
|
|
if len(speech.pending) != 1 || speech.pending[0].Text != "new mode" {
|
|
t.Fatalf("disabled events replayed: %+v", speech.pending)
|
|
}
|
|
}
|
|
|
|
func TestI3KeyboardInterruptsWorkspaceAndQueuedTitle(t *testing.T) {
|
|
for _, suspended := range []bool{false, true} {
|
|
for _, key := range []string{"Control_L", "Shift_L", "a"} {
|
|
name := "application/" + key
|
|
if suspended {
|
|
name = "handoff/" + key
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
logger := slog.New(slog.DiscardHandler)
|
|
speaker := &controlledSpeaker{calls: make(chan speechCall, 8)}
|
|
worker := newSpeechWorker(logger, speaker)
|
|
if err := worker.Start(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := worker.Stop(ctx); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
live := &liveSettings{}
|
|
settings := config.DefaultSettings()
|
|
settings.EnableKeyEcho = false
|
|
live.Store(settings)
|
|
gate := newFocusGate(nil)
|
|
gate.SetSuspended(suspended)
|
|
output := gatedSpeech{gate: gate, speech: outputQueue{speech: worker, live: live}}
|
|
controller := i3Announcements{live: live, output: output}
|
|
controller.present(i3ipc.Event{Kind: i3ipc.Workspace, Name: "8"})
|
|
active := expectSpeechCall(t, speaker, "Workspace 8")
|
|
// Both pieces can be accepted before the key, while only the first is
|
|
// speaking. A key must cancel the active utterance and queued continuation.
|
|
worker.Enqueue(speechRequest{Text: "Browser - example site", Cause: "window-title"})
|
|
events := &fakeInputListener{events: make(chan input.Event, 1)}
|
|
events.events <- input.Event{Name: key, Pressed: true, Timestamp: 100}
|
|
close(events.events)
|
|
processInputEvents(context.Background(), logger, gatedSpeech{gate: gate, speech: worker}, events, input.EchoSettings{}, live, nil)
|
|
firstCancel := expectSpeechCall(t, speaker, "cancel")
|
|
worker.mutex.Lock()
|
|
pending := len(worker.pending)
|
|
worker.mutex.Unlock()
|
|
if pending != 0 {
|
|
t.Fatal("key retained queued title")
|
|
}
|
|
close(firstCancel.done)
|
|
close(active.done)
|
|
followup := expectSpeechCall(t, speaker, "cancel")
|
|
close(followup.done)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestI3HandoffInterruptionRespectsSettingsAndGates(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, mode, key string
|
|
keys []string
|
|
locked, ui, release, stop bool
|
|
}{
|
|
{name: "all", mode: input.InterruptAllKeys, key: "Shift_L", stop: true},
|
|
{name: "common shift", mode: input.InterruptCommonKeys, key: "Shift_L"},
|
|
{name: "common control", mode: input.InterruptCommonKeys, key: "Control_L", stop: true},
|
|
{name: "custom unselected", mode: input.InterruptCustomKeys, keys: []string{"F8"}, key: "Control_L"},
|
|
{name: "custom selected", mode: input.InterruptCustomKeys, keys: []string{"F8"}, key: "F8", stop: true},
|
|
{name: "release", mode: input.InterruptAllKeys, key: "Control_L", release: true},
|
|
{name: "locked", mode: input.InterruptAllKeys, key: "Control_L", locked: true},
|
|
{name: "internal UI", mode: input.InterruptAllKeys, key: "Control_L", ui: true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
logger := slog.New(slog.DiscardHandler)
|
|
worker := newSpeechWorker(logger, nil)
|
|
worker.running = true
|
|
live := &liveSettings{}
|
|
settings := config.DefaultSettings()
|
|
settings.EnableKeyEcho = true
|
|
settings.EnableModifierKeys = true
|
|
settings.SpeechInterruption = input.InterruptionSettings{Mode: tc.mode, Keys: tc.keys}
|
|
live.Store(settings)
|
|
gate := newFocusGate(nil)
|
|
gate.SetSuspended(true)
|
|
output := gatedSpeech{gate: gate, speech: outputQueue{speech: worker, live: live}}
|
|
controller := i3Announcements{live: live, output: output}
|
|
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "personal"})
|
|
gate.secure.Store(tc.locked)
|
|
gate.internalUI = &fakeInternalUIState{active: tc.ui}
|
|
events := &fakeInputListener{events: make(chan input.Event, 1)}
|
|
events.events <- input.Event{Name: tc.key, Pressed: !tc.release, Timestamp: 100}
|
|
close(events.events)
|
|
processInputEvents(context.Background(), logger, output, events, input.EchoSettings{}, live, nil)
|
|
want := 1
|
|
if tc.stop {
|
|
want = 0
|
|
}
|
|
if len(worker.pending) != want {
|
|
t.Fatalf("pending=%+v, want %d; key echo must remain delegated", worker.pending, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestI3HandoffInterruptionSurvivesBackendAcceptanceAndResets(t *testing.T) {
|
|
logger := slog.New(slog.DiscardHandler)
|
|
speaker := &fakeSpeaker{spoken: make(chan string, 1)}
|
|
worker := newSpeechWorker(logger, speaker)
|
|
worker.running = true
|
|
live := &liveSettings{}
|
|
live.Store(config.DefaultSettings())
|
|
controller := i3Announcements{live: live, output: worker}
|
|
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "panel"})
|
|
request, ok := worker.next()
|
|
if !ok {
|
|
t.Fatal("missing desktop speech")
|
|
}
|
|
if err := worker.deliver(context.Background(), request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
<-speaker.spoken // SPEAK can return while the synthesizer is still talking.
|
|
key := input.Event{Name: "Control_L", Pressed: true, Timestamp: 100}
|
|
if !worker.InterruptForHandoffInput(key) {
|
|
t.Fatal("accepted desktop speech could not be stopped")
|
|
}
|
|
if worker.InterruptForHandoffInput(key) {
|
|
t.Fatal("handoff forwarded cancellation after desktop speech retired")
|
|
}
|
|
worker.Enqueue(speechRequest{Text: "Ordinary application speech"})
|
|
if worker.InterruptForHandoffInput(key) {
|
|
t.Fatal("ordinary delegated input was forwarded without desktop speech")
|
|
}
|
|
worker.SetEnabled(false)
|
|
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "disabled"})
|
|
worker.SetEnabled(true)
|
|
if worker.InterruptForHandoffInput(key) {
|
|
t.Fatal("disabled desktop request armed handoff cancellation")
|
|
}
|
|
}
|