// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "log/slog" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" ) func TestRunPresentsLockStatesWithKeyEchoDisabled(t *testing.T) { settings := config.DefaultSettings() settings.EnableSound = false settings.EnableBraille = false settings.EnableKeyEcho = false speaker := &fakeSpeaker{spoken: make(chan string, 4)} commands := &fakeCommands{commands: make(chan input.CommandEvent), lockStates: make(chan input.LockState)} ctx, cancel := context.WithCancel(context.Background()) completed := make(chan error, 1) go func() { completed <- Run(ctx, Options{ Logger: slog.New(slog.DiscardHandler), Settings: &settings, newCommands: func() commandListener { return commands }, newSpeaker: func() Speaker { return speaker }, newAdapter: func() Adapter { return &fakeAdapter{events: make(chan atspi.Event)} }, newInput: func() InputListener { return &fakeInputListener{events: make(chan input.Event)} }, }) }() defer func() { cancel() select { case err := <-completed: if err != nil { t.Error(err) } case <-time.After(time.Second): t.Error("reader did not stop") } }() select { case <-speaker.spoken: case <-time.After(time.Second): t.Fatal("missing startup speech") } for _, name := range []string{"Caps Lock", "Num Lock", "Scroll Lock"} { for _, locked := range []bool{true, false} { state := input.LockState{Name: name, Locked: locked, Timestamp: 100} select { case commands.lockStates <- state: case <-time.After(time.Second): t.Fatal("lock event was not received") } select { case text := <-speaker.spoken: if text != state.Text() { t.Fatalf("speech = %q, want %q", text, state.Text()) } case <-time.After(time.Second): t.Fatal("lock state did not reach speech") } } } } func TestCommandSpeechSurvivesDelayedInitiatingInput(t *testing.T) { for _, cause := range []string{"current-time", "current-date", "version"} { t.Run(cause, func(t *testing.T) { worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil) worker.running = true worker.ProtectCommand(100) worker.Enqueue(speechRequest{Text: "Command result", Role: "system", Cause: cause}) for _, timestamp := range []uint32{80, 99, 100} { if worker.InterruptForInput(input.Event{Pressed: true, Timestamp: timestamp}) { t.Fatalf("command interrupted by initiating input at %d", timestamp) } } if len(worker.pending) != 1 || worker.generation != 0 { t.Fatal("command speech was discarded") } if !worker.InterruptForInput(input.Event{Pressed: true, Timestamp: 101}) || len(worker.pending) != 0 { t.Fatal("later input did not interrupt command speech") } }) } } func TestCommandInputProtectionOrdersTimestampsAcrossWrap(t *testing.T) { worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil) worker.ProtectCommand(^uint32(0) - 1) worker.ProtectCommand(3) worker.ProtectCommand(^uint32(0)) // An older queued notification cannot move the cutoff back. for _, timestamp := range []uint32{^uint32(0), 2, 3} { if worker.InterruptForInput(input.Event{Pressed: true, Timestamp: timestamp}) { t.Fatalf("old input at %d interrupted", timestamp) } } for _, timestamp := range []uint32{4, 0} { if !worker.InterruptForInput(input.Event{Pressed: true, Timestamp: timestamp}) { t.Fatalf("new or untimed input at %d ignored", timestamp) } } }