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