v0.33.0 tui tap for live view and console enhancements
This commit is contained in:
1 parent
a1e9d21d06
commit
87f5576ccf
41 files changed
+4606
-449
No files matched your search
@@ -0,0 +1,569 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/lixenwraith/logwisp/internal/compose"
|
||||
"github.com/lixenwraith/logwisp/internal/core"
|
||||
|
||||
"github.com/lixenwraith/terminal"
|
||||
ui "github.com/lixenwraith/terminal/tui"
|
||||
)
|
||||
|
||||
// tapped is an entry from a pipeline's source as its flow left it: dropped at
|
||||
// stage and index, or passed with its payload when stage is ""
|
||||
func tapped(pipe, source, stage string, index int, msg string) core.Tapped {
|
||||
r := core.Tapped{Pipeline: pipe, Source: source, Stage: stage, Index: index,
|
||||
Event: core.TransportEvent{Entry: core.LogEntry{Time: time.Unix(1, 0), Source: source, Message: msg}}}
|
||||
if stage == "" {
|
||||
r.Event.Payload = []byte(msg + "\n")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// watching is a screen on c in look whose view takes the keys, drawn once so
|
||||
// its rows are built for where the canvas cursor is
|
||||
func watching(c *compose.Composition, l look) *app {
|
||||
a := newApp(c, l, unicodeFont)
|
||||
a.viewing = true
|
||||
render(a, 80, 24)
|
||||
return a
|
||||
}
|
||||
|
||||
// shown is the messages of the rows the view shows
|
||||
func shown(a *app) []string {
|
||||
var out []string
|
||||
for _, seq := range a.view.shown {
|
||||
out = append(out, a.view.rows[seq-a.view.rows[0].seq].Message)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cursorRow is the screen row the view's pointer is on
|
||||
func cursorRow(rows []string) (int, string) {
|
||||
for y, r := range rows {
|
||||
if strings.HasPrefix(r, string(unicodeFont.Glyphs.Pointer)) {
|
||||
return y, r
|
||||
}
|
||||
}
|
||||
return -1, ""
|
||||
}
|
||||
|
||||
// The view follows the newest row; a move up pauses it on its row while rows
|
||||
// arrive, and G, or moving down onto the newest, follows again
|
||||
func TestTheViewFollowsUntilMovedUp(t *testing.T) {
|
||||
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
|
||||
add := func(from, to int) {
|
||||
for i := from; i < to; i++ {
|
||||
a.view.take(tapped("p", "null", "", 0, fmt.Sprintf("row %03d", i)))
|
||||
}
|
||||
}
|
||||
at := func() string {
|
||||
_, r := cursorRow(text(render(a, 80, 24), 80))
|
||||
return r
|
||||
}
|
||||
if add(0, 50); !strings.Contains(at(), "row 049") {
|
||||
t.Fatalf("following: %q", at())
|
||||
}
|
||||
press(a, "k")
|
||||
if add(50, 60); !strings.Contains(at(), "row 048") || !strings.Contains(text(render(a, 80, 24), 80)[1], "paused · 11 newer") {
|
||||
t.Fatalf("paused: %q\n%s", at(), text(render(a, 80, 24), 80)[1])
|
||||
}
|
||||
if press(a, "G"); !strings.Contains(at(), "row 059") {
|
||||
t.Fatalf("G: %q", at())
|
||||
}
|
||||
press(a, "k", "j")
|
||||
if add(60, 61); !strings.Contains(at(), "row 060") {
|
||||
t.Fatalf("down onto the newest: %q", at())
|
||||
}
|
||||
}
|
||||
|
||||
// The rows hold no more than ringBytes, the oldest going first; a paused
|
||||
// cursor keeps its row, or takes the oldest kept when its own went
|
||||
func TestTheRowsKeepWithinTheirBytes(t *testing.T) {
|
||||
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
|
||||
big := strings.Repeat("x", 1<<20)
|
||||
for i := range 20 {
|
||||
a.view.take(tapped("p", "null", "", 0, fmt.Sprintf("%02d %s", i, big)))
|
||||
}
|
||||
render(a, 80, 24)
|
||||
press(a, "k", "k")
|
||||
focused := a.view.focused().Message[:2]
|
||||
for i := 20; i < 25; i++ {
|
||||
a.view.take(tapped("p", "null", "", 0, fmt.Sprintf("%02d %s", i, big)))
|
||||
}
|
||||
if got := a.view.focused().Message[:2]; a.view.bytes > ringBytes || got != focused {
|
||||
t.Fatalf("%d bytes held; cursor on %s, was on %s", a.view.bytes, got, focused)
|
||||
}
|
||||
for i := 25; i < 55; i++ {
|
||||
a.view.take(tapped("p", "null", "", 0, fmt.Sprintf("%02d %s", i, big)))
|
||||
}
|
||||
if got, oldest := a.view.focused().Message[:2], a.view.rows[0].Message[:2]; a.view.bytes > ringBytes || got != oldest || oldest == "00" {
|
||||
t.Fatalf("%d bytes held; cursor on %s, the oldest kept %s", a.view.bytes, got, oldest)
|
||||
}
|
||||
}
|
||||
|
||||
// A pattern shows only the rows matching it; setting or clearing it keeps
|
||||
// the focused row on its screen row, the rows around it back on clearing,
|
||||
// whether the view was paused or following
|
||||
func TestAFilterChangeKeepsTheFocusedRowInPlace(t *testing.T) {
|
||||
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
|
||||
for i := range 300 {
|
||||
msg := fmt.Sprintf("row %03d", i)
|
||||
if i%3 == 2 {
|
||||
msg += " needle"
|
||||
}
|
||||
a.view.take(tapped("p", "null", "", 0, msg))
|
||||
}
|
||||
screen := func() []string { return text(render(a, 80, 24), 80) }
|
||||
press(a, "g")
|
||||
screen()
|
||||
press(a, strings.Repeat("j", 150))
|
||||
screen()
|
||||
press(a, strings.Repeat("k", 10))
|
||||
y, r := cursorRow(screen())
|
||||
if !strings.Contains(r, "row 140 needle") {
|
||||
t.Fatalf("on %q", r)
|
||||
}
|
||||
press(a, "/", "needle", terminal.KeyEnter)
|
||||
rows := screen()
|
||||
if y2, r2 := cursorRow(rows); y2 != y || r2 != r || strings.Contains(strings.Join(rows, "\n"), "row 139") {
|
||||
t.Fatalf("with the pattern, on row %d %q, was %d\n%s", y2, r2, y, strings.Join(rows, "\n"))
|
||||
}
|
||||
press(a, "/", terminal.KeyCtrlU, terminal.KeyEnter)
|
||||
rows = screen()
|
||||
if y2, r2 := cursorRow(rows); y2 != y || r2 != r || !strings.Contains(rows[y2-1], "row 139") || !strings.Contains(rows[y2+1], "row 141") {
|
||||
t.Fatalf("cleared, on row %d %q, was %d\n%s", y2, r2, y, strings.Join(rows, "\n"))
|
||||
}
|
||||
following := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
|
||||
for i := range 40 {
|
||||
msg := fmt.Sprintf("row %03d", i)
|
||||
if i == 10 {
|
||||
msg += " needle"
|
||||
}
|
||||
following.view.take(tapped("p", "null", "", 0, msg))
|
||||
}
|
||||
press(following, "/", "needle", terminal.KeyEnter)
|
||||
render(following, 80, 24)
|
||||
press(following, "/", terminal.KeyCtrlU, terminal.KeyEnter)
|
||||
if render(following, 80, 24); following.view.focused().Message != "row 010 needle" || !following.view.paused {
|
||||
t.Fatalf("cleared while following, on %q", following.view.focused().Message)
|
||||
}
|
||||
}
|
||||
|
||||
// A row keeps copies of its text, none of the entry's, and its size counts
|
||||
// every string it keeps, so the ring's bound holds for any line
|
||||
func TestARowCountsAllItHolds(t *testing.T) {
|
||||
long := strings.Repeat("y", 4096)
|
||||
rec := tapped("p", "null", "", 0, `{"msg":"`+long+`","sub":"`+long+`","k":"`+long+`","t":"2026-10-10T12:00:00Z"}`)
|
||||
rec.Event.Entry.Level = long
|
||||
var v view
|
||||
size := v.take(rec)
|
||||
r := v.rows[0]
|
||||
held := rowBytes + len(r.Stamp) + len(r.Level) + len(r.Tag) + len(r.Message) + len(r.Layout) + len(r.fields) + len(r.payload)
|
||||
strs := []string{r.Stamp, r.Level, r.Tag, r.Message, r.Layout}
|
||||
for _, f := range r.Fields {
|
||||
held += int(unsafe.Sizeof(f)) + len(f.Key) + len(f.Value)
|
||||
strs = append(strs, f.Key, f.Value)
|
||||
}
|
||||
if size < held || v.bytes != size {
|
||||
t.Fatalf("size %d, ring %d, for %d held", size, v.bytes, held)
|
||||
}
|
||||
for _, s := range strs {
|
||||
for _, from := range []string{rec.Event.Entry.Message, rec.Event.Entry.Level} {
|
||||
if p, start := uintptr(unsafe.Pointer(unsafe.StringData(s))), uintptr(unsafe.Pointer(unsafe.StringData(from))); s != "" && p >= start && p < start+uintptr(len(from)) {
|
||||
t.Fatalf("%.20q is the entry's", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The canvas cursor picks the point and v the verdicts at it: a source's
|
||||
// entries, what reaches a stage or a filter, passes it or it drops, the
|
||||
// heartbeats, what the sinks got and what the flow dropped on the way; lw's
|
||||
// own log shows from info up at every point, and whole at its own
|
||||
func TestThePointAndModePickTheRows(t *testing.T) {
|
||||
c := pipeline(t, []string{"null", "null"}, []string{"null"})
|
||||
for _, typ := range []string{"include", "exclude"} {
|
||||
if _, err := c.Add(0, "filters", typ); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := c.AddPipeline("q"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s1, s2 := c.Pipelines[0].PluginSources[0].ID, c.Pipelines[0].PluginSources[1].ID
|
||||
a := watching(c, looks[terminal.ColorModeNone])
|
||||
lw := func(level, msg string) core.Tapped {
|
||||
r := tapped("", "lw", "", 0, msg)
|
||||
r.Event.Entry.Level = level
|
||||
return r
|
||||
}
|
||||
for _, r := range []core.Tapped{
|
||||
tapped("p", s1, "", 0, "a"), tapped("p", s2, "", 0, "b"), tapped("p", s1, "rate_limit", 0, "c"),
|
||||
tapped("p", s1, "filters", 0, "d"), tapped("p", s2, "filters", 1, "e"), tapped("p", s1, "format", 0, "f"),
|
||||
tapped("p", "", "", 0, "g"), tapped("q", s1, "", 0, "h"), lw("INFO", "i"), lw("DEBUG", "j"),
|
||||
} {
|
||||
a.view.take(r)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
pi int
|
||||
at compose.Node
|
||||
mode int
|
||||
want string
|
||||
own bool
|
||||
}{
|
||||
{0, compose.Node{Role: "sink", ID: "null"}, modeAll, "abgi", false},
|
||||
{0, compose.Node{Role: "sink", ID: "null"}, modePassed, "abgi", false},
|
||||
{0, compose.Node{Role: "sink", ID: "null"}, modeDropped, "cdefi", false},
|
||||
{0, compose.Node{Role: "source", ID: s1}, modeAll, "acdfi", false},
|
||||
{0, compose.Node{Role: "source", ID: s1}, modePassed, "ai", false},
|
||||
{0, compose.Node{Role: "source", ID: s1}, modeDropped, "cdfi", false},
|
||||
{0, compose.Node{Role: "rate_limit"}, modeAll, "abcdefi", false},
|
||||
{0, compose.Node{Role: "rate_limit"}, modePassed, "abdefi", false},
|
||||
{0, compose.Node{Role: "rate_limit"}, modeDropped, "ci", false},
|
||||
{0, compose.Node{Role: "filters"}, modeAll, "abdefi", false},
|
||||
{0, compose.Node{Role: "filters"}, modePassed, "abfi", false},
|
||||
{0, compose.Node{Role: "filters"}, modeDropped, "dei", false},
|
||||
{0, compose.Node{Role: "filters", Index: 1}, modeAll, "abefi", false},
|
||||
{0, compose.Node{Role: "filters", Index: 1}, modeDropped, "ei", false},
|
||||
{0, compose.Node{Role: "format"}, modeDropped, "fi", false},
|
||||
{0, compose.Node{Role: "heartbeat"}, modeAll, "gi", false},
|
||||
{0, compose.Node{Role: "heartbeat"}, modeDropped, "i", false},
|
||||
{1, compose.Node{}, modeAll, "hi", false},
|
||||
{0, compose.Node{Role: "filters"}, modeDropped, "ij", true},
|
||||
} {
|
||||
a.pi, a.inspect, a.view.mode, a.col, a.view.own = c.pi, false, c.mode, sinksCol, c.own
|
||||
a.choose(c.at)
|
||||
if c.at.Role == "filters" && c.at.Index > 0 {
|
||||
a.inspectAt(func(l line) bool { return l.node == c.at })
|
||||
}
|
||||
render(a, 80, 24)
|
||||
if got := strings.Join(shown(a), ""); got != c.want {
|
||||
t.Errorf("at %+v in pipeline %d, %s: %q, want %q", c.at, c.pi, verdicts[c.mode], got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The tap takes the entries the flow dropped only while the view's point and
|
||||
// mode show some
|
||||
func TestTheTapTakesDropsOnlyWhileShown(t *testing.T) {
|
||||
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
|
||||
a.live.tap = core.NewTap(1)
|
||||
for _, c := range []struct {
|
||||
at compose.Node
|
||||
mode int
|
||||
all bool
|
||||
}{
|
||||
{compose.Node{Role: "sink", ID: "null"}, modeAll, false},
|
||||
{compose.Node{Role: "sink", ID: "null"}, modeDropped, true},
|
||||
{compose.Node{Role: "source", ID: "null"}, modePassed, false},
|
||||
{compose.Node{Role: "source", ID: "null"}, modeAll, true},
|
||||
{compose.Node{Role: "filters"}, modePassed, true},
|
||||
} {
|
||||
a.view.mode = c.mode
|
||||
a.choose(c.at)
|
||||
if render(a, 80, 24); a.live.tap.All.Load() != c.all {
|
||||
t.Errorf("at %+v, %s: the tap takes drops %v", c.at, verdicts[c.mode], !c.all)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A digit is a level floor: rows under it hide, and those without a level;
|
||||
// 0 shows them all
|
||||
func TestTheLevelFloorHidesRowsUnderIt(t *testing.T) {
|
||||
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
|
||||
for _, l := range []string{"DEBUG", "INFO", "WARN", "ERROR", ""} {
|
||||
r := tapped("p", "null", "", 0, "at "+l)
|
||||
r.Event.Entry.Level = l
|
||||
a.view.take(r)
|
||||
}
|
||||
for _, c := range []struct{ key, want string }{{"4", "at WARN|at ERROR"}, {"2", "at DEBUG|at INFO|at WARN|at ERROR"}, {"0", "at DEBUG|at INFO|at WARN|at ERROR|at "}} {
|
||||
press(a, c.key)
|
||||
if render(a, 80, 24); strings.Join(shown(a), "|") != c.want {
|
||||
t.Errorf("%s: %q", c.key, shown(a))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A pattern in lower case ignores case, one with an upper-case letter keeps
|
||||
// it, in substrings and regular expressions alike
|
||||
func TestPatternsAreSmartCase(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
pattern, text string
|
||||
match bool
|
||||
}{
|
||||
{"disk", "DISK full", true}, {"Disk", "disk full", false}, {"Disk", "Disk full", true},
|
||||
{"d.sk", "DISK full", true}, {"D.sk", "disk full", false}, {"é", "CAFÉ", true}, {"x", "DISK", false},
|
||||
} {
|
||||
if p, err := newPattern(c.pattern); err != nil || p.match(c.text) != c.match {
|
||||
t.Errorf("%q in %q: %v, %v", c.pattern, c.text, !c.match, err)
|
||||
}
|
||||
}
|
||||
if _, err := newPattern("("); err == nil {
|
||||
t.Error("( compiled")
|
||||
}
|
||||
}
|
||||
|
||||
// From 40 rows the view sits under the diagram, whichever takes the keys;
|
||||
// shorter, it takes the screen while it takes the keys, t going back
|
||||
func TestTheViewSitsUnderTheDiagramWhenTall(t *testing.T) {
|
||||
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
|
||||
a.view.take(tapped("p", "null", "", 0, "hello"))
|
||||
layout := func(h int) string {
|
||||
screen := strings.Join(text(render(a, 80, h), 80), "\n")
|
||||
diagram, view := strings.Index(screen, "● SOURCES"), strings.Index(screen, "hello")
|
||||
switch {
|
||||
case diagram >= 0 && view > diagram:
|
||||
return "split"
|
||||
case view >= 0 && diagram < 0:
|
||||
return "view"
|
||||
case diagram >= 0 && view < 0:
|
||||
return "diagram"
|
||||
}
|
||||
return screen
|
||||
}
|
||||
if got := layout(40); got != "split" {
|
||||
t.Errorf("40 rows: %s", got)
|
||||
}
|
||||
if got := layout(39); got != "view" {
|
||||
t.Errorf("39 rows: %s", got)
|
||||
}
|
||||
press(a, "t")
|
||||
if got := layout(39); got != "diagram" {
|
||||
t.Errorf("39 rows, back: %s", got)
|
||||
}
|
||||
if got := layout(40); got != "split" {
|
||||
t.Errorf("40 rows, back: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Enter shows the focused row's detail beside the rows from 160 columns,
|
||||
// under them below that
|
||||
func TestTheDetailSitsBesideFrom160(t *testing.T) {
|
||||
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
|
||||
a.view.take(tapped("p", "null", "", 0, "hello"))
|
||||
press(a, terminal.KeyEnter)
|
||||
for w, beside := range map[int]bool{160: true, 159: false} {
|
||||
rows := text(render(a, w, 30), w)
|
||||
y := slices.IndexFunc(rows, func(r string) bool { return strings.Contains(r, "─ detail ─") })
|
||||
if got := y == 2 && strings.Contains(rows[2], "hello"); got != beside {
|
||||
t.Errorf("%d columns: beside %v, detail on row %d\n%s", w, got, y, strings.Join(rows, "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The detail shows the entry whole: its verdict, the layout logline found,
|
||||
// its fields, and the payload as the sinks receive it
|
||||
func TestTheDetailShowsTheEntryWhole(t *testing.T) {
|
||||
c := pipeline(t, []string{"null"}, []string{"null"})
|
||||
if _, err := c.Add(0, "filters", "exclude"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Set(0, compose.Node{Role: "filters"}, "patterns", "slow"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := watching(c, looks[terminal.ColorModeNone])
|
||||
line := `{"time":"2026-10-10T12:00:00Z","level":"WARN","sub":"fsm","fields":{"msg":"transition","state":"idle"}}`
|
||||
a.view.take(tapped("p", "null", "", 0, line))
|
||||
press(a, terminal.KeyEnter)
|
||||
screen := strings.Join(text(render(a, 170, 30), 170), "\n")
|
||||
for _, want := range []string{"verdict passed every stage", "layout json, message in fields.msg", "state idle", "as sinks receive it", line[:50]} {
|
||||
if !strings.Contains(screen, want) {
|
||||
t.Errorf("no %q\n%s", want, screen)
|
||||
}
|
||||
}
|
||||
a.view.take(tapped("p", "null", "filters", 0, "slow tick"))
|
||||
if screen := strings.Join(text(render(a, 170, 30), 170), "\n"); !strings.Contains(screen, "dropped by filter 1, exclude slow") || strings.Contains(screen, "as sinks receive it") {
|
||||
t.Errorf("a dropped row\n%s", screen)
|
||||
}
|
||||
}
|
||||
|
||||
// A row draws one cell per rune: a wide, combining or control rune is the
|
||||
// font's placeholder and a line break its mark, which the detail writes as
|
||||
// escapes and a break
|
||||
func TestRowsDrawOneCellPerRune(t *testing.T) {
|
||||
msg := "a\u4f60\u0301\x1b[2Jb\tc\nd"
|
||||
for _, f := range []font{unicodeFont, cp437Font, asciiFont} {
|
||||
a := newApp(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone], f)
|
||||
a.viewing = true
|
||||
render(a, 80, 24)
|
||||
a.view.take(tapped("p", "null", "", 0, msg))
|
||||
odd, br := string(f.fold(f.odd)), string(f.fold(f.newline))
|
||||
want := "a" + odd + odd + odd + "[2Jb c" + br + "d"
|
||||
if row := text(render(a, 80, 24), 80)[2]; !strings.Contains(row, want) {
|
||||
t.Errorf("%c: %q, want %q", f.bar, row, want)
|
||||
}
|
||||
}
|
||||
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
|
||||
a.view.take(tapped("p", "null", "", 0, msg))
|
||||
press(a, terminal.KeyEnter)
|
||||
if screen := strings.Join(text(render(a, 80, 30), 80), "\n"); !strings.Contains(screen, `message a\u4F60\u0301\u001B[2Jb\u0009c`+strings.Repeat(" ", 10)) {
|
||||
t.Errorf("the detail\n%s", screen)
|
||||
}
|
||||
}
|
||||
|
||||
// A narrow view drops the time, then the level's name, the tag and the
|
||||
// level, and never draws one over the message
|
||||
func TestANarrowViewDropsColumns(t *testing.T) {
|
||||
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
|
||||
r := tapped("p", "null", "", 0, "hello world")
|
||||
r.Event.Entry.Level, r.Event.Entry.Source = "WARN", "app.log"
|
||||
a.view.take(r)
|
||||
stamp := time.Unix(1, 0).Format("15:04:05")
|
||||
for _, c := range []struct {
|
||||
w int
|
||||
want string
|
||||
}{{100, stamp + ".000 WARN app.log hello world"}, {60, stamp + ".000 W app.log hello world"},
|
||||
{45, stamp + " W hello world"}, {30, "▸ W hello world"}, {20, "▸ hello world"}} {
|
||||
if row := text(render(a, c.w, 24), c.w)[2]; !strings.Contains(row, c.want) {
|
||||
t.Errorf("%d columns: %q, want %q", c.w, row, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A row's level shows in its level's color; a level lw does not name, in the
|
||||
// text's
|
||||
func TestALevelShowsInItsColor(t *testing.T) {
|
||||
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeTrueColor])
|
||||
for _, level := range []string{"WARN", "AUDIT"} {
|
||||
r := tapped("p", "null", "", 0, "hello")
|
||||
r.Event.Entry.Level = level
|
||||
a.view.take(r)
|
||||
}
|
||||
cells := render(a, 100, 24)
|
||||
for y, c := range []struct {
|
||||
level string
|
||||
want ui.Style
|
||||
}{{"WARN", a.look.Levels["WARN"]}, {"AUDIT", a.th.Text}} {
|
||||
row := []rune(text(cells, 100)[2+y])
|
||||
x := slices.Index(row, rune(c.level[0]))
|
||||
if got := cells[(2+y)*100+x]; x < 0 || got.Fg != c.want.Fg {
|
||||
t.Errorf("%s at %d is %+v, want %+v", c.level, x, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The cursor row is one bar: without color every cell takes one style, so a
|
||||
// console draws no split bar; in color each keeps its role on the bar's
|
||||
// background
|
||||
func TestTheViewsCursorRowIsOneBar(t *testing.T) {
|
||||
for _, mode := range []terminal.ColorMode{terminal.ColorModeNone, terminal.ColorMode16, terminal.ColorModeTrueColor} {
|
||||
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[mode])
|
||||
r := tapped("p", "null", "filters", 0, "hello world")
|
||||
r.Event.Entry.Level, r.Event.Entry.Fields = "ERROR", json.RawMessage(`{"k":1}`)
|
||||
a.view.take(r)
|
||||
a.view.mode = modeDropped
|
||||
a.choose(compose.Node{Role: "sink", ID: "null"})
|
||||
cells := render(a, 100, 24)
|
||||
row, bar := cells[2*100:3*100], a.th.Text.On(a.th.Selected)
|
||||
for x, c := range row {
|
||||
one := c.Fg == bar.Fg && c.Bg == bar.Bg && c.Attrs == bar.Attr
|
||||
if mode == terminal.ColorModeNone && !one || c.Bg != bar.Bg || c.Attrs&terminal.AttrBgColor != bar.Attr&terminal.AttrBgColor {
|
||||
t.Fatalf("mode %v: %q at %d is %+v, the bar %+v", mode, c.Rune, x, c, bar)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A tick reads the taps up to drainBytes, the rest waiting for the next
|
||||
func TestADrainKeepsToItsBudget(t *testing.T) {
|
||||
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
|
||||
a.live.tap = core.NewTap(100)
|
||||
for range 100 {
|
||||
a.live.tap.Offer(tapped("p", "null", "", 0, strings.Repeat("x", 64<<10)))
|
||||
}
|
||||
press(a, terminal.KeyNone)
|
||||
size := a.view.rows[0].size
|
||||
if n, want := len(a.view.rows), int(math.Ceil(float64(drainBytes)/float64(size))); n != want || len(a.live.tap.Records()) != 100-n {
|
||||
t.Fatalf("read %d rows of %d bytes, want %d; %d left", n, size, want, len(a.live.tap.Records()))
|
||||
}
|
||||
}
|
||||
|
||||
// While a tap holds records the watch wakes the screen; an empty one does
|
||||
// not
|
||||
func TestATapHoldingRecordsWakesTheScreen(t *testing.T) {
|
||||
posts := make(chan struct{}, 8)
|
||||
r := newRun(&fakeRunner{}, func() { posts <- struct{}{} })
|
||||
tap := core.NewTap(1)
|
||||
r.fed.Store(tap)
|
||||
go r.watch(time.Hour)
|
||||
defer r.endAsks()
|
||||
select {
|
||||
case <-posts:
|
||||
t.Fatal("an empty tap woke the screen")
|
||||
case <-time.After(3 * drainEvery):
|
||||
}
|
||||
tap.Offer(tapped("p", "null", "", 0, "x"))
|
||||
select {
|
||||
case <-posts:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("a tap holding a record did not wake the screen")
|
||||
}
|
||||
}
|
||||
|
||||
// A rerun reads the earlier run's tap whole, then a rule marks where the new
|
||||
// run's rows begin
|
||||
func TestARerunMarksTheRows(t *testing.T) {
|
||||
f := &fakeRunner{}
|
||||
a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f)
|
||||
a.live.tap = core.NewTap(4)
|
||||
a.live.tap.Offer(tapped("p", "null", "", 0, "before"))
|
||||
next := core.NewTap(4)
|
||||
next.Offer(tapped("p", "null", "", 0, "after"))
|
||||
f.start = func(Ask) (*core.Tap, error) { return next, nil }
|
||||
a.edit(a.comp.Set(0, compose.Node{Role: "format"}, "type", "txt"))
|
||||
press(a, "t")
|
||||
await(t, a)
|
||||
var got []string
|
||||
for _, r := range a.view.rows {
|
||||
got = append(got, r.Message+strings.SplitN(r.mark, " ", 2)[0])
|
||||
}
|
||||
if strings.Join(got, ",") != "before,rerun,after" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// i inspects the filter that dropped the focused row, which the view then
|
||||
// looks at
|
||||
func TestIInspectsTheFilterThatDropped(t *testing.T) {
|
||||
c := pipeline(t, []string{"null"}, []string{"null"})
|
||||
for _, typ := range []string{"include", "exclude"} {
|
||||
if _, err := c.Add(0, "filters", typ); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
a := watching(c, looks[terminal.ColorModeNone])
|
||||
a.view.take(tapped("p", "null", "filters", 1, "dropped"))
|
||||
press(a, "i")
|
||||
if l := a.lines()[a.insp.cursor]; a.viewing || !a.inspect || l.node != (compose.Node{Role: "filters", Index: 1}) || a.point().name != "filter 2" {
|
||||
t.Fatalf("viewing %v, inspecting %v on %+v, at %s", a.viewing, a.inspect, l.node, a.point().name)
|
||||
}
|
||||
}
|
||||
|
||||
// s picks the point from a list, moving the canvas cursor there, or lw's own
|
||||
// log while it reaches the screen, until s picks another or a key on the
|
||||
// diagram moves the cursor
|
||||
func TestTheChooserPicksThePoint(t *testing.T) {
|
||||
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
|
||||
press(a, "s", "format", terminal.KeyEnter)
|
||||
if n, _ := a.node(); n.Role != "format" || a.point().name != "format" || !a.viewing {
|
||||
t.Fatalf("on %+v, at %s, viewing %v", n, a.point().name, a.viewing)
|
||||
}
|
||||
a.live.state.Log = core.NewTap(1)
|
||||
for _, leave := range [][]any{{"s", "format", terminal.KeyEnter}, {"t", "j"}} {
|
||||
if press(a, "s", "own log", terminal.KeyEnter); !a.point().own {
|
||||
t.Fatalf("at %s", a.point().name)
|
||||
}
|
||||
if press(a, leave...); a.point().own {
|
||||
t.Fatalf("%v left the point on lw's own log", leave)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user