Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

395 commit(s) on main

update readme Keenan Nemetz · 57m ago 32290114
history blame raw 8.3 KiB
1package main
2
3import (
4 "testing"
5 "time"
6
7 "rodent.software/vole/metadata"
8)
9
10func issueForTest() *metadata.Issue {
11 return &metadata.Issue{
12 ID: "01M5JR2JK29KKMAA1T9MDNTPBP",
13 State: "open",
14 Author: "keenan",
15 Title: "A title",
16 Labels: []string{"bug", "ui"},
17 Created: time.Unix(1700000000, 0).UTC(),
18 }
19}
20
21func issueSpecs() []flagSpec {
22 return []flagSpec{
23 {long: "title", short: "t"},
24 {long: "message", short: "m"},
25 {long: "file", short: "F"},
26 {long: "label", short: "l"},
27 {long: "no-push", boolean: true},
28 }
29}
30
31func TestFlagForms(t *testing.T) {
32 for name, args := range map[string][]string{
33 "separate long": {"--title", "hello"},
34 "stuck long": {"--title=hello"},
35 "separate short": {"-t", "hello"},
36 "stuck short": {"-thello"},
37 } {
38 f, err := parseFlags(args, issueSpecs()...)
39 if err != nil {
40 t.Fatalf("%s: %v", name, err)
41 }
42 if got := f.one("title"); got != "hello" {
43 t.Fatalf("%s: title = %q", name, got)
44 }
45 }
46}
47
48func TestRepeatableFlag(t *testing.T) {
49 f, err := parseFlags([]string{"-l", "bug", "--label", "ui", "-lperf"}, issueSpecs()...)
50 if err != nil {
51 t.Fatal(err)
52 }
53 got := f.all("label")
54 if len(got) != 3 || got[0] != "bug" || got[1] != "ui" || got[2] != "perf" {
55 t.Fatalf("labels = %v", got)
56 }
57}
58
59func TestBooleanFlag(t *testing.T) {
60 f, err := parseFlags([]string{"--no-push"}, issueSpecs()...)
61 if err != nil {
62 t.Fatal(err)
63 }
64 if !f.has("no-push") {
65 t.Fatal("--no-push not set")
66 }
67 if _, err := parseFlags([]string{"--no-push=yes"}, issueSpecs()...); err == nil {
68 t.Fatal("a boolean flag accepted a value")
69 }
70}
71
72// TestOptionsAnywhere: git accepts options after arguments, so Vole does too.
73// Anything else is a local dialect, and `git commit -m msg path` is the shape
74// people already type.
75func TestOptionsAnywhere(t *testing.T) {
76 f, err := parseFlags([]string{"01M5", "-m", "text"}, issueSpecs()...)
77 if err != nil {
78 t.Fatalf("option after argument was refused: %v", err)
79 }
80 if f.one("message") != "text" || len(f.rest) != 1 || f.rest[0] != "01M5" {
81 t.Fatalf("rest = %v, message = %q", f.rest, f.one("message"))
82 }
83
84 f, err = parseFlags([]string{"-m", "text", "01M5"}, issueSpecs()...)
85 if err != nil {
86 t.Fatalf("options first failed: %v", err)
87 }
88 if len(f.rest) != 1 || f.rest[0] != "01M5" {
89 t.Fatalf("rest = %v", f.rest)
90 }
91}
92
93func TestEndOfOptions(t *testing.T) {
94 for _, term := range []string{"--", "--end-of-options"} {
95 f, err := parseFlags([]string{term, "--title", "-m"}, issueSpecs()...)
96 if err != nil {
97 t.Fatalf("%s: %v", term, err)
98 }
99 if len(f.rest) != 2 || f.rest[0] != "--title" || f.rest[1] != "-m" {
100 t.Fatalf("%s: rest = %v", term, f.rest)
101 }
102 if f.one("title") != "" {
103 t.Fatal("option after the terminator was parsed")
104 }
105 }
106}
107
108func TestUsageErrors(t *testing.T) {
109 for name, args := range map[string][]string{
110 "unknown long": {"--nope"},
111 "unknown short": {"-Z"},
112 "missing value": {"--title"},
113 "equals on a short opt": {"-l=perf"},
114 } {
115 _, err := parseFlags(args, issueSpecs()...)
116 if err == nil {
117 t.Fatalf("%s: expected an error", name)
118 }
119 if !isUsageErr(err) {
120 t.Fatalf("%s: error is %T, want usageErr", name, err)
121 }
122 }
123}
124
125// TestShortOptionsBundle: `-am msg` is `-a -m msg`, as it is in git. The
126// option that takes a value consumes the rest of the cluster, so `-mm` still
127// means `-m m`.
128func TestShortOptionsBundle(t *testing.T) {
129 specs := []flagSpec{
130 {long: "message", short: "m"},
131 {long: "quiet", short: "q", boolean: true},
132 {long: "all", short: "a", boolean: true},
133 }
134 f, err := parseFlags([]string{"-qamhello"}, specs...)
135 if err != nil {
136 t.Fatalf("-qamhello: %v", err)
137 }
138 if !f.has("quiet") || !f.has("all") {
139 t.Fatalf("bundle did not set booleans: %+v", f.bools)
140 }
141 if f.one("message") != "hello" {
142 t.Fatalf("message = %q, want \"hello\"", f.one("message"))
143 }
144
145 f, err = parseFlags([]string{"-mm"}, specs...)
146 if err != nil {
147 t.Fatalf("-mm should be the stuck form: %v", err)
148 }
149 if f.one("message") != "m" {
150 t.Fatalf("message = %q, want \"m\"", f.one("message"))
151 }
152
153 if _, err := parseFlags([]string{"-qZ"}, specs...); err == nil || !isUsageErr(err) {
154 t.Fatalf("unknown option inside a cluster = %v, want a usage error", err)
155 }
156}
157
158// TestConfigFlagSetsPath: the parser stores flags generically, so --config has to
159// be copied to cfgPath explicitly. Missing that silently discards an operator's
160// chosen instance and falls back to discovery.
161func TestConfigFlagSetsPath(t *testing.T) {
162 for _, args := range [][]string{
163 {"--config", "/tmp/x.toml"},
164 {"-c", "/tmp/x.toml"},
165 {"--config=/tmp/x.toml"},
166 } {
167 f, err := parseFlags(args, configFlag())
168 if err != nil {
169 t.Fatal(err)
170 }
171 if f.cfgPath != "/tmp/x.toml" {
172 t.Fatalf("%v: cfgPath = %q", args, f.cfgPath)
173 }
174 }
175 f, err := parseFlags(nil, configFlag())
176 if err != nil {
177 t.Fatal(err)
178 }
179 if f.cfgPath != "" {
180 t.Fatalf("default cfgPath = %q, want empty (resolved later)", f.cfgPath)
181 }
182}
183
184func TestHelpFlag(t *testing.T) {
185 for _, a := range []string{"-h", "--help"} {
186 f, err := parseFlags([]string{a}, issueSpecs()...)
187 if err != nil {
188 t.Fatal(err)
189 }
190 if !f.help {
191 t.Fatalf("%s did not set help", a)
192 }
193 }
194}
195
196func TestMessageAndFileAreExclusive(t *testing.T) {
197 f, err := parseFlags([]string{"-m", "text", "-F", "file"}, issueSpecs()...)
198 if err != nil {
199 t.Fatal(err)
200 }
201 if _, err := readBody(f); err == nil {
202 t.Fatal("-m with -F was accepted")
203 } else if !isUsageErr(err) {
204 t.Fatalf("not a usage error: %v", err)
205 }
206}
207
208// TestRepeatedMessagesBecomeParagraphs: git concatenates repeated -m values
209// with a blank line, and silently dropping every one but the last is data loss.
210func TestRepeatedMessagesBecomeParagraphs(t *testing.T) {
211 f, err := parseFlags([]string{"-m", "one", "--message", "two"}, issueSpecs()...)
212 if err != nil {
213 t.Fatal(err)
214 }
215 body, err := readBody(f)
216 if err != nil {
217 t.Fatal(err)
218 }
219 if body != "one\n\ntwo\n" {
220 t.Fatalf("body = %q, want %q", body, "one\n\ntwo\n")
221 }
222}
223
224func TestFormatIssue(t *testing.T) {
225 iss := issueForTest()
226 // The numbering is derived from history, so the formatting test supplies it the
227 // same way the command does.
228 nums := metadata.RankAdditions([][]string{{"issues/" + iss.ID + "/issue.md"}})
229 out := formatIssue("%(number) %(shortid) %(state) %(labels) %(title)", iss, nums)
230 want := "#1 01M5JR2J open bug,ui A title"
231 if out != want {
232 t.Fatalf("format = %q, want %q", out, want)
233 }
234 // A branch with no numbering must say so rather than print a number nobody
235 // assigned.
236 if out := formatIssue("%(number)", iss, nil); out != "#?" {
237 t.Fatalf("unnumbered format = %q, want %q", out, "#?")
238 }
239}
240
241// TestFlagsBelongToTheSubcommand: options are parsed per group, but a flag that
242// belongs to a sibling subcommand must still be refused. Silent acceptance
243// reads as success, which is worse than a usage error.
244func TestFlagsBelongToTheSubcommand(t *testing.T) {
245 f, err := parseFlags([]string{"--title", "x", "--no-push"}, issueSpecs()...)
246 if err != nil {
247 t.Fatal(err)
248 }
249 if err := f.checkAllowed("vole issue close", "remote", "no-push"); err == nil || !isUsageErr(err) {
250 t.Fatalf("close with --title = %v, want a usage error", err)
251 }
252 if err := f.checkAllowed("vole issue new", "title", "message", "file", "label", "no-push"); err != nil {
253 t.Fatalf("new with its own flags: %v", err)
254 }
255}
256
257// TestAdminGlobalsFollowGitOptionForms: the admin namespace has its own small
258// parser because it must find --remote before it knows the resource. It should
259// accept the same short forms as the main parser, and `--` before the resource
260// must not become the resource name.
261func TestAdminGlobalsFollowGitOptionForms(t *testing.T) {
262 for _, args := range [][]string{
263 {"--remote", "origin", "account", "list"},
264 {"--remote=origin", "account", "list"},
265 {"-r", "origin", "account", "list"},
266 {"-rorigin", "account", "list"},
267 {"--", "account", "list"},
268 } {
269 remote, _, _, rest, err := splitAdminGlobals(args)
270 if err != nil {
271 t.Fatalf("%v: %v", args, err)
272 }
273 if args[0] == "--" {
274 if remote != "" || len(rest) != 2 || rest[0] != "account" {
275 t.Fatalf("%v: remote = %q, rest = %v", args, remote, rest)
276 }
277 continue
278 }
279 if remote != "origin" || len(rest) != 2 || rest[0] != "account" {
280 t.Fatalf("%v: remote = %q, rest = %v", args, remote, rest)
281 }
282 }
283 if _, _, _, _, err := splitAdminGlobals([]string{"-r=origin", "account", "list"}); err == nil || !isUsageErr(err) {
284 t.Fatalf("-r=origin = %v, want a usage error", err)
285 }
286}
287