11package tui
22
33import (
4+ "bytes"
45 "errors"
56 "fmt"
67 "image/color"
8+ "io"
79 "os"
810 "path/filepath"
911 "reflect"
@@ -12,10 +14,12 @@ import (
1214 "time"
1315
1416 tea "charm.land/bubbletea/v2"
15- "charm.land/lipgloss/v2"
1617 "github.com/charmbracelet/x/ansi"
18+ "github.com/charmbracelet/x/exp/teatest/v2"
1719
1820 "github.com/RandomCodeSpace/kb/internal/board"
21+ "github.com/RandomCodeSpace/kb/internal/tui/theme"
22+ "github.com/RandomCodeSpace/kb/internal/tui/widget"
1923)
2024
2125func boardViewFixture (now time.Time ) board.Board {
@@ -81,28 +85,34 @@ func TestDueChipParity(t *testing.T) {
8185 }
8286}
8387
88+ // TestLabelColorUsesWebHash pins the label wheel the board has always used.
89+ // The hash and the pill vocabulary now live in the widget package (spec
90+ // sections 1.6 and 3.5), but the colors a tag lands on are unchanged.
8491func TestLabelColorUsesWebHash (t * testing.T ) {
85- tests := []struct {
92+ styles := theme .New (true )
93+ for _ , tag := range []string {
94+ "backend" ,
95+ "🙂" , // JavaScript length is two UTF-16 units.
96+ "" ,
97+ } {
98+ if got := theme .LabelSlot (widget .LabelWheel (tag )); got != theme .Label1 {
99+ t .Errorf ("label slot(%q) = %v, want %v" , tag , got , theme .Label1 )
100+ }
101+ }
102+ for _ , test := range []struct {
86103 tag string
87- want color. Color
104+ want string
88105 }{
89- {"backend" , lipgloss .Color ("#ff7b54" )},
90- {"🙂" , lipgloss .Color ("#ff7b54" )}, // JavaScript length is two UTF-16 units.
91- {"" , lipgloss .Color ("#ff7b54" )},
92- }
93- for _ , test := range tests {
94- if got := labelColor (test .tag ); ! reflect .DeepEqual (got , test .want ) {
95- t .Errorf ("labelColor(%q) = %v, want %v" , test .tag , got , test .want )
106+ {"type::feature" , "▐type:feature▌" },
107+ {"backend" , "▐#backend▌" },
108+ {"broken::" , "▐#broken::▌" },
109+ } {
110+ if got := plain (widget .Label (styles , test .tag , theme .Card , false )); got != test .want {
111+ t .Errorf ("label pill(%q) = %q, want %q" , test .tag , got , test .want )
96112 }
97113 }
98- if got := plain (labelChip ("type::feature" )); got != "[type:feature]" {
99- t .Fatalf ("scoped chip = %q" , got )
100- }
101- if got := plain (labelChip ("backend" )); got != "[#backend]" {
102- t .Fatalf ("plain chip = %q" , got )
103- }
104- if got := plain (labelChip ("broken::" )); got != "[#broken::]" {
105- t .Fatalf ("empty scoped value chip = %q" , got )
114+ if got := plain (widget .Label (styles , "type::feature" , theme .Card , true )); got != "feature" {
115+ t .Fatalf ("compact scoped label = %q" , got )
106116 }
107117}
108118
@@ -239,20 +249,32 @@ func TestBoardRenderResponsiveFullCardsAndMouse(t *testing.T) {
239249 m .board = boardViewFixture (now )
240250 m .now = func () time.Time { return now }
241251 m .renderedAt = now
242- m .width , m .height = 160 , 22
252+ m .width , m .height = 160 , 40
243253 m .boardView .showCancelled = true
244254
245255 content , hits := m .renderBoard ()
246256 text := plain (content )
247257 for _ , want := range []string {
248- "[ 1 TO DO 2] " , "2 DOING 1 " , "3 DONE 1 " , "4 CANCELLED 1 " ,
249- "🚀 Ship terminal board" , "#7" , "new" , "P1" , "[⛔ blocked] " , "[ today] " , "[M] " , "[ #backend] " , "[ type:feature] " ,
258+ "▸ 1 TO DO" , "● 2 DOING" , "● 3 DONE" , "● 4 CANCELLED" , "2 cards · 1 blocked " ,
259+ "🚀 Ship terminal board" , "#7" , "new" , "P1" , "▐ blocked▌ " , "▐ today▌ " , "◇M " , "▐ #backend▌ " , "▐ type:feature▌ " ,
250260 "3h here" , "shipped" , "1d old" , "c cancelled:on" ,
251261 } {
252262 if ! strings .Contains (text , want ) {
253263 t .Errorf ("wide render missing %q:\n %s" , want , text )
254264 }
255265 }
266+ // Compaction drops the description, the meta line and the pill end caps.
267+ m .height = 22
268+ compact := plain (m .render ())
269+ for _ , want := range []string {"P1 new ⛔ !today ◇M #backend feature" , "P2 3h here !tomorrow" } {
270+ if ! strings .Contains (compact , want ) {
271+ t .Errorf ("compact render missing %q:\n %s" , want , compact )
272+ }
273+ }
274+ if strings .Contains (compact , "cards · " ) || strings .Contains (compact , "▐blocked▌" ) {
275+ t .Errorf ("compact render kept normal-density chrome:\n %s" , compact )
276+ }
277+ m .height = 40
256278 if len (hits ) < 9 { // four columns and five cards.
257279 t .Fatalf ("render hits = %+v" , hits )
258280 }
@@ -282,7 +304,7 @@ func TestBoardRenderResponsiveFullCardsAndMouse(t *testing.T) {
282304 m .width = 99
283305 m .boardView .column = 2
284306 narrow := plain (m .render ())
285- if ! strings .Contains (narrow , "[ 3 DONE 1] " ) || strings .Contains (narrow , "TO DO" ) || strings .Contains (narrow , "CANCELLED" ) {
307+ if ! strings .Contains (narrow , "▸ 3 DONE" ) || strings .Contains (narrow , "TO DO" ) || strings .Contains (narrow , "CANCELLED" ) {
286308 t .Fatalf ("narrow focused column:\n %s" , narrow )
287309 }
288310 for _ , line := range strings .Split (m .render (), "\n " ) {
@@ -292,6 +314,82 @@ func TestBoardRenderResponsiveFullCardsAndMouse(t *testing.T) {
292314 }
293315}
294316
317+ // TestBoardCardsColorGolden is the palette golden of spec section 6.4: the
318+ // depth model is background color, so the board's second golden pins truecolor
319+ // and records the shade tiers, the band hues, the priority rails and the pills.
320+ func TestBoardCardsColorGolden (t * testing.T ) {
321+ now := time .Date (2026 , 8 , 17 , 12 , 0 , 0 , 0 , time .UTC )
322+ fixture := boardViewFixture (now )
323+ fixture .Tasks [0 ].Desc = "Pointer capture leaks when the column scrolls under the drag ghost"
324+ m := NewModel (stubBoardReader {board : fixture }, nil , "alice" )
325+ m .loading = false
326+ m .board = fixture
327+ m .now = func () time.Time { return now }
328+ m .renderedAt = now
329+ sized , _ := m .Update (tea.WindowSizeMsg {Width : 120 , Height : 40 })
330+ m = sized .(Model )
331+ tm := teatest .NewTestModel (t , m ,
332+ teatest .WithInitialTermSize (120 , 40 ),
333+ teatest .WithProgramOptions (theme .PinColor ()),
334+ )
335+ t .Cleanup (func () { _ = tm .Quit () })
336+ var captured bytes.Buffer
337+ teatest .WaitFor (t , io .TeeReader (tm .Output (), & captured ), func (output []byte ) bool {
338+ return bytes .Contains (output , []byte ("Ship terminal board" ))
339+ }, teatest .WithDuration (5 * time .Second ), teatest .WithCheckInterval (10 * time .Millisecond ))
340+ frame , ok := finalFullScreenFrame (captured .Bytes ())
341+ if ! ok {
342+ t .Fatal ("teatest output did not contain a full-screen frame" )
343+ }
344+ grid , err := renderedCellGrid (frame , 120 , 40 )
345+ if err != nil {
346+ t .Fatal (err )
347+ }
348+ teatest .RequireEqualOutput (t , grid )
349+ tm .Send (tea.KeyPressMsg {Code : 'q' })
350+ tm .WaitFinished (t , teatest .WithFinalTimeout (5 * time .Second ))
351+ }
352+
353+ // TestNarrowTallBoardGolden is the 60x50 capture ticket #141 asked for to tune
354+ // the compaction width axis. Below the wide-frame threshold kb shows a single
355+ // column, and the spec's MaxColumnWidth clamp holds it at 52, so a card's inner
356+ // field is 47 cells and the width axis does not fire at this size: the frame is
357+ // tall, the description gets its second line, and the density stays normal. The
358+ // 22 threshold is therefore left at the spec's value; it fires between frame
359+ // widths 100 and ~116, where four columns share the frame.
360+ func TestNarrowTallBoardGolden (t * testing.T ) {
361+ now := time .Date (2026 , 8 , 17 , 12 , 0 , 0 , 0 , time .UTC )
362+ fixture := boardViewFixture (now )
363+ fixture .Tasks [0 ].Desc = "Pointer capture leaks when the column scrolls under the drag ghost and the row grid stays fixed"
364+ m := NewModel (stubBoardReader {board : fixture }, nil , "alice" )
365+ m .loading = false
366+ m .board = fixture
367+ m .now = func () time.Time { return now }
368+ m .renderedAt = now
369+ sized , _ := m .Update (tea.WindowSizeMsg {Width : 60 , Height : 50 })
370+ m = sized .(Model )
371+ tm := teatest .NewTestModel (t , m ,
372+ teatest .WithInitialTermSize (60 , 50 ),
373+ teatest .WithProgramOptions (theme .PinStructure ()),
374+ )
375+ t .Cleanup (func () { _ = tm .Quit () })
376+ var captured bytes.Buffer
377+ teatest .WaitFor (t , io .TeeReader (tm .Output (), & captured ), func (output []byte ) bool {
378+ return bytes .Contains (output , []byte ("Ship terminal" ))
379+ }, teatest .WithDuration (5 * time .Second ), teatest .WithCheckInterval (10 * time .Millisecond ))
380+ frame , ok := finalFullScreenFrame (captured .Bytes ())
381+ if ! ok {
382+ t .Fatal ("teatest output did not contain a full-screen frame" )
383+ }
384+ grid , err := renderedCellGrid (frame , 60 , 50 )
385+ if err != nil {
386+ t .Fatal (err )
387+ }
388+ teatest .RequireEqualOutput (t , grid )
389+ tm .Send (tea.KeyPressMsg {Code : 'q' })
390+ tm .WaitFinished (t , teatest .WithFinalTimeout (5 * time .Second ))
391+ }
392+
295393func TestViewIsByteStableAcrossMovingWallClock (t * testing.T ) {
296394 stamp := time .Date (2026 , 8 , 18 , 12 , 0 , 0 , 0 , time .UTC )
297395 m := NewModel (stubBoardReader {}, nil , "alice" )
@@ -348,7 +446,8 @@ func TestPollTickRefreshesRenderTimeAtRolloverBoundary(t *testing.T) {
348446 }
349447
350448 rolled := plain (m .View ().Content )
351- for _ , want := range []string {"1d old" , "overdue · 1d" } {
449+ // The frame is short, so the due pill is the compact "!" mark.
450+ for _ , want := range []string {"1d old" , "!1d" } {
352451 if ! strings .Contains (rolled , want ) {
353452 t .Fatalf ("post-tick render missing %q:\n %s" , want , rolled )
354453 }
@@ -448,22 +547,92 @@ func TestBoardViewSmallHelpers(t *testing.T) {
448547 if statusIndex ("unknown" ) != 0 || statusLabel ("unknown" ) != "" {
449548 t .Fatal ("unknown status helpers changed" )
450549 }
451- if got := plain (priorityChip (99 )); got != "P3" {
550+ if got := columnHue ("unknown" ); got != theme .HueTodo {
551+ t .Fatalf ("unknown column hue = %v" , got )
552+ }
553+ if got := plain (widget .Priority (theme .New (true ), 99 , theme .Card )); got != "P3" {
452554 t .Fatalf ("invalid priority fallback = %q" , got )
453555 }
454556 if got := padLine ("abcdef" , 3 , "-" ); got != "abc" {
455557 t .Fatalf ("truncated pad = %q" , got )
456558 }
457- if got := wrapTokens ([]string {"one" , "two" , "verylong" }, 4 ); ! reflect .DeepEqual (got , []string {"one" , "two" , "very" }) {
458- t .Fatalf ("wrapTokens = %q" , got )
559+ if got := compactDue ("overdue · 2d" ); got != "2d" {
560+ t .Fatalf ("compact due = %q" , got )
561+ }
562+ if got := columnMetaLine ([]board.Task {{}}); got != "1 card" {
563+ t .Fatalf ("single card meta = %q" , got )
564+ }
565+ if got := hiddenCards ([]string {"a" , "a" , "" , "b" }, 2 ); got != 1 {
566+ t .Fatalf ("hidden cards = %d" , got )
459567 }
460568 if got := visibleCardStart ([]string {"a" , "" , "b" }, []string {"a" , "" , "b" }, 1 , 2 ); got != 1 {
461569 t .Fatalf ("visible start = %d" , got )
462570 }
571+ // A Model assembled field by field still renders: the board falls back to
572+ // the default dark palette when no theme was resolved for it.
463573 column := Model {board : board.Board {Tasks : []board.Task {{ID : "x" , Title : "x" , Status : board .StatusTodo }}}, boardView : boardViewState {}, renderedAt : time .Now ()}.renderBoardColumn (board .StatusTodo , 2 , 4 )
464- if len (column .lines ) != 4 || ! strings . Contains (column .lines [0 ], "TO" ) {
574+ if len (column .lines ) != 4 || plain (column .lines [0 ]) != "▸ " {
465575 t .Fatalf ("tiny column = %+v" , column )
466576 }
577+ if empty := (Model {}).renderBoardColumnAt (board .StatusTodo , 0 , 0 , theme .DensityCompact ); len (empty .lines ) != 0 || len (empty .hits ) != 2 {
578+ t .Fatalf ("zero-sized column = %+v" , empty )
579+ }
580+ }
581+
582+ // TestBoardStateCarriesSemanticHue pins the footer's state segment onto the
583+ // status colors of spec section 1.5.
584+ func TestBoardStateCarriesSemanticHue (t * testing.T ) {
585+ base := NewModel (stubBoardReader {}, nil , "u" )
586+ base .loading = false
587+ for _ , test := range []struct {
588+ name string
589+ setup func (* Model )
590+ want string
591+ slot theme.Slot
592+ }{
593+ {"ready" , func (* Model ) {}, "ready" , theme .StatusOK },
594+ {"action ok" , func (m * Model ) {
595+ m .actionNotice , m .actionStatus = true , "shipped one card"
596+ }, "shipped one card" , theme .StatusOK },
597+ {"action error" , func (m * Model ) {
598+ m .actionNotice , m .actionStatus , m .actionStatusError = true , "ship failed" , true
599+ }, "ship failed" , theme .StatusDanger },
600+ {"load error" , func (m * Model ) { m .loadErr = errors .New ("gone" ) }, "error: gone" , theme .StatusDanger },
601+ {"poll error" , func (m * Model ) { m .pollErr = errors .New ("stale" ) }, "error: stale" , theme .StatusDanger },
602+ {"preference error" , func (m * Model ) { m .preferenceErr = errors .New ("disk" ) }, "error: disk" , theme .StatusDanger },
603+ {"move status" , func (m * Model ) { m .move .status = "moved" }, "moved" , theme .StatusWarn },
604+ {"loading" , func (m * Model ) { m .loading , m .haveBoardSnapshot = true , false }, "loading board..." , theme .FgMuted },
605+ } {
606+ t .Run (test .name , func (t * testing.T ) {
607+ m := base
608+ test .setup (& m )
609+ state , slot := m .boardState ()
610+ if state != test .want || slot != test .slot {
611+ t .Fatalf ("board state = %q,%v want %q,%v" , state , slot , test .want , test .slot )
612+ }
613+ if ! strings .Contains (plain (m .render ()), test .want ) {
614+ t .Fatalf ("footer dropped %q:\n %s" , test .want , plain (m .render ()))
615+ }
616+ })
617+ }
618+ }
619+
620+ // TestBackgroundColorRebuildsTheme is spec section 6.3: the palette defaults to
621+ // dark and is rebuilt exactly once, when the terminal answers.
622+ func TestBackgroundColorRebuildsTheme (t * testing.T ) {
623+ m := NewModel (stubBoardReader {}, nil , "u" )
624+ m .loading = false
625+ before := m .styles
626+ if before == nil {
627+ t .Fatal ("constructed model carries no resolved theme" )
628+ }
629+ updateTestModel (t , & m , tea.BackgroundColorMsg {Color : color .White })
630+ if m .styles == nil || m .styles == before {
631+ t .Fatal ("background color answer did not rebuild the theme" )
632+ }
633+ if ! strings .Contains (plain (m .render ()), "ready" ) {
634+ t .Fatal ("rebuilt theme stopped rendering the board" )
635+ }
467636}
468637
469638func TestCancelledPreferencePathAndIsolation (t * testing.T ) {
0 commit comments