From de8c1d13e937d3d6d3035ffa4792a7b10741545b Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 2 Sep 2026 12:37:21 +0200 Subject: [PATCH] Tests for one spelling per shape: no one-item choice, repeated item or inert object --- bench_test.go | 13 ++-- bound_test.go | 104 ++++++++++++++----------------- builtins_test.go | 54 ++++++++--------- calc_test.go | 36 +++++------ edge_test.go | 84 ++++++++++++------------- loading_test.go | 32 +++++----- one_spelling_test.go | 48 +++++++++++++++ reference_test.go | 74 +++++++++++------------ shipped_data_test.go | 14 ++--- template_stability_test.go | 16 ++--- template_test.go | 121 +++++++++++++++++++------------------ 11 files changed, 315 insertions(+), 281 deletions(-) create mode 100644 one_spelling_test.go diff --git a/bench_test.go b/bench_test.go index 0b850ed..d3bc76f 100644 --- a/bench_test.go +++ b/bench_test.go @@ -43,7 +43,7 @@ func tmpData(b *testing.B, name, body string) string { } func BenchmarkCalc(b *testing.B) { - dir := tmpData(b, "inv", `{"format":"{net} x {qty} = {calc(net * qty, 2)}","net":["19.99"],"qty":["3"]}`) + dir := tmpData(b, "inv", `{"format":"{net} x {qty} = {calc(net * qty, 2)}","net":"19.99","qty":"3"}`) benchPath(b, dir, "inv") } @@ -61,24 +61,19 @@ func BenchmarkRepeat(b *testing.B) { // what a correlated pair costs against BenchmarkUnbound, the same output drawn from // two independent fields. func BenchmarkBound(b *testing.B) { - dir := tmpData(b, "addr", `{"format":"{place.postal-code} {place.locality}","place":[ - {"format":"{locality}","locality":"Stockholm","postal-code":{"format":"1{digits(2)} {digits(2)}"}}, - {"format":"{locality}","locality":"Tranås","postal-code":{"format":"573 {digits(2)}"}}]}`) + dir := tmpData(b, "addr", `{"format":"{place.postal-code} {place.locality}","place":[{"format":"{locality}","locality":"Stockholm","postal-code":"1{digits(2)} {digits(2)}"},{"format":"{locality}","locality":"Tranås","postal-code":"573 {digits(2)}"}]}`) benchPath(b, dir, "addr") } func BenchmarkUnbound(b *testing.B) { - dir := tmpData(b, "addr", `{"format":"{postal-code} {locality}", - "postal-code":[{"format":"1{digits(2)} {digits(2)}"},{"format":"573 {digits(2)}"}], - "locality":["Stockholm","Tranås"]}`) + dir := tmpData(b, "addr", `{"format":"{postal-code} {locality}","postal-code":["1{digits(2)} {digits(2)}","573 {digits(2)}"],"locality":["Stockholm","Tranås"]}`) benchPath(b, dir, "addr") } // BenchmarkBoundDeep reads two paths through an intermediate level, the shape the // depth question is about. func BenchmarkBoundDeep(b *testing.B) { - dir := tmpData(b, "addr", `{"format":"{p.addr.city} {p.addr.zip}","p":[ - {"format":"{addr}","addr":{"format":"{city}","city":["Stockholm","Tranås"],"zip":{"format":"1{digits(2)} {digits(2)}"}}}]}`) + dir := tmpData(b, "addr", `{"format":"{p.addr.city} {p.addr.zip}","p":{"format":"{addr}","addr":{"format":"{city}","city":["Stockholm","Tranås"],"zip":"1{digits(2)} {digits(2)}"}}}`) benchPath(b, dir, "addr") } diff --git a/bound_test.go b/bound_test.go index a209949..a266a34 100644 --- a/bound_test.go +++ b/bound_test.go @@ -15,10 +15,7 @@ import ( // swedishPlaces is a two-variant sibling whose variants pair a locality with the // postal-code prefix that really belongs to it. -const swedishPlaces = `{"format":"%s","place":[ - {"format":"{locality}","locality":"Stockholm","postal-code":{"format":"1{digits(2)} {digits(2)}"}}, - {"format":"{locality}","locality":"Tranås","postal-code":{"format":"573 {digits(2)}"}} -]}` +const swedishPlaces = `{"format":"%s","place":[{"format":"{locality}","locality":"Stockholm","postal-code":"1{digits(2)} {digits(2)}"},{"format":"{locality}","locality":"Tranås","postal-code":"573 {digits(2)}"}]}` // agree reports whether a rendered "postcode locality" pair is a real pairing. var agree = regexp.MustCompile(`^(1[0-9]{2} [0-9]{2} Stockholm|573 [0-9]{2} Tranås)$`) @@ -48,10 +45,10 @@ func TestRenderingALevelAndReadingIntoItIsRejected(t *testing.T) { // other rendered — so the pair is a load error rather than a shape whose two // halves can disagree. rejected := map[string]string{ - "bare head beside a path": `{"format":"{p} + {p.first}","p":[{"format":"{first}","first":["Anna","Bo"]}]}`, - "prefix path beside a path": `{"format":"{p.addr}|{p.addr.city}","p":[{"format":"{addr}","addr":[` + - `{"format":"{city}","city":["Kiruna","Boden"]},{"format":"{city}","city":["Malmö","Lund"]}]}]}`, - "either order": `{"format":"{p.first} + {p}","p":[{"format":"{first}","first":["Anna","Bo"]}]}`, + "bare head beside a path": `{"format":"{p} + {p.first}","p":{"format":"{first}","first":["Anna","Bo"]}}`, + "prefix path beside a path": `{"format":"{p.addr}|{p.addr.city}","p":{"format":"{addr}","addr":[` + + `{"format":"{city}","city":["Kiruna","Boden"]},{"format":"{city}","city":["Malmö","Lund"]}]}}`, + "either order": `{"format":"{p.first} + {p}","p":{"format":"{first}","first":["Anna","Bo"]}}`, } for name, file := range rejected { _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file}))) @@ -65,7 +62,7 @@ func TestRenderingALevelAndReadingIntoItIsRejected(t *testing.T) { } // The same path twice is one spelling, so it stays legal. if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "cat": `{"format":"{p.first} + {p.first}","p":[{"format":"{first}","first":["Anna","Bo"]}]}`, + "cat": `{"format":"{p.first} + {p.first}","p":{"format":"{first}","first":["Anna","Bo"]}}`, }))); err != nil { t.Errorf("New = %v, want one path read twice accepted", err) } @@ -84,7 +81,7 @@ func TestCalcOperandNamingABoundLevelIsRejected(t *testing.T) { } // Arithmetic over fields no path names is untouched. if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "cat": `{"format":"{net} x {qty} = {calc(net * qty, 2)}","net":["19.99"],"qty":["3"]}`, + "cat": `{"format":"{net} x {qty} = {calc(net * qty, 2)}","net":"19.99","qty":"3"}`, }))); err != nil { t.Errorf("New = %v, want plain arithmetic accepted", err) } @@ -95,13 +92,13 @@ func TestReferenceNamingABoundLevelIsRejected(t *testing.T) { // afresh beside the path that reads its held draw — the same overlap by // another spelling. rejected := map[string]string{ - "reference names the head": `{"format":"{p.first}|{..cat.p}","p":[{"format":"{first}","first":["Anna","Bo"]}]}`, + "reference names the head": `{"format":"{p.first}|{..cat.p}","p":{"format":"{first}","first":["Anna","Bo"]}}`, "reference names the leaf": `{"format":"{p.addr}|{..cat.p.addr}","p":{"format":"x","addr":["A","B","C","D"]}}`, // The reference need not sit in the format that binds: any field it renders // reaches the level just the same, however deep. "reference from a sibling field": `{"format":"{p.first}|{inner}","p":[` + `{"format":"{first}-{last}","first":"A","last":"1"},{"format":"{first}-{last}","first":"B","last":"2"}],` + - `"inner":{"format":"{..cat.p}"}}`, + `"inner":"{..cat.p}"}`, } for name, file := range rejected { _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file}))) @@ -111,7 +108,7 @@ func TestReferenceNamingABoundLevelIsRejected(t *testing.T) { } // A reference to anything this format does not bind is untouched. if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "cat": `{"format":"{p.first} {..surname}","p":[{"format":"{first}","first":["Anna","Bo"]}]}`, + "cat": `{"format":"{p.first} {..surname}","p":{"format":"{first}","first":["Anna","Bo"]}}`, "surname": `["Eriksson","Lindqvist"]`, }))); err != nil { t.Errorf("New = %v, want a reference outside the bound level accepted", err) @@ -123,7 +120,7 @@ func TestCycleReachedOnlyByAPathTokenIsRejected(t *testing.T) { // cycle walk has to follow it there. A head whose own format names nothing // would otherwise hide the cycle until render, where it is fatal. _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "a": `{"format":"{p.x}","p":{"format":"static","x":{"format":"{..a}"}}}`, + "a": `{"format":"{p.x}","p":{"format":"static","x":"{..a}"}}`, }))) if err == nil || !strings.Contains(err.Error(), "reference cycle") { t.Fatalf("New = %v, want the cycle through {p.x} rejected", err) @@ -134,7 +131,7 @@ func TestALevelRenderedOnlyByAPathTokenIsHeld(t *testing.T) { // {p.a} renders q, so it is a route to the level {q.x} holds — even though p's // own format names nothing. _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "thing": `{"format":"{p.a} {q.x}","p":{"format":"static","a":{"format":"{..thing.q}"}},` + + "thing": `{"format":"{p.a} {q.x}","p":{"format":"static","a":"{..thing.q}"},` + `"q":{"format":"{x}","x":["1","2"]}}`, }))) if err == nil || !strings.Contains(err.Error(), "reads a path into") { @@ -161,7 +158,7 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) { "a reference one level down": { map[string]string{ "cat": `{"format":"{calc(net * 2, 2)} {q}","net":["10.00","20.00"],` + - `"q":{"format":"{..cat.net}"}}`, + `"q":"{..cat.net}"}`, }, `{q} renders "net"`, }, @@ -169,8 +166,8 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) { // so wrapping the operand in one changes nothing. "a reference to an operand wrapped in a choice": { map[string]string{ - "cat": `{"format":"{calc(n * 2, 2)} {q}","n":[{"format":"{v}","v":["1","2"]}],` + - `"q":{"format":"{..cat.n}"}}`, + "cat": `{"format":"{calc(n * 2, 2)} {q}","n":{"format":"{v}","v":["1","2"]},` + + `"q":"{..cat.n}"}`, }, `{q} renders "n"`, }, @@ -179,7 +176,7 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) { "an operand reaching another operand": { map[string]string{ "cat": `{"format":"{calc(a + b, 0)}","a":{"format":"{x}","x":["1","2"]},` + - `"b":{"format":"{..cat.a}"}}`, + `"b":"{..cat.a}"}`, }, `calc operand "b" renders "a"`, }, @@ -196,7 +193,7 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) { "a reference into the operand one level down": { map[string]string{ "cat": `{"format":"{calc(net * 2, 2)}|{q}","net":{"format":"{v}","v":["10.00","20.00"]},` + - `"q":{"format":"{..cat.net.v}"}}`, + `"q":"{..cat.net.v}"}`, }, `{q} renders "net"`, }, @@ -206,7 +203,7 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) { "a violation on the second of two held heads": { map[string]string{ "cat": `{"format":"{calc(a + b, 0)} {w}","a":{"format":"{x}","x":["1","2"]},` + - `"b":{"format":"{y}","y":["3","4"]},"w":{"format":"{..cat.b}"}}`, + `"b":{"format":"{y}","y":["3","4"]},"w":"{..cat.b}"}`, }, `{w} renders "b"`, }, @@ -233,20 +230,20 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) { "a reference to a sibling the operand never renders": { "cat": `{"format":"{calc(net * 2, 2)} {unit}",` + `"net":{"format":"{v}","v":["1","2"],"spare":["kg","lb"]},` + - `"unit":{"format":"{..cat.net.spare}"}}`, + `"unit":"{..cat.net.spare}"}`, }, // Two operands drawing from one source are two names, so two draws: each is // held under its own name and shown once, and neither can disagree. "two operands sharing one source": { "die": `["1","2","3","4","5","6"]`, - "cat": `{"format":"{d1} + {d2} = {calc(d1 + d2, 0)}","d1":{"format":"{..die}"},` + - `"d2":{"format":"{..die}"}}`, + "cat": `{"format":"{d1} + {d2} = {calc(d1 + d2, 0)}","d1":"{..die}",` + + `"d2":"{..die}"}`, }, // Likewise a reference drawing from what the operand draws from: {..common} // and net are two names, not two spellings of one field. "a reference to what an operand renders through": { "common": `["1","2"]`, - "cat": `{"format":"{calc(net * 2, 2)} {..common}","net":{"format":"{..common}"}}`, + "cat": `{"format":"{calc(net * 2, 2)} {..common}","net":"{..common}"}`, }, // A fixed string cannot disagree with itself, so it needs no fence. "a literal operand named twice": { @@ -272,11 +269,11 @@ func TestAPathReachesEveryVariantItMightDraw(t *testing.T) { // to a held level disagrees silently. rejected := map[string]struct{ file, want string }{ "a cycle in a later variant": { - `{"format":"{p.x}","p":[{"format":"h","x":"safe"},{"format":"h","x":{"format":"{..cat}"}}]}`, + `{"format":"{p.x}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{..cat}"}]}`, "reference cycle", }, "a second route in a later variant": { - `{"format":"{p.x} {q.y}","p":[{"format":"h","x":"safe"},{"format":"h","x":{"format":"{..cat.q}"}}],` + + `{"format":"{p.x} {q.y}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{..cat.q}"}],` + `"q":{"format":"{y}","y":["1","2"]}}`, "reads a path into", }, @@ -309,10 +306,10 @@ func TestALevelAPathNeverRendersIsAccepted(t *testing.T) { func TestADeepDiamondChainLoads(t *testing.T) { // A diamond chain is 2^n routes through n nodes. The search past a bound level // must walk each node once, not once per route, or a deep chain never loads. - files := map[string]string{"l0": `["x"]`} + files := map[string]string{"l0": `"x"`} for i := 1; i <= 30; i++ { files[fmt.Sprintf("l%d", i)] = fmt.Sprintf( - `{"format":"{a}{b}","a":{"format":"{..l%d}"},"b":{"format":"{..l%d}"}}`, i-1, i-1) + `{"format":"{a}{b}","a":"{..l%d}","b":"{..l%d}"}`, i-1, i-1) } files["thing"] = `{"format":"{p.first} {..l30}","p":{"format":"x","first":["A","B"]}}` if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err != nil { @@ -325,9 +322,9 @@ func TestASharedNodeIsWalkedOnce(t *testing.T) { // The search past a bound level must take that in its stride rather than walk // the shared node once per route. f, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "cat": `{"format":"{p.first}|{q}","p":[{"format":"{first}","first":["Anna","Bo"]}],` + - `"q":{"format":"{a}{b}","a":{"format":"{..shared}"},"b":{"format":"{..shared}"}}}`, - "shared": `["x"]`, + "cat": `{"format":"{p.first}|{q}","p":{"format":"{first}","first":["Anna","Bo"]},` + + `"q":{"format":"{a}{b}","a":"{..shared}","b":"{..shared}"}}`, + "shared": `"x"`, })), WithSeed(1)) if err != nil { t.Fatalf("New = %v, want a shared node accepted", err) @@ -342,7 +339,7 @@ func TestTheEarlierReaderIsNamed(t *testing.T) { // first is the one reported, so the error points at the same place a reader // looking at the format would start. _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "cat": `{"format":"{calc(p * 1)} {p} {p.first}","p":[{"format":"{first}","first":["1","2"]}]}`, + "cat": `{"format":"{calc(p * 1)} {p} {p.first}","p":{"format":"{first}","first":["1","2"]}}`, }))) if err == nil || !strings.Contains(err.Error(), `calc operand "p"`) { t.Fatalf("New = %v, want the calc operand named, being written first", err) @@ -368,7 +365,7 @@ func TestNestedChoiceDrawsOneVariant(t *testing.T) { f := engine(21) seen := map[string]bool{} for i := 0; i < 200; i++ { - seen[mustRender(t, f, `{"format":"{p.x}","p":[[{"format":"{x}","x":"1"}],[{"format":"{x}","x":"2"}]]}`)] = true + seen[mustRender(t, f, `{"format":"{p.x}","p":[{"format":"{x}","x":"1"},{"format":"{x}","x":"2"}]}`)] = true } if !seen["1"] || !seen["2"] || len(seen) != 2 { t.Fatalf("200 draws produced %v, want 1 and 2", seen) @@ -379,7 +376,7 @@ func TestRepeatingLevelIsNamedInTheError(t *testing.T) { // The level carrying the repeat is the one to fix, so the error names it // rather than the head the path started from. _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "cat": `{"format":"[{p.a.b}]","p":{"format":"{a}","a":{"format":"{b}","repeat":3,"separator":",","b":["z"]}}}`, + "cat": `{"format":"[{p.a.b}]","p":{"format":"{a}","a":{"format":"{b}","repeat":3,"separator":",","b":"z"}}}`, }))) if err == nil || !strings.Contains(err.Error(), `"p.a"`) { t.Fatalf("New = %v, want it to name the level p.a that carries the repeat", err) @@ -391,7 +388,7 @@ func TestPathIntoARepeatingLevelBehindAChoiceIsRejected(t *testing.T) { // reach inside one — otherwise the direct spelling is a load error and the same // mistake behind a choice silently drops the repeat. _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "cat": `{"format":"[{p.a}]","p":[{"format":"{a}","repeat":3,"separator":",","a":["x"]},{"format":"{a}","a":["y"]}]}`, + "cat": `{"format":"[{p.a}]","p":[{"format":"{a}","repeat":3,"separator":",","a":"x"},{"format":"{a}","a":"y"}]}`, }))) if err == nil || !strings.Contains(err.Error(), "repeat") { t.Fatalf("New = %v, want the repeat behind a choice rejected", err) @@ -402,7 +399,7 @@ func TestPathIntoARepeatingLevelIsRejected(t *testing.T) { // A path reads one level's draw, so it can never apply that level's repeat. // The engine rejects options that cannot take effect, and this is one. _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "cat": `{"format":"[{p.a}]","p":{"format":"{a}","repeat":3,"separator":",","a":["z"]}}`, + "cat": `{"format":"[{p.a}]","p":{"format":"{a}","repeat":3,"separator":",","a":"z"}}`, }))) if err == nil || !strings.Contains(err.Error(), "repeat") { t.Fatalf("New = %v, want a path into a repeating level rejected", err) @@ -511,7 +508,7 @@ func TestDottedTokenErrors(t *testing.T) { want string }{ "unknown head": { - `{"format":"{nope.x}","place":[{"format":"{v}","v":"A"}]}`, + `{"format":"{nope.x}","place":{"format":"{v}","v":"A"}}`, `no field "nope"`, }, "unknown tail": { @@ -519,7 +516,7 @@ func TestDottedTokenErrors(t *testing.T) { `"nope"`, }, "tail only some variants carry": { - `{"format":"{place.locality}","place":[{"format":"{locality}","locality":"A"},{"format":"x"}]}`, + `{"format":"{place.locality}","place":[{"format":"{locality}","locality":"A"},"x"]}`, `locality`, }, "path into a literal": { @@ -527,7 +524,7 @@ func TestDottedTokenErrors(t *testing.T) { `"y"`, }, "dotted field key": { - `{"format":"[{a.b}]","a.b":["V"]}`, + `{"format":"[{a.b}]","a.b":"V"}`, `contains "."`, }, } @@ -548,8 +545,7 @@ func TestSamePathReadTwiceReadsOneValue(t *testing.T) { // what a name shown in a display form and again in an address needs. f := engine(7) for i := 0; i < 300; i++ { - got := mustRender(t, f, `{"format":"{p.first}|{p.first}", - "p":[{"format":"{first}","first":["Anna","Astrid","Elin","Karin"]}]}`) + got := mustRender(t, f, `{"format":"{p.first}|{p.first}","p":{"format":"{first}","first":["Anna","Astrid","Elin","Karin"]}}`) parts := strings.Split(got, "|") if parts[0] != parts[1] { t.Fatalf("draw %d = %q, want one value read twice", i, got) @@ -571,9 +567,7 @@ func TestDifferentTailsUnderOneHeadShareTheRow(t *testing.T) { // deepPlaces nests the correlated facts a level down: the row holds an addr, and // the addr holds the city and the zip that belongs to it. -const deepPlaces = `{"format":"%s","p":[{"format":"{addr}","addr":[ - {"format":"{city}","city":"Stockholm","zip":"11111"}, - {"format":"{city}","city":"Kiruna","zip":"98100"}]}]}` +const deepPlaces = `{"format":"%s","p":{"format":"{addr}","addr":[{"format":"{city}","city":"Stockholm","zip":"11111"},{"format":"{city}","city":"Kiruna","zip":"98100"}]}}` func TestPathHoldsItsDrawAtEveryLevel(t *testing.T) { // A choice below the head is drawn once too, so two paths sharing a prefix @@ -595,13 +589,7 @@ func TestPathHoldsItsDrawAtEveryLevel(t *testing.T) { func TestPathHoldsItsDrawThreeLevelsDown(t *testing.T) { // The rule does not run out at two: every level a path passes through is held. f := engine(12) - tmpl := `{"format":"{p.geo.town.name}/{p.geo.town.zip} {p.geo.region}","p":[{"format":"{geo}","geo":[ - {"format":"{town}","region":"Norrbotten","town":[ - {"format":"{name}","name":"Kiruna","zip":"98100"}, - {"format":"{name}","name":"Luleå","zip":"97200"}]}, - {"format":"{town}","region":"Skåne","town":[ - {"format":"{name}","name":"Malmö","zip":"21100"}, - {"format":"{name}","name":"Lund","zip":"22100"}]}]}]}` + tmpl := `{"format":"{p.geo.town.name}/{p.geo.town.zip} {p.geo.region}","p":{"format":"{geo}","geo":[{"format":"{town}","region":"Norrbotten","town":[{"format":"{name}","name":"Kiruna","zip":"98100"},{"format":"{name}","name":"Luleå","zip":"97200"}]},{"format":"{town}","region":"Skåne","town":[{"format":"{name}","name":"Malmö","zip":"21100"},{"format":"{name}","name":"Lund","zip":"22100"}]}]}}` want := map[string]bool{ "Kiruna/98100 Norrbotten": true, "Luleå/97200 Norrbotten": true, "Malmö/21100 Skåne": true, "Lund/22100 Skåne": true, @@ -641,9 +629,7 @@ func TestDeepPathsUnderOneHeadStayIndependentWhereTheyDiverge(t *testing.T) { // Two paths share only what they actually share: a common prefix is one draw, // and each keeps its own draw past the point they part. f := engine(14) - tmpl := `{"format":"{p.a.v}{p.b.v}","p":[{"format":"x", - "a":[{"format":"{v}","v":"1"},{"format":"{v}","v":"2"}], - "b":[{"format":"{v}","v":"1"},{"format":"{v}","v":"2"}]}]}` + tmpl := `{"format":"{p.a.v}{p.b.v}","p":{"format":"x","a":[{"format":"{v}","v":"1"},{"format":"{v}","v":"2"}],"b":[{"format":"{v}","v":"1"},{"format":"{v}","v":"2"}]}}` seen := map[string]bool{} for i := 0; i < 400; i++ { seen[mustRender(t, f, tmpl)] = true @@ -660,9 +646,9 @@ func TestEmptyPathSegmentIsRejected(t *testing.T) { // nothing is reported as that rather than as a missing field. An empty name is // rejected where it is authored, so no data can make these resolve. rejected := map[string]string{ - "trailing dot": `{"format":"[{a.}]","a":{"format":"x"}}`, - "leading dot": `{"format":"[{.b}]","a":{"format":"{b}","b":["V"]}}`, - "double dot": `{"format":"[{a..b}]","a":{"format":"x"}}`, + "trailing dot": `{"format":"[{a.}]","a":"x"}`, + "leading dot": `{"format":"[{.b}]","a":{"format":"{b}","b":"V"}}`, + "double dot": `{"format":"[{a..b}]","a":"x"}`, } for name, file := range rejected { _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file}))) @@ -681,7 +667,7 @@ func TestCycleThroughAPathTokenIsRejected(t *testing.T) { // must be caught at New. Reaching render would be fatal: the recursion never // terminates, and a stack overflow cannot be recovered. _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "a": `{"format":"{p.x}","p":{"format":"{x}","x":{"format":"{..a}"}}}`, + "a": `{"format":"{p.x}","p":{"format":"{x}","x":"{..a}"}}`, }))) if err == nil || !strings.Contains(err.Error(), "reference cycle") { t.Fatalf("New = %v, want the cycle through {p.x} rejected", err) diff --git a/builtins_test.go b/builtins_test.go index 13af539..7a63509 100644 --- a/builtins_test.go +++ b/builtins_test.go @@ -13,10 +13,10 @@ func TestBuiltinIDGenerators(t *testing.T) { tmpl string re *regexp.Regexp }{ - {"uuid v7", `{"format":"{uuid()}"}`, regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)}, - {"ulid", `{"format":"{ulid()}"}`, regexp.MustCompile(`^[0-7][0-9A-HJKMNP-TV-Z]{25}$`)}, - {"nanoid", `{"format":"{nanoid(21)}"}`, regexp.MustCompile(`^[A-Za-z0-9_-]{21}$`)}, - {"hex", `{"format":"{hex(16)}"}`, regexp.MustCompile(`^[0-9a-f]{16}$`)}, + {"uuid v7", `"{uuid()}"`, regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)}, + {"ulid", `"{ulid()}"`, regexp.MustCompile(`^[0-7][0-9A-HJKMNP-TV-Z]{25}$`)}, + {"nanoid", `"{nanoid(21)}"`, regexp.MustCompile(`^[A-Za-z0-9_-]{21}$`)}, + {"hex", `"{hex(16)}"`, regexp.MustCompile(`^[0-9a-f]{16}$`)}, } f := engine(1) for _, c := range cases { @@ -38,9 +38,9 @@ func TestBuiltinIDGenerators(t *testing.T) { // sample draws only from the seeded rng, so same seed -> same value. func TestBuiltinSamplesReproducible(t *testing.T) { for _, tmpl := range []string{ - `{"format":"{uuid()}"}`, `{"format":"{ulid()}"}`, - `{"format":"{nanoid(12)}"}`, `{"format":"{int(1,1000000)}"}`, - `{"format":"{float(0,1,6)}"}`, `{"format":"{base64(12)}"}`, `{"format":"{iban(SE)}"}`, + `"{uuid()}"`, `"{ulid()}"`, + `"{nanoid(12)}"`, `"{int(1,1000000)}"`, + `"{float(0,1,6)}"`, `"{base64(12)}"`, `"{iban(SE)}"`, } { if a, b := mustRender(t, engine(7), tmpl), mustRender(t, engine(7), tmpl); a != b { t.Fatalf("%s not reproducible: %q != %q", tmpl, a, b) @@ -52,7 +52,7 @@ func TestBuiltinIntInclusiveRange(t *testing.T) { f := engine(1) lo, hi := false, false for i := 0; i < 1000; i++ { - n, err := strconv.Atoi(mustRender(t, f, `{"format":"{int(3,7)}"}`)) + n, err := strconv.Atoi(mustRender(t, f, `"{int(3,7)}"`)) if err != nil || n < 3 || n > 7 { t.Fatalf("int(3,7) = %d (err %v), out of range", n, err) } @@ -66,14 +66,14 @@ func TestBuiltinIntInclusiveRange(t *testing.T) { func TestBuiltinFloat(t *testing.T) { f, re := engine(1), regexp.MustCompile(`^[12]\.\d{3}$`) for i := 0; i < 200; i++ { - if got := mustRender(t, f, `{"format":"{float(1,2,3)}"}`); !re.MatchString(got) { + if got := mustRender(t, f, `"{float(1,2,3)}"`); !re.MatchString(got) { t.Fatalf("float(1,2,3) = %q, want d.ddd in [1,2]", got) } } } func TestBuiltinBase64(t *testing.T) { - got := mustRender(t, engine(1), `{"format":"{base64(9)}"}`) + got := mustRender(t, engine(1), `"{base64(9)}"`) if b, err := base64.StdEncoding.DecodeString(got); err != nil || len(b) != 9 { t.Fatalf("base64(9) = %q decodes to %d bytes (err %v), want 9", got, len(b), err) } @@ -83,9 +83,9 @@ func TestBuiltinBase64(t *testing.T) { // hand-computed check, like the luhn test. mod-11 emits X when it would be 10. func TestBuiltinChecksums(t *testing.T) { cases := map[string]string{ - `{"format":"12345678{mod11()}"}`: "123456785", // weights 2..7 from the right - `{"format":"6{mod11()}"}`: "6X", // remainder 10 -> X - `{"format":"400638133393{ean()}"}`: "4006381333931", // EAN-13 (= ISBN-13) check digit + `"12345678{mod11()}"`: "123456785", // weights 2..7 from the right + `"6{mod11()}"`: "6X", // remainder 10 -> X + `"400638133393{ean()}"`: "4006381333931", // EAN-13 (= ISBN-13) check digit } f := engine(1) for tmpl, want := range cases { @@ -101,14 +101,14 @@ func TestBuiltinChecksums(t *testing.T) { func TestBuiltinSeqPerSession(t *testing.T) { f := engine(1) for i := 1; i <= 5; i++ { - if got := mustRender(t, f, `{"format":"{seq()}"}`); got != strconv.Itoa(i) { + if got := mustRender(t, f, `"{seq()}"`); got != strconv.Itoa(i) { t.Fatalf("seq call %d = %q, want %d", i, got, i) } } - if got := mustRender(t, f, `{"format":"{seq(orders)}"}`); got != "1" { + if got := mustRender(t, f, `"{seq(orders)}"`); got != "1" { t.Fatalf("named seq(orders) = %q, want its own count from 1", got) } - if got := mustRender(t, f, `{"format":"{seq()}"}`); got != "6" { + if got := mustRender(t, f, `"{seq()}"`); got != "6" { t.Fatalf("default seq after the named one = %q, want 6 (counters are independent)", got) } // repeat drives one counter forward across its renders... @@ -116,7 +116,7 @@ func TestBuiltinSeqPerSession(t *testing.T) { t.Fatalf("repeat seq = %q, want 1,2,3", got) } // ...and a fresh session restarts from 1. - if got := mustRender(t, engine(2), `{"format":"{seq()}"}`); got != "1" { + if got := mustRender(t, engine(2), `"{seq()}"`); got != "1" { t.Fatalf("new session seq = %q, want 1", got) } } @@ -126,14 +126,14 @@ func TestBuiltinSeqPerSession(t *testing.T) { // at compile, not detonate when the template is drawn. func TestBuiltinArgLimits(t *testing.T) { bad := []string{ - `{"format":"{int(-9223372036854775808,9223372036854775807)}"}`, // span overflows int -> IntN panic - `{"format":"{hex(2000000000)}"}`, // ~2 GB string - `{"format":"{nanoid(2000000000)}"}`, - `{"format":"{base64(2000000000)}"}`, - `{"format":"{float(-1e308,1e308,2)}"}`, // span is +Inf - `{"format":"{float(0,1,100000)}"}`, // decimals -> huge string - `{"format":"{calc(1+1,100000)}"}`, // calc decimals -> huge string - `{"format":"{x}","x":["1"],"repeat":2000000000}`, // repeat -> multi-GB join + `"{int(-9223372036854775808,9223372036854775807)}"`, // span overflows int -> IntN panic + `"{hex(2000000000)}"`, // ~2 GB string + `"{nanoid(2000000000)}"`, + `"{base64(2000000000)}"`, + `"{float(-1e308,1e308,2)}"`, // span is +Inf + `"{float(0,1,100000)}"`, // decimals -> huge string + `"{calc(1+1,100000)}"`, // calc decimals -> huge string + `{"format":"{x}","x":"1","repeat":2000000000}`, // repeat -> multi-GB join } for _, tmpl := range bad { if _, err := compile(parse(t, tmpl)); err == nil { @@ -141,7 +141,7 @@ func TestBuiltinArgLimits(t *testing.T) { } } // Ordinary values still compile. - if _, err := compile(parse(t, `{"format":"{hex(16)} {int(-5,5)} {float(0,1,3)} {calc(1+1,2)}"}`)); err != nil { + if _, err := compile(parse(t, `"{hex(16)} {int(-5,5)} {float(0,1,3)} {calc(1+1,2)}"`)); err != nil { t.Errorf("compile of normal args failed: %v", err) } } @@ -150,7 +150,7 @@ func TestBuiltinIBAN(t *testing.T) { wantLen := map[string]int{"SE": 24, "DE": 22, "NO": 15} f := engine(1) for cc, n := range wantLen { - tmpl := `{"format":"{iban(` + cc + `)}"}` + tmpl := `"{iban(` + cc + `)}"` for i := 0; i < 100; i++ { got := mustRender(t, f, tmpl) if got[:2] != cc || len(got) != n { diff --git a/calc_test.go b/calc_test.go index 1b9a3a2..b9d6072 100644 --- a/calc_test.go +++ b/calc_test.go @@ -12,14 +12,14 @@ import ( func TestCalcArithmetic(t *testing.T) { f := engine(1) cases := map[string]string{ - `{"format":"{calc(2 + 3)}"}`: "5", - `{"format":"{calc(2 * 3 + 4)}"}`: "10", // * binds tighter than + - `{"format":"{calc(2 + 3 * 4)}"}`: "14", - `{"format":"{calc((2 + 3) * 4)}"}`: "20", // parentheses override - `{"format":"{calc(10 / 4)}"}`: "2.5", - `{"format":"{calc(-2 + 5)}"}`: "3", // unary minus - `{"format":"{calc(2 - -3)}"}`: "5", - `{"format":"{calc(1.5 * 2)}"}`: "3", // whole result drops the decimals + `"{calc(2 + 3)}"`: "5", + `"{calc(2 * 3 + 4)}"`: "10", // * binds tighter than + + `"{calc(2 + 3 * 4)}"`: "14", + `"{calc((2 + 3) * 4)}"`: "20", // parentheses override + `"{calc(10 / 4)}"`: "2.5", + `"{calc(-2 + 5)}"`: "3", // unary minus + `"{calc(2 - -3)}"`: "5", + `"{calc(1.5 * 2)}"`: "3", // whole result drops the decimals } for tmpl, want := range cases { if got := mustRender(t, f, tmpl); got != want { @@ -33,9 +33,9 @@ func TestCalcArithmetic(t *testing.T) { func TestCalcAuto(t *testing.T) { f := engine(1) cases := map[string]string{ - `{"format":"{calc(10 / 3)}"}`: "3.3333333333333335", - `{"format":"{calc(6 / 2)}"}`: "3", - `{"format":"{calc(1 / 4)}"}`: "0.25", + `"{calc(10 / 3)}"`: "3.3333333333333335", + `"{calc(6 / 2)}"`: "3", + `"{calc(1 / 4)}"`: "0.25", } for tmpl, want := range cases { if got := mustRender(t, f, tmpl); got != want { @@ -49,9 +49,9 @@ func TestCalcAuto(t *testing.T) { func TestCalcDecimals(t *testing.T) { f := engine(1) cases := map[string]string{ - `{"format":"{calc(10 / 3, 2)}"}`: "3.33", - `{"format":"{calc(10 / 3, 0)}"}`: "3", - `{"format":"{calc(2 * 3, 2)}"}`: "6.00", + `"{calc(10 / 3, 2)}"`: "3.33", + `"{calc(10 / 3, 0)}"`: "3", + `"{calc(2 * 3, 2)}"`: "6.00", } for tmpl, want := range cases { if got := mustRender(t, f, tmpl); got != want { @@ -63,11 +63,11 @@ func TestCalcDecimals(t *testing.T) { // TestCalcFields pins that bare names resolve to sibling fields, rendered then // parsed as numbers. func TestCalcFields(t *testing.T) { - if got := mustRender(t, engine(1), `{"format":"{calc(price * qty, 2)}","price":["19.99"],"qty":["3"]}`); got != "59.97" { + if got := mustRender(t, engine(1), `{"format":"{calc(price * qty, 2)}","price":"19.99","qty":"3"}`); got != "59.97" { t.Fatalf("calc over fields = %q, want 59.97", got) } // A field that is itself a template renders before parsing. - if got := mustRender(t, engine(1), `{"format":"{calc(a - b)}","a":[{"format":"{n}","n":["10"]}],"b":["3"]}`); got != "7" { + if got := mustRender(t, engine(1), `{"format":"{calc(a - b)}","a":{"format":"{n}","n":"10"},"b":"3"}`); got != "7" { t.Fatalf("calc(a - b) = %q, want 7", got) } } @@ -75,14 +75,14 @@ func TestCalcFields(t *testing.T) { // TestCalcNonNumericIsNaN pins the never-fail rule: a field that doesn't render // to a number becomes NaN, which propagates and prints visibly. func TestCalcNonNumericIsNaN(t *testing.T) { - if got := mustRender(t, engine(1), `{"format":"{calc(x * 2)}","x":["abc"]}`); got != "NaN" { + if got := mustRender(t, engine(1), `{"format":"{calc(x * 2)}","x":"abc"}`); got != "NaN" { t.Fatalf("calc over non-numeric field = %q, want NaN", got) } } // TestCalcReproducible pins that a calc over a random operand stays seed-stable. func TestCalcReproducible(t *testing.T) { - tmpl := `{"format":"{calc(q * 2 + 1)}","q":[{"format":"{int(1,1000000)}"}]}` + tmpl := `{"format":"{calc(q * 2 + 1)}","q":"{int(1,1000000)}"}` if a, b := mustRender(t, engine(7), tmpl), mustRender(t, engine(7), tmpl); a != b { t.Fatalf("calc not reproducible: %q != %q", a, b) } diff --git a/edge_test.go b/edge_test.go index 4103385..eee05c8 100644 --- a/edge_test.go +++ b/edge_test.go @@ -16,11 +16,11 @@ import ( func TestHashIsLiteral(t *testing.T) { f := engine(1) cases := map[string]string{ - `{"format":"","x":["v"]}`: "", - `{"format":"#","x":["v"]}`: "#", - `{"format":"##","x":["v"]}`: "##", - `{"format":"#0#1#A#a","x":["v"]}`: "#0#1#A#a", - `{"format":"#{x}","x":["v"]}`: "#v", + `{"format":"","x":"v"}`: "", + `{"format":"#","x":"v"}`: "#", + `{"format":"##","x":"v"}`: "##", + `{"format":"#0#1#A#a","x":"v"}`: "#0#1#A#a", + `{"format":"#{x}","x":"v"}`: "#v", } for tmpl, want := range cases { if got := mustRender(t, f, tmpl); got != want { @@ -32,7 +32,7 @@ func TestHashIsLiteral(t *testing.T) { func TestMultibyteFormat(t *testing.T) { // Scanning is rune-aware: multibyte literals coexist with class chars and // tokens without corrupting indices. - got := mustRender(t, engine(2), `{"format":"Öster{x}-{digits(1)}å","x":["väg"]}`) + got := mustRender(t, engine(2), `{"format":"Öster{x}-{digits(1)}å","x":"väg"}`) if !regexp.MustCompile(`^Österväg-[0-9]å$`).MatchString(got) { t.Fatalf("multibyte format = %q", got) } @@ -42,7 +42,7 @@ func TestAlternationThreeWay(t *testing.T) { f := engine(4) seen := map[string]bool{} for i := 0; i < 200; i++ { - seen[mustRender(t, f, `{"format":"{a|b|c}","a":["A"],"b":["B"],"c":["C"]}`)] = true + seen[mustRender(t, f, `{"format":"{a|b|c}","a":"A","b":"B","c":"C"}`)] = true } if !seen["A"] || !seen["B"] || !seen["C"] || len(seen) != 3 { t.Fatalf("3-way alternation produced %v, want A, B and C", seen) @@ -69,11 +69,11 @@ func TestNewErrors(t *testing.T) { want string }{ "separator without repeat": { - map[string]string{"a": `{"format":"{x}","x":["1"],"separator":","}`}, + map[string]string{"a": `{"format":"{x}","x":"1","separator":","}`}, "has no effect without a repeat above 1", }, "separator with an explicit repeat of 1": { - map[string]string{"a": `{"format":"{x}","x":["1"],"repeat":1,"separator":","}`}, + map[string]string{"a": `{"format":"{x}","x":"1","separator":","}`}, "has no effect without a repeat above 1", }, "weight outside a choice": { @@ -85,38 +85,38 @@ func TestNewErrors(t *testing.T) { "weight only skews a choice's items", }, "weight used as a field": { - map[string]string{"a": `{"format":"{name} {weight}kg","name":["Anvil"],"weight":["7"]}`}, + map[string]string{"a": `{"format":"{name} {weight}kg","name":"Anvil","weight":["7"]}`}, "can never be a field", }, "an option name used as a token": { - map[string]string{"a": `{"format":"{weight}"}`}, + map[string]string{"a": `"{weight}"`}, `"weight" is an option and can never be a field`, }, "category name with a dot": { - map[string]string{"a.b": `["1"]`}, + map[string]string{"a.b": `"1"`}, `category "a.b" contains "."`, }, "folder name with a dot": { - map[string]string{"a.b/cat": `["1"]`}, + map[string]string{"a.b/cat": `"1"`}, `/a.b: folder "a.b" contains "."`, }, // The token grammar reserves three more characters. A name carrying one // still resolves by dot path, but no format can name it, so it is rejected // where it is authored rather than at the token that cannot reach it. "field name with a pipe": { - map[string]string{"a": `{"format":"{x}","x":["1"],"b|c":["2"]}`}, + map[string]string{"a": `{"format":"{x}","x":"1","b|c":"2"}`}, `field "b|c" contains "|"`, }, "field name with a paren": { - map[string]string{"a": `{"format":"{x}","x":["1"],"b(c":["2"]}`}, + map[string]string{"a": `{"format":"{x}","x":"1","b(c":"2"}`}, `field "b(c" contains "("`, }, "field name with a closing brace": { - map[string]string{"a": `{"format":"{x}","x":["1"],"b}c":["2"]}`}, + map[string]string{"a": `{"format":"{x}","x":"1","b}c":"2"}`}, `field "b}c" contains "}"`, }, "category name with a pipe": { - map[string]string{"a|b": `["1"]`}, + map[string]string{"a|b": `"1"`}, `category "a|b" contains "|"`, }, // An empty name is not a path segment, so List never offered it — while a @@ -127,42 +127,42 @@ func TestNewErrors(t *testing.T) { `field "" is empty`, }, "folder name with a paren": { - map[string]string{"a(b/cat": `["1"]`}, + map[string]string{"a(b/cat": `"1"`}, `folder "a(b" contains "("`, }, // A repeated arm skews an alternation, which weight is the spelling for. "repeated alternation arm": { - map[string]string{"a": `{"format":"{x|x}","x":["1"]}`}, + map[string]string{"a": `{"format":"{x|x}","x":"1"}`}, `arm "x" is repeated`, }, "repeated arm among others": { - map[string]string{"a": `{"format":"{x|y|x}","x":["1"],"y":["2"]}`}, + map[string]string{"a": `{"format":"{x|y|x}","x":"1","y":"2"}`}, `arm "x" is repeated`, }, "repeated path arm": { - map[string]string{"a": `{"format":"{p.v|p.v}","p":{"format":"{v}","v":["1"]}}`}, + map[string]string{"a": `{"format":"{p.v|p.v}","p":{"format":"{v}","v":"1"}}`}, `arm "p.v" is repeated`, }, // A reference arm is the only kind that reaches the repeat check by passing // the per-arm checks rather than falling through them. "repeated reference arm": { - map[string]string{"a": `["x"]`, "b": `{"format":"{..a|..a}"}`}, + map[string]string{"a": `"x"`, "b": `"{..a|..a}"`}, `arm "..a" is repeated`, }, // An arm that is broken on its own terms is reported as that, not as a // repeat: the repeat is a consequence of the real mistake. "repeated arm with no path": { - map[string]string{"a": `{"format":"{..|..}"}`}, + map[string]string{"a": `"{..|..}"`}, "reference has no path", }, // No field can be named "", so the token is told that rather than sent to // name one — the fix "no field" points at is itself a load error. "repeated empty arm": { - map[string]string{"a": `{"format":"{|}"}`}, + map[string]string{"a": `"{|}"`}, "a name is never empty", }, "bare empty token": { - map[string]string{"a": `{"format":"[{}]","x":["1"]}`}, + map[string]string{"a": `{"format":"[{}]","x":"1"}`}, "a name is never empty", }, } @@ -182,16 +182,16 @@ func TestNewErrors(t *testing.T) { path string want string }{ - "option name as a field": {map[string]string{"a": `{"format":"{name} {Weight}kg","name":["Anvil"],"Weight":["7"]}`}, "a", "Anvil 7kg"}, - "format spelling as a field": {map[string]string{"a": `{"format":"{Format}","Format":["PDF"]}`}, "a", "PDF"}, - "hyphenated field": {map[string]string{"a": `{"format":"{x-y}","x-y":["1"]}`}, "a.x-y", "1"}, - "category named Format": {map[string]string{"Format": `["1"]`}, "Format", "1"}, - "folder named Repeat": {map[string]string{"Repeat/cat": `["1"]`}, "Repeat.cat", "1"}, - "field with a closing paren": {map[string]string{"a": `{"format":"{b)c}","b)c":["2"]}`}, "a", "2"}, - "repeat without a separator": {map[string]string{"a": `{"format":"{x}","repeat":3,"x":["1"]}`}, "a", "111"}, + "option name as a field": {map[string]string{"a": `{"format":"{name} {Weight}kg","name":"Anvil","Weight":"7"}`}, "a", "Anvil 7kg"}, + "format spelling as a field": {map[string]string{"a": `{"format":"{Format}","Format":"PDF"}`}, "a", "PDF"}, + "hyphenated field": {map[string]string{"a": `{"format":"{x-y}","x-y":"1"}`}, "a.x-y", "1"}, + "category named Format": {map[string]string{"Format": `"1"`}, "Format", "1"}, + "folder named Repeat": {map[string]string{"Repeat/cat": `"1"`}, "Repeat.cat", "1"}, + "field with a closing paren": {map[string]string{"a": `{"format":"{b)c}","b)c":"2"}`}, "a", "2"}, + "repeat without a separator": {map[string]string{"a": `{"format":"{x}","repeat":3,"x":"1"}`}, "a", "111"}, // One name in two separate tokens is two independent draws, not a repeated // arm; only a repeat within one alternation is rejected. - "one name in two tokens": {map[string]string{"a": `{"format":"{x}{x}","x":["1"]}`}, "a", "11"}, + "one name in two tokens": {map[string]string{"a": `{"format":"{x}{x}","x":"1"}`}, "a", "11"}, } for name, c := range accepted { f, err := New(WithoutShippedData(), WithDataPath(writeData(t, c.files))) @@ -212,7 +212,7 @@ func TestDeepDottedPath(t *testing.T) { // on the path are single-variant, so it resolves deterministically. f := engine(1) f.categories = map[string]node{ - "deep": compiled(t, `[{"format":"{a}","a":[{"format":"{b}","b":[{"format":"{c}","c":[{"format":"{d}","d":["leaf"]}]}]}]}]`), + "deep": compiled(t, `{"format":"{a}","a":{"format":"{b}","b":{"format":"{c}","c":{"format":"{d}","d":"leaf"}}}}`), } if got, err := f.Fake("deep.a.b.c.d"); err != nil || got != "leaf" { t.Fatalf("Fake(deep.a.b.c.d) = %q, %v, want leaf", got, err) @@ -225,7 +225,7 @@ func TestDeepDottedPath(t *testing.T) { func TestDescendIntoStringErrors(t *testing.T) { f := engine(1) - f.categories = map[string]node{"greeting": compiled(t, `["hej"]`)} + f.categories = map[string]node{"greeting": compiled(t, `"hej"`)} if _, err := f.Fake("greeting.extra"); err == nil || !strings.Contains(err.Error(), `no field "extra"`) { t.Fatalf("Fake(greeting.extra) = %v, want a no-field error", err) } @@ -235,9 +235,9 @@ func TestDescendIntoStringErrors(t *testing.T) { // one call and failing on the next: every variant must carry the rest of the path. func TestPathThroughChoice(t *testing.T) { dir := writeData(t, map[string]string{ - "every": `[{"format":"{f}","f":["1"]},{"format":"{f}","f":["2"]}]`, - "notall": `[{"format":"{f}","f":["1"]},["plain"]]`, - "some": `[{"format":"{f}","f":["1"]},{"format":"{f}","f":["2"],"extra":["x"]}]`, + "every": `[{"format":"{f}","f":"1"},{"format":"{f}","f":"2"}]`, + "notall": `[{"format":"{f}","f":"1"},"plain"]`, + "some": `[{"format":"{f}","f":"1"},{"format":"{f}","f":"2","extra":"x"}]`, }) f := newGenerator(t, dir, WithSeed(1)) for i := 0; i < 200; i++ { @@ -269,7 +269,7 @@ func TestPathThroughChoice(t *testing.T) { // wherever it appears. func TestPathKeyIsUnambiguous(t *testing.T) { dir := writeData(t, map[string]string{ - "cat": `[{"format":"{a.b}","a.b":["1"]},{"format":"{a}","a":{"format":"{b}","b":["2"]}}]`, + "cat": `[{"format":"{a.b}","a.b":"1"},{"format":"{a}","a":{"format":"{b}","b":"2"}}]`, }) _, err := New(WithoutShippedData(), WithDataPath(dir)) if err == nil || !strings.Contains(err.Error(), `field "a.b" contains "."`) { @@ -277,7 +277,7 @@ func TestPathKeyIsUnambiguous(t *testing.T) { } // The same data without the dotted key is fine, and the path resolves. f := newGenerator(t, writeData(t, map[string]string{ - "cat": `{"format":"{a.b}","a":{"format":"{b}","b":["2"]}}`, + "cat": `{"format":"{a.b}","a":{"format":"{b}","b":"2"}}`, }), WithSeed(1)) if !slices.Contains(f.List(), "cat.a.b") { t.Error("List() omits cat.a.b, which the data carries") @@ -302,8 +302,8 @@ func TestMissingFieldNamesItself(t *testing.T) { func TestCategoryRootShapes(t *testing.T) { dir := writeData(t, map[string]string{ - "obj": `{"format":"{digits(2)}"}`, // object root - "lit": `"hello"`, // bare-string root + "obj": `"{digits(2)}"`, // object root + "lit": `"hello"`, // bare-string root }) f := newGenerator(t, dir, WithSeed(1)) if got := fake(t, f, "obj"); !regexp.MustCompile(`^\d\d$`).MatchString(got) { diff --git a/loading_test.go b/loading_test.go index d8890ca..f7aadfc 100644 --- a/loading_test.go +++ b/loading_test.go @@ -13,13 +13,13 @@ import ( // folder segments — descending transparently through single-variant choices. func TestList(t *testing.T) { dir := writeData(t, map[string]string{ - "person": `{"format":"{first} {last}","first":["A"],"last":["B"]}`, + "person": `{"format":"{first} {last}","first":"A","last":"B"}`, "word": `["x", "y"]`, - "geo/city": `["Z"]`, + "geo/city": `"Z"`, // A bound {..path} reference is a render edge, not an addressable field. - "greeting": `{"format":"hej {..person.first} and {own}","own":["x"]}`, + "greeting": `{"format":"hej {..person.first} and {own}","own":"x"}`, // Only the fields every variant carries are addressable, so "extra" is not. - "coin": `[{"format":"{code}","code":["A"],"name":["Aa"]},{"format":"{code}","code":["B"],"name":["Bb"],"extra":["x"]}]`, + "coin": `[{"format":"{code}","code":"A","name":"Aa"},{"format":"{code}","code":"B","name":"Bb","extra":"x"}]`, }) got := newGenerator(t, dir, WithSeed(1)).List() want := []string{"coin", "coin.code", "coin.name", "geo.city", "greeting", "greeting.own", "person", "person.first", "person.last", "word"} @@ -66,8 +66,8 @@ func TestNewEmptyDirErrors(t *testing.T) { // namespace, so data//person.json is reachable as ".person". func TestFoldersBecomeDotPaths(t *testing.T) { dir := writeData(t, map[string]string{ - "sv_SE/greeting": `["hej"]`, - "en_US/greeting": `["hi"]`, + "sv_SE/greeting": `"hej"`, + "en_US/greeting": `"hi"`, }) f := newGenerator(t, dir, WithSeed(1)) if got := fake(t, f, "sv_SE.greeting"); got != "hej" { @@ -82,7 +82,7 @@ func TestFoldersBecomeDotPaths(t *testing.T) { // a/b/c, then deep JSON fields inside the file at the end of that path. func TestNestedFoldersAndJSON(t *testing.T) { dir := writeData(t, map[string]string{ - "a/b/c/thing": `{"format":"{x}","x":{"format":"{y}","y":{"format":"{z}","z":["leaf"]}}}`, + "a/b/c/thing": `{"format":"{x}","x":{"format":"{y}","y":{"format":"{z}","z":"leaf"}}}`, }) f := newGenerator(t, dir, WithSeed(1)) // Folders a.b.c, file thing, then JSON fields x.y.z — one continuous path. @@ -96,7 +96,7 @@ func TestNestedFoldersAndJSON(t *testing.T) { } func TestRenderingAFolderErrors(t *testing.T) { - dir := writeData(t, map[string]string{"sv_SE/greeting": `["hej"]`}) + dir := writeData(t, map[string]string{"sv_SE/greeting": `"hej"`}) f := newGenerator(t, dir, WithSeed(1)) if _, err := f.Fake("sv_SE"); err == nil { t.Fatal("Fake(folder) = nil error, want a not-a-value error") @@ -106,8 +106,8 @@ func TestRenderingAFolderErrors(t *testing.T) { // TestMultiPathLastWins loads two dirs; on a name clash the later dir wins, and // non-clashing entries from both are reachable (data combines). func TestMultiPathLastWins(t *testing.T) { - a := writeData(t, map[string]string{"greeting": `["from-a"]`, "only-a": `["a"]`}) - b := writeData(t, map[string]string{"greeting": `["from-b"]`, "only-b": `["b"]`}) + a := writeData(t, map[string]string{"greeting": `"from-a"`, "only-a": `"a"`}) + b := writeData(t, map[string]string{"greeting": `"from-b"`, "only-b": `"b"`}) f := newGeneratorN(t, []string{a, b}, WithSeed(1)) if got := fake(t, f, "greeting"); got != "from-b" { t.Fatalf("greeting = %q, want from-b (last loaded wins)", got) @@ -124,8 +124,8 @@ func TestMultiPathLastWins(t *testing.T) { // children rather than replacing wholesale: each dir adds a file to sv_SE, and // a per-file clash inside still resolves last-wins. func TestMultiPathMergesFolders(t *testing.T) { - a := writeData(t, map[string]string{"sv_SE/person": `["from-a"]`, "sv_SE/shared": `["a"]`}) - b := writeData(t, map[string]string{"sv_SE/company": `["from-b"]`, "sv_SE/shared": `["b"]`}) + a := writeData(t, map[string]string{"sv_SE/person": `"from-a"`, "sv_SE/shared": `"a"`}) + b := writeData(t, map[string]string{"sv_SE/company": `"from-b"`, "sv_SE/shared": `"b"`}) f := newGeneratorN(t, []string{a, b}, WithSeed(1)) if got := fake(t, f, "sv_SE.person"); got != "from-a" { t.Fatalf("sv_SE.person = %q, want from-a (folder merged, not replaced)", got) @@ -163,10 +163,10 @@ func TestListedPathsAllRender(t *testing.T) { // carrying no JSON never contributed a namespace, so neither may fail the load. func TestHiddenEntriesAreSkipped(t *testing.T) { dir := writeData(t, map[string]string{ - "cat": `["V"]`, - ".git/HEAD": `["ignored"]`, - ".hidden": `["ignored"]`, - "empty.folder/doc": `["ignored"]`, + "cat": `"V"`, + ".git/HEAD": `"ignored"`, + ".hidden": `"ignored"`, + "empty.folder/doc": `"ignored"`, }) if err := os.Rename(filepath.Join(dir, "empty.folder", "doc.json"), filepath.Join(dir, "empty.folder", "doc.txt")); err != nil { t.Fatal(err) diff --git a/one_spelling_test.go b/one_spelling_test.go new file mode 100644 index 0000000..e2d0fe1 --- /dev/null +++ b/one_spelling_test.go @@ -0,0 +1,48 @@ +package fejkdata + +import ( + "strings" + "testing" +) + +func TestOneItemChoiceIsRejected(t *testing.T) { + for _, src := range []string{`["x"]`, `[{"format":"{d}","d":"x"}]`, `{"format":"{w}","w":["only"]}`} { + if _, err := compile(parse(t, src)); err == nil || !strings.Contains(err.Error(), "one-item choice") { + t.Errorf("compile(%s) = %v, want the one-item choice rejected", src, err) + } + } +} + +func TestRepeatedChoiceItemIsRejected(t *testing.T) { + for src, want := range map[string]string{ + `["a", "a", "b"]`: `{ "format": "a", "weight": 2 }`, + `[{"format":"{x}","x":"1"},{"format":"{x}","x":"1"}]`: "repeats item", + `{"format":"{w}","w":["", "", "x"]}`: `{ "format": "", "weight": 2 }`, + } { + if _, err := compile(parse(t, src)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("compile(%s) = %v, want an error naming %s", src, err, want) + } + } + if _, err := compile(parse(t, `[{"format":"a","weight":2}, "b"]`)); err != nil { + t.Errorf("compile(weighted a, b) = %v", err) + } +} + +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", + } { + 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)}"`} { + if _, err := compile(parse(t, ok)); err != nil { + t.Errorf("compile(%s) = %v", ok, err) + } + } +} diff --git a/reference_test.go b/reference_test.go index 3ed55ef..7ac7b52 100644 --- a/reference_test.go +++ b/reference_test.go @@ -6,8 +6,8 @@ import "testing" // pulls a value from another via a {..path} reference resolved from the data root. func TestRootReferenceAcrossFolders(t *testing.T) { dir := writeData(t, map[string]string{ - "en_US/person": `["Pat Smith"]`, - "sv_SE/greeting": `{"format":"Hej, {..en_US.person}!"}`, + "en_US/person": `"Pat Smith"`, + "sv_SE/greeting": `"Hej, {..en_US.person}!"`, }) f := newGenerator(t, dir, WithSeed(1)) if got := fake(t, f, "sv_SE.greeting"); got != "Hej, Pat Smith!" { @@ -19,8 +19,8 @@ func TestRootReferenceAcrossFolders(t *testing.T) { // a single-variant choice and then a template field (..who.last). func TestReferenceIntoAField(t *testing.T) { dir := writeData(t, map[string]string{ - "who": `[{"format":"{first} {last}","first":["Ada"],"last":["Byron"]}]`, - "card": `{"format":"signed {..who.last}"}`, + "who": `{"format":"{first} {last}","first":"Ada","last":"Byron"}`, + "card": `"signed {..who.last}"`, }) f := newGenerator(t, dir, WithSeed(1)) if got := fake(t, f, "card"); got != "signed Byron" { @@ -32,8 +32,8 @@ func TestReferenceIntoAField(t *testing.T) { // alternation, so a field and a cross-file value share one slot. func TestReferenceInAlternation(t *testing.T) { dir := writeData(t, map[string]string{ - "far": `["X"]`, - "near": `{"format":"{here|..far}","here":["H"]}`, + "far": `"X"`, + "near": `{"format":"{here|..far}","here":"H"}`, }) f := newGenerator(t, dir, WithSeed(2)) seen := map[string]bool{} @@ -48,8 +48,8 @@ func TestReferenceInAlternation(t *testing.T) { // TestReferenceCombinesLoadedPaths is the point of references over the merge // model: data layered from two dirs can point at each other through the root. func TestReferenceCombinesLoadedPaths(t *testing.T) { - a := writeData(t, map[string]string{"en_US/word": `["river"]`}) - b := writeData(t, map[string]string{"mine/slug": `{"format":"the-{..en_US.word}"}`}) + a := writeData(t, map[string]string{"en_US/word": `"river"`}) + b := writeData(t, map[string]string{"mine/slug": `"the-{..en_US.word}"`}) f := newGeneratorN(t, []string{a, b}, WithSeed(1)) if got := fake(t, f, "mine.slug"); got != "the-river" { t.Fatalf("slug = %q, want the-river", got) @@ -60,9 +60,9 @@ func TestReferenceCombinesLoadedPaths(t *testing.T) { // linking order cannot matter. func TestReferenceChain(t *testing.T) { dir := writeData(t, map[string]string{ - "a": `{"format":"{..b}"}`, - "b": `{"format":"{..c}"}`, - "c": `["deep"]`, + "a": `"{..b}"`, + "b": `"{..c}"`, + "c": `"deep"`, }) f := newGenerator(t, dir, WithSeed(1)) if got := fake(t, f, "a"); got != "deep" { @@ -74,37 +74,37 @@ func TestReferenceChain(t *testing.T) { // fails at load, never at a random render. func TestReferenceErrors(t *testing.T) { cases := map[string]map[string]string{ - "missing target": {"card": `{"format":"{..nope.gone}"}`}, - "folder target": {"en_US/word": `["w"]`, "card": `{"format":"{..en_US}"}`}, + "missing target": {"card": `"{..nope.gone}"`}, + "folder target": {"en_US/word": `"w"`, "card": `"{..en_US}"`}, "multi-variant on the path": { - "who": `[{"format":"{f}","f":["1"]},{"format":"{f}","f":["2"]}]`, - "card": `{"format":"{..who.f}"}`, + "who": `[{"format":"{f}","f":"1"},{"format":"{f}","f":"2"}]`, + "card": `"{..who.f}"`, }, - "empty reference path": {"card": `{"format":"{..}"}`}, + "empty reference path": {"card": `"{..}"`}, // A reference that leads back to its own value never terminates at render, // so New must reject the cycle up front (direct, mutual, or chained). - "direct cycle": {"a": `{"format":"x{..a}"}`}, - "mutual cycle": {"a": `{"format":"{..b}"}`, "b": `{"format":"{..a}"}`}, - "chain cycle": {"a": `{"format":"{..b}"}`, "b": `{"format":"{..c}"}`, "c": `{"format":"{..a}"}`}, + "direct cycle": {"a": `"x{..a}"`}, + "mutual cycle": {"a": `"{..b}"`, "b": `"{..a}"`}, + "chain cycle": {"a": `"{..b}"`, "b": `"{..c}"`, "c": `"{..a}"`}, // calc renders its operands, so a cycle through one must be caught too. - "calc operand cycle": {"x": `{"format":"{calc(y)}","y":[{"format":"{..x}"}]}`}, + "calc operand cycle": {"x": `{"format":"{calc(y)}","y":"{..x}"}`}, // A field its parent's format never renders is still reachable by dot path, // so a cycle hiding in one must fail at New rather than at render. - "cycle in an unrendered field": {"cat": `{"format":"hi","x":{"format":"{..cat.x}"}}`}, + "cycle in an unrendered field": {"cat": `{"format":"hi","x":"{..cat.x}"}`}, "mutual cycle between unrendered fields": { - "cat": `{"format":"hi","x":{"format":"{..cat.y}"},"y":{"format":"{..cat.x}"}}`, + "cat": `{"format":"hi","x":"{..cat.y}","y":"{..cat.x}"}`, }, "cycle in an unrendered field of a choice arm": { - "cat": `[{"format":"hi","x":{"format":"{..cat.x}"}}]`, + "cat": `{"format":"hi","x":"{..cat.x}"}`, }, // The shipped layout puts categories in folders, so a cycle one level down // is the common case, not an edge case. - "cycle in a subfolder": {"sv_SE/a": `{"format":"x{..sv_SE.a}"}`}, - "mutual cycle within a subfolder": {"sv_SE/a": `{"format":"{..sv_SE.b}"}`, "sv_SE/b": `{"format":"{..sv_SE.a}"}`}, - "mutual cycle across two folders": {"en_US/a": `{"format":"{..sv_SE.b}"}`, "sv_SE/b": `{"format":"{..en_US.a}"}`}, + "cycle in a subfolder": {"sv_SE/a": `"x{..sv_SE.a}"`}, + "mutual cycle within a subfolder": {"sv_SE/a": `"{..sv_SE.b}"`, "sv_SE/b": `"{..sv_SE.a}"`}, + "mutual cycle across two folders": {"en_US/a": `"{..sv_SE.b}"`, "sv_SE/b": `"{..en_US.a}"`}, // ".." is reserved for bound references, so an authored key using it would // name a node nothing can reach and nothing would validate. - "field key using the reference prefix": {"cat": `{"format":"hi","..x":{"format":"{..nope}"}}`}, + "field key using the reference prefix": {"cat": `{"format":"hi","..x":"{..nope}"}`}, } for name, files := range cases { if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err == nil { @@ -119,10 +119,10 @@ func TestReferenceErrors(t *testing.T) { // starts with "." — it is skipped, not rejected. func TestDotPrefixedDataEntriesAreSkipped(t *testing.T) { f := newGenerator(t, writeData(t, map[string]string{ - "sv_SE/ok": `["fine"]`, - "sv_SE/..bad": `{"format":"{..nope}"}`, - "sv_SE/..y/ct": `{"format":"{..nope}"}`, - ".git/config": `["not data"]`, + "sv_SE/ok": `"fine"`, + "sv_SE/..bad": `"{..nope}"`, + "sv_SE/..y/ct": `"{..nope}"`, + ".git/config": `"not data"`, }), WithSeed(1)) if got := f.List(); len(got) != 1 || got[0] != "sv_SE.ok" { t.Fatalf("List() = %v, want only sv_SE.ok", got) @@ -133,7 +133,7 @@ func TestDotPrefixedDataEntriesAreSkipped(t *testing.T) { // over-rejecting: a field the format never renders may point back at its own // category, which terminates, and stays renderable by path. func TestReferenceFromUnrenderedFieldTerminates(t *testing.T) { - dir := writeData(t, map[string]string{"cat": `{"format":"hi","x":{"format":"see {..cat}"}}`}) + dir := writeData(t, map[string]string{"cat": `{"format":"hi","x":"see {..cat}"}`}) f := newGenerator(t, dir, WithSeed(1)) if got := fake(t, f, "cat"); got != "hi" { t.Fatalf("cat = %q, want hi", got) @@ -149,9 +149,9 @@ func TestReferenceFromUnrenderedFieldTerminates(t *testing.T) { func TestNewErrorIsDeterministic(t *testing.T) { cases := map[string]map[string]string{ "three bad references": { - "a": `{"format":"{..nope.one}"}`, - "b": `{"format":"{..nope.two}"}`, - "c": `{"format":"{..nope.three}"}`, + "a": `"{..nope.one}"`, + "b": `"{..nope.two}"`, + "c": `"{..nope.three}"`, }, "two bad fields in one template": { "cat": `{"format":"hi","aaa":{"no":1},"zzz":{"no":2}}`, @@ -188,12 +188,12 @@ func TestNewErrorPathIsCanonical(t *testing.T) { }{ { "cycle inside a choice arm", - map[string]string{"cat": `[{"format":"hi","x":{"format":"{..cat.x}"}}]`}, + map[string]string{"cat": `{"format":"hi","x":"{..cat.x}"}`}, "fejkdata: reference cycle: cat.x -> ..cat.x", }, { "bad reference reached through another reference", - map[string]string{"a": `{"format":"{..b}"}`, "b": `{"format":"{..nope}"}`}, + map[string]string{"a": `"{..b}"`, "b": `"{..nope}"`}, `fejkdata: b: reference {..nope}: no entry "nope"`, }, } diff --git a/shipped_data_test.go b/shipped_data_test.go index 660fccd..c1d97e3 100644 --- a/shipped_data_test.go +++ b/shipped_data_test.go @@ -26,7 +26,7 @@ func TestNewDefaultsToShippedData(t *testing.T) { } func TestWithDataPathLayersOverShipped(t *testing.T) { - dir := writeData(t, map[string]string{"sv_SE/word": `["only-mine"]`, "greeting": `["hej"]`}) + dir := writeData(t, map[string]string{"sv_SE/word": `"only-mine"`, "greeting": `"hej"`}) f, err := New(WithDataPath(dir), WithSeed(1)) if err != nil { t.Fatalf("New = %v", err) @@ -43,7 +43,7 @@ func TestWithDataPathLayersOverShipped(t *testing.T) { } func TestUserDataMayReferenceShipped(t *testing.T) { - dir := writeData(t, map[string]string{"greeting": `{"format":"Hej {..sv_SE.person}!"}`}) + dir := writeData(t, map[string]string{"greeting": `"Hej {..sv_SE.person}!"`}) f, err := New(WithDataPath(dir), WithSeed(1)) if err != nil { t.Fatalf("New = %v", err) @@ -61,7 +61,7 @@ func TestWithoutShippedDataNeedsASource(t *testing.T) { } func TestWithoutShippedDataListsOnlyOwn(t *testing.T) { - dir := writeData(t, map[string]string{"greeting": `["hej"]`}) + dir := writeData(t, map[string]string{"greeting": `"hej"`}) f := newGenerator(t, dir, WithSeed(1)) if got := f.List(); !reflect.DeepEqual(got, []string{"greeting"}) { t.Errorf("List() = %v, want only the loaded dir", got) @@ -70,10 +70,10 @@ func TestWithoutShippedDataListsOnlyOwn(t *testing.T) { func TestWithDataFS(t *testing.T) { fsys := fstest.MapFS{ - "greeting.json": {Data: []byte(`["hej"]`)}, - "nested/x.json": {Data: []byte(`{"format":"{y}","y":["z"]}`)}, - ".hidden.json": {Data: []byte(`["ignored"]`)}, - "broken/no.json": {Data: []byte(`["x"]`)}, + "greeting.json": {Data: []byte(`"hej"`)}, + "nested/x.json": {Data: []byte(`{"format":"{y}","y":"z"}`)}, + ".hidden.json": {Data: []byte(`"ignored"`)}, + "broken/no.json": {Data: []byte(`"x"`)}, } f, err := New(WithoutShippedData(), WithDataFS(fsys), WithSeed(1)) if err != nil { diff --git a/template_stability_test.go b/template_stability_test.go index 67dc7f1..4e410ed 100644 --- a/template_stability_test.go +++ b/template_stability_test.go @@ -7,16 +7,16 @@ import "testing" // cannot quietly shift the rng stream that reproducibility depends on. func TestSeededOutputIsStable(t *testing.T) { dir := writeData(t, map[string]string{ - "alt": `{"format":"{a|b}","a":["A"],"b":["B"]}`, + "alt": `{"format":"{a|b}","a":"A","b":"B"}`, "calc": `{"format":"{net} x {qty} = {calc(net * qty, 2)}","net":["19.99","5.00","100.00"],"qty":["2","3","7"]}`, - "classes": `{"format":"{digits(2)}-{int(1,9)}{int(1,9)}-{upper(2)}-{lower(2)}"}`, - "escapes": `{"format":"01Aa#{x}","x":["!"]}`, - "funcs": `{"format":"{hex(6)} {int(10,99)} {float(0,1,3)} {nanoid(5)} {seq()}"}`, - "nested": `{"format":"{outer}","outer":[{"format":"{inner}-{digits(2)}","inner":["i"]}]}`, - "ref": `{"format":"see {..alt}"}`, + "classes": `"{digits(2)}-{int(1,9)}{int(1,9)}-{upper(2)}-{lower(2)}"`, + "escapes": `{"format":"01Aa#{x}","x":"!"}`, + "funcs": `"{hex(6)} {int(10,99)} {float(0,1,3)} {nanoid(5)} {seq()}"`, + "nested": `{"format":"{outer}","outer":{"format":"{inner}-{digits(2)}","inner":"i"}}`, + "ref": `"see {..alt}"`, "repeat": `{"format":"{w}","repeat":4,"separator":",","w":["x","y","z"]}`, - "sums": `{"format":"9{d}{luhn()} {e}{ean()} {m}{mod11()}","d":["012345678901234"],"e":["123456789012"],"m":["12345678"]}`, - "weights": `[{"format":"big","weight":9},{"format":"tiny","weight":1}]`, + "sums": `{"format":"9{d}{luhn()} {e}{ean()} {m}{mod11()}","d":"012345678901234","e":"123456789012","m":"12345678"}`, + "weights": `[{"format":"big","weight":9},"tiny"]`, }) want := map[string][]string{ "alt": {"A", "B", "A", "A"}, diff --git a/template_test.go b/template_test.go index cdd6420..1f8a59a 100644 --- a/template_test.go +++ b/template_test.go @@ -74,7 +74,7 @@ func TestClassBuiltins(t *testing.T) { } func TestTextIsLiteral(t *testing.T) { - if got := mustRender(t, engine(1), `{"format":"100 Main St #1 {x}","x":["A"]}`); got != "100 Main St #1 A" { + if got := mustRender(t, engine(1), `{"format":"100 Main St #1 {x}","x":"A"}`); got != "100 Main St #1 A" { t.Fatalf("text = %q, want it verbatim", got) } } @@ -82,21 +82,21 @@ func TestTextIsLiteral(t *testing.T) { func TestBraceEscapes(t *testing.T) { f := engine(1) for src, want := range map[string]string{ - `{"format":"{{","x":["v"]}`: "{", - `{"format":"}}","x":["v"]}`: "}", - `{"format":"{{x}}","x":["v"]}`: "{x}", - `{"format":"{{{x}}}","x":["v"]}`: "{v}", - `{"format":"a{{{{b}}}}","x":["v"]}`: "a{{b}}", + `{"format":"{{","x":"v"}`: "{", + `{"format":"}}","x":"v"}`: "}", + `{"format":"{{x}}","x":"v"}`: "{x}", + `{"format":"{{{x}}}","x":"v"}`: "{v}", + `{"format":"a{{{{b}}}}","x":"v"}`: "a{{b}}", } { if got := mustRender(t, f, src); got != want { t.Errorf("render(%s) = %q, want %q", src, got, want) } } for src, want := range map[string]string{ - `{"format":"x}y"}`: "}}", - `{"format":"}"}`: "}}", - `{"format":"{a{b}","a":["Q"]}`: "'{'", - `{"format":"{x"}`: "unterminated", + `"x}y"`: "}}", + `"}"`: "}}", + `{"format":"{a{b}","a":"Q"}`: "'{'", + `"{x"`: "unterminated", } { 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) @@ -105,7 +105,7 @@ func TestBraceEscapes(t *testing.T) { } func TestTokenSubstitution(t *testing.T) { - if got := mustRender(t, engine(1), `{"format":"{x}sson","x":["Erik"]}`); got != "Eriksson" { + if got := mustRender(t, engine(1), `{"format":"{x}sson","x":"Erik"}`); got != "Eriksson" { t.Fatalf("token = %q, want Eriksson", got) } } @@ -114,7 +114,7 @@ func TestAlternationPicksOneField(t *testing.T) { f := engine(3) seen := map[string]bool{} for i := 0; i < 100; i++ { - seen[mustRender(t, f, `{"format":"{a|b}","a":["A"],"b":["B"]}`)] = true + seen[mustRender(t, f, `{"format":"{a|b}","a":"A","b":"B"}`)] = true } if !seen["A"] || !seen["B"] || len(seen) != 2 { t.Fatalf("alternation produced %v, want both A and B", seen) @@ -134,7 +134,7 @@ func TestWeightSkewsDistribution(t *testing.T) { f := engine(9) heavy := 0 for i := 0; i < 1000; i++ { - if mustRender(t, f, `[{"format":"H","weight":10},{"format":"L"}]`) == "H" { + if mustRender(t, f, `[{"format":"H","weight":10},"L"]`) == "H" { heavy++ } } @@ -173,10 +173,10 @@ func TestFunctionTokenLuhn(t *testing.T) { // buffer, so a value is never re-rendered. Bodies are escaped to fix input. f := engine(1) cases := map[string]string{ - `{"format":"811218987{luhn()}"}`: "8112189876", // personnummer body - `{"format":"7992739871{luhn()}"}`: "79927398713", // classic Luhn vector - `{"format":"811218-987{luhn()}"}`: "811218-9876", // '-' skipped, kept - `{"format":"{n}{luhn()}","n":["811218987"]}`: "8112189876", // over a rendered token + `"811218987{luhn()}"`: "8112189876", // personnummer body + `"7992739871{luhn()}"`: "79927398713", // classic Luhn vector + `"811218-987{luhn()}"`: "811218-9876", // '-' skipped, kept + `{"format":"{n}{luhn()}","n":"811218987"}`: "8112189876", // over a rendered token } for tmpl, want := range cases { if got := mustRender(t, f, tmpl); got != want { @@ -189,7 +189,7 @@ func TestRecursionHasNoDepthLimit(t *testing.T) { // Build {format:{a}, a:[{format:{a}, a:[ ... "deep" ]]}} 50 levels deep. tmpl := `"deep"` for i := 0; i < 50; i++ { - tmpl = `{"format":"{a}","a":[` + tmpl + `]}` + tmpl = `{"format":"{a}","a":` + tmpl + `}` } if got := mustRender(t, engine(1), tmpl); got != "deep" { t.Fatalf("deep recursion = %q, want deep", got) @@ -200,51 +200,56 @@ func TestCompileErrors(t *testing.T) { // Every structural problem is caught up front, at compile/New time, never // deferred to a random render that happens to hit the bad branch. for _, bad := range []string{ - `{"x":["Q"]}`, // object without "format" - `{"format":"{y}","x":1}`, // a field is a bare number - `[1, 2]`, // a choice of numbers - `5`, // unsupported node type - `[]`, // empty choice - `{"format":"{x"}`, // unterminated brace - `{"format":"x}y"}`, // a lone } must be written }} - `{"format":"{a{b}","a":["Q"]}`, // a brace inside a token - `"{x}"`, // a bare string has no fields to name - `{"format":"{digits(0)}"}`, // count must be positive - `{"format":"{upper(x)}"}`, // count must be an integer - `{"format":"{lower()}"}`, // wrong arity - `{"format":"{y}","x":["Q"]}`, // token names a missing field - `{"format":"{}"}`, // empty token name - `{"format":"{a|}","a":["Q"]}`, // empty alternation segment - `[{"format":"A","weight":-1},{"format":"B"}]`, // negative weight + `{"x":"Q"}`, // object without "format" + `{"format":"{y}","x":1}`, // a field is a bare number + `[1, 2]`, // a choice of numbers + `5`, // unsupported node type + `[]`, // empty choice + `"{x"`, // unterminated brace + `"x}y"`, // a lone } must be written }} + `["x"]`, // a one-item choice is its item + `["a","a"]`, // a repeated item is a weight + `{"format":"x"}`, // an object holding only a format is a string + `[{"format":"a","weight":1},"b"]`, // weight 1 is the default + `{"format":"{x}","x":"v","repeat":1}`, // repeat 1 is the default + `{"format":"{a{b}","a":"Q"}`, // a brace inside a token + `"{x}"`, // a bare string has no fields to name + `"{digits(0)}"`, // count must be positive + `"{upper(x)}"`, // count must be an integer + `"{lower()}"`, // wrong arity + `{"format":"{y}","x":"Q"}`, // token names a missing field + `"{}"`, // empty token name + `{"format":"{a|}","a":"Q"}`, // empty alternation segment + `[{"format":"A","weight":-1},"B"]`, // negative weight `[{"format":"A","weight":0},{"format":"B","weight":0}]`, // weights sum to zero `[{"format":"A","weight":1e308},{"format":"B","weight":1e308}]`, // weights overflow to +Inf - `[{"format":"A","weight":"heavy"}]`, // non-numeric weight + `{"format":"A","weight":"heavy"}`, // non-numeric weight `{"format":"x","repeat":0}`, // repeat below 1 `{"format":"x","repeat":-2}`, // negative repeat `{"format":"x","repeat":1.5}`, // non-integer repeat `{"format":"x","repeat":"two"}`, // non-numeric repeat `{"format":"x","repeat":2,"separator":5}`, // non-string separator - `{"format":"{nope()}"}`, // unknown function - `{"format":"{luhn(x)}"}`, // function given args it takes none of - `{"format":"{luhn(}"}`, // malformed function token - `{"format":"{int(1)}"}`, // wrong arity - `{"format":"{int(a,b)}"}`, // non-integer args - `{"format":"{int(5,1)}"}`, // min > max - `{"format":"{hex(0)}"}`, // count must be positive - `{"format":"{nanoid(-1)}"}`, // negative count - `{"format":"{base64(0)}"}`, // count must be positive - `{"format":"{float(1,2)}"}`, // wrong arity - `{"format":"{float(1,2,-1)}"}`, // negative decimals - `{"format":"{iban(US)}"}`, // unsupported country - `{"format":"{seq(a,b)}"}`, // seq takes at most one name - `{"format":"{calc()}"}`, // calc needs an expression - `{"format":"{calc(1 +)}"}`, // dangling operator - `{"format":"{calc((1 + 2)}"}`, // unbalanced parenthesis - `{"format":"{calc(1 2)}"}`, // two operands, no operator - `{"format":"{calc(price)}"}`, // operand names no field - `{"format":"{calc(1, 2, 3)}"}`, // too many args - `{"format":"{calc(1, x)}"}`, // decimals arg not an integer - `{"format":"{calc(1, -1)}"}`, // decimals negative + `"{nope()}"`, // unknown function + `"{luhn(x)}"`, // function given args it takes none of + `"{luhn(}"`, // malformed function token + `"{int(1)}"`, // wrong arity + `"{int(a,b)}"`, // non-integer args + `"{int(5,1)}"`, // min > max + `"{hex(0)}"`, // count must be positive + `"{nanoid(-1)}"`, // negative count + `"{base64(0)}"`, // count must be positive + `"{float(1,2)}"`, // wrong arity + `"{float(1,2,-1)}"`, // negative decimals + `"{iban(US)}"`, // unsupported country + `"{seq(a,b)}"`, // seq takes at most one name + `"{calc()}"`, // calc needs an expression + `"{calc(1 +)}"`, // dangling operator + `"{calc((1 + 2)}"`, // unbalanced parenthesis + `"{calc(1 2)}"`, // two operands, no operator + `"{calc(price)}"`, // operand names no field + `"{calc(1, 2, 3)}"`, // too many args + `"{calc(1, x)}"`, // decimals arg not an integer + `"{calc(1, -1)}"`, // decimals negative } { if _, err := compile(parse(t, bad)); err == nil { t.Errorf("compile(%s) = nil error, want error", bad) @@ -255,7 +260,7 @@ func TestCompileErrors(t *testing.T) { func TestFakePathNavigation(t *testing.T) { f := engine(1) f.categories = map[string]node{ - "addr": compiled(t, `[{"format":"{street}","street":["Main"]}]`), + "addr": compiled(t, `{"format":"{street}","street":"Main"}`), } if got, err := f.Fake("addr"); err != nil || !strings.Contains(got, "Main") { t.Fatalf("Fake(addr) = %q, %v", got, err) @@ -288,7 +293,7 @@ func TestGrowIsALowerBound(t *testing.T) { "9{d}{luhn()}", "{a|b} and {a|b}", } { - src := `{"format":` + quote(format) + `,"x":["1"],"a":["A"],"b":["B"],"d":["012345678901234"]}` + src := `{"format":` + quote(format) + `,"x":"1","a":"A","b":"B","d":"012345678901234"}` tmpl, ok := compiled(t, src).(*template) if !ok { t.Fatalf("format %q did not compile to a template", format)