clone

a lazygit-style terminal viewer for openspec repos: watch changes, tasks and spec deltas land as agents work

main
lazyopenspec / internal / ui / model_test.go
model_test.go 6.0K
1package ui23import (4 "path/filepath"5 "strings"6 "testing"7 "time"89 tea "charm.land/bubbletea/v2"10 "github.com/charmbracelet/x/ansi"1112 "src.rest/cat/lazyopenspec/internal/spec"13)1415func fixtureModel(t *testing.T, w, h int) Model {16 t.Helper()17 root, err := filepath.Abs("../../testdata/fixture")18 if err != nil {19 t.Fatal(err)20 }21 snap, err := spec.Load(root)22 if err != nil {23 t.Fatal(err)24 }25 m := New(Options{Root: root, Interval: time.Hour})26 return step(t, step(t, m, tea.WindowSizeMsg{Width: w, Height: h}), loadedMsg{snap: snap})27}2829func step(t *testing.T, m Model, msg tea.Msg) Model {30 t.Helper()31 next, _ := m.Update(msg)32 return next.(Model)33}3435func key(s string) tea.KeyPressMsg {36 switch s {37 case "tab":38 return tea.KeyPressMsg{Code: tea.KeyTab}39 case "enter":40 return tea.KeyPressMsg{Code: tea.KeyEnter}41 }42 return tea.KeyPressMsg{Code: rune(s[0]), Text: s}43}4445// Every frame must be exactly the terminal size, at any size, in any panel.46func TestViewFitsTerminal(t *testing.T) {47 for _, size := range [][2]int{{160, 45}, {80, 24}, {84, 46}, {60, 90}, {40, 10}, {20, 5}, {3, 2}, {1, 1}} {48 m := fixtureModel(t, size[0], size[1])49 for _, k := range []string{"", "tab", "j", "]", "z", "?", "x", "2", "enter", "d", "v", "tab", "v"} {50 if k != "" {51 m = step(t, m, key(k))52 }53 lines := strings.Split(m.View().Content, "\n")54 if len(lines) != size[1] {55 t.Fatalf("%dx%d after %q: %d lines, want %d", size[0], size[1], k, len(lines), size[1])56 }57 for i, l := range lines {58 if w := ansi.StringWidth(l); w > size[0] {59 t.Fatalf("%dx%d after %q: line %d is %d wide", size[0], size[1], k, i, w)60 }61 }62 }63 }64}6566func TestSelectionFollowsIDAcrossReloads(t *testing.T) {67 m := fixtureModel(t, 120, 40)68 m = step(t, m, key("j")) // add-due-dates69 if got := m.selected[panelChanges]; got != "add-due-dates" {70 t.Fatalf("selected %q", got)71 }72 // A change sorting before it appears; the cursor should stay on add-due-dates.73 next := *m.snap74 next.Changes = append([]spec.Change{{ID: "aaa-new", Name: "aaa-new"}}, next.Changes...)75 m = step(t, m, loadedMsg{snap: &next})76 if got := m.idAt(panelChanges, m.cursors[panelChanges]); got != "add-due-dates" {77 t.Errorf("after reload cursor on %q, want add-due-dates", got)78 }79 if len(m.events) != 1 || m.events[0].ID != "aaa-new" {80 t.Errorf("events = %+v", m.events)81 }82}8384func TestResizeKeepsCursorVisible(t *testing.T) {85 m := fixtureModel(t, 120, 60)86 for range 4 {87 m = step(t, m, key("j"))88 }89 m = step(t, m, tea.WindowSizeMsg{Width: 120, Height: 16})90 h := m.geometry().panels[panelChanges].h - 291 c, off := m.cursors[panelChanges], m.offsets[panelChanges]92 if c < off || c >= off+h {93 t.Errorf("cursor %d not within visible rows [%d,%d)", c, off, off+h)94 }95}9697func TestAllocateFillsTotal(t *testing.T) {98 share := [numLists]int{35, 25, 15, 25}99 for _, total := range []int{4, 10, 37, 100} {100 rows := allocate([numLists]int{50, 3, 2, 1}, share, total, panelChanges)101 sum := 0102 for _, r := range rows {103 if r < 1 {104 t.Errorf("total %d: panel got %d rows", total, r)105 }106 sum += r107 }108 if sum != total {109 t.Errorf("total %d: allocated %d (%v)", total, sum, rows)110 }111 }112}113114func TestPortraitAuto(t *testing.T) {115 // lazygit's defaults: portrait when width <= 84 and height >= 46.116 tests := []struct {117 w, h int118 want bool119 }{120 {84, 46, true},121 {60, 120, true},122 {85, 46, false},123 {84, 45, false},124 {160, 45, false},125 }126 for _, tt := range tests {127 if got := fixtureModel(t, tt.w, tt.h).geometry().portrait; got != tt.want {128 t.Errorf("%dx%d: portrait = %v, want %v", tt.w, tt.h, got, tt.want)129 }130 }131 m := fixtureModel(t, 160, 45)132 m.portraitMode = PortraitAlways133 if !m.geometry().portrait {134 t.Error("always: not portrait at 160x45")135 }136 m = fixtureModel(t, 60, 120)137 m.portraitMode = PortraitNever138 if m.geometry().portrait {139 t.Error("never: portrait at 60x120")140 }141}142143func TestPortraitStacksPanelsAboveMain(t *testing.T) {144 m := fixtureModel(t, 80, 62) // body 60: side 20, main 40145 g := m.geometry()146 if g.main.x != 0 || g.main.w != 80 || g.main.y != headerRows+20 || g.main.h != 40 {147 t.Errorf("main = %+v, want full width below a 20-row side section", g.main)148 }149 y := headerRows150 for p, r := range g.panels {151 if r.x != 0 || r.w != 80 || r.y != y {152 t.Errorf("panel %d = %+v, want full width at y=%d", p, r, y)153 }154 y += r.h155 }156 if y != g.main.y {157 t.Errorf("panels end at y=%d, main starts at %d", y, g.main.y)158 }159}160161func TestSquashedSidePanels(t *testing.T) {162 // A 20-row side section is below the normal-layout threshold (22) but163 // tall enough (>= 16) for 3-row squashed panels, like lazygit.164 m := fixtureModel(t, 80, 62)165 if got := m.sideHeights(20); got != [numLists]int{11, 3, 3, 3} {166 t.Errorf("focused Changes: heights %v", got)167 }168 m = step(t, m, key("2"))169 if got := m.sideHeights(20); got != [numLists]int{3, 11, 3, 3} {170 t.Errorf("focused Specs: heights %v", got)171 }172 m = step(t, m, key("0")) // main focused: Specs still drives it and stays open173 if got := m.sideHeights(20); got != [numLists]int{3, 11, 3, 3} {174 t.Errorf("main focused: heights %v", got)175 }176 if got := m.sideHeights(12); got != [numLists]int{1, 9, 1, 1} {177 t.Errorf("short section: heights %v, want title-only squashed panels", got)178 }179 if got := m.sideHeights(40); got[0] < 4 || got[1] < 4 {180 t.Errorf("tall section should not squash: %v", got)181 }182}183184func TestTabWindowKeepsActiveTabVisible(t *testing.T) {185 m := fixtureModel(t, 120, 40)186 parts := []string{"proposal.md", "design.md", "tasks.md", "Δ one", "Δ two", "Δ three", "Δ four", "Δ five"}187 for active := range parts {188 got := ansi.Strip(m.tabWindow(parts, active, 30))189 if w := ansi.StringWidth(got); w > 30 {190 t.Errorf("active %d: %q is %d wide", active, got, w)191 }192 if !strings.Contains(got, parts[active]) {193 t.Errorf("active %d: %q hides the active tab", active, got)194 }195 }196 if got := ansi.Strip(m.tabWindow(parts, 7, 30)); !strings.HasPrefix(got, " ‹ ") {197 t.Errorf("tabs hidden on the left should be marked: %q", got)198 }199 if got := ansi.Strip(m.tabWindow(parts, 0, 30)); !strings.HasSuffix(got, " ›") {200 t.Errorf("tabs hidden on the right should be marked: %q", got)201 }202}