349 lines
11 KiB
Go
349 lines
11 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package review
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.stormux.org/storm/fenrir/internal/command"
|
|
"git.stormux.org/storm/fenrir/internal/screen"
|
|
)
|
|
|
|
func table_fixture(lines ...string) screen.Snapshot {
|
|
width := 1
|
|
for _, line := range lines {
|
|
width = max(width, len([]rune(line)))
|
|
}
|
|
s := screen.Snapshot{Terminal: 2, Active: true, Rows: len(lines), Cols: width}
|
|
for _, text := range lines {
|
|
line := []rune(text)
|
|
s.Cells = append(s.Cells, line...)
|
|
s.Cells = append(s.Cells, []rune(strings.Repeat(" ", width-len(line)))...)
|
|
}
|
|
return s
|
|
}
|
|
|
|
var freeTable = []string{
|
|
" total used free shared buff/cache available",
|
|
"Mem: 31Gi 9.5Gi 9.7Gi 3.0Gi 15Gi 21Gi",
|
|
"Swap: 15Gi 46Mi 15Gi",
|
|
}
|
|
|
|
func TestTableFreeAutomaticAndManualHeaders(t *testing.T) {
|
|
for _, manual := range []bool{false, true} {
|
|
r := Review{}
|
|
r.Update(table_fixture(freeTable...))
|
|
r.Set_table_mode(true)
|
|
r.Execute(command.CurrentLine)
|
|
if manual {
|
|
r.Set_mark()
|
|
}
|
|
r.Execute(command.NextLine)
|
|
for col, want := range []string{"Mem: Column 1", "31Gi total", "9.5Gi used", "9.7Gi free", "3.0Gi shared", "15Gi buff/cache", "21Gi available"} {
|
|
id := command.CurrentWord
|
|
if col != 0 {
|
|
id = command.NextWord
|
|
}
|
|
if got := r.Execute(id).Text; got != want {
|
|
t.Fatalf("manual=%v col=%d got %q want %q", manual, col, got, want)
|
|
}
|
|
}
|
|
r.Execute(command.NextLine)
|
|
if got := r.Execute(command.CurrentWord).Text; got != "blank available" {
|
|
t.Fatal("missing trailing fields shifted columns", got)
|
|
}
|
|
r.Execute(command.LineStart)
|
|
if got := r.Execute(command.NextWord).Text; got != "15Gi total" {
|
|
t.Fatal(got)
|
|
}
|
|
if got := r.Execute(command.SpellWord); got.Spelling == nil || got.Spelling.Text != "15Gi" {
|
|
t.Fatal("spelling used a different layout", got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTableLinuxCommandLayouts(t *testing.T) {
|
|
for _, trial := range []struct {
|
|
name string
|
|
lines []string
|
|
wants []string
|
|
}{
|
|
{"df", []string{
|
|
"Filesystem Size Used Avail Use% Mounted on",
|
|
"/dev/example1 491G 244G 222G 53% /",
|
|
}, []string{"/dev/example1 Filesystem", "491G Size", "244G Used", "222G Avail", "53% Use%", "/ Mounted on"}},
|
|
{"lsblk", []string{
|
|
"NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS",
|
|
"sda 8:0 0 64G 0 disk",
|
|
"└─example01 8:1 0 64G 0 part /media/example",
|
|
}, []string{"sda NAME", "8:0 MAJ:MIN", "0 RM", "64G SIZE", "0 RO", "disk TYPE", "blank MOUNTPOINTS"}},
|
|
{"ps", []string{
|
|
"USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND",
|
|
"Username 123 0.0 0.1 12345 1234 pts/1 S 10:30 0:00 example --flag a,b",
|
|
}, []string{"Username USER", "123 PID", "0.0 %CPU", "0.1 %MEM", "12345 VSZ", "1234 RSS", "pts/1 TTY", "S STAT", "10:30 START", "0:00 TIME", "example --flag a,b COMMAND"}},
|
|
{"delimited", []string{"Device|Used|Free", "example|12|34"}, []string{"example Device", "12 Used", "34 Free"}},
|
|
{"ss", []string{
|
|
"State Recv-Q Send-Q Local Address:Port Peer Address:Port",
|
|
"LISTEN 0 128 127.0.0.1:1234 0.0.0.0:*",
|
|
}, []string{"LISTEN State", "0 Recv-Q", "128 Send-Q", "127.0.0.1:1234 Local Address:Port", "0.0.0.0:* Peer Address:Port"}},
|
|
{"vmstat", []string{
|
|
"procs --------memory-------",
|
|
" r b swpd free buff",
|
|
" 1 0 12345 678901 23456",
|
|
}, []string{"1 r", "0 b", "12345 swpd", "678901 free", "23456 buff"}},
|
|
} {
|
|
t.Run(trial.name, func(t *testing.T) {
|
|
var r Review
|
|
s := table_fixture(trial.lines...)
|
|
s.Y = 1
|
|
if trial.name == "vmstat" {
|
|
s.Y = 2
|
|
}
|
|
r.Update(s)
|
|
r.Set_table_mode(true)
|
|
for col, want := range trial.wants {
|
|
id := command.CurrentWord
|
|
if col != 0 {
|
|
id = command.NextWord
|
|
}
|
|
if got := r.Execute(id).Text; got != want {
|
|
t.Fatalf("col %d: got %q want %q", col, got, want)
|
|
}
|
|
}
|
|
if trial.name == "lsblk" {
|
|
r.Execute(command.NextLine)
|
|
if got := r.Execute(command.LineStart).Text; got != "└─example01 NAME" {
|
|
t.Fatal("longer row split by previous row's gaps", got)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTableClearHeadersOverrideAndModeReset(t *testing.T) {
|
|
var r Review
|
|
r.Update(table_fixture(freeTable...))
|
|
r.Set_table_mode(true)
|
|
r.Execute(command.NextLine)
|
|
if got := r.Execute(command.NextWord).Text; got != "31Gi total" {
|
|
t.Fatal(got)
|
|
}
|
|
r.Clear_marks()
|
|
if got := r.Execute(command.CurrentWord).Text; got != "31Gi Column 2" {
|
|
t.Fatal("clear reassigned headers", got)
|
|
}
|
|
r.Execute(command.LineEnd)
|
|
r.Execute(command.NextLine)
|
|
if got := r.Execute(command.CurrentWord).Text; got != "blank Column 7" {
|
|
t.Fatal("clearing headers discarded column geometry", got)
|
|
}
|
|
r.Execute(command.PreviousLine)
|
|
r.Execute(command.LineStart)
|
|
r.Execute(command.NextWord)
|
|
r.Execute(command.PreviousLine)
|
|
if got := r.Set_mark().Text; got != "header row set" {
|
|
t.Fatal(got)
|
|
}
|
|
r.Execute(command.NextLine)
|
|
if got := r.Execute(command.CurrentWord).Text; got != "31Gi total" {
|
|
t.Fatal("manual override after clear", got)
|
|
}
|
|
r.Clear_marks()
|
|
r.Set_table_mode(false)
|
|
r.Set_table_mode(true)
|
|
if got := r.Execute(command.CurrentWord).Text; got != "31Gi total" {
|
|
t.Fatal("mode reset did not restore automatic headers", got)
|
|
}
|
|
}
|
|
|
|
func TestTableAutomaticHeadersStayInOutputBlock(t *testing.T) {
|
|
for _, lines := range [][]string{
|
|
{"$ example", "12 34", "56 78"},
|
|
{"Some ordinary prose", "More ordinary prose"},
|
|
{"Name Used Free", "one 12 34", "", "56 78"},
|
|
{"Name Used Free", "one 12 34", "$ next", "56 78"},
|
|
} {
|
|
var r Review
|
|
s := table_fixture(lines...)
|
|
s.Y = len(lines) - 1
|
|
r.Update(s)
|
|
r.Set_table_mode(true)
|
|
r.Execute(command.CurrentWord)
|
|
if len(r.tableHeaders) != 0 {
|
|
t.Fatal("guessed an unrelated header", lines, r.tableHeaders)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTableAutomaticHeadersFollowNewOutput(t *testing.T) {
|
|
var r Review
|
|
after := table_fixture(append(append([]string{"$ free -h"}, freeTable...), "$ ")...)
|
|
before := after
|
|
before.Cells = []rune(strings.Repeat(" ", len(after.Cells)))
|
|
copy(before.Cells, []rune("$ free -h"))
|
|
before.Y = 1
|
|
r.Update(before)
|
|
r.Set_table_mode(true)
|
|
after.Y = 4
|
|
r.Update(after)
|
|
if got := r.Execute(command.CurrentWord).Text; got != "Mem: Column 1" {
|
|
t.Fatal("new table did not place review at the first cell", got)
|
|
}
|
|
if got := r.Execute(command.NextWord).Text; got != "31Gi total" {
|
|
t.Fatal("new output not recognized", got)
|
|
}
|
|
// Replace the output without changing screen identity or dimensions.
|
|
for index := range after.Cells {
|
|
after.Cells[index] = ' '
|
|
}
|
|
r.Update(after)
|
|
if got := r.Execute(command.CurrentWord).Text; got != "no table data" || len(r.tableHeaders) != 0 {
|
|
t.Fatal("stale automatic headers", got, r.tableHeaders)
|
|
}
|
|
}
|
|
|
|
func TestTableReviewLandingOnlyOnEntry(t *testing.T) {
|
|
var r Review
|
|
s := table_fixture(append(append([]string{"$ free -h"}, freeTable...), "$ ")...)
|
|
s.Y, s.X = 4, 2
|
|
r.Update(s)
|
|
r.Set_table_mode(true)
|
|
if got := r.Execute(command.CurrentWord).Text; got != "Mem: Column 1" || r.x != 0 || r.y != 2 || r.tableColumn != 0 {
|
|
t.Fatal("wrong initial cell", got, r.x, r.y, r.tableColumn)
|
|
}
|
|
if r.screen.X != 2 || r.screen.Y != 4 {
|
|
t.Fatal("moved application cursor")
|
|
}
|
|
r.Execute(command.NextLine)
|
|
r.Execute(command.NextWord)
|
|
r.Update(s)
|
|
if got := r.Execute(command.CurrentWord).Text; got != "15Gi total" || r.y != 3 {
|
|
t.Fatal("existing review was pulled back to the first row", got, r.y)
|
|
}
|
|
}
|
|
|
|
func TestTableReviewLandingGuards(t *testing.T) {
|
|
for _, trial := range []string{"cursor", "highlight", "cleared", "unrelated output", "manual"} {
|
|
t.Run(trial, func(t *testing.T) {
|
|
var r Review
|
|
lines := append(append([]string{}, freeTable...), "$ ")
|
|
if trial == "unrelated output" {
|
|
lines = append(lines[:3], "unrelated output", "$ ")
|
|
}
|
|
s := table_fixture(lines...)
|
|
s.Y = len(lines) - 1
|
|
r.Update(s)
|
|
r.Set_table_mode(trial != "cursor" && trial != "highlight")
|
|
if trial == "cleared" {
|
|
r.Clear_marks()
|
|
}
|
|
if trial == "manual" {
|
|
r.Execute(command.Top)
|
|
r.Set_mark()
|
|
r.Resume_tracking()
|
|
}
|
|
r.Execute(command.CurrentWord)
|
|
if r.y != s.Y {
|
|
t.Fatal("unexpected review jump", trial, r.y, s.Y)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTableReviewLandingFindsLatestOutput(t *testing.T) {
|
|
var r Review
|
|
s := table_fixture("Name|Used|Free", "old|12|34", "$ next", "Name|Used|Free", "new|56|78", "$ ")
|
|
s.Y = 5
|
|
r.Update(s)
|
|
r.Set_table_mode(true)
|
|
if got := r.Execute(command.CurrentWord).Text; got != "new Name" || r.y != 4 {
|
|
t.Fatal("landed on an older table", got, r.y)
|
|
}
|
|
}
|
|
|
|
func TestTableManualHeadersOverrideAutomaticAndClear(t *testing.T) {
|
|
var r Review
|
|
r.Update(table_fixture("Name|Used|Free", "example|12|34", "Custom|First|Second", "other||56"))
|
|
r.Set_table_mode(true)
|
|
r.Execute(command.NextLine)
|
|
if got := r.Execute(command.NextWord).Text; got != "12 Used" {
|
|
t.Fatal(got)
|
|
}
|
|
r.Execute(command.NextLine)
|
|
r.Set_mark()
|
|
r.Execute(command.NextLine)
|
|
if got := r.Execute(command.CurrentWord).Text; got != "blank First" {
|
|
t.Fatal("manual headers did not replace automatic", got)
|
|
}
|
|
r.Clear_marks()
|
|
if got := r.Execute(command.CurrentWord).Text; got != "blank Column 2" {
|
|
t.Fatal(got)
|
|
}
|
|
r.Set_table_mode(false)
|
|
if got := r.Set_mark().Text; got != "set mark" {
|
|
t.Fatal("ordinary marks broken", got)
|
|
}
|
|
}
|
|
|
|
func TestTableEmptyInteriorFieldAndTextOnlyManualHeader(t *testing.T) {
|
|
var r Review
|
|
r.Update(table_fixture("Name Used Free", "alpha 12 34", "beta 56"))
|
|
r.Set_table_mode(true)
|
|
r.Execute(command.NextLine)
|
|
r.Execute(command.NextWord)
|
|
r.Execute(command.NextLine)
|
|
if got := r.Execute(command.CurrentWord).Text; got != "blank Used" {
|
|
t.Fatal("empty interior column shifted", got)
|
|
}
|
|
if got := r.Execute(command.NextWord).Text; got != "56 Free" {
|
|
t.Fatal(got)
|
|
}
|
|
r = Review{}
|
|
r.Update(table_fixture("Name Description", "alpha two words"))
|
|
r.Set_table_mode(true)
|
|
r.Execute(command.CurrentWord)
|
|
if len(r.tableHeaders) != 0 {
|
|
t.Fatal("text-only headers should require selection")
|
|
}
|
|
r.Set_mark()
|
|
r.Execute(command.NextLine)
|
|
if got := r.Execute(command.NextWord).Text; got != "two words Description" {
|
|
t.Fatal("manual text-only table", got)
|
|
}
|
|
}
|
|
|
|
func TestTableAutomaticHeaderSessionResetAndReplacement(t *testing.T) {
|
|
for _, change := range []string{"terminal", "generation", "resize"} {
|
|
t.Run(change, func(t *testing.T) {
|
|
var r Review
|
|
s := table_fixture("Name|Used|Free", "example|12|34")
|
|
s.Y = 1
|
|
r.Update(s)
|
|
r.Set_table_mode(true)
|
|
r.Execute(command.NextWord)
|
|
r.Clear_marks()
|
|
switch change {
|
|
case "terminal":
|
|
s.Terminal++
|
|
case "generation":
|
|
s.Generation++
|
|
case "resize":
|
|
s = table_fixture("Name|Used|Free", "example|12|34", "")
|
|
s.Y = 1
|
|
}
|
|
r.Update(s)
|
|
if got := r.Execute(command.NextWord).Text; got != "12 Used" {
|
|
t.Fatal("new session retained header suppression", got)
|
|
}
|
|
// Same dimensions and session: changed headings must be rediscovered.
|
|
copy(s.Cells, []rune("Name|Busy|Idle"))
|
|
r.Update(s)
|
|
if got := r.Execute(command.CurrentWord).Text; got != "12 Busy" {
|
|
t.Fatal("reused stale automatic header", got)
|
|
}
|
|
})
|
|
}
|
|
}
|