Files
fenrir/internal/app/tutorial_test.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)
}
}