Files
cthulhu/internal/app/tentacle_uninterruptible_test.go

228 lines
7.9 KiB
Go

// 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")
}
}