44package app
55
66import (
7+ "fmt"
78 "testing"
9+ "time"
810
911 tea "github.com/charmbracelet/bubbletea"
1012 zone "github.com/lrstanley/bubblezone"
@@ -33,6 +35,27 @@ func requireZone(t *testing.T, m *Model, id string) *zone.ZoneInfo {
3335 return z
3436}
3537
38+ // drilldownColX returns the X coordinate of the drilldown column's header
39+ // zone. This is needed because row clicks also select the column, so tests
40+ // that expect drilldown must click at the drilldown column's X position.
41+ func drilldownColX (t * testing.T , m * Model , tab * Tab ) int {
42+ t .Helper ()
43+ m .View ()
44+ width := m .effectiveWidth ()
45+ normalSep := m .styles .TableSeparator ().Render (" \u2502 " )
46+ vp := computeTableViewport (tab , width , normalSep , m .cur .Symbol ())
47+ for vi , fi := range vp .VisToFull {
48+ if fi < len (tab .Specs ) && tab .Specs [fi ].Kind == cellDrilldown {
49+ z := m .zones .Get (fmt .Sprintf ("%s%d" , zoneCol , vi ))
50+ if z != nil && ! z .IsZero () {
51+ return z .StartX
52+ }
53+ }
54+ }
55+ t .Skip ("drilldown column zone not rendered" )
56+ return 0
57+ }
58+
3659// TestTabClickSwitchesTab verifies that clicking on a tab changes the
3760// active tab, simulating a real user left-click on tab zone markers.
3861func TestTabClickSwitchesTab (t * testing.T ) {
@@ -79,6 +102,31 @@ func TestRowClickMovesCursor(t *testing.T) {
79102 assert .Equal (t , 1 , tab .Table .Cursor (), "clicking row-1 should move cursor to row 1" )
80103}
81104
105+ // TestRowClickSelectsColumn verifies that clicking on a cell within a row
106+ // also moves the column cursor to the clicked column.
107+ func TestRowClickSelectsColumn (t * testing.T ) {
108+ t .Parallel ()
109+ m := newTestModelWithDemoData (t , 42 )
110+
111+ tab := m .effectiveTab ()
112+ require .NotNil (t , tab )
113+ require .Greater (t , len (tab .CellRows ), 1 , "need at least 2 rows" )
114+ require .Greater (t , len (tab .Specs ), 1 , "need at least 2 columns" )
115+
116+ tab .Table .SetCursor (0 )
117+ tab .ColCursor = 0
118+
119+ // Get the second column header zone for its X range.
120+ colZone := requireZone (t , m , "col-1" )
121+ // Get a row zone for its Y range.
122+ rowZone := requireZone (t , m , "row-1" )
123+
124+ // Click at the X of column 1, Y of row 1.
125+ sendClick (m , colZone .StartX , rowZone .StartY )
126+ assert .Equal (t , 1 , tab .Table .Cursor (), "clicking should move row cursor to row 1" )
127+ assert .NotEqual (t , 0 , tab .ColCursor , "clicking in column 1 area should move column cursor" )
128+ }
129+
82130// TestScrollWheelMovesCursor verifies that scroll wheel events move the
83131// table cursor like j/k.
84132func TestScrollWheelMovesCursor (t * testing.T ) {
@@ -232,9 +280,9 @@ func TestMouseNoOpOnRelease(t *testing.T) {
232280 assert .Equal (t , before , m .active , "mouse release should not change state" )
233281}
234282
235- // TestSelectedRowClickDrillsDown verifies that clicking an already-selected
236- // row triggers drilldown (same as pressing enter).
237- func TestSelectedRowClickDrillsDown (t * testing.T ) {
283+ // TestDoubleClickRowDrillsDown verifies that double- clicking a row triggers
284+ // drilldown (same as pressing enter).
285+ func TestDoubleClickRowDrillsDown (t * testing.T ) {
238286 t .Parallel ()
239287 m := newTestModelWithDemoData (t , 42 )
240288
@@ -255,10 +303,115 @@ func TestSelectedRowClickDrillsDown(t *testing.T) {
255303 }
256304
257305 tab .Table .SetCursor (0 )
306+ colX := drilldownColX (t , m , tab )
258307 z := requireZone (t , m , "row-0" )
259308
260- sendClick (m , z .StartX , z .StartY )
261- assert .True (t , m .inDetail (), "clicking selected row should trigger drilldown" )
309+ // First click selects (already selected, but records the click).
310+ sendClick (m , colX , z .StartY )
311+ assert .False (t , m .inDetail (), "single click should not trigger drilldown" )
312+
313+ // Second click within threshold triggers drilldown.
314+ z = requireZone (t , m , "row-0" )
315+ sendClick (m , colX , z .StartY )
316+ assert .True (t , m .inDetail (), "double-click should trigger drilldown" )
317+ }
318+
319+ // TestSingleClickOnSelectedRowDoesNotDrill verifies that a single click on
320+ // an already-selected row does not trigger drilldown.
321+ func TestSingleClickOnSelectedRowDoesNotDrill (t * testing.T ) {
322+ t .Parallel ()
323+ m := newTestModelWithDemoData (t , 42 )
324+
325+ tab := m .effectiveTab ()
326+ require .NotNil (t , tab )
327+ require .NotEmpty (t , tab .CellRows )
328+
329+ hasDrilldown := false
330+ for i , spec := range tab .Specs {
331+ if spec .Kind == cellDrilldown {
332+ tab .ColCursor = i
333+ hasDrilldown = true
334+ break
335+ }
336+ }
337+ if ! hasDrilldown {
338+ t .Skip ("no drilldown column available" )
339+ }
340+
341+ tab .Table .SetCursor (0 )
342+ colX := drilldownColX (t , m , tab )
343+ z := requireZone (t , m , "row-0" )
344+
345+ sendClick (m , colX , z .StartY )
346+ assert .False (t , m .inDetail (), "single click on selected row should not drill down" )
347+ }
348+
349+ // TestDoubleClickExpiredDoesNotDrill verifies that two clicks with too much
350+ // time between them do not trigger drilldown.
351+ func TestDoubleClickExpiredDoesNotDrill (t * testing.T ) {
352+ t .Parallel ()
353+ m := newTestModelWithDemoData (t , 42 )
354+
355+ tab := m .effectiveTab ()
356+ require .NotNil (t , tab )
357+ require .NotEmpty (t , tab .CellRows )
358+
359+ hasDrilldown := false
360+ for i , spec := range tab .Specs {
361+ if spec .Kind == cellDrilldown {
362+ tab .ColCursor = i
363+ hasDrilldown = true
364+ break
365+ }
366+ }
367+ if ! hasDrilldown {
368+ t .Skip ("no drilldown column available" )
369+ }
370+
371+ tab .Table .SetCursor (0 )
372+ colX := drilldownColX (t , m , tab )
373+ z := requireZone (t , m , "row-0" )
374+
375+ sendClick (m , colX , z .StartY )
376+ // Simulate an expired click by backdating the recorded time.
377+ m .lastRowClick .at = m .lastRowClick .at .Add (- time .Second )
378+
379+ z = requireZone (t , m , "row-0" )
380+ sendClick (m , colX , z .StartY )
381+ assert .False (t , m .inDetail (), "expired double-click should not trigger drilldown" )
382+ }
383+
384+ // TestDoubleClickDifferentRowDoesNotDrill verifies that clicking two
385+ // different rows in quick succession does not trigger drilldown.
386+ func TestDoubleClickDifferentRowDoesNotDrill (t * testing.T ) {
387+ t .Parallel ()
388+ m := newTestModelWithDemoData (t , 42 )
389+
390+ tab := m .effectiveTab ()
391+ require .NotNil (t , tab )
392+ require .Greater (t , len (tab .CellRows ), 1 , "need at least 2 rows" )
393+
394+ hasDrilldown := false
395+ for i , spec := range tab .Specs {
396+ if spec .Kind == cellDrilldown {
397+ tab .ColCursor = i
398+ hasDrilldown = true
399+ break
400+ }
401+ }
402+ if ! hasDrilldown {
403+ t .Skip ("no drilldown column available" )
404+ }
405+
406+ tab .Table .SetCursor (0 )
407+ colX := drilldownColX (t , m , tab )
408+ z0 := requireZone (t , m , "row-0" )
409+ sendClick (m , colX , z0 .StartY )
410+
411+ z1 := requireZone (t , m , "row-1" )
412+ sendClick (m , colX , z1 .StartY )
413+ assert .False (t , m .inDetail (), "clicking different rows should not trigger drilldown" )
414+ assert .Equal (t , 1 , tab .Table .Cursor (), "second click should select row 1" )
262415}
263416
264417// TestDashboardScrollWheel verifies that scroll wheel events in the
@@ -281,6 +434,76 @@ func TestDashboardScrollWheel(t *testing.T) {
281434 assert .Equal (t , 0 , m .dash .cursor , "scroll up in dashboard should move cursor back" )
282435}
283436
437+ // TestDashboardRowClickSelects verifies that a single click on a dashboard
438+ // row selects it without jumping.
439+ func TestDashboardRowClickSelects (t * testing.T ) {
440+ t .Parallel ()
441+ m := newTestModelWithDemoData (t , 42 )
442+
443+ sendKey (m , "D" )
444+ if ! m .dashboardVisible () {
445+ t .Skip ("dashboard has no data to display" )
446+ }
447+ require .Greater (t , len (m .dash .nav ), 1 , "need multiple dashboard nav items" )
448+
449+ m .dash .cursor = 0
450+ // Render once to populate all zones including overlay.
451+ m .View ()
452+ oz := m .zones .Get (zoneOverlay )
453+ if oz == nil || oz .IsZero () {
454+ t .Skip ("overlay zone not rendered" )
455+ }
456+ z := requireZone (t , m , "dash-1" )
457+
458+ sendClick (m , z .StartX , z .StartY )
459+ assert .True (t , m .dashboardVisible (), "single click should not close dashboard" )
460+ assert .Equal (t , 1 , m .dash .cursor , "single click should move dashboard cursor" )
461+ }
462+
463+ // TestDashboardDoubleClickJumps verifies that double-clicking a dashboard
464+ // row jumps to the item (closes the dashboard and switches tabs).
465+ func TestDashboardDoubleClickJumps (t * testing.T ) {
466+ t .Parallel ()
467+ m := newTestModelWithDemoData (t , 42 )
468+
469+ sendKey (m , "D" )
470+ if ! m .dashboardVisible () {
471+ t .Skip ("dashboard has no data to display" )
472+ }
473+ require .Greater (t , len (m .dash .nav ), 1 , "need multiple dashboard nav items" )
474+
475+ // Find a jumpable (non-header, non-info-only) row.
476+ jumpIdx := - 1
477+ for i , nav := range m .dash .nav {
478+ if ! nav .IsHeader && ! nav .InfoOnly {
479+ jumpIdx = i
480+ break
481+ }
482+ }
483+ if jumpIdx < 0 {
484+ t .Skip ("no jumpable dashboard row" )
485+ }
486+
487+ m .dash .cursor = 0
488+ // Render once to populate all zones including overlay.
489+ m .View ()
490+ oz := m .zones .Get (zoneOverlay )
491+ if oz == nil || oz .IsZero () {
492+ t .Skip ("overlay zone not rendered" )
493+ }
494+ z := requireZone (t , m , fmt .Sprintf ("dash-%d" , jumpIdx ))
495+
496+ // First click selects.
497+ sendClick (m , z .StartX , z .StartY )
498+ require .True (t , m .dashboardVisible (), "single click should keep dashboard open" )
499+ require .Equal (t , jumpIdx , m .dash .cursor )
500+
501+ // Second click within threshold jumps.
502+ z = requireZone (t , m , fmt .Sprintf ("dash-%d" , jumpIdx ))
503+ sendClick (m , z .StartX , z .StartY )
504+ assert .False (t , m .dashboardVisible (), "double-click should close dashboard and jump" )
505+ }
506+
284507// TestDashboardDismissOnOutsideClick verifies that clicking outside the
285508// dashboard overlay dismisses it.
286509func TestDashboardDismissOnOutsideClick (t * testing.T ) {
0 commit comments