Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,215 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user