diff --git a/bound_test.go b/bound_test.go new file mode 100644 index 0000000..b1f3054 --- /dev/null +++ b/bound_test.go @@ -0,0 +1,192 @@ +package fakes + +import ( + "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":{"format":"#100 00"}}, + {"format":"{locality}","locality":"Tranås","postal-code":{"format":"#5#7#3 00"}} +]}` + +// 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 TestBareTokenBindsWhenTheFormatAlsoTakesAPath(t *testing.T) { + // A head any dotted token addresses is drawn once, so a bare {place} in the + // same format reads that same draw rather than a second one. + f := engine(2) + for i := 0; i < 300; i++ { + got := mustRender(t, f, places("{place.postal-code} {place}")) + if !agree.MatchString(got) { + t.Fatalf("draw %d = %q, want the bare token to read the bound draw", i, got) + } + } +} + +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"},{"format":"x"}]}`, + `locality`, + }, + "path into a literal": { + `{"format":"{x.y}","x":"plain"}`, + `"y"`, + }, + "dotted field key": { + `{"format":"[{a.b}]","a.b":["V"]}`, + `contains a dot`, + }, + } + for name, c := range rejected { + _, err := New([]string{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 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([]string{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) + } + } +} diff --git a/edge_test.go b/edge_test.go index c83d92c..e98f24b 100644 --- a/edge_test.go +++ b/edge_test.go @@ -120,7 +120,6 @@ func TestNewErrors(t *testing.T) { }{ "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"}, - "dotted field via its token": {map[string]string{"a": `{"format":"[{a.b}]","a.b":["V"]}`}, "a", "[V]"}, "hyphenated field": {map[string]string{"a": `{"format":"{x-y}","x-y":["1"]}`}, "a.x-y", "1"}, "category named Format": {map[string]string{"Format": `["1"]`}, "Format", "1"}, "folder named Repeat": {map[string]string{"Repeat/cat": `["1"]`}, "Repeat.cat", "1"},