// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "reflect" "strings" "testing" "time" "git.stormux.org/storm/fenrir/internal/command" "git.stormux.org/storm/fenrir/internal/config" "git.stormux.org/storm/fenrir/internal/input" "git.stormux.org/storm/fenrir/internal/interaction" "git.stormux.org/storm/fenrir/internal/presentation" "git.stormux.org/storm/fenrir/internal/screen" ) func prompt_snapshot(lines ...string) screen.Snapshot { result := screen.Snapshot{Terminal: 1, Active: true, Rows: len(lines), Cols: 80, Y: len(lines) - 1} for _, line := range lines { cells := make([]rune, result.Cols) for index := range cells { cells[index] = ' ' } copy(cells, []rune(line)) result.Cells = append(result.Cells, cells...) } return result } func TestPromptRestorationDiscardsBatchAndPreservesModes(t *testing.T) { for _, mode := range []string{"sound", "speech", "missing sound", "automatic off", "muted", "menu"} { for _, line := range []string{"$", "Password:", "Worked for 19s • 7:46 PM", "Worked for 3m 35s • 8:05 PM", "CUSTOM READY"} { t.Run(mode+"/"+line, func(t *testing.T) { settings := config.Defaults() settings.Presentation.Sound.Enabled = mode != "speech" settings.Reading.Automatic = mode != "automatic off" settings.Prompt.ExactMatches = []string{"CUSTOM READY"} source := &fakeSource{snapshot: prompt_snapshot("old", "working"), events: make(chan error, 1)} keys := &fakeKeys{events: make(chan error, 1)} voice, sounds := &fakeVoice{}, &fakeSounds{} if mode == "missing sound" { sounds.missing = map[presentation.Cue]bool{presentation.SpeechOn: true} } reads, polls := 0, 0 source.afterRead = func() { reads++ if reads > 1 { keys.events <- nil } } keys.poll = func(_ bool, handle func(input.Decision)) error { polls++ switch polls { case 1: handle(input.Decision{Command: command.TempDisableSpeech, Interrupt: true, Keypress: true}) if mode == "muted" { handle(input.Decision{Command: command.Mute}) } if mode == "menu" { // Menu loading is asynchronous. Start the prompt trial // only after its opening announcement has been delivered. voice.onSay = func(text string) { if !strings.HasPrefix(text, "Fenrir Menu.") { return } voice.onSay = nil voice.messages, sounds.cues = nil, nil source.snapshot = prompt_snapshot("discard this backlog", "Worked for 19s • 7:4") source.events <- nil } handle(input.Decision{Command: command.FenrirMenu}) return nil } voice.messages, sounds.cues = nil, nil source.snapshot = prompt_snapshot("discard this backlog", "Worked for 19s • 7:4") source.events <- nil case 2: if len(voice.messages) != 0 || len(sounds.cues) != 0 { t.Fatal("incomplete line restored speech", voice.messages, sounds.cues) } source.snapshot = prompt_snapshot("also discard this batch", line) source.events <- nil case 3: var wantSpeech []string var wantCues []presentation.Cue if mode != "muted" && mode != "menu" { if mode == "speech" || mode == "missing sound" { wantSpeech = append(wantSpeech, "Speech restored") } else { wantCues = []presentation.Cue{presentation.SpeechOn} } if mode != "automatic off" { wantSpeech = append(wantSpeech, line) } } if !reflect.DeepEqual(voice.messages, wantSpeech) || !reflect.DeepEqual(sounds.cues, wantCues) { t.Fatal(voice.messages, sounds.cues, "want", wantSpeech, wantCues) } voice.messages, sounds.cues = nil, nil source.events <- nil // unchanged trigger must not repeat case 4: if len(voice.messages) != 0 || len(sounds.cues) != 0 { t.Fatal("repeated restoration", voice.messages, sounds.cues) } if mode == "menu" { handle(input.Decision{UI: &interaction.Key{Name: "escape"}}) } 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: settings, Sounds: sounds}); err != nil || polls != 4 { t.Fatal(err, polls) } }) } } }