Add shared primary voice profiles and editor
This commit is contained in:
1 parent
5ca137c879
commit
03896de84e
7 files changed
+590
-4
No files matched your search
@@ -44,7 +44,7 @@ before editing. Describe what changed, the tests run, and remaining live reader
|
||||
checks. Leave submission to the contributor after their review and testing.
|
||||
|
||||
The shared quick menu and validated discovery API are available starting with
|
||||
v0.5.0. Both readers pin this version for standalone builds.
|
||||
v0.5.0. Both readers pin v0.6.0 for standalone builds.
|
||||
|
||||
## License
|
||||
|
||||
@@ -174,7 +174,26 @@ and 0..9 pitch/volume values at the boundary. It no longer has a separate
|
||||
DECtalk encoding. Port opening, serial/USB I/O, flush timing and cancellation
|
||||
synchronization remain the responsibility of the device-owning reader.
|
||||
|
||||
These additions are available starting with v0.4.0. Both readers pin this
|
||||
version for standalone builds. Verify pinned dependencies with `GOWORK=off`;
|
||||
These additions are available starting with v0.4.0. Both readers pin v0.6.0
|
||||
for standalone builds. Verify pinned dependencies with `GOWORK=off`;
|
||||
physical keyboard, audible speech, braille, and hardware acceptance remain
|
||||
separate live checks.
|
||||
|
||||
## Primary voice profiles
|
||||
|
||||
`voiceprofiles` supplies the shared primary-voice preset model, collection
|
||||
validation/copying, unique automatic names, management-tab controls, and editor
|
||||
forms for software and hardware speech. `Editor.Refresh_session` maintains
|
||||
module/language/voice dependencies in the shared UI toolkit; native GTK hosts
|
||||
use the same form and dependent-control updates. Catalog discovery, native
|
||||
widget rendering, speech preparation/rollback and atomic settings persistence
|
||||
remain host responsibilities. Existing echo and message voice policies remain
|
||||
with each reader. A preset stores only primary speech transport/voice/numeric
|
||||
settings and never modifies unrelated preferences.
|
||||
|
||||
`internalui.Session.Focus_control` restores a control when a child editor
|
||||
returns to its parent form. `quicksettings.Option.Literal` preserves user-chosen
|
||||
profile names without translation. These APIs are available in v0.6.0, pinned
|
||||
by both readers for standalone builds. Run `go test -race ./...` here and the
|
||||
consumer settings/speech regressions with `GOWORK=off` in both readers. The
|
||||
sibling workspace supports local development without machine-specific replaces.
|
||||
@@ -175,6 +175,27 @@ func (s *Session) Focused_control() (Control, bool) {
|
||||
return control, true
|
||||
}
|
||||
|
||||
// Focus_control restores a host's focus after returning from a child dialog.
|
||||
func (s *Session) Focus_control(tabID, controlID string) bool {
|
||||
if s.closed {
|
||||
return false
|
||||
}
|
||||
for ti, tab := range s.form.Tabs {
|
||||
if tab.ID != tabID {
|
||||
continue
|
||||
}
|
||||
for ci, control := range tab.Controls {
|
||||
if control.ID == controlID && !control.Hidden && !control.Disabled {
|
||||
s.tab, s.focus = ti, ci
|
||||
s.cursor = len([]rune(control.Text))
|
||||
s.anchor = s.cursor
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Set_choices updates an asynchronous grid without moving keyboard focus or
|
||||
// discarding its search. Keep the selected word when it still matches.
|
||||
func (s *Session) Set_choices(tabID, controlID string, choices []Choice) {
|
||||
|
||||
@@ -26,7 +26,10 @@ const (
|
||||
Range
|
||||
)
|
||||
|
||||
type Option struct{ ID, Label string }
|
||||
type Option struct {
|
||||
ID, Label string
|
||||
Literal bool // User-provided labels bypass translation.
|
||||
}
|
||||
|
||||
// Entry describes one setting. Label and option labels are English message
|
||||
// IDs passed through the menu's translator.
|
||||
@@ -267,6 +270,9 @@ func (m *Menu) describe(entry Entry, value Value, options []Option) (text string
|
||||
}
|
||||
for _, option := range options {
|
||||
if option.ID == value.Choice {
|
||||
if option.Literal {
|
||||
return option.Label, false
|
||||
}
|
||||
return m.Messages.T(option.Label), false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,3 +183,15 @@ func TestCategoriesCannotMutateMenuOrClone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiteralProfileNamesBypassTranslation(t *testing.T) {
|
||||
menu := New(nil, Messages{Translate: func(value string) string { return "translated " + value }})
|
||||
entry := Entry{ID: "profile", Label: "Voice profile", Kind: Choice}
|
||||
value := Value{Choice: "Reading"}
|
||||
if got := menu.Describe(entry, value, []Option{{ID: "Reading", Label: "Reading", Literal: true}}); got != "Reading" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := menu.Describe(entry, value, []Option{{ID: "Reading", Label: "Reading"}}); got != "translated Reading" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package voiceprofiles
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
ui "git.stormux.org/storm/screenreader-common/internalui"
|
||||
sd "git.stormux.org/storm/screenreader-common/speechdispatcher"
|
||||
)
|
||||
|
||||
const PageID = "voice-profile"
|
||||
|
||||
// Editor builds the same labeled controls for native GTK and internal forms.
|
||||
// Catalog queries and committing the draft remain host operations.
|
||||
type Editor struct {
|
||||
drivers []Driver
|
||||
catalog sd.Catalog
|
||||
previous Profile
|
||||
}
|
||||
|
||||
func New_editor(p Profile, drivers []Driver, catalog sd.Catalog) *Editor {
|
||||
copyCatalog := sd.Catalog{}
|
||||
for module, voices := range catalog {
|
||||
copyCatalog[module] = append([]sd.Voice(nil), voices...)
|
||||
}
|
||||
return &Editor{drivers: append([]Driver(nil), drivers...), catalog: copyCatalog, previous: p}
|
||||
}
|
||||
|
||||
func choice(id, label string) ui.Choice { return ui.Choice{ID: id, Label: label, Literal: true} }
|
||||
|
||||
func combo(id, label, selected string, choices []ui.Choice) ui.Control {
|
||||
index := -1
|
||||
for i, c := range choices {
|
||||
if c.ID == selected {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
index = len(choices)
|
||||
choices = append(choices, choice(selected, selected+" (unavailable)"))
|
||||
}
|
||||
return ui.Control{ID: id, Label: label, Kind: ui.ComboBox, Choices: choices, Selected: index}
|
||||
}
|
||||
|
||||
func (e *Editor) controls(p Profile) []ui.Control {
|
||||
drivers := []ui.Choice{}
|
||||
for _, driver := range e.drivers {
|
||||
drivers = append(drivers, choice(driver.ID, driver.Label))
|
||||
}
|
||||
controls := []ui.Control{{ID: "name", Label: "Voice Profile Name", Kind: ui.Entry, Text: p.Name, MaxLength: 128}, combo("driver", "Synthesizer", p.Driver, drivers)}
|
||||
driver, _ := driver_for(p.Driver)
|
||||
modules := []ui.Choice{choice("", "Default")}
|
||||
keys := []string{}
|
||||
for module := range e.catalog {
|
||||
if module != "" {
|
||||
keys = append(keys, module)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, module := range keys {
|
||||
modules = append(modules, choice(module, module))
|
||||
}
|
||||
languages := []ui.Choice{choice("", "Default")}
|
||||
voices := []ui.Choice{choice("", "Default")}
|
||||
seen := map[string]bool{"": true}
|
||||
for _, voice := range e.catalog[p.Module] {
|
||||
if !seen[voice.Language] {
|
||||
languages = append(languages, choice(voice.Language, voice.Language))
|
||||
seen[voice.Language] = true
|
||||
}
|
||||
}
|
||||
filtered := e.catalog[p.Module]
|
||||
if p.Language != "" {
|
||||
filtered = sd.LanguageVoices(filtered, p.Language)
|
||||
}
|
||||
seen = map[string]bool{"": true}
|
||||
for _, voice := range filtered {
|
||||
if !seen[voice.Name] {
|
||||
voices = append(voices, choice(voice.Name, voice.Name+" ("+voice.Language+")"))
|
||||
seen[voice.Name] = true
|
||||
}
|
||||
}
|
||||
for _, c := range []ui.Control{combo("module", "Module", p.Module, modules), combo("language", "Language", p.Language, languages), combo("voice", "Voice", p.Voice, voices)} {
|
||||
c.Hidden = driver.Hardware
|
||||
controls = append(controls, c)
|
||||
}
|
||||
baud := []ui.Choice{}
|
||||
for _, n := range BaudRates {
|
||||
baud = append(baud, choice(strconv.Itoa(n), strconv.Itoa(n)))
|
||||
}
|
||||
device := ui.Control{ID: "device", Label: "Serial device", Kind: ui.Entry, Text: p.Device, Hidden: !driver.Serial, MaxLength: 1024}
|
||||
baudControl := combo("baud", "Baud rate", strconv.Itoa(p.BaudRate), baud)
|
||||
baudControl.Hidden = !driver.Serial
|
||||
controls = append(controls, device, baudControl)
|
||||
for _, item := range []struct {
|
||||
id, label string
|
||||
value int
|
||||
}{{"rate", "Rate", p.Rate}, {"pitch", "Pitch", p.Pitch}, {"volume", "Volume", p.Volume}} {
|
||||
controls = append(controls, ui.Control{ID: item.id, Label: item.label, Kind: ui.Number, Number: float64(item.value), Min: -100, Max: 100, Step: 10, UpDownStep: 1})
|
||||
}
|
||||
return append(controls, ui.Control{ID: "save", Label: "Save Voice Profile", Kind: ui.Button, Action: "save-profile"}, ui.Control{ID: "cancel", Label: "Cancel", Kind: ui.Button, Action: "cancel", SkipValidation: true})
|
||||
}
|
||||
|
||||
func (e *Editor) Form() ui.Form {
|
||||
return ui.Form{Type: ui.FlatForm, Title: "Voice Profile", Tabs: []ui.Tab{{ID: PageID, Label: "Voice Profile", Controls: e.controls(e.previous)}}}
|
||||
}
|
||||
|
||||
func selected(c ui.Control) string {
|
||||
if c.Selected >= 0 && c.Selected < len(c.Choices) {
|
||||
return c.Choices[c.Selected].ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *Editor) read(values map[string]ui.Control) Profile {
|
||||
get := func(id string) ui.Control { return values[PageID+"."+id] }
|
||||
baud, _ := strconv.Atoi(selected(get("baud")))
|
||||
p := Profile{Name: get("name").Text, Driver: selected(get("driver")), Module: selected(get("module")), Language: selected(get("language")), Voice: selected(get("voice")), Device: get("device").Text, BaudRate: baud, Rate: int(get("rate").Number), Pitch: int(get("pitch").Number), Volume: int(get("volume").Number)}
|
||||
if p.Module == e.previous.Module && p.Language == e.previous.Language && p.Voice == e.previous.Voice {
|
||||
p.Variant = e.previous.Variant
|
||||
}
|
||||
for _, voice := range e.catalog[p.Module] {
|
||||
if voice.Name == p.Voice && (p.Language == "" || strings.EqualFold(p.Language, voice.Language)) {
|
||||
p.Language = voice.Language
|
||||
p.Variant = voice.Variant
|
||||
break
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Refresh returns only dependent controls, preserving entry text and numeric drafts.
|
||||
func (e *Editor) Refresh(values map[string]ui.Control) []ui.Control {
|
||||
p := e.read(values)
|
||||
if p.Driver == e.previous.Driver && p.Module == e.previous.Module && selected(values[PageID+".language"]) == e.previous.Language {
|
||||
return nil
|
||||
}
|
||||
if p.Module != e.previous.Module {
|
||||
p.Language = ""
|
||||
p.Voice = ""
|
||||
p.Variant = ""
|
||||
}
|
||||
if selected(values[PageID+".language"]) != e.previous.Language {
|
||||
p.Voice = ""
|
||||
p.Variant = ""
|
||||
}
|
||||
// The language combo is a filter; selecting a voice alone must not reset it.
|
||||
p.Language = selected(values[PageID+".language"])
|
||||
if p.Module != e.previous.Module {
|
||||
p.Language = ""
|
||||
}
|
||||
e.previous = p
|
||||
var changes []ui.Control
|
||||
for _, c := range e.controls(p) {
|
||||
if c.ID == "module" || c.ID == "language" || c.ID == "voice" || c.ID == "device" || c.ID == "baud" {
|
||||
changes = append(changes, c)
|
||||
}
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func (e *Editor) Refresh_session(session *ui.Session) {
|
||||
for _, c := range e.Refresh(session.Values()) {
|
||||
if c.Kind == ui.ComboBox {
|
||||
session.Replace_choices(PageID, c.ID, c.Choices, selected(c))
|
||||
}
|
||||
session.Set_hidden(PageID, c.ID, c.Hidden)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Editor) Read(values map[string]ui.Control) (Profile, error) {
|
||||
for _, id := range []string{"rate", "pitch", "volume"} {
|
||||
n := values[PageID+"."+id].Number
|
||||
if math.IsNaN(n) || math.IsInf(n, 0) || math.Trunc(n) != n || n < -100 || n > 100 {
|
||||
return Profile{}, fmt.Errorf("Enter a %s between -100 and 100", id)
|
||||
}
|
||||
}
|
||||
p := e.read(values).Normalize()
|
||||
found := false
|
||||
for _, driver := range e.drivers {
|
||||
if driver.ID == p.Driver {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return p, fmt.Errorf("Choose a supported synthesizer")
|
||||
}
|
||||
driver, _ := driver_for(p.Driver)
|
||||
if driver.Hardware {
|
||||
p.Module = ""
|
||||
p.Language = ""
|
||||
p.Voice = ""
|
||||
p.Variant = ""
|
||||
}
|
||||
if !driver.Serial {
|
||||
p.Device = ""
|
||||
p.BaudRate = 0
|
||||
}
|
||||
return p, p.Validate()
|
||||
}
|
||||
|
||||
func (c Collection) Tab() ui.Tab {
|
||||
tab := ui.Tab{ID: "voice-profiles", Label: "Voice Profiles"}
|
||||
if len(c.Profiles) > 0 {
|
||||
choices := []ui.Choice{}
|
||||
for _, p := range c.Profiles {
|
||||
choices = append(choices, choice(p.Name, p.Name))
|
||||
}
|
||||
tab.Controls = append(tab.Controls, ui.Control{ID: "profiles", Label: "Voice Profiles", Kind: ui.ComboBox, Choices: choices})
|
||||
}
|
||||
tab.Controls = append(tab.Controls, ui.Control{ID: "add", Label: "Add Voice Profile", Kind: ui.Button, Action: "add-profile"})
|
||||
if len(c.Profiles) > 0 {
|
||||
for _, item := range []struct{ id, label string }{{"edit", "Edit Voice Profile"}, {"remove", "Remove Voice Profile"}, {"use", "Use Voice Profile"}} {
|
||||
tab.Controls = append(tab.Controls, ui.Control{ID: item.id, Label: item.label, Kind: ui.Button, Action: item.id + "-profile"})
|
||||
}
|
||||
}
|
||||
return tab
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package voiceprofiles owns primary-voice presets, independent of reader storage
|
||||
// and speech transports. Selecting a preset never changes echo or message voices.
|
||||
package voiceprofiles
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
sd "git.stormux.org/storm/screenreader-common/speechdispatcher"
|
||||
)
|
||||
|
||||
type Profile struct {
|
||||
Name string `toml:"name"`
|
||||
Driver string `toml:"driver"`
|
||||
Module string `toml:"module,omitempty"`
|
||||
Language string `toml:"language,omitempty"`
|
||||
Voice string `toml:"voice,omitempty"`
|
||||
Variant string `toml:"variant,omitempty"`
|
||||
Rate int `toml:"rate"`
|
||||
Pitch int `toml:"pitch"`
|
||||
Volume int `toml:"volume"`
|
||||
Device string `toml:"device,omitempty"`
|
||||
BaudRate int `toml:"baud-rate,omitempty"`
|
||||
}
|
||||
|
||||
type Collection struct {
|
||||
Profiles []Profile `toml:"profiles,omitempty"`
|
||||
}
|
||||
|
||||
func (c Collection) Clone() Collection { return Collection{Profiles: slices.Clone(c.Profiles)} }
|
||||
|
||||
type Driver struct {
|
||||
ID, Label string
|
||||
Hardware, Serial bool
|
||||
}
|
||||
|
||||
func Drivers() []Driver {
|
||||
return []Driver{{ID: "speech-dispatcher", Label: "Speech Dispatcher"},
|
||||
{ID: "dectalk", Label: "DECtalk", Hardware: true, Serial: true},
|
||||
{ID: "dectalk-usb", Label: "DECtalk USB", Hardware: true},
|
||||
{ID: "litetalk", Label: "LiteTalk", Hardware: true, Serial: true},
|
||||
{ID: "doubletalk", Label: "DoubleTalk LT", Hardware: true, Serial: true},
|
||||
{ID: "tripletalk", Label: "TripleTalk", Hardware: true, Serial: true}}
|
||||
}
|
||||
|
||||
func driver_for(id string) (Driver, bool) {
|
||||
for _, driver := range Drivers() {
|
||||
if driver.ID == id {
|
||||
return driver, true
|
||||
}
|
||||
}
|
||||
return Driver{}, false
|
||||
}
|
||||
|
||||
// Normalize supplies the saved name when the editor's name field is blank.
|
||||
func (p Profile) Normalize() Profile {
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
if p.Name == "" {
|
||||
driver, _ := driver_for(p.Driver)
|
||||
if !driver.Hardware {
|
||||
p.Name = strings.TrimSpace(strings.Join([]string{p.Module, p.Voice}, " "))
|
||||
}
|
||||
if p.Name == "" {
|
||||
p.Name = driver.Label
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p Profile) Validate() error {
|
||||
if p.Name == "" || p.Name != strings.TrimSpace(p.Name) || utf8.RuneCountInString(p.Name) > 128 || !utf8.ValidString(p.Name) {
|
||||
return fmt.Errorf("Enter a voice profile name of at most 128 characters")
|
||||
}
|
||||
for _, char := range p.Name {
|
||||
if unicode.IsControl(char) {
|
||||
return fmt.Errorf("Voice profile names must be single-line text")
|
||||
}
|
||||
}
|
||||
driver, ok := driver_for(p.Driver)
|
||||
if !ok {
|
||||
return fmt.Errorf("Choose a supported synthesizer")
|
||||
}
|
||||
for _, number := range []int{p.Rate, p.Pitch, p.Volume} {
|
||||
if number < -100 || number > 100 {
|
||||
return fmt.Errorf("Voice rate, pitch and volume must be between -100 and 100")
|
||||
}
|
||||
}
|
||||
if err := sd.ValidSelection(p.Module, p.Language, p.Voice); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(p.Variant) > 1024 || strings.IndexFunc(p.Variant, unicode.IsControl) >= 0 {
|
||||
return fmt.Errorf("Invalid voice variant")
|
||||
}
|
||||
if driver.Serial {
|
||||
if !filepath.IsAbs(p.Device) || len(p.Device) > 1024 || strings.IndexFunc(p.Device, unicode.IsControl) >= 0 {
|
||||
return fmt.Errorf("Choose an absolute serial device path")
|
||||
}
|
||||
if !slices.Contains(BaudRates, p.BaudRate) {
|
||||
return fmt.Errorf("Choose a supported serial baud rate")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var BaudRates = []int{1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200}
|
||||
|
||||
func (c Collection) Validate() error {
|
||||
if len(c.Profiles) > 128 {
|
||||
return fmt.Errorf("At most 128 voice profiles can be saved")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, p := range c.Profiles {
|
||||
if err := p.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
name := strings.ToLower(p.Name)
|
||||
if seen[name] {
|
||||
return fmt.Errorf("A voice profile named %q already exists", p.Name)
|
||||
}
|
||||
seen[name] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Put replaces only the explicitly edited profile; duplicate names never overwrite.
|
||||
func (c Collection) Put(index int, p Profile) (Collection, error) {
|
||||
if index < -1 || index >= len(c.Profiles) {
|
||||
return c, fmt.Errorf("Voice profile no longer exists")
|
||||
}
|
||||
next := c.Clone()
|
||||
p = p.Normalize()
|
||||
if index == -1 {
|
||||
next.Profiles = append(next.Profiles, p)
|
||||
} else {
|
||||
next.Profiles[index] = p
|
||||
}
|
||||
if err := next.Validate(); err != nil {
|
||||
return c, err
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func (c Collection) Remove(index int) Collection {
|
||||
next := c.Clone()
|
||||
if index >= 0 && index < len(next.Profiles) {
|
||||
next.Profiles = slices.Delete(next.Profiles, index, index+1)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func Same_voice(a, b Profile) bool {
|
||||
a.Name = ""
|
||||
b.Name = ""
|
||||
a.Variant = ""
|
||||
b.Variant = ""
|
||||
return a == b
|
||||
}
|
||||
|
||||
func (c Collection) Matching(p Profile) string {
|
||||
for _, candidate := range c.Profiles {
|
||||
if Same_voice(candidate, p) {
|
||||
return candidate.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c Collection) Find(name string) (Profile, bool) {
|
||||
for _, p := range c.Profiles {
|
||||
if p.Name == name {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return Profile{}, false
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package voiceprofiles
|
||||
|
||||
import (
|
||||
ui "git.stormux.org/storm/screenreader-common/internalui"
|
||||
sd "git.stormux.org/storm/screenreader-common/speechdispatcher"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fixture_profile() Profile {
|
||||
return Profile{Driver: "speech-dispatcher", Module: "fixture", Voice: "Reading voice", Language: "en", Volume: 100}
|
||||
}
|
||||
func TestCollectionNamingDuplicatesAndIsolation(t *testing.T) {
|
||||
p := fixture_profile()
|
||||
collection, err := (Collection{}).Put(-1, p)
|
||||
if err != nil || collection.Profiles[0].Name != "fixture Reading voice" {
|
||||
t.Fatal(collection, err)
|
||||
}
|
||||
before := collection.Clone()
|
||||
p.Name = "FIXTURE READING VOICE"
|
||||
if _, err = collection.Put(-1, p); err == nil {
|
||||
t.Fatal("duplicate silently overwrote a profile")
|
||||
}
|
||||
if !reflect.DeepEqual(before, collection) {
|
||||
t.Fatal("failed add mutated collection")
|
||||
}
|
||||
p.Name = "Long reading"
|
||||
next, err := collection.Put(0, p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next.Profiles[0].Name = "Changed"
|
||||
if collection.Profiles[0].Name != before.Profiles[0].Name {
|
||||
t.Fatal("aliased profile list")
|
||||
}
|
||||
for _, name := range []string{"bad\nname", strings.Repeat("x", 129)} {
|
||||
p.Name = name
|
||||
if _, err = collection.Put(-1, p); err == nil {
|
||||
t.Fatal("accepted invalid name")
|
||||
}
|
||||
}
|
||||
hardware := Profile{Driver: "dectalk", Device: "/dev/fixture", BaudRate: 9600, Volume: 100}.Normalize()
|
||||
if hardware.Name != "DECtalk" || hardware.Validate() != nil {
|
||||
t.Fatal(hardware)
|
||||
}
|
||||
if len(collection.Remove(0).Profiles) != 0 || len(collection.Profiles) != 1 {
|
||||
t.Fatal("remove mutated original")
|
||||
}
|
||||
}
|
||||
func TestEditorDependenciesHardwareAndUnavailableVoice(t *testing.T) {
|
||||
p := fixture_profile()
|
||||
p.Name = "Saved"
|
||||
p.Voice = "Offline voice"
|
||||
p.Variant = "saved"
|
||||
catalog := sd.Catalog{"fixture": {{Name: "English", Language: "en"}, {Name: "Spanish", Language: "es"}}, "other": {{Name: "Other", Language: "fr"}}}
|
||||
editor := New_editor(p, Drivers(), catalog)
|
||||
session, err := ui.New_session(editor.Form())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c, _ := session.Focused_control(); c.ID != "name" {
|
||||
t.Fatal("name is not initially focused")
|
||||
}
|
||||
got, err := editor.Read(session.Values())
|
||||
if err != nil || got.Voice != p.Voice || got.Variant != p.Variant {
|
||||
t.Fatal("offline voice lost", got, err)
|
||||
}
|
||||
session.Focus_control(PageID, "rate")
|
||||
session.Handle(ui.Key{Name: "left"})
|
||||
session.Select(PageID, "module", 2)
|
||||
editor.Refresh_session(session)
|
||||
got, err = editor.Read(session.Values())
|
||||
if err != nil || got.Module != "other" || got.Voice != "" || got.Language != "" || got.Rate != -10 {
|
||||
t.Fatal("dependent reset lost draft", got, err)
|
||||
}
|
||||
session.Select(PageID, "language", 1)
|
||||
editor.Refresh_session(session)
|
||||
session.Select(PageID, "voice", 1)
|
||||
got, err = editor.Read(session.Values())
|
||||
if err != nil || got.Voice != "Other" || got.Language != "fr" {
|
||||
t.Fatal(got, err)
|
||||
}
|
||||
session.Select(PageID, "driver", 1)
|
||||
editor.Refresh_session(session)
|
||||
if !session.Values()[PageID+".voice"].Hidden || session.Values()[PageID+".device"].Hidden {
|
||||
t.Fatal("wrong hardware fields")
|
||||
}
|
||||
session.Focus_control(PageID, "device")
|
||||
session.Handle(ui.Key{Ctrl: true, Text: "a"})
|
||||
session.Insert("/dev/fixture")
|
||||
session.Select(PageID, "baud", 3)
|
||||
got, err = editor.Read(session.Values())
|
||||
if err != nil || got.Driver != "dectalk" || got.Module != "" || got.Voice != "" || got.BaudRate != 9600 {
|
||||
t.Fatal(got, err)
|
||||
}
|
||||
}
|
||||
func TestEditorCancelAndGeneratedName(t *testing.T) {
|
||||
p := fixture_profile()
|
||||
editor := New_editor(p, Drivers(), nil)
|
||||
session, err := ui.New_session(editor.Form())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := editor.Read(session.Values())
|
||||
if err != nil || got.Name != "fixture Reading voice" {
|
||||
t.Fatal(got, err)
|
||||
}
|
||||
if !session.Handle(ui.Key{Name: "escape"}).Cancelled {
|
||||
t.Fatal("escape did not cancel")
|
||||
}
|
||||
if p.Name != "" {
|
||||
t.Fatal("editor mutated original")
|
||||
}
|
||||
tab := (Collection{}).Tab()
|
||||
if len(tab.Controls) != 1 || tab.Controls[0].Action != "add-profile" {
|
||||
t.Fatal(tab)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user