git.sr.ht/~pingoo/stdx@v0.0.0-20240218134121-094174641f6e/toml/toml_test.go (about)

     1  //go:build go1.16
     2  // +build go1.16
     3  
     4  package toml_test
     5  
     6  import (
     7  	"bytes"
     8  	"encoding/json"
     9  	"fmt"
    10  	"os"
    11  	"path/filepath"
    12  	"regexp"
    13  	"strings"
    14  	"testing"
    15  
    16  	"git.sr.ht/~pingoo/stdx/toml"
    17  	"git.sr.ht/~pingoo/stdx/toml/internal/tag"
    18  	tomltest "git.sr.ht/~pingoo/stdx/toml/internal/toml-test"
    19  )
    20  
    21  // Test if the error message matches what we want for invalid tests. Every slice
    22  // entry is tested with strings.Contains.
    23  //
    24  // Filepaths are glob'd
    25  var errorTests = map[string][]string{
    26  	"encoding/bad-utf8*":            {"invalid UTF-8 byte"},
    27  	"encoding/utf16*":               {"files cannot contain NULL bytes; probably using UTF-16"},
    28  	"string/multiline-escape-space": {`invalid escape: '\ '`},
    29  }
    30  
    31  // Test metadata; all keys listed as "keyname: type".
    32  var metaTests = map[string]string{
    33  	"implicit-and-explicit-after": `
    34  		a.b.c:         Hash
    35  		a.b.c.answer:  Integer
    36  		a:             Hash
    37  		a.better:      Integer
    38  	`,
    39  	"implicit-and-explicit-before": `
    40  		a:             Hash
    41  		a.better:      Integer
    42  		a.b.c:         Hash
    43  		a.b.c.answer:  Integer
    44  	`,
    45  	"key/case-sensitive": `
    46  		sectioN:       String
    47  		section:       Hash
    48  		section.name:  String
    49  		section.NAME:  String
    50  		section.Name:  String
    51  		Section:       Hash
    52  		Section.name:  String
    53  		Section."μ":   String
    54  		Section."Μ":   String
    55  		Section.M:     String
    56  	`,
    57  	"key/dotted": `
    58  		name.first:                   String
    59  		name.last:                    String
    60  		many.dots.here.dot.dot.dot:   Integer
    61  		count.a:                      Integer
    62  		count.b:                      Integer
    63  		count.c:                      Integer
    64  		count.d:                      Integer
    65  		count.e:                      Integer
    66  		count.f:                      Integer
    67  		count.g:                      Integer
    68  		count.h:                      Integer
    69  		count.i:                      Integer
    70  		count.j:                      Integer
    71  		count.k:                      Integer
    72  		count.l:                      Integer
    73  		tbl:                          Hash
    74  		tbl.a.b.c:                    Float
    75  		a.few.dots:                   Hash
    76  		a.few.dots.polka.dot:         String
    77  		a.few.dots.polka.dance-with:  String
    78  		arr:                          ArrayHash
    79  		arr.a.b.c:                    Integer
    80  		arr.a.b.d:                    Integer
    81  		arr:                          ArrayHash
    82  		arr.a.b.c:                    Integer
    83  		arr.a.b.d:                    Integer
    84  	 `,
    85  	"key/empty": `
    86  		"": String
    87  	`,
    88  	"key/quoted-dots": `
    89  		plain:                          Integer
    90  		"with.dot":                     Integer
    91  		plain_table:                    Hash
    92  		plain_table.plain:              Integer
    93  		plain_table."with.dot":         Integer
    94  		table.withdot:                  Hash
    95  		table.withdot.plain:            Integer
    96  		table.withdot."key.with.dots":  Integer
    97  	`,
    98  	"key/space": `
    99  		"a b": Integer
   100  		" c d ": Integer
   101  		" tbl ": Hash
   102  		" tbl "."\ttab\ttab\t": String
   103  	`,
   104  	"key/special-chars": "\n" +
   105  		"\"=~!@$^&*()_+-`1234567890[]|/?><.,;:'=\": Integer\n",
   106  
   107  	// TODO: "(albums): Hash" is missing; the problem is that this is an
   108  	// "implied key", which is recorded in the parser in implicits, rather than
   109  	// in keys. This is to allow "redefining" tables, for example:
   110  	//
   111  	//    [a.b.c]
   112  	//    answer = 42
   113  	//    [a]
   114  	//    better = 43
   115  	//
   116  	// However, we need to actually pass on this information to the MetaData so
   117  	// we can use it.
   118  	//
   119  	// Keys are supposed to be in order, for the above right now that's:
   120  	//
   121  	//     (a).(b).(c):           Hash
   122  	//     (a).(b).(c).(answer):  Integer
   123  	//     (a):                   Hash
   124  	//     (a).(better):          Integer
   125  	//
   126  	// So if we want to add "(a).(b): Hash", where should this be in the order?
   127  	"table/array-implicit": `
   128  		albums.songs:       ArrayHash
   129  		albums.songs.name:  String
   130  	`,
   131  
   132  	// TODO: people and people.* listed many times; not entirely sure if that's
   133  	// what we want?
   134  	//
   135  	// It certainly causes problems, because keys is a slice, and types a map.
   136  	// So if array entry 1 differs in type from array entry 2 then that won't be
   137  	// recorded right. This related to the problem in the above comment.
   138  	//
   139  	// people:                ArrayHash
   140  	//
   141  	// people[0]:             Hash
   142  	// people[0].first_name:  String
   143  	// people[0].last_name:   String
   144  	//
   145  	// people[1]:             Hash
   146  	// people[1].first_name:  String
   147  	// people[1].last_name:   String
   148  	//
   149  	// people[2]:             Hash
   150  	// people[2].first_name:  String
   151  	// people[2].last_name:   String
   152  	"table/array-many": `
   153  		people:             ArrayHash
   154  		people.first_name:  String
   155  		people.last_name:   String
   156  		people:             ArrayHash
   157  		people.first_name:  String
   158  		people.last_name:   String
   159  		people:             ArrayHash
   160  		people.first_name:  String
   161  		people.last_name:   String
   162  	`,
   163  	"table/array-nest": `
   164  		albums:             ArrayHash
   165  		albums.name:        String
   166  		albums.songs:       ArrayHash
   167  		albums.songs.name:  String
   168  		albums.songs:       ArrayHash
   169  		albums.songs.name:  String
   170  		albums:             ArrayHash
   171  		albums.name:        String
   172  		albums.songs:       ArrayHash
   173  		albums.songs.name:  String
   174  		albums.songs:       ArrayHash
   175  		albums.songs.name:  String
   176  	`,
   177  	"table/array-one": `
   178  		people:             ArrayHash
   179  		people.first_name:  String
   180  		people.last_name:   String
   181  	`,
   182  	"table/array-table-array": `
   183  		a:        ArrayHash
   184  		a.b:      ArrayHash
   185  		a.b.c:    Hash
   186  		a.b.c.d:  String
   187  		a.b:      ArrayHash
   188  		a.b.c:    Hash
   189  		a.b.c.d:  String
   190  	`,
   191  	"table/empty": `
   192  		a: Hash
   193  	`,
   194  	"table/keyword": `
   195  		true:   Hash
   196  		false:  Hash
   197  		inf:    Hash
   198  		nan:    Hash
   199  	`,
   200  	"table/names": `
   201  		a.b.c:    Hash
   202  		a."b.c":  Hash
   203  		a."d.e":  Hash
   204  		a." x ":  Hash
   205  		d.e.f:    Hash
   206  		g.h.i:    Hash
   207  		j."ʞ".l:  Hash
   208  		x.1.2:    Hash
   209  	`,
   210  	"table/no-eol": `
   211  		table: Hash
   212  	`,
   213  	"table/sub-empty": `
   214  		a:    Hash
   215  		a.b:  Hash
   216  	`,
   217  	"table/whitespace": `
   218  		"valid key": Hash
   219  	`,
   220  	"table/with-literal-string": `
   221  		a:                   Hash
   222  		a."\"b\"":           Hash
   223  		a."\"b\"".c:         Hash
   224  		a."\"b\"".c.answer:  Integer
   225  	`,
   226  	"table/with-pound": `
   227  		"key#group":         Hash
   228  		"key#group".answer:  Integer
   229  	`,
   230  	"table/with-single-quotes": `
   231  		a:             Hash
   232  		a.b:           Hash
   233  		a.b.c:         Hash
   234  		a.b.c.answer:  Integer
   235  	`,
   236  	"table/without-super": `
   237  		x.y.z.w:  Hash
   238  		x:        Hash
   239  	`,
   240  }
   241  
   242  func TestToml(t *testing.T) {
   243  	for k := range errorTests { // Make sure patterns are valid.
   244  		_, err := filepath.Match(k, "")
   245  		if err != nil {
   246  			t.Fatal(err)
   247  		}
   248  	}
   249  
   250  	// TODO: bit of a hack to make sure not all test run; without this "-run=.."
   251  	// will still run alll tests, but just report the errors for the -run value.
   252  	// This is annoying in cases where you have some debug printf.
   253  	//
   254  	// Need to update toml-test a bit to make this easier, but this good enough
   255  	// for now.
   256  	var runTests []string
   257  	for _, a := range os.Args {
   258  		if strings.HasPrefix(a, "-test.run=TestToml/") {
   259  			a = strings.TrimPrefix(a, "-test.run=TestToml/encode/")
   260  			a = strings.TrimPrefix(a, "-test.run=TestToml/decode/")
   261  			runTests = []string{a, a + "/*"}
   262  			break
   263  		}
   264  	}
   265  
   266  	// Make sure the keys in metaTests and errorTests actually exist; easy to
   267  	// make a typo and nothing will get tested.
   268  	var (
   269  		shouldExistValid   = make(map[string]struct{})
   270  		shouldExistInvalid = make(map[string]struct{})
   271  	)
   272  	if len(runTests) == 0 {
   273  		for k := range metaTests {
   274  			shouldExistValid["valid/"+k] = struct{}{}
   275  		}
   276  		for k := range errorTests {
   277  			shouldExistInvalid["invalid/"+k] = struct{}{}
   278  		}
   279  	}
   280  
   281  	run := func(t *testing.T, enc bool) {
   282  		r := tomltest.Runner{
   283  			Files:    tomltest.EmbeddedTests(),
   284  			Encoder:  enc,
   285  			Parser:   parser{},
   286  			RunTests: runTests,
   287  			SkipTests: []string{
   288  				// "15" in time.Parse() accepts both "1" and "01". The TOML
   289  				// specification says that times *must* start with a leading
   290  				// zero, but this requires writing out own datetime parser.
   291  				// I think it's actually okay to just accept both really.
   292  				// https://git.sr.ht/~pingoo/stdx/toml/issues/320
   293  				"invalid/datetime/time-no-leads",
   294  
   295  				// This test is fine, just doesn't deal well with empty output.
   296  				"valid/comment/noeol",
   297  
   298  				// TODO: fix this.
   299  				"invalid/table/append-with-dotted*",
   300  				"invalid/inline-table/add",
   301  				"invalid/table/duplicate-key-dotted-table",
   302  				"invalid/table/duplicate-key-dotted-table2",
   303  			},
   304  		}
   305  
   306  		tests, err := r.Run()
   307  		if err != nil {
   308  			t.Fatal(err)
   309  		}
   310  
   311  		for _, test := range tests.Tests {
   312  			t.Run(test.Path, func(t *testing.T) {
   313  				if test.Failed() {
   314  					t.Fatalf("\nError:\n%s\n\nInput:\n%s\nOutput:\n%s\nWant:\n%s\n",
   315  						test.Failure, test.Input, test.Output, test.Want)
   316  					return
   317  				}
   318  
   319  				// Test error message.
   320  				if test.Type() == tomltest.TypeInvalid {
   321  					testError(t, test, shouldExistInvalid)
   322  				}
   323  				// Test metadata
   324  				if !enc && test.Type() == tomltest.TypeValid {
   325  					delete(shouldExistValid, test.Path)
   326  					testMeta(t, test)
   327  				}
   328  			})
   329  		}
   330  		t.Logf("passed: %d; failed: %d; skipped: %d", tests.Passed, tests.Failed, tests.Skipped)
   331  	}
   332  
   333  	t.Run("decode", func(t *testing.T) { run(t, false) })
   334  	t.Run("encode", func(t *testing.T) { run(t, true) })
   335  
   336  	if len(shouldExistValid) > 0 {
   337  		var s []string
   338  		for k := range shouldExistValid {
   339  			s = append(s, k)
   340  		}
   341  		t.Errorf("the following meta tests didn't match any files: %s", strings.Join(s, ", "))
   342  	}
   343  	if len(shouldExistInvalid) > 0 {
   344  		var s []string
   345  		for k := range shouldExistInvalid {
   346  			s = append(s, k)
   347  		}
   348  		t.Errorf("the following meta tests didn't match any files: %s", strings.Join(s, ", "))
   349  	}
   350  }
   351  
   352  var reCollapseSpace = regexp.MustCompile(` +`)
   353  
   354  func testMeta(t *testing.T, test tomltest.Test) {
   355  	want, ok := metaTests[strings.TrimPrefix(test.Path, "valid/")]
   356  	if !ok {
   357  		return
   358  	}
   359  	var s interface{}
   360  	meta, err := toml.Decode(test.Input, &s)
   361  	if err != nil {
   362  		t.Fatal(err)
   363  	}
   364  
   365  	b := new(strings.Builder)
   366  	for i, k := range meta.Keys() {
   367  		if i > 0 {
   368  			b.WriteByte('\n')
   369  		}
   370  		fmt.Fprintf(b, "%s: %s", k, meta.Type(k...))
   371  	}
   372  	have := b.String()
   373  
   374  	want = reCollapseSpace.ReplaceAllString(strings.ReplaceAll(strings.TrimSpace(want), "\t", ""), " ")
   375  	if have != want {
   376  		t.Errorf("MetaData wrong\nhave:\n%s\nwant:\n%s", have, want)
   377  	}
   378  }
   379  
   380  func testError(t *testing.T, test tomltest.Test, shouldExist map[string]struct{}) {
   381  	path := strings.TrimPrefix(test.Path, "invalid/")
   382  
   383  	errs, ok := errorTests[path]
   384  	if ok {
   385  		delete(shouldExist, "invalid/"+path)
   386  	}
   387  	if !ok {
   388  		for k := range errorTests {
   389  			ok, _ = filepath.Match(k, path)
   390  			if ok {
   391  				delete(shouldExist, "invalid/"+k)
   392  				errs = errorTests[k]
   393  				break
   394  			}
   395  		}
   396  	}
   397  	if !ok {
   398  		return
   399  	}
   400  
   401  	for _, e := range errs {
   402  		if !strings.Contains(test.Output, e) {
   403  			t.Errorf("\nwrong error message\nhave: %s\nwant: %s", test.Output, e)
   404  		}
   405  	}
   406  }
   407  
   408  type parser struct{}
   409  
   410  func (p parser) Encode(input string) (output string, outputIsError bool, retErr error) {
   411  	defer func() {
   412  		if r := recover(); r != nil {
   413  			switch rr := r.(type) {
   414  			case error:
   415  				retErr = rr
   416  			default:
   417  				retErr = fmt.Errorf("%s", rr)
   418  			}
   419  		}
   420  	}()
   421  
   422  	var tmp interface{}
   423  	err := json.Unmarshal([]byte(input), &tmp)
   424  	if err != nil {
   425  		return "", false, err
   426  	}
   427  
   428  	rm, err := tag.Remove(tmp)
   429  	if err != nil {
   430  		return err.Error(), true, retErr
   431  	}
   432  
   433  	buf := new(bytes.Buffer)
   434  	err = toml.NewEncoder(buf).Encode(rm)
   435  	if err != nil {
   436  		return err.Error(), true, retErr
   437  	}
   438  
   439  	return buf.String(), false, retErr
   440  }
   441  
   442  func (p parser) Decode(input string) (output string, outputIsError bool, retErr error) {
   443  	defer func() {
   444  		if r := recover(); r != nil {
   445  			switch rr := r.(type) {
   446  			case error:
   447  				retErr = rr
   448  			default:
   449  				retErr = fmt.Errorf("%s", rr)
   450  			}
   451  		}
   452  	}()
   453  
   454  	var d interface{}
   455  	if _, err := toml.Decode(input, &d); err != nil {
   456  		return err.Error(), true, retErr
   457  	}
   458  
   459  	j, err := json.MarshalIndent(tag.Add("", d), "", "  ")
   460  	if err != nil {
   461  		return "", false, err
   462  	}
   463  	return string(j), false, retErr
   464  }