GNU-style CLI flags, embedded data, and a literal format grammar #3

Merged
lilleman merged 38 commits from cli-and-syntax into main 2026-09-03 14:21:08 +02:00
11 changed files with 315 additions and 281 deletions
Showing only changes of commit de8c1d13e9 - Show all commits
+4 -9
View File
@@ -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")
}
+45 -59
View File
@@ -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)
+27 -27
View File
@@ -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 {
+18 -18
View File
@@ -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)
}
+42 -42
View File
@@ -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) {
+16 -16
View File
@@ -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/<loc>/person.json is reachable as "<loc>.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)
+48
View File
@@ -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)
}
}
}
+37 -37
View File
@@ -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"`,
},
}
+7 -7
View File
@@ -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 {
+8 -8
View File
@@ -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"},
+63 -58
View File
@@ -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)