// 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: "résumé & keys", Markup: true}, "résumé & keys mode"}, {i3ipc.Event{Kind: i3ipc.Mode, Name: ""}, " mode"}, {i3ipc.Event{Kind: i3ipc.Mode, Name: "", 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") } }