Add shared internal UI toolkit for both readers
This commit is contained in:
1 parent
672caca854
commit
e49ba198d7
34 files changed
+3471
No files matched your search
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package interaction
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UnmarshalJSON keeps a malformed secret flag from silently becoming an
|
||||
// ordinary entry (encoding/json otherwise accepts null for a boolean). The
|
||||
// wire decoder separately rejects duplicate keys and bounds nesting/framing.
|
||||
func (element *Element) UnmarshalJSON(data []byte) error {
|
||||
type plain Element
|
||||
var value plain
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &fields); err != nil {
|
||||
return err
|
||||
}
|
||||
for name, raw := range fields {
|
||||
if strings.EqualFold(name, "secret") {
|
||||
flag := string(bytes.TrimSpace(raw))
|
||||
if name != "secret" || flag != "true" && flag != "false" {
|
||||
return errors.New("secret must be a boolean")
|
||||
}
|
||||
}
|
||||
}
|
||||
*element = Element(value)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package interaction
|
||||
|
||||
import (
|
||||
"errors"
|
||||
ui "git.stormux.org/storm/screenreader-common/internalui"
|
||||
)
|
||||
|
||||
type GridTab struct {
|
||||
ID, Label string
|
||||
Items []Item
|
||||
}
|
||||
|
||||
func NewGridSession(title string, tabs []GridTab, columns int, checkbox Element) (*Session, error) {
|
||||
if title == "" || len(tabs) == 0 || columns < 1 || columns > 64 || checkbox.Kind != CheckBox || checkbox.ID == "" || checkbox.Label == "" {
|
||||
return nil, errors.New("invalid tabbed grid")
|
||||
}
|
||||
form := ui.Form{Type: ui.TabForm, Title: title, Detailed: true}
|
||||
for _, tab := range tabs {
|
||||
choices := make([]ui.Choice, 0, len(tab.Items))
|
||||
for _, item := range tab.Items {
|
||||
if item.ID == "" {
|
||||
return nil, errors.New("invalid grid item")
|
||||
}
|
||||
choices = append(choices, ui.Choice{ID: item.ID, Label: item.Label, Disabled: item.Disabled, Literal: true})
|
||||
}
|
||||
form.Tabs = append(form.Tabs, ui.Tab{ID: tab.ID, Label: tab.Label, Controls: []ui.Control{
|
||||
{ID: "grid", Label: "Grid", Kind: ui.Grid, Choices: choices, Columns: columns, Action: "select-grid-item"},
|
||||
{ID: checkbox.ID, Label: checkbox.Label, Kind: ui.CheckBox, Checked: checkbox.Checked},
|
||||
}})
|
||||
}
|
||||
core, err := ui.NewSession(form, ui.Messages{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Session{core: core, title: title, grid: true, checkbox: checkbox.ID}, nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package interaction
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGridRowsBoundsAndDisabledItems(t *testing.T) {
|
||||
items := []Item{{ID: "1", Label: "One"}, {ID: "2", Label: "Two"}, {ID: "3", Label: "Three"}, {ID: "4", Label: "Four"}, {ID: "5", Label: "Five", Disabled: true}, {ID: "6", Label: "Six"}}
|
||||
session, err := NewGridSession("Picker", []GridTab{{ID: "all", Label: "All", Items: items}}, 3, Element{ID: "copy", Label: "Copy", Kind: CheckBox})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session.Handle(Event{Key: Tab})
|
||||
for _, step := range []struct {
|
||||
key Key
|
||||
want string
|
||||
}{{Right, "2"}, {Right, "3"}, {Down, "6"}, {Down, "6"}, {Up, "2"}, {Home, "1"}, {Left, "1"}, {End, "6"}, {Right, "6"}, {PageUp, "1"}, {PageDown, "6"}} {
|
||||
session.Handle(Event{Key: step.key})
|
||||
if got := session.Values()["grid"].Selected; got != step.want {
|
||||
t.Fatalf("%v selected %q want %q", step.key, got, step.want)
|
||||
}
|
||||
}
|
||||
if result := session.Handle(Event{Key: Escape}); !result.Cancelled {
|
||||
t.Fatal("escape not cancelled")
|
||||
}
|
||||
if session.Handle(Event{Key: Enter}).Activated != "" || session.Presentation().Speech != "" {
|
||||
t.Fatal("closed grid responded")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package interaction
|
||||
|
||||
import "testing"
|
||||
|
||||
func secretForm(text string) Form {
|
||||
return Form{Title: "Connection", Elements: []Element{
|
||||
{ID: "key", Label: "Channel key", Kind: Entry, Secret: true, Text: text, Required: true, MaxLength: 12},
|
||||
{ID: "connect", Label: "Connect", Kind: Button},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestSecretEntryEditingAndPresentation(t *testing.T) {
|
||||
session, err := NewSession(secretForm("猫é123"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := session.Opening(); got.Speech != "Connection. Channel key, password edit, 5 characters" || got.Braille != "Channel key, password edit: *****" || got.Cursor != 33 {
|
||||
t.Fatalf("unexpected masked opening: %+v", got)
|
||||
}
|
||||
steps := []struct {
|
||||
event Event
|
||||
speech, text, braille string
|
||||
}{
|
||||
{Event{Key: Home}, "Hidden character", "猫é123", "Channel key, password edit: *****"},
|
||||
{Event{Key: Right, Shift: true}, "Hidden character, selected", "猫é123", "Channel key, password edit: ***** (selected 1 characters)"},
|
||||
{Event{Text: "密"}, "Inserted 1 character", "密é123", "Channel key, password edit: *****"},
|
||||
{Event{Key: Delete}, "Deleted 1 character", "密123", "Channel key, password edit: ****"},
|
||||
{Event{Key: Backspace}, "Deleted 1 character", "123", "Channel key, password edit: ***"},
|
||||
{Event{Key: End}, "End of text", "123", "Channel key, password edit: ***"},
|
||||
{Event{Text: "a", Control: true}, "Selected all", "123", "Channel key, password edit: *** (selected 3 characters)"},
|
||||
{Event{Text: "c", Control: true}, "Copy and cut are unavailable for password fields", "123", "Channel key, password edit: *** (selected 3 characters)"},
|
||||
{Event{Text: "x", Control: true}, "Copy and cut are unavailable for password fields", "123", "Channel key, password edit: *** (selected 3 characters)"},
|
||||
{Event{Key: Backspace}, "Deleted 3 characters", "", "Channel key, password edit: "},
|
||||
{Event{Key: Enter}, "Channel key is required", "", "Channel key is required"},
|
||||
}
|
||||
for index, step := range steps {
|
||||
effect := session.Handle(step.event)
|
||||
if effect.Speech != step.speech || effect.Braille != step.braille || effect.Copy != nil || effect.Activated != "" || session.Values()["key"].Text != step.text {
|
||||
t.Fatalf("secret editing step %d failed", index)
|
||||
}
|
||||
}
|
||||
if !session.Handle(Event{Text: "v", Control: true}).Paste {
|
||||
t.Fatal("explicit paste not requested")
|
||||
}
|
||||
if effect := session.Insert("pasted猫"); effect.Speech != "Inserted 7 characters" || effect.Braille != "Channel key, password edit: *******" {
|
||||
t.Fatal("paste was not masked")
|
||||
}
|
||||
for _, text := range []string{"oversized content", "bad\ntext", string([]byte{0xff})} {
|
||||
session.Insert(text)
|
||||
if session.Values()["key"].Text != "pasted猫" {
|
||||
t.Fatal("invalid paste modified secret")
|
||||
}
|
||||
}
|
||||
session.Handle(Event{Key: Tab})
|
||||
if got := session.Handle(Event{Key: Tab, Shift: true}); got.Speech != "Channel key, password edit, 7 characters" {
|
||||
t.Fatal("returning to password did not mask it")
|
||||
}
|
||||
session.Handle(Event{Key: Tab})
|
||||
if effect := session.Handle(Event{Key: Enter}); effect.Activated != "connect" || session.Values()["key"].Text != "pasted猫" {
|
||||
t.Fatal("submission lost the actual secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretEntryCloseAndValidation(t *testing.T) {
|
||||
for _, escape := range []bool{false, true} {
|
||||
session, _ := NewSession(secretForm("fixture"))
|
||||
if escape {
|
||||
if !session.Handle(Event{Key: Escape}).Cancelled {
|
||||
t.Fatal("escape did not cancel")
|
||||
}
|
||||
} else {
|
||||
session.Close()
|
||||
}
|
||||
session.Close()
|
||||
if session.Values() != nil || session.Presentation() != (Presentation{}) || session.Insert("more") != (Effect{}) {
|
||||
t.Fatal("closed editor retained or accepted a secret")
|
||||
}
|
||||
}
|
||||
for _, kind := range []Kind{List, CheckBox, Button} {
|
||||
form := secretForm("")
|
||||
form.Elements[0].Kind = kind
|
||||
if _, err := NewSession(form); err == nil {
|
||||
t.Fatal("secret accepted on non-entry")
|
||||
}
|
||||
}
|
||||
if err := ValidatePreferences(secretForm("fixture")); err == nil {
|
||||
t.Fatal("secret accepted in persistent preferences")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package interaction implements focus-free controls. It owns no keyboard,
|
||||
// accessibility objects, output services or callbacks; the reader owns those.
|
||||
package interaction
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
ui "git.stormux.org/storm/screenreader-common/internalui"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type Kind uint8
|
||||
|
||||
const (
|
||||
List Kind = iota + 1
|
||||
Entry
|
||||
CheckBox
|
||||
Button
|
||||
)
|
||||
|
||||
type Item struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
type Element struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Kind Kind `json:"kind"`
|
||||
Items []Item `json:"items,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Checked bool `json:"checked,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
MaxLength int `json:"max_length,omitempty"`
|
||||
// Secret is supported only on transient entries, never preference pages.
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
}
|
||||
type Form struct {
|
||||
Title string `json:"title"`
|
||||
Elements []Element `json:"elements"`
|
||||
}
|
||||
type Key uint8
|
||||
|
||||
const (
|
||||
Text Key = iota
|
||||
Up
|
||||
Down
|
||||
Left
|
||||
Right
|
||||
Home
|
||||
End
|
||||
PageUp
|
||||
PageDown
|
||||
Tab
|
||||
Enter
|
||||
Escape
|
||||
Backspace
|
||||
Delete
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
Key Key
|
||||
Text string
|
||||
Shift, Control, Alt bool
|
||||
}
|
||||
type Presentation struct {
|
||||
Speech, Braille string
|
||||
Cursor int
|
||||
}
|
||||
|
||||
// Effect describes a host operation. Activation does not close the form: the
|
||||
// caller can validate, replace it with another form, or finish the interaction.
|
||||
type Effect struct {
|
||||
Presentation
|
||||
Activated string
|
||||
Cancelled, Paste bool
|
||||
Copy *string
|
||||
}
|
||||
type Value struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Selected string `json:"selected,omitempty"`
|
||||
Checked bool `json:"checked,omitempty"`
|
||||
}
|
||||
type elementState struct {
|
||||
element Element
|
||||
selected int
|
||||
}
|
||||
type Session struct {
|
||||
core *ui.Session
|
||||
title string
|
||||
grid bool
|
||||
checkbox string
|
||||
}
|
||||
|
||||
const maxTextLength = 8192
|
||||
|
||||
func NewSession(form Form) (*Session, error) {
|
||||
return new_session(form, 256, 64)
|
||||
}
|
||||
|
||||
// ValidatePreferences shares control validation with internal forms, while
|
||||
// allowing a native, scrolling page to contain several full voice editors.
|
||||
func ValidatePreferences(form Form) error {
|
||||
for _, element := range form.Elements {
|
||||
if element.Secret {
|
||||
return errors.New("secret entries are not supported in preferences")
|
||||
}
|
||||
}
|
||||
_, err := new_session(form, 256, 256)
|
||||
return err
|
||||
}
|
||||
|
||||
// NewNavigationSession is for core document lists. External forms retain
|
||||
// their smaller protocol limit. The selected row follows the reading caret.
|
||||
func NewNavigationSession(title string, items []Item, selected int) (*Session, error) {
|
||||
session, err := new_session(Form{Title: title, Elements: []Element{{ID: "items", Label: title, Kind: List, Items: items}}}, 65536, 64)
|
||||
if err == nil && selected >= 0 && selected < len(items) && !items[selected].Disabled {
|
||||
session.core.Select("form", "items", selected)
|
||||
}
|
||||
return session, err
|
||||
}
|
||||
|
||||
func new_session(form Form, maxItems, maxElements int) (*Session, error) {
|
||||
if form.Title == "" || len(form.Title) > 1024 || len(form.Elements) == 0 || len(form.Elements) > maxElements {
|
||||
return nil, fmt.Errorf("a titled form with 1 to %d controls is required", maxElements)
|
||||
}
|
||||
session := struct {
|
||||
elements []elementState
|
||||
focus int
|
||||
}{focus: -1}
|
||||
ids := map[string]bool{}
|
||||
for _, element := range form.Elements {
|
||||
if element.ID == "" || len(element.ID) > 1024 || element.Label == "" || len(element.Label) > 1024 || ids[element.ID] || element.Kind < List || element.Kind > Button {
|
||||
return nil, errors.New("controls need unique IDs, labels and supported kinds")
|
||||
}
|
||||
ids[element.ID] = true
|
||||
if element.Secret && element.Kind != Entry {
|
||||
return nil, errors.New("only entries may be secret")
|
||||
}
|
||||
if element.MaxLength == 0 {
|
||||
element.MaxLength = maxTextLength
|
||||
}
|
||||
if element.MaxLength < 1 || element.MaxLength > maxTextLength || len([]rune(element.Text)) > element.MaxLength {
|
||||
return nil, errors.New("invalid entry length")
|
||||
}
|
||||
if !utf8.ValidString(element.Text) || strings.ContainsAny(element.Text, "\r\n\x00") {
|
||||
return nil, errors.New("entries must contain single-line text")
|
||||
}
|
||||
for _, character := range element.Text {
|
||||
if unicode.IsControl(character) {
|
||||
return nil, errors.New("entries must contain single-line text")
|
||||
}
|
||||
}
|
||||
element.Items = append([]Item(nil), element.Items...)
|
||||
state := elementState{element: element, selected: -1}
|
||||
itemIDs := map[string]bool{}
|
||||
if len(element.Items) > maxItems {
|
||||
return nil, errors.New("too many list items")
|
||||
}
|
||||
for index, item := range element.Items {
|
||||
if item.ID == "" || len(item.ID) > 1024 || item.Label == "" || len(item.Label) > 1024 || itemIDs[item.ID] {
|
||||
return nil, errors.New("list items need unique IDs and labels")
|
||||
}
|
||||
itemIDs[item.ID] = true
|
||||
if state.selected < 0 && !item.Disabled {
|
||||
state.selected = index
|
||||
}
|
||||
}
|
||||
if element.Kind == List && state.selected < 0 {
|
||||
state.element.Disabled = true
|
||||
}
|
||||
session.elements = append(session.elements, state)
|
||||
if session.focus < 0 && !state.element.Disabled {
|
||||
session.focus = len(session.elements) - 1
|
||||
}
|
||||
}
|
||||
if session.focus < 0 {
|
||||
return nil, errors.New("form has no enabled controls")
|
||||
}
|
||||
controls := make([]ui.Control, 0, len(session.elements))
|
||||
for _, state := range session.elements {
|
||||
e := state.element
|
||||
c := ui.Control{ID: e.ID, Label: e.Label, Text: e.Text, Checked: e.Checked, Disabled: state.element.Disabled, Required: e.Required, Secret: e.Secret, MaxLength: e.MaxLength, Selected: state.selected, Action: e.ID}
|
||||
switch e.Kind {
|
||||
case List:
|
||||
c.Kind = ui.List
|
||||
c.SelectAction = true
|
||||
for _, item := range e.Items {
|
||||
c.Choices = append(c.Choices, ui.Choice{ID: item.ID, Label: item.Label, Literal: true, Disabled: item.Disabled})
|
||||
}
|
||||
case Entry:
|
||||
c.Kind = ui.Entry
|
||||
case CheckBox:
|
||||
c.Kind = ui.CheckBox
|
||||
case Button:
|
||||
c.Kind = ui.Button
|
||||
}
|
||||
// Empty disabled lists need no selectable placeholder in the shared model.
|
||||
controls = append(controls, c)
|
||||
}
|
||||
core, err := ui.NewSession(ui.Form{Type: ui.FlatForm, Title: form.Title, Detailed: true, Tabs: []ui.Tab{{ID: "form", Label: form.Title, Controls: controls}}}, ui.Messages{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Session{core: core, title: form.Title}, nil
|
||||
}
|
||||
|
||||
func (s *Session) Presentation() Presentation {
|
||||
r := s.core.Presentation()
|
||||
return Presentation{Speech: r.Speech, Braille: r.Braille, Cursor: r.Cursor}
|
||||
}
|
||||
func (s *Session) Opening() Presentation {
|
||||
r := s.Presentation()
|
||||
if !s.grid {
|
||||
c, ok := s.core.Focused_control()
|
||||
if ok && c.Kind == ui.List && len(s.core.Values()) > 1 {
|
||||
r.Speech = c.Label + ". " + r.Speech
|
||||
}
|
||||
}
|
||||
r.Speech = s.title + ". " + r.Speech
|
||||
return r
|
||||
}
|
||||
func (s *Session) Values() map[string]Value {
|
||||
values := s.core.Values()
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
result := map[string]Value{}
|
||||
if s.grid {
|
||||
for _, c := range values {
|
||||
if c.ID == s.checkbox {
|
||||
result[c.ID] = Value{Checked: c.Checked}
|
||||
break
|
||||
}
|
||||
}
|
||||
c, ok := s.core.Focused_control()
|
||||
if ok && c.Kind == ui.Grid && c.Selected >= 0 {
|
||||
v := c.Choices[c.Selected]
|
||||
result["grid"] = Value{Selected: v.ID, Text: v.Label}
|
||||
}
|
||||
return result
|
||||
}
|
||||
for _, c := range values {
|
||||
v := Value{Text: c.Text, Checked: c.Checked}
|
||||
if c.Kind == ui.List {
|
||||
v.Text = ""
|
||||
if c.Selected >= 0 && c.Selected < len(c.Choices) {
|
||||
v.Selected = c.Choices[c.Selected].ID
|
||||
}
|
||||
}
|
||||
result[c.ID] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
func (s *Session) Close() { s.core.Close() }
|
||||
func (s *Session) Handle(event Event) Effect {
|
||||
name := map[Key]string{Up: "up", Down: "down", Left: "left", Right: "right", Home: "home", End: "end", PageUp: "pageup", PageDown: "pagedown", Tab: "tab", Enter: "enter", Escape: "escape", Backspace: "backspace", Delete: "delete"}[event.Key]
|
||||
if event.Key == Text && event.Text == " " {
|
||||
name = "space"
|
||||
}
|
||||
r := s.core.Handle(ui.Key{Name: name, Text: event.Text, Shift: event.Shift, Ctrl: event.Control, Alt: event.Alt})
|
||||
if s.grid {
|
||||
c, ok := s.core.Focused_control()
|
||||
if ok && c.Kind == ui.CheckBox {
|
||||
s.core.SetChecked(c.ID, c.Checked)
|
||||
}
|
||||
}
|
||||
if !s.grid && event.Key == Tab && r.Speech != "" {
|
||||
c, ok := s.core.Focused_control()
|
||||
if ok && c.Kind == ui.List && len(s.core.Values()) > 1 {
|
||||
r.Speech = c.Label + ". " + r.Speech
|
||||
}
|
||||
}
|
||||
return effect(r)
|
||||
}
|
||||
func effect(r ui.Result) Effect {
|
||||
return Effect{Presentation: Presentation{Speech: r.Speech, Braille: r.Braille, Cursor: r.Cursor}, Activated: r.Action, Cancelled: r.Cancelled, Paste: r.Paste, Copy: r.Copy}
|
||||
}
|
||||
func (s *Session) Insert(text string) Effect { return effect(s.core.Insert(text)) }
|
||||
|
||||
// NewUnifiedSession exposes the full toolkit to core reader features. External
|
||||
// protocol forms continue through NewSession and its bounded validation.
|
||||
func NewUnifiedSession(form ui.Form, messages ui.Messages) (*Session, error) {
|
||||
core, err := ui.NewSession(form, messages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Session{core: core, title: form.Title}, nil
|
||||
}
|
||||
|
||||
// ControlValues includes rich typed values keyed by page.control, including
|
||||
// plaintext secrets. Use only for submission; never log or persist secrets.
|
||||
func (s *Session) ControlValues() map[string]ui.Control { return s.core.Values() }
|
||||
@@ -0,0 +1,191 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package interaction
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNavigationListsRetainExternalFormLimit(t *testing.T) {
|
||||
items := make([]Item, 300)
|
||||
for index := range items {
|
||||
items[index] = Item{ID: strconv.Itoa(index), Label: "Example item"}
|
||||
}
|
||||
if _, err := NewSession(Form{Title: "External form", Elements: []Element{{ID: "items", Label: "Items", Kind: List, Items: items}}}); err == nil {
|
||||
t.Fatal("external form exceeded its protocol limit")
|
||||
}
|
||||
session, err := NewNavigationSession("Links", items, 299)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if effect := session.Handle(Event{Key: Enter}); effect.Activated != "299" {
|
||||
t.Fatalf("lost initial selection: %+v", effect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNavigationAndIndependentSessions(t *testing.T) {
|
||||
form := Form{Title: "Actions", Elements: []Element{{ID: "actions", Label: "Actions", Kind: List, Items: []Item{{ID: "click", Label: "Click"}, {ID: "skip", Label: "Unavailable", Disabled: true}, {ID: "press", Label: "Press"}, {ID: "ancestor", Label: "Click ancestor"}}}}}
|
||||
first, err := NewSession(form)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, _ := NewSession(form)
|
||||
if opening := first.Opening().Speech; opening != "Actions. Click" {
|
||||
t.Fatalf("opening speech = %q", opening)
|
||||
}
|
||||
if moved := first.Handle(Event{Key: Down}).Speech; moved != "Press" {
|
||||
t.Fatalf("option speech = %q", moved)
|
||||
}
|
||||
if effect := first.Handle(Event{Key: Enter}); effect.Activated != "press" {
|
||||
t.Fatalf("selection = %+v", effect)
|
||||
}
|
||||
if second.Handle(Event{Key: Enter}).Activated != "click" {
|
||||
t.Fatal("sessions share state")
|
||||
}
|
||||
first.Handle(Event{Key: End})
|
||||
first.Handle(Event{Key: Down})
|
||||
if first.Handle(Event{Key: Enter}).Activated != "ancestor" {
|
||||
t.Fatal("boundary moved selection")
|
||||
}
|
||||
first.Handle(Event{Key: Home})
|
||||
first.Handle(Event{Text: "p"})
|
||||
if first.Handle(Event{Key: Enter}).Activated != "press" {
|
||||
t.Fatal("typeahead failed")
|
||||
}
|
||||
if !first.Handle(Event{Key: Escape}).Cancelled || first.Handle(Event{Key: Enter}).Activated != "" {
|
||||
t.Fatal("cancelled session remained usable")
|
||||
}
|
||||
form.Elements[0].Items[0].Label = "Changed"
|
||||
if !strings.Contains(second.Presentation().Speech, "Click") {
|
||||
t.Fatal("caller mutation changed session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallerSuppliedMenuTitleAndFormListLabel(t *testing.T) {
|
||||
menu, err := NewSession(Form{Title: "Wormhole Menu", Elements: []Element{{ID: "choices", Label: "Options", Kind: List, Items: []Item{{ID: "send", Label: "Send focused item"}, {ID: "receive", Label: "Receive file"}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opening := menu.Opening().Speech; opening != "Wormhole Menu. Send focused item" {
|
||||
t.Fatalf("custom title = %q", opening)
|
||||
}
|
||||
if moved := menu.Handle(Event{Key: Down}).Speech; moved != "Receive file" {
|
||||
t.Fatalf("custom menu option = %q", moved)
|
||||
}
|
||||
if focus := menu.Handle(Event{Key: Tab}).Speech; focus != "Receive file" {
|
||||
t.Fatalf("menu repeated title on Tab: %q", focus)
|
||||
}
|
||||
form, _ := NewSession(Form{Title: "Example", Elements: []Element{{ID: "code", Label: "Code", Kind: Entry}, {ID: "method", Label: "Method", Kind: List, Items: []Item{{ID: "send", Label: "Send"}}}}})
|
||||
if focus := form.Handle(Event{Key: Tab}).Speech; focus != "Method. Send" {
|
||||
t.Fatalf("form list lost its field label: %q", focus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormTraversalEditingAndValidation(t *testing.T) {
|
||||
session, err := NewSession(Form{Title: "Receive", Elements: []Element{{ID: "code", Label: "Code", Kind: Entry, Required: true, MaxLength: 8}, {ID: "disabled", Label: "Disabled", Kind: Button, Disabled: true}, {ID: "keep", Label: "Keep", Kind: CheckBox}, {ID: "submit", Label: "Receive", Kind: Button}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(session.Handle(Event{Key: Enter}).Speech, "required") {
|
||||
t.Fatal("missing required validation")
|
||||
}
|
||||
session.Handle(Event{Text: "ab猫"})
|
||||
session.Handle(Event{Key: Left, Shift: true})
|
||||
session.Insert("é")
|
||||
if session.Values()["code"].Text != "abé" {
|
||||
t.Fatal("Unicode selection replacement")
|
||||
}
|
||||
session.Handle(Event{Key: Home})
|
||||
session.Handle(Event{Key: Delete})
|
||||
if session.Values()["code"].Text != "bé" {
|
||||
t.Fatal("Unicode deletion")
|
||||
}
|
||||
session.Handle(Event{Text: "a", Control: true})
|
||||
if effect := session.Handle(Event{Text: "c", Control: true}); effect.Copy == nil || *effect.Copy != "bé" {
|
||||
t.Fatal("copy selection")
|
||||
}
|
||||
if !session.Handle(Event{Text: "v", Control: true}).Paste {
|
||||
t.Fatal("paste host request missing")
|
||||
}
|
||||
before := session.Values()["code"].Text
|
||||
if !strings.Contains(session.Insert("too long to fit").Speech, "limit") || session.Values()["code"].Text != before {
|
||||
t.Fatal("oversized insertion changed field")
|
||||
}
|
||||
if !strings.Contains(session.Insert("bad\ntext").Speech, "single-line") {
|
||||
t.Fatal("multiline paste accepted")
|
||||
}
|
||||
if session.Insert(string([]byte{0xff})).Speech != "Invalid text" || session.Values()["code"].Text != before {
|
||||
t.Fatal("invalid UTF-8 paste changed entry")
|
||||
}
|
||||
session.Handle(Event{Key: Tab})
|
||||
session.Handle(Event{Text: " "})
|
||||
if !session.Values()["keep"].Checked {
|
||||
t.Fatal("checkbox not toggled / disabled control took focus")
|
||||
}
|
||||
session.Handle(Event{Key: Tab})
|
||||
if session.Handle(Event{Key: Enter}).Activated != "submit" {
|
||||
t.Fatal("button did not submit")
|
||||
}
|
||||
session.Handle(Event{Key: Tab})
|
||||
if !strings.Contains(session.Presentation().Speech, "Code") {
|
||||
t.Fatal("Tab did not wrap")
|
||||
}
|
||||
session.Handle(Event{Key: Tab, Shift: true})
|
||||
if session.Handle(Event{Key: Enter}).Activated != "submit" {
|
||||
t.Fatal("Shift Tab did not wrap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordMovementAndSelection(t *testing.T) {
|
||||
session, _ := NewSession(Form{Title: "Edit", Elements: []Element{{ID: "text", Label: "Text", Kind: Entry, Text: "one two 猫"}}})
|
||||
session.Handle(Event{Key: Left, Control: true, Shift: true})
|
||||
if effect := session.Handle(Event{Text: "c", Control: true}); effect.Copy == nil || *effect.Copy != "猫" {
|
||||
t.Fatal("word selection")
|
||||
}
|
||||
session.Handle(Event{Key: Backspace})
|
||||
if session.Values()["text"].Text != "one two " {
|
||||
t.Fatal("word selection deletion")
|
||||
}
|
||||
session.Handle(Event{Key: Home})
|
||||
session.Handle(Event{Key: Right, Control: true})
|
||||
session.Insert("é")
|
||||
if session.Values()["text"].Text != "one étwo " {
|
||||
t.Fatalf("word movement = %+v", session.Values())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidForms(t *testing.T) {
|
||||
for _, form := range []Form{{}, {Title: "Empty", Elements: []Element{{ID: "list", Label: "List", Kind: List}}}, {Title: "Duplicate", Elements: []Element{{ID: "same", Label: "First", Kind: Button}, {ID: "same", Label: "Second", Kind: Entry}}}} {
|
||||
if _, err := NewSession(form); err == nil {
|
||||
t.Fatalf("accepted invalid form %+v", form)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestButtonActivationValidatesRequiredEntries(t *testing.T) {
|
||||
for _, event := range []Event{{Key: Enter}, {Key: Text, Text: " "}} {
|
||||
t.Run(map[Key]string{Enter: "Enter", Text: "Space"}[event.Key], func(t *testing.T) {
|
||||
session, err := NewSession(Form{Title: "Receive", Elements: []Element{
|
||||
{ID: "code", Label: "Code", Kind: Entry, Required: true},
|
||||
{ID: "submit", Label: "Receive", Kind: Button},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session.Handle(Event{Key: Tab})
|
||||
if effect := session.Handle(event); effect.Activated != "" || effect.Speech != "Code is required" {
|
||||
t.Fatalf("empty required field accepted: %+v", effect)
|
||||
}
|
||||
session.Handle(Event{Key: Tab, Shift: true})
|
||||
session.Insert("1234")
|
||||
session.Handle(Event{Key: Tab})
|
||||
if effect := session.Handle(event); effect.Activated != "submit" {
|
||||
t.Fatalf("completed form rejected: %+v", effect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package interaction_test
|
||||
|
||||
import (
|
||||
flat "git.stormux.org/storm/screenreader-common/interaction"
|
||||
"git.stormux.org/storm/screenreader-common/interaction/tabbed"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Exercise both public form APIs, so an update to grid movement or speech must
|
||||
// reach both readers even though their surrounding focus/presentation differs.
|
||||
func TestGridBehaviorAcrossFormLayouts(t *testing.T) {
|
||||
items := []flat.Item{{ID: "a", Label: "Alpha"}, {ID: "b", Label: "Beta"}, {ID: "c", Label: "Charlie"}, {ID: "d", Label: "Delta"}, {ID: "e", Label: "Echo"}}
|
||||
choices := make([]tabbed.Choice, len(items))
|
||||
for i, item := range items {
|
||||
choices[i] = tabbed.Choice{ID: item.ID, Label: item.Label}
|
||||
}
|
||||
first, err := flat.NewGridSession("Picker", []flat.GridTab{{ID: "items", Label: "Items", Items: items}}, 3, flat.Element{ID: "copy", Label: "Copy", Kind: flat.CheckBox})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := tabbed.New_session(tabbed.Form{Type: tabbed.TabForm, Title: "Picker", Tabs: []tabbed.Tab{{ID: "items", Label: "Items", Controls: []tabbed.Control{{ID: "grid", Label: "Grid", Kind: tabbed.Grid, Choices: choices, Columns: 3}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first.Handle(flat.Event{Key: flat.Tab})
|
||||
second.Handle(tabbed.Key{Name: "tab"})
|
||||
for _, step := range []struct {
|
||||
key flat.Key
|
||||
name string
|
||||
}{{flat.Right, "right"}, {flat.Down, "down"}, {flat.Down, "down"}, {flat.Up, "up"}, {flat.End, "end"}, {flat.Left, "left"}, {flat.Home, "home"}} {
|
||||
a := first.Handle(flat.Event{Key: step.key})
|
||||
b := second.Handle(tabbed.Key{Name: step.name})
|
||||
if a.Speech != b.Speech {
|
||||
t.Fatalf("%s speech: %q != %q", step.name, a.Speech, b.Speech)
|
||||
}
|
||||
selected := second.Values()["items.grid"]
|
||||
if first.Values()["grid"].Selected != selected.Choices[selected.Selected].ID {
|
||||
t.Fatalf("%s selection mismatch", step.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Fenrir console screen reader.
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify it
|
||||
// under the terms of the GNU General Public License as published by the Free
|
||||
// Software Foundation, either version 3 of the License, or (at your option)
|
||||
// any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
// more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
||||
|
||||
package tabbed
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGroupedChoicesTraversalIsolationAndSelection(t *testing.T) {
|
||||
form := Form{Type: TabForm, Title: "Settings", Tabs: []Tab{{ID: "presentation", Label: "Presentation", Controls: []Control{
|
||||
{ID: "speech.driver", Label: "Driver", Group: "Speech", Kind: ComboBox, Choices: []Choice{{ID: "first", Label: "First"}, {ID: "second", Label: "Second", Unavailable: "fixture dependency missing"}}},
|
||||
{ID: "speech.enabled", Label: "Speech enabled", Group: "Speech", Kind: CheckBox},
|
||||
{ID: "sound.enabled", Label: "Sound enabled", Group: "Sound", Kind: CheckBox},
|
||||
{ID: "ok", Label: "OK", Kind: Button, Action: "save"},
|
||||
}}}}
|
||||
session, err := New_session(form)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "tab"}).Speech; got != "Speech. Driver combo box First" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
for _, key := range []Key{{Text: "invented"}, {Name: "enter"}, {Name: "space"}, {Ctrl: true, Text: "a"}} {
|
||||
if result := session.Handle(key); result.Action != "" || result.Cancelled {
|
||||
t.Fatal(result)
|
||||
}
|
||||
}
|
||||
if got := session.Values()["presentation.speech.driver"].Choices[0].ID; got != "first" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "down"}).Speech; got != "Second. unavailable: fixture dependency missing" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
session.Handle(Key{Name: "down"})
|
||||
if got := session.Values()["presentation.speech.driver"].Selected; got != 1 {
|
||||
t.Fatal("combo wrapped", got)
|
||||
}
|
||||
values := session.Values()
|
||||
values["presentation.speech.driver"].Choices[1].Label = "altered"
|
||||
if strings.Contains(session.Describe(), "altered") || form.Tabs[0].Controls[0].Selected != 0 {
|
||||
t.Fatal("aliased choice state")
|
||||
}
|
||||
session.Handle(Key{Name: "home"})
|
||||
if got := session.Values()["presentation.speech.driver"].Selected; got != 0 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "tab"}).Speech; got != "Speech enabled unchecked" {
|
||||
t.Fatal("repeated group", got)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "tab"}).Speech; got != "Sound. Sound enabled unchecked" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "tab", Shift: true}).Speech; got != "Speech. Speech enabled unchecked" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if !session.Handle(Key{Name: "escape"}).Cancelled {
|
||||
t.Fatal("escape trapped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComboChangesSpeakOnlyValueAndFocusRepeatsLabel(t *testing.T) {
|
||||
form := Form{Type: TabForm, Title: "Settings", Tabs: []Tab{{ID: "settings", Label: "Settings", Controls: []Control{
|
||||
{ID: "format", Label: "Format", Kind: ComboBox, Choices: []Choice{{ID: "one", Label: "First"}, {ID: "two", Label: "Second"}, {ID: "three", Label: "Third"}}},
|
||||
{ID: "ok", Label: "OK", Kind: Button, Action: "save"},
|
||||
}}}}
|
||||
session, err := New_session(form)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "tab"}).Speech; got != "Format combo box First" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
for _, testCase := range []struct{ key, want string }{
|
||||
{"down", "Second"}, {"right", "Third"}, {"up", "Second"},
|
||||
{"left", "First"}, {"end", "Third"}, {"home", "First"},
|
||||
} {
|
||||
if got := session.Handle(Key{Name: testCase.key}).Speech; got != testCase.want {
|
||||
t.Fatal(testCase.key, got, testCase.want)
|
||||
}
|
||||
}
|
||||
session.Handle(Key{Name: "tab"})
|
||||
if got := session.Handle(Key{Name: "tab", Shift: true}).Speech; got != "Format combo box First" {
|
||||
t.Fatal("reverse focus omitted label", got)
|
||||
}
|
||||
session.Handle(Key{Name: "tab", Shift: true})
|
||||
if got := session.Handle(Key{Name: "tab"}).Speech; got != "Format combo box First" {
|
||||
t.Fatal("forward focus omitted label", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChoiceDefinitionsRejectMissingAndDuplicateSelections(t *testing.T) {
|
||||
for _, control := range []Control{
|
||||
{Choices: nil},
|
||||
{Choices: []Choice{{ID: "one", Label: "One"}}, Selected: 1},
|
||||
{Choices: []Choice{{ID: "one", Label: "One"}, {ID: "one", Label: "Duplicate"}}},
|
||||
{Choices: []Choice{{ID: "one"}}},
|
||||
} {
|
||||
control.ID, control.Label, control.Kind = "driver", "Driver", ComboBox
|
||||
form := Form{Type: TabForm, Title: "Settings", Tabs: []Tab{{ID: "presentation", Label: "Presentation", Controls: []Control{control}}}}
|
||||
if _, err := New_session(form); err == nil {
|
||||
t.Fatal("invalid combo accepted", control)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package tabbed
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEmptyAndRefreshedGridKeepsFocusSearchAndLiteralWords(t *testing.T) {
|
||||
form := grid_form()
|
||||
form.Tabs[0].Controls[0].Choices = nil
|
||||
form.Tabs[0].Controls[0].DeleteAction = "remove"
|
||||
session, err := New_session(form)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session.Handle(Key{Name: "tab"})
|
||||
if got := session.Handle(Key{Name: "enter"}); got.Action != "" || got.Speech != "No matches" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
session.Handle(Key{Text: "c"})
|
||||
session.Set_choices("one", "grid", []Choice{{ID: "cat", Label: "cat", Literal: true}, {ID: "car", Label: "car", Literal: true}})
|
||||
session.Handle(Key{Name: "right"})
|
||||
session.Set_choices("one", "grid", []Choice{{ID: "car", Label: "car", Literal: true}, {ID: "cat", Label: "cat", Literal: true}})
|
||||
control, _ := session.Focused_control()
|
||||
if control.Text != "c" || control.Choices[control.Selected].ID != "car" {
|
||||
t.Fatal(control)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "delete", Repeat: true}); got.Action != "" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "delete"}); got.Action != "remove" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
session.Set_choices("one", "grid", nil)
|
||||
if !strings.Contains(session.Describe(), "No matches") {
|
||||
t.Fatal(session.Describe())
|
||||
}
|
||||
// Updating a grid must own its choices, independently of worker buffers.
|
||||
choices := []Choice{{ID: "cat", Label: "cat", Literal: true}}
|
||||
session.Set_choices("one", "grid", choices)
|
||||
choices[0].Label = "wrong"
|
||||
if !strings.Contains(session.Describe(), "cat") {
|
||||
t.Fatal(session.Describe())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Fenrir console screen reader.
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package tabbed
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func grid_form() Form {
|
||||
return Form{Type: TabForm, Title: "Picker", Tabs: []Tab{{ID: "one", Label: "One", Controls: []Control{{
|
||||
ID: "grid", Label: "Items", Kind: Grid, Columns: 3, Action: "choose",
|
||||
Choices: []Choice{{ID: "a", Label: "Apple"}, {ID: "b", Label: "Bat"}, {ID: "c", Label: "Cat"},
|
||||
{ID: "d", Label: "Dog"}, {ID: "j", Label: "Jack o'lantern"}, {ID: "jp", Label: "Japan"}, {ID: "é", Label: "Éclair"}},
|
||||
}}}}}
|
||||
}
|
||||
|
||||
func TestGridNavigationRaggedRowsAndActivation(t *testing.T) {
|
||||
session, err := New_session(grid_form())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session.Handle(Key{Name: "tab"})
|
||||
for _, trial := range []struct{ key, want string }{
|
||||
{"right", "b"}, {"up", "b"}, {"down", "j"}, {"down", "é"}, {"down", "é"},
|
||||
{"up", "d"}, {"left", "c"}, {"home", "a"}, {"left", "a"}, {"end", "é"}, {"right", "é"},
|
||||
} {
|
||||
session.Handle(Key{Name: trial.key})
|
||||
control, _ := session.Focused_control()
|
||||
if got := control.Choices[control.Selected].ID; got != trial.want {
|
||||
t.Fatalf("%s: got %s, want %s", trial.key, got, trial.want)
|
||||
}
|
||||
}
|
||||
// XLookupString supplies carriage return alongside the named Enter key.
|
||||
if got := session.Handle(Key{Name: "enter", Text: "\r"}); got.Action != "choose" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridPrefixEmptyResultsUnicodeAndIsolation(t *testing.T) {
|
||||
form := grid_form()
|
||||
session, err := New_session(form)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session.Handle(Key{Name: "tab"})
|
||||
if got := session.Handle(Key{Text: "J"}); !strings.Contains(got.Speech, "Jack o'lantern") || !strings.Contains(got.Speech, "1 of 2") {
|
||||
t.Fatal(got)
|
||||
}
|
||||
session.Handle(Key{Name: "right"})
|
||||
control, _ := session.Focused_control()
|
||||
if control.Selected != 5 {
|
||||
t.Fatal("filtered selection lost catalog index", control)
|
||||
}
|
||||
control.Choices[5].Label = "changed"
|
||||
if got := session.Handle(Key{Text: "z"}); !strings.Contains(got.Speech, "No matches") {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "enter"}); got.Action != "" {
|
||||
t.Fatal("empty results activated", got)
|
||||
}
|
||||
session.Handle(Key{Name: "backspace"})
|
||||
if got := session.Handle(Key{Name: "right"}); !strings.Contains(got.Speech, "Japan") {
|
||||
t.Fatal("focused value aliased session", got)
|
||||
}
|
||||
session.Handle(Key{Name: "backspace", Ctrl: true})
|
||||
if got := session.Handle(Key{Text: "é"}); !strings.Contains(got.Speech, "Éclair") {
|
||||
t.Fatal(got)
|
||||
}
|
||||
session.Handle(Key{Name: "backspace"})
|
||||
control, _ = session.Focused_control()
|
||||
if control.Text != "" || form.Tabs[0].Controls[0].Selected != 0 || form.Tabs[0].Controls[0].Text != "" {
|
||||
t.Fatal("Unicode deletion or form isolation failed", control)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "escape"}); !got.Cancelled {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridValidation(t *testing.T) {
|
||||
form := grid_form()
|
||||
form.Tabs[0].Controls[0].Columns = 0
|
||||
if _, err := New_session(form); err == nil {
|
||||
t.Fatal("zero-width grid accepted")
|
||||
}
|
||||
form = grid_form()
|
||||
form.Tabs[0].Controls[0].Choices[1].ID = "a"
|
||||
if _, err := New_session(form); err == nil {
|
||||
t.Fatal("duplicate values accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGridSpeechDetailOptions(t *testing.T) {
|
||||
for _, trial := range []struct {
|
||||
coordinates, position bool
|
||||
want, filtered string
|
||||
}{
|
||||
{true, true, "Bat. row 1, column 2. 2 of 7", "Japan. row 1, column 2. 2 of 2"},
|
||||
{true, false, "Bat. row 1, column 2", "Japan. row 1, column 2"},
|
||||
{false, true, "Bat. 2 of 7", "Japan. 2 of 2"},
|
||||
{false, false, "Bat", "Japan"},
|
||||
} {
|
||||
session, err := New_session(grid_form())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session.Configure_grid(trial.coordinates, trial.position)
|
||||
session.Handle(Key{Name: "tab"})
|
||||
if got := session.Handle(Key{Name: "right"}).Speech; got != trial.want {
|
||||
t.Fatalf("%+v: %q", trial, got)
|
||||
}
|
||||
if got := session.Describe(); got != "Items grid. "+trial.want {
|
||||
t.Fatal(got)
|
||||
}
|
||||
session.Handle(Key{Text: "j"})
|
||||
if got := session.Handle(Key{Name: "right"}).Speech; got != trial.filtered {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "enter"}).Action; got != "choose" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := session.Handle(Key{Text: "zzz"}).Speech; got != "Search jzzz. No matches" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Fenrir console screen reader.
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package tabbed
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestListPrefixSearch(t *testing.T) {
|
||||
session, err := New_session(Form{Type: TabForm, Title: "Picker", Tabs: []Tab{{ID: "items", Label: "Items", Controls: []Control{
|
||||
{ID: "list", Label: "Item", Kind: List, Items: []string{"Default", "NetworkManager", "Ninjam", "Éclair"}, Action: "choose"},
|
||||
{ID: "cancel", Label: "Cancel", Kind: Button, Action: "cancel"},
|
||||
}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session.Handle(Key{Name: "tab"})
|
||||
for _, step := range []struct {
|
||||
key Key
|
||||
want, prefix string
|
||||
selected int
|
||||
}{
|
||||
{Key{Text: "N"}, "NetworkManager", "N", 1},
|
||||
{Key{Text: "i"}, "Ninjam", "Ni", 2},
|
||||
{Key{Text: "z"}, "No matches", "Ni", 2},
|
||||
{Key{Text: "n"}, "Ninjam", "Nin", 2},
|
||||
{Key{Name: "backspace"}, "Ninjam", "Ni", 2},
|
||||
{Key{Name: "backspace"}, "NetworkManager", "N", 1},
|
||||
{Key{Name: "backspace", Ctrl: true}, "Default", "", 0},
|
||||
{Key{Text: "é"}, "Éclair", "é", 3},
|
||||
{Key{Name: "down"}, "Éclair", "", 3},
|
||||
{Key{Text: "ni"}, "Ninjam", "ni", 2},
|
||||
{Key{Name: "up"}, "NetworkManager", "", 1},
|
||||
{Key{Text: "d"}, "Default", "d", 0},
|
||||
{Key{Text: "n", Ctrl: true}, "", "d", 0},
|
||||
{Key{Text: "n", Alt: true}, "", "d", 0},
|
||||
{Key{Text: "\n"}, "", "d", 0},
|
||||
} {
|
||||
got := session.Handle(step.key)
|
||||
control := session.Values()["items.list"]
|
||||
if got.Speech != step.want || got.Action != "" || control.Text != step.prefix || control.Selected != step.selected {
|
||||
t.Fatalf("key %+v: result %+v, prefix %q, selection %d; want %q, %q, %d", step.key, got, control.Text, control.Selected, step.want, step.prefix, step.selected)
|
||||
}
|
||||
}
|
||||
session.Handle(Key{Name: "tab"})
|
||||
session.Handle(Key{Name: "tab", Shift: true})
|
||||
if got := session.Handle(Key{Text: "ni"}).Speech; got != "Ninjam" {
|
||||
t.Fatal("focus change did not clear search", got)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "enter"}).Action; got != "choose" {
|
||||
t.Fatal("search prevented activation", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package tabbed
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSessionCatalogIsolationAndLiteralGridSearch(t *testing.T) {
|
||||
form := Form{Type: TabForm, Title: "Picker", Tabs: []Tab{{ID: "items", Label: "Items", Controls: []Control{{ID: "grid", Label: "Grid", Kind: Grid, Columns: 2, Choices: []Choice{{ID: "a", Label: "Alpha"}, {ID: "b", Label: "Beta", Literal: true}}}}}}}
|
||||
translated, err := NewSession(form, Messages{Translate: func(text string) string {
|
||||
if text == "Alpha" {
|
||||
return "Translated"
|
||||
}
|
||||
if text == "Beta" {
|
||||
return "Wrong"
|
||||
}
|
||||
return text
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
english, err := New_session(form)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
translated.Handle(Key{Name: "tab"})
|
||||
english.Handle(Key{Name: "tab"})
|
||||
if got := translated.Handle(Key{Text: "Trans"}).Speech; got != "Search Trans. Translated. row 1, column 1. 1 of 1" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := english.Handle(Key{Text: "Al"}).Speech; got != "Search Al. Alpha. row 1, column 1. 1 of 1" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
translated.Handle(Key{Name: "backspace", Ctrl: true})
|
||||
if got := translated.Handle(Key{Text: "Beta"}).Speech; got != "Search Beta. Beta. row 1, column 1. 1 of 1" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Fenrir console screen reader.
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify it
|
||||
// under the terms of the GNU General Public License as published by the Free
|
||||
// Software Foundation, either version 3 of the License, or (at your option)
|
||||
// any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
// more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
||||
|
||||
package tabbed
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInvalidNumberUpDownStepRejected(t *testing.T) {
|
||||
for _, step := range []float64{-1, math.NaN(), math.Inf(1), math.Inf(-1)} {
|
||||
form := test_form()
|
||||
form.Tabs[0].Controls[1].UpDownStep = step
|
||||
if _, err := New_session(form); err == nil {
|
||||
t.Fatalf("invalid Up/Down step %v accepted", step)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func test_form() Form {
|
||||
return Form{Type: TabForm, Title: "Settings", Tabs: []Tab{
|
||||
{ID: "speech", Label: "Speech", Controls: []Control{
|
||||
{ID: "enabled", Label: "Speech enabled", Kind: CheckBox, Checked: true},
|
||||
{ID: "range", Label: "Range", Kind: Number, Number: 2.5, Min: -5, Max: 5, Step: 0.1},
|
||||
{ID: "text", Label: "Text", Kind: Entry, Text: "a界c"},
|
||||
{ID: "ok", Label: "OK", Kind: Button, Action: "save"},
|
||||
{ID: "cancel", Label: "Cancel", Kind: Button, Action: "cancel"},
|
||||
}},
|
||||
{ID: "sound", Label: "Sound"},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestTabFormTraversalControlsAndIsolation(t *testing.T) {
|
||||
form := test_form()
|
||||
session, err := New_session(form)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session.Open() != "Settings. Speech tab" {
|
||||
t.Fatal(session.Open())
|
||||
}
|
||||
for _, name := range []string{"right", "left", "down", "up"} {
|
||||
result := session.Handle(Key{Name: name})
|
||||
if result.Speech == "" {
|
||||
t.Fatal("silent tab", name)
|
||||
}
|
||||
}
|
||||
if session.Describe() != "Speech tab" {
|
||||
t.Fatal(session.Describe())
|
||||
}
|
||||
if got := session.Handle(Key{Name: "tab"}).Speech; got != "Speech enabled checked" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "space"}).Speech; got != "Speech enabled unchecked" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if !form.Tabs[0].Controls[0].Checked {
|
||||
t.Fatal("form mutated outside session")
|
||||
}
|
||||
session.Handle(Key{Name: "tab"})
|
||||
for _, fixture := range []struct {
|
||||
name string
|
||||
want float64
|
||||
}{
|
||||
{"up", 3.5}, {"right", 3.6}, {"left", 3.5}, {"down", 2.5},
|
||||
} {
|
||||
session.Handle(Key{Name: fixture.name})
|
||||
if got := session.Values()["speech.range"].Number; got != fixture.want {
|
||||
t.Fatal(fixture, got)
|
||||
}
|
||||
}
|
||||
for range 100 {
|
||||
session.Handle(Key{Name: "up"})
|
||||
}
|
||||
if got := session.Values()["speech.range"].Number; got != 5 {
|
||||
t.Fatal("upper bound", got)
|
||||
}
|
||||
for range 100 {
|
||||
session.Handle(Key{Name: "down"})
|
||||
}
|
||||
if got := session.Values()["speech.range"].Number; got != -5 {
|
||||
t.Fatal("lower bound", got)
|
||||
}
|
||||
session.Handle(Key{Name: "tab"})
|
||||
session.Handle(Key{Name: "home"})
|
||||
session.Handle(Key{Name: "right"})
|
||||
session.Handle(Key{Name: "delete"})
|
||||
session.Handle(Key{Text: "é"})
|
||||
if got := session.Values()["speech.text"].Text; got != "aéc" {
|
||||
t.Fatal("Unicode editing", got)
|
||||
}
|
||||
session.Handle(Key{Ctrl: true, Text: "a"})
|
||||
session.Handle(Key{Text: "replacement"})
|
||||
if got := session.Values()["speech.text"].Text; got != "replacement" {
|
||||
t.Fatal("select all", got)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "tab"}).Speech; got != "OK button" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "enter"}).Action; got != "save" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "tab"}).Speech; got != "Cancel button" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "enter"}).Action; got != "cancel" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "tab"}).Speech; got != "Speech tab" {
|
||||
t.Fatal("tab cycle", got)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "tab", Shift: true}).Speech; got != "Cancel button" {
|
||||
t.Fatal("reverse cycle", got)
|
||||
}
|
||||
if !session.Handle(Key{Name: "escape"}).Cancelled {
|
||||
t.Fatal("escape did not cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCategoryFormReturnsSelectionWithoutSideEffects(t *testing.T) {
|
||||
form := Form{Type: TabForm, Title: "Picker", Tabs: []Tab{{ID: "category", Label: "Category", Controls: []Control{{ID: "items", Label: "Items", Kind: List, Items: []string{"First", "Second"}, Action: "choose"}}}}}
|
||||
session, err := New_session(form)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session.Handle(Key{Name: "tab"})
|
||||
if got := session.Handle(Key{Name: "up"}).Speech; got != "First" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
session.Handle(Key{Name: "down"})
|
||||
if got := session.Handle(Key{Name: "enter"}).Action; got != "choose" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
values := session.Values()
|
||||
values["category.items"].Items[1] = "changed"
|
||||
if got := session.Describe(); got != "Items Second" {
|
||||
t.Fatal("values aliased", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownFormTypeRejected(t *testing.T) {
|
||||
form := test_form()
|
||||
form.Type = "unknown"
|
||||
if _, err := New_session(form); err == nil {
|
||||
t.Fatal("unknown form type accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package tabbed
|
||||
|
||||
import ui "git.stormux.org/storm/screenreader-common/internalui"
|
||||
|
||||
type Kind = ui.Kind
|
||||
type Control = ui.Control
|
||||
type Choice = ui.Choice
|
||||
type Tab = ui.Tab
|
||||
type FormType = ui.FormType
|
||||
type Form = ui.Form
|
||||
type Key = ui.Key
|
||||
type Result = ui.Result
|
||||
type Session = ui.Session
|
||||
type Messages = ui.Messages
|
||||
|
||||
const (
|
||||
CheckBox = ui.CheckBox
|
||||
Number = ui.Number
|
||||
Entry = ui.Entry
|
||||
List = ui.List
|
||||
Button = ui.Button
|
||||
ComboBox = ui.ComboBox
|
||||
Grid = ui.Grid
|
||||
TabForm = ui.TabForm
|
||||
FlatForm = ui.FlatForm
|
||||
)
|
||||
|
||||
func New_session(f Form) (*Session, error) { return ui.New_session(f) }
|
||||
func NewSession(f Form, m Messages) (*Session, error) { return ui.NewSession(f, m) }
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package tabbed
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHiddenControlsRetainDraftAndRemainOutsideBothTabOrders(t *testing.T) {
|
||||
session, err := New_session(test_form())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session.Set_hidden("speech", "range", true)
|
||||
for _, want := range []string{"enabled", "text", "ok", "cancel", ""} {
|
||||
session.Handle(Key{Name: "tab"})
|
||||
got, focused := session.Focused_control()
|
||||
if focused != (want != "") || got.ID != want {
|
||||
t.Fatal(got, want)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"cancel", "ok", "text", "enabled", ""} {
|
||||
session.Handle(Key{Name: "tab", Shift: true})
|
||||
got, focused := session.Focused_control()
|
||||
if focused != (want != "") || got.ID != want {
|
||||
t.Fatal(got, want)
|
||||
}
|
||||
}
|
||||
session.Handle(Key{Name: "tab"})
|
||||
session.Handle(Key{Name: "tab"})
|
||||
session.Handle(Key{Ctrl: true, Text: "a"})
|
||||
session.Handle(Key{Text: "draft"})
|
||||
session.Set_hidden("speech", "text", true)
|
||||
if got, _ := session.Focused_control(); got.ID != "ok" {
|
||||
t.Fatal("hidden focus retained", got)
|
||||
}
|
||||
session.Set_hidden("speech", "text", false)
|
||||
session.Handle(Key{Name: "tab", Shift: true})
|
||||
if got := session.Values()["speech.text"]; got.Text != "draft" || got.Hidden {
|
||||
t.Fatal(got)
|
||||
}
|
||||
for _, id := range []string{"enabled", "range", "text", "ok", "cancel"} {
|
||||
session.Set_hidden("speech", id, true)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "tab"}).Speech; got != "Speech tab" {
|
||||
t.Fatal("all-hidden tab trapped focus", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user