feat(command): add action registry
- commit
- af68b90cfe64641b2ab42e0d161bd18dc0174815
- author
- brice · · 2026-07-03 05:18 UTC
- committer
- brice · · 2026-07-03 07:33 UTC
- parent
- 3e25d6d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +155 -0
A internal/usecase/command/command.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | package command | |
| 2 | + | ||
| 3 | + | // Context is the set of application actions available to commands. | |
| 4 | + | type Context interface { | |
| 5 | + | CreateTab() error | |
| 6 | + | CloseTab() error | |
| 7 | + | NextTab() error | |
| 8 | + | PrevTab() error | |
| 9 | + | Detach() error | |
| 10 | + | EnterCopyMode() error | |
| 11 | + | RenameSession() error | |
| 12 | + | OpenSessionPicker() error | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // Command describes an executable command. | |
| 16 | + | type Command struct { | |
| 17 | + | Code, Name, Desc string | |
| 18 | + | Run func(Context) error | |
| 19 | + | } | |
| @@ -0,0 +1,19 @@ | |||||
| 1 | + | package command | |||
| 2 | + | ||||
| 3 | + | // Context is the set of application actions available to commands. | |||
| 4 | + | type Context interface { | |||
| 5 | + | CreateTab() error | |||
| 6 | + | CloseTab() error | |||
| 7 | + | NextTab() error | |||
| 8 | + | PrevTab() error | |||
| 9 | + | Detach() error | |||
| 10 | + | EnterCopyMode() error | |||
| 11 | + | RenameSession() error | |||
| 12 | + | OpenSessionPicker() error | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // Command describes an executable command. | |||
| 16 | + | type Command struct { | |||
| 17 | + | Code, Name, Desc string | |||
| 18 | + | Run func(Context) error | |||
| 19 | + | } | |||
A internal/usecase/command/registry.go +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | package command | |
| 2 | + | ||
| 3 | + | import "strings" | |
| 4 | + | ||
| 5 | + | // Registry returns all commands in display order. | |
| 6 | + | func Registry() []Command { | |
| 7 | + | commands := []Command{ | |
| 8 | + | {Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }}, | |
| 9 | + | {Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }}, | |
| 10 | + | {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }}, | |
| 11 | + | {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }}, | |
| 12 | + | {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, | |
| 13 | + | {Code: "CPY", Name: "Copy mode", Desc: "Enter copy mode", Run: func(ctx Context) error { return ctx.EnterCopyMode() }}, | |
| 14 | + | {Code: "RNS", Name: "Rename session", Desc: "Rename the current session", Run: func(ctx Context) error { return ctx.RenameSession() }}, | |
| 15 | + | {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }}, | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | return commands | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // ByCode returns the command matching code, case-insensitively. | |
| 22 | + | func ByCode(code string) (Command, bool) { | |
| 23 | + | code = strings.ToUpper(code) | |
| 24 | + | for _, cmd := range Registry() { | |
| 25 | + | if cmd.Code == code { | |
| 26 | + | return cmd, true | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | return Command{}, false | |
| 31 | + | } | |
| @@ -0,0 +1,31 @@ | |||||
| 1 | + | package command | |||
| 2 | + | ||||
| 3 | + | import "strings" | |||
| 4 | + | ||||
| 5 | + | // Registry returns all commands in display order. | |||
| 6 | + | func Registry() []Command { | |||
| 7 | + | commands := []Command{ | |||
| 8 | + | {Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }}, | |||
| 9 | + | {Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }}, | |||
| 10 | + | {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }}, | |||
| 11 | + | {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }}, | |||
| 12 | + | {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, | |||
| 13 | + | {Code: "CPY", Name: "Copy mode", Desc: "Enter copy mode", Run: func(ctx Context) error { return ctx.EnterCopyMode() }}, | |||
| 14 | + | {Code: "RNS", Name: "Rename session", Desc: "Rename the current session", Run: func(ctx Context) error { return ctx.RenameSession() }}, | |||
| 15 | + | {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }}, | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | return commands | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // ByCode returns the command matching code, case-insensitively. | |||
| 22 | + | func ByCode(code string) (Command, bool) { | |||
| 23 | + | code = strings.ToUpper(code) | |||
| 24 | + | for _, cmd := range Registry() { | |||
| 25 | + | if cmd.Code == code { | |||
| 26 | + | return cmd, true | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | return Command{}, false | |||
| 31 | + | } | |||
A internal/usecase/command/registry_test.go +105 -0
| @@ -0,0 +1,105 @@ | |||
| 1 | + | package command | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "regexp" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | |
| 10 | + | commands := Registry() | |
| 11 | + | wantCodes := []string{"CNT", "CLT", "NXT", "PVT", "SSP", "CPY", "RNS", "DET"} | |
| 12 | + | ||
| 13 | + | if len(commands) != len(wantCodes) { | |
| 14 | + | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | codePattern := regexp.MustCompile(`^[A-Z]{3}$`) | |
| 18 | + | seen := make(map[string]bool, len(commands)) | |
| 19 | + | for i, cmd := range commands { | |
| 20 | + | if cmd.Code != wantCodes[i] { | |
| 21 | + | t.Fatalf("Registry()[%d].Code = %q, want %q", i, cmd.Code, wantCodes[i]) | |
| 22 | + | } | |
| 23 | + | if !codePattern.MatchString(cmd.Code) { | |
| 24 | + | t.Errorf("Registry()[%d].Code = %q, want three uppercase letters", i, cmd.Code) | |
| 25 | + | } | |
| 26 | + | if seen[cmd.Code] { | |
| 27 | + | t.Errorf("Registry()[%d].Code = %q, duplicate code", i, cmd.Code) | |
| 28 | + | } | |
| 29 | + | seen[cmd.Code] = true | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestByCodeIsCaseInsensitive(t *testing.T) { | |
| 34 | + | for _, code := range []string{"CNT", "cnt", "CnT"} { | |
| 35 | + | cmd, ok := ByCode(code) | |
| 36 | + | if !ok { | |
| 37 | + | t.Fatalf("ByCode(%q) ok = false, want true", code) | |
| 38 | + | } | |
| 39 | + | if cmd.Code != "CNT" { | |
| 40 | + | t.Fatalf("ByCode(%q).Code = %q, want CNT", code, cmd.Code) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | if _, ok := ByCode("missing"); ok { | |
| 45 | + | t.Fatal("ByCode(unknown) ok = true, want false") | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |
| 50 | + | tests := []struct { | |
| 51 | + | code string | |
| 52 | + | want string | |
| 53 | + | }{ | |
| 54 | + | {code: "CNT", want: "CreateTab"}, | |
| 55 | + | {code: "CLT", want: "CloseTab"}, | |
| 56 | + | {code: "NXT", want: "NextTab"}, | |
| 57 | + | {code: "PVT", want: "PrevTab"}, | |
| 58 | + | {code: "SSP", want: "OpenSessionPicker"}, | |
| 59 | + | {code: "CPY", want: "EnterCopyMode"}, | |
| 60 | + | {code: "RNS", want: "RenameSession"}, | |
| 61 | + | {code: "DET", want: "Detach"}, | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | for _, tt := range tests { | |
| 65 | + | t.Run(tt.code, func(t *testing.T) { | |
| 66 | + | cmd, ok := ByCode(tt.code) | |
| 67 | + | if !ok { | |
| 68 | + | t.Fatalf("ByCode(%q) ok = false, want true", tt.code) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | ctx := &spyContext{} | |
| 72 | + | if err := cmd.Run(ctx); err != nil { | |
| 73 | + | t.Fatalf("Run() error = %v, want nil", err) | |
| 74 | + | } | |
| 75 | + | if got := ctx.onlyCall(); got != tt.want { | |
| 76 | + | t.Fatalf("Run() called %q, want %q", got, tt.want) | |
| 77 | + | } | |
| 78 | + | }) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | type spyContext struct { | |
| 83 | + | calls []string | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (s *spyContext) record(name string) error { | |
| 87 | + | s.calls = append(s.calls, name) | |
| 88 | + | return nil | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (s *spyContext) onlyCall() string { | |
| 92 | + | if len(s.calls) != 1 { | |
| 93 | + | return fmt.Sprintf("%v", s.calls) | |
| 94 | + | } | |
| 95 | + | return s.calls[0] | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (s *spyContext) CreateTab() error { return s.record("CreateTab") } | |
| 99 | + | func (s *spyContext) CloseTab() error { return s.record("CloseTab") } | |
| 100 | + | func (s *spyContext) NextTab() error { return s.record("NextTab") } | |
| 101 | + | func (s *spyContext) PrevTab() error { return s.record("PrevTab") } | |
| 102 | + | func (s *spyContext) Detach() error { return s.record("Detach") } | |
| 103 | + | func (s *spyContext) EnterCopyMode() error { return s.record("EnterCopyMode") } | |
| 104 | + | func (s *spyContext) RenameSession() error { return s.record("RenameSession") } | |
| 105 | + | func (s *spyContext) OpenSessionPicker() error { return s.record("OpenSessionPicker") } | |
| @@ -0,0 +1,105 @@ | |||||
| 1 | + | package command | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "regexp" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | |||
| 10 | + | commands := Registry() | |||
| 11 | + | wantCodes := []string{"CNT", "CLT", "NXT", "PVT", "SSP", "CPY", "RNS", "DET"} | |||
| 12 | + | ||||
| 13 | + | if len(commands) != len(wantCodes) { | |||
| 14 | + | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | codePattern := regexp.MustCompile(`^[A-Z]{3}$`) | |||
| 18 | + | seen := make(map[string]bool, len(commands)) | |||
| 19 | + | for i, cmd := range commands { | |||
| 20 | + | if cmd.Code != wantCodes[i] { | |||
| 21 | + | t.Fatalf("Registry()[%d].Code = %q, want %q", i, cmd.Code, wantCodes[i]) | |||
| 22 | + | } | |||
| 23 | + | if !codePattern.MatchString(cmd.Code) { | |||
| 24 | + | t.Errorf("Registry()[%d].Code = %q, want three uppercase letters", i, cmd.Code) | |||
| 25 | + | } | |||
| 26 | + | if seen[cmd.Code] { | |||
| 27 | + | t.Errorf("Registry()[%d].Code = %q, duplicate code", i, cmd.Code) | |||
| 28 | + | } | |||
| 29 | + | seen[cmd.Code] = true | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestByCodeIsCaseInsensitive(t *testing.T) { | |||
| 34 | + | for _, code := range []string{"CNT", "cnt", "CnT"} { | |||
| 35 | + | cmd, ok := ByCode(code) | |||
| 36 | + | if !ok { | |||
| 37 | + | t.Fatalf("ByCode(%q) ok = false, want true", code) | |||
| 38 | + | } | |||
| 39 | + | if cmd.Code != "CNT" { | |||
| 40 | + | t.Fatalf("ByCode(%q).Code = %q, want CNT", code, cmd.Code) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | if _, ok := ByCode("missing"); ok { | |||
| 45 | + | t.Fatal("ByCode(unknown) ok = true, want false") | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||
| 50 | + | tests := []struct { | |||
| 51 | + | code string | |||
| 52 | + | want string | |||
| 53 | + | }{ | |||
| 54 | + | {code: "CNT", want: "CreateTab"}, | |||
| 55 | + | {code: "CLT", want: "CloseTab"}, | |||
| 56 | + | {code: "NXT", want: "NextTab"}, | |||
| 57 | + | {code: "PVT", want: "PrevTab"}, | |||
| 58 | + | {code: "SSP", want: "OpenSessionPicker"}, | |||
| 59 | + | {code: "CPY", want: "EnterCopyMode"}, | |||
| 60 | + | {code: "RNS", want: "RenameSession"}, | |||
| 61 | + | {code: "DET", want: "Detach"}, | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | for _, tt := range tests { | |||
| 65 | + | t.Run(tt.code, func(t *testing.T) { | |||
| 66 | + | cmd, ok := ByCode(tt.code) | |||
| 67 | + | if !ok { | |||
| 68 | + | t.Fatalf("ByCode(%q) ok = false, want true", tt.code) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | ctx := &spyContext{} | |||
| 72 | + | if err := cmd.Run(ctx); err != nil { | |||
| 73 | + | t.Fatalf("Run() error = %v, want nil", err) | |||
| 74 | + | } | |||
| 75 | + | if got := ctx.onlyCall(); got != tt.want { | |||
| 76 | + | t.Fatalf("Run() called %q, want %q", got, tt.want) | |||
| 77 | + | } | |||
| 78 | + | }) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | type spyContext struct { | |||
| 83 | + | calls []string | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (s *spyContext) record(name string) error { | |||
| 87 | + | s.calls = append(s.calls, name) | |||
| 88 | + | return nil | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (s *spyContext) onlyCall() string { | |||
| 92 | + | if len(s.calls) != 1 { | |||
| 93 | + | return fmt.Sprintf("%v", s.calls) | |||
| 94 | + | } | |||
| 95 | + | return s.calls[0] | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func (s *spyContext) CreateTab() error { return s.record("CreateTab") } | |||
| 99 | + | func (s *spyContext) CloseTab() error { return s.record("CloseTab") } | |||
| 100 | + | func (s *spyContext) NextTab() error { return s.record("NextTab") } | |||
| 101 | + | func (s *spyContext) PrevTab() error { return s.record("PrevTab") } | |||
| 102 | + | func (s *spyContext) Detach() error { return s.record("Detach") } | |||
| 103 | + | func (s *spyContext) EnterCopyMode() error { return s.record("EnterCopyMode") } | |||
| 104 | + | func (s *spyContext) RenameSession() error { return s.record("RenameSession") } | |||
| 105 | + | func (s *spyContext) OpenSessionPicker() error { return s.record("OpenSessionPicker") } | |||