// 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) } }