test(daemon): cover pane resize modal and control paths
- commit
- c54ef32d5b26b0dbe20d2b8e3c72ac3a14ef09b4
- author
- brice · · 2026-07-25 04:51 UTC
- parent
- 9ebcbf7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +369 -0
M internal/app/cmd_test.go +34 -0
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "testing" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/vev/internal/ports" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | func TestParseCmdArgs(t *testing.T) { | |
| @@ -104,6 +105,29 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) { | |||
| 104 | 105 | } | |
| 105 | 106 | } | |
| 106 | 107 | ||
| 108 | + | func TestResizeOneShotCmdsParseAndUseSelfTarget(t *testing.T) { | |
| 109 | + | for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} { | |
| 110 | + | t.Run(slug, func(t *testing.T) { | |
| 111 | + | invocation, err := parseArgs([]string{"cmd", "--self", slug}) | |
| 112 | + | require.NoError(t, err) | |
| 113 | + | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}} | |
| 114 | + | require.NoError(t, runCmdWithDeps(context.Background(), invocation.cmd, cmdDeps{ | |
| 115 | + | stdout: io.Discard, | |
| 116 | + | getenv: func(string) string { return "session=work,tab=t_abc,pane=p_def" }, | |
| 117 | + | dial: func(context.Context, string) (ports.Transport, error) { return transport, nil }, | |
| 118 | + | })) | |
| 119 | + | require.Len(t, transport.sent, 1) | |
| 120 | + | request, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload) | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | require.Equal(t, slug, request.Slug) | |
| 123 | + | require.True(t, request.Self) | |
| 124 | + | require.Equal(t, "work", request.TargetSession) | |
| 125 | + | require.Equal(t, "t_abc", request.TargetTab) | |
| 126 | + | require.Equal(t, "p_def", request.TargetPane) | |
| 127 | + | }) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 107 | 131 | func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) { | |
| 108 | 132 | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true, Output: "done"})}} | |
| 109 | 133 | out := new(strings.Builder) | |
| @@ -225,6 +249,16 @@ func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) { | |||
| 225 | 249 | result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "no live sessions"}, | |
| 226 | 250 | wantCode: 1, | |
| 227 | 251 | }, | |
| 252 | + | { | |
| 253 | + | name: "resize not in split remains an exit one failure", | |
| 254 | + | result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "pane is not in a split"}, | |
| 255 | + | wantCode: 1, | |
| 256 | + | }, | |
| 257 | + | { | |
| 258 | + | name: "resize minimum remains an exit one failure", | |
| 259 | + | result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "pane cannot be resized further"}, | |
| 260 | + | wantCode: 1, | |
| 261 | + | }, | |
| 228 | 262 | } | |
| 229 | 263 | for _, tt := range tests { | |
| 230 | 264 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "testing" | 10 | "testing" | ||
| 11 | 11 | ||||
| 12 | "github.com/bnema/vev/internal/ports" | 12 | "github.com/bnema/vev/internal/ports" | ||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| 15 | func TestParseCmdArgs(t *testing.T) { | 16 | func TestParseCmdArgs(t *testing.T) { | ||
| @@ -104,6 +105,29 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) { | |||||
| 104 | } | 105 | } | ||
| 105 | } | 106 | } | ||
| 106 | 107 | ||||
| 108 | + | func TestResizeOneShotCmdsParseAndUseSelfTarget(t *testing.T) { | |||
| 109 | + | for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} { | |||
| 110 | + | t.Run(slug, func(t *testing.T) { | |||
| 111 | + | invocation, err := parseArgs([]string{"cmd", "--self", slug}) | |||
| 112 | + | require.NoError(t, err) | |||
| 113 | + | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}} | |||
| 114 | + | require.NoError(t, runCmdWithDeps(context.Background(), invocation.cmd, cmdDeps{ | |||
| 115 | + | stdout: io.Discard, | |||
| 116 | + | getenv: func(string) string { return "session=work,tab=t_abc,pane=p_def" }, | |||
| 117 | + | dial: func(context.Context, string) (ports.Transport, error) { return transport, nil }, | |||
| 118 | + | })) | |||
| 119 | + | require.Len(t, transport.sent, 1) | |||
| 120 | + | request, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload) | |||
| 121 | + | require.NoError(t, err) | |||
| 122 | + | require.Equal(t, slug, request.Slug) | |||
| 123 | + | require.True(t, request.Self) | |||
| 124 | + | require.Equal(t, "work", request.TargetSession) | |||
| 125 | + | require.Equal(t, "t_abc", request.TargetTab) | |||
| 126 | + | require.Equal(t, "p_def", request.TargetPane) | |||
| 127 | + | }) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 107 | func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) { | 131 | func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) { | ||
| 108 | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true, Output: "done"})}} | 132 | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true, Output: "done"})}} | ||
| 109 | out := new(strings.Builder) | 133 | out := new(strings.Builder) | ||
| @@ -225,6 +249,16 @@ func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) { | |||||
| 225 | result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "no live sessions"}, | 249 | result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "no live sessions"}, | ||
| 226 | wantCode: 1, | 250 | wantCode: 1, | ||
| 227 | }, | 251 | }, | ||
| 252 | + | { | |||
| 253 | + | name: "resize not in split remains an exit one failure", | |||
| 254 | + | result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "pane is not in a split"}, | |||
| 255 | + | wantCode: 1, | |||
| 256 | + | }, | |||
| 257 | + | { | |||
| 258 | + | name: "resize minimum remains an exit one failure", | |||
| 259 | + | result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "pane cannot be resized further"}, | |||
| 260 | + | wantCode: 1, | |||
| 261 | + | }, | |||
| 228 | } | 262 | } | ||
| 229 | for _, tt := range tests { | 263 | for _, tt := range tests { | ||
| 230 | t.Run(tt.name, func(t *testing.T) { | 264 | t.Run(tt.name, func(t *testing.T) { | ||
M internal/usecase/daemon/control_test.go +96 -0
| @@ -271,6 +271,102 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) { | |||
| 271 | 271 | invoking.mu.Unlock() | |
| 272 | 272 | } | |
| 273 | 273 | ||
| 274 | + | func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) { | |
| 275 | + | targets := []struct { | |
| 276 | + | name string | |
| 277 | + | request func() ports.CommandRequest | |
| 278 | + | }{ | |
| 279 | + | {"named", func() ports.CommandRequest { return ports.CommandRequest{TargetSession: "work"} }}, | |
| 280 | + | {"unique", func() ports.CommandRequest { return ports.CommandRequest{} }}, | |
| 281 | + | } | |
| 282 | + | for _, target := range targets { | |
| 283 | + | t.Run(target.name, func(t *testing.T) { | |
| 284 | + | for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} { | |
| 285 | + | t.Run(slug, func(t *testing.T) { | |
| 286 | + | factory := &controlPTYFactory{} | |
| 287 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 288 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |
| 289 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 290 | + | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |
| 291 | + | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK) | |
| 292 | + | tb := sess.activeTab() | |
| 293 | + | tb.mu.Lock() | |
| 294 | + | generation := tb.layoutGeneration | |
| 295 | + | tb.mu.Unlock() | |
| 296 | + | sess.snapEligible.Store(true) | |
| 297 | + | sess.snapshotMu.Lock() | |
| 298 | + | snapshotGeneration := sess.snapshotGeneration | |
| 299 | + | sess.snapshotMu.Unlock() | |
| 300 | + | request := target.request() | |
| 301 | + | request.Slug = slug | |
| 302 | + | result := sendCommand(t, d, request) | |
| 303 | + | require.True(t, result.OK, result.Text) | |
| 304 | + | require.Empty(t, result.Output) | |
| 305 | + | tb.mu.Lock() | |
| 306 | + | require.Equal(t, generation+1, tb.layoutGeneration, "one accepted action has one layout generation boundary") | |
| 307 | + | tb.mu.Unlock() | |
| 308 | + | sess.snapshotMu.Lock() | |
| 309 | + | require.Equal(t, snapshotGeneration+1, sess.snapshotGeneration, "one accepted action has one snapshot dirty boundary") | |
| 310 | + | sess.snapshotMu.Unlock() | |
| 311 | + | sess.mu.Lock() | |
| 312 | + | require.Nil(t, sess.client, "one-shots must work headless") | |
| 313 | + | sess.mu.Unlock() | |
| 314 | + | }) | |
| 315 | + | } | |
| 316 | + | }) | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) { | |
| 321 | + | factory := &controlPTYFactory{} | |
| 322 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 323 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |
| 324 | + | sess := addControlSession(d, "work", "t_active", "p_active") | |
| 325 | + | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |
| 326 | + | active := sess.tabs[0] | |
| 327 | + | locator := newTabWithStableID("t_locator", "p_locator", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 328 | + | locator.ctx, locator.cancel = context.WithCancel(d.serveCtx) | |
| 329 | + | sess.mu.Lock() | |
| 330 | + | sess.tabs = append(sess.tabs, locator) | |
| 331 | + | sess.active = 0 | |
| 332 | + | sess.mu.Unlock() | |
| 333 | + | active.mu.Lock() | |
| 334 | + | before := active.layoutGeneration | |
| 335 | + | active.mu.Unlock() | |
| 336 | + | ||
| 337 | + | result := sendCommand(t, d, ports.CommandRequest{ | |
| 338 | + | Slug: "grow-pane-width", TargetSession: "work", TargetTab: "t_locator", TargetPane: "p_locator", | |
| 339 | + | }) | |
| 340 | + | require.True(t, result.OK, result.Text) | |
| 341 | + | active.mu.Lock() | |
| 342 | + | require.Equal(t, before+1, active.layoutGeneration, "stable IDs locate the session but must not redirect a non-self action") | |
| 343 | + | active.mu.Unlock() | |
| 344 | + | locator.mu.Lock() | |
| 345 | + | require.Zero(t, locator.layoutGeneration) | |
| 346 | + | locator.mu.Unlock() | |
| 347 | + | sess.mu.Lock() | |
| 348 | + | require.Zero(t, sess.active) | |
| 349 | + | sess.mu.Unlock() | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | func TestResizeControlTooSmallUsesStableFailure(t *testing.T) { | |
| 353 | + | factory := &controlPTYFactory{} | |
| 354 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 355 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |
| 356 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 357 | + | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |
| 358 | + | tb := sess.activeTab() | |
| 359 | + | tb.mu.Lock() | |
| 360 | + | tb.size.Cols = 41 | |
| 361 | + | tb.bumpLayoutGenerationLocked() | |
| 362 | + | tb.mu.Unlock() | |
| 363 | + | ||
| 364 | + | result := sendCommand(t, d, ports.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"}) | |
| 365 | + | require.False(t, result.OK) | |
| 366 | + | require.Equal(t, ports.ErrNoSuchTarget, result.Code) | |
| 367 | + | require.Equal(t, "pane cannot be resized further", result.Text) | |
| 368 | + | } | |
| 369 | + | ||
| 274 | 370 | func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) { | |
| 275 | 371 | d := newTestDaemon(t, nil, stubClock{}) | |
| 276 | 372 | sess := addControlSession(d, "work", "t_active", "p_active") | |
| @@ -271,6 +271,102 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) { | |||||
| 271 | invoking.mu.Unlock() | 271 | invoking.mu.Unlock() | ||
| 272 | } | 272 | } | ||
| 273 | 273 | ||||
| 274 | + | func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) { | |||
| 275 | + | targets := []struct { | |||
| 276 | + | name string | |||
| 277 | + | request func() ports.CommandRequest | |||
| 278 | + | }{ | |||
| 279 | + | {"named", func() ports.CommandRequest { return ports.CommandRequest{TargetSession: "work"} }}, | |||
| 280 | + | {"unique", func() ports.CommandRequest { return ports.CommandRequest{} }}, | |||
| 281 | + | } | |||
| 282 | + | for _, target := range targets { | |||
| 283 | + | t.Run(target.name, func(t *testing.T) { | |||
| 284 | + | for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} { | |||
| 285 | + | t.Run(slug, func(t *testing.T) { | |||
| 286 | + | factory := &controlPTYFactory{} | |||
| 287 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 288 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |||
| 289 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |||
| 290 | + | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |||
| 291 | + | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK) | |||
| 292 | + | tb := sess.activeTab() | |||
| 293 | + | tb.mu.Lock() | |||
| 294 | + | generation := tb.layoutGeneration | |||
| 295 | + | tb.mu.Unlock() | |||
| 296 | + | sess.snapEligible.Store(true) | |||
| 297 | + | sess.snapshotMu.Lock() | |||
| 298 | + | snapshotGeneration := sess.snapshotGeneration | |||
| 299 | + | sess.snapshotMu.Unlock() | |||
| 300 | + | request := target.request() | |||
| 301 | + | request.Slug = slug | |||
| 302 | + | result := sendCommand(t, d, request) | |||
| 303 | + | require.True(t, result.OK, result.Text) | |||
| 304 | + | require.Empty(t, result.Output) | |||
| 305 | + | tb.mu.Lock() | |||
| 306 | + | require.Equal(t, generation+1, tb.layoutGeneration, "one accepted action has one layout generation boundary") | |||
| 307 | + | tb.mu.Unlock() | |||
| 308 | + | sess.snapshotMu.Lock() | |||
| 309 | + | require.Equal(t, snapshotGeneration+1, sess.snapshotGeneration, "one accepted action has one snapshot dirty boundary") | |||
| 310 | + | sess.snapshotMu.Unlock() | |||
| 311 | + | sess.mu.Lock() | |||
| 312 | + | require.Nil(t, sess.client, "one-shots must work headless") | |||
| 313 | + | sess.mu.Unlock() | |||
| 314 | + | }) | |||
| 315 | + | } | |||
| 316 | + | }) | |||
| 317 | + | } | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) { | |||
| 321 | + | factory := &controlPTYFactory{} | |||
| 322 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 323 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |||
| 324 | + | sess := addControlSession(d, "work", "t_active", "p_active") | |||
| 325 | + | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |||
| 326 | + | active := sess.tabs[0] | |||
| 327 | + | locator := newTabWithStableID("t_locator", "p_locator", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 328 | + | locator.ctx, locator.cancel = context.WithCancel(d.serveCtx) | |||
| 329 | + | sess.mu.Lock() | |||
| 330 | + | sess.tabs = append(sess.tabs, locator) | |||
| 331 | + | sess.active = 0 | |||
| 332 | + | sess.mu.Unlock() | |||
| 333 | + | active.mu.Lock() | |||
| 334 | + | before := active.layoutGeneration | |||
| 335 | + | active.mu.Unlock() | |||
| 336 | + | ||||
| 337 | + | result := sendCommand(t, d, ports.CommandRequest{ | |||
| 338 | + | Slug: "grow-pane-width", TargetSession: "work", TargetTab: "t_locator", TargetPane: "p_locator", | |||
| 339 | + | }) | |||
| 340 | + | require.True(t, result.OK, result.Text) | |||
| 341 | + | active.mu.Lock() | |||
| 342 | + | require.Equal(t, before+1, active.layoutGeneration, "stable IDs locate the session but must not redirect a non-self action") | |||
| 343 | + | active.mu.Unlock() | |||
| 344 | + | locator.mu.Lock() | |||
| 345 | + | require.Zero(t, locator.layoutGeneration) | |||
| 346 | + | locator.mu.Unlock() | |||
| 347 | + | sess.mu.Lock() | |||
| 348 | + | require.Zero(t, sess.active) | |||
| 349 | + | sess.mu.Unlock() | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | func TestResizeControlTooSmallUsesStableFailure(t *testing.T) { | |||
| 353 | + | factory := &controlPTYFactory{} | |||
| 354 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 355 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |||
| 356 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |||
| 357 | + | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |||
| 358 | + | tb := sess.activeTab() | |||
| 359 | + | tb.mu.Lock() | |||
| 360 | + | tb.size.Cols = 41 | |||
| 361 | + | tb.bumpLayoutGenerationLocked() | |||
| 362 | + | tb.mu.Unlock() | |||
| 363 | + | ||||
| 364 | + | result := sendCommand(t, d, ports.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"}) | |||
| 365 | + | require.False(t, result.OK) | |||
| 366 | + | require.Equal(t, ports.ErrNoSuchTarget, result.Code) | |||
| 367 | + | require.Equal(t, "pane cannot be resized further", result.Text) | |||
| 368 | + | } | |||
| 369 | + | ||||
| 274 | func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) { | 370 | func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) { | ||
| 275 | d := newTestDaemon(t, nil, stubClock{}) | 371 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 276 | sess := addControlSession(d, "work", "t_active", "p_active") | 372 | sess := addControlSession(d, "work", "t_active", "p_active") | ||
M internal/usecase/daemon/resize_mode_test.go +239 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "sync" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| @@ -155,3 +156,241 @@ func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) { | |||
| 155 | 156 | require.Equal(t, 1, right) | |
| 156 | 157 | require.Empty(t, pending) | |
| 157 | 158 | } | |
| 159 | + | ||
| 160 | + | // TestResizeModeKeysAndEscapes exercises the modal parser through the real | |
| 161 | + | // action seam. Every directional spelling must commit exactly one mutation; | |
| 162 | + | // parser prefixes are deliberately split across reads. | |
| 163 | + | func TestResizeModeKeysAndEscapes(t *testing.T) { | |
| 164 | + | tests := []struct { | |
| 165 | + | name string | |
| 166 | + | chunks [][]byte | |
| 167 | + | change bool | |
| 168 | + | exit bool | |
| 169 | + | }{ | |
| 170 | + | {"h", [][]byte{[]byte("h")}, true, false}, | |
| 171 | + | {"j", [][]byte{[]byte("j")}, true, false}, | |
| 172 | + | {"k", [][]byte{[]byte("k")}, true, false}, | |
| 173 | + | {"l", [][]byte{[]byte("l")}, true, false}, | |
| 174 | + | {"CSI left split", [][]byte{[]byte("\x1b["), []byte("D")}, true, false}, | |
| 175 | + | {"CSI right", [][]byte{[]byte("\x1b[C")}, true, false}, | |
| 176 | + | {"CSI up", [][]byte{[]byte("\x1b[A")}, true, false}, | |
| 177 | + | {"CSI down", [][]byte{[]byte("\x1b[B")}, true, false}, | |
| 178 | + | {"SS3 left", [][]byte{[]byte("\x1bOD")}, true, false}, | |
| 179 | + | {"SS3 right split", [][]byte{[]byte("\x1bO"), []byte("C")}, true, false}, | |
| 180 | + | {"SS3 up", [][]byte{[]byte("\x1bOA")}, true, false}, | |
| 181 | + | {"SS3 down", [][]byte{[]byte("\x1bOB")}, true, false}, | |
| 182 | + | {"equalize", [][]byte{[]byte("=")}, true, false}, | |
| 183 | + | {"ignored", [][]byte{[]byte("z\x00")}, false, false}, | |
| 184 | + | {"q exits", [][]byte{[]byte("q")}, false, true}, | |
| 185 | + | {"enter exits", [][]byte{[]byte("\r")}, false, true}, | |
| 186 | + | {"escape exits", [][]byte{[]byte("\x1b"), []byte("x")}, false, true}, | |
| 187 | + | } | |
| 188 | + | for _, tt := range tests { | |
| 189 | + | t.Run(tt.name, func(t *testing.T) { | |
| 190 | + | d, sess, ac := resizeModeFixture(t) | |
| 191 | + | tb := sess.activeTab() | |
| 192 | + | tb.mu.Lock() | |
| 193 | + | before := tb.layoutGeneration | |
| 194 | + | if tt.name == "equalize" { | |
| 195 | + | tb.tree.Root.Children[0].Weight = 3 | |
| 196 | + | } | |
| 197 | + | tb.mu.Unlock() | |
| 198 | + | for _, chunk := range tt.chunks { | |
| 199 | + | d.handleResizeInput(ac, chunk) | |
| 200 | + | } | |
| 201 | + | tb.mu.Lock() | |
| 202 | + | after := tb.layoutGeneration | |
| 203 | + | if tt.name == "equalize" { | |
| 204 | + | for _, child := range tb.tree.Root.Children { | |
| 205 | + | require.Zero(t, child.Weight) | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | tb.mu.Unlock() | |
| 209 | + | if tt.change { | |
| 210 | + | require.Equal(t, before+1, after) | |
| 211 | + | } else { | |
| 212 | + | require.Equal(t, before, after) | |
| 213 | + | } | |
| 214 | + | require.Equal(t, !tt.exit, ac.overlays.resizeModeActive()) | |
| 215 | + | }) | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func TestResizeModeRendersGuidanceWithoutHidingContentOrCursor(t *testing.T) { | |
| 220 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 221 | + | tb := sess.activeTab() | |
| 222 | + | second := newPane("pane-2", nil, domain.Size{Cols: 39, Rows: 23}) | |
| 223 | + | tb.mu.Lock() | |
| 224 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |
| 225 | + | tb.panes[second.id] = second | |
| 226 | + | tb.mu.Unlock() | |
| 227 | + | second.screen.Write([]byte("live resize content")) | |
| 228 | + | require.NoError(t, d.enterResizeMode(sess, ac)) | |
| 229 | + | data := string(mustOutputData(t, sends)) | |
| 230 | + | require.Contains(t, data, "resize: h/j/k/l or arrows") | |
| 231 | + | require.Contains(t, data, "live resize content") | |
| 232 | + | require.Contains(t, data, "\x1b[?25h", "resize leaves the focused pane cursor visible") | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func TestResizeModeOverlayPriority(t *testing.T) { | |
| 236 | + | tests := []struct { | |
| 237 | + | name string | |
| 238 | + | input []byte | |
| 239 | + | setup func(*Daemon, *session, *attachedClient) | |
| 240 | + | check func(*testing.T, *attachedClient) | |
| 241 | + | }{ | |
| 242 | + | {"prompt before palette picker notices resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) { | |
| 243 | + | d.enterCopyMode(sess, ac) | |
| 244 | + | d.enterNotices(sess, ac) | |
| 245 | + | d.enterPicker(sess, ac) | |
| 246 | + | d.enterPalette(sess, ac) | |
| 247 | + | d.enterPrompt(sess, ac, "prompt", "", func(string) error { return nil }) | |
| 248 | + | }, func(t *testing.T, ac *attachedClient) { | |
| 249 | + | require.True(t, ac.overlays.promptActive()) | |
| 250 | + | require.True(t, ac.overlays.resizeModeActive()) | |
| 251 | + | }}, | |
| 252 | + | {"palette before picker notices resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) { | |
| 253 | + | d.enterCopyMode(sess, ac) | |
| 254 | + | d.enterPicker(sess, ac) | |
| 255 | + | d.enterPalette(sess, ac) | |
| 256 | + | }, func(t *testing.T, ac *attachedClient) { | |
| 257 | + | require.True(t, ac.overlays.paletteActive()) | |
| 258 | + | require.True(t, ac.overlays.resizeModeActive()) | |
| 259 | + | }}, | |
| 260 | + | {"picker before resize and copy", []byte("j"), func(d *Daemon, sess *session, ac *attachedClient) { | |
| 261 | + | d.enterCopyMode(sess, ac) | |
| 262 | + | d.enterPicker(sess, ac) | |
| 263 | + | }, func(t *testing.T, ac *attachedClient) { | |
| 264 | + | require.True(t, ac.overlays.pickerActive()) | |
| 265 | + | require.True(t, ac.overlays.resizeModeActive()) | |
| 266 | + | }}, | |
| 267 | + | {"notices before resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) { | |
| 268 | + | d.enterCopyMode(sess, ac) | |
| 269 | + | d.enterNotices(sess, ac) | |
| 270 | + | }, func(t *testing.T, ac *attachedClient) { | |
| 271 | + | require.False(t, ac.overlays.noticesActive(), "q must be consumed by notices") | |
| 272 | + | require.True(t, ac.overlays.resizeModeActive()) | |
| 273 | + | }}, | |
| 274 | + | } | |
| 275 | + | for _, tt := range tests { | |
| 276 | + | t.Run(tt.name, func(t *testing.T) { | |
| 277 | + | d, sess, ac := resizeModeFixture(t) | |
| 278 | + | tt.setup(d, sess, ac) | |
| 279 | + | require.True(t, ac.overlays.HandleInput(d, tt.input)) | |
| 280 | + | tt.check(t, ac) | |
| 281 | + | }) | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | func TestResizeModeWarningPersistsAndActionsShareTransaction(t *testing.T) { | |
| 286 | + | t.Run("too small warning leaves mode active", func(t *testing.T) { | |
| 287 | + | d, sess, ac := resizeModeFixture(t) | |
| 288 | + | tb := sess.activeTab() | |
| 289 | + | tb.mu.Lock() | |
| 290 | + | tb.size.Cols = 41 // two minimum-width panes and their separator | |
| 291 | + | tb.bumpLayoutGenerationLocked() | |
| 292 | + | tb.mu.Unlock() | |
| 293 | + | d.handleResizeInput(ac, []byte("l")) | |
| 294 | + | require.True(t, ac.overlays.resizeModeActive()) | |
| 295 | + | history := d.notices.history() | |
| 296 | + | require.NotEmpty(t, history) | |
| 297 | + | require.Equal(t, "pane cannot be resized further", history[len(history)-1].Message) | |
| 298 | + | }) | |
| 299 | + | ||
| 300 | + | t.Run("palette control and modal have the same committed weights", func(t *testing.T) { | |
| 301 | + | type invoke func(*Daemon, *session, *attachedClient) error | |
| 302 | + | invocations := []struct { | |
| 303 | + | name string | |
| 304 | + | run invoke | |
| 305 | + | }{ | |
| 306 | + | {"palette", func(d *Daemon, sess *session, ac *attachedClient) error { | |
| 307 | + | return (paletteExec{d: d, sess: sess, ac: ac}).GrowPaneWidth() | |
| 308 | + | }}, | |
| 309 | + | {"control", func(d *Daemon, sess *session, _ *attachedClient) error { | |
| 310 | + | target := resolveDaemonActionTarget(sess) | |
| 311 | + | return (controlExec{d: d, sess: sess, tab: target.tab, target: target}).GrowPaneWidth() | |
| 312 | + | }}, | |
| 313 | + | {"modal", func(d *Daemon, _ *session, ac *attachedClient) error { | |
| 314 | + | d.handleResizeInput(ac, []byte("l")) | |
| 315 | + | return nil | |
| 316 | + | }}, | |
| 317 | + | } | |
| 318 | + | var want []float64 | |
| 319 | + | for _, tt := range invocations { | |
| 320 | + | t.Run(tt.name, func(t *testing.T) { | |
| 321 | + | d, sess, ac := resizeModeFixture(t) | |
| 322 | + | require.NoError(t, tt.run(d, sess, ac)) | |
| 323 | + | tb := sess.activeTab() | |
| 324 | + | tb.mu.Lock() | |
| 325 | + | got := []float64{tb.tree.Root.Children[0].Weight, tb.tree.Root.Children[1].Weight} | |
| 326 | + | tb.mu.Unlock() | |
| 327 | + | if want == nil { | |
| 328 | + | want = got | |
| 329 | + | } else { | |
| 330 | + | require.Equal(t, want, got) | |
| 331 | + | } | |
| 332 | + | }) | |
| 333 | + | } | |
| 334 | + | }) | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | type resizeLockProbePTY struct { | |
| 338 | + | once sync.Once | |
| 339 | + | entered chan struct{} | |
| 340 | + | release chan struct{} | |
| 341 | + | check func() | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func (p *resizeLockProbePTY) Read([]byte) (int, error) { return 0, nil } | |
| 345 | + | func (*resizeLockProbePTY) Write(b []byte) (int, error) { return len(b), nil } | |
| 346 | + | func (*resizeLockProbePTY) Close() error { return nil } | |
| 347 | + | func (*resizeLockProbePTY) Pid() int { return 0 } | |
| 348 | + | func (*resizeLockProbePTY) ForegroundPgid() (int, error) { return 0, nil } | |
| 349 | + | func (p *resizeLockProbePTY) Resize(domain.Size) error { | |
| 350 | + | p.once.Do(func() { | |
| 351 | + | p.check() | |
| 352 | + | close(p.entered) | |
| 353 | + | <-p.release | |
| 354 | + | }) | |
| 355 | + | return nil | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | func TestResizeModeReleasesTabAndOverlayLocksBeforePTY(t *testing.T) { | |
| 359 | + | // Build a live tree directly so the probe is not concurrently owned by a | |
| 360 | + | // pane reader; this isolates the lock boundary being asserted. | |
| 361 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 362 | + | tb := sess.activeTab() | |
| 363 | + | probe := &resizeLockProbePTY{entered: make(chan struct{}), release: make(chan struct{})} | |
| 364 | + | second := newPane("pane-2", probe, domain.Size{Cols: 39, Rows: 23}) | |
| 365 | + | tb.mu.Lock() | |
| 366 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |
| 367 | + | tb.panes[second.id] = second | |
| 368 | + | tb.mu.Unlock() | |
| 369 | + | probe.check = func() { | |
| 370 | + | require.True(t, tb.mu.TryLock(), "PTY callback must not inherit tab.mu") | |
| 371 | + | tb.mu.Unlock() | |
| 372 | + | require.True(t, ac.overlays.resizeMu.TryLock(), "PTY callback must not inherit resize overlay mutex") | |
| 373 | + | ac.overlays.resizeMu.Unlock() | |
| 374 | + | } | |
| 375 | + | require.NoError(t, d.enterResizeMode(sess, ac)) | |
| 376 | + | done := make(chan struct{}) | |
| 377 | + | go func() { d.handleResizeInput(ac, []byte("l")); close(done) }() | |
| 378 | + | <-probe.entered | |
| 379 | + | close(probe.release) | |
| 380 | + | <-done | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | func resizeModeFixture(t *testing.T) (*Daemon, *session, *attachedClient) { | |
| 384 | + | t.Helper() | |
| 385 | + | factory := &controlPTYFactory{} | |
| 386 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 387 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |
| 388 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 389 | + | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |
| 390 | + | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK) | |
| 391 | + | ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 392 | + | ac.initOverlays() | |
| 393 | + | ac.setSession(sess) | |
| 394 | + | require.NoError(t, d.enterResizeMode(sess, ac)) | |
| 395 | + | return d, sess, ac | |
| 396 | + | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "sync" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| @@ -155,3 +156,241 @@ func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) { | |||||
| 155 | require.Equal(t, 1, right) | 156 | require.Equal(t, 1, right) | ||
| 156 | require.Empty(t, pending) | 157 | require.Empty(t, pending) | ||
| 157 | } | 158 | } | ||
| 159 | + | ||||
| 160 | + | // TestResizeModeKeysAndEscapes exercises the modal parser through the real | |||
| 161 | + | // action seam. Every directional spelling must commit exactly one mutation; | |||
| 162 | + | // parser prefixes are deliberately split across reads. | |||
| 163 | + | func TestResizeModeKeysAndEscapes(t *testing.T) { | |||
| 164 | + | tests := []struct { | |||
| 165 | + | name string | |||
| 166 | + | chunks [][]byte | |||
| 167 | + | change bool | |||
| 168 | + | exit bool | |||
| 169 | + | }{ | |||
| 170 | + | {"h", [][]byte{[]byte("h")}, true, false}, | |||
| 171 | + | {"j", [][]byte{[]byte("j")}, true, false}, | |||
| 172 | + | {"k", [][]byte{[]byte("k")}, true, false}, | |||
| 173 | + | {"l", [][]byte{[]byte("l")}, true, false}, | |||
| 174 | + | {"CSI left split", [][]byte{[]byte("\x1b["), []byte("D")}, true, false}, | |||
| 175 | + | {"CSI right", [][]byte{[]byte("\x1b[C")}, true, false}, | |||
| 176 | + | {"CSI up", [][]byte{[]byte("\x1b[A")}, true, false}, | |||
| 177 | + | {"CSI down", [][]byte{[]byte("\x1b[B")}, true, false}, | |||
| 178 | + | {"SS3 left", [][]byte{[]byte("\x1bOD")}, true, false}, | |||
| 179 | + | {"SS3 right split", [][]byte{[]byte("\x1bO"), []byte("C")}, true, false}, | |||
| 180 | + | {"SS3 up", [][]byte{[]byte("\x1bOA")}, true, false}, | |||
| 181 | + | {"SS3 down", [][]byte{[]byte("\x1bOB")}, true, false}, | |||
| 182 | + | {"equalize", [][]byte{[]byte("=")}, true, false}, | |||
| 183 | + | {"ignored", [][]byte{[]byte("z\x00")}, false, false}, | |||
| 184 | + | {"q exits", [][]byte{[]byte("q")}, false, true}, | |||
| 185 | + | {"enter exits", [][]byte{[]byte("\r")}, false, true}, | |||
| 186 | + | {"escape exits", [][]byte{[]byte("\x1b"), []byte("x")}, false, true}, | |||
| 187 | + | } | |||
| 188 | + | for _, tt := range tests { | |||
| 189 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 190 | + | d, sess, ac := resizeModeFixture(t) | |||
| 191 | + | tb := sess.activeTab() | |||
| 192 | + | tb.mu.Lock() | |||
| 193 | + | before := tb.layoutGeneration | |||
| 194 | + | if tt.name == "equalize" { | |||
| 195 | + | tb.tree.Root.Children[0].Weight = 3 | |||
| 196 | + | } | |||
| 197 | + | tb.mu.Unlock() | |||
| 198 | + | for _, chunk := range tt.chunks { | |||
| 199 | + | d.handleResizeInput(ac, chunk) | |||
| 200 | + | } | |||
| 201 | + | tb.mu.Lock() | |||
| 202 | + | after := tb.layoutGeneration | |||
| 203 | + | if tt.name == "equalize" { | |||
| 204 | + | for _, child := range tb.tree.Root.Children { | |||
| 205 | + | require.Zero(t, child.Weight) | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | tb.mu.Unlock() | |||
| 209 | + | if tt.change { | |||
| 210 | + | require.Equal(t, before+1, after) | |||
| 211 | + | } else { | |||
| 212 | + | require.Equal(t, before, after) | |||
| 213 | + | } | |||
| 214 | + | require.Equal(t, !tt.exit, ac.overlays.resizeModeActive()) | |||
| 215 | + | }) | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func TestResizeModeRendersGuidanceWithoutHidingContentOrCursor(t *testing.T) { | |||
| 220 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |||
| 221 | + | tb := sess.activeTab() | |||
| 222 | + | second := newPane("pane-2", nil, domain.Size{Cols: 39, Rows: 23}) | |||
| 223 | + | tb.mu.Lock() | |||
| 224 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |||
| 225 | + | tb.panes[second.id] = second | |||
| 226 | + | tb.mu.Unlock() | |||
| 227 | + | second.screen.Write([]byte("live resize content")) | |||
| 228 | + | require.NoError(t, d.enterResizeMode(sess, ac)) | |||
| 229 | + | data := string(mustOutputData(t, sends)) | |||
| 230 | + | require.Contains(t, data, "resize: h/j/k/l or arrows") | |||
| 231 | + | require.Contains(t, data, "live resize content") | |||
| 232 | + | require.Contains(t, data, "\x1b[?25h", "resize leaves the focused pane cursor visible") | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func TestResizeModeOverlayPriority(t *testing.T) { | |||
| 236 | + | tests := []struct { | |||
| 237 | + | name string | |||
| 238 | + | input []byte | |||
| 239 | + | setup func(*Daemon, *session, *attachedClient) | |||
| 240 | + | check func(*testing.T, *attachedClient) | |||
| 241 | + | }{ | |||
| 242 | + | {"prompt before palette picker notices resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) { | |||
| 243 | + | d.enterCopyMode(sess, ac) | |||
| 244 | + | d.enterNotices(sess, ac) | |||
| 245 | + | d.enterPicker(sess, ac) | |||
| 246 | + | d.enterPalette(sess, ac) | |||
| 247 | + | d.enterPrompt(sess, ac, "prompt", "", func(string) error { return nil }) | |||
| 248 | + | }, func(t *testing.T, ac *attachedClient) { | |||
| 249 | + | require.True(t, ac.overlays.promptActive()) | |||
| 250 | + | require.True(t, ac.overlays.resizeModeActive()) | |||
| 251 | + | }}, | |||
| 252 | + | {"palette before picker notices resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) { | |||
| 253 | + | d.enterCopyMode(sess, ac) | |||
| 254 | + | d.enterPicker(sess, ac) | |||
| 255 | + | d.enterPalette(sess, ac) | |||
| 256 | + | }, func(t *testing.T, ac *attachedClient) { | |||
| 257 | + | require.True(t, ac.overlays.paletteActive()) | |||
| 258 | + | require.True(t, ac.overlays.resizeModeActive()) | |||
| 259 | + | }}, | |||
| 260 | + | {"picker before resize and copy", []byte("j"), func(d *Daemon, sess *session, ac *attachedClient) { | |||
| 261 | + | d.enterCopyMode(sess, ac) | |||
| 262 | + | d.enterPicker(sess, ac) | |||
| 263 | + | }, func(t *testing.T, ac *attachedClient) { | |||
| 264 | + | require.True(t, ac.overlays.pickerActive()) | |||
| 265 | + | require.True(t, ac.overlays.resizeModeActive()) | |||
| 266 | + | }}, | |||
| 267 | + | {"notices before resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) { | |||
| 268 | + | d.enterCopyMode(sess, ac) | |||
| 269 | + | d.enterNotices(sess, ac) | |||
| 270 | + | }, func(t *testing.T, ac *attachedClient) { | |||
| 271 | + | require.False(t, ac.overlays.noticesActive(), "q must be consumed by notices") | |||
| 272 | + | require.True(t, ac.overlays.resizeModeActive()) | |||
| 273 | + | }}, | |||
| 274 | + | } | |||
| 275 | + | for _, tt := range tests { | |||
| 276 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 277 | + | d, sess, ac := resizeModeFixture(t) | |||
| 278 | + | tt.setup(d, sess, ac) | |||
| 279 | + | require.True(t, ac.overlays.HandleInput(d, tt.input)) | |||
| 280 | + | tt.check(t, ac) | |||
| 281 | + | }) | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | func TestResizeModeWarningPersistsAndActionsShareTransaction(t *testing.T) { | |||
| 286 | + | t.Run("too small warning leaves mode active", func(t *testing.T) { | |||
| 287 | + | d, sess, ac := resizeModeFixture(t) | |||
| 288 | + | tb := sess.activeTab() | |||
| 289 | + | tb.mu.Lock() | |||
| 290 | + | tb.size.Cols = 41 // two minimum-width panes and their separator | |||
| 291 | + | tb.bumpLayoutGenerationLocked() | |||
| 292 | + | tb.mu.Unlock() | |||
| 293 | + | d.handleResizeInput(ac, []byte("l")) | |||
| 294 | + | require.True(t, ac.overlays.resizeModeActive()) | |||
| 295 | + | history := d.notices.history() | |||
| 296 | + | require.NotEmpty(t, history) | |||
| 297 | + | require.Equal(t, "pane cannot be resized further", history[len(history)-1].Message) | |||
| 298 | + | }) | |||
| 299 | + | ||||
| 300 | + | t.Run("palette control and modal have the same committed weights", func(t *testing.T) { | |||
| 301 | + | type invoke func(*Daemon, *session, *attachedClient) error | |||
| 302 | + | invocations := []struct { | |||
| 303 | + | name string | |||
| 304 | + | run invoke | |||
| 305 | + | }{ | |||
| 306 | + | {"palette", func(d *Daemon, sess *session, ac *attachedClient) error { | |||
| 307 | + | return (paletteExec{d: d, sess: sess, ac: ac}).GrowPaneWidth() | |||
| 308 | + | }}, | |||
| 309 | + | {"control", func(d *Daemon, sess *session, _ *attachedClient) error { | |||
| 310 | + | target := resolveDaemonActionTarget(sess) | |||
| 311 | + | return (controlExec{d: d, sess: sess, tab: target.tab, target: target}).GrowPaneWidth() | |||
| 312 | + | }}, | |||
| 313 | + | {"modal", func(d *Daemon, _ *session, ac *attachedClient) error { | |||
| 314 | + | d.handleResizeInput(ac, []byte("l")) | |||
| 315 | + | return nil | |||
| 316 | + | }}, | |||
| 317 | + | } | |||
| 318 | + | var want []float64 | |||
| 319 | + | for _, tt := range invocations { | |||
| 320 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 321 | + | d, sess, ac := resizeModeFixture(t) | |||
| 322 | + | require.NoError(t, tt.run(d, sess, ac)) | |||
| 323 | + | tb := sess.activeTab() | |||
| 324 | + | tb.mu.Lock() | |||
| 325 | + | got := []float64{tb.tree.Root.Children[0].Weight, tb.tree.Root.Children[1].Weight} | |||
| 326 | + | tb.mu.Unlock() | |||
| 327 | + | if want == nil { | |||
| 328 | + | want = got | |||
| 329 | + | } else { | |||
| 330 | + | require.Equal(t, want, got) | |||
| 331 | + | } | |||
| 332 | + | }) | |||
| 333 | + | } | |||
| 334 | + | }) | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | type resizeLockProbePTY struct { | |||
| 338 | + | once sync.Once | |||
| 339 | + | entered chan struct{} | |||
| 340 | + | release chan struct{} | |||
| 341 | + | check func() | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | func (p *resizeLockProbePTY) Read([]byte) (int, error) { return 0, nil } | |||
| 345 | + | func (*resizeLockProbePTY) Write(b []byte) (int, error) { return len(b), nil } | |||
| 346 | + | func (*resizeLockProbePTY) Close() error { return nil } | |||
| 347 | + | func (*resizeLockProbePTY) Pid() int { return 0 } | |||
| 348 | + | func (*resizeLockProbePTY) ForegroundPgid() (int, error) { return 0, nil } | |||
| 349 | + | func (p *resizeLockProbePTY) Resize(domain.Size) error { | |||
| 350 | + | p.once.Do(func() { | |||
| 351 | + | p.check() | |||
| 352 | + | close(p.entered) | |||
| 353 | + | <-p.release | |||
| 354 | + | }) | |||
| 355 | + | return nil | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | func TestResizeModeReleasesTabAndOverlayLocksBeforePTY(t *testing.T) { | |||
| 359 | + | // Build a live tree directly so the probe is not concurrently owned by a | |||
| 360 | + | // pane reader; this isolates the lock boundary being asserted. | |||
| 361 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 362 | + | tb := sess.activeTab() | |||
| 363 | + | probe := &resizeLockProbePTY{entered: make(chan struct{}), release: make(chan struct{})} | |||
| 364 | + | second := newPane("pane-2", probe, domain.Size{Cols: 39, Rows: 23}) | |||
| 365 | + | tb.mu.Lock() | |||
| 366 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |||
| 367 | + | tb.panes[second.id] = second | |||
| 368 | + | tb.mu.Unlock() | |||
| 369 | + | probe.check = func() { | |||
| 370 | + | require.True(t, tb.mu.TryLock(), "PTY callback must not inherit tab.mu") | |||
| 371 | + | tb.mu.Unlock() | |||
| 372 | + | require.True(t, ac.overlays.resizeMu.TryLock(), "PTY callback must not inherit resize overlay mutex") | |||
| 373 | + | ac.overlays.resizeMu.Unlock() | |||
| 374 | + | } | |||
| 375 | + | require.NoError(t, d.enterResizeMode(sess, ac)) | |||
| 376 | + | done := make(chan struct{}) | |||
| 377 | + | go func() { d.handleResizeInput(ac, []byte("l")); close(done) }() | |||
| 378 | + | <-probe.entered | |||
| 379 | + | close(probe.release) | |||
| 380 | + | <-done | |||
| 381 | + | } | |||
| 382 | + | ||||
| 383 | + | func resizeModeFixture(t *testing.T) (*Daemon, *session, *attachedClient) { | |||
| 384 | + | t.Helper() | |||
| 385 | + | factory := &controlPTYFactory{} | |||
| 386 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 387 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |||
| 388 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |||
| 389 | + | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |||
| 390 | + | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK) | |||
| 391 | + | ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 392 | + | ac.initOverlays() | |||
| 393 | + | ac.setSession(sess) | |||
| 394 | + | require.NoError(t, d.enterResizeMode(sess, ac)) | |||
| 395 | + | return d, sess, ac | |||
| 396 | + | } | |||