714 lines
30 KiB
Go
714 lines
30 KiB
Go
package fejkdata
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// This file pins the one-draw rule for dotted sibling tokens. A format that
|
|
// addresses a sibling by path ({place.locality}) draws that sibling once per
|
|
// expansion, so every {place.*} token in the same format reads the same variant.
|
|
// That is what makes a correlated pair — a locality and a postal code that agree
|
|
// — expressible in data, without the row having to be the whole node.
|
|
|
|
// swedishPlaces is a two-variant sibling whose variants pair a locality with the
|
|
// postal-code prefix that really belongs to it.
|
|
const swedishPlaces = `{"format":"%s","place":[{"format":"{locality}","locality":"Stockholm","postal-code":"1{digits(2)} {digits(2)}"},{"format":"{locality}","locality":"Tranås","postal-code":"573 {digits(2)}"}]}`
|
|
|
|
// agree reports whether a rendered "postcode locality" pair is a real pairing.
|
|
var agree = regexp.MustCompile(`^(1[0-9]{2} [0-9]{2} Stockholm|573 [0-9]{2} Tranås)$`)
|
|
|
|
func places(format string) string {
|
|
return strings.Replace(swedishPlaces, "%s", format, 1)
|
|
}
|
|
|
|
func TestDottedTokenCorrelatesSiblingFields(t *testing.T) {
|
|
f := engine(1)
|
|
seen := map[string]bool{}
|
|
for i := 0; i < 500; i++ {
|
|
got := mustRender(t, f, places("{place.postal-code} {place.locality}"))
|
|
if !agree.MatchString(got) {
|
|
t.Fatalf("draw %d = %q, want a postcode and locality that pair", i, got)
|
|
}
|
|
seen[got[len(got)-4:]] = true
|
|
}
|
|
if len(seen) < 2 {
|
|
t.Fatalf("500 draws produced only %v, want both localities", seen)
|
|
}
|
|
}
|
|
|
|
func TestRenderingALevelAndReadingIntoItIsRejected(t *testing.T) {
|
|
// One draw, one spelling. A token that renders a level and one that reads a
|
|
// path into it name the same draw two ways, and only the path reads what the
|
|
// other rendered — so the pair is a load error rather than a shape whose two
|
|
// halves can disagree.
|
|
rejected := map[string]string{
|
|
"bare head beside a path": `{"format":"{p} + {p.first}","p":{"format":"{first}","first":["Anna","Bo"]}}`,
|
|
"prefix path beside a path": `{"format":"{p.addr}|{p.addr.city}","p":{"format":"{addr}","addr":[` +
|
|
`{"format":"{city}","city":["Kiruna","Boden"]},{"format":"{city}","city":["Malmö","Lund"]}]}}`,
|
|
"either order": `{"format":"{p.first} + {p}","p":{"format":"{first}","first":["Anna","Bo"]}}`,
|
|
}
|
|
for name, file := range rejected {
|
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file})))
|
|
if err == nil {
|
|
t.Errorf("%s: New = nil error, want the overlapping tokens rejected", name)
|
|
continue
|
|
}
|
|
if !strings.Contains(err.Error(), "reads a path into") {
|
|
t.Errorf("%s: New = %v, want it to name the overlap", name, err)
|
|
}
|
|
}
|
|
// The same path twice is one spelling, so it stays legal.
|
|
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
|
"cat": `{"format":"{p.first} + {p.first}","p":{"format":"{first}","first":["Anna","Bo"]}}`,
|
|
}))); err != nil {
|
|
t.Errorf("New = %v, want one path read twice accepted", err)
|
|
}
|
|
}
|
|
|
|
func TestCalcOperandNamingABoundLevelIsRejected(t *testing.T) {
|
|
// A calc operand renders its field, so naming a bound level in one is the same
|
|
// overlap as a bare token: {calc(item * 1)} renders what {item.price} reads a
|
|
// path into, and the two disagree.
|
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
|
"cat": `{"format":"{item.price}|{calc(item * 1)}","item":[` +
|
|
`{"format":"{price}","price":"10"},{"format":"{price}","price":"20"}]}`,
|
|
})))
|
|
if err == nil || !strings.Contains(err.Error(), "reads a path into") {
|
|
t.Fatalf("New = %v, want the calc operand rejected as an overlap", err)
|
|
}
|
|
// Arithmetic over fields no path names is untouched.
|
|
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
|
"cat": `{"format":"{net} x {qty} = {calc(net * qty, 2)}","net":"19.99","qty":"3"}`,
|
|
}))); err != nil {
|
|
t.Errorf("New = %v, want plain arithmetic accepted", err)
|
|
}
|
|
}
|
|
|
|
func TestReferenceNamingABoundLevelIsRejected(t *testing.T) {
|
|
// A reference can name a bound level from the data root, which renders it
|
|
// afresh beside the path that reads its held draw — the same overlap by
|
|
// another spelling.
|
|
rejected := map[string]string{
|
|
"reference names the head": `{"format":"{p.first}|{/cat.p}","p":{"format":"{first}","first":["Anna","Bo"]}}`,
|
|
"reference names the leaf": `{"format":"{p.addr}|{/cat.p.addr}","p":{"format":"x","addr":["A","B","C","D"]}}`,
|
|
// The reference need not sit in the format that binds: any field it renders
|
|
// reaches the level just the same, however deep.
|
|
"reference from a sibling field": `{"format":"{p.first}|{inner}","p":[` +
|
|
`{"format":"{first}-{last}","first":"A","last":"1"},{"format":"{first}-{last}","first":"B","last":"2"}],` +
|
|
`"inner":"{/cat.p}"}`,
|
|
}
|
|
for name, file := range rejected {
|
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file})))
|
|
if err == nil || !strings.Contains(err.Error(), "reads a path into") {
|
|
t.Errorf("%s: New = %v, want the reference rejected as an overlap", name, err)
|
|
}
|
|
}
|
|
// A reference to anything this format does not bind is untouched.
|
|
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
|
"cat": `{"format":"{p.first} {/surname}","p":{"format":"{first}","first":["Anna","Bo"]}}`,
|
|
"surname": `["Eriksson","Lindqvist"]`,
|
|
}))); err != nil {
|
|
t.Errorf("New = %v, want a reference outside the bound level accepted", err)
|
|
}
|
|
}
|
|
|
|
func TestCycleReachedOnlyByAPathTokenIsRejected(t *testing.T) {
|
|
// A path token renders what it lands on, not the level it started from, so the
|
|
// cycle walk has to follow it there. A head whose own format names nothing
|
|
// would otherwise hide the cycle until render, where it is fatal.
|
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
|
"a": `{"format":"{p.x}","p":{"format":"static","x":"{/a}"}}`,
|
|
})))
|
|
if err == nil || !strings.Contains(err.Error(), "reference cycle") {
|
|
t.Fatalf("New = %v, want the cycle through {p.x} rejected", err)
|
|
}
|
|
}
|
|
|
|
func TestALevelRenderedOnlyByAPathTokenIsHeld(t *testing.T) {
|
|
// {p.a} renders q, so it is a route to the level {q.x} holds — even though p's
|
|
// own format names nothing.
|
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
|
"thing": `{"format":"{p.a} {q.x}","p":{"format":"static","a":"{/thing.q}"},` +
|
|
`"q":{"format":"{x}","x":["1","2"]}}`,
|
|
})))
|
|
if err == nil || !strings.Contains(err.Error(), "reads a path into") {
|
|
t.Fatalf("New = %v, want the second route to q rejected", err)
|
|
}
|
|
}
|
|
|
|
// TestACalcOperandIsHeldAgainstEveryRoute pins what a {calc()} operand's hold
|
|
// fences. A calc renders its operand whole, so the draw it holds is that one node's
|
|
// value: another route conflicts only by naming that same node, and then the value
|
|
// shown is not the value computed. Two names that merely draw from one source are
|
|
// two draws, as {word} {word} is — the rule everywhere else in the engine.
|
|
func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
|
|
rejected := map[string]struct {
|
|
files map[string]string
|
|
want string // the route the error names, spelled as the author wrote it
|
|
}{
|
|
"a reference beside the operand": {
|
|
map[string]string{
|
|
"cat": `{"format":"{/cat.net} x 2 = {calc(net * 2, 2)}","net":["10.00","20.00"]}`,
|
|
},
|
|
`{/cat.net} renders "net"`,
|
|
},
|
|
"a reference one level down": {
|
|
map[string]string{
|
|
"cat": `{"format":"{calc(net * 2, 2)} {q}","net":["10.00","20.00"],` +
|
|
`"q":"{/cat.net}"}`,
|
|
},
|
|
`{q} renders "net"`,
|
|
},
|
|
// A reference ending at the operand binds the choice itself, not a variant,
|
|
// so wrapping the operand in one changes nothing.
|
|
"a reference to an operand wrapped in a choice": {
|
|
map[string]string{
|
|
"cat": `{"format":"{calc(n * 2, 2)} {q}","n":{"format":"{v}","v":["1","2"]},` +
|
|
`"q":"{/cat.n}"}`,
|
|
},
|
|
`{q} renders "n"`,
|
|
},
|
|
// The reaching route can be another operand, which is not a token — so the
|
|
// error must name it as one rather than invent a {b} the format never wrote.
|
|
"an operand reaching another operand": {
|
|
map[string]string{
|
|
"cat": `{"format":"{calc(a + b, 0)}","a":{"format":"{x}","x":["1","2"]},` +
|
|
`"b":"{/cat.a}"}`,
|
|
},
|
|
`calc operand "b" renders "a"`,
|
|
},
|
|
// Reaching *into* the operand is the same shape as naming it: the draw the
|
|
// calc holds settled that value too. The token spelling {net.v} is already
|
|
// rejected, so the reference spelling has to be.
|
|
"a reference into the operand": {
|
|
map[string]string{
|
|
"cat": `{"format":"{calc(net * 2, 2)}|{/cat.net.v}",` +
|
|
`"net":{"format":"{v}","v":["10.00","20.00"]}}`,
|
|
},
|
|
`{/cat.net.v} renders "net"`,
|
|
},
|
|
"a reference into the operand one level down": {
|
|
map[string]string{
|
|
"cat": `{"format":"{calc(net * 2, 2)}|{q}","net":{"format":"{v}","v":["10.00","20.00"]},` +
|
|
`"q":"{/cat.net.v}"}`,
|
|
},
|
|
`{q} renders "net"`,
|
|
},
|
|
// Each head is walked against its own seen set. Sharing one across heads
|
|
// would let the first head's walk mark the only route to the second's draw,
|
|
// and this violation sits on the later head in sorted order.
|
|
"a violation on the second of two held heads": {
|
|
map[string]string{
|
|
"cat": `{"format":"{calc(a + b, 0)} {w}","a":{"format":"{x}","x":["1","2"]},` +
|
|
`"b":{"format":"{y}","y":["3","4"]},"w":"{/cat.b}"}`,
|
|
},
|
|
`{w} renders "b"`,
|
|
},
|
|
}
|
|
for name, c := range rejected {
|
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, c.files)))
|
|
if err == nil || !strings.Contains(err.Error(), "a {calc()} also reads") {
|
|
t.Errorf("%s: New = %v, want the second route to the operand rejected", name, err)
|
|
continue
|
|
}
|
|
if !strings.Contains(err.Error(), c.want) {
|
|
t.Errorf("%s: New = %v, want it to name %q", name, err, c.want)
|
|
}
|
|
}
|
|
|
|
accepted := map[string]map[string]string{
|
|
// The spellings that read the one draw: the operand itself, and a bare token.
|
|
"only the operand and a bare token": {
|
|
"cat": `{"format":"{net} x 2 = {calc(net * 2, 2)}","net":["10.00","20.00"]}`,
|
|
},
|
|
// A sibling the operand's format never renders is no part of its value. A
|
|
// path head differs — a path may read into anything the level contains —
|
|
// which is why that half of the fence covers containment.
|
|
"a reference to a sibling the operand never renders": {
|
|
"cat": `{"format":"{calc(net * 2, 2)} {unit}",` +
|
|
`"net":{"format":"{v}","v":["1","2"],"spare":["kg","lb"]},` +
|
|
`"unit":"{/cat.net.spare}"}`,
|
|
},
|
|
// Two operands drawing from one source are two names, so two draws: each is
|
|
// held under its own name and shown once, and neither can disagree.
|
|
"two operands sharing one source": {
|
|
"die": `["1","2","3","4","5","6"]`,
|
|
"cat": `{"format":"{d1} + {d2} = {calc(d1 + d2, 0)}","d1":"{/die}",` +
|
|
`"d2":"{/die}"}`,
|
|
},
|
|
// Likewise a reference drawing from what the operand draws from: {/common}
|
|
// and net are two names, not two spellings of one field.
|
|
"a reference to what an operand renders through": {
|
|
"common": `["1","2"]`,
|
|
"cat": `{"format":"{calc(net * 2, 2)} {/common}","net":"{/common}"}`,
|
|
},
|
|
// A fixed string cannot disagree with itself, so it needs no fence.
|
|
"a literal operand named twice": {
|
|
"cat": `{"format":"{calc(n * 2, 0)} {/cat.n}","n":"5"}`,
|
|
},
|
|
// One node reached twice while walking the operand: the walk must not
|
|
// revisit it, and the repeat is not a second route to anything.
|
|
"an operand that renders one field twice": {
|
|
"cat": `{"format":"{calc(v * 2, 0)}","v":{"format":"{a}{a}","a":["1","2"]}}`,
|
|
},
|
|
}
|
|
for name, files := range accepted {
|
|
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err != nil {
|
|
t.Errorf("%s: New = %v, want it accepted", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAPathReachesEveryVariantItMightDraw(t *testing.T) {
|
|
// A path through a choice may land in any variant, so both walks have to see
|
|
// all of them. Reaching only the first leaves whatever hides in a later
|
|
// variant to be found at render — a cycle there is fatal, and a second route
|
|
// to a held level disagrees silently.
|
|
rejected := map[string]struct{ file, want string }{
|
|
"a cycle in a later variant": {
|
|
`{"format":"{p.x}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{/cat}"}]}`,
|
|
"reference cycle",
|
|
},
|
|
"a second route in a later variant": {
|
|
`{"format":"{p.x} {q.y}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{/cat.q}"}],` +
|
|
`"q":{"format":"{y}","y":["1","2"]}}`,
|
|
"reads a path into",
|
|
},
|
|
}
|
|
for name, c := range rejected {
|
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": c.file})))
|
|
if err == nil || !strings.Contains(err.Error(), c.want) {
|
|
t.Errorf("%s: New = %v, want it to mention %q", name, err, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestALevelAPathNeverRendersIsAccepted(t *testing.T) {
|
|
// A path token does not expand its head's format, so a reference sitting in
|
|
// that format is not a second route to anything: it is never rendered by the
|
|
// path at all. Both orders must load.
|
|
accepted := map[string]string{
|
|
"reference in the head's own format": `{"format":"{p.first} {q.a}",` +
|
|
`"p":{"format":"{first} {/thing.q}","first":["A","B"]},"q":{"format":"{a}","a":["1","2"]}}`,
|
|
"the mirror shape": `{"format":"{p.first} {q.a}",` +
|
|
`"p":{"format":"{first}","first":["A","B"]},"q":{"format":"{a} {/thing.p}","a":["1","2"]}}`,
|
|
}
|
|
for name, file := range accepted {
|
|
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"thing": file}))); err != nil {
|
|
t.Errorf("%s: New = %v, want it accepted", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestADeepDiamondChainLoads(t *testing.T) {
|
|
// A diamond chain is 2^n routes through n nodes. The search past a bound level
|
|
// must walk each node once, not once per route, or a deep chain never loads.
|
|
files := map[string]string{"l0": `"x"`}
|
|
for i := 1; i <= 30; i++ {
|
|
files[fmt.Sprintf("l%d", i)] = fmt.Sprintf(
|
|
`{"format":"{a}{b}","a":"{/l%d}","b":"{/l%d}"}`, i-1, i-1)
|
|
}
|
|
files["thing"] = `{"format":"{p.first} {/l30}","p":{"format":"x","first":["A","B"]}}`
|
|
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err != nil {
|
|
t.Fatalf("New = %v, want a deep diamond chain to load", err)
|
|
}
|
|
}
|
|
|
|
func TestASharedNodeIsWalkedOnce(t *testing.T) {
|
|
// Two fields reaching one node make the render graph a diamond, not a tree.
|
|
// The search past a bound level must take that in its stride rather than walk
|
|
// the shared node once per route.
|
|
f, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
|
"cat": `{"format":"{p.first}|{q}","p":{"format":"{first}","first":["Anna","Bo"]},` +
|
|
`"q":{"format":"{a}{b}","a":"{/shared}","b":"{/shared}"}}`,
|
|
"shared": `"x"`,
|
|
})), WithSeed(1))
|
|
if err != nil {
|
|
t.Fatalf("New = %v, want a shared node accepted", err)
|
|
}
|
|
if got, err := f.Fake("cat"); err != nil || !strings.HasSuffix(got, "|xx") {
|
|
t.Fatalf("Fake(cat) = %q, %v, want it to end in xx", got, err)
|
|
}
|
|
}
|
|
|
|
func TestTheEarlierReaderIsNamed(t *testing.T) {
|
|
// A token and a calc operand can name one level. The one the format writes
|
|
// first is the one reported, so the error points at the same place a reader
|
|
// looking at the format would start.
|
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
|
"cat": `{"format":"{calc(p * 1)} {p} {p.first}","p":{"format":"{first}","first":["1","2"]}}`,
|
|
})))
|
|
if err == nil || !strings.Contains(err.Error(), `calc operand "p"`) {
|
|
t.Fatalf("New = %v, want the calc operand named, being written first", err)
|
|
}
|
|
}
|
|
|
|
func TestReferenceToAMatchingStringIsAccepted(t *testing.T) {
|
|
// A literal renders one fixed string, so no draw of it can disagree with a
|
|
// held one. Two unrelated literals that merely spell the same text must not
|
|
// read as the same node — the trap when comparing a value type.
|
|
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
|
"cat": `{"format":"{p.city} {/other.tag}","p":{"format":"{city}","city":"Stockholm"}}`,
|
|
"other": `{"format":"x","tag":"Stockholm"}`,
|
|
}))); err != nil {
|
|
t.Fatalf("New = %v, want a reference to a matching string accepted", err)
|
|
}
|
|
}
|
|
|
|
func TestNestedChoiceDrawsOneVariant(t *testing.T) {
|
|
// A choice item may itself be a choice, so drawing a bound head unwraps until
|
|
// it reaches a value. Stopping at one level leaves a choice where the walk
|
|
// expects a template.
|
|
f := engine(21)
|
|
seen := map[string]bool{}
|
|
for i := 0; i < 200; i++ {
|
|
seen[mustRender(t, f, `{"format":"{p.x}","p":[{"format":"{x}","x":"1"},{"format":"{x}","x":"2"}]}`)] = true
|
|
}
|
|
if !seen["1"] || !seen["2"] || len(seen) != 2 {
|
|
t.Fatalf("200 draws produced %v, want 1 and 2", seen)
|
|
}
|
|
}
|
|
|
|
func TestRepeatingLevelIsNamedInTheError(t *testing.T) {
|
|
// The level carrying the repeat is the one to fix, so the error names it
|
|
// rather than the head the path started from.
|
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
|
"cat": `{"format":"[{p.a.b}]","p":{"format":"{a}","a":{"format":"{b}","repeat":3,"separator":",","b":"z"}}}`,
|
|
})))
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestPathIntoARepeatingLevelBehindAChoiceIsRejected(t *testing.T) {
|
|
// A choice of rows is the shape this feature is for, so the repeat rule has to
|
|
// reach inside one — otherwise the direct spelling is a load error and the same
|
|
// mistake behind a choice silently drops the repeat.
|
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
|
"cat": `{"format":"[{p.a}]","p":[{"format":"{a}","repeat":3,"separator":",","a":"x"},{"format":"{a}","a":"y"}]}`,
|
|
})))
|
|
if err == nil || !strings.Contains(err.Error(), "repeat") {
|
|
t.Fatalf("New = %v, want the repeat behind a choice rejected", err)
|
|
}
|
|
}
|
|
|
|
func TestPathIntoARepeatingLevelIsRejected(t *testing.T) {
|
|
// A path reads one level's draw, so it can never apply that level's repeat.
|
|
// The engine rejects options that cannot take effect, and this is one.
|
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
|
"cat": `{"format":"[{p.a}]","p":{"format":"{a}","repeat":3,"separator":",","a":"z"}}`,
|
|
})))
|
|
if err == nil || !strings.Contains(err.Error(), "repeat") {
|
|
t.Fatalf("New = %v, want a path into a repeating level rejected", err)
|
|
}
|
|
}
|
|
|
|
func TestPathIntoAPlainTemplateNamesTheMissingField(t *testing.T) {
|
|
// A head that is one template, not a choice, reports the missing segment
|
|
// directly — there are no variants to compare.
|
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
|
"cat": `{"format":"{a.nope}","a":{"format":"x","b":"1"}}`,
|
|
})))
|
|
if err == nil || !strings.Contains(err.Error(), `no field "nope"`) {
|
|
t.Fatalf("New = %v, want it to name the missing field", err)
|
|
}
|
|
}
|
|
|
|
func TestFieldWithoutAPathTokenStillDrawsEachTime(t *testing.T) {
|
|
// A format with no dotted token keeps today's behaviour: every {word} is an
|
|
// independent draw.
|
|
f := engine(3)
|
|
varied := false
|
|
for i := 0; i < 200 && !varied; i++ {
|
|
got := mustRender(t, f, `{"format":"{w}{w}{w}","w":["a","b","c","d","e"]}`)
|
|
varied = got[0] != got[1] || got[1] != got[2]
|
|
}
|
|
if !varied {
|
|
t.Fatal("200 draws of {w}{w}{w} never varied, want independent draws")
|
|
}
|
|
}
|
|
|
|
func TestTwoBoundHeadsDrawIndependently(t *testing.T) {
|
|
// Binding is per head, so two correlated sets in one format compose instead
|
|
// of needing a cross product of variants.
|
|
f := engine(4)
|
|
seen := map[string]bool{}
|
|
for i := 0; i < 400; i++ {
|
|
seen[mustRender(t, f, `{"format":"{p.v}{q.v}",
|
|
"p":[{"format":"{v}","v":"A"},{"format":"{v}","v":"B"}],
|
|
"q":[{"format":"{v}","v":"1"},{"format":"{v}","v":"2"}]}`)] = true
|
|
}
|
|
for _, want := range []string{"A1", "A2", "B1", "B2"} {
|
|
if !seen[want] {
|
|
t.Fatalf("400 draws produced %v, want every combination including %q", seen, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRepeatRedrawsTheBinding(t *testing.T) {
|
|
// The binding lives for one expansion, so each repetition is a fresh draw —
|
|
// and each repetition is internally consistent.
|
|
f := engine(5)
|
|
tmpl := strings.Replace(places("{place.postal-code} {place.locality}"), `"format":"{place.postal-code} {place.locality}"`,
|
|
`"format":"{place.postal-code} {place.locality}","repeat":6,"separator":"\n"`, 1)
|
|
varied := false
|
|
for i := 0; i < 40; i++ {
|
|
got := mustRender(t, f, tmpl)
|
|
lines := strings.Split(got, "\n")
|
|
if len(lines) != 6 {
|
|
t.Fatalf("repeat 6 produced %d lines", len(lines))
|
|
}
|
|
for _, line := range lines {
|
|
if !agree.MatchString(line) {
|
|
t.Fatalf("repetition %q does not pair", line)
|
|
}
|
|
}
|
|
if lines[0] != lines[1] || lines[1] != lines[2] {
|
|
varied = true
|
|
}
|
|
}
|
|
if !varied {
|
|
t.Fatal("40 renders of repeat 6 never varied within a render, want a redraw per repetition")
|
|
}
|
|
}
|
|
|
|
func TestNestedTemplateKeepsItsOwnBinding(t *testing.T) {
|
|
// A binding belongs to the expansion that made it: an inner template's own
|
|
// place is not the outer one's.
|
|
f := engine(6)
|
|
tmpl := `{"format":"{place.v}{inner}",
|
|
"place":[{"format":"{v}","v":"A"},{"format":"{v}","v":"B"}],
|
|
"inner":{"format":"{place.v}","place":[{"format":"{v}","v":"A"},{"format":"{v}","v":"B"}]}}`
|
|
seen := map[string]bool{}
|
|
for i := 0; i < 400; i++ {
|
|
seen[mustRender(t, f, tmpl)] = true
|
|
}
|
|
if !seen["AB"] && !seen["BA"] {
|
|
t.Fatalf("400 draws produced %v, want the inner binding to be independent", seen)
|
|
}
|
|
}
|
|
|
|
func TestBoundDrawIsSeedStable(t *testing.T) {
|
|
tmpl := places("{place.postal-code} {place.locality}")
|
|
a := mustRender(t, engine(42), tmpl)
|
|
b := mustRender(t, engine(42), tmpl)
|
|
if a != b {
|
|
t.Fatalf("same seed gave %q and %q, want an identical draw", a, b)
|
|
}
|
|
}
|
|
|
|
func TestDottedTokenErrors(t *testing.T) {
|
|
// A path token is validated where every other token is: at New, naming what
|
|
// is wrong, so a typo can never reach a render.
|
|
rejected := map[string]struct {
|
|
file string
|
|
want string
|
|
}{
|
|
"unknown head": {
|
|
`{"format":"{nope.x}","place":{"format":"{v}","v":"A"}}`,
|
|
`no field "nope"`,
|
|
},
|
|
"unknown tail": {
|
|
`{"format":"{place.nope}","place":[{"format":"{v}","v":"A"},{"format":"{v}","v":"B"}]}`,
|
|
`"nope"`,
|
|
},
|
|
"tail only some variants carry": {
|
|
`{"format":"{place.locality}","place":[{"format":"{locality}","locality":"A"},"x"]}`,
|
|
`locality`,
|
|
},
|
|
"path into a literal": {
|
|
`{"format":"{x.y}","x":"plain"}`,
|
|
`"y"`,
|
|
},
|
|
"dotted field key": {
|
|
`{"format":"[{a.b}]","a.b":"V"}`,
|
|
`contains "."`,
|
|
},
|
|
}
|
|
for name, c := range rejected {
|
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": c.file})))
|
|
if err == nil {
|
|
t.Errorf("%s: New = nil error, want it rejected at load", name)
|
|
continue
|
|
}
|
|
if !strings.Contains(err.Error(), c.want) {
|
|
t.Errorf("%s: New = %v, want it to mention %q", name, err, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSamePathReadTwiceReadsOneValue(t *testing.T) {
|
|
// A path is drawn once per expansion, so reading it twice reads one value —
|
|
// what a name shown in a display form and again in an address needs.
|
|
f := engine(7)
|
|
for i := 0; i < 300; i++ {
|
|
got := mustRender(t, f, `{"format":"{p.first}|{p.first}","p":{"format":"{first}","first":["Anna","Astrid","Elin","Karin"]}}`)
|
|
parts := strings.Split(got, "|")
|
|
if parts[0] != parts[1] {
|
|
t.Fatalf("draw %d = %q, want one value read twice", i, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDifferentTailsUnderOneHeadShareTheRow(t *testing.T) {
|
|
// Two tails of one head stay in the same variant, each keeping its own value.
|
|
f := engine(8)
|
|
for i := 0; i < 300; i++ {
|
|
got := mustRender(t, f, `{"format":"{p.a}{p.b}",
|
|
"p":[{"format":"x","a":"A","b":"1"},{"format":"y","a":"B","b":"2"}]}`)
|
|
if got != "A1" && got != "B2" {
|
|
t.Fatalf("draw %d = %q, want a row's own pair", i, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// deepPlaces nests the correlated facts a level down: the row holds an addr, and
|
|
// the addr holds the city and the zip that belongs to it.
|
|
const deepPlaces = `{"format":"%s","p":{"format":"{addr}","addr":[{"format":"{city}","city":"Stockholm","zip":"11111"},{"format":"{city}","city":"Kiruna","zip":"98100"}]}}`
|
|
|
|
func TestPathHoldsItsDrawAtEveryLevel(t *testing.T) {
|
|
// A choice below the head is drawn once too, so two paths sharing a prefix
|
|
// share every level of it — not just the row they started from.
|
|
f := engine(11)
|
|
seen := map[string]bool{}
|
|
for i := 0; i < 500; i++ {
|
|
got := mustRender(t, f, strings.Replace(deepPlaces, "%s", "{p.addr.city}/{p.addr.zip}", 1))
|
|
if got != "Stockholm/11111" && got != "Kiruna/98100" {
|
|
t.Fatalf("draw %d = %q, want a city and the zip that belongs to it", i, got)
|
|
}
|
|
seen[got] = true
|
|
}
|
|
if len(seen) < 2 {
|
|
t.Fatalf("500 draws produced only %v, want both", seen)
|
|
}
|
|
}
|
|
|
|
func TestPathHoldsItsDrawThreeLevelsDown(t *testing.T) {
|
|
// The rule does not run out at two: every level a path passes through is held.
|
|
f := engine(12)
|
|
tmpl := `{"format":"{p.geo.town.name}/{p.geo.town.zip} {p.geo.region}","p":{"format":"{geo}","geo":[{"format":"{town}","region":"Norrbotten","town":[{"format":"{name}","name":"Kiruna","zip":"98100"},{"format":"{name}","name":"Luleå","zip":"97200"}]},{"format":"{town}","region":"Skåne","town":[{"format":"{name}","name":"Malmö","zip":"21100"},{"format":"{name}","name":"Lund","zip":"22100"}]}]}}`
|
|
want := map[string]bool{
|
|
"Kiruna/98100 Norrbotten": true, "Luleå/97200 Norrbotten": true,
|
|
"Malmö/21100 Skåne": true, "Lund/22100 Skåne": true,
|
|
}
|
|
seen := map[string]bool{}
|
|
for i := 0; i < 600; i++ {
|
|
got := mustRender(t, f, tmpl)
|
|
if !want[got] {
|
|
t.Fatalf("draw %d = %q, want a town with its own zip and region", i, got)
|
|
}
|
|
seen[got] = true
|
|
}
|
|
if len(seen) != len(want) {
|
|
t.Fatalf("600 draws produced %v, want all four towns", seen)
|
|
}
|
|
}
|
|
|
|
func TestPathWalksVariantsOfDifferentShape(t *testing.T) {
|
|
// One variant holds addr as a plain template where another holds a choice.
|
|
// Both carry the path, so both must walk — the walk reads what it drew, never
|
|
// assuming a shape.
|
|
f := engine(13)
|
|
tmpl := `{"format":"{p.addr.city}/{p.addr.zip}","p":[
|
|
{"format":"{addr}","addr":{"format":"{city}","city":"Kiruna","zip":"98100"}},
|
|
{"format":"{addr}","addr":[
|
|
{"format":"{city}","city":"Malmö","zip":"21100"},
|
|
{"format":"{city}","city":"Lund","zip":"22100"}]}]}`
|
|
for i := 0; i < 400; i++ {
|
|
got := mustRender(t, f, tmpl)
|
|
if got != "Kiruna/98100" && got != "Malmö/21100" && got != "Lund/22100" {
|
|
t.Fatalf("draw %d = %q, want a city with its own zip", i, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDeepPathsUnderOneHeadStayIndependentWhereTheyDiverge(t *testing.T) {
|
|
// Two paths share only what they actually share: a common prefix is one draw,
|
|
// and each keeps its own draw past the point they part.
|
|
f := engine(14)
|
|
tmpl := `{"format":"{p.a.v}{p.b.v}","p":{"format":"x","a":[{"format":"{v}","v":"1"},{"format":"{v}","v":"2"}],"b":[{"format":"{v}","v":"1"},{"format":"{v}","v":"2"}]}}`
|
|
seen := map[string]bool{}
|
|
for i := 0; i < 400; i++ {
|
|
seen[mustRender(t, f, tmpl)] = true
|
|
}
|
|
for _, want := range []string{"11", "12", "21", "22"} {
|
|
if !seen[want] {
|
|
t.Fatalf("400 draws produced %v, want every combination including %q", seen, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEmptyPathSegmentIsRejected(t *testing.T) {
|
|
// "{a.}" and "{a..b}" are unfinished paths, and the segment naming nothing is
|
|
// reported as that rather than as a missing field. An empty name is rejected
|
|
// where it is authored, so no data can make these resolve.
|
|
rejected := map[string]string{
|
|
"trailing dot": `{"format":"[{a.}]","a":"x"}`,
|
|
"double dot": `{"format":"[{a..b}]","a":"x"}`,
|
|
}
|
|
for name, file := range rejected {
|
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file})))
|
|
if err == nil {
|
|
t.Errorf("%s: New = nil error, want the unfinished path rejected", name)
|
|
continue
|
|
}
|
|
if !strings.Contains(err.Error(), "empty segment") {
|
|
t.Errorf("%s: New = %v, want it to name the empty segment", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCycleThroughAPathTokenIsRejected(t *testing.T) {
|
|
// A path token is a render edge like any other, so a cycle routed through one
|
|
// must be caught at New. Reaching render would be fatal: the recursion never
|
|
// terminates, and a stack overflow cannot be recovered.
|
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
|
"a": `{"format":"{p.x}","p":{"format":"{x}","x":"{/a}"}}`,
|
|
})))
|
|
if err == nil || !strings.Contains(err.Error(), "reference cycle") {
|
|
t.Fatalf("New = %v, want the cycle through {p.x} rejected", err)
|
|
}
|
|
}
|
|
|
|
func TestBoundPathIsReachableByFake(t *testing.T) {
|
|
// Binding changes how a format reads a sibling, not what List and Fake offer:
|
|
// the sub-fields stay addressable on their own.
|
|
dir := writeData(t, map[string]string{"address": places("{place.postal-code} {place.locality}")})
|
|
f, err := New(WithoutShippedData(), WithDataPath(dir), WithSeed(9))
|
|
if err != nil {
|
|
t.Fatalf("New = %v", err)
|
|
}
|
|
for _, path := range []string{"address", "address.place", "address.place.locality", "address.place.postal-code"} {
|
|
if _, err := f.Fake(path); err != nil {
|
|
t.Errorf("Fake(%q) = %v, want it to render", path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRepeatedBareTokenOfAHeldNameIsRejected(t *testing.T) {
|
|
for src, want := range map[string]string{
|
|
`{"format":"{w} {w} {uppercase(w)}","w":["a","b"]}`: "write {w} once",
|
|
`{"format":"{w} {w|x} {uppercase(w)}","w":["a","b"],"x":["c","d"]}`: "write {w} once",
|
|
`{"format":"{n} + {n} = {calc(n * 2)}","n":["1","2"]}`: "write {n} once",
|
|
`{"format":"{p.a} {q} {q} {lowercase(q)}","p":{"format":"{a}","a":"1"},"q":["A","B"]}`: "write {q} once",
|
|
} {
|
|
_, err := compile(parse(t, src))
|
|
if err == nil || !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), "holds") {
|
|
t.Errorf("compile(%s) = %v, want the repeated token rejected naming %s", src, err, want)
|
|
}
|
|
}
|
|
for _, ok := range []string{
|
|
`{"format":"{w} {w}","w":["a","b"]}`,
|
|
`{"format":"{w} {uppercase(w)}","w":["a","b"]}`,
|
|
`{"format":"{net} x {qty} = {calc(net * qty, 2)}","net":["19.99","5.00"],"qty":["3","7"]}`,
|
|
`{"format":"{uppercase(w)} {uppercase(w)}","w":["a","b"]}`,
|
|
} {
|
|
if _, err := compile(parse(t, ok)); err != nil {
|
|
t.Errorf("compile(%s) = %v, want it accepted", ok, err)
|
|
}
|
|
}
|
|
}
|