@@ -6,6 +6,7 @@ package app
66import (
77 "context"
88 "errors"
9+ "strings"
910 "testing"
1011 "time"
1112
@@ -52,6 +53,13 @@ func newExtractionModel(t *testing.T, steps map[extractionStep]stepStatus) *Mode
5253 return m
5354}
5455
56+ // hintIndex returns the byte index of a hint label in ANSI-stripped overlay
57+ // output. Keycap rendering inserts variable padding between key and label,
58+ // so this searches by label word alone.
59+ func hintIndex (stripped , label string ) int {
60+ return strings .Index (stripped , label )
61+ }
62+
5563func sendExtractionKey (m * Model , key string ) {
5664 var msg tea.KeyPressMsg
5765 switch key {
@@ -3644,3 +3652,165 @@ func TestExtractionClient_RetryClearsErrorOnSuccess(t *testing.T) {
36443652 require .NoError (t , m .ex .extractionClientErr )
36453653 require .Equal (t , "some-model" , client .Model ())
36463654}
3655+
3656+ func TestExploreMode_HintBarTrailingOrder (t * testing.T ) {
3657+ t .Parallel ()
3658+ m := newPreviewModel (t , []extract.Operation {
3659+ {Action : "create" , Table : data .TableVendors , Data : map [string ]any {"name" : "A" }},
3660+ })
3661+ ex := m .ex .extraction
3662+
3663+ // Pipeline mode: x should come after a and before esc.
3664+ pipelineOut := m .buildExtractionPipelineOverlay (100 , 90 , "test" )
3665+ pipelinePlain := ansi .Strip (pipelineOut )
3666+
3667+ pipelineAcceptIdx := hintIndex (pipelinePlain , "accept" )
3668+ pipelineExploreIdx := hintIndex (pipelinePlain , "explore" )
3669+ pipelineEscIdx := hintIndex (pipelinePlain , "discard" )
3670+ require .Positive (t , pipelineAcceptIdx , "accept hint should appear" )
3671+ require .Positive (t , pipelineExploreIdx , "explore hint should appear" )
3672+ require .Positive (t , pipelineEscIdx , "discard hint should appear" )
3673+ assert .Less (t , pipelineAcceptIdx , pipelineExploreIdx ,
3674+ "accept should come before explore" )
3675+ assert .Less (t , pipelineExploreIdx , pipelineEscIdx ,
3676+ "explore should come before discard" )
3677+
3678+ // Enter explore mode: x should still come after a and before esc.
3679+ sendExtractionKey (m , "x" )
3680+ require .True (t , ex .exploring )
3681+
3682+ exploreOut := m .buildExtractionPipelineOverlay (100 , 90 , "test" )
3683+ explorePlain := ansi .Strip (exploreOut )
3684+
3685+ exploreAcceptIdx := hintIndex (explorePlain , "accept" )
3686+ exploreBackIdx := hintIndex (explorePlain , "back" )
3687+ exploreEscIdx := hintIndex (explorePlain , "discard" )
3688+ require .Positive (t , exploreAcceptIdx , "accept hint should appear" )
3689+ require .Positive (t , exploreBackIdx , "back hint should appear" )
3690+ require .Positive (t , exploreEscIdx , "discard hint should appear" )
3691+ assert .Less (t , exploreAcceptIdx , exploreBackIdx ,
3692+ "accept should come before back" )
3693+ assert .Less (t , exploreBackIdx , exploreEscIdx ,
3694+ "back should come before discard" )
3695+ }
3696+
3697+ func TestExploreMode_HintBarTrailingOrderMultiTab (t * testing.T ) {
3698+ t .Parallel ()
3699+ m := newPreviewModel (t , []extract.Operation {
3700+ {Action : "create" , Table : data .TableVendors , Data : map [string ]any {"name" : "A" }},
3701+ {
3702+ Action : "create" ,
3703+ Table : data .TableQuotes ,
3704+ Data : map [string ]any {"total_cents" : float64 (100 )},
3705+ },
3706+ })
3707+ ex := m .ex .extraction
3708+
3709+ // Explore mode with multiple tabs: b/f tabs hint appears but
3710+ // a/x/esc must still be trailing three.
3711+ sendExtractionKey (m , "x" )
3712+ require .True (t , ex .exploring )
3713+ require .Greater (t , len (ex .previewGroups ), 1 , "need multiple tabs" )
3714+
3715+ out := m .buildExtractionPipelineOverlay (100 , 90 , "test" )
3716+ plain := ansi .Strip (out )
3717+
3718+ tabsIdx := hintIndex (plain , "tabs" )
3719+ acceptIdx := hintIndex (plain , "accept" )
3720+ backIdx := hintIndex (plain , "back" )
3721+ escIdx := hintIndex (plain , "discard" )
3722+ require .Positive (t , tabsIdx , "tabs hint should appear" )
3723+ require .Positive (t , acceptIdx )
3724+ require .Positive (t , backIdx )
3725+ require .Positive (t , escIdx )
3726+
3727+ assert .Less (t , tabsIdx , acceptIdx ,
3728+ "tabs should come before stable trailing group" )
3729+ assert .Less (t , acceptIdx , backIdx ,
3730+ "accept should come before back" )
3731+ assert .Less (t , backIdx , escIdx ,
3732+ "back should come before discard" )
3733+ }
3734+
3735+ func TestExploreMode_OverlayHeightStableAcrossToggle (t * testing.T ) {
3736+ t .Parallel ()
3737+ // Two tables with different row counts to trigger height change.
3738+ m := newPreviewModel (t , []extract.Operation {
3739+ {Action : "create" , Table : data .TableVendors , Data : map [string ]any {"name" : "A" }},
3740+ {Action : "create" , Table : data .TableVendors , Data : map [string ]any {"name" : "B" }},
3741+ {Action : "create" , Table : data .TableVendors , Data : map [string ]any {"name" : "C" }},
3742+ {
3743+ Action : "create" ,
3744+ Table : data .TableQuotes ,
3745+ Data : map [string ]any {"total_cents" : float64 (100 )},
3746+ },
3747+ })
3748+ ex := m .ex .extraction
3749+
3750+ // Pipeline mode overlay.
3751+ pipelineOut := m .buildExtractionPipelineOverlay (100 , 90 , "test" )
3752+ pipelineLines := strings .Count (pipelineOut , "\n " )
3753+
3754+ // Enter explore mode and switch to tab with fewer rows.
3755+ sendExtractionKey (m , "x" )
3756+ require .True (t , ex .exploring )
3757+ sendExtractionKey (m , "f" ) // switch to quotes tab (1 row vs 3)
3758+ require .Equal (t , 1 , ex .previewTab )
3759+
3760+ exploreOut := m .buildExtractionPipelineOverlay (100 , 90 , "test" )
3761+ exploreLines := strings .Count (exploreOut , "\n " )
3762+
3763+ assert .Equal (t , pipelineLines , exploreLines ,
3764+ "overlay height should be identical regardless of active tab row count" )
3765+ }
3766+
3767+ func TestExploreMode_TabPersistsOnExit (t * testing.T ) {
3768+ t .Parallel ()
3769+ m := newPreviewModel (t , []extract.Operation {
3770+ {Action : "create" , Table : data .TableVendors , Data : map [string ]any {"name" : "A" }},
3771+ {
3772+ Action : "create" ,
3773+ Table : data .TableQuotes ,
3774+ Data : map [string ]any {"total_cents" : float64 (100 )},
3775+ },
3776+ })
3777+ ex := m .ex .extraction
3778+
3779+ // Enter explore, switch to tab 1, exit.
3780+ sendExtractionKey (m , "x" )
3781+ require .True (t , ex .exploring )
3782+ sendExtractionKey (m , "f" )
3783+ require .Equal (t , 1 , ex .previewTab )
3784+ sendExtractionKey (m , "x" ) // exit explore
3785+ require .False (t , ex .exploring )
3786+
3787+ // Pipeline mode should now show tab 1 (dimmed), not reset to 0.
3788+ out := m .renderOperationPreviewSection (80 , false )
3789+ plain := ansi .Strip (out )
3790+ // Tab 1 is Quotes -- its data should appear in rendered table.
3791+ assert .Contains (t , plain , "Total" ,
3792+ "pipeline mode should show last-explored tab content" )
3793+ }
3794+
3795+ func TestExploreMode_UnknownTableOverlayStable (t * testing.T ) {
3796+ t .Parallel ()
3797+ // Unknown tables produce no preview groups but hasOps is still true.
3798+ // Overlay should render without panicking and maintain stable height.
3799+ m := newPreviewModel (t , []extract.Operation {
3800+ {Action : "create" , Table : "unknown_table" , Data : map [string ]any {"x" : "y" }},
3801+ })
3802+
3803+ out := m .buildExtractionPipelineOverlay (100 , 90 , "test" )
3804+ plain := ansi .Strip (out )
3805+ lines := strings .Count (out , "\n " )
3806+
3807+ assert .Positive (t , lines , "overlay should render with nonzero height" )
3808+ assert .Contains (t , plain , "no operations" ,
3809+ "should show fallback text for unknown tables" )
3810+
3811+ // Build again -- height should be identical (stable).
3812+ out2 := m .buildExtractionPipelineOverlay (100 , 90 , "test" )
3813+ lines2 := strings .Count (out2 , "\n " )
3814+ assert .Equal (t , lines , lines2 ,
3815+ "overlay height should be stable across renders" )
3816+ }
0 commit comments