1 package template
2
3 import (
4 "bytes"
5 "fmt"
6 "io"
7 "log"
8 "os"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/MakeNowJust/heredoc"
14 "github.com/cli/go-gh/v2/pkg/text"
15 "github.com/stretchr/testify/assert"
16 )
17
18 func ExampleTemplate() {
19
20 colorEnabled := true
21 termWidth := 14
22 json := strings.NewReader(heredoc.Doc(`[
23 {"number": 1, "title": "One"},
24 {"number": 2, "title": "Two"}
25 ]`))
26 template := "HEADER\n\n{{range .}}{{tablerow .number .title}}{{end}}{{tablerender}}\nFOOTER"
27 tmpl := New(os.Stdout, termWidth, colorEnabled)
28 if err := tmpl.Parse(template); err != nil {
29 log.Fatal(err)
30 }
31 if err := tmpl.Execute(json); err != nil {
32 log.Fatal(err)
33 }
34
35
36
37
38
39
40
41 }
42
43 func ExampleTemplate_Funcs() {
44
45 colorEnabled := true
46 termWidth := 14
47 json := strings.NewReader(heredoc.Doc(`[
48 {"num": 1, "thing": "apple"},
49 {"num": 2, "thing": "orange"}
50 ]`))
51 template := "{{range .}}* {{pluralize .num .thing}}\n{{end}}"
52 tmpl := New(os.Stdout, termWidth, colorEnabled)
53 tmpl.Funcs(map[string]interface{}{
54 "pluralize": func(fields ...interface{}) (string, error) {
55 if l := len(fields); l != 2 {
56 return "", fmt.Errorf("wrong number of args for pluralize: want 2 got %d", l)
57 }
58 var ok bool
59 var num float64
60 var thing string
61 if num, ok = fields[0].(float64); !ok && num == float64(int(num)) {
62 return "", fmt.Errorf("invalid value; expected int")
63 }
64 if thing, ok = fields[1].(string); !ok {
65 return "", fmt.Errorf("invalid value; expected string")
66 }
67 return text.Pluralize(int(num), thing), nil
68 },
69 })
70 if err := tmpl.Parse(template); err != nil {
71 log.Fatal(err)
72 }
73 if err := tmpl.Execute(json); err != nil {
74 log.Fatal(err)
75 }
76
77
78
79 }
80
81 func TestJsonScalarToString(t *testing.T) {
82 tests := []struct {
83 name string
84 input interface{}
85 want string
86 wantErr bool
87 }{
88 {
89 name: "string",
90 input: "hello",
91 want: "hello",
92 },
93 {
94 name: "int",
95 input: float64(1234),
96 want: "1234",
97 },
98 {
99 name: "float",
100 input: float64(12.34),
101 want: "12.34",
102 },
103 {
104 name: "null",
105 input: nil,
106 want: "",
107 },
108 {
109 name: "true",
110 input: true,
111 want: "true",
112 },
113 {
114 name: "false",
115 input: false,
116 want: "false",
117 },
118 {
119 name: "object",
120 input: map[string]interface{}{},
121 wantErr: true,
122 },
123 }
124 for _, tt := range tests {
125 t.Run(tt.name, func(t *testing.T) {
126 got, err := jsonScalarToString(tt.input)
127 if tt.wantErr {
128 assert.Error(t, err)
129 return
130 }
131 assert.NoError(t, err)
132 assert.Equal(t, tt.want, got)
133 })
134 }
135 }
136
137 func TestExecute(t *testing.T) {
138 type args struct {
139 json io.Reader
140 template string
141 colorize bool
142 }
143 tests := []struct {
144 name string
145 args args
146 wantW string
147 wantErr bool
148 }{
149 {
150 name: "color",
151 args: args{
152 json: strings.NewReader(`{}`),
153 template: `{{color "blue+h" "songs are like tattoos"}}`,
154 },
155 wantW: "\x1b[0;94msongs are like tattoos\x1b[0m",
156 },
157 {
158 name: "autocolor enabled",
159 args: args{
160 json: strings.NewReader(`{}`),
161 template: `{{autocolor "red" "stop"}}`,
162 colorize: true,
163 },
164 wantW: "\x1b[0;31mstop\x1b[0m",
165 },
166 {
167 name: "autocolor disabled",
168 args: args{
169 json: strings.NewReader(`{}`),
170 template: `{{autocolor "red" "go"}}`,
171 },
172 wantW: "go",
173 },
174 {
175 name: "timefmt",
176 args: args{
177 json: strings.NewReader(`{"created_at":"2008-02-25T20:18:33Z"}`),
178 template: `{{.created_at | timefmt "Mon Jan 2, 2006"}}`,
179 },
180 wantW: "Mon Feb 25, 2008",
181 },
182 {
183 name: "timeago",
184 args: args{
185 json: strings.NewReader(fmt.Sprintf(`{"created_at":"%s"}`, time.Now().Add(-5*time.Minute).Format(time.RFC3339))),
186 template: `{{.created_at | timeago}}`,
187 },
188 wantW: "5 minutes ago",
189 },
190 {
191 name: "pluck",
192 args: args{
193 json: strings.NewReader(heredoc.Doc(`[
194 {"name": "bug"},
195 {"name": "feature request"},
196 {"name": "chore"}
197 ]`)),
198 template: `{{range(pluck "name" .)}}{{. | printf "%s\n"}}{{end}}`,
199 },
200 wantW: "bug\nfeature request\nchore\n",
201 },
202 {
203 name: "join",
204 args: args{
205 json: strings.NewReader(`[ "bug", "feature request", "chore" ]`),
206 template: `{{join "\t" .}}`,
207 },
208 wantW: "bug\tfeature request\tchore",
209 },
210 {
211 name: "table",
212 args: args{
213 json: strings.NewReader(heredoc.Doc(`[
214 {"number": 1, "title": "One"},
215 {"number": 20, "title": "Twenty"},
216 {"number": 3000, "title": "Three thousand"}
217 ]`)),
218 template: `{{range .}}{{tablerow (.number | printf "#%v") .title}}{{end}}`,
219 },
220 wantW: heredoc.Doc(`#1 One
221 #20 Twenty
222 #3000 Three thousand
223 `),
224 },
225 {
226 name: "table with multiline text",
227 args: args{
228 json: strings.NewReader(heredoc.Doc(`[
229 {"number": 1, "title": "One\ranother line of text"},
230 {"number": 20, "title": "Twenty\nanother line of text"},
231 {"number": 3000, "title": "Three thousand\r\nanother line of text"}
232 ]`)),
233 template: `{{range .}}{{tablerow (.number | printf "#%v") .title}}{{end}}`,
234 },
235 wantW: heredoc.Doc(`#1 One...
236 #20 Twenty...
237 #3000 Three thousand...
238 `),
239 },
240 {
241 name: "table with mixed value types",
242 args: args{
243 json: strings.NewReader(heredoc.Doc(`[
244 {"number": 1, "title": null, "float": false},
245 {"number": 20.1, "title": "Twenty-ish", "float": true},
246 {"number": 3000, "title": "Three thousand", "float": false}
247 ]`)),
248 template: `{{range .}}{{tablerow .number .title .float}}{{end}}`,
249 },
250 wantW: heredoc.Doc(`1 false
251 20.10 Twenty-ish true
252 3000 Three thousand false
253 `),
254 },
255 {
256 name: "table with color",
257 args: args{
258 json: strings.NewReader(heredoc.Doc(`[
259 {"number": 1, "title": "One"}
260 ]`)),
261 template: `{{range .}}{{tablerow (.number | color "green") .title}}{{end}}`,
262 },
263 wantW: "\x1b[0;32m1\x1b[0m One\n",
264 },
265 {
266 name: "table with header and footer",
267 args: args{
268 json: strings.NewReader(heredoc.Doc(`[
269 {"number": 1, "title": "One"},
270 {"number": 2, "title": "Two"}
271 ]`)),
272 template: heredoc.Doc(`HEADER
273 {{range .}}{{tablerow .number .title}}{{end}}FOOTER
274 `),
275 },
276 wantW: heredoc.Doc(`HEADER
277 FOOTER
278 1 One
279 2 Two
280 `),
281 },
282 {
283 name: "table with header and footer using endtable",
284 args: args{
285 json: strings.NewReader(heredoc.Doc(`[
286 {"number": 1, "title": "One"},
287 {"number": 2, "title": "Two"}
288 ]`)),
289 template: heredoc.Doc(`HEADER
290 {{range .}}{{tablerow .number .title}}{{end}}{{tablerender}}FOOTER
291 `),
292 },
293 wantW: heredoc.Doc(`HEADER
294 1 One
295 2 Two
296 FOOTER
297 `),
298 },
299 {
300 name: "multiple tables with different columns",
301 args: args{
302 json: strings.NewReader(heredoc.Doc(`{
303 "issues": [
304 {"number": 1, "title": "One"},
305 {"number": 2, "title": "Two"}
306 ],
307 "prs": [
308 {"number": 3, "title": "Three", "reviewDecision": "REVIEW_REQUESTED"},
309 {"number": 4, "title": "Four", "reviewDecision": "CHANGES_REQUESTED"}
310 ]
311 }`)),
312 template: heredoc.Doc(`{{tablerow "ISSUE" "TITLE"}}{{range .issues}}{{tablerow .number .title}}{{end}}{{tablerender}}
313 {{tablerow "PR" "TITLE" "DECISION"}}{{range .prs}}{{tablerow .number .title .reviewDecision}}{{end}}`),
314 },
315 wantW: heredoc.Docf(`ISSUE TITLE
316 1 One
317 2 Two
318
319 PR TITLE DECISION
320 3 Three REVIEW_REQUESTED
321 4 Four CHANGES_REQUESTED
322 `),
323 },
324 {
325 name: "truncate",
326 args: args{
327 json: strings.NewReader(`{"title": "This is a long title"}`),
328 template: `{{truncate 13 .title}}`,
329 },
330 wantW: "This is a ...",
331 },
332 {
333 name: "truncate with JSON null",
334 args: args{
335 json: strings.NewReader(`{}`),
336 template: `{{ truncate 13 .title }}`,
337 },
338 wantW: "",
339 },
340 {
341 name: "truncate with piped JSON null",
342 args: args{
343 json: strings.NewReader(`{}`),
344 template: `{{ .title | truncate 13 }}`,
345 },
346 wantW: "",
347 },
348 {
349 name: "truncate with piped JSON null in parenthetical",
350 args: args{
351 json: strings.NewReader(`{}`),
352 template: `{{ (.title | truncate 13) }}`,
353 },
354 wantW: "",
355 },
356 {
357 name: "truncate invalid type",
358 args: args{
359 json: strings.NewReader(`{"title": 42}`),
360 template: `{{ (.title | truncate 13) }}`,
361 },
362 wantErr: true,
363 },
364 {
365 name: "hyperlink enabled",
366 args: args{
367 json: strings.NewReader(`{"link":"https://github.com"}`),
368 template: `{{ hyperlink .link "" }}`,
369 },
370 wantW: "\x1b]8;;https://github.com\x1b\\https://github.com\x1b]8;;\x1b\\",
371 },
372 {
373 name: "hyperlink with text enabled",
374 args: args{
375 json: strings.NewReader(`{"link":"https://github.com","text":"GitHub"}`),
376 template: `{{ hyperlink .link .text }}`,
377 },
378 wantW: "\x1b]8;;https://github.com\x1b\\GitHub\x1b]8;;\x1b\\",
379 },
380 }
381 for _, tt := range tests {
382 t.Run(tt.name, func(t *testing.T) {
383 w := &bytes.Buffer{}
384 tmpl := New(w, 80, tt.args.colorize)
385 err := tmpl.Parse(tt.args.template)
386 assert.NoError(t, err)
387 err = tmpl.Execute(tt.args.json)
388 if tt.wantErr {
389 assert.Error(t, err)
390 return
391 }
392 assert.NoError(t, err)
393 err = tmpl.Flush()
394 assert.NoError(t, err)
395 assert.Equal(t, tt.wantW, w.String())
396 })
397 }
398 }
399
400 func TestTruncateMultiline(t *testing.T) {
401 type args struct {
402 max int
403 s string
404 }
405 tests := []struct {
406 name string
407 args args
408 want string
409 }{
410 {
411 name: "exactly minimum width",
412 args: args{
413 max: 5,
414 s: "short",
415 },
416 want: "short",
417 },
418 {
419 name: "exactly minimum width with new line",
420 args: args{
421 max: 5,
422 s: "short\n",
423 },
424 want: "sh...",
425 },
426 {
427 name: "less than minimum width",
428 args: args{
429 max: 4,
430 s: "short",
431 },
432 want: "shor",
433 },
434 {
435 name: "less than minimum width with new line",
436 args: args{
437 max: 4,
438 s: "short\n",
439 },
440 want: "shor",
441 },
442 {
443 name: "first line of multiple is short enough",
444 args: args{
445 max: 80,
446 s: "short\n\nthis is a new line",
447 },
448 want: "short...",
449 },
450 {
451 name: "using Windows line endings",
452 args: args{
453 max: 80,
454 s: "short\r\n\r\nthis is a new line",
455 },
456 want: "short...",
457 },
458 {
459 name: "using older MacOS line endings",
460 args: args{
461 max: 80,
462 s: "short\r\rthis is a new line",
463 },
464 want: "short...",
465 },
466 }
467 for _, tt := range tests {
468 t.Run(tt.name, func(t *testing.T) {
469 got := truncateMultiline(tt.args.max, tt.args.s)
470 assert.Equal(t, tt.want, got)
471 })
472 }
473 }
474
475 func TestFuncs(t *testing.T) {
476 w := &bytes.Buffer{}
477 tmpl := New(w, 80, false)
478
479
480 tmpl.Funcs(map[string]interface{}{
481 "truncate": func(fields ...interface{}) (string, error) {
482 if l := len(fields); l != 2 {
483 return "", fmt.Errorf("wrong number of args for truncate: want 2 got %d", l)
484 }
485 var ok bool
486 var width int
487 var input string
488 if width, ok = fields[0].(int); !ok {
489 return "", fmt.Errorf("invalid value; expected int")
490 }
491 if input, ok = fields[1].(string); !ok {
492 return "", fmt.Errorf("invalid value; expected string")
493 }
494 return input[:width], nil
495 },
496 "foo": func(fields ...interface{}) (string, error) {
497 return "test", nil
498 },
499 })
500
501 err := tmpl.Parse(`{{ .text | truncate 5 }} {{ .status | color "green" }} {{ foo }}`)
502 assert.NoError(t, err)
503
504 r := strings.NewReader(`{"text":"truncated","status":"open"}`)
505 err = tmpl.Execute(r)
506 assert.NoError(t, err)
507
508 err = tmpl.Flush()
509 assert.NoError(t, err)
510 assert.Equal(t, "trunc \x1b[0;32mopen\x1b[0m test", w.String())
511 }
512
View as plain text