package app import ( "context" "log/slog" "testing" "time" "git.stormux.org/storm/cthulhu/internal/braille" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) func tentacleOutputFixture(t *testing.T) *tentacleOutput { t.Helper() live := &liveSettings{} settings := config.DefaultSettings() settings.EnableBraille = true settings.Braille.FlashMessages = true settings.Braille.FlashPersistent = true live.Store(settings) display := &brailleWorker{logger: slog.New(slog.DiscardHandler), cancel: func() {}, connected: true, wake: make(chan struct{}, 1), frame: braille.Frame{Text: "Core focus", Cursor: -1}, baseFrame: braille.Frame{Text: "Core focus", Cursor: -1}} speech := newSpeechWorker(slog.New(slog.DiscardHandler), nil) speech.running = true // Inspect the bounded queue without a delivery goroutine. owner := &tentacleOutput{speech: speech, braille: display, gate: newFocusGate(nil), live: live} owner.gate.tentacleOutput = owner t.Cleanup(owner.Retire) return owner } func TestTentacleIndependentOutputOwnership(t *testing.T) { for _, change := range []string{"none", "speech interrupt", "new speech", "new braille", "braille disabled", "flashes disabled"} { t.Run(change, func(t *testing.T) { owner := tentacleOutputFixture(t) ticket := owner.Capture(context.Background()) expectSpeech, expectBraille := true, true switch change { case "speech interrupt": owner.speech.InterruptForUser() expectSpeech = false case "new speech": owner.speech.Enqueue(speechRequest{Text: "New core speech"}) expectSpeech = false case "new braille": owner.braille.Enqueue("New core braille") expectBraille = false case "braille disabled": settings := owner.live.Load() settings.EnableBraille = false owner.live.Store(settings) expectBraille = false case "flashes disabled": settings := owner.live.Load() settings.Braille.FlashMessages = false owner.live.Store(settings) expectBraille = false } owner.Present(ticket, tentaclewire.Presentation{Speech: "Tentacle speech", Braille: "Tentacle braille"}, "tentacle/test/probe") gotSpeech := false for _, request := range owner.speech.pending { if request.Text == "Tentacle speech" { gotSpeech = true if request.context != ticket.ctx || request.ForceSpeech || request.BypassFocusGate || request.Role != "system" { t.Fatal("unsafe speech request") } } } if gotSpeech != expectSpeech || (owner.braille.current().Text == "Tentacle braille") != expectBraille { t.Fatal("channel ownership bypassed") } if len(owner.braille.current().Routes) != 0 { t.Fatal("message gained routing targets") } }) } } func TestTentacleRetiredAndBufferedOutput(t *testing.T) { for _, change := range []string{"revision", "new command", "revoked", "handoff and resume", "lock and unlock"} { t.Run(change, func(t *testing.T) { owner := tentacleOutputFixture(t) parent, cancel := context.WithCancel(context.Background()) defer cancel() ticket := owner.Capture(parent) switch change { case "revision": owner.gate.retireTentacleOutput() case "new command": owner.Capture(context.Background()) case "revoked": cancel() case "handoff and resume": owner.gate.SetSuspended(true) owner.gate.SetSuspended(false) case "lock and unlock": owner.gate.SetLocked(true, nil, nil, nil, owner.speech, owner.braille) owner.gate.SetLocked(false, nil, nil, nil, owner.speech, owner.braille) } owner.Present(ticket, tentaclewire.Presentation{Speech: "Late speech", Braille: "Late braille"}, "tentacle/test/probe") if len(owner.speech.pending) != 0 || owner.braille.current().Text == "Late braille" { t.Fatal("retired output was accepted") } }) } } func TestTentacleAcceptedOutputRetirement(t *testing.T) { for _, newer := range []bool{false, true} { owner := tentacleOutputFixture(t) parent, cancel := context.WithCancel(context.Background()) ticket := owner.Capture(parent) owner.Present(ticket, tentaclewire.Presentation{Speech: "Queued speech", Braille: "Tentacle flash"}, "tentacle/test/probe") if len(owner.speech.pending) != 1 || owner.braille.current().Text != "Tentacle flash" { t.Fatal("presentation not queued") } if newer { owner.speech.Enqueue(speechRequest{Text: "New core output"}) owner.braille.Flash("New core flash", owner.live.Load().Braille) } cancel() // Reply was already consumed; its session still owns output lifetime. if !newer && owner.braille.current().Text != "Core focus" { t.Fatal("revoked flash survived consumption") } owner.RetireRevoked() if newer { if owner.braille.current().Text != "New core flash" || len(owner.speech.pending) != 1 || owner.speech.pending[0].Text != "New core output" { t.Fatal("retirement displaced core output") } } else if len(owner.speech.pending) != 0 { t.Fatal("queued retired speech remains") } } } func TestTentacleInitiatingInputAndLaterInterruption(t *testing.T) { owner := tentacleOutputFixture(t) owner.speech.ProtectCommand(100) ticket := owner.Capture(context.Background()) if owner.speech.InterruptForInput(input.Event{Timestamp: 100, Pressed: true}) { t.Fatal("initiating chord interrupted command") } owner.Present(ticket, tentaclewire.Presentation{Speech: "Command result"}, "tentacle/test/probe") if len(owner.speech.pending) != 1 { t.Fatal("initiating input retired speech") } owner.speech.InterruptForInput(input.Event{Timestamp: 101, Pressed: true}) if len(owner.speech.pending) != 0 { t.Fatal("later input failed to interrupt") } } func TestTentacleCoreSpeechModes(t *testing.T) { for _, mode := range []speechMode{speechModeSpeech, speechModeBeeps, speechModeOff} { t.Run(mode.announcement(), func(t *testing.T) { owner := tentacleOutputFixture(t) speaker := &fakeSpeaker{spoken: make(chan string, 1)} owner.speech.speaker = speaker owner.speech.mode = mode beeps := 0 owner.speech.beep = func(context.Context) error { beeps++; return nil } ticket := owner.Capture(context.Background()) owner.Present(ticket, tentaclewire.Presentation{Speech: "Hello from a tentacle.", Braille: "Display message"}, "tentacle/test/probe") request, ok := owner.speech.next() if !ok { t.Fatal("no queued presentation") } if err := owner.speech.deliver(request.context, request); err != nil { t.Fatal(err) } if (len(speaker.spoken) == 1) != (mode == speechModeSpeech) || (beeps == 1) != (mode == speechModeBeeps) { t.Fatal("tentacle bypassed core speech mode") } if owner.braille.current().Text != "Display message" { t.Fatal("speech mode suppressed independent braille") } }) } } func TestTentacleRevocationPreservesQueuedCoreSpeech(t *testing.T) { owner := tentacleOutputFixture(t) speaker := &controlledSpeaker{calls: make(chan speechCall, 10)} owner.speech.speaker = speaker owner.speech.running = false if err := owner.speech.Start(context.Background()); err != nil { t.Fatal(err) } t.Cleanup(func() { owner.speech.Stop(context.Background()) }) ticket := owner.Capture(context.Background()) owner.Present(ticket, tentaclewire.Presentation{Speech: "Tentacle output"}, "tentacle/test/probe") close(expectSpeechCall(t, speaker, "cancel").done) _ = expectSpeechCall(t, speaker, "Tentacle output") // Remains in flight until context cancellation. owner.speech.Enqueue(speechRequest{Text: "Later core speech", Append: true}) owner.Retire() cancellations := 0 deadline := time.After(time.Second) for { select { case call := <-speaker.calls: close(call.done) if call.text == "cancel" { cancellations++ continue } if call.text != "Later core speech" || cancellations == 0 { t.Fatalf("unexpected delivery after revocation: %q", call.text) } return case <-deadline: t.Fatal("core speech failed to resume after revocation") } } }