After commit c018f5fd29 Fenrir is being switched over to go. If you do not want to be part of the testing process, please switch to master.

This commit is contained in:
Storm Dragon committed 2026-10-27 20:30:55 -04:00
1 parent c018f5fd29
commit 0e50de4c76
1047 files changed
+58364 -63747

No files matched your search

+329
View File
@@ -0,0 +1,329 @@
// 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>.
//go:build linux && cgo
package terminal
import (
"fmt"
"reflect"
"strings"
"testing"
"time"
"git.stormux.org/storm/fenrir/internal/command"
"git.stormux.org/storm/fenrir/internal/input"
"git.stormux.org/storm/fenrir/internal/presentation"
"git.stormux.org/storm/fenrir/internal/reader"
"git.stormux.org/storm/fenrir/internal/screen"
)
func TestPTYAttributeOnlyHighlightMovement(t *testing.T) {
emulator, err := new_emulator(4, 30)
if err != nil {
t.Fatal(err)
}
defer emulator.close()
emulator.feed([]byte("\x1b[7mFirst message\x1b[0m\r\nSecond message\r\nThird message\x1b[4;1H"))
before := emulator.snapshot(true, false)
if len(before.Attributes) != len(before.Cells) || before.Attributes[0] == before.Attributes[30] {
t.Fatal("reverse video attributes were discarded")
}
engine := reader.New()
engine.Configure_tracking("highlight")
engine.Update(before)
emulator.feed([]byte("\x1b[1;1HFirst message\x1b[2;1H\x1b[7mSecond message\x1b[0m\x1b[4;1H"))
after := emulator.snapshot(true, false)
if !reflect.DeepEqual(before.Cells, after.Cells) || before.X != after.X || before.Y != after.Y {
t.Fatal("fixture changed text or hardware cursor")
}
if got := engine.Update(after); !reflect.DeepEqual(got, []presentation.Message{{Text: "Second message"}}) {
t.Fatal("PTY selection was silent or read deselected text", got)
}
if before.Attributes[0] == before.Attributes[30] {
t.Fatal("snapshot attribute storage was reused")
}
// Colours as well as style changes must survive conversion, including RGB.
emulator.feed([]byte("\x1b[1;1H\x1b[1;38;2;12;34;56;48;2;65;43;21mColor\x1b[0m"))
colored := emulator.snapshot(true, false)
want := screen.Attribute{Foreground: 0x0c2238, Background: 0x412b15, Style: 1}
if colored.Attributes[0] != want {
t.Fatal("RGB/style attributes", colored.Attributes[0], want)
}
}
func TestPTYUnicodeSplitSequencesAndSharedReview(t *testing.T) {
emulator, err := new_emulator(4, 20)
if err != nil {
t.Fatal(err)
}
defer emulator.close()
bytes := []byte("alpha\r\né界\r\nthird\x1b[2;2H")
// Split every UTF-8 character and escape sequence across reads.
for _, value := range bytes {
emulator.feed([]byte{value})
}
snapshot := emulator.snapshot(true, false)
if snapshot.Text(0) != "alpha" || snapshot.Text(1) != "é界" || snapshot.X != 1 || snapshot.Y != 1 {
t.Fatalf("wrong PTY screen: %+v, %q", snapshot, snapshot.Cells)
}
engine := reader.New()
engine.Update(snapshot)
for _, fixture := range []struct {
action command.ID
want string
}{
{command.CurrentLine, "é界"}, {command.PreviousLine, "alpha"},
{command.CurrentWord, "alpha"}, {command.CurrentChar, "a"},
{command.ReadScreen, "alpha\né界\nthird"},
} {
if got := engine.Command(fixture.action).Text; got != fixture.want {
t.Fatalf("shared %s: got %q, want %q", fixture.action, got, fixture.want)
}
}
}
func TestPTYAlternateScreenResizeAndScroll(t *testing.T) {
emulator, err := new_emulator(3, 12)
if err != nil {
t.Fatal(err)
}
defer emulator.close()
engine := reader.New()
emulator.feed([]byte("shell\x1b[?1049h\x1b[2J\x1b[Heditor"))
alternate := emulator.snapshot(true, false)
engine.Update(alternate)
engine.Command(command.NextLine)
if got := emulator.snapshot(true, false).Text(0); got != "editor" {
t.Fatal("alternate screen", got)
}
emulator.feed([]byte("\x1b[?1049l"))
main := emulator.snapshot(true, false)
if got := main.Text(0); got != "shell" {
t.Fatal("main screen lost", got)
}
if main.Generation == alternate.Generation {
t.Fatal("alternate buffer reused its screen generation")
}
engine.Update(main)
if got := engine.Command(command.CurrentLine).Text; got != "shell" {
t.Fatal("review was not reset for the restored buffer", got)
}
emulator.resize(4, 20)
snapshot := emulator.snapshot(true, false)
if snapshot.Rows != 4 || snapshot.Cols != 20 || len(snapshot.Cells) != 80 || snapshot.Text(0) != "shell" {
t.Fatal("resize", snapshot)
}
emulator.feed([]byte("\x1b[2J\x1b[Hone\r\ntwo\r\nthree\r\nfour\r\nfive"))
snapshot = emulator.snapshot(true, false)
var lines []string
for row := range snapshot.Rows {
lines = append(lines, snapshot.Text(row))
}
if !reflect.DeepEqual(lines, []string{"two", "three", "four", "five"}) {
t.Fatal("scroll", lines)
}
}
func TestPTYCarriageReturnEraseAndCursorEcho(t *testing.T) {
emulator, err := new_emulator(3, 20)
if err != nil {
t.Fatal(err)
}
defer emulator.close()
engine := reader.New()
engine.Update(emulator.snapshot(true, false))
emulator.feed([]byte("hello"))
if got := engine.Update(emulator.snapshot(true, false)); !reflect.DeepEqual(got, []presentation.Message{{Text: "hello"}}) {
t.Fatal("shared automatic reading", got)
}
emulator.feed([]byte("\b \b"))
if got := engine.Update(emulator.snapshot(true, false)); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('o')}) {
t.Fatal("shared deletion echo", got)
}
emulator.feed([]byte("\rnew\x1b[K"))
if got := emulator.snapshot(true, false).Text(0); got != "new" {
t.Fatal("repaint/erase", got)
}
emulator.feed([]byte("\x1b[2;1Hnext\x1b[1;1H"))
engine.Update(emulator.snapshot(true, false))
emulator.feed([]byte("\x1b[2;1H"))
if got := engine.Update(emulator.snapshot(true, false)); !reflect.DeepEqual(got, []presentation.Message{{Text: "next"}}) {
t.Fatal("shared cursor echo", got)
}
}
func TestPTYChatScrollRegionPreservesFixedRows(t *testing.T) {
emulator, err := new_emulator(7, 30)
if err != nil {
t.Fatal(err)
}
defer emulator.close()
engine := reader.New()
emulator.feed([]byte("header\r\nold\r\nkeep one\r\nkeep two\r\nlast\r\nstatus\r\n> draft\x1b[2;5r\x1b[7;8H"))
engine.Update(emulator.snapshot(true, false))
// DECSTBM + line feed shifts only the chat area, as in irssi. The
// application restores the cursor to its unchanged input line afterward.
emulator.feed([]byte("\x1b[5;1H\n\rnew message\x1b[7;8H"))
if got := engine.Update(emulator.snapshot(true, false)); !reflect.DeepEqual(got, []presentation.Message{{Text: "new message"}}) {
t.Fatal(got)
}
if got := emulator.snapshot(true, false); got.Text(0) != "header" || got.Text(5) != "status" || got.Text(6) != "> draft" {
t.Fatal(got)
}
}
func TestTerminalEnvironmentValidation(t *testing.T) {
t.Setenv("WINDOWID", "invalid")
if _, err := Open(nil, nil, input.Router{}); err == nil || !strings.Contains(err.Error(), "WINDOWID") {
t.Fatal("invalid target", err)
}
t.Setenv("WINDOWID", "123")
t.Setenv("SHELL", "")
if _, err := Open(nil, nil, input.Router{}); err == nil || !strings.Contains(err.Error(), "SHELL") {
t.Fatal("missing shell", err)
}
for _, size := range [][2]int{{0, 80}, {25, 0}, {1001, 80}, {1000, 2000}} {
if emulator, err := new_emulator(size[0], size[1]); err == nil {
emulator.close()
t.Fatal("accepted excessive geometry", size)
}
}
}
func TestPTYTypingEchoAtMarginsAndWithWideCharacters(t *testing.T) {
for _, enabled := range []bool{false, true} {
emulator, err := new_emulator(3, 5)
if err != nil {
t.Fatal(err)
}
engine := reader.New()
engine.Configure_echo(enabled, false, false)
engine.Update(emulator.snapshot(true, false))
for _, char := range "abcdefghijklmnop q 界r" {
engine.Note_input(true, true, char == ' ')
emulator.feed([]byte(string(char)))
var want []presentation.Message
if enabled && char != ' ' {
want = []presentation.Message{presentation.Character_message(char)}
}
if got := engine.Update(emulator.snapshot(true, false)); !reflect.DeepEqual(got, want) {
t.Fatalf("echo=%v, char=%q: got %v, want %v", enabled, char, got, want)
}
}
emulator.close()
}
}
func TestProgressAfterTerminalScroll(t *testing.T) {
for _, mode := range []string{"percent", "spinner", "scroll region"} {
t.Run(mode, func(t *testing.T) {
emulator, err := new_emulator(79, 318)
if err != nil {
t.Fatal(err)
}
defer emulator.close()
engine := reader.New()
engine.Configure_progress(true)
// Fill the screen, then produce newline-terminated updates exactly as a
// script does at the bottom of the viewport. A fixed status row exercises
// a terminal multiplexer-style scrolling region too.
if mode == "scroll region" {
emulator.feed([]byte("\x1b[1;78r\x1b[79;1Hstatus\x1b[78;1H"))
}
for index := 0; index < 90; index++ {
emulator.feed([]byte(fmt.Sprintf("previous output %03d\r\n", index)))
}
engine.Update(emulator.snapshot(true, false))
now := time.Now()
for index := 1; index <= 3; index++ {
text := fmt.Sprintf("Progress %d%%\r\n", index*10)
want := float64(index * 10)
if mode == "spinner" {
text = string([]rune("⠋⠙⠹")[index-1]) + " Preparing files\r\n"
want = 50
}
emulator.feed([]byte(text))
engine.Update(emulator.snapshot(true, false))
if value, ok := engine.Next_progress(now.Add(time.Duration(index) * 2 * time.Second)); !ok || value != want {
t.Fatalf("scroll update %d: value=%v accepted=%v", index, value, ok)
}
}
})
}
}
func TestProgressIgnoresTerminalBackspaceAndDelete(t *testing.T) {
for _, key := range []string{"backspace", "delete"} {
t.Run(key, func(t *testing.T) {
emulator, err := new_emulator(3, 40)
if err != nil {
t.Fatal(err)
}
defer emulator.close()
engine := reader.New()
engine.Configure_progress(true)
emulator.feed([]byte("type 2% abc"))
if key == "delete" {
emulator.feed([]byte("\x1b[1;11H"))
}
engine.Update(emulator.snapshot(true, false))
engine.Note_input(true, false, false)
engine.Note_deletion()
if key == "backspace" {
emulator.feed([]byte("\b \b"))
} else {
emulator.feed([]byte("\x1b[P"))
}
output := engine.Update(emulator.snapshot(true, false))
if value, ok := engine.Next_progress(time.Now()); ok {
t.Fatal("deletion beeped", value)
}
if key == "backspace" && !reflect.DeepEqual(output, []presentation.Message{presentation.Character_message('c')}) {
t.Fatal("deletion feedback lost", output)
}
emulator.feed([]byte("\x1b[2;1HDownload 30%\x1b[1;11H"))
engine.Update(emulator.snapshot(true, false))
if value, ok := engine.Next_progress(time.Now()); !ok || value != 30 {
t.Fatal("background progress stopped", value, ok)
}
})
}
}
func TestPTYAttributesReportStylesPaletteRGBAndDefaults(t *testing.T) {
emulator, err := new_emulator(2, 30)
if err != nil {
t.Fatal(err)
}
defer emulator.close()
engine := reader.New()
engine.Configure_attributes("speech", "fenrirFGColor|fenrirBGColor|fenrirBold|fenrirUnderline|fenrirItalics|fenrirBlink|fenrirReverse|fenrirStrikethrough")
for _, trial := range []struct{ sgr, want string }{
{"1;4;3;5;7;9;31;44", "red|blue|bold|underline|italic|blink|reverse|strikethrough"},
{"38;5;196;48;5;232", "#ff0000|#080808||||||"},
{"38;2;12;34;56;48;2;65;43;21", "#0c2238|#412b15||||||"},
{"0", "default|default||||||"},
} {
emulator.feed([]byte("\x1b[H\x1b[0m\x1b[" + trial.sgr + "mX\x1b[0m\x1b[H"))
engine.Update(emulator.snapshot(true, false))
if got := engine.Command(command.AttributeCursor).Text; got != trial.want {
t.Fatal(trial, got)
}
}
}