265 lines
8.9 KiB
Go
265 lines
8.9 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/fenrir/internal/command"
|
|
"git.stormux.org/storm/fenrir/internal/config"
|
|
"git.stormux.org/storm/fenrir/internal/i18n"
|
|
"git.stormux.org/storm/fenrir/internal/input"
|
|
"git.stormux.org/storm/fenrir/internal/screen"
|
|
"git.stormux.org/storm/fenrir/internal/scripts"
|
|
)
|
|
|
|
func TestTutorialBrowsesCustomUnboundAndPluginActions(t *testing.T) {
|
|
bindings := []input.Binding{{Keys: []string{"FENRIR", "Z"}, Command: command.Time, DoubleCommand: command.Date, TripleCommand: command.CurrentQuickEntry}}
|
|
installed := map[command.ID]scripts.Script{"script:example": {Name: "EXAMPLE", Keys: []string{"KEY_SCRIPT", "KEY_SHIFT", "KEY_U"}}}
|
|
mode := new_tutorial(bindings, installed, []string{"KEY_COMPOSE", "KEY_META"})
|
|
if got := mode.describe(command.Date); !strings.Contains(got, "2 times fenrir, z") || !strings.Contains(got, "presents the date") {
|
|
t.Fatal(got)
|
|
}
|
|
if got := mode.describe(command.CurrentQuickEntry); !strings.Contains(got, "3 times fenrir, z") {
|
|
t.Fatal(got)
|
|
}
|
|
if got := mode.describe("script:example"); !strings.Contains(got, "compose / meta, shift, u") {
|
|
t.Fatal(got)
|
|
}
|
|
press := func(code uint16) string {
|
|
t.Helper()
|
|
text, closed := mode.handle(input.LearnKey{Code: code, Bare: true})
|
|
if closed {
|
|
t.Fatal("unexpected exit")
|
|
}
|
|
return text
|
|
}
|
|
if press(input.KeySpace) != "Active actions" {
|
|
t.Fatal(mode.current())
|
|
}
|
|
first := press(input.KeyDown)
|
|
if first != mode.describe(command.CurrentQuickEntry) || press(input.KeySpace) != first {
|
|
t.Fatal(first)
|
|
}
|
|
if press(input.KeyUp) != mode.describe(command.Time) {
|
|
t.Fatal("previous did not wrap", mode.current())
|
|
}
|
|
if press(input.KeyRight) != "Unbound actions" {
|
|
t.Fatal(mode.current())
|
|
}
|
|
if got := press(input.KeyDown); !strings.Contains(got, "Shortcuts unbound") {
|
|
t.Fatal(got)
|
|
}
|
|
if press(input.KeyRight) != "Plugins" || press(input.KeyDown) != mode.describe("script:example") {
|
|
t.Fatal(mode.current())
|
|
}
|
|
if press(input.KeyRight) != "Active actions" || press(input.KeyLeft) != "Plugins" {
|
|
t.Fatal(mode.current())
|
|
}
|
|
empty := new_tutorial(nil, nil, nil)
|
|
empty.section = 2
|
|
if got, _ := empty.handle(input.LearnKey{Code: input.KeyDown, Bare: true}); got != "Plugins" {
|
|
t.Fatal(got)
|
|
}
|
|
if got, _ := mode.handle(input.LearnKey{Code: 88}); got != "f12. unbound" {
|
|
t.Fatal(got)
|
|
}
|
|
if got, _ := mode.handle(input.LearnKey{Code: input.KeyRightShift}); got != "rightshift. unbound" {
|
|
t.Fatal(got)
|
|
}
|
|
if _, closed := mode.handle(input.LearnKey{Code: input.KeyEsc, Held: []uint16{44}}); !closed {
|
|
t.Fatal("Escape did not exit")
|
|
}
|
|
}
|
|
|
|
func TestTutorialDispatcherBlocksActionsAndRestoresTyping(t *testing.T) {
|
|
bindings, _, err := input.Load_bindings([]string{"../../config/keyboard/desktop.toml"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
router, err := input.New_router(bindings)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := router.Configure_scripts([]string{"KEY_COMPOSE"}, []input.ScriptBinding{{ID: "script:example", Keys: []string{"KEY_SCRIPT", "KEY_U"}}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
keys, voice := &fakeKeys{}, &fakeVoice{}
|
|
keys.onCapture = router.Capture
|
|
var forwarded []uint16
|
|
keys.poll = func(_ bool, handle func(input.Decision)) error {
|
|
chord := func(codes ...uint16) {
|
|
for _, code := range codes {
|
|
decision := router.Key(0, code, 1, false, false)
|
|
if decision.Forward {
|
|
forwarded = append(forwarded, code)
|
|
}
|
|
handle(decision)
|
|
}
|
|
for _, code := range codes {
|
|
handle(router.Key(0, code, 0, false, false))
|
|
}
|
|
}
|
|
chord(input.KeyInsert, input.KeyH)
|
|
for _, code := range []uint16{input.KeyQ, input.KeyF4, input.KeyBackspace, input.KeyF, input.KeyKPEnter, 47} {
|
|
chord(input.KeyInsert, code)
|
|
}
|
|
chord(input.KeyInsert, input.KeyCtrl, 31) // save
|
|
compose, _ := input.Key_code("KEY_COMPOSE")
|
|
chord(compose, 22)
|
|
chord(88)
|
|
if !router.Captured() {
|
|
t.Fatal("described action closed tutorial")
|
|
}
|
|
chord(input.KeyEsc)
|
|
chord(30)
|
|
chord(input.KeyInsert, input.KeyQ)
|
|
return nil
|
|
}
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true}}
|
|
if err := run(context.Background(), source, keys, voice, nil, RunOptions{Bindings: bindings.Bindings, Settings: config.Defaults()}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if voice.muted || keys.passed || !keys.closed || !reflect.DeepEqual(keys.captures, []bool{true, false}) || !reflect.DeepEqual(forwarded, []uint16{30}) {
|
|
t.Fatal(voice.muted, keys, forwarded)
|
|
}
|
|
text := strings.Join(voice.messages, "\n")
|
|
for _, expected := range []string{tutorialEntry, tutorialExit, "exits Fenrir", "enables or disables speech", "Fenrir Menu", "Saves your current Fenrir settings", "f12. unbound"} {
|
|
if !strings.Contains(text, expected) {
|
|
t.Fatal("missing", expected, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTutorialCaptureFailureAndMenuExclusion(t *testing.T) {
|
|
keys, voice := &fakeKeys{}, &fakeVoice{}
|
|
ui := new_internal_ui(context.Background(), keys, voice)
|
|
keys.captureErr = errors.New("capture refused")
|
|
ui.open_tutorial(nil, nil, nil)
|
|
if ui.active || voice.messages[len(voice.messages)-1] != tutorialCaptureFailed {
|
|
t.Fatal(ui, voice.messages)
|
|
}
|
|
keys.captureErr = nil
|
|
ui.open_tutorial(nil, nil, nil)
|
|
keys.captureErr = errors.New("release refused")
|
|
ui.close()
|
|
if !ui.active || ui.tutorial == nil || voice.messages[len(voice.messages)-1] != tutorialReleaseFailed {
|
|
t.Fatal(ui, voice.messages)
|
|
}
|
|
keys.captureErr = nil
|
|
ui.close()
|
|
if ui.active || voice.messages[len(voice.messages)-1] != tutorialExit {
|
|
t.Fatal(ui, voice.messages)
|
|
}
|
|
ui.begin("Fenrir Menu")
|
|
count := len(keys.captures)
|
|
ui.open_tutorial(nil, nil, nil)
|
|
if ui.tutorial != nil || len(keys.captures) != count {
|
|
t.Fatal("tutorial replaced menu")
|
|
}
|
|
ui.close()
|
|
}
|
|
|
|
func TestTutorialClosesOnFocusOrVTHandoff(t *testing.T) {
|
|
for _, focusLoss := range []bool{false, true} {
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true}}
|
|
keys, voice := &fakeKeys{}, &fakeVoice{}
|
|
step := 0
|
|
keys.poll = func(_ bool, handle func(input.Decision)) error {
|
|
step++
|
|
if step == 1 {
|
|
handle(input.Decision{Command: command.Help})
|
|
if focusLoss {
|
|
source.snapshot.Active = false
|
|
} else {
|
|
source.snapshot.Terminal = 2
|
|
}
|
|
} else {
|
|
handle(input.Decision{Command: command.Quit})
|
|
}
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
err := Run(ctx, source, keys, voice, RunOptions{Settings: config.Defaults()})
|
|
cancel()
|
|
if err != nil || step != 2 || !reflect.DeepEqual(keys.captures, []bool{true, false}) || !strings.Contains(strings.Join(voice.messages, "\n"), tutorialExit) {
|
|
t.Fatal(focusLoss, err, step, keys.captures, voice.messages)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTutorialSuppressesBackgroundReading(t *testing.T) {
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true, Rows: 1, Cols: 5, Cells: []rune("first")}, events: make(chan error, 1)}
|
|
keys, voice := &fakeKeys{events: make(chan error, 1)}, &fakeVoice{}
|
|
step := 0
|
|
keys.poll = func(_ bool, handle func(input.Decision)) error {
|
|
step++
|
|
if step == 1 {
|
|
handle(input.Decision{Command: command.Help})
|
|
source.snapshot.Cells = []rune("later")
|
|
source.afterRead = func() { source.afterRead = nil; keys.events <- nil }
|
|
source.events <- nil
|
|
} else {
|
|
handle(input.Decision{Learn: &input.LearnKey{Code: input.KeyEsc}})
|
|
handle(input.Decision{Command: command.CurrentLine})
|
|
handle(input.Decision{Command: command.Quit})
|
|
}
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := run(ctx, source, keys, voice, nil, RunOptions{Settings: config.Defaults()}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
count := 0
|
|
for _, text := range voice.messages {
|
|
if text == "later" {
|
|
count++
|
|
}
|
|
}
|
|
if step != 2 || count != 1 {
|
|
t.Fatal("background reading escaped or review failed after exit", step, voice.messages)
|
|
}
|
|
}
|
|
|
|
func TestTutorialReusesLocalizedDescriptionsAndNamedFields(t *testing.T) {
|
|
root := t.TempDir()
|
|
dir := filepath.Join(root, "xx", "LC_MESSAGES")
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data := `msgid ""
|
|
msgstr ""
|
|
"Content-Type: text/plain; charset=UTF-8\n"
|
|
|
|
msgid "{command_name}, Shortcuts {command_shortcuts}, Description {command_description}"
|
|
msgstr "{command_description}: {command_shortcuts}: {command_name}"
|
|
|
|
msgid "presents the time"
|
|
msgstr "translated-time"
|
|
|
|
msgid "{} times "
|
|
msgstr "{} repetitions "
|
|
|
|
msgid "fenrir"
|
|
msgstr "translated-fenrir"
|
|
`
|
|
if err := os.WriteFile(filepath.Join(dir, "fenrir.po"), []byte(data), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
previous := i18n.Use(i18n.New(root, "xx"))
|
|
t.Cleanup(func() { i18n.Use(previous) })
|
|
mode := new_tutorial([]input.Binding{{Keys: []string{"FENRIR", "Z"}, Command: command.Date, DoubleCommand: command.Time}}, nil, nil)
|
|
if got := mode.describe(command.Time); got != "translated-time: 2 repetitions translated-fenrir, z: time" {
|
|
t.Fatal(got)
|
|
}
|
|
}
|