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,467 @@
|
||||
// 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"
|
||||
"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"`
|
||||
}
|
||||
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, caret, anchor int
|
||||
prefix string
|
||||
}
|
||||
type Session struct {
|
||||
title string
|
||||
elements []elementState
|
||||
focus int
|
||||
closed bool
|
||||
}
|
||||
|
||||
const maxTextLength = 8192
|
||||
|
||||
func NewSession(form Form) (*Session, error) {
|
||||
if form.Title == "" || len(form.Title) > 1024 || len(form.Elements) == 0 || len(form.Elements) > 64 {
|
||||
return nil, errors.New("a titled form with 1 to 64 controls is required")
|
||||
}
|
||||
session := &Session{title: form.Title, 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.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, caret: len([]rune(element.Text)), anchor: len([]rune(element.Text))}
|
||||
itemIDs := map[string]bool{}
|
||||
if len(element.Items) > 256 {
|
||||
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")
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (session *Session) Presentation() Presentation {
|
||||
if session.closed {
|
||||
return Presentation{}
|
||||
}
|
||||
state := &session.elements[session.focus]
|
||||
element := state.element
|
||||
text := element.Label
|
||||
cursor := -1
|
||||
switch element.Kind {
|
||||
case List:
|
||||
text += fmt.Sprintf(": %s, %d of %d", element.Items[state.selected].Label, state.selected+1, len(element.Items))
|
||||
case Entry:
|
||||
text += ", edit: " + element.Text
|
||||
cursor = len([]rune(element.Label+", edit: ")) + state.caret
|
||||
if state.caret != state.anchor {
|
||||
text += fmt.Sprintf(" (selected %d characters)", abs(state.caret-state.anchor))
|
||||
}
|
||||
case CheckBox:
|
||||
if element.Checked {
|
||||
text += ", checked"
|
||||
} else {
|
||||
text += ", unchecked"
|
||||
}
|
||||
case Button:
|
||||
text += ", button"
|
||||
}
|
||||
speech := text
|
||||
if element.Kind == List {
|
||||
speech = element.Items[state.selected].Label
|
||||
}
|
||||
return Presentation{Speech: speech, Braille: text, Cursor: cursor}
|
||||
}
|
||||
|
||||
func (session *Session) Opening() Presentation {
|
||||
output := session.Presentation()
|
||||
if !session.closed && len(session.elements) > 1 && session.elements[session.focus].element.Kind == List {
|
||||
output.Speech = session.elements[session.focus].element.Label + ". " + output.Speech
|
||||
}
|
||||
output.Speech = session.title + ". " + output.Speech
|
||||
return output
|
||||
}
|
||||
|
||||
func (session *Session) Values() map[string]Value {
|
||||
values := make(map[string]Value, len(session.elements))
|
||||
for _, state := range session.elements {
|
||||
value := Value{Text: state.element.Text, Checked: state.element.Checked}
|
||||
if state.element.Kind == List && state.selected >= 0 {
|
||||
value.Selected = state.element.Items[state.selected].ID
|
||||
}
|
||||
values[state.element.ID] = value
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func (session *Session) Handle(event Event) Effect {
|
||||
if session.closed {
|
||||
return Effect{}
|
||||
}
|
||||
if event.Key == Escape {
|
||||
session.closed = true
|
||||
return Effect{Cancelled: true}
|
||||
}
|
||||
if event.Key == Tab {
|
||||
step := 1
|
||||
if event.Shift {
|
||||
step = -1
|
||||
}
|
||||
for range len(session.elements) {
|
||||
session.focus = (session.focus + step + len(session.elements)) % len(session.elements)
|
||||
if !session.elements[session.focus].element.Disabled {
|
||||
break
|
||||
}
|
||||
}
|
||||
output := session.Presentation()
|
||||
// A list in a multi-control form needs its field label when Tab enters
|
||||
// it. A menu's only control uses its opening title instead.
|
||||
state := &session.elements[session.focus]
|
||||
if len(session.elements) > 1 && state.element.Kind == List {
|
||||
output.Speech = state.element.Label + ". " + output.Speech
|
||||
}
|
||||
return Effect{Presentation: output}
|
||||
}
|
||||
state := &session.elements[session.focus]
|
||||
if event.Alt {
|
||||
return Effect{}
|
||||
}
|
||||
if event.Key == Enter || state.element.Kind == Button && event.Key == Text && event.Text == " " && !event.Control {
|
||||
for _, candidate := range session.elements {
|
||||
if candidate.element.Kind == Entry && candidate.element.Required && !candidate.element.Disabled && strings.TrimSpace(candidate.element.Text) == "" {
|
||||
return Effect{Presentation: Presentation{Speech: candidate.element.Label + " is required", Braille: candidate.element.Label + " is required", Cursor: -1}}
|
||||
}
|
||||
}
|
||||
id := state.element.ID
|
||||
if state.element.Kind == List {
|
||||
id = state.element.Items[state.selected].ID
|
||||
}
|
||||
return Effect{Activated: id}
|
||||
}
|
||||
switch state.element.Kind {
|
||||
case List:
|
||||
step := 0
|
||||
switch event.Key {
|
||||
case Up:
|
||||
step = -1
|
||||
case Down:
|
||||
step = 1
|
||||
case PageUp:
|
||||
step = -10
|
||||
case PageDown:
|
||||
step = 10
|
||||
case Home:
|
||||
state.selected = 0
|
||||
step = 1
|
||||
case End:
|
||||
state.selected = len(state.element.Items) - 1
|
||||
step = -1
|
||||
case Text:
|
||||
if event.Control || event.Text == "" {
|
||||
return Effect{}
|
||||
}
|
||||
prefix := state.prefix + strings.ToLower(event.Text)
|
||||
if !state.find_prefix(prefix) {
|
||||
prefix = strings.ToLower(event.Text)
|
||||
state.find_prefix(prefix)
|
||||
}
|
||||
state.prefix = prefix
|
||||
return Effect{Presentation: session.Presentation()}
|
||||
default:
|
||||
return Effect{}
|
||||
}
|
||||
state.prefix = ""
|
||||
if event.Key == Home || event.Key == End {
|
||||
for state.element.Items[state.selected].Disabled {
|
||||
state.selected += step
|
||||
}
|
||||
} else {
|
||||
for count := 0; count < abs(step); count++ {
|
||||
previous := state.selected
|
||||
for next := previous + sign(step); next >= 0 && next < len(state.element.Items); next += sign(step) {
|
||||
if !state.element.Items[next].Disabled {
|
||||
state.selected = next
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case CheckBox:
|
||||
if event.Key != Text || event.Text != " " || event.Control {
|
||||
return Effect{}
|
||||
}
|
||||
state.element.Checked = !state.element.Checked
|
||||
case Button:
|
||||
return Effect{}
|
||||
case Entry:
|
||||
return session.edit(event)
|
||||
}
|
||||
return Effect{Presentation: session.Presentation()}
|
||||
}
|
||||
|
||||
func (state *elementState) find_prefix(prefix string) bool {
|
||||
for offset := 1; offset <= len(state.element.Items); offset++ {
|
||||
index := (state.selected + offset) % len(state.element.Items)
|
||||
item := state.element.Items[index]
|
||||
if !item.Disabled && strings.HasPrefix(strings.ToLower(item.Label), prefix) {
|
||||
state.selected = index
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func sign(value int) int {
|
||||
if value < 0 {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
func abs(value int) int {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (session *Session) edit(event Event) Effect {
|
||||
state := &session.elements[session.focus]
|
||||
characters := []rune(state.element.Text)
|
||||
start, end := min(state.caret, state.anchor), max(state.caret, state.anchor)
|
||||
if event.Control && event.Key == Text {
|
||||
switch strings.ToLower(event.Text) {
|
||||
case "a":
|
||||
state.anchor, state.caret = 0, len(characters)
|
||||
output := session.Presentation()
|
||||
output.Speech = "Selected all"
|
||||
return Effect{Presentation: output}
|
||||
case "v":
|
||||
return Effect{Paste: true}
|
||||
case "c", "x":
|
||||
if start == end {
|
||||
return Effect{}
|
||||
}
|
||||
text := string(characters[start:end])
|
||||
// Cutting is completed by the host only after clipboard write succeeds.
|
||||
return Effect{Copy: &text}
|
||||
default:
|
||||
return Effect{}
|
||||
}
|
||||
}
|
||||
if event.Key == Left || event.Key == Right || event.Key == Home || event.Key == End {
|
||||
next := state.caret
|
||||
switch event.Key {
|
||||
case Left:
|
||||
if !event.Shift && start != end {
|
||||
next = start
|
||||
} else {
|
||||
next = max(0, next-1)
|
||||
if event.Control {
|
||||
for next > 0 && unicode.IsSpace(characters[next]) {
|
||||
next--
|
||||
}
|
||||
for next > 0 && !unicode.IsSpace(characters[next-1]) {
|
||||
next--
|
||||
}
|
||||
}
|
||||
}
|
||||
case Right:
|
||||
if !event.Shift && start != end {
|
||||
next = end
|
||||
} else {
|
||||
next = min(len(characters), next+1)
|
||||
if event.Control {
|
||||
for next < len(characters) && !unicode.IsSpace(characters[next]) {
|
||||
next++
|
||||
}
|
||||
for next < len(characters) && unicode.IsSpace(characters[next]) {
|
||||
next++
|
||||
}
|
||||
}
|
||||
}
|
||||
case Home:
|
||||
next = 0
|
||||
case End:
|
||||
next = len(characters)
|
||||
}
|
||||
state.caret = next
|
||||
if !event.Shift {
|
||||
state.anchor = next
|
||||
}
|
||||
output := session.Presentation()
|
||||
if next == len(characters) {
|
||||
output.Speech = "End of text"
|
||||
} else {
|
||||
output.Speech = string(characters[next])
|
||||
}
|
||||
if event.Shift {
|
||||
output.Speech += ", selected"
|
||||
}
|
||||
return Effect{Presentation: output}
|
||||
}
|
||||
if event.Key == Backspace || event.Key == Delete {
|
||||
if start == end {
|
||||
if event.Key == Backspace {
|
||||
start = max(0, start-1)
|
||||
} else {
|
||||
end = min(len(characters), end+1)
|
||||
}
|
||||
}
|
||||
if start == end {
|
||||
return Effect{}
|
||||
}
|
||||
removed := string(characters[start:end])
|
||||
state.element.Text = string(append(characters[:start], characters[end:]...))
|
||||
state.caret, state.anchor = start, start
|
||||
output := session.Presentation()
|
||||
output.Speech = "Deleted " + removed
|
||||
return Effect{Presentation: output}
|
||||
}
|
||||
if event.Key == Text && event.Text != "" && !event.Control {
|
||||
return session.Insert(event.Text)
|
||||
}
|
||||
return Effect{}
|
||||
}
|
||||
|
||||
// Insert handles typing and paste atomically; invalid/oversized input leaves
|
||||
// the field and selection untouched.
|
||||
func (session *Session) Insert(text string) Effect {
|
||||
if session.closed {
|
||||
return Effect{}
|
||||
}
|
||||
state := &session.elements[session.focus]
|
||||
if state.element.Kind != Entry {
|
||||
return Effect{}
|
||||
}
|
||||
if !utf8.ValidString(text) {
|
||||
return Effect{Presentation: Presentation{Speech: "Invalid text", Braille: "Invalid text", Cursor: -1}}
|
||||
}
|
||||
characters := []rune(state.element.Text)
|
||||
start, end := min(state.caret, state.anchor), max(state.caret, state.anchor)
|
||||
inserted := []rune(text)
|
||||
for _, character := range inserted {
|
||||
if unicode.IsControl(character) {
|
||||
return Effect{Presentation: Presentation{Speech: "Only single-line text is accepted", Braille: "Only single-line text is accepted", Cursor: -1}}
|
||||
}
|
||||
}
|
||||
if len(characters)-(end-start)+len(inserted) > state.element.MaxLength {
|
||||
return Effect{Presentation: Presentation{Speech: "Text limit reached", Braille: "Text limit reached", Cursor: -1}}
|
||||
}
|
||||
state.element.Text = string(characters[:start]) + text + string(characters[end:])
|
||||
state.caret, state.anchor = start+len(inserted), start+len(inserted)
|
||||
output := session.Presentation()
|
||||
output.Speech = text
|
||||
return Effect{Presentation: output}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package interaction
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user