Files
cthulhu/internal/app/tentacle_output_test.go

216 lines
7.8 KiB
Go

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