216 lines
7.8 KiB
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")
|
|
}
|
|
}
|
|
}
|