mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
Modules declare go 1.27.1 and the build images follow. go fix applied the modernizers, and the ptr helper went once new(expr) could say it; omitzero is not applied, since it changes what the JSON says. Co-authored-by: Hanzo Dev <[email protected]>
1430 lines
42 KiB
Go
1430 lines
42 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
"github.com/insights/insights/phrocs/internal/config"
|
|
"github.com/insights/insights/phrocs/internal/docker"
|
|
"github.com/insights/insights/phrocs/internal/process"
|
|
)
|
|
|
|
// testConfig creates a Config with the named stub processes (no autostart).
|
|
func testConfig(names ...string) *config.Config {
|
|
f := false
|
|
procs := make(map[string]config.ProcConfig, len(names))
|
|
for _, n := range names {
|
|
procs[n] = config.ProcConfig{Shell: "true", Autostart: &f}
|
|
}
|
|
return &config.Config{
|
|
Procs: procs,
|
|
MouseScrollSpeed: 3,
|
|
Scrollback: 1000,
|
|
}
|
|
}
|
|
|
|
// readyModel returns a model that has processed a WindowSizeMsg and is ready.
|
|
func readyModel(t *testing.T, names ...string) Model {
|
|
t.Helper()
|
|
cfg := testConfig(names...)
|
|
mgr := process.NewManager(cfg)
|
|
m := New(mgr, cfg, "", nil)
|
|
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
|
|
return next.(Model)
|
|
}
|
|
|
|
func readyDockerModel(t *testing.T) Model {
|
|
t.Helper()
|
|
f := false
|
|
cfg := &config.Config{
|
|
Procs: map[string]config.ProcConfig{
|
|
"docker": {Shell: "docker compose -f docker-compose.dev.yml up", Autostart: &f},
|
|
},
|
|
MouseScrollSpeed: 3,
|
|
Scrollback: 1000,
|
|
}
|
|
mgr := process.NewManager(cfg)
|
|
m := New(mgr, cfg, "", nil)
|
|
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
|
|
dockerModel := next.(Model)
|
|
dockerModel.containers = []docker.DockerContainer{{Service: "web"}}
|
|
dockerModel.containerCursor = 1
|
|
dockerModel.containerLines = []string{"regular line", "error: failed"}
|
|
return dockerModel
|
|
}
|
|
|
|
func keypress(r rune) tea.KeyPressMsg {
|
|
return tea.KeyPressMsg{Code: r, Text: string(r)}
|
|
}
|
|
|
|
func specialKey(code rune) tea.KeyPressMsg {
|
|
return tea.KeyPressMsg{Code: code}
|
|
}
|
|
|
|
func update(m Model, msg tea.Msg) Model {
|
|
next, _ := m.Update(msg)
|
|
return next.(Model)
|
|
}
|
|
|
|
// ── New() initial state ───────────────────────────────────────────────────────
|
|
|
|
func TestNew_initialState(t *testing.T) {
|
|
cfg := testConfig("backend", "frontend")
|
|
mgr := process.NewManager(cfg)
|
|
m := New(mgr, cfg, "", nil)
|
|
if m.ready {
|
|
t.Error("model should not be ready before WindowSizeMsg")
|
|
}
|
|
if m.servicesCursor != 0 {
|
|
t.Errorf("cursor: got %d, want 0", m.servicesCursor)
|
|
}
|
|
if m.focusedPane != focusServices {
|
|
t.Error("initial focus should be sidebar")
|
|
}
|
|
if !m.viewportAtBottom {
|
|
t.Error("atBottom should be true initially")
|
|
}
|
|
if m.copyMode {
|
|
t.Error("should not be in copy mode initially")
|
|
}
|
|
}
|
|
|
|
func TestUpdate_windowSizeSetsReady(t *testing.T) {
|
|
cfg := testConfig("backend")
|
|
mgr := process.NewManager(cfg)
|
|
m := New(mgr, cfg, "", nil)
|
|
m = update(m, tea.WindowSizeMsg{Width: 120, Height: 40})
|
|
if !m.ready {
|
|
t.Error("model should be ready after WindowSizeMsg")
|
|
}
|
|
if m.width != 120 {
|
|
t.Errorf("width: got %d, want 120", m.width)
|
|
}
|
|
if m.height != 40 {
|
|
t.Errorf("height: got %d, want 40", m.height)
|
|
}
|
|
}
|
|
|
|
// ── Sidebar navigation ────────────────────────────────────────────────────────
|
|
|
|
func TestNavigation_nextProc(t *testing.T) {
|
|
m := readyModel(t, "backend", "celery", "frontend")
|
|
// j = next proc
|
|
m = update(m, keypress('j'))
|
|
if m.servicesCursor != 1 {
|
|
t.Errorf("cursor after j: got %d, want 1", m.servicesCursor)
|
|
}
|
|
m = update(m, keypress('j'))
|
|
if m.servicesCursor != 2 {
|
|
t.Errorf("cursor after j j: got %d, want 2", m.servicesCursor)
|
|
}
|
|
}
|
|
|
|
func TestNavigation_prevProc(t *testing.T) {
|
|
m := readyModel(t, "backend", "celery", "frontend")
|
|
m.servicesCursor = 2
|
|
// k = prev proc
|
|
m = update(m, keypress('k'))
|
|
if m.servicesCursor != 1 {
|
|
t.Errorf("cursor after k: got %d, want 1", m.servicesCursor)
|
|
}
|
|
}
|
|
|
|
func TestNavigation_clampsAtBottom(t *testing.T) {
|
|
m := readyModel(t, "backend", "frontend")
|
|
m.servicesCursor = 1
|
|
m = update(m, keypress('j'))
|
|
if m.servicesCursor != 1 {
|
|
t.Errorf("cursor should clamp at %d, got %d", 1, m.servicesCursor)
|
|
}
|
|
}
|
|
|
|
func TestNavigation_clampsAtTop(t *testing.T) {
|
|
m := readyModel(t, "backend", "frontend")
|
|
m.servicesCursor = 0
|
|
m = update(m, keypress('k'))
|
|
if m.servicesCursor != 0 {
|
|
t.Errorf("cursor should clamp at 0, got %d", m.servicesCursor)
|
|
}
|
|
}
|
|
|
|
func TestNavigation_arrowKeys(t *testing.T) {
|
|
m := readyModel(t, "backend", "frontend")
|
|
m = update(m, specialKey(tea.KeyDown))
|
|
if m.servicesCursor != 1 {
|
|
t.Errorf("cursor after down: got %d, want 1", m.servicesCursor)
|
|
}
|
|
m = update(m, specialKey(tea.KeyUp))
|
|
if m.servicesCursor != 0 {
|
|
t.Errorf("cursor after up: got %d, want 0", m.servicesCursor)
|
|
}
|
|
}
|
|
|
|
// ── Focus ─────────────────────────────────────────────────────────────────────
|
|
|
|
func TestFocus_swapWithTab(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
if m.focusedPane != focusServices {
|
|
t.Fatal("expected sidebar focus initially")
|
|
}
|
|
m = update(m, specialKey(tea.KeyTab))
|
|
if m.focusedPane != focusOutput {
|
|
t.Error("tab should switch to output focus")
|
|
}
|
|
m = update(m, specialKey(tea.KeyTab))
|
|
if m.focusedPane != focusServices {
|
|
t.Error("second tab should return to sidebar focus")
|
|
}
|
|
}
|
|
|
|
func TestFocus_mouseClickSidebar(t *testing.T) {
|
|
m := readyModel(t, "backend", "frontend")
|
|
// Click second row in sidebar: header (1) + top border (1) + first row (1) = Y=3
|
|
m = update(m, tea.MouseClickMsg{Button: tea.MouseLeft, X: 5, Y: headerHeight + 2})
|
|
if m.focusedPane != focusServices {
|
|
t.Error("click in sidebar should focus sidebar")
|
|
}
|
|
if m.servicesCursor != 1 {
|
|
t.Errorf("click on row 1: cursor should be 1, got %d", m.servicesCursor)
|
|
}
|
|
}
|
|
|
|
func TestFocus_mouseClickOutput(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m = update(m, tea.MouseClickMsg{Button: tea.MouseLeft, X: sidebarWidth + 10, Y: 5})
|
|
if m.focusedPane != focusOutput {
|
|
t.Error("click in output pane should focus output")
|
|
}
|
|
}
|
|
|
|
// ── Input mode ──────────────────────────────────────────────────────────────
|
|
|
|
// startedModel returns a ready model with a single running process, skipping
|
|
// the test if the subprocess can't be spawned in this environment.
|
|
func startedModel(t *testing.T, shell string) (Model, *process.Process) {
|
|
t.Helper()
|
|
f := false
|
|
cfg := &config.Config{
|
|
Procs: map[string]config.ProcConfig{"proc": {Shell: shell, Autostart: &f}},
|
|
MouseScrollSpeed: 3,
|
|
Scrollback: 1000,
|
|
}
|
|
mgr := process.NewManager(cfg)
|
|
mgr.SetSend(func(tea.Msg) {}) // drain status/output so Start doesn't block
|
|
m := New(mgr, cfg, "", nil)
|
|
m = update(m, tea.WindowSizeMsg{Width: 120, Height: 40})
|
|
p := m.activeProc()
|
|
if err := p.Start(mgr.Send()); err != nil {
|
|
t.Skipf("cannot spawn subprocess: %v", err)
|
|
}
|
|
if !p.IsRunning() {
|
|
t.Skip("proc did not reach running state")
|
|
}
|
|
return m, p
|
|
}
|
|
|
|
func TestInputMode_enterAndExit(t *testing.T) {
|
|
m, p := startedModel(t, "sleep 30")
|
|
defer p.Stop()
|
|
|
|
m = update(m, specialKey(tea.KeyTab)) // focus output pane
|
|
if m.focusedPane != focusOutput {
|
|
t.Fatal("expected output focus after tab")
|
|
}
|
|
m = update(m, specialKey(tea.KeyEnter))
|
|
if !m.inputMode {
|
|
t.Error("enter should activate input mode on a running proc")
|
|
}
|
|
m = update(m, specialKey(tea.KeyEscape))
|
|
if m.inputMode {
|
|
t.Error("esc should leave input mode")
|
|
}
|
|
}
|
|
|
|
func TestInputMode_ignoredWhenProcNotRunning(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m = update(m, specialKey(tea.KeyTab))
|
|
if m.focusedPane != focusOutput {
|
|
t.Fatal("expected output focus after tab")
|
|
}
|
|
m = update(m, specialKey(tea.KeyEnter))
|
|
if m.inputMode {
|
|
t.Error("enter should not activate input mode when the proc is not running")
|
|
}
|
|
}
|
|
|
|
func TestInputMode_resetWhenLeavingOutputPane(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m.focusedPane = focusOutput
|
|
m.inputMode = true
|
|
m = update(m, specialKey(tea.KeyTab)) // back to sidebar
|
|
if m.focusedPane != focusServices {
|
|
t.Fatal("expected sidebar focus after tab")
|
|
}
|
|
if m.inputMode {
|
|
t.Error("leaving the output pane should reset input mode")
|
|
}
|
|
}
|
|
|
|
// ── Help ──────────────────────────────────────────────────────────────────────
|
|
|
|
func TestHelp_toggle(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
if m.showHelp {
|
|
t.Fatal("showHelp should be false initially")
|
|
}
|
|
m = update(m, keypress('?'))
|
|
if !m.showHelp {
|
|
t.Error("? should show help")
|
|
}
|
|
m = update(m, keypress('?'))
|
|
if m.showHelp {
|
|
t.Error("second ? should hide help")
|
|
}
|
|
}
|
|
|
|
// ── Copy mode ─────────────────────────────────────────────────────────────────
|
|
|
|
func TestCopyMode_enterAndExit(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m = update(m, keypress('c'))
|
|
if !m.copyMode {
|
|
t.Error("c should enter copy mode")
|
|
}
|
|
if m.copyAnchor != -1 {
|
|
t.Errorf("copyAnchor should be -1 on entry, got %d", m.copyAnchor)
|
|
}
|
|
m = update(m, specialKey(tea.KeyEscape))
|
|
if m.copyMode {
|
|
t.Error("esc should exit copy mode")
|
|
}
|
|
}
|
|
|
|
func TestCopyMode_setAnchor(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m = update(m, keypress('c')) // enter copy mode
|
|
// First c press in copy mode sets the anchor at the current cursor position
|
|
m = update(m, keypress('c'))
|
|
if m.copyAnchor != m.copyCursor {
|
|
t.Errorf("first c in copy mode should set anchor to copyCursor (%d), got %d", m.copyCursor, m.copyAnchor)
|
|
}
|
|
}
|
|
|
|
func TestCopyMode_navigation(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
// Populate some lines so the viewport has content to navigate.
|
|
p, _ := m.mgr.Get("backend")
|
|
for _, line := range []string{"line 1", "line 2", "line 3"} {
|
|
p.AppendLine(line)
|
|
m = update(m, process.OutputMsg{Name: "backend"})
|
|
}
|
|
m = update(m, keypress('c')) // enter copy mode
|
|
initial := m.copyCursor
|
|
|
|
// j = next line in copy mode
|
|
m = update(m, keypress('j'))
|
|
if m.copyCursor != initial+1 {
|
|
t.Errorf("j in copy mode: copyCursor want %d, got %d", initial+1, m.copyCursor)
|
|
}
|
|
// k = prev line in copy mode
|
|
m = update(m, keypress('k'))
|
|
if m.copyCursor != initial {
|
|
t.Errorf("k in copy mode: copyCursor want %d, got %d", initial, m.copyCursor)
|
|
}
|
|
}
|
|
|
|
func TestCopyMode_exitOnProcSwitch(t *testing.T) {
|
|
m := readyModel(t, "backend", "frontend")
|
|
m = update(m, keypress('c'))
|
|
if !m.copyMode {
|
|
t.Fatal("should be in copy mode")
|
|
}
|
|
// Mouse-clicking a different process in the sidebar calls loadActiveProc,
|
|
// which always exits copy mode. Y=headerHeight+2 hits the second sidebar row.
|
|
m = update(m, tea.MouseClickMsg{Button: tea.MouseLeft, X: 5, Y: headerHeight + 2})
|
|
if m.copyMode {
|
|
t.Error("switching process should exit copy mode")
|
|
}
|
|
}
|
|
|
|
// ── Search ────────────────────────────────────────────────────────────────────
|
|
|
|
func TestSearch_enterAndExit(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m = update(m, keypress('/'))
|
|
if !m.searchMode {
|
|
t.Error("/ should enter search mode")
|
|
}
|
|
m = update(m, specialKey(tea.KeyEscape))
|
|
if m.searchMode {
|
|
t.Error("esc should exit search mode")
|
|
}
|
|
if m.searchQuery != "" {
|
|
t.Error("esc should clear query")
|
|
}
|
|
}
|
|
|
|
func TestSearch_typeQuery(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m = update(m, keypress('/'))
|
|
m = update(m, keypress('e'))
|
|
m = update(m, keypress('r'))
|
|
m = update(m, keypress('r'))
|
|
if m.searchQuery != "err" {
|
|
t.Errorf("typed 'err', got %q", m.searchQuery)
|
|
}
|
|
}
|
|
|
|
func TestSearch_spaceInQuery(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m = update(m, keypress('/'))
|
|
m = update(m, keypress('h'))
|
|
m = update(m, tea.KeyPressMsg{Code: tea.KeySpace, Text: "space"})
|
|
m = update(m, keypress('w'))
|
|
if m.searchQuery != "h w" {
|
|
t.Errorf("space in query: want %q, got %q", "h w", m.searchQuery)
|
|
}
|
|
}
|
|
|
|
func TestSearch_backspace(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m = update(m, keypress('/'))
|
|
m = update(m, keypress('e'))
|
|
m = update(m, keypress('r'))
|
|
m = update(m, tea.KeyPressMsg{Code: tea.KeyBackspace, Text: "backspace"})
|
|
if m.searchQuery != "e" {
|
|
t.Errorf("after backspace want %q, got %q", "e", m.searchQuery)
|
|
}
|
|
}
|
|
|
|
func TestSearch_matchesHighlighted(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
p, _ := m.mgr.Get("backend")
|
|
for _, line := range []string{"hello world", "error here", "another error"} {
|
|
p.AppendLine(line)
|
|
m = update(m, process.OutputMsg{Name: "backend"})
|
|
}
|
|
m = update(m, keypress('/'))
|
|
m = update(m, keypress('e'))
|
|
m = update(m, keypress('r'))
|
|
m = update(m, keypress('r'))
|
|
if len(m.searchMatches) != 2 {
|
|
t.Errorf("want 2 matches for 'err', got %d", len(m.searchMatches))
|
|
}
|
|
}
|
|
|
|
func TestSearch_tabCommitsToFilter(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
p, _ := m.mgr.Get("backend")
|
|
for _, line := range []string{"foo", "bar", "foo again"} {
|
|
p.AppendLine(line)
|
|
m = update(m, process.OutputMsg{Name: "backend"})
|
|
}
|
|
m = update(m, keypress('/'))
|
|
m = update(m, keypress('f'))
|
|
m = update(m, keypress('o'))
|
|
m = update(m, keypress('o'))
|
|
m = update(m, specialKey(tea.KeyTab))
|
|
if m.searchMode {
|
|
t.Error("search mode should be off after tab (committed)")
|
|
}
|
|
if !m.filterMode {
|
|
t.Error("filter mode should be on after tab")
|
|
}
|
|
if m.searchQuery != "foo" {
|
|
t.Errorf("query should be preserved after commit, got %q", m.searchQuery)
|
|
}
|
|
if m.viewport.TotalLineCount() != 2 {
|
|
t.Errorf("filter should show 2 matching lines, got %d", m.viewport.TotalLineCount())
|
|
}
|
|
}
|
|
|
|
func TestSearch_navigateWithArrows(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
p, _ := m.mgr.Get("backend")
|
|
for _, line := range []string{"match one", "nothing here", "match two"} {
|
|
p.AppendLine(line)
|
|
m = update(m, process.OutputMsg{Name: "backend"})
|
|
}
|
|
m = update(m, keypress('/'))
|
|
for _, ch := range "match" {
|
|
m = update(m, keypress(ch))
|
|
}
|
|
if len(m.searchMatches) != 2 {
|
|
t.Fatalf("want 2 matches, got %d", len(m.searchMatches))
|
|
}
|
|
if m.searchCursor != 0 {
|
|
t.Fatalf("initial cursor should be 0, got %d", m.searchCursor)
|
|
}
|
|
// ↓ → next match
|
|
m = update(m, specialKey(tea.KeyDown))
|
|
if m.searchCursor != 1 {
|
|
t.Errorf("down: want cursor 1, got %d", m.searchCursor)
|
|
}
|
|
// ↑ → prev match
|
|
m = update(m, specialKey(tea.KeyUp))
|
|
if m.searchCursor != 0 {
|
|
t.Errorf("up: want cursor 0, got %d", m.searchCursor)
|
|
}
|
|
}
|
|
|
|
func TestSearch_incrementalUpdate(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
p, _ := m.mgr.Get("backend")
|
|
// Seed two lines, start a search, then deliver a new matching line.
|
|
for _, line := range []string{"error log", "info log"} {
|
|
p.AppendLine(line)
|
|
m = update(m, process.OutputMsg{Name: "backend"})
|
|
}
|
|
// Activate search
|
|
m = update(m, keypress('/'))
|
|
for _, ch := range "error" {
|
|
m = update(m, keypress(ch))
|
|
}
|
|
if len(m.searchMatches) != 1 {
|
|
t.Fatalf("want 1 match for 'error', got %d", len(m.searchMatches))
|
|
}
|
|
// New matching line arrives — recomputeSearch picks it up
|
|
p.AppendLine("another error")
|
|
m = update(m, process.OutputMsg{Name: "backend"})
|
|
if len(m.searchMatches) != 2 {
|
|
t.Errorf("after new matching line: want 2 matches, got %d", len(m.searchMatches))
|
|
}
|
|
}
|
|
|
|
func TestSearch_eviction(t *testing.T) {
|
|
// Use a tiny scrollback (3 lines) and small window so the VT emulator
|
|
// screen is just 1 row, causing scrollback eviction after 4 lines.
|
|
f := false
|
|
cfg := &config.Config{
|
|
Procs: map[string]config.ProcConfig{"svc": {Shell: "true", Autostart: &f}},
|
|
MouseScrollSpeed: 3,
|
|
Scrollback: 3,
|
|
}
|
|
mgr := process.NewManager(cfg)
|
|
m := New(mgr, cfg, "", nil)
|
|
m = update(m, tea.WindowSizeMsg{Width: 120, Height: 5})
|
|
p, _ := mgr.Get("svc")
|
|
|
|
// Fill the scrollback: lines 0,1,2 = "err0","ok1","err2"
|
|
for _, line := range []string{"err0", "ok1", "err2"} {
|
|
p.AppendLine(line)
|
|
m = update(m, process.OutputMsg{Name: "svc"})
|
|
}
|
|
// Search for "err" → matches at indices 0 and 2
|
|
m = update(m, keypress('/'))
|
|
m = update(m, keypress('e'))
|
|
m = update(m, keypress('r'))
|
|
m = update(m, keypress('r'))
|
|
if len(m.searchMatches) != 2 {
|
|
t.Fatalf("want 2 matches, got %d", len(m.searchMatches))
|
|
}
|
|
|
|
// Append "ok3" — evicts "err0" (index 0).
|
|
// Buffer becomes: ["ok1","err2","ok3"] at indices 0,1,2.
|
|
// "err0" is gone; "err2" shifts to index 1; new line "ok3" at index 2.
|
|
p.AppendLine("ok3")
|
|
m = update(m, process.OutputMsg{Name: "svc"})
|
|
if len(m.searchMatches) != 1 {
|
|
t.Errorf("after evicting matching line: want 1 match, got %d", len(m.searchMatches))
|
|
}
|
|
if m.searchMatches[0] != 1 {
|
|
t.Errorf("surviving match should be at index 1, got %d", m.searchMatches[0])
|
|
}
|
|
}
|
|
|
|
func TestSearch_escClearsActiveSearch(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
p, _ := m.mgr.Get("backend")
|
|
p.AppendLine("something")
|
|
m = update(m, process.OutputMsg{Name: "backend"})
|
|
// Build a search result, then exit via esc
|
|
m = update(m, keypress('/'))
|
|
m = update(m, keypress('s'))
|
|
if m.searchQuery == "" {
|
|
t.Fatal("search query should be set")
|
|
}
|
|
m = update(m, specialKey(tea.KeyEscape))
|
|
if m.searchQuery != "" {
|
|
t.Error("esc should clear search query")
|
|
}
|
|
if len(m.searchMatches) != 0 {
|
|
t.Error("esc should clear search matches")
|
|
}
|
|
}
|
|
|
|
func TestSearch_dockerUsesContainerLogs(t *testing.T) {
|
|
m := readyDockerModel(t)
|
|
|
|
m = update(m, keypress('/'))
|
|
m = update(m, keypress('e'))
|
|
m = update(m, keypress('r'))
|
|
m = update(m, keypress('r'))
|
|
|
|
if len(m.searchMatches) != 1 {
|
|
t.Fatalf("want 1 docker log match for 'err', got %d", len(m.searchMatches))
|
|
}
|
|
if m.searchMatches[0] != 1 {
|
|
t.Fatalf("docker match index: want 1, got %d", m.searchMatches[0])
|
|
}
|
|
}
|
|
|
|
func TestSearch_dockerIgnoresProcessLines(t *testing.T) {
|
|
m := readyDockerModel(t)
|
|
p, _ := m.mgr.Get("docker")
|
|
p.AppendLine("error from process stream")
|
|
|
|
m = update(m, keypress('/'))
|
|
m = update(m, keypress('e'))
|
|
m = update(m, keypress('r'))
|
|
m = update(m, keypress('r'))
|
|
|
|
if len(m.searchMatches) != 1 {
|
|
t.Fatalf("want 1 match from container logs only, got %d", len(m.searchMatches))
|
|
}
|
|
if m.searchMatches[0] != 1 {
|
|
t.Fatalf("docker match index: want 1, got %d", m.searchMatches[0])
|
|
}
|
|
}
|
|
|
|
func TestSearch_dockerIncrementalLogLineUpdatesMatches(t *testing.T) {
|
|
m := readyDockerModel(t)
|
|
m.searchQuery = "err"
|
|
m.recomputeSearch()
|
|
|
|
m = update(m, docker.ContainerLogLineMsg{Service: "web", Line: "new error line"})
|
|
|
|
if len(m.searchMatches) != 2 {
|
|
t.Fatalf("want 2 matches after new docker log line, got %d", len(m.searchMatches))
|
|
}
|
|
if m.searchMatches[1] != 2 {
|
|
t.Fatalf("new docker match index: want 2, got %d", m.searchMatches[1])
|
|
}
|
|
}
|
|
|
|
// ── Filter ────────────────────────────────────────────────────────────────────
|
|
|
|
// enterFilterMode opens search via / and commits to filter via tab.
|
|
func enterFilterMode(m Model) Model {
|
|
m = update(m, keypress('/'))
|
|
m = update(m, specialKey(tea.KeyTab))
|
|
return m
|
|
}
|
|
|
|
func TestFilter_enterAndExit(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m = enterFilterMode(m)
|
|
if !m.filterMode {
|
|
t.Error("/ then tab should enter filter mode")
|
|
}
|
|
m = update(m, specialKey(tea.KeyEscape))
|
|
if m.filterMode {
|
|
t.Error("esc should exit filter mode")
|
|
}
|
|
if m.searchQuery != "" {
|
|
t.Error("esc should clear query")
|
|
}
|
|
}
|
|
|
|
func TestFilter_typeQuery(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m = enterFilterMode(m)
|
|
m = update(m, keypress('e'))
|
|
m = update(m, keypress('r'))
|
|
m = update(m, keypress('r'))
|
|
if m.searchQuery != "err" {
|
|
t.Errorf("typed 'err', got %q", m.searchQuery)
|
|
}
|
|
}
|
|
|
|
func TestFilter_backspace(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m = enterFilterMode(m)
|
|
m = update(m, keypress('e'))
|
|
m = update(m, keypress('r'))
|
|
m = update(m, tea.KeyPressMsg{Code: tea.KeyBackspace, Text: "backspace"})
|
|
if m.searchQuery != "e" {
|
|
t.Errorf("after backspace want %q, got %q", "e", m.searchQuery)
|
|
}
|
|
}
|
|
|
|
func TestFilter_showsOnlyMatchingLines(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
p, _ := m.mgr.Get("backend")
|
|
for _, line := range []string{"hello world", "error here", "another error", "info log"} {
|
|
p.AppendLine(line)
|
|
m = update(m, process.OutputMsg{Name: "backend"})
|
|
}
|
|
m = enterFilterMode(m)
|
|
for _, ch := range "error" {
|
|
m = update(m, keypress(ch))
|
|
}
|
|
// Only 2 lines contain "error", so the viewport should have 2 lines
|
|
if m.viewport.TotalLineCount() != 2 {
|
|
t.Errorf("want 2 filtered lines, got %d", m.viewport.TotalLineCount())
|
|
}
|
|
}
|
|
|
|
func TestFilter_noMatchShowsEmpty(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
p, _ := m.mgr.Get("backend")
|
|
p.AppendLine("hello world")
|
|
m = update(m, process.OutputMsg{Name: "backend"})
|
|
m = enterFilterMode(m)
|
|
for _, ch := range "zzz" {
|
|
m = update(m, keypress(ch))
|
|
}
|
|
if m.viewport.TotalLineCount() != 0 {
|
|
t.Errorf("want 0 lines for no matches, got %d", m.viewport.TotalLineCount())
|
|
}
|
|
}
|
|
|
|
func TestFilter_emptyQueryShowsAllLines(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
p, _ := m.mgr.Get("backend")
|
|
for _, line := range []string{"line 1", "line 2", "line 3"} {
|
|
p.AppendLine(line)
|
|
m = update(m, process.OutputMsg{Name: "backend"})
|
|
}
|
|
before := m.viewport.TotalLineCount()
|
|
m = enterFilterMode(m)
|
|
// With empty filter query, all lines should be visible
|
|
if m.viewport.TotalLineCount() != before {
|
|
t.Errorf("empty filter should show all lines: want %d, got %d", before, m.viewport.TotalLineCount())
|
|
}
|
|
}
|
|
|
|
func TestFilter_isFullScreen(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m = enterFilterMode(m)
|
|
if !m.isFullScreen() {
|
|
t.Error("filter mode should be full screen")
|
|
}
|
|
}
|
|
|
|
func TestFilter_liveUpdate(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
p, _ := m.mgr.Get("backend")
|
|
p.AppendLine("error one")
|
|
m = update(m, process.OutputMsg{Name: "backend"})
|
|
m = enterFilterMode(m)
|
|
for _, ch := range "error" {
|
|
m = update(m, keypress(ch))
|
|
}
|
|
if m.viewport.TotalLineCount() != 1 {
|
|
t.Fatalf("want 1 filtered line initially, got %d", m.viewport.TotalLineCount())
|
|
}
|
|
// New matching line arrives
|
|
p.AppendLine("error two")
|
|
m = update(m, process.OutputMsg{Name: "backend"})
|
|
if m.viewport.TotalLineCount() != 2 {
|
|
t.Errorf("after new matching line: want 2 filtered lines, got %d", m.viewport.TotalLineCount())
|
|
}
|
|
}
|
|
|
|
func TestFilter_exitRestoresAllLines(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
p, _ := m.mgr.Get("backend")
|
|
for _, line := range []string{"error one", "info two", "error three"} {
|
|
p.AppendLine(line)
|
|
m = update(m, process.OutputMsg{Name: "backend"})
|
|
}
|
|
before := m.viewport.TotalLineCount()
|
|
// Enter filter, type query
|
|
m = enterFilterMode(m)
|
|
for _, ch := range "error" {
|
|
m = update(m, keypress(ch))
|
|
}
|
|
if m.viewport.TotalLineCount() >= before {
|
|
t.Fatal("filter should reduce visible lines")
|
|
}
|
|
// Exit filter
|
|
m = update(m, specialKey(tea.KeyEscape))
|
|
if m.viewport.TotalLineCount() != before {
|
|
t.Errorf("after exit, want %d lines restored, got %d", before, m.viewport.TotalLineCount())
|
|
}
|
|
}
|
|
|
|
func TestFilter_negativeExcludesLines(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
p, _ := m.mgr.Get("backend")
|
|
for _, line := range []string{"error: crash", "debug: verbose", "info: started", "warning: slow"} {
|
|
p.AppendLine(line)
|
|
m = update(m, process.OutputMsg{Name: "backend"})
|
|
}
|
|
m = enterFilterMode(m)
|
|
// Type "!debug"
|
|
m = update(m, tea.KeyPressMsg{Code: '!', Mod: tea.ModShift, Text: "!"})
|
|
for _, ch := range "debug" {
|
|
m = update(m, keypress(ch))
|
|
}
|
|
// Should show 3 lines (all except "debug: verbose")
|
|
if m.viewport.TotalLineCount() != 3 {
|
|
t.Errorf("want 3 lines excluding debug, got %d", m.viewport.TotalLineCount())
|
|
}
|
|
}
|
|
|
|
func TestFilter_multipleNegatives(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
p, _ := m.mgr.Get("backend")
|
|
for _, line := range []string{"error: crash", "debug: verbose", "info: started", "warning: slow"} {
|
|
p.AppendLine(line)
|
|
m = update(m, process.OutputMsg{Name: "backend"})
|
|
}
|
|
m = enterFilterMode(m)
|
|
// Type "!debug !warning" (two negative tokens separated by space)
|
|
for _, ch := range "!debug" {
|
|
if ch == '!' {
|
|
m = update(m, tea.KeyPressMsg{Code: '!', Mod: tea.ModShift, Text: "!"})
|
|
} else {
|
|
m = update(m, keypress(ch))
|
|
}
|
|
}
|
|
m = update(m, tea.KeyPressMsg{Code: tea.KeySpace, Text: "space"})
|
|
for _, ch := range "!warning" {
|
|
if ch == '!' {
|
|
m = update(m, tea.KeyPressMsg{Code: '!', Mod: tea.ModShift, Text: "!"})
|
|
} else {
|
|
m = update(m, keypress(ch))
|
|
}
|
|
}
|
|
// Should show 2 lines (error + info)
|
|
if m.viewport.TotalLineCount() != 2 {
|
|
t.Errorf("want 2 lines excluding debug+warning, got %d", m.viewport.TotalLineCount())
|
|
}
|
|
}
|
|
|
|
func TestFilter_spaceInQuery(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m = enterFilterMode(m)
|
|
m = update(m, keypress('h'))
|
|
m = update(m, tea.KeyPressMsg{Code: tea.KeySpace, Text: "space"})
|
|
m = update(m, keypress('w'))
|
|
if m.searchQuery != "h w" {
|
|
t.Errorf("space in filter query: want %q, got %q", "h w", m.searchQuery)
|
|
}
|
|
}
|
|
|
|
func TestFilter_clearedOnProcSwitch(t *testing.T) {
|
|
m := readyModel(t, "alpha", "beta")
|
|
m = enterFilterMode(m)
|
|
m = update(m, keypress('x'))
|
|
if m.searchQuery != "x" {
|
|
t.Fatal("query should be set")
|
|
}
|
|
// Switch to next proc
|
|
m = update(m, specialKey(tea.KeyEscape)) // exit filter first (clears query)
|
|
m = update(m, keypress('j')) // move to beta
|
|
if m.searchQuery != "" {
|
|
t.Error("query should be cleared on proc switch")
|
|
}
|
|
if m.filterMode {
|
|
t.Error("filter mode should be cleared on proc switch")
|
|
}
|
|
}
|
|
|
|
func TestSearch_homeEndScrollsViewport(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
p, _ := m.mgr.Get("backend")
|
|
for i := range 100 {
|
|
p.AppendLine(fmt.Sprintf("line %d", i))
|
|
}
|
|
m = update(m, process.OutputMsg{Name: "backend"})
|
|
m = update(m, keypress('/'))
|
|
m.viewport.GotoBottom()
|
|
m = update(m, specialKey(tea.KeyHome))
|
|
if m.viewport.YOffset() != 0 {
|
|
t.Errorf("home in search mode: want YOffset 0, got %d", m.viewport.YOffset())
|
|
}
|
|
m = update(m, specialKey(tea.KeyEnd))
|
|
if !m.viewport.AtBottom() {
|
|
t.Error("end in search mode: viewport should be at bottom")
|
|
}
|
|
}
|
|
|
|
func TestFilter_homeEndScrollsViewport(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
p, _ := m.mgr.Get("backend")
|
|
for i := range 100 {
|
|
p.AppendLine(fmt.Sprintf("line %d", i))
|
|
}
|
|
m = update(m, process.OutputMsg{Name: "backend"})
|
|
m = enterFilterMode(m)
|
|
m.viewport.GotoBottom()
|
|
m = update(m, specialKey(tea.KeyHome))
|
|
if m.viewport.YOffset() != 0 {
|
|
t.Errorf("home in filter mode: want YOffset 0, got %d", m.viewport.YOffset())
|
|
}
|
|
m = update(m, specialKey(tea.KeyEnd))
|
|
if !m.viewport.AtBottom() {
|
|
t.Error("end in filter mode: viewport should be at bottom")
|
|
}
|
|
}
|
|
|
|
func TestFilter_backspaceOnEmptyGoesBackToSearch(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
p, _ := m.mgr.Get("backend")
|
|
p.AppendLine("hello")
|
|
m = update(m, process.OutputMsg{Name: "backend"})
|
|
m = enterFilterMode(m)
|
|
if !m.filterMode || m.searchQuery != "" {
|
|
t.Fatalf("setup: filterMode=%v query=%q", m.filterMode, m.searchQuery)
|
|
}
|
|
m = update(m, tea.KeyPressMsg{Code: tea.KeyBackspace, Text: "backspace"})
|
|
if m.filterMode {
|
|
t.Error("filter mode should be off after backspace on empty query")
|
|
}
|
|
if !m.searchMode {
|
|
t.Error("search mode should be on after backspace on empty query")
|
|
}
|
|
}
|
|
|
|
func TestFilter_tabGoesBackToSearch(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
p, _ := m.mgr.Get("backend")
|
|
for _, line := range []string{"error here", "info ok", "another error"} {
|
|
p.AppendLine(line)
|
|
m = update(m, process.OutputMsg{Name: "backend"})
|
|
}
|
|
m = enterFilterMode(m)
|
|
for _, ch := range "error" {
|
|
m = update(m, keypress(ch))
|
|
}
|
|
// Tab → back to search mode
|
|
m = update(m, specialKey(tea.KeyTab))
|
|
if m.filterMode {
|
|
t.Error("filter mode should be off after toggle")
|
|
}
|
|
if !m.searchMode {
|
|
t.Error("search mode should be on after toggle")
|
|
}
|
|
if m.searchQuery != "error" {
|
|
t.Errorf("query should carry over, got %q", m.searchQuery)
|
|
}
|
|
if len(m.searchMatches) != 2 {
|
|
t.Errorf("want 2 search matches, got %d", len(m.searchMatches))
|
|
}
|
|
}
|
|
|
|
func TestNormal_fDoesNotEnterFilterMode(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m = update(m, keypress('f'))
|
|
if m.filterMode {
|
|
t.Error("f in normal mode should not enter filter mode")
|
|
}
|
|
if m.searchMode {
|
|
t.Error("f in normal mode should not enter search mode")
|
|
}
|
|
}
|
|
|
|
func TestCopySelectedText_dockerUsesContainerLogs(t *testing.T) {
|
|
m := readyDockerModel(t)
|
|
m.containerLines = []string{"first", "\x1b[31msecond\x1b[0m", "third"}
|
|
m.copyMode = true
|
|
m.copyAnchor = 0
|
|
m.copyCursor = 1
|
|
|
|
got := m.copySelectedText()
|
|
want := "first\nsecond"
|
|
if got != want {
|
|
t.Fatalf("copySelectedText docker: want %q, got %q", want, got)
|
|
}
|
|
}
|
|
|
|
// ── Process output and status messages ───────────────────────────────────────
|
|
|
|
func TestOutputMsg_activeProc(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
// AppendLine writes to the VT emulator; OutputMsg triggers a full reload.
|
|
p, _ := m.mgr.Get("backend")
|
|
p.AppendLine("hello world")
|
|
before := m.viewport.TotalLineCount()
|
|
m = update(m, process.OutputMsg{Name: "backend"})
|
|
after := m.viewport.TotalLineCount()
|
|
if after != before+1 {
|
|
t.Errorf("OutputMsg for active proc: line count want %d, got %d", before+1, after)
|
|
}
|
|
}
|
|
|
|
func TestOutputMsg_inactiveProc(t *testing.T) {
|
|
m := readyModel(t, "backend", "frontend")
|
|
// cursor is on backend (index 0); send output for frontend
|
|
before := m.viewport.TotalLineCount()
|
|
m = update(m, process.OutputMsg{Name: "frontend"})
|
|
after := m.viewport.TotalLineCount()
|
|
if after != before {
|
|
t.Error("OutputMsg for inactive proc should not update viewport")
|
|
}
|
|
}
|
|
|
|
func TestStatusMsg_updatesCursor(t *testing.T) {
|
|
m := readyModel(t, "backend", "frontend")
|
|
m.servicesCursor = 1
|
|
// Simulate enough removals to make cursor out of bounds — a StatusMsg
|
|
// should clamp cursor safely. We can't actually remove procs without
|
|
// Manager internals, so just verify that a StatusMsg for a known proc
|
|
// doesn't panic and doesn't move the cursor unnecessarily.
|
|
m = update(m, process.StatusMsg{Name: "backend", Status: process.StatusRunning})
|
|
if m.servicesCursor > len(m.services)-1 {
|
|
t.Errorf("cursor %d out of bounds after StatusMsg", m.servicesCursor)
|
|
}
|
|
}
|
|
|
|
// ── Viewport scroll anchors ───────────────────────────────────────────────────
|
|
|
|
func TestGotoBottom_setsAtBottom(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m.viewportAtBottom = false
|
|
m = update(m, specialKey(tea.KeyEnd))
|
|
if !m.viewportAtBottom {
|
|
t.Error("end key should set atBottom=true")
|
|
}
|
|
}
|
|
|
|
func TestGotoTop_clearsAtBottom(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m = update(m, specialKey(tea.KeyHome))
|
|
if m.viewportAtBottom {
|
|
t.Error("home key should set atBottom=false")
|
|
}
|
|
}
|
|
|
|
// ── Sorting ──────────────────────────────────────────────────────────────────
|
|
|
|
func serviceNames(m Model) []string {
|
|
names := make([]string, len(m.services))
|
|
for i, p := range m.services {
|
|
names[i] = p.Name
|
|
}
|
|
return names
|
|
}
|
|
|
|
func TestSort_defaultIsName(t *testing.T) {
|
|
m := readyModel(t, "frontend", "backend", "celery")
|
|
got := serviceNames(m)
|
|
want := []string{"backend", "celery", "frontend"}
|
|
for i, n := range got {
|
|
if n != want[i] {
|
|
t.Errorf("index %d: got %q, want %q (full: %v)", i, n, want[i], got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSort_cycleWithO(t *testing.T) {
|
|
m := readyModel(t, "backend", "frontend")
|
|
if m.sortMode != SortName {
|
|
t.Fatalf("initial sort mode: got %v, want SortName", m.sortMode)
|
|
}
|
|
m = update(m, keypress('o'))
|
|
if m.sortMode != SortCPU {
|
|
t.Errorf("after first o: got %v, want SortCPU", m.sortMode)
|
|
}
|
|
m = update(m, keypress('o'))
|
|
if m.sortMode != SortRAM {
|
|
t.Errorf("after second o: got %v, want SortRAM", m.sortMode)
|
|
}
|
|
m = update(m, keypress('o'))
|
|
if m.sortMode != SortStatus {
|
|
t.Errorf("after third o: got %v, want SortStatus", m.sortMode)
|
|
}
|
|
m = update(m, keypress('o'))
|
|
if m.sortMode != SortName {
|
|
t.Errorf("after fourth o: got %v, want SortName (wrap)", m.sortMode)
|
|
}
|
|
}
|
|
|
|
func TestSort_cursorPreservedAcrossModes(t *testing.T) {
|
|
m := readyModel(t, "alpha", "beta", "gamma")
|
|
// Select "gamma" (last in alphabetical order)
|
|
m = update(m, keypress('j'))
|
|
m = update(m, keypress('j'))
|
|
if m.services[m.servicesCursor].Name != "gamma" {
|
|
t.Fatalf("cursor should be on gamma, got %s", m.services[m.servicesCursor].Name)
|
|
}
|
|
// Cycle sort — cursor should stay on gamma
|
|
m = update(m, keypress('o'))
|
|
if m.services[m.servicesCursor].Name != "gamma" {
|
|
t.Errorf("after sort cycle, cursor should stay on gamma, got %s", m.services[m.servicesCursor].Name)
|
|
}
|
|
}
|
|
|
|
func TestSort_cursorPreservedOnStatusMsg(t *testing.T) {
|
|
m := readyModel(t, "alpha", "beta", "gamma")
|
|
// After init+sort, order is alphabetical: alpha, beta, gamma.
|
|
// Navigate to the last entry.
|
|
m = update(m, keypress('j'))
|
|
m = update(m, keypress('j'))
|
|
selected := m.services[m.servicesCursor].Name
|
|
if selected != "gamma" {
|
|
t.Fatalf("cursor should be on gamma, got %s (order: %v)", selected, serviceNames(m))
|
|
}
|
|
// StatusMsg re-fetches and re-sorts — cursor should stay on gamma
|
|
m = update(m, process.StatusMsg{Name: "alpha", Status: process.StatusRunning})
|
|
if m.services[m.servicesCursor].Name != "gamma" {
|
|
t.Errorf("after StatusMsg, cursor should stay on gamma, got %s", m.services[m.servicesCursor].Name)
|
|
}
|
|
}
|
|
|
|
func TestSort_statusOrder(t *testing.T) {
|
|
// All test processes start as StatusStopped since we don't call Start().
|
|
// With equal statuses, status sort falls back to alphabetical.
|
|
m := readyModel(t, "gamma", "alpha", "beta")
|
|
m.sortMode = SortStatus
|
|
m.sortServices()
|
|
|
|
got := serviceNames(m)
|
|
want := []string{"alpha", "beta", "gamma"}
|
|
for i, n := range got {
|
|
if n != want[i] {
|
|
t.Errorf("status sort index %d: got %q, want %q (full: %v)", i, n, want[i], got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSort_statusSortOrder(t *testing.T) {
|
|
// Verify the rank function directly
|
|
tests := []struct {
|
|
status process.Status
|
|
want int
|
|
}{
|
|
{process.StatusRunning, 0},
|
|
{process.StatusPending, 1},
|
|
{process.StatusCrashed, 2},
|
|
{process.StatusStopped, 3},
|
|
{process.StatusDone, 4},
|
|
}
|
|
for i, tt := range tests {
|
|
got := statusSortOrder(tt.status)
|
|
if got != tt.want {
|
|
t.Errorf("statusSortOrder(%v): got %d, want %d (index %d)", tt.status, got, tt.want, i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSort_infoSortsAlphabetically(t *testing.T) {
|
|
m := readyModel(t, "info", "backend", "alpha")
|
|
got := serviceNames(m)
|
|
want := []string{"info", "alpha", "backend"}
|
|
for i, n := range got {
|
|
if n != want[i] {
|
|
t.Errorf("index %d: got %q, want %q (full: %v)", i, n, want[i], got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Setup mode ──────────────────────────────────────────────────────────────
|
|
|
|
func setupModel(t *testing.T) Model {
|
|
t.Helper()
|
|
m := readyModel(t, "backend", "frontend")
|
|
m.setupMode = true
|
|
m.setupStep = 1
|
|
m.setupEntries = []config.Intent{
|
|
{Name: "web", Description: "Web app"},
|
|
{Name: "analytics", Description: "Analytics pipeline"},
|
|
{Name: "api", Description: "API server"},
|
|
}
|
|
m.setupCursor = 0
|
|
m.setupOffset = 0
|
|
m.setupChecked = map[string]bool{"web": true}
|
|
m.setupError = ""
|
|
return m
|
|
}
|
|
|
|
func TestSetup_navigation(t *testing.T) {
|
|
m := setupModel(t)
|
|
m = update(m, keypress('j'))
|
|
if m.setupCursor != 1 {
|
|
t.Errorf("j: cursor want 1, got %d", m.setupCursor)
|
|
}
|
|
m = update(m, keypress('j'))
|
|
if m.setupCursor != 2 {
|
|
t.Errorf("j j: cursor want 2, got %d", m.setupCursor)
|
|
}
|
|
m = update(m, keypress('j'))
|
|
if m.setupCursor != 2 {
|
|
t.Errorf("j at bottom: cursor should clamp at 2, got %d", m.setupCursor)
|
|
}
|
|
m = update(m, keypress('k'))
|
|
if m.setupCursor != 1 {
|
|
t.Errorf("k: cursor want 1, got %d", m.setupCursor)
|
|
}
|
|
}
|
|
|
|
func TestSetup_arrowKeys(t *testing.T) {
|
|
m := setupModel(t)
|
|
m = update(m, specialKey(tea.KeyDown))
|
|
if m.setupCursor != 1 {
|
|
t.Errorf("down: cursor want 1, got %d", m.setupCursor)
|
|
}
|
|
m = update(m, specialKey(tea.KeyUp))
|
|
if m.setupCursor != 0 {
|
|
t.Errorf("up: cursor want 0, got %d", m.setupCursor)
|
|
}
|
|
}
|
|
|
|
func TestSetup_clampsAtTop(t *testing.T) {
|
|
m := setupModel(t)
|
|
m = update(m, keypress('k'))
|
|
if m.setupCursor != 0 {
|
|
t.Errorf("k at top: cursor should stay 0, got %d", m.setupCursor)
|
|
}
|
|
}
|
|
|
|
func TestSetup_toggle(t *testing.T) {
|
|
m := setupModel(t)
|
|
if !m.setupChecked["web"] {
|
|
t.Fatal("web should be checked initially")
|
|
}
|
|
// Toggle web off
|
|
m = update(m, specialKey(tea.KeySpace))
|
|
if m.setupChecked["web"] {
|
|
t.Error("space should uncheck web")
|
|
}
|
|
// Toggle web back on
|
|
m = update(m, specialKey(tea.KeySpace))
|
|
if !m.setupChecked["web"] {
|
|
t.Error("second space should check web")
|
|
}
|
|
}
|
|
|
|
func TestSetup_toggleDifferentEntry(t *testing.T) {
|
|
m := setupModel(t)
|
|
m = update(m, keypress('j')) // move to analytics
|
|
m = update(m, specialKey(tea.KeySpace))
|
|
if !m.setupChecked["analytics"] {
|
|
t.Error("space on analytics should check it")
|
|
}
|
|
if !m.setupChecked["web"] {
|
|
t.Error("web should remain checked")
|
|
}
|
|
}
|
|
|
|
func TestSetup_escExitsFromStep1(t *testing.T) {
|
|
m := setupModel(t)
|
|
m = update(m, specialKey(tea.KeyEscape))
|
|
if m.setupMode {
|
|
t.Error("esc from step 1 should exit setup mode")
|
|
}
|
|
if m.focusedPane != focusServices {
|
|
t.Error("exiting setup should focus sidebar")
|
|
}
|
|
}
|
|
|
|
func TestSetup_escExitsFromStep2(t *testing.T) {
|
|
m := setupModel(t)
|
|
m.setupStep = 2
|
|
m.setupEntries = []config.Intent{{Name: "proc1"}, {Name: "proc2"}}
|
|
m.setupChecked = map[string]bool{"proc1": true, "proc2": true}
|
|
m = update(m, specialKey(tea.KeyEscape))
|
|
if m.setupMode {
|
|
t.Error("esc from step 1 should exit setup mode")
|
|
}
|
|
if m.focusedPane != focusServices {
|
|
t.Error("exiting setup should focus sidebar")
|
|
}
|
|
}
|
|
|
|
func TestSetup_gotoTopAndBottom(t *testing.T) {
|
|
m := setupModel(t)
|
|
m = update(m, specialKey(tea.KeyEnd))
|
|
if m.setupCursor != 2 {
|
|
t.Errorf("end: cursor want 2, got %d", m.setupCursor)
|
|
}
|
|
m = update(m, specialKey(tea.KeyHome))
|
|
if m.setupCursor != 0 {
|
|
t.Errorf("home: cursor want 0, got %d", m.setupCursor)
|
|
}
|
|
}
|
|
|
|
func TestSetup_isFullScreen(t *testing.T) {
|
|
m := setupModel(t)
|
|
if !m.isFullScreen() {
|
|
t.Error("setup mode should be full screen")
|
|
}
|
|
}
|
|
|
|
func TestSetup_handleListUnitsMsg(t *testing.T) {
|
|
m := setupModel(t)
|
|
m.handleListUnitsMsg(listUnitsMsg{
|
|
units: []string{"backend", "celery", "frontend"},
|
|
intents: []string{"web"},
|
|
excluded: map[string]bool{"celery": true},
|
|
})
|
|
if m.setupStep != 2 {
|
|
t.Errorf("step: want 2, got %d", m.setupStep)
|
|
}
|
|
if len(m.setupEntries) != 3 {
|
|
t.Fatalf("entries: want 3, got %d", len(m.setupEntries))
|
|
}
|
|
if !m.setupChecked["backend"] {
|
|
t.Error("backend should be checked")
|
|
}
|
|
if m.setupChecked["celery"] {
|
|
t.Error("celery should be unchecked (excluded)")
|
|
}
|
|
if !m.setupChecked["frontend"] {
|
|
t.Error("frontend should be checked")
|
|
}
|
|
if len(m.setupIntents) != 1 || m.setupIntents[0] != "web" {
|
|
t.Errorf("intents: want [web], got %v", m.setupIntents)
|
|
}
|
|
}
|
|
|
|
func TestSetup_handleListUnitsMsgError(t *testing.T) {
|
|
m := setupModel(t)
|
|
m.handleListUnitsMsg(listUnitsMsg{err: fmt.Errorf("insightscli failed")})
|
|
if m.setupError == "" {
|
|
t.Error("error message should be set")
|
|
}
|
|
if m.setupStep != 1 {
|
|
t.Error("should remain on step 1 after error")
|
|
}
|
|
}
|
|
|
|
func TestSetup_handleDevApplyMsgError(t *testing.T) {
|
|
m := setupModel(t)
|
|
m.handleDevApplyMsg(devApplyMsg{err: fmt.Errorf("apply failed")})
|
|
if m.setupError == "" {
|
|
t.Error("error message should be set")
|
|
}
|
|
if !m.setupMode {
|
|
t.Error("should remain in setup mode after error")
|
|
}
|
|
}
|
|
|
|
// ── Mascot mode ───────────────────────────────────────────────────────────
|
|
|
|
func TestMascot_enterAndExit(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m = update(m, keypress('h'))
|
|
if !m.mascotMode {
|
|
t.Error("h should enter mascot mode")
|
|
}
|
|
if m.mascotX != 0 {
|
|
t.Errorf("initial X: got %d, want 0", m.mascotX)
|
|
}
|
|
if m.mascotDir != 1 {
|
|
t.Errorf("initial dir: got %d, want 1 (right)", m.mascotDir)
|
|
}
|
|
m = update(m, keypress('h'))
|
|
if m.mascotMode {
|
|
t.Error("second h should exit mascot mode")
|
|
}
|
|
}
|
|
|
|
func TestMascot_exitWithEscape(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m = update(m, keypress('h'))
|
|
if !m.mascotMode {
|
|
t.Fatal("should be in mascot mode")
|
|
}
|
|
m = update(m, specialKey(tea.KeyEscape))
|
|
if m.mascotMode {
|
|
t.Error("esc should exit mascot mode")
|
|
}
|
|
}
|
|
|
|
func TestMascot_jump(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m = update(m, keypress('h'))
|
|
if m.mascotY != 0 {
|
|
t.Fatalf("initial Y: got %d, want 0", m.mascotY)
|
|
}
|
|
m = update(m, specialKey(tea.KeySpace))
|
|
if m.mascotY != 1 {
|
|
t.Errorf("Y after jump: got %d, want 1", m.mascotY)
|
|
}
|
|
if m.mascotVelY != 1 {
|
|
t.Errorf("velY after jump: got %d, want 1", m.mascotVelY)
|
|
}
|
|
}
|
|
|
|
func TestMascot_jumpOnlyFromGround(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m = update(m, keypress('h'))
|
|
// First jump
|
|
m = update(m, specialKey(tea.KeySpace))
|
|
y := m.mascotY
|
|
vel := m.mascotVelY
|
|
// Second jump while airborne should be ignored
|
|
m = update(m, specialKey(tea.KeySpace))
|
|
if m.mascotY != y || m.mascotVelY != vel {
|
|
t.Error("space while airborne should not change Y or velY")
|
|
}
|
|
}
|
|
|
|
func TestMascot_advanceMoves(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m.mascotMode = true
|
|
m.mascotDir = 1
|
|
m.mascotX = 0
|
|
m.mascotFrame = 0
|
|
|
|
m.advanceMascot()
|
|
if m.mascotX != 1 {
|
|
t.Errorf("X after advance: got %d, want 1", m.mascotX)
|
|
}
|
|
if m.mascotFrame != 1 {
|
|
t.Errorf("frame after advance: got %d, want 1", m.mascotFrame)
|
|
}
|
|
}
|
|
|
|
func TestMascot_bounceAtEdge(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m.mascotMode = true
|
|
spriteW := len(mascotFramesRight[0][0])
|
|
m.mascotX = m.viewport.Width() - spriteW
|
|
m.mascotDir = 1
|
|
|
|
m.advanceMascot()
|
|
if m.mascotDir != -1 {
|
|
t.Errorf("dir after hitting right edge: got %d, want -1", m.mascotDir)
|
|
}
|
|
}
|
|
|
|
func TestMascot_bounceAtLeftEdge(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m.mascotMode = true
|
|
m.mascotX = 0
|
|
m.mascotDir = -1
|
|
|
|
m.advanceMascot()
|
|
if m.mascotDir != 1 {
|
|
t.Errorf("dir after hitting left edge: got %d, want 1", m.mascotDir)
|
|
}
|
|
}
|
|
|
|
func TestMascot_gravity(t *testing.T) {
|
|
m := readyModel(t, "backend")
|
|
m.mascotMode = true
|
|
m.mascotY = 2
|
|
m.mascotVelY = 0
|
|
m.mascotDir = 1
|
|
|
|
m.advanceMascot()
|
|
if m.mascotY != 2 {
|
|
t.Errorf("Y with velY=0 at Y=2: got %d, want 2", m.mascotY)
|
|
}
|
|
// Y>0 and velY=0 means gravity pulls down: velY becomes -1
|
|
if m.mascotVelY != -1 {
|
|
t.Errorf("velY should decrease by 1, got %d", m.mascotVelY)
|
|
}
|
|
|
|
m.advanceMascot()
|
|
// Y=2+(-1)=1, velY=-1-1=-2
|
|
if m.mascotY != 1 {
|
|
t.Errorf("Y after gravity: got %d, want 1", m.mascotY)
|
|
}
|
|
|
|
m.advanceMascot()
|
|
// Y=1+(-2)=-1 → clamped to 0
|
|
if m.mascotY != 0 {
|
|
t.Errorf("Y should clamp to 0, got %d", m.mascotY)
|
|
}
|
|
if m.mascotVelY != 0 {
|
|
t.Errorf("velY should reset to 0 on landing, got %d", m.mascotVelY)
|
|
}
|
|
}
|