Files

130 lines
4.7 KiB
Go

// Fenrir console screen reader.
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
package app
import (
"context"
"os"
"path/filepath"
"reflect"
"slices"
"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/screen"
"git.stormux.org/storm/fenrir/internal/scripts"
)
func TestHungScriptDoesNotBlockReviewOrReaderShutdown(t *testing.T) {
root := t.TempDir()
path, marker := filepath.Join(root, "script.sh"), filepath.Join(root, "started")
if err := os.WriteFile(path, []byte("#!/bin/sh\necho started > '"+marker+"'\nsleep 30\n"), 0700); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
runner := scripts.New_runner(ctx, scripts.Limits{Workers: 1, Queue: 1, OutputBytes: 1024}, nil)
defer runner.Close()
script := scripts.Script{ID: "script:hang", Path: path}
source := &fakeSource{snapshot: screen.Snapshot{Active: true, Terminal: 1, Rows: 1, Cols: 5, Cells: []rune("alpha")}}
keys := &fakeKeys{events: make(chan error, 1)}
voice := &fakeVoice{}
polls := 0
keys.poll = func(_ bool, handle func(input.Decision)) error {
polls++
if polls == 1 {
handle(input.Decision{Command: script.ID, Interrupt: true})
} else {
handle(input.Decision{Command: command.CurrentLine, Interrupt: true})
handle(input.Decision{Command: command.Quit})
}
return nil
}
go func() {
ticker := time.NewTicker(5 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if _, err := os.Stat(marker); err == nil {
keys.events <- nil
return
}
}
}
}()
started := time.Now()
err := run(ctx, source, keys, voice, nil, RunOptions{Settings: config.Defaults(), Scripts: map[command.ID]scripts.Script{script.ID: script}, ScriptRunner: runner})
if err != nil || ctx.Err() != nil || time.Since(started) > time.Second || !keys.closed || !slices.Contains(voice.messages, "alpha") {
t.Fatal("script stalled reader", err, ctx.Err(), voice.messages)
}
}
func TestScriptResultsUseMainLoopStderrThenStdout(t *testing.T) {
path := filepath.Join(t.TempDir(), "script.sh")
if err := os.WriteFile(path, []byte("#!/bin/sh\necho failure >&2\necho result\nexit 7\n"), 0700); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
runner := scripts.New_runner(ctx, scripts.Limits{Workers: 1, Queue: 1, OutputBytes: 1024}, nil)
defer runner.Close()
script := scripts.Script{ID: "script:result", Path: path}
source := &fakeSource{snapshot: screen.Snapshot{Active: true, Terminal: 1, Rows: 1, Cols: 5, Cells: []rune("alpha")}}
keys := &fakeKeys{events: make(chan error, 1)}
voice := &fakeVoice{onSay: func(text string) {
if text == "result\n" {
keys.events <- nil
}
}}
polls := 0
keys.poll = func(_ bool, handle func(input.Decision)) error {
polls++
if polls == 1 {
handle(input.Decision{Command: script.ID})
} else {
handle(input.Decision{Command: command.Quit})
}
return nil
}
err := run(ctx, source, keys, voice, nil, RunOptions{Settings: config.Defaults(), Scripts: map[command.ID]scripts.Script{script.ID: script}, ScriptRunner: runner})
if err != nil || ctx.Err() != nil {
t.Fatal(err, ctx.Err())
}
if len(voice.messages) < 2 || !reflect.DeepEqual(voice.messages[len(voice.messages)-2:], []string{"failure\n", "result\n"}) {
t.Fatal(voice.messages)
}
}
func TestRemoteSettingsAreAtomicAndUsePythonNames(t *testing.T) {
base := config.Defaults()
updated, err := remote_settings(base, "set speech#enabled=False;speech#auto_read_incoming=False,keyboard#word_echo=True")
if err != nil || updated.Presentation.Speech.Enabled || updated.Reading.Automatic || !updated.Keyboard.WordEcho {
t.Fatal(updated, err)
}
failed, err := remote_settings(base, "SET speech#enabled=False;unknown#setting=True")
if err == nil || !reflect.DeepEqual(failed, base) {
t.Fatal("partial settings applied", failed, err)
}
}