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