// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "testing" "time" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) type protectedSpeaker struct{ confirmationSpeaker } func (speaker *protectedSpeaker) speak_uninterruptible(ctx context.Context, text string) error { return speaker.call(ctx, text) } func protected_output_fixture(t *testing.T) (*tentacleOutput, *protectedSpeaker) { t.Helper() owner := tentacleOutputFixture(t) speaker := &protectedSpeaker{confirmationSpeaker{controlledSpeaker{calls: make(chan speechCall, 20)}}} owner.speech.speaker = speaker owner.speech.running = false if err := owner.speech.Start(t.Context()); err != nil { t.Fatal(err) } t.Cleanup(func() { owner.speech.Stop(context.Background()) }) return owner, speaker } func TestTentacleUninterruptibleSurvivesInputAndFocusUntilEnd(t *testing.T) { owner, speaker := protected_output_fixture(t) ticket := owner.Capture(t.Context()) owner.Present(ticket, tentaclewire.Presentation{Speech: "Hello world!", Braille: "Hello world!", Uninterruptible: true}, "tentacle/test/hello") // Protection begins on admission, before the backend has accepted SPEAK. if owner.speech.InterruptForInput(input.Event{Timestamp: 101, Pressed: true}) { t.Fatal("queued greeting was interrupted") } close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done) greeting := expectSpeechCall(t, &speaker.controlledSpeaker, "Hello world!") owner.speech.Interrupt() owner.speech.InterruptForUser() owner.speech.ProtectCommand(102) owner.Capture(t.Context()) // Another command and ordinary focus retirement. owner.gate.retireTentacleOutput() if owner.speech.InterruptForInput(input.Event{Timestamp: 103, Pressed: true}) { t.Fatal("active greeting was interrupted") } owner.speech.Enqueue(speechRequest{Text: "Focused control"}) owner.speech.mutex.Lock() if len(owner.speech.pending) != 1 || !owner.speech.pending[0].Append || !owner.speech.has_uninterruptible_locked() { t.Error("core output did not queue behind protected speech") } owner.speech.mutex.Unlock() select { case call := <-speaker.calls: t.Fatalf("backend was interrupted before END: %q", call.text) default: } close(greeting.done) // Actual END, not the SPEAK acknowledgement. focus := expectSpeechCall(t, &speaker.controlledSpeaker, "Focused control") if !owner.speech.InterruptForInput(input.Event{Timestamp: 104, Pressed: true}) { t.Fatal("protection leaked after completion") } close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done) close(focus.done) } func TestTentacleUninterruptibleStillRevokes(t *testing.T) { for _, action := range []string{"session", "disable", "lock", "handoff", "speech off", "mode", "stop", "lifetime"} { t.Run(action, func(t *testing.T) { owner, speaker := protected_output_fixture(t) parent, cancel := context.WithCancel(t.Context()) defer cancel() ticket := owner.Capture(parent) owner.Present(ticket, tentaclewire.Presentation{Speech: "Protected greeting", Uninterruptible: true}, "tentacle/test/hello") owner.speech.mutex.Lock() request := owner.speech.pending[0] deadline, bounded := request.context.Deadline() owner.speech.mutex.Unlock() if !bounded || time.Until(deadline) > uninterruptibleSpeechTimeout { t.Fatal("unbounded protected speech") } close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done) _ = expectSpeechCall(t, &speaker.controlledSpeaker, "Protected greeting") switch action { case "session": cancel() case "disable": owner.Stop() case "lock": owner.gate.SetLocked(true, nil, nil, nil, owner.speech, owner.braille) case "handoff": owner.gate.SetSuspended(true) case "speech off": owner.speech.SetEnabled(false) case "mode": owner.speech.cycle_speech_mode() case "stop": if err := owner.speech.Stop(t.Context()); err != nil { t.Fatal(err) } case "lifetime": // Retire the admitted lifetime deterministically. Its actual // deadline is checked above, without a 30-second test sleep. request.cancel() } if request.context.Err() == nil { t.Fatal("protected lifetime survived revocation") } if action == "stop" { return } // Complete cancellation calls, then prove delivery resumes. Off // and disabled modes are restored only after their barrier drains. owner.speech.SetEnabled(true) owner.speech.mutex.Lock() owner.speech.mode = speechModeSpeech owner.speech.secure = false owner.speech.mutex.Unlock() owner.speech.Enqueue(speechRequest{Text: "Resumed"}) for { call := expect_next_speech_call(t, &speaker.controlledSpeaker) close(call.done) if call.text == "Resumed" { return } if call.text != "cancel" { t.Fatalf("unexpected output %q", call.text) } } }) } } func expect_next_speech_call(t *testing.T, speaker *controlledSpeaker) speechCall { t.Helper() select { case call := <-speaker.calls: return call case <-time.After(time.Second): t.Fatal("speech failed to resume") return speechCall{} } } func TestTentacleUninterruptibleCannotResurrectStaleReply(t *testing.T) { for _, action := range []string{"input", "focus", "new speech", "revoked", "lock", "handoff"} { t.Run(action, func(t *testing.T) { owner := tentacleOutputFixture(t) parent, cancel := context.WithCancel(t.Context()) defer cancel() ticket := owner.Capture(parent) switch action { case "input": owner.speech.InterruptForUser() case "focus": owner.Retire() case "new speech": owner.speech.Enqueue(speechRequest{Text: "Core"}) case "revoked": cancel() case "lock": owner.gate.SetLocked(true, nil, nil, nil, owner.speech, owner.braille) case "handoff": owner.gate.SetSuspended(true) } owner.Present(ticket, tentaclewire.Presentation{Speech: "Stale", Uninterruptible: true}, "tentacle/test/hello") for _, request := range owner.speech.pending { if request.Text == "Stale" || request.Uninterruptible { t.Fatal("protected option bypassed stale result check") } } }) } } func TestTentacleProtectedStopPreservesCoreQueue(t *testing.T) { owner, speaker := protected_output_fixture(t) owner.Present(owner.Capture(t.Context()), tentaclewire.Presentation{Speech: "Greeting", Uninterruptible: true}, "tentacle/test/hello") close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done) _ = expectSpeechCall(t, &speaker.controlledSpeaker, "Greeting") owner.speech.Enqueue(speechRequest{Text: "Core output"}) owner.Stop() for { call := expect_next_speech_call(t, &speaker.controlledSpeaker) close(call.done) if call.text == "Core output" { return } if call.text != "cancel" { t.Fatalf("unexpected output %q", call.text) } } } func TestTentacleProtectedModesAndUnsupportedHardware(t *testing.T) { for _, mode := range []speechMode{speechModeBeeps, speechModeOff} { owner := tentacleOutputFixture(t) owner.speech.mode = mode beeps := 0 owner.speech.beep = func(context.Context) error { beeps++; return nil } owner.Present(owner.Capture(t.Context()), tentaclewire.Presentation{ Speech: "Greeting", Braille: "Display", Uninterruptible: true, }, "tentacle/test/hello") request, ok := owner.speech.next() if !ok { t.Fatal("no protected presentation") } if err := owner.speech.deliver(request.context, request); err != nil { t.Fatal(err) } completeSpeechRequest(request, nil) if (beeps == 1) != (mode == speechModeBeeps) || owner.braille.current().Text != "Display" { t.Fatal("protected presentation bypassed core output preferences") } } // Hardware SpeakAndWait reports transmission rather than audible END. It // must not be treated as a completion-capable protected-speech backend. configured := &configuredSpeaker{active: &hardwareSpeaker{}} if err := configured.speak_uninterruptible(t.Context(), "Greeting"); err == nil { t.Fatal("hardware backend claimed audible completion") } }