228 lines
7.9 KiB
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")
|
|
}
|
|
}
|