From 4b31482a5378cdb367e1bdcad61ff3720ce695c4 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 22:45:13 +0200 Subject: [PATCH] Tests: a reference path is one draw per render in both views, a repeat iteration draws anew, a group draws apart, and a bare reference beside a path is refused --- hold_test.go | 9 ++++- one_spelling_test.go | 38 ++++++++++++++---- record_test.go | 94 +++++++++++++++++++++++++++++--------------- reference_test.go | 62 ++++++++++++++++++++++++++++- template_test.go | 4 +- 5 files changed, 162 insertions(+), 45 deletions(-) diff --git a/hold_test.go b/hold_test.go index ac1ac56..99b73f3 100644 --- a/hold_test.go +++ b/hold_test.go @@ -404,6 +404,13 @@ func TestPathIntoARepeatingLevelIsRejected(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "repeat") { t.Fatalf("New = %v, want a path into a repeating level rejected", err) } + _, err = New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ + "cat": `{"format":"[{p.a}]","p":{"format":"{a}","a":"{/word.w}","group":"g"}}`, + "word": `{"format":"{w}","w":["x","y"]}`, + }))) + if err == nil || !strings.Contains(err.Error(), `the level "p" carries a group`) { + t.Fatalf("New = %v, want a path into a level carrying a group rejected", err) + } } func TestPathIntoAPlainTemplateNamesTheMissingField(t *testing.T) { @@ -718,6 +725,6 @@ func TestReadFieldPanicsOnAnUnheldPath(t *testing.T) { t.Fatal("not a template") } mustPanic(t, "unheld arm with a path", func() { - readField(engine(1).rand, tm, nil, nil, arm{name: "w.x", key: "w", tail: []string{"x"}, path: "w.x"}) + readField(engine(1).rand, tm, nil, drawScope{}, arm{name: "w.x", key: "w", tail: []string{"x"}, path: "w.x"}) }) } diff --git a/one_spelling_test.go b/one_spelling_test.go index 8253b1c..6074934 100644 --- a/one_spelling_test.go +++ b/one_spelling_test.go @@ -31,25 +31,47 @@ func TestRepeatedChoiceItemIsRejected(t *testing.T) { func TestInertObjectIsRejected(t *testing.T) { for src, want := range map[string]string{ - `{"format":"Malmö"}`: `write "Malmö"`, - `{"format":"{digits(3)}"}`: `write "{digits(3)}"`, - `[{"format":"a","weight":1},"b"]`: "weight 1", - `{"format":"{x}","x":"v","repeat":1}`: "repeat 1", - `{"format":"{x}","x":"v","separator":","}`: "separator", - `{"format":"{x}","x":"v","repeat":2,"separator":""}`: "default", - `{"format":"","n":{"format":"1","datatype":"string"}}`: `datatype "string" is the default`, + `{"format":"Malmö"}`: `write "Malmö"`, + `{"format":"{digits(3)}"}`: `write "{digits(3)}"`, + `[{"format":"a","weight":1},"b"]`: "weight 1", + `{"format":"{x}","x":"v","repeat":1}`: "repeat 1", + `{"format":"{x}","x":"v","separator":","}`: "separator", + `{"format":"{x}","x":"v","repeat":2,"separator":""}`: "default", + `{"format":"","n":{"format":"1","datatype":"string"}}`: `datatype "string" is the default`, + `{"format":"{x}","x":{"format":"{y}","y":"v","group":""}}`: `group "" is the default`, + `{"format":"{x}","x":{"format":"{y}","y":"v","group":1}}`: "group must be a string", } { if _, err := compile(parse(t, src)); err == nil || !strings.Contains(err.Error(), want) { t.Errorf("compile(%s) = %v, want an error mentioning %s", src, err, want) } } - for _, ok := range []string{`[{"format":"a","weight":2},"b"]`, `{"format":"ab","repeat":2}`, `{"format":"{x}","x":"v"}`, `"{digits(3)}"`} { + for _, ok := range []string{`[{"format":"a","weight":2},"b"]`, `{"format":"ab","repeat":2}`, `{"format":"{x}","x":"v"}`, `"{digits(3)}"`, `{"format":"{/cat.x}","group":"g"}`} { if _, err := compile(parse(t, ok)); err != nil { t.Errorf("compile(%s) = %v", ok, err) } } } +func TestAGroupThatSplitsNothingIsRejected(t *testing.T) { + files := map[string]string{"mail": `"{/word.w}@example.com"`, "word": `{"format":"{w}","w":["a","b"]}`} + for src, want := range map[string]string{ + `{"format":"{x}","x":{"format":"{y}","y":["a","b"],"group":"g"}}`: `group "g" splits nothing`, + `{"format":"{x}","x":{"format":"{/word}","group":"g"}}`: `group "g" splits nothing`, + `{"format":"{/word.w}","group":"g"}`: "", + `{"format":"{x}","x":{"format":"{/mail}","group":"g"}}`: "", + `{"format":"{x}","x":{"format":"{y}","y":"{/word.w}","repeat":2,"group":"g"}}`: "", + } { + files["cat"] = src + _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))) + switch { + case want == "" && err != nil: + t.Errorf("New(%s) = %v, want a group over a reference path accepted, however deep", src, err) + case want != "" && (err == nil || !strings.Contains(err.Error(), want)): + t.Errorf("New(%s) = %v, want an error mentioning %s", src, err, want) + } + } +} + func TestInlineFolderSigilsAreRejected(t *testing.T) { f := shipped(t) for _, input := range []string{"{.sv_SE.person.last}", "{..sv_SE.person.last}"} { diff --git a/record_test.go b/record_test.go index 9094172..384d41a 100644 --- a/record_test.go +++ b/record_test.go @@ -200,13 +200,22 @@ func TestRecordWritesTypedAndNullColumns(t *testing.T) { } func TestRecordRejectsOverlappingReferenceColumns(t *testing.T) { - cat := `{"format":"","a":[{"format":"A={b}","b":"1"},{"format":"A={b}","b":"2"}]}` + cat := `{"format":"{a}","a":[{"format":"A={b}","b":"1"},{"format":"A={b}","b":"2"}]}` + for _, c := range []struct{ name, row string }{ + {"through a column repeat, which draws anew", `{"format":"","whole":"{/cat.a}","inner":{"format":"{/cat.a.b}","repeat":2,"separator":"-"}}`}, + {"in a group of its own", `{"format":"","whole":{"format":"{/cat.a}","group":"g"},"inner":"{/cat.a.b}"}`}, + } { + f := newGenerator(t, writeData(t, map[string]string{"cat": cat, "row": c.row}), WithSeed(1)) + if _, err := f.FakeRecord("row"); err != nil { + t.Errorf("%s: FakeRecord = %v, want it accepted", c.name, err) + } + } for _, c := range []struct{ name, row string }{ {"sibling columns", `{"format":"","whole":"{/cat.a}","inner":"{/cat.a.b}"}`}, {"through a nested template", `{"format":"","whole":"{/cat.a}","inner":{"format":"{/cat.a.b} {x}","x":"1"}}`}, - {"through a column repeat", `{"format":"","whole":"{/cat.a}","inner":{"format":"{/cat.a.b}","repeat":2,"separator":"-"}}`}, {"through a choice variant", `{"format":"","whole":"{/cat.a}","inner":[{"format":"{/cat.a.b} {x}","x":"1"},{"format":"{/cat.a.b}! {x}","x":"2"}]}`}, {"as a builtin operand", `{"format":"","whole":"{uppercase(/cat.a)}","inner":"{/cat.a.b}"}`}, + {"a bare reference beside a path", `{"format":"","whole":"{/cat}","inner":"{/cat.a.b}"}`}, } { f := newGenerator(t, writeData(t, map[string]string{"cat": cat, "row": c.row}), WithSeed(1)) _, err := f.FakeRecord("row") @@ -311,9 +320,10 @@ func TestRecordRejectsFieldDescent(t *testing.T) { func TestRecordSharesAReferenceAcrossColumns(t *testing.T) { dir := writeData(t, map[string]string{ "currency": `[{"format":"{code}","code":"AUD","symbol":"$"},{"format":"{code}","code":"EUR","symbol":"€"}]`, - "price": `{"format":"","code":"{/currency.code}","symbol":"{/currency.symbol}"}`, + "price": `{"format":"{code} {symbol}","code":"{/currency.code}","symbol":"{/currency.symbol}"}`, }) f := newGenerator(t, dir, WithSeed(1)) + symbols := map[string]string{"AUD": "$", "EUR": "€"} for i := 0; i < 100; i++ { r, err := f.FakeRecord("price") if err != nil { @@ -323,48 +333,63 @@ func TestRecordSharesAReferenceAcrossColumns(t *testing.T) { for _, c := range r.Columns() { m[c.Name] = c.Value } - switch m["code"] { - case "AUD": - if m["symbol"] != "$" { - t.Fatalf("record %q: code AUD but symbol %q, want one currency draw across columns", r.JSON(), m["symbol"]) - } - case "EUR": - if m["symbol"] != "€" { - t.Fatalf("record %q: code EUR but symbol %q, want one currency draw across columns", r.JSON(), m["symbol"]) - } - default: - t.Fatalf("record %q has unexpected code %q", r.JSON(), m["code"]) + if symbol, known := symbols[m["code"]]; !known || m["symbol"] != symbol { + t.Fatalf("record %s, want one currency draw across columns", r.JSON()) + } + v := fake(t, f, "price") + if code, symbol, _ := strings.Cut(v, " "); symbol == "" || symbols[code] != symbol { + t.Fatalf("Fake(price) = %q, want its fields one currency draw, as the record's columns are", v) } } } -func TestRecordSharesAReferenceIntoAColumnRepeat(t *testing.T) { +func TestRepeatIterationsDrawReferencesAnew(t *testing.T) { dir := writeData(t, map[string]string{ - "currency": `[{"format":"{code}","code":"AUD"},{"format":"{code}","code":"EUR"}]`, - "order": `{"format":"","codes":{"format":"{/currency.code}","repeat":3,"separator":"-"}}`, + "party": `{"format":"{host}: {guests}","guests":{"format":"{/person.first} {/person.last}","repeat":3,"separator":", "},"host":"{/person.first} {/person.last}"}`, + "person": drawPeople, }) f := newGenerator(t, dir, WithSeed(1)) - for i := 0; i < 50; i++ { - r, err := f.FakeRecord("order") + differed := map[[2]int]bool{} + check := func(view string, names []string) { + t.Helper() + if len(names) != 4 { + t.Fatalf("%s rendered %q, want a host and three guests", view, names) + } + for i, name := range names { + if !onePerson(name) { + t.Fatalf("%s rendered %q, want each name one person", view, names) + } + for j := range names[:i] { + differed[[2]int{j, i}] = differed[[2]int{j, i}] || names[j] != name + } + } + } + for i := 0; i < 100; i++ { + r, err := f.FakeRecord("party") if err != nil { t.Fatal(err) } - parts := strings.Split(r.Columns()[0].Value, "-") - if len(parts) != 3 || parts[0] != parts[1] || parts[1] != parts[2] { - t.Fatalf("codes column = %q, want one shared draw across its repeat", r.Columns()[0].Value) + guests, host := r.Columns()[0].Value, r.Columns()[1].Value + check("FakeRecord", append([]string{host}, strings.Split(guests, ", ")...)) + host, guests, _ = strings.Cut(fake(t, f, "party"), ": ") + check("Fake", append([]string{host}, strings.Split(guests, ", ")...)) + } + for pair, ok := range differed { + if !ok { + t.Errorf("names %v never differed in 200 renders; the host and each repeat iteration are a draw of their own, so four people", pair) } } } -func TestRecordBareReferenceStaysIndependent(t *testing.T) { +func TestRecordGroupsDrawApart(t *testing.T) { dir := writeData(t, map[string]string{ - "currency": `[{"format":"{code}","code":"AUD"},{"format":"{code}","code":"EUR"}]`, - "order": `{"format":"","whole":"{/currency}","code":"{/currency.code}"}`, + "person": drawPeople, + "transfer": `{"format":"{from_first} {from_last} to {to_first} {to_last}","from_first":{"format":"{/person.first}","group":"from"},"from_last":{"format":"{/person.last}","group":"from"},"to_first":{"format":"{/person.first}","group":"to"},"to_last":{"format":"{/person.last}","group":"to"}}`, }) f := newGenerator(t, dir, WithSeed(1)) - sawMismatch := false + apart := map[string]bool{} for i := 0; i < 100; i++ { - r, err := f.FakeRecord("order") + r, err := f.FakeRecord("transfer") if err != nil { t.Fatal(err) } @@ -372,13 +397,18 @@ func TestRecordBareReferenceStaysIndependent(t *testing.T) { for _, c := range r.Columns() { m[c.Name] = c.Value } - if m["whole"] != m["code"] { - sawMismatch = true - break + from, to, _ := strings.Cut(fake(t, f, "transfer"), " to ") + for view, pair := range map[string][2]string{"FakeRecord": {m["from_first"] + " " + m["from_last"], m["to_first"] + " " + m["to_last"]}, "Fake": {from, to}} { + if !onePerson(pair[0]) || !onePerson(pair[1]) { + t.Fatalf("%s drew %q and %q, want each group one person", view, pair[0], pair[1]) + } + apart[view] = apart[view] || pair[0] != pair[1] } } - if !sawMismatch { - t.Fatal("a bare {/currency} column never disagreed with a tailed {/currency.code} column; a bare reference should draw independently") + for _, view := range []string{"FakeRecord", "Fake"} { + if !apart[view] { + t.Errorf("%s: groups from and to drew one person in 100 renders, want a draw each", view) + } } } diff --git a/reference_test.go b/reference_test.go index 65494d5..26fa9ee 100644 --- a/reference_test.go +++ b/reference_test.go @@ -255,15 +255,73 @@ func TestBareReferenceDrawsEachTime(t *testing.T) { } func TestReferenceOverlapIsRejected(t *testing.T) { + p := `[{"format":"{first}","first":"A","last":"1"},{"format":"{first}","first":"B","last":"2"}]` for name, file := range map[string]string{ - "head beside a path": `{"format":"{/cat.p} {/cat.p.first}","p":[{"format":"{first}","first":"A"},{"format":"{first}","first":"B"}]}`, - "sibling path beside a reference path": `{"format":"{p.first} {/cat.p.last}","p":[{"format":"{first}","first":"A","last":"1"},{"format":"{first}","first":"B","last":"2"}]}`, + "head beside a path": `{"format":"{/cat.p} {/cat.p.first}","p":` + p + `}`, + "sibling path beside a reference path": `{"format":"{p.first} {/cat.p.last}","p":` + p + `}`, + "sibling fields reading a level and a path into it": `{"format":"{a} {b}","a":"{/cat.p}","b":"{/cat.p.first}","p":` + p + `}`, + "a field rendering the level a nested reference reads into": `{"format":"{x} {p}","x":"{/cat.p.first}","p":` + p + `}`, } { _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file}))) if err == nil || !strings.Contains(err.Error(), "reads a path into") { t.Errorf("%s: New = %v, want the overlap rejected", name, err) } } + if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ + "cat": `{"format":"{a} {b}","a":{"format":"{/cat.p}","group":"g"},"b":"{/cat.p.first}","p":` + p + `}`, + }))); err != nil { + t.Errorf("New = %v, want a level and a path into it accepted in groups of their own", err) + } +} + +const drawPeople = `[{"format":"{first} {last}","first":"Ada","last":"Lovelace"},{"format":"{first} {last}","first":"Bo","last":"Ek"},{"format":"{first} {last}","first":"Cy","last":"Young"}]` + +// onePerson reports whether name is the first name and surname of one drawPeople row. +func onePerson(name string) bool { + first, last, _ := strings.Cut(name, " ") + return last != "" && map[string]string{"Ada": "Lovelace", "Bo": "Ek", "Cy": "Young"}[first] == last +} + +func TestAReferencePathIsOneDrawPerRender(t *testing.T) { + dir := writeData(t, map[string]string{ + "apart": `{"format":"{a} & {b}","a":{"format":"{/person.first} {/person.last}","group":"x"},"b":{"format":"{/person.first} {/person.last}","group":"y"}}`, + "contact": `{"format":"{first} {last} <{email}>","email":"{lowercase(/person.first)}.{lowercase(/person.last)}@example.com","first":"{/person.first}","last":"{/person.last}"}`, + "nested": `{"format":"{/person.first} {inner}","inner":"{/person.last}"}`, + "pair": `{"format":"{a} & {b}","a":"{/person.first} {/person.last}","b":"{/person.first} {/person.last}"}`, + "person": drawPeople, + }) + f := newGenerator(t, dir, WithSeed(1)) + apart := false + for i := 0; i < 100; i++ { + if got := fake(t, f, "nested"); !onePerson(got) { + t.Fatalf("nested = %q, want a nested template's path one draw with its parent's", got) + } + name, email, _ := strings.Cut(fake(t, f, "contact"), " <") + first, last, _ := strings.Cut(name, " ") + if !onePerson(name) || email != strings.ToLower(first)+"."+strings.ToLower(last)+"@example.com>" { + t.Fatalf("contact = %q <%s, want first, last and email one person", name, email) + } + r, err := f.FakeRecord("contact") + if err != nil { + t.Fatal(err) + } + c := r.Columns() + if !onePerson(c[1].Value+" "+c[2].Value) || c[0].Value != strings.ToLower(c[1].Value)+"."+strings.ToLower(c[2].Value)+"@example.com" { + t.Fatalf("contact record %s, want first, last and email one person", r.JSON()) + } + a, b, _ := strings.Cut(fake(t, f, "pair"), " & ") + if !onePerson(a) || a != b { + t.Fatalf("pair = %q & %q, want both fields one person", a, b) + } + a, b, _ = strings.Cut(fake(t, f, "apart"), " & ") + if !onePerson(a) || !onePerson(b) { + t.Fatalf("apart = %q & %q, want each group one person", a, b) + } + apart = apart || a != b + } + if !apart { + t.Error("groups x and y drew one person in 100 renders, want a draw each") + } } func TestRelativeReferences(t *testing.T) { diff --git a/template_test.go b/template_test.go index 080972e..12d9ae7 100644 --- a/template_test.go +++ b/template_test.go @@ -38,7 +38,7 @@ func compiled(t *testing.T, s string) node { func mustRender(t *testing.T, f *Generator, s string) string { t.Helper() - return render(f.rand, compiled(t, s), nil) + return renderOnce(f.rand, compiled(t, s)) } func TestStringIsAFormat(t *testing.T) { @@ -315,7 +315,7 @@ func TestGrowIsALowerBound(t *testing.T) { t.Fatalf("format %q did not compile to a template", format) } for i := 0; i < 50; i++ { - if got := len(expand(f.rand, tmpl, nil)); got < tmpl.grow { + if got := len(expand(f.rand, tmpl, drawScope{set: &drawSet{}})); got < tmpl.grow { t.Errorf("format %q: expand emitted %d bytes, below grow %d", format, got, tmpl.grow) } }