135 lines
6.4 KiB
Go
135 lines
6.4 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
package tentaclewire
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"git.stormux.org/storm/cthulhu/internal/config"
|
|
"git.stormux.org/storm/cthulhu/internal/interaction"
|
|
"math"
|
|
"reflect"
|
|
"testing"
|
|
)
|
|
|
|
func reader_test_extensions() Extensions {
|
|
return Extensions{
|
|
Pages: []PreferencePage{{ID: "options", Title: "Reader probe", Command: "apply", Elements: []interaction.Element{
|
|
{ID: "text", Label: "Text", Kind: interaction.Entry, Required: true, MaxLength: 32},
|
|
{ID: "enabled", Label: "Enabled", Kind: interaction.CheckBox},
|
|
{ID: "choice", Label: "Choice", Kind: interaction.List, Items: []interaction.Item{{ID: "first", Label: "First"}, {ID: "second", Label: "Second", Disabled: true}}},
|
|
}, Values: map[string]interaction.Value{"text": {Text: "Initial"}, "enabled": {}, "choice": {Selected: "first"}}}},
|
|
QuickSettings: []QuickSetting{{ID: "choice", Label: "Probe choice", Command: "apply", Options: []interaction.Item{{ID: "default", Label: "Default"}, {ID: "custom", Label: "Custom"}}, Selected: "default"}},
|
|
}
|
|
}
|
|
|
|
func TestReaderPageCanDescribeSixCompleteVoiceConfigurations(t *testing.T) {
|
|
page := PreferencePage{ID: "voices", Title: "Voice configurations", Command: "apply", Values: map[string]interaction.Value{}}
|
|
for profile := 1; profile <= 6; profile++ {
|
|
for field := 1; field <= 24; field++ {
|
|
id := fmt.Sprintf("profile%d.field%d", profile, field)
|
|
page.Elements = append(page.Elements, interaction.Element{ID: id, Label: fmt.Sprintf("Profile %d setting %d", profile, field), Kind: interaction.Entry, MaxLength: 64})
|
|
page.Values[id] = interaction.Value{Text: "Example"}
|
|
}
|
|
}
|
|
if err := ValidateExtensions(Extensions{Pages: []PreferencePage{page}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := interaction.NewSession(interaction.Form{Title: page.Title, Elements: page.Elements}); err == nil {
|
|
t.Fatal("native page limit leaked into internal forms")
|
|
}
|
|
state := ReaderState{Extensions: Extensions{Pages: []PreferencePage{page}}, Speech: SpeechFromSettings(config.DefaultSettings()), DefaultSpeech: SpeechFromSettings(config.DefaultSettings())}
|
|
data, err := json.Marshal(ExtensionEvent{Event: "preferences.apply", ID: page.ID, Values: page.Values, Reader: state})
|
|
if err != nil || len(data) > 48*1024 {
|
|
t.Fatal("six configurations exceed callback budget", len(data), err)
|
|
}
|
|
}
|
|
func TestReaderExtensionsRejectInvalidSchemasAndValues(t *testing.T) {
|
|
mutations := map[string]func(*Extensions){
|
|
"duplicate page": func(v *Extensions) { v.Pages = append(v.Pages, v.Pages[0]) },
|
|
"duplicate setting": func(v *Extensions) { v.QuickSettings = append(v.QuickSettings, v.QuickSettings[0]) },
|
|
"duplicate control": func(v *Extensions) { v.Pages[0].Elements = append(v.Pages[0].Elements, v.Pages[0].Elements[0]) },
|
|
"button": func(v *Extensions) { v.Pages[0].Elements[0].Kind = interaction.Button },
|
|
"bad label": func(v *Extensions) { v.Pages[0].Title = "Probe\npage" },
|
|
"bad identifier": func(v *Extensions) { v.Pages[0].ID = "../page" },
|
|
"missing value": func(v *Extensions) { delete(v.Pages[0].Values, "text") },
|
|
"extra value": func(v *Extensions) { v.Pages[0].Values["other"] = interaction.Value{} },
|
|
"wrong value type": func(v *Extensions) { v.Pages[0].Values["enabled"] = interaction.Value{Text: "yes"} },
|
|
"required": func(v *Extensions) { v.Pages[0].Values["text"] = interaction.Value{} },
|
|
"oversized": func(v *Extensions) {
|
|
v.Pages[0].Values["text"] = interaction.Value{Text: "This string is longer than thirty-two characters"}
|
|
},
|
|
"control character": func(v *Extensions) { v.Pages[0].Values["text"] = interaction.Value{Text: "bad\x1bvalue"} },
|
|
"disabled choice": func(v *Extensions) { v.Pages[0].Values["choice"] = interaction.Value{Selected: "second"} },
|
|
"unknown quick choice": func(v *Extensions) { v.QuickSettings[0].Selected = "missing" },
|
|
"disabled quick choice": func(v *Extensions) { v.QuickSettings[0].Options[0].Disabled = true },
|
|
}
|
|
if err := ValidateExtensions(reader_test_extensions()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for name, mutate := range mutations {
|
|
t.Run(name, func(t *testing.T) {
|
|
value := reader_test_extensions()
|
|
mutate(&value)
|
|
if ValidateExtensions(value) == nil {
|
|
t.Fatal("accepted invalid extension")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func TestReaderSpeechConfigurationRoundTripAndIsolation(t *testing.T) {
|
|
settings := config.DefaultSettings()
|
|
rate, pitch, gain := 72.0, 6.2, 8.1
|
|
settings.DefaultVoice = config.Voice{Rate: &rate, AveragePitch: &pitch, Gain: &gain, Family: &config.VoiceFamily{Name: "Example voice", Lang: "en", Dialect: "US", Variant: "sample"}}
|
|
settings.UppercaseVoice = config.Voice{AveragePitch: &pitch}
|
|
settings.HyperlinkVoice = config.Voice{Rate: &rate}
|
|
settings.SystemVoice = config.Voice{Gain: &gain}
|
|
settings.UseCustomEchoVoice = true
|
|
settings.EchoVoice = config.Voice{Rate: &rate}
|
|
base := config.DefaultSettings()
|
|
base.EnableSpeech = false
|
|
base.EnableBraille = true
|
|
result := SpeechFromSettings(settings).Apply(base)
|
|
if !reflect.DeepEqual(SpeechFromSettings(result), SpeechFromSettings(settings)) {
|
|
t.Fatal("voice settings lost")
|
|
}
|
|
if result.EnableSpeech || !result.EnableBraille || result.KeyboardLayout != base.KeyboardLayout {
|
|
t.Fatal("unrelated settings changed")
|
|
}
|
|
value := SpeechFromSettings(settings)
|
|
value.DefaultVoice.Rate = new(float64)
|
|
*value.DefaultVoice.Rate = math.NaN()
|
|
if ValidateReaderRequest(ReaderRequest{Speech: &value}) == nil {
|
|
t.Fatal("accepted NaN")
|
|
}
|
|
}
|
|
func TestReaderInteractionValidation(t *testing.T) {
|
|
for _, raw := range []string{
|
|
`{"reader":{"snapshot":true},"next":"receive"}`,
|
|
`{"reader":{"restore_speech":true}}`,
|
|
`{"reader":{"extensions":{}}}`,
|
|
} {
|
|
if _, err := DecodeInteraction([]byte(raw)); err != nil {
|
|
t.Fatal(raw, err)
|
|
}
|
|
}
|
|
for _, raw := range []string{
|
|
`{"reader":{"snapshot":true}}`,
|
|
`{"reader":{"snapshot":true,"restore_speech":true},"next":"receive"}`,
|
|
`{"reader":{"restore_speech":true},"clipboard":"value"}`,
|
|
`{"reader":{"restore_speech":true},"done":true}`,
|
|
`{"reader":{"module":"example"}}`,
|
|
`{"reader":{"restore_speech":true,"unknown":true}}`,
|
|
`{"reader":null}`,
|
|
} {
|
|
if _, err := DecodeInteraction([]byte(raw)); err == nil {
|
|
t.Fatal("accepted", raw)
|
|
}
|
|
}
|
|
value := reader_test_extensions()
|
|
data, _ := json.Marshal(Interaction{Reader: &ReaderRequest{Extensions: &value}})
|
|
if _, err := DecodeInteraction(data); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|