From fcc58a74ab294d29a1cafaa8d8b0d83ab2895c80 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 2 Sep 2026 18:24:58 +0200 Subject: [PATCH] Tests for reference sigils: / the root, . this folder, .. the folder above --- bound_test.go | 60 +++++++++---------- edge_test.go | 6 +- loading_test.go | 6 +- reference_test.go | 119 ++++++++++++++++++++++++++----------- shipped_data_test.go | 2 +- template_stability_test.go | 2 +- transform_test.go | 2 +- 7 files changed, 122 insertions(+), 75 deletions(-) diff --git a/bound_test.go b/bound_test.go index 89f6d2a..f4891c8 100644 --- a/bound_test.go +++ b/bound_test.go @@ -92,13 +92,13 @@ func TestReferenceNamingABoundLevelIsRejected(t *testing.T) { // afresh beside the path that reads its held draw — the same overlap by // another spelling. rejected := map[string]string{ - "reference names the head": `{"format":"{p.first}|{..cat.p}","p":{"format":"{first}","first":["Anna","Bo"]}}`, - "reference names the leaf": `{"format":"{p.addr}|{..cat.p.addr}","p":{"format":"x","addr":["A","B","C","D"]}}`, + "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}"}`, + `"inner":"{/cat.p}"}`, } for name, file := range rejected { _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file}))) @@ -108,7 +108,7 @@ func TestReferenceNamingABoundLevelIsRejected(t *testing.T) { } // A reference to anything this format does not bind is untouched. if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "cat": `{"format":"{p.first} {..surname}","p":{"format":"{first}","first":["Anna","Bo"]}}`, + "cat": `{"format":"{p.first} {/surname}","p":{"format":"{first}","first":["Anna","Bo"]}}`, "surname": `["Eriksson","Lindqvist"]`, }))); err != nil { t.Errorf("New = %v, want a reference outside the bound level accepted", err) @@ -120,7 +120,7 @@ func TestCycleReachedOnlyByAPathTokenIsRejected(t *testing.T) { // cycle walk has to follow it there. A head whose own format names nothing // would otherwise hide the cycle until render, where it is fatal. _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "a": `{"format":"{p.x}","p":{"format":"static","x":"{..a}"}}`, + "a": `{"format":"{p.x}","p":{"format":"static","x":"{/a}"}}`, }))) if err == nil || !strings.Contains(err.Error(), "reference cycle") { t.Fatalf("New = %v, want the cycle through {p.x} rejected", err) @@ -131,7 +131,7 @@ func TestALevelRenderedOnlyByAPathTokenIsHeld(t *testing.T) { // {p.a} renders q, so it is a route to the level {q.x} holds — even though p's // own format names nothing. _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "thing": `{"format":"{p.a} {q.x}","p":{"format":"static","a":"{..thing.q}"},` + + "thing": `{"format":"{p.a} {q.x}","p":{"format":"static","a":"{/thing.q}"},` + `"q":{"format":"{x}","x":["1","2"]}}`, }))) if err == nil || !strings.Contains(err.Error(), "reads a path into") { @@ -151,14 +151,14 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) { }{ "a reference beside the operand": { map[string]string{ - "cat": `{"format":"{..cat.net} x 2 = {calc(net * 2, 2)}","net":["10.00","20.00"]}`, + "cat": `{"format":"{/cat.net} x 2 = {calc(net * 2, 2)}","net":["10.00","20.00"]}`, }, - `{..cat.net} renders "net"`, + `{/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":"{/cat.net}"}`, }, `{q} renders "net"`, }, @@ -167,7 +167,7 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) { "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":"{/cat.n}"}`, }, `{q} renders "n"`, }, @@ -176,7 +176,7 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) { "an operand reaching another operand": { map[string]string{ "cat": `{"format":"{calc(a + b, 0)}","a":{"format":"{x}","x":["1","2"]},` + - `"b":"{..cat.a}"}`, + `"b":"{/cat.a}"}`, }, `calc operand "b" renders "a"`, }, @@ -185,15 +185,15 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) { // 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}",` + + "cat": `{"format":"{calc(net * 2, 2)}|{/cat.net.v}",` + `"net":{"format":"{v}","v":["10.00","20.00"]}}`, }, - `{..cat.net.v} renders "net"`, + `{/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":"{/cat.net.v}"}`, }, `{q} renders "net"`, }, @@ -203,7 +203,7 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) { "a violation on the second of two held heads": { map[string]string{ "cat": `{"format":"{calc(a + b, 0)} {w}","a":{"format":"{x}","x":["1","2"]},` + - `"b":{"format":"{y}","y":["3","4"]},"w":"{..cat.b}"}`, + `"b":{"format":"{y}","y":["3","4"]},"w":"{/cat.b}"}`, }, `{w} renders "b"`, }, @@ -230,24 +230,24 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) { "a reference to a sibling the operand never renders": { "cat": `{"format":"{calc(net * 2, 2)} {unit}",` + `"net":{"format":"{v}","v":["1","2"],"spare":["kg","lb"]},` + - `"unit":"{..cat.net.spare}"}`, + `"unit":"{/cat.net.spare}"}`, }, // Two operands drawing from one source are two names, so two draws: each is // held under its own name and shown once, and neither can disagree. "two operands sharing one source": { "die": `["1","2","3","4","5","6"]`, - "cat": `{"format":"{d1} + {d2} = {calc(d1 + d2, 0)}","d1":"{..die}",` + - `"d2":"{..die}"}`, + "cat": `{"format":"{d1} + {d2} = {calc(d1 + d2, 0)}","d1":"{/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. "a reference to what an operand renders through": { "common": `["1","2"]`, - "cat": `{"format":"{calc(net * 2, 2)} {..common}","net":"{..common}"}`, + "cat": `{"format":"{calc(net * 2, 2)} {/common}","net":"{/common}"}`, }, // A fixed string cannot disagree with itself, so it needs no fence. "a literal operand named twice": { - "cat": `{"format":"{calc(n * 2, 0)} {..cat.n}","n":"5"}`, + "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. @@ -269,11 +269,11 @@ func TestAPathReachesEveryVariantItMightDraw(t *testing.T) { // to a held level disagrees silently. rejected := map[string]struct{ file, want string }{ "a cycle in a later variant": { - `{"format":"{p.x}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{..cat}"}]}`, + `{"format":"{p.x}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{/cat}"}]}`, "reference cycle", }, "a second route in a later variant": { - `{"format":"{p.x} {q.y}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{..cat.q}"}],` + + `{"format":"{p.x} {q.y}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{/cat.q}"}],` + `"q":{"format":"{y}","y":["1","2"]}}`, "reads a path into", }, @@ -292,9 +292,9 @@ func TestALevelAPathNeverRendersIsAccepted(t *testing.T) { // 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"]}}`, + `"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"]}}`, + `"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 { @@ -309,9 +309,9 @@ func TestADeepDiamondChainLoads(t *testing.T) { 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) + `{"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 { t.Fatalf("New = %v, want a deep diamond chain to load", err) } @@ -323,7 +323,7 @@ func TestASharedNodeIsWalkedOnce(t *testing.T) { // 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}"}}`, + `"q":{"format":"{a}{b}","a":"{/shared}","b":"{/shared}"}}`, "shared": `"x"`, })), WithSeed(1)) if err != nil { @@ -351,7 +351,7 @@ func TestReferenceToAMatchingStringIsAccepted(t *testing.T) { // 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"}}`, + "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) @@ -667,7 +667,7 @@ func TestCycleThroughAPathTokenIsRejected(t *testing.T) { // must be caught at New. Reaching render would be fatal: the recursion never // terminates, and a stack overflow cannot be recovered. _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "a": `{"format":"{p.x}","p":{"format":"{x}","x":"{..a}"}}`, + "a": `{"format":"{p.x}","p":{"format":"{x}","x":"{/a}"}}`, }))) if err == nil || !strings.Contains(err.Error(), "reference cycle") { t.Fatalf("New = %v, want the cycle through {p.x} rejected", err) diff --git a/edge_test.go b/edge_test.go index eee05c8..bdede29 100644 --- a/edge_test.go +++ b/edge_test.go @@ -120,7 +120,7 @@ func TestNewErrors(t *testing.T) { `category "a|b" contains "|"`, }, // An empty name is not a path segment, so List never offered it — while a - // bare {}, a trailing dot in Fake("a.") and a {..a.} reference all reached + // bare {}, a trailing dot in Fake("a.") and a {/a.} reference all reached // it. The engine accepted spellings it would never advertise. "empty field name": { map[string]string{"a": `{"format":"[{}]","":"VALUE"}`}, @@ -146,13 +146,13 @@ func TestNewErrors(t *testing.T) { // A reference arm is the only kind that reaches the repeat check by passing // the per-arm checks rather than falling through them. "repeated reference arm": { - map[string]string{"a": `"x"`, "b": `"{..a|..a}"`}, + map[string]string{"a": `"x"`, "b": `"{/a|..a}"`}, `arm "..a" is repeated`, }, // An arm that is broken on its own terms is reported as that, not as a // repeat: the repeat is a consequence of the real mistake. "repeated arm with no path": { - map[string]string{"a": `"{..|..}"`}, + map[string]string{"a": `"{/|..}"`}, "reference has no path", }, // No field can be named "", so the token is told that rather than sent to diff --git a/loading_test.go b/loading_test.go index 9c711f7..b590b6c 100644 --- a/loading_test.go +++ b/loading_test.go @@ -16,8 +16,8 @@ func TestList(t *testing.T) { "person": `{"format":"{first} {last}","first":"A","last":"B"}`, "word": `["x", "y"]`, "geo/city": `"Z"`, - // A bound {..path} reference is a render edge, not an addressable field. - "greeting": `{"format":"hej {..person.first} and {own}","own":"x"}`, + // A bound {/path} reference is a render edge, not an addressable field. + "greeting": `{"format":"hej {/person.first} and {own}","own":"x"}`, // Only the fields every variant carries are addressable, so "extra" is not. "coin": `[{"format":"{code}","code":"A","name":"Aa"},{"format":"{code}","code":"B","name":"Bb","extra":"x"}]`, }) @@ -186,7 +186,7 @@ func TestRepeatProductAlongAPathIsCapped(t *testing.T) { for name, files := range map[string]map[string]string{ "nested": {"cat": `{"format":"{a}","repeat":2048,"a":{"format":"{b}","repeat":2048,"b":"x"}}`}, "through a reference": { - "a": `{"format":"{..b}","repeat":2048}`, + "a": `{"format":"{/b}","repeat":2048}`, "b": `{"format":"x","repeat":2048}`, }, } { diff --git a/reference_test.go b/reference_test.go index 90d35b6..ff59bcd 100644 --- a/reference_test.go +++ b/reference_test.go @@ -6,11 +6,11 @@ import ( ) // TestRootReferenceAcrossFolders is the headline case: a category in one folder -// 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) { dir := writeData(t, map[string]string{ "en_US/person": `"Pat Smith"`, - "sv_SE/greeting": `"Hej, {..en_US.person}!"`, + "sv_SE/greeting": `"Hej, {/en_US.person}!"`, }) f := newGenerator(t, dir, WithSeed(1)) if got := fake(t, f, "sv_SE.greeting"); got != "Hej, Pat Smith!" { @@ -22,7 +22,7 @@ func TestRootReferenceAcrossFolders(t *testing.T) { func TestReferenceIntoAField(t *testing.T) { dir := writeData(t, map[string]string{ "who": `{"format":"{first} {last}","first":"Ada","last":"Byron"}`, - "card": `"signed {..who.last}"`, + "card": `"signed {/who.last}"`, }) f := newGenerator(t, dir, WithSeed(1)) if got := fake(t, f, "card"); got != "signed Byron" { @@ -51,7 +51,7 @@ func TestReferenceInAlternation(t *testing.T) { // model: data layered from two dirs can point at each other through the root. func TestReferenceCombinesLoadedPaths(t *testing.T) { a := writeData(t, map[string]string{"en_US/word": `"river"`}) - b := writeData(t, map[string]string{"mine/slug": `"the-{..en_US.word}"`}) + b := writeData(t, map[string]string{"mine/slug": `"the-{/en_US.word}"`}) f := newGeneratorN(t, []string{a, b}, WithSeed(1)) if got := fake(t, f, "mine.slug"); got != "the-river" { t.Fatalf("slug = %q, want the-river", got) @@ -62,8 +62,8 @@ func TestReferenceCombinesLoadedPaths(t *testing.T) { // linking order cannot matter. func TestReferenceChain(t *testing.T) { dir := writeData(t, map[string]string{ - "a": `"{..b}"`, - "b": `"{..c}"`, + "a": `"{/b}"`, + "b": `"{/c}"`, "c": `"deep"`, }) f := newGenerator(t, dir, WithSeed(1)) @@ -76,37 +76,37 @@ func TestReferenceChain(t *testing.T) { // fails at load, never at a random render. func TestReferenceErrors(t *testing.T) { cases := map[string]map[string]string{ - "missing target": {"card": `"{..nope.gone}"`}, - "folder target": {"en_US/word": `"w"`, "card": `"{..en_US}"`}, + "missing target": {"card": `"{/nope.gone}"`}, + "folder target": {"en_US/word": `"w"`, "card": `"{/en_US}"`}, "a variant on the path lacks the field": { "who": `[{"format":"{f}","f":"1"},{"format":"{g}","g":"2"}]`, - "card": `"{..who.f}"`, + "card": `"{/who.f}"`, }, - "empty reference path": {"card": `"{..}"`}, + "empty reference path": {"card": `"{/}"`}, // A reference that leads back to its own value never terminates at render, // so New must reject the cycle up front (direct, mutual, or chained). - "direct cycle": {"a": `"x{..a}"`}, - "mutual cycle": {"a": `"{..b}"`, "b": `"{..a}"`}, - "chain cycle": {"a": `"{..b}"`, "b": `"{..c}"`, "c": `"{..a}"`}, + "direct cycle": {"a": `"x{/a}"`}, + "mutual cycle": {"a": `"{/b}"`, "b": `"{/a}"`}, + "chain cycle": {"a": `"{/b}"`, "b": `"{/c}"`, "c": `"{/a}"`}, // calc renders its operands, so a cycle through one must be caught too. - "calc operand cycle": {"x": `{"format":"{calc(y)}","y":"{..x}"}`}, + "calc operand cycle": {"x": `{"format":"{calc(y)}","y":"{/x}"}`}, // A field its parent's format never renders is still reachable by dot path, // so a cycle hiding in one must fail at New rather than at render. - "cycle in an unrendered field": {"cat": `{"format":"hi","x":"{..cat.x}"}`}, + "cycle in an unrendered field": {"cat": `{"format":"hi","x":"{/cat.x}"}`}, "mutual cycle between unrendered fields": { - "cat": `{"format":"hi","x":"{..cat.y}","y":"{..cat.x}"}`, + "cat": `{"format":"hi","x":"{/cat.y}","y":"{/cat.x}"}`, }, "cycle in an unrendered field of a choice arm": { - "cat": `{"format":"hi","x":"{..cat.x}"}`, + "cat": `{"format":"hi","x":"{/cat.x}"}`, }, // The shipped layout puts categories in folders, so a cycle one level down // is the common case, not an edge case. - "cycle in a subfolder": {"sv_SE/a": `"x{..sv_SE.a}"`}, - "mutual cycle within a subfolder": {"sv_SE/a": `"{..sv_SE.b}"`, "sv_SE/b": `"{..sv_SE.a}"`}, - "mutual cycle across two folders": {"en_US/a": `"{..sv_SE.b}"`, "sv_SE/b": `"{..en_US.a}"`}, + "cycle in a subfolder": {"sv_SE/a": `"x{/sv_SE.a}"`}, + "mutual cycle within a subfolder": {"sv_SE/a": `"{/sv_SE.b}"`, "sv_SE/b": `"{/sv_SE.a}"`}, + "mutual cycle across two folders": {"en_US/a": `"{/sv_SE.b}"`, "sv_SE/b": `"{/en_US.a}"`}, // ".." is reserved for bound references, so an authored key using it would // name a node nothing can reach and nothing would validate. - "field key using the reference prefix": {"cat": `{"format":"hi","..x":"{..nope}"}`}, + "field key using the reference prefix": {"cat": `{"format":"hi","..x":"{/nope}"}`}, } for name, files := range cases { if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err == nil { @@ -122,8 +122,8 @@ func TestReferenceErrors(t *testing.T) { func TestDotPrefixedDataEntriesAreSkipped(t *testing.T) { f := newGenerator(t, writeData(t, map[string]string{ "sv_SE/ok": `"fine"`, - "sv_SE/..bad": `"{..nope}"`, - "sv_SE/..y/ct": `"{..nope}"`, + "sv_SE/..bad": `"{/nope}"`, + "sv_SE/..y/ct": `"{/nope}"`, ".git/config": `"not data"`, }), WithSeed(1)) if got := f.List(); len(got) != 1 || got[0] != "sv_SE.ok" { @@ -135,7 +135,7 @@ func TestDotPrefixedDataEntriesAreSkipped(t *testing.T) { // over-rejecting: a field the format never renders may point back at its own // category, which terminates, and stays renderable by path. func TestReferenceFromUnrenderedFieldTerminates(t *testing.T) { - dir := writeData(t, map[string]string{"cat": `{"format":"hi","x":"see {..cat}"}`}) + dir := writeData(t, map[string]string{"cat": `{"format":"hi","x":"see {/cat}"}`}) f := newGenerator(t, dir, WithSeed(1)) if got := fake(t, f, "cat"); got != "hi" { t.Fatalf("cat = %q, want hi", got) @@ -151,9 +151,9 @@ func TestReferenceFromUnrenderedFieldTerminates(t *testing.T) { func TestNewErrorIsDeterministic(t *testing.T) { cases := map[string]map[string]string{ "three bad references": { - "a": `"{..nope.one}"`, - "b": `"{..nope.two}"`, - "c": `"{..nope.three}"`, + "a": `"{/nope.one}"`, + "b": `"{/nope.two}"`, + "c": `"{/nope.three}"`, }, "two bad fields in one template": { "cat": `{"format":"hi","aaa":{"no":1},"zzz":{"no":2}}`, @@ -180,7 +180,7 @@ func TestNewErrorIsDeterministic(t *testing.T) { } // TestNewErrorPathIsCanonical pins the node path a load error names: a choice arm -// adds no segment, and a bound {..path} reference is not a containment segment at +// adds no segment, and a bound {/path} reference is not a containment segment at // all, so a bad reference is reported against the node that holds it. func TestNewErrorPathIsCanonical(t *testing.T) { cases := []struct { @@ -190,13 +190,13 @@ func TestNewErrorPathIsCanonical(t *testing.T) { }{ { "cycle inside a choice arm", - map[string]string{"cat": `{"format":"hi","x":"{..cat.x}"}`}, + map[string]string{"cat": `{"format":"hi","x":"{/cat.x}"}`}, "fejkdata: reference cycle: cat.x -> ..cat.x", }, { "bad reference reached through another reference", - map[string]string{"a": `"{..b}"`, "b": `"{..nope}"`}, - `fejkdata: b: reference {..nope}: no entry "nope"`, + map[string]string{"a": `"{/b}"`, "b": `"{/nope}"`}, + `fejkdata: b: reference {/nope}: no entry "nope"`, }, } for _, c := range cases { @@ -214,7 +214,7 @@ func TestNewErrorPathIsCanonical(t *testing.T) { func TestReferencePathIsHeld(t *testing.T) { dir := writeData(t, map[string]string{ "person": `[{"format":"{first} {last}","first":"Anna","last":"Andersson"},{"format":"{first} {last}","first":"Bo","last":"Berg"}]`, - "card": `"{..person.first} {..person.last}"`, + "card": `"{/person.first} {/person.last}"`, }) f := newGenerator(t, dir, WithSeed(3)) seen := map[string]bool{} @@ -233,7 +233,7 @@ func TestReferencePathIsHeld(t *testing.T) { func TestReferenceThroughChoiceNeedsEveryVariant(t *testing.T) { _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ "who": `[{"format":"{f}{h}","f":"1","h":"x"},{"format":"{g}{h}","g":"2","h":"y"}]`, - "card": `"{..who.f}"`, + "card": `"{/who.f}"`, }))) if err == nil || !strings.Contains(err.Error(), "not every variant") { t.Fatalf("New = %v, want the missing variant named", err) @@ -243,7 +243,7 @@ func TestReferenceThroughChoiceNeedsEveryVariant(t *testing.T) { func TestBareReferenceDrawsEachTime(t *testing.T) { dir := writeData(t, map[string]string{ "die": `["1","2","3","4","5","6"]`, - "roll": `"{..die} {..die}"`, + "roll": `"{/die} {/die}"`, }) f := newGenerator(t, dir, WithSeed(1)) for i := 0; i < 50; i++ { @@ -256,8 +256,8 @@ func TestBareReferenceDrawsEachTime(t *testing.T) { func TestReferenceOverlapIsRejected(t *testing.T) { for name, file := range map[string]string{ - "head beside a path": `{"format":"{..cat.p} {..cat.p.first}","p":[{"format":"{first}","first":"A"},{"format":"{first}","first":"B"}]}`, - "sibling path beside a reference path": `{"format":"{p.first} {..cat.p.last}","p":[{"format":"{first}","first":"A","last":"1"},{"format":"{first}","first":"B","last":"2"}]}`, + "head beside a path": `{"format":"{/cat.p} {/cat.p.first}","p":[{"format":"{first}","first":"A"},{"format":"{first}","first":"B"}]}`, + "sibling path beside a reference path": `{"format":"{p.first} {/cat.p.last}","p":[{"format":"{first}","first":"A","last":"1"},{"format":"{first}","first":"B","last":"2"}]}`, } { _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file}))) if err == nil || !strings.Contains(err.Error(), "reads a path into") { @@ -265,3 +265,50 @@ func TestReferenceOverlapIsRejected(t *testing.T) { } } } + +func TestRelativeReferences(t *testing.T) { + dir := writeData(t, map[string]string{ + "sv_SE/username": `"bob"`, + "sv_SE/email": `"{.username}@example.com"`, + "sv_SE/deep/card": `"{..username} via {/sv_SE.username}"`, + "top": `"{.sv_SE.username}"`, + }) + f := newGenerator(t, dir, WithSeed(1)) + for path, want := range map[string]string{ + "sv_SE.email": "bob@example.com", + "sv_SE.deep.card": "bob via bob", + "top": "bob", + } { + if got := fake(t, f, path); got != want { + t.Errorf("%s = %q, want %q", path, got, want) + } + } +} + +func TestRelativeAndRootSpellingsBindOneDraw(t *testing.T) { + dir := writeData(t, map[string]string{ + "sv_SE/person": `[{"format":"{first} {last}","first":"Anna","last":"Andersson"},{"format":"{first} {last}","first":"Bo","last":"Berg"}]`, + "sv_SE/card": `"{.person.first} {/sv_SE.person.last}"`, + }) + f := newGenerator(t, dir, WithSeed(3)) + for i := 0; i < 50; i++ { + if got := fake(t, f, "sv_SE.card"); got != "Anna Andersson" && got != "Bo Berg" { + t.Fatalf("card = %q, want both spellings to read one person", got) + } + } +} + +func TestReferenceSigilErrors(t *testing.T) { + for name, files := range map[string]map[string]string{ + "no folder above the root": {"a": `"{..b}"`, "b": `"x"`}, + "three dots": {"a": `"{...b}"`, "b": `"x"`}, + "root sigil alone": {"a": `"{/}"`}, + "dot alone": {"a": `"{.}"`}, + "missing sibling": {"sv_SE/a": `"{.nope}"`}, + "slash in a field name": {"a": `{"format":"{x}","x":"1","a/b":"2"}`}, + } { + if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err == nil { + t.Errorf("%s: New = nil error, want a reference error", name) + } + } +} diff --git a/shipped_data_test.go b/shipped_data_test.go index 24aad04..be776c1 100644 --- a/shipped_data_test.go +++ b/shipped_data_test.go @@ -44,7 +44,7 @@ func TestWithDataPathLayersOverShipped(t *testing.T) { } func TestUserDataMayReferenceShipped(t *testing.T) { - dir := writeData(t, map[string]string{"greeting": `"Hej {..sv_SE.person}!"`}) + dir := writeData(t, map[string]string{"greeting": `"Hej {/sv_SE.person}!"`}) f, err := New(WithDataPath(dir), WithSeed(1)) if err != nil { t.Fatalf("New = %v", err) diff --git a/template_stability_test.go b/template_stability_test.go index 7204a23..e0e584f 100644 --- a/template_stability_test.go +++ b/template_stability_test.go @@ -13,7 +13,7 @@ func TestSeededOutputIsStable(t *testing.T) { "escapes": `{"format":"01Aa#{x}","x":"!"}`, "funcs": `"{hex(6)} {int(10,99)} {float(0,1,3)} {nanoid(5)} {seq()}"`, "nested": `{"format":"{outer}","outer":{"format":"{inner}-{digits(2)}","inner":"i"}}`, - "ref": `"see {..alt}"`, + "ref": `"see {/alt}"`, "repeat": `{"format":"{w}","repeat":4,"separator":",","w":["x","y","z"]}`, "sums": `{"format":"9{d}{luhn()} {e}{ean()} {m}{mod11()}","d":"012345678901234","e":"123456789012","m":"12345678"}`, "weights": `[{"format":"big","weight":9},"tiny"]`, diff --git a/transform_test.go b/transform_test.go index 37b8f96..d201427 100644 --- a/transform_test.go +++ b/transform_test.go @@ -28,7 +28,7 @@ func TestTransformReadsTheHeldDraw(t *testing.T) { func TestTransformOverAReference(t *testing.T) { dir := writeData(t, map[string]string{ "person": `[{"format":"{first} {last}","first":"Åsa","last":"Öberg"},{"format":"{first} {last}","first":"Bo","last":"Ek"}]`, - "email": `"{..person.first} {..person.last} <{lowercase(ascii(..person.first))}.{lowercase(ascii(..person.last))}@example.com>"`, + "email": `"{/person.first} {/person.last} <{lowercase(ascii(/person.first))}.{lowercase(ascii(/person.last))}@example.com>"`, }) f := newGenerator(t, dir, WithSeed(5)) for i := 0; i < 50; i++ {