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,227 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user