1package cli_test
2
3import (
4 "bytes"
5 "errors"
6 "flag"
7 "testing"
8 "time"
9
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12
13 "github.com/bnema/nefercap/internal/adapters/cli"
14 "github.com/bnema/nefercap/internal/ports"
15)
16
17func TestParseValid(t *testing.T) {
18 cases := []struct {
19 name string
20 args []string
21 want cli.Options
22 }{
23 {"all-in-one", nil, cli.Options{Command: cli.AllInOne}},
24 {"all-in-one debug", []string{"-debug"}, cli.Options{Command: cli.AllInOne, Debug: true}},
25 {"shot clipboard", []string{"shot", "-clipboard"}, cli.Options{Command: cli.Shot, Clipboard: true}},
26 {"shot clipboard file", []string{"shot", "-clipboard", "-file", "a.png"},
27 cli.Options{Command: cli.Shot, Clipboard: true, Path: "a.png"}},
28 {"shot", []string{"shot"}, cli.Options{Command: cli.Shot}},
29 {"shot all", []string{"shot", "-file", "a.png", "-output", "DP-1", "-debug"},
30 cli.Options{Command: cli.Shot, Path: "a.png", Output: "DP-1", Debug: true}},
31 {"rec defaults", []string{"rec"}, cli.Options{Command: cli.Rec, Video: ports.VideoSettings{FPS: 30}}},
32 {"rec all", []string{"rec", "-file", "a.mkv", "-output", "eDP-1", "-fps", "60", "-size", "1280x720", "-duration", "10s", "-debug"},
33 cli.Options{Command: cli.Rec, Path: "a.mkv", Output: "eDP-1", Duration: 10 * time.Second, Debug: true,
34 Video: ports.VideoSettings{FPS: 60, Width: 1280, Height: 720}}},
35 {"stop", []string{"stop"}, cli.Options{Command: cli.Stop}},
36 {"stop debug", []string{"stop", "-debug"}, cli.Options{Command: cli.Stop, Debug: true}},
37 {"status", []string{"status"}, cli.Options{Command: cli.Status}},
38 {"status debug", []string{"status", "-debug"}, cli.Options{Command: cli.Status, Debug: true}},
39 {"outputs", []string{"outputs"}, cli.Options{Command: cli.Outputs}},
40 {"outputs debug", []string{"outputs", "-debug"}, cli.Options{Command: cli.Outputs, Debug: true}},
41 {"screenshot", []string{"screenshot", "-file", "a.png"},
42 cli.Options{Command: cli.Screenshot, Path: "a.png"}},
43 {"screenshot all", []string{"screenshot", "-output", "DP-1", "-region", "10,20,300x400", "-file", "a.png", "-debug"},
44 cli.Options{Command: cli.Screenshot, Output: "DP-1", Path: "a.png", Debug: true,
45 Region: ports.Region{X: 10, Y: 20, Width: 300, Height: 400}}},
46 {"record defaults", []string{"record", "-file", "a.mkv"},
47 cli.Options{Command: cli.Record, Path: "a.mkv", Video: ports.VideoSettings{FPS: 30}}},
48 {"record all", []string{"record", "-file", "a.mkv", "-fps", "60", "-size", "1280x720", "-duration", "1m30s", "-output", "eDP-1", "-region", "0,0,640x480"},
49 cli.Options{Command: cli.Record, Path: "a.mkv", Output: "eDP-1", Duration: 90 * time.Second,
50 Region: ports.Region{Width: 640, Height: 480},
51 Video: ports.VideoSettings{FPS: 60, Width: 1280, Height: 720}}},
52 {"record zero duration", []string{"record", "-file", "a.mkv", "-duration", "0"},
53 cli.Options{Command: cli.Record, Path: "a.mkv", Video: ports.VideoSettings{FPS: 30}}},
54 {"record fps bounds", []string{"record", "-file", "a.mkv", "-fps", "120"},
55 cli.Options{Command: cli.Record, Path: "a.mkv", Video: ports.VideoSettings{FPS: 120}}},
56 {"double dash flag", []string{"record", "--file", "a.mkv", "--fps=1"},
57 cli.Options{Command: cli.Record, Path: "a.mkv", Video: ports.VideoSettings{FPS: 1}}},
58 }
59 for _, tc := range cases {
60 t.Run(tc.name, func(t *testing.T) {
61 var out bytes.Buffer
62 got, err := cli.Parse(tc.args, &out)
63 require.NoError(t, err)
64 assert.Equal(t, tc.want, got)
65 assert.Empty(t, out.String())
66 })
67 }
68}
69
70func TestParseUsageErrors(t *testing.T) {
71 cases := map[string][]string{
72 "unknown command": {"snap"},
73 "positional after command": {"outputs", "extra"},
74 "positional default": {"extra"},
75 "positional shot": {"shot", "extra"},
76 "positional stop": {"stop", "extra"},
77 "gui removed": {"gui"},
78 "gui removed with flag": {"gui", "-debug"},
79 "empty shot file": {"shot", "-file", ""},
80 "empty rec file": {"rec", "-file", ""},
81 "empty default file": {"-file", ""},
82 "region on shot": {"shot", "-region", "0,0,10x10"},
83 "region on rec": {"rec", "-region", "0,0,10x10"},
84 "region on default": {"-region", "0,0,10x10"},
85 "fps on shot": {"shot", "-fps", "30"},
86 "size on shot": {"shot", "-size", "2x2"},
87 "duration on shot": {"shot", "-duration", "1s"},
88 "fps zero on rec": {"rec", "-fps", "0"},
89 "fps high on rec": {"rec", "-fps", "121"},
90 "size odd on rec": {"rec", "-size", "641x480"},
91 "size huge on rec": {"rec", "-size", "16384x16384"},
92 "duration negative on rec": {"rec", "-duration", "-1s"},
93 "file on stop": {"stop", "-file", "a"},
94 "output on stop": {"stop", "-output", "DP-1"},
95 "file on status": {"status", "-file", "a"},
96 "output on status": {"status", "-output", "DP-1"},
97 "fps on status": {"status", "-fps", "30"},
98 "file on outputs": {"outputs", "-file", "a"},
99 "positional after flags": {"record", "-file", "a.mkv", "extra"},
100 "unknown flag": {"-nope"},
101 "unknown flag in command": {"outputs", "-nope"},
102 "screenshot missing file": {"screenshot"},
103 "record missing file": {"record", "-fps", "30"},
104 "empty file": {"screenshot", "-file", ""},
105 "capture flag on outputs": {"outputs", "-output", "DP-1"},
106 "fps on screenshot": {"screenshot", "-file", "a.png", "-fps", "30"},
107 "duration on screenshot": {"screenshot", "-file", "a.png", "-duration", "1s"},
108 "size on screenshot": {"screenshot", "-file", "a.png", "-size", "2x2"},
109 "fps zero": {"record", "-file", "a", "-fps", "0"},
110 "fps high": {"record", "-file", "a", "-fps", "121"},
111 "fps text": {"record", "-file", "a", "-fps", "fast"},
112 "size odd width": {"record", "-file", "a", "-size", "641x480"},
113 "size odd height": {"record", "-file", "a", "-size", "640x481"},
114 "size zero": {"record", "-file", "a", "-size", "0x0"},
115 "size too large": {"record", "-file", "a", "-size", "16386x2"},
116 "size frame too large": {"record", "-file", "a", "-size", "16384x16384"},
117 "size missing height": {"record", "-file", "a", "-size", "640"},
118 "size negative": {"record", "-file", "a", "-size", "-2x-2"},
119 "size junk": {"record", "-file", "a", "-size", "axb"},
120 "size signed": {"record", "-file", "a", "-size", "+2x2"},
121 "duration negative": {"record", "-file", "a", "-duration", "-1s"},
122 "duration bare number": {"record", "-file", "a", "-duration", "5"},
123 "region empty parts": {"screenshot", "-file", "a", "-region", ",,x"},
124 "region missing size": {"screenshot", "-file", "a", "-region", "1,2"},
125 "region missing y": {"screenshot", "-file", "a", "-region", "1,10x10"},
126 "region extra part": {"screenshot", "-file", "a", "-region", "1,2,3,10x10"},
127 "region negative x": {"screenshot", "-file", "a", "-region", "-1,0,10x10"},
128 "region negative y": {"screenshot", "-file", "a", "-region", "0,-1,10x10"},
129 "region zero width": {"screenshot", "-file", "a", "-region", "0,0,0x10"},
130 "region zero height": {"screenshot", "-file", "a", "-region", "0,0,10x0"},
131 "region overflow": {"screenshot", "-file", "a", "-region", "0,0,99999999999x1"},
132 "region sum overflow": {"screenshot", "-file", "a", "-region", "2147483647,0,1x1"},
133 "region signed": {"screenshot", "-file", "a", "-region", "+1,0,10x10"},
134 "region uppercase X": {"screenshot", "-file", "a", "-region", "0,0,10X10"},
135 "region spaces": {"screenshot", "-file", "a", "-region", "0, 0, 10x10"},
136 }
137 for name, args := range cases {
138 t.Run(name, func(t *testing.T) {
139 var out bytes.Buffer
140 got, err := cli.Parse(args, &out)
141 require.Error(t, err)
142 assert.ErrorIs(t, err, cli.ErrUsage)
143 assert.NotErrorIs(t, err, flag.ErrHelp)
144 assert.Equal(t, cli.Options{}, got)
145 assert.Empty(t, out.String(), "errors are reported by the caller")
146 })
147 }
148}
149
150func TestAllInOneRejectsFlags(t *testing.T) {
151 for _, args := range [][]string{
152 {"-file", "a.png"}, {"-output", "DP-1"}, {"-clipboard"}, {"-region", "0,0,1x1"}, {"-fps", "30"},
153 {"-size", "2x2"}, {"-duration", "1s"}, {"-nope"}, {"-debug", "-file", "a.png"}, {"-file", ""},
154 } {
155 got, err := cli.Parse(args, nil)
156 require.ErrorIs(t, err, cli.ErrUsage, args)
157 assert.ErrorContains(t, err, "all-in-one mode takes no flags except -debug")
158 assert.ErrorContains(t, err, "use shot, rec, screenshot or record for per-run overrides")
159 assert.Equal(t, cli.Options{}, got)
160 }
161}
162
163func TestParseErrorNamesProblem(t *testing.T) {
164 _, err := cli.Parse([]string{"record"}, nil)
165 assert.ErrorContains(t, err, "-file is required")
166 _, err = cli.Parse([]string{"snap"}, nil)
167 assert.ErrorContains(t, err, `unknown command "snap"`)
168 _, err = cli.Parse([]string{"screenshot", "-file", "a", "-region", "1,2"}, nil)
169 assert.ErrorContains(t, err, "X,Y,WxH")
170}
171
172func TestParseHelp(t *testing.T) {
173 for _, args := range [][]string{{"-h"}, {"--help"}, {"-help"}, {"record", "--help"}, {"outputs", "-h"}, {"shot", "-h"}, {"rec", "-h"}, {"stop", "-h"}, {"status", "--help"}} {
174 var out bytes.Buffer
175 got, err := cli.Parse(args, &out)
176 assert.True(t, errors.Is(err, flag.ErrHelp), args)
177 assert.Equal(t, cli.Options{}, got)
178 assert.Equal(t, cli.Usage(), out.String())
179 }
180}
181
182func TestUsageMentionsEverything(t *testing.T) {
183 u := cli.Usage()
184 for _, s := range []string{"shot", "rec", "stop", "status", "outputs", "screenshot", "record",
185 "layer-shell", "drag", "click", "Escape", "Enter", "workspace", "1..9", " r ", " m ", " w ", " g ", "-output", "-region", "-file", "-fps", "-size", "-duration", "-debug"} {
186 assert.Contains(t, u, s)
187 }
188 assert.NotContains(t, u, " gui ")
189}