351 lines
12 KiB
Go
351 lines
12 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>.
|
|
|
|
//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 TestPTYReverseSearchPromptReplacement(t *testing.T) {
|
|
for _, promptWidth := range []int{2, 23, 24, 25, 26, 40} {
|
|
t.Run(fmt.Sprint(promptWidth), func(t *testing.T) {
|
|
emulator, err := new_emulator(3, 80)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer emulator.close()
|
|
engine := reader.New()
|
|
emulator.feed([]byte(strings.Repeat("p", promptWidth-2) + "$ "))
|
|
engine.Update(emulator.snapshot(true, false))
|
|
// Bash Readline repaints the prompt and clears any remaining tail.
|
|
searchPrompt := "(reverse-i-search)`': "
|
|
emulator.feed([]byte("\r" + searchPrompt + "\x1b[K"))
|
|
if got := engine.Update(emulator.snapshot(true, false)); len(got) != 1 || got[0].Character != nil || strings.TrimRight(got[0].Text, " ") != strings.TrimRight(searchPrompt, " ") {
|
|
t.Fatalf("search prompt: got %+v, want %q", got, searchPrompt)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|