...

Source file src/github.com/cli/go-gh/v2/pkg/template/template_test.go

Documentation: github.com/cli/go-gh/v2/pkg/template

     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  	// Information about the terminal can be obtained using the [pkg/term] package.
    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  	// Output:
    35  	// HEADER
    36  	//
    37  	// 1  One
    38  	// 2  Two
    39  	//
    40  	// FOOTER
    41  }
    42  
    43  func ExampleTemplate_Funcs() {
    44  	// Information about the terminal can be obtained using the [pkg/term] package.
    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  	// Output:
    77  	// * 1 apple
    78  	// * 2 oranges
    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  	// Override "truncate" and define a new "foo" function.
   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