From 5b23ca77066cb8c9d95bad26115423eb673a1107 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 5 Oct 2026 09:05:35 +0200 Subject: [PATCH] =?UTF-8?q?Test=20that=20every=20{=E2=80=A6}=20draws=20afr?= =?UTF-8?q?esh=20and=20only=20a=20name=20keeps=20a=20pick,=20a=20field's?= =?UTF-8?q?=20and=20a=20struct's=20path=20tags=20included?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- calc_test.go | 54 +-- data_test.go | 39 +- drawfence_test.go | 225 ----------- draws_test.go | 286 ++++++++++++++ format_stability_test.go | 2 + hold_test.go | 784 --------------------------------------- inline_test.go | 10 +- name_test.go | 22 +- one_spelling_test.go | 43 +-- path_test.go | 7 - perf_test.go | 32 +- reads_test.go | 139 ------- record_test.go | 85 ++--- reference_test.go | 146 +------- render_test.go | 9 +- struct_test.go | 48 ++- table_test.go | 437 ++++++++-------------- transform_test.go | 7 +- 18 files changed, 608 insertions(+), 1767 deletions(-) delete mode 100644 drawfence_test.go create mode 100644 draws_test.go delete mode 100644 hold_test.go delete mode 100644 reads_test.go diff --git a/calc_test.go b/calc_test.go index 2fb017b..d01ea2d 100644 --- a/calc_test.go +++ b/calc_test.go @@ -123,13 +123,11 @@ func TestCalcGuardsPanic(t *testing.T) { // --- one draw, one value: a calc operand reads the expansion's draw --- -// TestCalcOperandReadsTheExpansionsDraw pins the one-draw rule for a calc operand. -// A field the format renders and a calc reads is drawn once per expansion, so the -// operand shown is the operand computed — the correlation the dotted-path rule -// already gives a level, applied to a plain sibling. -func TestCalcOperandReadsTheExpansionsDraw(t *testing.T) { +// TestANamedCalcOperandIsTheOneShown pins how a calc computes from the value its format +// shows: the format and the calc read one name. +func TestANamedCalcOperandIsTheOneShown(t *testing.T) { dir := writeData(t, map[string]string{ - "inv": `{"format":"{net} x {qty} = {calc(net * qty, 2)}","net":["19.99","5.00","100.00"],"qty":["2","3","7"]}`, + "inv": `{"format":"{net as n}{qty as q}{n} x {q} = {calc(n * q, 2)}","net":["19.99","5.00","100.00"],"qty":["2","3","7"]}`, }) f := newGenerator(t, dir, WithSeed(3)) for i := 0; i < 300; i++ { @@ -144,39 +142,23 @@ func TestCalcOperandReadsTheExpansionsDraw(t *testing.T) { } } -// TestCalcOperandSharesOneDraw pins the reach of that hold: the draw belongs to the -// expansion, not to the calc, so the bare token rendering the same name reads it too. -func TestCalcOperandSharesOneDraw(t *testing.T) { - dir := writeData(t, map[string]string{ - "same": `{"format":"{w} {calc(w)}","w":["1","2","3","4","5"]}`, - }) +// TestACalcOperandDrawsAfresh pins that a calc reading a field draws it as any {…} does. +func TestACalcOperandDrawsAfresh(t *testing.T) { + dir := writeData(t, map[string]string{"same": `{"format":"{w} {calc(w)}","w":["1","2","3","4","5"]}`}) f := newGenerator(t, dir, WithSeed(5)) for i := 0; i < 200; i++ { - got := fake(t, f, "same") - if p := strings.Fields(got); len(p) != 2 || p[0] != p[1] { - t.Fatalf("same = %q, want one value twice", got) - } - } -} - -// TestFieldNoCalcReadsDrawsEachTime guards the boundary: only a name a calc reads is -// held, so an ordinary {w} {w} still draws twice. -func TestFieldNoCalcReadsDrawsEachTime(t *testing.T) { - dir := writeData(t, map[string]string{"two": `{"format":"{w} {w}","w":["1","2","3","4","5"]}`}) - f := newGenerator(t, dir, WithSeed(5)) - for i := 0; i < 200; i++ { - if p := strings.Fields(fake(t, f, "two")); p[0] != p[1] { + if p := strings.Fields(fake(t, f, "same")); p[0] != p[1] { return } } - t.Fatal("{w} {w} never differed in 200 draws, want two independent draws") + t.Fatal("{w} {calc(w)} never differed in 200 draws, want two independent draws") } -// TestCalcHoldIsPerExpansion pins the scope of the hold: each repeat iteration is -// its own expansion, so it draws again while staying self-consistent. -func TestCalcHoldIsPerExpansion(t *testing.T) { +// TestANamedCalcOperandPicksEachIteration pins that a name a repeat binds picks again on each +// iteration, staying one value within it. +func TestANamedCalcOperandPicksEachIteration(t *testing.T) { dir := writeData(t, map[string]string{ - "rep": `{"format":"{n}={calc(n * 1)}","repeat":8,"separator":" ","n":["2","3","4","5","6","7","8","9"]}`, + "rep": `{"format":"{n as m}{m}={calc(m * 1)}","repeat":8,"separator":" ","n":["2","3","4","5","6","7","8","9"]}`, }) f := newGenerator(t, dir, WithSeed(11)) varied := false @@ -197,12 +179,12 @@ func TestCalcHoldIsPerExpansion(t *testing.T) { } } -// TestCalcHoldIsPerTemplate pins that a nested template holds its own: the inner -// {v} and its calc agree with each other, not with the outer pair. -func TestCalcHoldIsPerTemplate(t *testing.T) { +// TestTwoNamesPickApart pins that a nested template's name is a pick of its own: the inner +// pair agrees with itself, not with the outer pair. +func TestTwoNamesPickApart(t *testing.T) { dir := writeData(t, map[string]string{ - "nest": `{"format":"{v}={calc(v * 1)} {inner}","v":["2","3","4","5","6","7","8","9"], - "inner":{"format":"{v}={calc(v * 1)}","v":["2","3","4","5","6","7","8","9"]}}`, + "nest": `{"format":"{v as a}{a}={calc(a * 1)} {inner}","v":["2","3","4","5","6","7","8","9"], + "inner":{"format":"{v as b}{b}={calc(b * 1)}","v":["2","3","4","5","6","7","8","9"]}}`, }) f := newGenerator(t, dir, WithSeed(13)) differed := false diff --git a/data_test.go b/data_test.go index 6852afc..df16b64 100644 --- a/data_test.go +++ b/data_test.go @@ -268,7 +268,7 @@ func TestShippedPersonNames(t *testing.T) { seen := map[string]bool{} for i := 0; i < 2000; i++ { seen[fake(t, f, locale+".sex[f].first-name")] = true - got := fakeTemplate(t, f, `{/`+locale+`.person.first}|{/`+locale+`.person.sex}`) + got := fakeTemplate(t, f, `{/`+locale+`.person as p}{p.first}|{p.sex}`) first, sex, _ := strings.Cut(got, "|") fake(t, f, locale+".sex["+sex+"].first-name["+first+"]") } @@ -330,35 +330,42 @@ func TestShippedSwedishAddress(t *testing.T) { } // TestShippedAddressIsOneNamedPick pins that an address's street, postal code, -// locality and region are one place in the geo tables, and that two names bound to -// one address category are two addresses. +// locality and region are one place in the geo tables, read through a name or a struct's +// tags, and that a reference beside the name is another address. func TestShippedAddressIsOneNamedPick(t *testing.T) { f := newGenerator(t, "data", WithSeed(3)) for _, c := range []struct{ locale, country, template, largest string }{ - {"sv_SE", "SE", "{a.street}|{a.postal-code}|{a.locality}||{b.street}", "Stockholm"}, - {"en_US", "US", "{a.street}|{a.postal-code}|{a.locality}|{a.region}|{b.street}", "New York"}, + {"sv_SE", "SE", "{a.street}|{a.postal-code}|{a.locality}|", "Stockholm"}, + {"en_US", "US", "{a.street}|{a.postal-code}|{a.locality}|{a.region}", "New York"}, } { places := geoPlaces(t, c.country) apart, localities := false, map[string]int{} for i := 0; i < 2000; i++ { - got := fakeTemplate(t, f, `{/`+c.locale+`.address as a}{/`+c.locale+`.address as b}`+c.template) + got := fakeTemplate(t, f, `{/`+c.locale+`.address as a}`+c.template+`|{/`+c.locale+`.address.street}`) parts := strings.Split(got, "|") if !places[strings.Join(parts[:4], "|")] { t.Fatalf("%s.address: %q is no street, postal code, locality and region of one place", c.locale, got) } apart = apart || parts[0] != parts[4] localities[parts[2]]++ - region := "{/" + c.locale + ".address.region}" - if c.country == "SE" { - region = "" - } - got = fakeTemplate(t, f, `{/`+c.locale+`.address.street}|{/`+c.locale+`.address.postal-code}|{/`+c.locale+`.address.locality}|`+region) - if !places[got] { - t.Fatalf("%s.address: field paths %q are no street, postal code, locality and region of one place", c.locale, got) - } } if !apart { - t.Errorf("%s: two names bound to address drew one street in 2000 renders, want two addresses", c.locale) + t.Errorf("%s: a reference beside a name drew the name's street in 2000 renders, want two addresses", c.locale) + } + if c.country == "SE" { + var row struct { + Street string `fake:"sv_SE.address.street"` + PostalCode string `fake:"sv_SE.address.postal-code"` + Locality string `fake:"sv_SE.address.locality"` + } + for i := 0; i < 200; i++ { + if err := f.FakeStruct(&row); err != nil { + t.Fatal(err) + } + if got := row.Street + "|" + row.PostalCode + "|" + row.Locality + "|"; !places[got] { + t.Fatalf("sv_SE.address: struct tags %q are no street, postal code and locality of one place", got) + } + } } for l, n := range localities { if n > localities[c.largest] { @@ -545,7 +552,7 @@ func TestShippedUSTitleAgreesWithSex(t *testing.T) { female := map[string]bool{"Miss": true, "Mrs": true, "Ms": true} seen := map[string]bool{} for i := 0; i < 3000; i++ { - got := fakeTemplate(t, f, `{/en_US.person.prefix}|{/en_US.person.sex}`) + got := fakeTemplate(t, f, `{/en_US.person as p}{p.prefix}|{p.sex}`) title, sex, _ := strings.Cut(got, "|") title = strings.TrimSpace(title) seen[title] = true diff --git a/drawfence_test.go b/drawfence_test.go deleted file mode 100644 index 9a1c220..0000000 --- a/drawfence_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package fejkdata - -import ( - "strings" - "testing" - "testing/fstest" -) - -func fenceCorpus(t *testing.T) *Generator { - t.Helper() - dir := writeFiles(t, with(geo(), map[string]string{ - "addr.json": `{"format":"{a} {b} {c}","a":"{/locality.name}, {/municipality.name}","b":{"format":"{/region[12].name} {/place}","drawGroup":"g"},"c":{"format":"{/place.zip} ","repeat":2}}`, - "place.json": `{"format":"{zip} {name} {tag} {/w}","rows":"place.tsv","key":"name"}`, - "place.tsv": "name\tzip\ttag\nStockholm\t1{digits(2)} {digits(2)}\t{/x}\nTranås\t573 {digits(2)}\t{/region[12].name}\n", - "rec.json": `{"format":"","l":"{/locality.name}","m":"{/municipality.name}","t":"{/place[Stockholm].tag}"}`, - "w.json": `["x","y"]`, - "x.json": `"{/w}"`, - })) - f, err := New(WithDataPath(dir), WithSeed(1)) - if err != nil { - t.Fatalf("New = %v", err) - } - return f -} - -type fenceRoot struct { - label string - t *template -} - -func fenceRoots(t *testing.T, f *Generator) []fenceRoot { - t.Helper() - var roots []fenceRoot - _ = sitesScope(categorySites(&f.root))(func(path string, n node) error { - if tm, ok := n.(*template); ok { - roots = append(roots, fenceRoot{path, tm}) - } - return nil - }) - inline, err := f.NewTemplate(`{"format":"{x}|{y}","x":{"format":"{/locality.name}","drawGroup":"g"},"y":"{/region[01].name}"}`) - if err != nil { - t.Fatalf("NewTemplate = %v", err) - } - record, err := f.NewRecordTemplate(`{"format":"","a":"{/locality.name}","b":"{/municipality.name}"}`) - if err != nil { - t.Fatalf("NewRecordTemplate = %v", err) - } - _ = eachNode(inline.n, "inline", func(path string, n node) error { - if tm, ok := n.(*template); ok { - roots = append(roots, fenceRoot{path, tm}) - } - return nil - }) - return append(roots, fenceRoot{"inline record", record.template}) -} - -func TestPairsFindWhatAWholeReplayFinds(t *testing.T) { - corpus := fenceCorpus(t) - roots := fenceRoots(t, corpus) - shipped := newShippedWhole(t) - _ = sitesScope(categorySites(&shipped.root))(func(path string, n node) error { - if tm, ok := n.(*template); ok { - roots = append(roots, fenceRoot{path, tm}) - } - return nil - }) - compared := 0 - fold := newReadFold() - for _, root := range roots { - compared += wantPairsFindAll(t, root.label, surveyRender(fold, root.t).reads) - if root.t.isRecord && len(root.t.fields) > 0 { - compared += wantPairsFindAll(t, root.label+" as a record", surveyColumns(fold, root.t, sortedNames(root.t.fields)).reads) - } - } - if compared == 0 { - t.Error("no read set rendering together holds two table reads, so the comparison proved nothing") - } -} - -func TestPairsFindWhatAWholeReplayOfSelectedRowsFinds(t *testing.T) { - f, err := New(WithDataPath(writeFiles(t, siblings()))) - if err != nil { - t.Fatalf("New = %v", err) - } - var reads []pathRead - fold := newReadFold() - _ = sitesScope(categorySites(&f.root))(func(_ string, n node) error { - tb, isTable := n.(*table) - if !isTable || tb.keyIndex < 0 || tb.familyRoot().path != "region" { - return nil - } - for r := 0; r < tb.rowCount(); r++ { - inline, err := f.NewTemplate(`"{/` + tb.selectorSpelling(r) + `}"`) - if err != nil { - t.Fatalf("NewTemplate(%s) = %v", tb.selectorSpelling(r), err) - } - root, isTemplate := inline.n.(*template) - if !isTemplate { - t.Fatalf("NewTemplate(%s) compiled to %T", tb.selectorSpelling(r), inline.n) - } - reads = append(reads, surveyRender(fold, root).reads...) - } - return nil - }) - conflicts := 0 - for _, a := range reads { - for _, b := range reads { - for _, c := range reads { - triple := []pathRead{a, b, c} - wantPairsFindAll(t, a.a.spelling+", "+b.a.spelling+", "+c.a.spelling, triple) - if (&readSurvey{reads: triple}).check() != nil { - conflicts++ - } - } - } - } - if conflicts == 0 { - t.Error("no triple of selected rows conflicts, so the comparison proved nothing") - } -} - -// wantPairsFindAll counts the read sets rendering together that hold two table reads or more. -func wantPairsFindAll(t *testing.T, label string, reads []pathRead) int { - t.Helper() - var tabled []pathRead - for _, r := range reads { - if r.tr != nil { - tabled = append(tabled, r) - } - } - pairs := (&readSurvey{reads: reads}).check() - var whole error - compared := 0 - eachRenderSet(tabled, func(set []pathRead) { - if len(set) > 1 { - compared++ - } - var d pinSet - for _, r := range set { - if err := r.tr.replay(&d); err != nil && whole == nil { - whole = err - } - } - }) - if (pairs == nil) != (whole == nil) { - t.Errorf("%s: check = %v, a whole replay of every read set rendering together = %v", label, pairs, whole) - } - return compared -} - -// eachRenderSet calls fn with every maximal set of reads that render together, by Bron–Kerbosch. -func eachRenderSet(reads []pathRead, fn func([]pathRead)) { - var grow func(set, candidates, excluded []pathRead) - grow = func(set, candidates, excluded []pathRead) { - if len(candidates) == 0 && len(excluded) == 0 { - fn(set) - return - } - for len(candidates) > 0 { - r := candidates[0] - var nextC, nextX []pathRead - for _, c := range candidates[1:] { - if coRender(r, c) { - nextC = append(nextC, c) - } - } - for _, x := range excluded { - if coRender(r, x) { - nextX = append(nextX, x) - } - } - grow(append(set[:len(set):len(set)], r), nextC, nextX) - candidates, excluded = candidates[1:], append(excluded, r) - } - } - grow(nil, reads, nil) -} - -func TestReadSurveyReportsAPinClashAsAnError(t *testing.T) { - fsys := fstest.MapFS{ - "r.json": {Data: []byte(`"{/y[a].v}"`)}, - "m.json": {Data: []byte(`{"format":"{g}{f}","f":"{/y[a].v}","g":"{/y[a].v}"}`)}, - "n.json": {Data: []byte(`{"format":"{f}","f":"{/y[a].v}"}`)}, - "x.json": {Data: []byte(`{"format":"{v}","rows":"x.tsv","key":"code"}`)}, - "x.tsv": {Data: []byte("code\tv\n1\tb\n2\tc\n")}, - "y.json": {Data: []byte(`{"format":"{v}","rows":"y.tsv","key":"code","parent":"x"}`)}, - "y.tsv": {Data: []byte("code\tv\tx\na\t{/x[2].v}\t1\nb\tz\t2\n")}, - } - g, err := loadDir(dataSource{fsys: fsys}, ".") - if err != nil { - t.Fatalf("loadDir = %v", err) - } - if err := categoryBinding(categorySites(g), g.children).link(); err != nil { - t.Fatalf("link = %v", err) - } - r, isTemplate := g.children["r"].(*template) - if !isTemplate { - t.Fatalf("r is a %T, want a template", g.children["r"]) - } - if err := surveyRender(newReadFold(), r).check(); err == nil || !strings.Contains(err.Error(), "two rows of") { - t.Errorf("check = %v, want y[a]'s row of x clashing with x[2] named, whatever order the fences run in", err) - } - n, isTemplate := g.children["n"].(*template) - if !isTemplate { - t.Fatalf("n is a %T, want a template", g.children["n"]) - } - if err := surveyRender(newReadFold(), n).check(); err == nil || !strings.HasPrefix(err.Error(), "{f} with {/x[2].v}: x[1] and x[2] are two rows of x") { - t.Errorf("check = %v, want the clash below {f} named by the route the root reaches it by", err) - } - m, isTemplate := g.children["m"].(*template) - if !isTemplate { - t.Fatalf("m is a %T, want a template", g.children["m"]) - } - if err := surveyRender(newReadFold(), m).check(); err == nil || !strings.HasPrefix(err.Error(), "{f} with") { - t.Errorf("check = %v, want the clash of the lowest route named, {f}, whichever the walk reaches first", err) - } -} - -func surveyRender(f *readFold, t *template) *readSurvey { - return survey(f.rootReads(t), t.link.drawGroupKey) -} - -func surveyColumns(f *readFold, t *template, columns []string) *readSurvey { - return survey(f.columnReads(t, columns), t.link.drawGroupKey) -} diff --git a/draws_test.go b/draws_test.go new file mode 100644 index 0000000..f7ac197 --- /dev/null +++ b/draws_test.go @@ -0,0 +1,286 @@ +package fejkdata + +import ( + "fmt" + "regexp" + "strings" + "testing" +) + +// 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) +} + +// varies renders src until two renders of it satisfy differ, failing after 400 tries. +func varies(t *testing.T, f *Generator, src string, differ func(got string) bool) { + t.Helper() + for i := 0; i < 400; i++ { + if differ(mustRender(t, f, src)) { + return + } + } + t.Fatalf("400 renders of %s never drew apart, want each {…} a pick of its own", src) +} + +func TestEveryTokenDrawsAfresh(t *testing.T) { + f := engine(3) + pair := func(got string) bool { p := strings.Split(got, "|"); return p[0] != p[1] } + varies(t, f, `{"format":"{w}|{w}","w":["a","b","c","d","e"]}`, pair) + varies(t, f, `{"format":"{p.first}|{p.first}","p":{"format":"{first}","first":["Anna","Astrid","Elin","Karin"]}}`, pair) + varies(t, f, `{"format":"{w}|{uppercase(w)}","w":["a","b","c","d","e"]}`, func(got string) bool { + p := strings.Split(got, "|") + return strings.ToUpper(p[0]) != p[1] + }) + varies(t, f, `{"format":"{n}|{calc(n * 1)}","n":["1","2","3","4"]}`, pair) + varies(t, f, places("{place.postal-code} {place.locality}"), func(got string) bool { return !agree.MatchString(got) }) +} + +func TestEveryReferenceDrawsAfresh(t *testing.T) { + f := newGenerator(t, writeData(t, map[string]string{ + "person": `{"format":"{first} {last}","first":["Ada","Bo","Cy","Di"],"last":["Byron","Ek","Lind","Ros"]}`, + }), WithSeed(4)) + for _, src := range []string{ + `"{/person.first}|{/person.first}"`, + `"{/person}|{/person.first} {/person.last}"`, + `{"format":"{a}|{b}","a":"{/person.last}","b":"{/person.last}"}`, + } { + differ := false + for i := 0; i < 400 && !differ; i++ { + p := strings.Split(fakeTemplate(t, f, src), "|") + differ = p[0] != p[1] + } + if !differ { + t.Errorf("400 renders of %s never drew apart, want each reference a pick of its own", src) + } + } +} + +func TestANamedFieldCorrelatesItsReads(t *testing.T) { + f := engine(1) + seen := map[string]bool{} + for i := 0; i < 500; i++ { + got := mustRender(t, f, places("{place as p}{p.postal-code} {p.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 TestANamedFieldReadsItsVariantsWhole(t *testing.T) { + // One variant holds addr as a plain template where another holds a choice; the name + // keeps the variant drawn at every level its reads pass. + f := engine(13) + tmpl := `{"format":"{p as q}{q.addr.city}/{q.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 TestANamedFieldInARepeatPicksEachLine(t *testing.T) { + f := engine(5) + tmpl := `{"format":"{lines}","lines":` + strings.Replace(places("{place as p}{p.postal-code} {p.locality}"), `"format":`, `"repeat":6,"separator":"\n","format":`, 1) + `}` + varied := false + for i := 0; i < 40; i++ { + lines := strings.Split(mustRender(t, f, tmpl), "\n") + if len(lines) != 6 { + t.Fatalf("repeat 6 produced %d lines", len(lines)) + } + for _, line := range lines { + if !agree.MatchString(line) { + t.Fatalf("line %q does not pair", line) + } + } + varied = varied || lines[0] != lines[1] || lines[1] != lines[2] + } + if !varied { + t.Fatal("40 renders of repeat 6 never varied within a render, want a pick per line") + } +} + +func TestAFieldBindingIsRefusedWhereNoFieldIs(t *testing.T) { + for src, want := range map[string]string{ + `{"format":"{nope as p}{p}{p}","place":"x"}`: `no field "nope"`, + `{"format":"{place as p}{p.x}","place":{"format":"{x}","x":["a","b"]}}`: `write {place.x} where it is read`, + `{"format":"{place as p}{p}","place":["x","y"]}`: `write {place} where it is read`, + `{"format":"{place as p}{calc(p * 2)}","place":["1","2"]}`: `write place where it is read`, + `{"format":"{place as place}{place}{place}","place":["x","y"]}`: `name "place" is a field of the root template too`, + } { + if _, err := linked(t, src); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s = %v, want it refused naming %s", src, err, want) + } + } +} + +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. + _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ + "a": `{"format":"{p.x}","p":{"format":"static","x":"{/b}"}}`, + "b": `"{/a}"`, + }))) + if err == nil || !strings.Contains(err.Error(), "reference cycle") { + t.Fatalf("New = %v, want the cycle through {p.x} rejected", err) + } +} + +func TestACycleInALaterVariantIsRejected(t *testing.T) { + _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ + "cat": `{"format":"{p.x}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{/hop}"}]}`, + "hop": `"{/cat}"`, + }))) + if err == nil || !strings.Contains(err.Error(), "reference cycle") { + t.Fatalf("New = %v, want the cycle in the later variant rejected", err) + } +} + +func TestCycleThroughAPathTokenIsRejected(t *testing.T) { + _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ + "a": `{"format":"{p.x}","p":{"format":"{x}","x":"{/b}"}}`, + "b": `"{/a}"`, + }))) + if err == nil || !strings.Contains(err.Error(), "reference cycle") { + t.Fatalf("New = %v, want the cycle through {p.x} rejected", err) + } +} + +func TestADeepDiamondChainLoads(t *testing.T) { + // A diamond chain is 2^n routes through n nodes, so every load walk must visit each + // node once, not once per route. + 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 TestNestedChoiceDrawsOneVariant(t *testing.T) { + // A choice item may itself be a choice, so a path unwraps until it reaches a value. + 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) { + _, 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 TestPathIntoARepeatingLevelIsRejected(t *testing.T) { + // A path reads one level's draw, so it can never apply that level's repeat, directly or + // behind a choice. + for _, cat := range []string{ + `{"format":"[{p.a}]","p":{"format":"{a}","repeat":3,"separator":",","a":"z"}}`, + `{"format":"[{p.a}]","p":[{"format":"{a}","repeat":3,"separator":",","a":"x"},{"format":"{a}","a":"y"}]}`, + } { + _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": cat}))) + if err == nil || !strings.Contains(err.Error(), "repeat") { + t.Errorf("New(%s) = %v, want a path into a repeating level rejected", cat, err) + } + } +} + +func TestPathIntoAPlainTemplateNamesTheMissingField(t *testing.T) { + _, 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 TestADrawIsSeedStable(t *testing.T) { + tmpl := places("{place as p}{p.postal-code} {p.locality}") + if a, b := mustRender(t, engine(42), tmpl), mustRender(t, engine(42), tmpl); a != b { + t.Fatalf("same seed gave %q and %q, want an identical draw", a, b) + } +} + +func TestDottedTokenErrors(t *testing.T) { + 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 || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: New = %v, want it to mention %q", name, err, c.want) + } + } +} + +func TestEmptyPathSegmentIsRejected(t *testing.T) { + for name, file := range map[string]string{ + "trailing dot": `{"format":"[{a.}]","a":"x"}`, + "triple dot": `{"format":"[{a...b}]","a":"x"}`, + } { + _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file}))) + if err == nil || !strings.Contains(err.Error(), "empty segment") { + t.Errorf("%s: New = %v, want it to name the empty segment", name, err) + } + } +} + +func TestASubFieldIsReachableByFake(t *testing.T) { + dir := writeData(t, map[string]string{"address": places("{place as p}{p.postal-code} {p.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) + } + } +} diff --git a/format_stability_test.go b/format_stability_test.go index 3cfb0c9..7c3d5d1 100644 --- a/format_stability_test.go +++ b/format_stability_test.go @@ -14,6 +14,7 @@ func TestSeededOutputIsStable(t *testing.T) { "funcs.json": `"{hex(6)} {int(10,99)} {float(0,1,3)} {nanoid(5)} {seq()}"`, "municipality.json": `{"format":"{name}","rows":"municipality.tsv","key":"code","name":"name","parent":"region"}`, "municipality.tsv": "code\tname\tregion\n0180\tStockholm\t01\n0184\tSolna\t01\n1280\tMalmö\t12\n1281\tLund\t12\n", + "named.json": `{"format":"{p as q}{q.a}{q.b}","p":[{"format":"x","a":"1","b":"2"},{"format":"y","a":"A","b":"B"},{"format":"z","a":"a","b":"b"}]}`, "nested.json": `{"format":"{outer}","outer":{"format":"{inner}-{digits(2)}","inner":"i"}}`, "paths.json": `{"format":"{p.a}{p.b}","p":[{"format":"x","a":"1","b":"2"},{"format":"y","a":"A","b":"B"},{"format":"z","a":"a","b":"b"}]}`, "ref.json": `"see {/alt}"`, @@ -30,6 +31,7 @@ func TestSeededOutputIsStable(t *testing.T) { "classes": {"49-74-VY-gj", "38-68-RP-xo", "47-68-IB-hs", "91-89-LP-gk"}, "escapes": {"01Aa#!", "01Aa#!", "01Aa#!", "01Aa#!"}, "funcs": {"0016a2 33 0.649 k4Kwj 1", "a00c07 84 0.175 6UjGv 2", "f70eb8 12 0.390 p5p6g 3", "9048a3 90 0.531 I3Aqv 4"}, + "named": {"ab", "ab", "AB", "AB"}, "nested": {"i-73", "i-23", "i-67", "i-85"}, "paths": {"ab", "ab", "AB", "AB"}, "paths.p.a": {"A", "a", "a", "A"}, diff --git a/hold_test.go b/hold_test.go deleted file mode 100644 index 3dca823..0000000 --- a/hold_test.go +++ /dev/null @@ -1,784 +0,0 @@ -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.HasPrefix(err.Error(), "fejkdata: cat: ") || !strings.Contains(err.Error(), "renders a level that") { - t.Errorf("%s: New = %v, want it to name the category path and 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 TestCalcOperandNamingAHeldLevelIsRejected(t *testing.T) { - // A calc operand renders its field, so naming a held 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(), "renders a level that") { - 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 TestReferenceNamingAHeldLevelIsRejected(t *testing.T) { - // A reference can name a held level from the data root, which renders it - // afresh beside the path that reads its held draw — the same overlap by - // another spelling. - // A category never references itself, so each reference back into cat sits in a - // second category cat renders. - rejected := map[string]map[string]string{ - "reference names the head": { - "cat": `{"format":"{p.first}|{/hop}","p":{"format":"{first}","first":["Anna","Bo"]}}`, - "hop": `"{/cat.p}"`, - }, - "reference names the leaf": { - "cat": `{"format":"{p.addr}|{/hop}","p":{"format":"x","addr":["A","B","C","D"]}}`, - "hop": `"{/cat.p.addr}"`, - }, - // The reference need not sit in the format that holds the level: any field it renders - // reaches the level just the same, however deep. - "reference from a sibling field": { - "cat": `{"format":"{p.first}|{inner}","p":[` + - `{"format":"{first}-{last}","first":"A","last":"1"},{"format":"{first}-{last}","first":"B","last":"2"}],` + - `"inner":"{/hop}"}`, - "hop": `"{/cat.p}"`, - }, - } - for name, files := range rejected { - _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))) - if err == nil || !strings.Contains(err.Error(), "by a second route") { - t.Errorf("%s: New = %v, want the reference rejected as an overlap", name, err) - } - } - // A reference to anything this format does not hold 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 held 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":"{/b}"}}`, - "b": `"{/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":"{/hop}"},` + - `"q":{"format":"{x}","x":["1","2"]}}`, - "hop": `"{/thing.q}"`, - }))) - if err == nil || !strings.Contains(err.Error(), "by a second route") { - 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 category never references itself, so each route back into cat sits in a - // second category cat renders. - "a reference beside the operand": { - map[string]string{ - "cat": `{"format":"{/hop} x 2 = {calc(net * 2, 2)}","net":["10.00","20.00"]}`, - "hop": `"{/cat.net}"`, - }, - `{/hop} renders "net"`, - }, - "a reference one level down": { - map[string]string{ - "cat": `{"format":"{calc(net * 2, 2)} {q}","net":["10.00","20.00"],` + - `"q":"{/hop}"}`, - "hop": `"{/cat.net}"`, - }, - `{q} renders "net"`, - }, - // A reference ending at the operand holds 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":"{/hop}"}`, - "hop": `"{/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":"{/hop}"}`, - "hop": `"{/cat.a}"`, - }, - `calc operand "b" renders "a", which {calc(a + b, 0)} holds to one draw for this expansion; stop calc operand "b" reaching "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)}|{/hop}",` + - `"net":{"format":"{v}","v":["10.00","20.00"]}}`, - "hop": `"{/cat.net.v}"`, - }, - `{/hop} 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":"{/hop}"}`, - "hop": `"{/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":"{/hop}"}`, - "hop": `"{/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(), "holds to one draw for this expansion; ") { - 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":"{/hop}"}`, - "hop": `"{/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)} {/hop}","n":"5"}`, - "hop": `"{/cat.n}"`, - }, - // 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 { - files map[string]string - want string - }{ - "a cycle in a later variant": { - map[string]string{ - "cat": `{"format":"{p.x}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{/hop}"}]}`, - "hop": `"{/cat}"`, - }, - "reference cycle", - }, - "a second route in a later variant": { - map[string]string{ - "cat": `{"format":"{p.x} {q.y}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{/hop}"}],` + - `"q":{"format":"{y}","y":["1","2"]}}`, - "hop": `"{/cat.q}"`, - }, - "by a second route", - }, - } - for name, c := range rejected { - _, err := New(WithoutShippedData(), WithDataPath(writeData(t, c.files))) - 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. p and q sit one level down, so they are - // one column of thing rather than two that would read one another. - accepted := map[string]map[string]string{ - "reference in the head's own format": { - "thing": `{"format":"{inner}","inner":{"format":"{p.first} {q.a}",` + - `"p":{"format":"{first} {/hop}","first":["A","B"]},"q":{"format":"{a}","a":["1","2"]}}}`, - "hop": `"{/thing.inner.q}"`, - }, - "the mirror shape": { - "thing": `{"format":"{inner}","inner":{"format":"{p.first} {q.a}",` + - `"p":{"format":"{first}","first":["A","B"]},"q":{"format":"{a} {/hop}","a":["1","2"]}}}`, - "hop": `"{/thing.inner.p}"`, - }, - } - 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 TestADeepDiamondChainLoads(t *testing.T) { - // A diamond chain is 2^n routes through n nodes. The search past a held 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 held 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 held 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) - } - _, err = New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "cat": `{"format":"[{p.a}]","p":{"format":"{a}","a":"{/word.w}","drawGroup":"g"}}`, - "word": `{"format":"{w}","w":["x","y"]}`, - }))) - if err == nil || !strings.Contains(err.Error(), `the level "p" carries a drawGroup`) { - t.Fatalf("New = %v, want a path into a level carrying a group 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 TestTwoHeldHeadsDrawIndependently(t *testing.T) { - // A hold 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 TestRepeatRedrawsTheHold(t *testing.T) { - // The hold 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 TestNestedTemplateKeepsItsOwnHold(t *testing.T) { - // A hold 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 hold to be independent", seen) - } -} - -func TestHeldDrawIsSeedStable(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"}`, - "triple 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":"{/b}"}}`, - "b": `"{/a}"`, - }))) - if err == nil || !strings.Contains(err.Error(), "reference cycle") { - t.Fatalf("New = %v, want the cycle through {p.x} rejected", err) - } -} - -func TestHeldPathIsReachableByFake(t *testing.T) { - // Holding 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) { - f := newGenerator(t, writeData(t, map[string]string{"w": `"a"`})) - 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 := f.NewTemplate(src) - if err == nil || !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), "holds") { - t.Errorf("NewTemplate(%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 := f.NewTemplate(ok); err != nil { - t.Errorf("NewTemplate(%s) = %v, want it accepted", ok, err) - } - } -} - -func TestCompileFixesHowEachArmIsRead(t *testing.T) { - f, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "cat": `{"format":"{w} {w} {p.x} {/other.y} {n} {uppercase(n)}","w":["a","b"],"n":["c","d"],"p":{"format":"{x}","x":"1"}}`, - "other": `{"format":"{y}","y":"2"}`, - }))) - if err != nil { - t.Fatal(err) - } - tm, ok := f.root.children["cat"].(*template) - if !ok { - t.Fatal("cat is not a template") - } - want := map[string]armKind{"/other.y": refPathRead, "n": heldRead, "p.x": heldRead, "w": freshRead} - for _, o := range tm.compiled.ops { - for _, a := range append(o.arms, o.operands...) { - if k, ok := want[a.spelling]; !ok || a.kind != k { - t.Errorf("{%s} compiles to kind %d, want %d", a.spelling, a.kind, k) - } - } - } -} diff --git a/inline_test.go b/inline_test.go index 6bc6e14..ae9612f 100644 --- a/inline_test.go +++ b/inline_test.go @@ -53,13 +53,13 @@ func TestFakeTemplateJSONArray(t *testing.T) { } } -func TestFakeTemplateCorrelatedReferences(t *testing.T) { +func TestFakeTemplateNamedReference(t *testing.T) { dir := writeData(t, map[string]string{ "person": `[{"format":"{first} {last}","first":"Ada","last":"Lovelace"},{"format":"{first} {last}","first":"Bo","last":"Ek","born":"1990"}]`, }) f := newGenerator(t, dir, WithSeed(1)) for i := 0; i < 100; i++ { - got := tmpl(t, f, "{/person.first} {/person.last}") + got := tmpl(t, f, "{/person as p}{p.first} {p.last}") if got != "Ada Lovelace" && got != "Bo Ek" { t.Fatalf("correlated references = %q, want one person's first and last", got) } @@ -108,12 +108,6 @@ func TestFakeTemplateErrors(t *testing.T) { {`name: {/no.such.path}`, "no entry"}, {`name: {..nope}`, "write {/nope}"}, {`{"format":"x"}`, "is a string"}, - {`{/misc.territory} {/misc.territory.alpha2}`, "{/misc.territory} renders its own draw of what {/misc.territory.alpha2} reads a path through; name the fields you want instead, or move {/misc.territory.alpha2} into a field with a drawGroup"}, - {`{"format":"{/misc.territory.alpha2} {x}","x":"{/misc.territory}"}`, "renders its own draw of what"}, - {`{/misc.territory} {/misc.territory} {/misc.territory.alpha2}`, "{/misc.territory} renders its own draw of what {/misc.territory.alpha2} reads a path through; name the fields you want instead, or move {/misc.territory.alpha2} into a field with a drawGroup"}, - {`{"format":"{uppercase(/misc.territory)} {y}","y":"{/misc.territory}"}`, `{y} renders "/misc.territory", which {uppercase(/misc.territory)} holds to one draw for this expansion; drop {y}, or write {/misc.territory} in this format to read that draw`}, - {`{"format":"{uppercase(/misc.territory.alpha2)} {lowercase(/misc.territory)} {y}","y":"{/misc.territory}"}`, "which {lowercase(/misc.territory)} holds to one draw for this expansion; drop {y}, or write {/misc.territory} in"}, - {`{"format":"{/misc.territory.alpha2}","drawGroup":"g"}`, "nothing can reference"}, } { _, err := f.FakeTemplate(c.input) if err == nil || !strings.Contains(err.Error(), c.want) { diff --git a/name_test.go b/name_test.go index 721234b..0ddcd64 100644 --- a/name_test.go +++ b/name_test.go @@ -184,26 +184,30 @@ func TestNameErrors(t *testing.T) { `name "w" is a field of the root template too`}, {"an option", map[string]string{"word": `["a","b"]`, "card": `"{/word as format}{format}"`}, `"format" is an option and can never be a name`}, - {"no reference", map[string]string{"card": `{"format":"{word as w}{w}","word":["a","b"]}`}, - `"word" is no reference`}, + {"no reference or field", map[string]string{"card": `{"format":"{word as w}{w}{w}","words":["a","b"]}`}, + `no field "word"; a name binds a pick of a field, or of a reference`}, + {"read once through a field", map[string]string{"card": `{"format":"{place as p}{p.x}","place":{"format":"{x}","x":["a","b"]}}`}, + `name "p" is read once, so it keeps no pick for another read; write {place.x} where it is read`}, {"read by nothing", map[string]string{"word": `["a","b"]`, "card": `"{/word as w}"`}, `nothing reads name "w"`}, - {"read in two draw groups", map[string]string{"nm.json": `{"format":"{first}","rows":"nm.tsv"}`, "nm.tsv": "first\tlast\nAda\tByron\nBo\tEk\n", "person": `{"format":"{first} {last}","first":"{/nm.first}","last":"{/nm.last}"}`, "card": `{"format":"{b}|{a}","a":"{/person as p}{p}","b":{"format":"{p.first}","drawGroup":"g"}}`}, - `name "p" is read in two draw groups`}, - {"read in a repeat", map[string]string{"nm.json": `{"format":"{first}","rows":"nm.tsv"}`, "nm.tsv": "first\tlast\nAda\tByron\nBo\tEk\n", "person": `{"format":"{first} {last}","first":"{/nm.first}","last":"{/nm.last}"}`, "card": `{"format":"{/person as p}{p}{each}","each":{"format":"{p.first}","repeat":2}}`}, - `name "p" is read inside a repeat`}, + {"read in a repeat", map[string]string{"nm.json": `{"format":"{first}","rows":"nm.tsv"}`, "nm.tsv": "first\tlast\nAda\tByron\nBo\tEk\n", "person": `{"format":"{first} {last}","first":"{/nm.first}","last":"{/nm.last}"}`, "card": `{"format":"{/person as p}{each}","each":{"format":"{p.first}","repeat":2}}`}, + ""}, {"a misspelt name", map[string]string{"word": `["a","b"]`, "card": `"{/word as p}{p}{p}{pp}"`}, `no field or name "pp"; the names bound here are "p"`}, - {"read once through a path beside its reference path", map[string]string{"word": `{"format":"{w}","w":["a","b"]}`, "card": `"{/word as a}{a.w} {/word.w}"`}, - ""}, + {"read once through a path", map[string]string{"word": `{"format":"{w}","w":["a","b"]}`, "card": `"{/word as a}{a.w} {/word.w}"`}, + `write {/word.w} where it is read`}, {"read once whole, bound to a reference path", map[string]string{"word": `{"format":"{w}","w":["a","b"]}`, "card": `"{/word.w as c}{c} {/word.w}"`}, + `write {/word.w} where it is read`}, + {"read once through a step up", map[string]string{"region.json": nameTables()["region.json"], "region.tsv": nameTables()["region.tsv"], "municipality.json": nameTables()["municipality.json"], "municipality.tsv": nameTables()["municipality.tsv"], "card": `"{/municipality as m}{m..region.name}"`}, + `write {/municipality..region.name} where it is read`}, + {"read once as a calc operand", map[string]string{"word": `["1","2"]`, "card": `"{/word as w}{calc(w * 2)}"`}, ""}, {"read outside a repeat in the root choice", map[string]string{"word": `["a","b"]`, "card": `[{"format":"{/word as p}{p}{p}","repeat":2},"{p}"]`}, `name "p" is bound at an item of the root choice, inside a repeat`}, {"an empty name", map[string]string{"word": `["a","b"]`, "card": `"{/word as }"`}, "a binding names nothing"}, {"read once whole", map[string]string{"word": `["a","b"]`, "card": `{"format":"{/word as w}{x}","x":"{w}"}`}, - `write {/word}`}, + `write {/word} where it is read`}, {"read once whole, as a transform's operand", map[string]string{"word": `["a","b"]`, "card": `"{/word as w}{uppercase(w)}"`}, `write /word where it is read`}, {"rendered twice where a path reads it", map[string]string{"word": `{"format":"{w}-{w}","w":["a","b"]}`, "card": `"{/word as n}{n}|{n.w}"`}, diff --git a/one_spelling_test.go b/one_spelling_test.go index 8673873..abbf9e9 100644 --- a/one_spelling_test.go +++ b/one_spelling_test.go @@ -31,52 +31,25 @@ func TestRepeatedChoiceItemIsRejected(t *testing.T) { 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", - `{"format":"{x}","x":"v","repeat":2,"separator":""}`: "default", - `{"format":"","n":{"format":"1","datatype":"string"}}`: `datatype "string" is the default`, - `{"format":"{x}","x":{"format":"{y}","y":"v","drawGroup":""}}`: `drawGroup "" is the default`, - `{"format":"{x}","x":{"format":"{y}","y":"v","drawGroup":1}}`: "drawGroup must be a 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", + `{"format":"{x}","x":"v","repeat":2,"separator":""}`: "default", + `{"format":"","n":{"format":"1","datatype":"string"}}`: `datatype "string" is the default`, } { if _, err := linked(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)}"`, `{"format":"{/cat.x}","drawGroup":"g"}`} { + 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) } } } -func TestAGroupThatSplitsNothingIsRejected(t *testing.T) { - files := map[string]string{"mail": `"{/word.w}@example.com"`, "word": `{"format":"{w}","w":["a","b"]}`} - for src, want := range map[string]string{ - `{"format":"{x}","x":{"format":"{y}","y":["a","b"],"drawGroup":"g"}}`: `drawGroup "g" splits nothing`, - `{"format":"{x}","x":{"format":"{/word}","drawGroup":"g"}}`: `drawGroup "g" splits nothing`, - `{"format":"{x}","x":{"format":"{r}","drawGroup":"g","r":{"format":"{/word.w}","repeat":2}}}`: `drawGroup "g" splits nothing`, - `{"format":"{x}","x":{"format":"{y}","drawGroup":"g","y":{"format":"{/word.w}","drawGroup":"h"}}}`: `drawGroup "g" splits nothing`, - `{"format":"{x}","x":{"format":"{y}","y":"{/word.w}","repeat":2,"drawGroup":"g"}}`: `drawGroup "g" on a repeat`, - `{"format":"{x}","x":{"format":"{/word.w} {y}","drawGroup":"g","y":{"format":"{/word.w}!","drawGroup":"g"}}}`: `"y" names drawGroup "g", the draw group this template draws in already`, - `{"format":"{/word.w}","drawGroup":"g"}`: "", - `{"format":"{x}","x":{"format":"{/mail}","drawGroup":"g"}}`: "", - `{"format":"{x}","x":{"format":"{/word.w} {y}","drawGroup":"g","y":{"format":"{/word.w}!","drawGroup":"h"}}}`: "", - `{"format":"{x}","x":{"format":"{/word.w} {r}","drawGroup":"g","r":{"format":"{y}","repeat":2,"y":{"format":"{/word.w}","drawGroup":"g"}}}}`: "", - } { - files["cat"] = src - _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))) - switch { - case want == "" && err != nil: - t.Errorf("New(%s) = %v, want a group over a reference path accepted, however deep", src, err) - case want != "" && (err == nil || !strings.Contains(err.Error(), want)): - t.Errorf("New(%s) = %v, want an error mentioning %s", src, err, want) - } - } -} - func TestInlineFolderSigilsAreRejected(t *testing.T) { f := shipped(t) for _, input := range []string{"{.sv_SE.person.last}", "{..sv_SE.person.last}"} { diff --git a/path_test.go b/path_test.go index 2110263..c69b9a6 100644 --- a/path_test.go +++ b/path_test.go @@ -29,13 +29,6 @@ func TestPathCheckChoiceConsumesNoSegment(t *testing.T) { } } -func TestPathCoverStopsAtAChoice(t *testing.T) { - n := compiled(t, `[{"format":"{f}","f":"1"},{"format":"{f}","f":"2"}]`) - if cover := compilePath(n, []string{"f"}).cover; cover != n { - t.Fatalf("cover through a choice = %v, want the choice itself", cover) - } -} - func TestDrawStepsPanicsOnAStepItsNodeLacks(t *testing.T) { defer func() { if r := recover(); r == nil || !strings.Contains(fmt.Sprint(r), `no field "f"`) { diff --git a/perf_test.go b/perf_test.go index 178ffc7..1e70e64 100644 --- a/perf_test.go +++ b/perf_test.go @@ -35,13 +35,13 @@ func TestNoRenderAllocRegression(t *testing.T) { json string base float64 }{ - {"nested depth 25", nestedJSON(25), 27}, - {"nested depth 100", nestedJSON(100), 102}, - {"wide 500 tokens", wideTokenJSON(500), 9}, - {"two paths from one held draw", `{"format":"{p.a} {p.b}","p":[{"format":"x","a":"1","b":"2"},{"format":"y","a":"A","b":"B"}]}`, 3}, - {"ten paths from one held draw", `{"format":"{r.a}{r.b}{r.c}{r.d}{r.e}{r.f}{r.g}{r.h}{r.i}{r.j}","r":[ + {"nested depth 25", nestedJSON(25), 26}, + {"nested depth 100", nestedJSON(100), 101}, + {"wide 500 tokens", wideTokenJSON(500), 8}, + {"two paths into one field", `{"format":"{p.a} {p.b}","p":[{"format":"x","a":"1","b":"2"},{"format":"y","a":"A","b":"B"}]}`, 2}, + {"ten paths into one field", `{"format":"{r.a}{r.b}{r.c}{r.d}{r.e}{r.f}{r.g}{r.h}{r.i}{r.j}","r":[ {"format":"x","a":"1","b":"2","c":"3","d":"4","e":"5","f":"6","g":"7","h":"8","i":"9","j":"0"}, - {"format":"y","a":"A","b":"B","c":"C","d":"D","e":"E","f":"F","g":"G","h":"H","i":"I","j":"J"}]}`, 6}, + {"format":"y","a":"A","b":"B","c":"C","d":"D","e":"E","f":"F","g":"G","h":"H","i":"I","j":"J"}]}`, 3}, } for _, s := range shapes { f, err := New(WithoutShippedData(), WithDataFS(fstest.MapFS{"x.json": {Data: []byte(s.json)}})) @@ -55,16 +55,15 @@ func TestNoRenderAllocRegression(t *testing.T) { } } -// The repeat shape prices what keeps a render's draws off the heap: a copy dropped costs an alloc an iteration. +// The repeat shape prices what keeps a render's scope off the heap: a copy dropped costs an alloc an iteration. func TestNoReferenceAllocRegression(t *testing.T) { word := `{"format":"{w}","w":["alpha","beta","gamma","delta"]}` for _, s := range []struct { name, json string base float64 }{ - {"a repeat of a reference path", `{"format":"{r}","r":{"format":"{/word.w}","repeat":20,"separator":", "}}`, 66}, - {"a named draw group", `{"format":"{a}","a":{"format":"{/word.w}","drawGroup":"g"}}`, 11}, - {"a repeat of a name", `{"format":"{r}","r":{"format":"{/word as n}{n.w}{n.w}","repeat":20,"separator":", "}}`, 205}, + {"a repeat of a reference path", `{"format":"{r}","r":{"format":"{/word.w}","repeat":20,"separator":", "}}`, 25}, + {"a repeat of a name", `{"format":"{r}","r":{"format":"{/word as n}{n.w}{n.w}","repeat":20,"separator":", "}}`, 161}, } { f, err := New(WithoutShippedData(), WithDataFS(fstest.MapFS{ "word.json": {Data: []byte(word)}, @@ -74,12 +73,12 @@ func TestNoReferenceAllocRegression(t *testing.T) { t.Fatalf("New(%s): %v", s.name, err) } if allocs := testing.AllocsPerRun(10000, func() { f.Fake("x") }); allocs > s.base*1.10 { - t.Errorf("%s: %.1f allocs/op regressed past %.1f (baseline %.1f + 10%%); a render's draws reaching the heap is the usual cause", s.name, allocs, s.base*1.10, s.base) + t.Errorf("%s: %.1f allocs/op regressed past %.1f (baseline %.1f + 10%%); a render's scope reaching the heap is the usual cause", s.name, allocs, s.base*1.10, s.base) } } } -// A table read pins a row in the render's draws and reads its cells in place. +// A table read pins a row inline and reads its cells in place. func TestNoTableAllocRegression(t *testing.T) { f, err := New(WithoutShippedData(), WithDataFS(fstest.MapFS{ "region.json": {Data: []byte(`{"format":"{name}","rows":"region.tsv","key":"code","weight":"population"}`)}, @@ -96,7 +95,7 @@ func TestNoTableAllocRegression(t *testing.T) { {"a table drawn", "region", 2}, {"a column of a drawn row", "region.name", 1}, {"a row selected", "region[12]", 2}, - {"two columns of one draw", "x", 10}, + {"two columns, each drawn", "x", 3}, } { if allocs := testing.AllocsPerRun(10000, func() { f.Fake(s.path) }); allocs > s.base*1.10 { t.Errorf("%s: %.1f allocs/op regressed past %.1f (baseline %.1f + 10%%); a row lookup built per draw is the usual cause", s.name, allocs, s.base*1.10, s.base) @@ -117,7 +116,7 @@ func TestNoTableAllocRegression(t *testing.T) { if err != nil { t.Fatal(err) } - const base = 13.0 + const base = 3.0 if allocs := testing.AllocsPerRun(10000, func() { f.Fake("addr") }); allocs > base*1.10 { t.Errorf("five linked tables: %.1f allocs/op regressed past %.1f (baseline %.1f + 10%%); a pin spilling past the inline set is the usual cause", allocs, base*1.10, base) } @@ -136,7 +135,7 @@ func TestNoRecordAllocRegression(t *testing.T) { if _, err := f.FakeRecord("x"); err != nil { t.Fatalf("FakeRecord(%s): %v", s.name, err) // else the gate would measure the error path } - const base = 4.0 + const base = 3.0 if allocs := testing.AllocsPerRun(10000, func() { f.FakeRecord("x") }); allocs > base*1.10 { t.Errorf("%s: %.1f allocs/op regressed past %.1f (baseline %.1f + 10%%); a record fence running per draw is the usual cause", s.name, allocs, base*1.10, base) } @@ -160,7 +159,8 @@ func TestNoStructAllocRegression(t *testing.T) { if err := f.FakeStruct(&v); err != nil { t.Fatal(err) } - const base = 5.0 + // The three tags read one pick of x: its frame and memo cost two allocations. + const base = 7.0 if allocs := testing.AllocsPerRun(10000, func() { f.FakeStruct(&v) }); allocs > base*1.10 { t.Errorf("FakeStruct: %.1f allocs/op regressed past %.1f (baseline %.1f + 10%%); compiling the type per call is the usual cause", allocs, base*1.10, base) } diff --git a/reads_test.go b/reads_test.go deleted file mode 100644 index 2abd852..0000000 --- a/reads_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package fejkdata - -import ( - "strings" - "testing" - "testing/fstest" -) - -func TestEveryReadARenderMakesIsGathered(t *testing.T) { - f := fenceCorpus(t) - roots := fenceRoots(t, f) - traced := 0 - fold := newReadFold() - for _, root := range roots { - traced += wantGathered(t, root.label, surveyRender(fold, root.t).reads, func(trace renderTrace) { renderRoot(f.rand, root.t, trace) }) - if !root.t.isRecord || len(root.t.fields) == 0 { - continue - } - _, columns, err := recordOf(root.t) - if err != nil { - t.Fatalf("%s: recordOf = %v", root.label, err) - } - traced += wantGathered(t, root.label+" as a record", surveyColumns(fold, root.t, sortedNames(root.t.fields)).reads, func(trace renderTrace) { renderRecordRoot(f.rand, root.t, columns, trace) }) - } - if traced == 0 { - t.Error("no render traced a reference read, so the comparison proved nothing") - } -} - -func wantGathered(t *testing.T, label string, gathered []pathRead, renderWith func(renderTrace)) int { - t.Helper() - var reads []pathRead - type traceKey struct { - group, path string - row renderedRow - } - seen := map[traceKey]bool{} - trace := func(group string, row renderedRow, a arm) { - if !isRef(a.head) { - return - } - r := pathRead{group: group, a: a} - if row.t != nil { - r.branches.pins.add(row.t, row.index) - } - if k := (traceKey{group, a.path, row}); !seen[k] { - seen[k] = true - reads = append(reads, r) - } - } - for i := 0; i < 5; i++ { - renderWith(trace) - } - for _, r := range reads { - if !gathers(gathered, r) { - t.Errorf("%s: the render read {%s} in draw group %q from %s, and the fold gathered no such read", label, r.a.spelling, r.group, spellPins(&r.branches.pins)) - } - } - return len(reads) -} - -func gathers(gathered []pathRead, r pathRead) bool { - for _, g := range gathered { - if g.group == r.group && g.a.path == r.a.path && !g.branches.pins.differs(&r.branches.pins) && !g.branches.wholePins.differs(&r.branches.pins) { - return true - } - } - return false -} - -func spellPins(p *pinSet) string { - var rows []string - p.each(func(t *table, r int) { rows = append(rows, t.selectorSpelling(r)) }) - if len(rows) == 0 { - return "no row" - } - return "row " + strings.Join(rows, ", ") -} - -func renderRoot(s *generatorState, t *template, trace renderTrace) { - draws := renderDraws{trace: trace} - sc, _ := renderScope{draws: &draws}.enter(t, &draws.unnamed.memo) - switch { - case t.site.isFormat(): - tbl := t.site.table - render(s, tbl, sc) - for r := 0; r < tbl.rowCount(); r++ { - draws := renderDraws{trace: trace} - sc := renderScope{draws: &draws} - sc.groupDraws().pins.pin(tbl, r) - render(s, tbl.rowNode, sc.at(tbl.rowNode, &sc.groupDraws().pins)) - } - case t.repeat > 1: - expand(s, t, sc) - default: - render(s, t, sc) - } -} - -func renderRecordRoot(s *generatorState, t *template, columns []Column, trace renderTrace) { - draws := renderDraws{trace: trace} - sc := renderScope{draws: &draws} - if t.site.isFormat() { - t.site.table.drawIn(s, &sc.groupDraws().pins) - } - renderRecord(s, t, columns, sc) -} - -func TestReadFoldWalksACellByEveryRoute(t *testing.T) { - fsys := fstest.MapFS{ - "g.json": {Data: []byte(`{"format":"{/x.v}","drawGroup":"g2"}`)}, - "r.json": {Data: []byte(`"{/g} {/x}"`)}, - "x.json": {Data: []byte(`{"format":"{v}","rows":"x.tsv","key":"code"}`)}, - "x.tsv": {Data: []byte("code\tv\n1\t{/z}\n2\t{/z.a}\n")}, - "z.json": {Data: []byte(`{"format":"{a}","a":"hi"}`)}, - } - g, err := loadDir(dataSource{fsys: fsys}, ".") - if err != nil { - t.Fatalf("loadDir = %v", err) - } - if err := categoryBinding(categorySites(g), g.children).link(); err != nil { - t.Fatalf("link = %v", err) - } - r, isTemplate := g.children["r"].(*template) - if !isTemplate { - t.Fatalf("r is a %T, want a template", g.children["r"]) - } - var pinned, whole bool - for _, read := range surveyRender(newReadFold(), r).reads { - if read.a.path != "/z" { - continue - } - pinned = pinned || read.branches.pins.used > 0 - whole = whole || read.branches.wholePins.used > 0 - } - if !pinned || !whole { - t.Errorf("{/z} gathered under a pinned row %v and a whole read's row %v, want both: {/g} reaches x's cells from the root, and {/x} renders them whole", pinned, whole) - } -} diff --git a/record_test.go b/record_test.go index bee5950..bece4d8 100644 --- a/record_test.go +++ b/record_test.go @@ -199,46 +199,23 @@ func TestRecordWritesTypedAndNullColumns(t *testing.T) { } } -func TestRecordRejectsOverlappingReferenceColumns(t *testing.T) { +func TestRecordColumnsReadingOneReferenceLoad(t *testing.T) { cat := `{"format":"{a}","a":[{"format":"A={b}","b":"1"},{"format":"A={b}","b":"2"}]}` mid := `"{/cat.a}"` - for _, c := range []struct{ name, row string }{ - {"through a column repeat, which draws anew", `{"format":"","whole":"{/cat.a}","inner":{"format":"{/cat.a.b}","repeat":2,"separator":"-"}}`}, - {"in a group of its own", `{"format":"","whole":{"format":"{/cat.a}","drawGroup":"g"},"inner":"{/cat.a.b}"}`}, + for _, row := range []string{ + `{"format":"","whole":"{/cat.a}","inner":{"format":"{/cat.a.b}","repeat":2,"separator":"-"}}`, + `{"format":"","whole":"{/cat.a}","inner":"{/cat.a.b}"}`, + `{"format":"","whole":"{/cat.a}","inner":{"format":"{/cat.a.b} {x}","x":"1"}}`, + `{"format":"","whole":"{/cat.a}","inner":[{"format":"{/cat.a.b} {x}","x":"1"},{"format":"{/cat.a.b}! {x}","x":"2"}]}`, + `{"format":"","whole":"{uppercase(/cat.a)}","inner":"{/cat.a.b}"}`, + `{"format":"","whole":"{/cat}","inner":"{/cat.a.b}"}`, + `{"format":"","whole":"{/mid}","inner":"{/cat.a.b}"}`, } { - f := newGenerator(t, writeData(t, map[string]string{"cat": cat, "mid": mid, "row": c.row}), WithSeed(1)) + f := newGenerator(t, writeData(t, map[string]string{"cat": cat, "mid": mid, "row": row}), WithSeed(1)) if _, err := f.FakeRecord("row"); err != nil { - t.Errorf("%s: FakeRecord = %v, want it accepted", c.name, err) + t.Errorf("%s: FakeRecord = %v, want it accepted", row, err) } } - for _, c := range []struct{ name, row string }{ - {"sibling columns", `{"format":"","whole":"{/cat.a}","inner":"{/cat.a.b}"}`}, - {"through a nested template", `{"format":"","whole":"{/cat.a}","inner":{"format":"{/cat.a.b} {x}","x":"1"}}`}, - {"through a choice variant", `{"format":"","whole":"{/cat.a}","inner":[{"format":"{/cat.a.b} {x}","x":"1"},{"format":"{/cat.a.b}! {x}","x":"2"}]}`}, - {"as a builtin operand", `{"format":"","whole":"{uppercase(/cat.a)}","inner":"{/cat.a.b}"}`}, - {"a bare reference beside a path", `{"format":"","whole":"{/cat}","inner":"{/cat.a.b}"}`}, - {"a column reaching back through another category", `{"format":"","whole":"{/mid}","inner":"{/cat.a.b}"}`}, - } { - _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": cat, "mid": mid, "row": c.row}))) - if err == nil || !strings.Contains(err.Error(), "renders its own draw of what") { - t.Errorf("%s: New = %v, want the overlap refused at load, the way one format is", c.name, err) - continue - } - if !strings.Contains(err.Error(), `"whole"`) || !strings.Contains(err.Error(), `"inner"`) { - t.Errorf("%s: error %q names neither column; it must name both", c.name, err) - } - } -} - -func TestInlineRecordRejectsOverlappingColumns(t *testing.T) { - dir := writeData(t, map[string]string{ - "cat": `{"format":"","a":[{"format":"A={b}","b":"1"},{"format":"A={b}","b":"2"}]}`, - }) - f := newGenerator(t, dir, WithSeed(1)) - _, err := f.FakeRecordTemplate(`{"format":"","whole":"{/cat.a}","inner":"{/cat.a.b}"}`) - if err == nil || !strings.Contains(err.Error(), "renders its own draw of what") { - t.Fatalf("inline record over an overlapping pair = %v, want the inline entry point to refuse it too", err) - } } func TestRecordRejectsAColumnReadingItsOwnRecord(t *testing.T) { @@ -276,15 +253,16 @@ func TestRecordBareReferenceStaysIndependentAsAnOperand(t *testing.T) { if !sawMismatch { t.Error("two bare-reference operand columns never disagreed; a bare reference draws on its own, as the plain spelling does") } - for i := 0; i < 50; i++ { + for i := 0; i < 200; i++ { v, err := f.FakeTemplate("{/cur}|{uppercase(/cur)}") if err != nil { t.Fatal(err) } if parts := strings.Split(v, "|"); !strings.EqualFold(parts[0], parts[1]) { - t.Fatalf("one format rendered %q; within an expansion a bare reference is still one draw", v) + return } } + t.Error("{/cur}|{uppercase(/cur)} never disagreed in 200 renders; each reference draws on its own") } func TestRecordSQLInsert(t *testing.T) { @@ -315,10 +293,10 @@ func TestRecordRejectsFieldDescent(t *testing.T) { } } -func TestRecordSharesAReferenceAcrossColumns(t *testing.T) { +func TestRecordColumnsReadOneName(t *testing.T) { dir := writeData(t, map[string]string{ "currency": `[{"format":"{code}","code":"AUD","symbol":"$"},{"format":"{code}","code":"EUR","symbol":"€","name":"Euro"}]`, - "price": `{"format":"{code} {symbol}","code":"{/currency.code}","symbol":"{/currency.symbol}"}`, + "price": `{"format":"{code} {symbol}","code":"{/currency as c}{c.code}","symbol":"{c.symbol}"}`, }) f := newGenerator(t, dir, WithSeed(1)) symbols := map[string]string{"AUD": "$", "EUR": "€"} @@ -341,9 +319,9 @@ func TestRecordSharesAReferenceAcrossColumns(t *testing.T) { } } -func TestRepeatIterationsDrawReferencesAnew(t *testing.T) { +func TestRepeatIterationsPickNamesAnew(t *testing.T) { dir := writeData(t, map[string]string{ - "party": `{"format":"{host}: {guests}","guests":{"format":"{/person.first} {/person.last}","repeat":3,"separator":", "},"host":"{/person.first} {/person.last}"}`, + "party": `{"format":"{host}: {guests}","guests":{"format":"{/person as p}{p.first} {p.last}","repeat":3,"separator":", "},"host":"{/person as h}{h.first} {h.last}"}`, "person": drawPeople, }) f := newGenerator(t, dir, WithSeed(1)) @@ -379,10 +357,10 @@ func TestRepeatIterationsDrawReferencesAnew(t *testing.T) { } } -func TestRecordGroupsDrawApart(t *testing.T) { +func TestRecordNamesDrawApart(t *testing.T) { dir := writeData(t, map[string]string{ "person": drawPeople, - "transfer": `{"format":"{from_first} {from_last} to {to_first} {to_last}","from_first":{"format":"{/person.first}","drawGroup":"from"},"from_last":{"format":"{/person.last}","drawGroup":"from"},"to_first":{"format":"{/person.first}","drawGroup":"to"},"to_last":{"format":"{/person.last}","drawGroup":"to"}}`, + "transfer": `{"format":"{from_first} {from_last} to {to_first} {to_last}","from_first":"{/person as from}{from.first}","from_last":"{from.last}","to_first":"{/person as to}{to.first}","to_last":"{to.last}"}`, }) f := newGenerator(t, dir, WithSeed(1)) apart := map[string]bool{} @@ -398,14 +376,14 @@ func TestRecordGroupsDrawApart(t *testing.T) { from, to, _ := strings.Cut(fake(t, f, "transfer"), " to ") for view, pair := range map[string][2]string{"FakeRecord": {m["from_first"] + " " + m["from_last"], m["to_first"] + " " + m["to_last"]}, "Fake": {from, to}} { if !onePerson(pair[0]) || !onePerson(pair[1]) { - t.Fatalf("%s drew %q and %q, want each group one person", view, pair[0], pair[1]) + t.Fatalf("%s drew %q and %q, want each name one person", view, pair[0], pair[1]) } apart[view] = apart[view] || pair[0] != pair[1] } } for _, view := range []string{"FakeRecord", "Fake"} { if !apart[view] { - t.Errorf("%s: groups from and to drew one person in 100 renders, want a draw each", view) + t.Errorf("%s: names from and to drew one person in 100 renders, want a pick each", view) } } } @@ -432,22 +410,19 @@ func TestRecordColumnOfOneReferenceIsTheColumnItReads(t *testing.T) { cols[c.Name] = c } score, code := cols["score"], cols["code"] - agree := func(name string, want Column) bool { - return cols[name].Null == want.Null && cols[name].Value == want.Value - } + digit := func(c Column) bool { return len(c.Value) == 1 && c.Value >= "1" && c.Value <= "9" } + isScore := func(name string) bool { return cols[name].Null != digit(cols[name]) } switch { case score.DataType != DataTypeInteger || cols["chain"].DataType != DataTypeInteger || code.DataType != DataTypeInteger || cols["pick"].DataType != DataTypeInteger || cols["label"].DataType != DataTypeString || cols["mixed"].DataType != DataTypeString || cols["text"].DataType != DataTypeString: t.Fatalf("%s: want score, chain, code and pick integer columns, label, mixed and text string ones", r.JSON()) - case score.Null == (len(score.Value) == 1 && score.Value >= "1" && score.Value <= "9"): - t.Fatalf("score = %+v, want null or a digit from src.score", score) + case !isScore("score") || !isScore("same") || !isScore("chain") || !isScore("dot"): + t.Fatalf("%s: want score, same, chain and dot each null or a digit from src.score", r.JSON()) case code.Null == (code.Value == "200" || code.Value == "404"): t.Fatalf("code = %+v, want null or a code from src.code", code) - case !agree("same", score) || !agree("chain", score) || !agree("dot", score) || !agree("text", code): - t.Fatalf("%s: want every read of one src column one draw, through mid too", r.JSON()) - case cols["pick"].Value != "7" && !agree("pick", score), cols["mixed"].Null || cols["mixed"].Value != "n/a" && cols["mixed"].Value != score.Value: - t.Fatalf("pick = %+v, mixed = %+v beside score %+v: want pick 7 or the score's draw, mixed n/a or the score's text", cols["pick"], cols["mixed"], score) - case cols["label"].Null || cols["label"].Value != "n="+score.Value: - t.Fatalf("label = %+v beside score %+v, want the text of the read, a null as \"\"", cols["label"], score) + case cols["pick"].Value != "7" && !isScore("pick"), cols["mixed"].Null || cols["mixed"].Value != "n/a" && cols["mixed"].Value != "" && !digit(cols["mixed"]): + t.Fatalf("pick = %+v, mixed = %+v: want pick 7 or a score, mixed n/a or a score's text", cols["pick"], cols["mixed"]) + case cols["label"].Null || !strings.HasPrefix(cols["label"].Value, "n="): + t.Fatalf("label = %+v, want the text of the read, a null as \"\"", cols["label"]) case strings.Contains(r.JSON(), `"score":null`) != score.Null: t.Fatalf("JSON() = %s, want a null score written null", r.JSON()) } diff --git a/reference_test.go b/reference_test.go index da4f750..34c5dcc 100644 --- a/reference_test.go +++ b/reference_test.go @@ -218,25 +218,6 @@ 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","born":"1980"}]`, - "card": `"{/person.first} {/person.last}"`, - }) - f := newGenerator(t, dir, WithSeed(3)) - seen := map[string]bool{} - for i := 0; i < 50; i++ { - got := fake(t, f, "card") - if got != "Anna Andersson" && got != "Bo Berg" { - t.Fatalf("card = %q, want one person's first and last name", got) - } - seen[got] = true - } - if len(seen) != 2 { - t.Fatalf("card only ever rendered %v", seen) - } -} - 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"}]`, @@ -261,43 +242,23 @@ func TestBareReferenceDrawsEachTime(t *testing.T) { t.Fatal("two bare references always agreed; each should be its own draw") } -func TestReferenceOverlapIsRejected(t *testing.T) { - // A category never references itself, so the reads sit in a second category. +func TestReferencesIntoOneLevelLoad(t *testing.T) { cat := `{"format":"x","p":[{"format":"{first}","first":"A","last":"1"},{"format":"{first}","first":"B","last":"2"}]}` - for name, row := range map[string]string{ - "head beside a path": `"{/cat.p} {/cat.p.first}"`, - "sibling fields reading a level and a path into it": `{"format":"{a} {b}","a":"{/cat.p}","b":"{/cat.p.first}"}`, - "a field rendering the level a nested reference reads into": `{"format":"{x} {p}","x":"{/cat.p.first}","p":"{/cat.p}"}`, - "a bare reference beside a path into what it never renders": `{"format":"{a} {b}","a":"{/cat}","b":"{/cat.p.first}"}`, + for _, files := range []map[string]string{ + {"cat": cat, "row": `"{/cat.p} {/cat.p.first}"`}, + {"cat": cat, "row": `{"format":"{a} {b}","a":"{/cat.p}","b":"{/cat.p.first}"}`}, + {"cat": cat, "row": `{"format":"{x} {p}","x":"{/cat.p.first}","p":"{/cat.p}"}`}, + {"cat": cat, "row": `{"format":"{a} {b}","a":"{/cat}","b":"{/cat.p.first}"}`}, + {"row": `{"format":"{p.first} {/hop}","p":[{"format":"{first}","first":"A","last":"1"},{"format":"{first}","first":"B","last":"2"}]}`, "hop": `"{/row.p.last}"`}, + {"sv_SE/person": cat, "sv_SE/mail": `"{.person} <{/sv_SE.person.p.first}>"`}, } { - _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": cat, "row": row}))) - if err == nil || !strings.Contains(err.Error(), "renders its own draw of what") { - t.Errorf("%s: New = %v, want the overlap rejected", name, err) + f, err := New(WithoutShippedData(), WithDataPath(writeData(t, files)), WithSeed(1)) + if err != nil { + t.Errorf("New(%v) = %v, want each read a draw of its own", files, err) + continue } - } - for row, suffix := range map[string]string{ - `{"format":"{a} {b}","a":"{/cat}","b":"{/cat.p.first}"}`: "or give {b} a drawGroup", - `{"format":"{a}","a":"{/cat} {/cat.p.first}"}`: "name the fields you want instead", - } { - _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": cat, "row": row}))) - if err == nil || !strings.HasSuffix(err.Error(), suffix) { - t.Errorf("New(%s) = %v, want it to end %q", row, err, suffix) - } - } - // A sibling path and a reference into the level it holds are the same overlap, - // and the reference reaches it from a category row renders. - if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "row": `{"format":"{p.first} {/hop}","p":[{"format":"{first}","first":"A","last":"1"},{"format":"{first}","first":"B","last":"2"}]}`, - "hop": `"{/row.p.last}"`, - }))); err == nil || !strings.Contains(err.Error(), "by a second route") { - t.Errorf("New = %v, want a sibling path beside a reference into it rejected", err) - } - for _, row := range []string{ - `{"format":"{a} {b}","a":{"format":"{/cat.p}","drawGroup":"g"},"b":"{/cat.p.first}"}`, - `{"format":"{a} {b}","a":"{/cat}","b":{"format":"{/cat.p.first}","drawGroup":"g"}}`, - } { - if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": cat, "row": row}))); err != nil { - t.Errorf("New(%s) = %v, want a level and a path into it accepted in groups of their own", row, err) + for path := range files { + fake(t, f, strings.ReplaceAll(path, "/", ".")) } } } @@ -310,33 +271,13 @@ func onePerson(name string) bool { return last != "" && map[string]string{"Ada": "Lovelace", "Bo": "Ek", "Cy": "Young"}[first] == last } -func TestAReferencePathIsOneDrawPerRender(t *testing.T) { +func TestFieldsReadOneNameTheirCategoryBinds(t *testing.T) { dir := writeData(t, map[string]string{ - "apart": `{"format":"{a} & {b}","a":{"format":"{/person.first} {/person.last}","drawGroup":"x"},"b":{"format":"{/person.first} {/person.last}","drawGroup":"y"}}`, - "caller": `{"format":"{a} & {b}","a":{"format":"{/person.first} {/person.last}","drawGroup":"x"},"b":"{/pay}"}`, - "contact": `{"format":"{first} {last} <{email}>","email":"{lowercase(/person.first)}.{lowercase(/person.last)}@example.com","first":"{/person.first}","last":"{/person.last}"}`, - "iterations": `{"format":"{/person.first} {r}","drawGroup":"outer","r":{"format":"{a}={b}","repeat":3,"separator":",","a":"{/person.first}","b":{"format":"{/person.first}","drawGroup":"outer"}}}`, - "nested": `{"format":"{/person.first} {inner}","inner":"{/person.last}"}`, - "pair": `{"format":"{a} & {b}","a":"{/person.first} {/person.last}","b":"{/person.first} {/person.last}"}`, - "pay": `{"format":"{p}","p":{"format":"{/person.first} {/person.last}","drawGroup":"x"}}`, - "person": drawPeople, + "contact": `{"format":"{first} {last} <{email}>","email":"{lowercase(p.first)}.{lowercase(p.last)}@example.com","first":"{/person as p}{p.first}","last":"{p.last}"}`, + "person": drawPeople, }) f := newGenerator(t, dir, WithSeed(1)) - apart, local, noGroup := false, false, false for i := 0; i < 100; i++ { - a, b, _ := strings.Cut(fake(t, f, "caller"), " & ") - if !onePerson(a) || !onePerson(b) { - t.Fatalf("caller = %q & %q, want each one person", a, b) - } - local = local || a != b - _, iterations, _ := strings.Cut(fake(t, f, "iterations"), " ") - for _, pair := range strings.Split(iterations, ",") { - a, b, _ := strings.Cut(pair, "=") - noGroup = noGroup || a != b - } - if got := fake(t, f, "nested"); !onePerson(got) { - t.Fatalf("nested = %q, want a nested template's path one draw with its parent's", got) - } name, email, _ := strings.Cut(fake(t, f, "contact"), " <") first, last, _ := strings.Cut(name, " ") if !onePerson(name) || email != strings.ToLower(first)+"."+strings.ToLower(last)+"@example.com>" { @@ -350,24 +291,6 @@ func TestAReferencePathIsOneDrawPerRender(t *testing.T) { if !onePerson(c[1].Value+" "+c[2].Value) || c[0].Value != strings.ToLower(c[1].Value)+"."+strings.ToLower(c[2].Value)+"@example.com" { t.Fatalf("contact record %s, want first, last and email one person", r.JSON()) } - a, b, _ = strings.Cut(fake(t, f, "pair"), " & ") - if !onePerson(a) || a != b { - t.Fatalf("pair = %q & %q, want both fields one person", a, b) - } - a, b, _ = strings.Cut(fake(t, f, "apart"), " & ") - if !onePerson(a) || !onePerson(b) { - t.Fatalf("apart = %q & %q, want each group one person", a, b) - } - apart = apart || a != b - } - if !apart { - t.Error("groups x and y drew one person in 100 renders, want a draw each") - } - if !local { - t.Error("group x in caller and group x in the pay it references drew one person in 100 renders; a group name is local to its category") - } - if !noGroup { - t.Error("an iteration's plain read and its group outer always agreed; a repeat iteration renders in no group, so they are a draw each") } } @@ -390,19 +313,6 @@ func TestRelativeReferences(t *testing.T) { } } -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","born":"1980"}]`, - "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"`}, @@ -418,28 +328,6 @@ func TestReferenceSigilErrors(t *testing.T) { } } -func TestSpellingsOfOneReferenceAreOneLevel(t *testing.T) { - person := `[{"format":"{first} {last}","first":"Ada","last":"Byron"},{"format":"{first} {last}","first":"Bo","last":"Ek","born":"1990"}]` - _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "sv_SE/person": person, - "sv_SE/mail": `"{.person} <{/sv_SE.person.first}>"`, - }))) - if err == nil || !strings.Contains(err.Error(), "renders its own draw of what") || !strings.Contains(err.Error(), "{.person}") { - t.Errorf("New = %v, want the bare spelling rejected beside the path spelling", err) - } - dir := writeData(t, map[string]string{ - "sv_SE/word": `["alpha","beta","gamma"]`, - "sv_SE/loud": `"{/sv_SE.word} {uppercase(.word)}"`, - }) - f := newGenerator(t, dir, WithSeed(2)) - for i := 0; i < 50; i++ { - got := strings.Fields(fake(t, f, "sv_SE.loud")) - if len(got) != 2 || strings.ToUpper(got[0]) != got[1] { - t.Fatalf("loud = %q, want one draw under both spellings", got) - } - } -} - func TestSlashAfterAReferenceSigilIsRejected(t *testing.T) { for name, files := range map[string]map[string]string{ "after ..": {"sv_SE/person": `"Ada"`, "sv_SE/deep/a": `"{../person}"`}, diff --git a/render_test.go b/render_test.go index c30bdb5..10939bd 100644 --- a/render_test.go +++ b/render_test.go @@ -42,8 +42,8 @@ func TestGrowIsALowerBound(t *testing.T) { t.Fatalf("format %q did not compile to a template", format) } for i := 0; i < 50; i++ { - var draws renderDraws - if got := len(expand(f.rand, tmpl, renderScope{draws: &draws})); got < tmpl.compiled.grow { + var frames frameStack + if got := len(expand(f.rand, tmpl, renderScope{frames: &frames})); got < tmpl.compiled.grow { t.Errorf("format %q: expand emitted %d bytes, below grow %d", format, got, tmpl.compiled.grow) } } @@ -111,10 +111,9 @@ func TestNodeSwitchesHandleEveryKind(t *testing.T) { "contained": func(n node) { contained(n) }, "paths": func(n node) { paths(n) }, "prove": func(n node) { (&valueProof{}).prove(n) }, - "reads": func(n node) { newReadFold().reads(n) }, "render": func(n node) { - var draws renderDraws - render(engine(1).rand, n, renderScope{draws: &draws, row: renderedRow{tbl, 0}}) + var frames frameStack + render(engine(1).rand, n, renderScope{frames: &frames, row: renderedRow{tbl, 0}}) }, "renderEdges": func(n node) { renderEdges(n) }, "stepInto": func(n node) { _, _ = stepInto(n, "x") }, diff --git a/struct_test.go b/struct_test.go index b2b579f..18bba3e 100644 --- a/struct_test.go +++ b/struct_test.go @@ -93,8 +93,10 @@ func TestFakeStructFieldTaggedWithAColumnIsThatColumn(t *testing.T) { switch { case show(v.Del) != "nil": t.Fatalf("Del = %s, want nil from a column only ever null", show(v.Del)) - case show(v.Mixed) != "x" && show(v.Mixed) != score, show(v.Pair) != "5" && show(v.Pair) != score, show(v.Codes) != score && show(v.Codes) != code: - t.Fatalf("Mixed %s, Pair %s, Codes %s beside score %s and code %s: want each the literal or the draw of the column it reads", show(v.Mixed), show(v.Pair), show(v.Codes), score, code) + case !digitOrNil(show(v.Mixed)) && show(v.Mixed) != "x", !digitOrNil(show(v.Pair)) && show(v.Pair) != "5", !digitOrNil(show(v.Codes)) && show(v.Codes) != "200" && show(v.Codes) != "404": + t.Fatalf("Mixed %s, Pair %s, Codes %s: want each the literal or a draw of the column it reads", show(v.Mixed), show(v.Pair), show(v.Codes)) + case score != "nil" && !digitOrNil(score), code != "nil" && code != "200" && code != "404": + t.Fatalf("Score %s, Code %s: want each nil or a draw of its column", score, code) } switch { case v.Code == nil: @@ -102,11 +104,11 @@ func TestFakeStructFieldTaggedWithAColumnIsThatColumn(t *testing.T) { case *v.Code != "200" && *v.Code != "404": t.Fatalf("Code = %q, want nil or a code, never a null's \"\"", *v.Code) } - switch { - case v.Score == nil && v.Label == "n=": + if v.Score == nil { nils["score"]++ - case v.Score == nil || *v.Score < 1 || *v.Score > 9 || v.Label != "n="+strconv.FormatInt(*v.Score, 10): - t.Fatalf("%+v, want Score nil beside Label n=, or one digit in both", v) + } + if label, _ := strings.CutPrefix(v.Label, "n="); !strings.HasPrefix(v.Label, "n=") || label != "" && !digitOrNil(label) { + t.Fatalf("Label %q, want n= and a draw of src.score, a null as \"\"", v.Label) } } for _, name := range []string{"code", "score"} { @@ -116,6 +118,28 @@ func TestFakeStructFieldTaggedWithAColumnIsThatColumn(t *testing.T) { } } +func digitOrNil(s string) bool { + return s == "nil" || len(s) == 1 && s >= "1" && s <= "9" +} + +func TestFakeStructPathTagsReadOnePickOfTheirCategory(t *testing.T) { + f := structData(t) + var v struct { + Leg string `fake:"trip.leg"` + To string `fake:"trip.leg.to"` + Name string `fake:"person"` + Last string `fake:"person.last"` + } + for i := 0; i < 100; i++ { + if err := f.FakeStruct(&v); err != nil { + t.Fatal(err) + } + if v.Leg != v.To || v.Name != "Ada "+v.Last && v.Name != "Bo "+v.Last { + t.Fatalf("%+v, want the tags into trip one leg, and into person one person", v) + } + } +} + func TestFakeStructFillsTaggedFields(t *testing.T) { a, b := structData(t), structData(t) people := map[string]string{"Ada": "Lovelace", "Bo": "Ek"} @@ -133,8 +157,8 @@ func TestFakeStructFillsTaggedFields(t *testing.T) { switch { case !reflect.DeepEqual(u, twin): t.Fatalf("same seed diverged: %+v != %+v", u, twin) - case people[u.First] != u.Last || u.Email != strings.ToLower(u.First)+"@example.com": - t.Fatalf("person fields %q %q %q, want one person drawn across the struct", u.First, u.Last, u.Email) + case people[u.First] != u.Last || u.Email != "ada@example.com" && u.Email != "bo@example.com": + t.Fatalf("person fields %q %q %q, want one person across the path tags, and the template its own draw", u.First, u.Last, u.Email) case zips[u.Home.City] != u.Home.Zip || u.Work == nil || zips[u.Work.City] != u.Work.Zip: t.Fatalf("places %+v, %+v, want each nested struct one place, the pointer allocated", u.Home, u.Work) case u.ID != int64(i+1) || u.Age < 18 || u.Age > 99 || u.Score < 0 || u.Score > 1 || u.Rank == nil || (*u.Rank != 1 && *u.Rank != 2): @@ -162,8 +186,8 @@ func TestFakeStructFillsEmbeddedFieldsIntoItsRecord(t *testing.T) { if err := f.FakeStruct(&e); err != nil { t.Fatal(err) } - if e.StructFamily == nil || people[e.First] != e.Last || e.Email != strings.ToLower(e.First)+"@example.com" { - t.Fatalf("%+v, %+v: want the promoted fields one person with the struct's own, the embedded pointer allocated", e, e.StructFamily) + if e.StructFamily == nil || people[e.First] != e.Last { + t.Fatalf("%+v, %+v: want the promoted fields one person, the embedded pointer allocated", e, e.StructFamily) } } var skipped struct { @@ -293,10 +317,6 @@ func TestFakeStructErrors(t *testing.T) { {&struct { A string `fake:"{x}"` }{}, `no field "x"`}, - {&struct { - A string `fake:"trip.leg"` - B string `fake:"trip.leg.to"` - }{}, "renders its own draw of what"}, {&struct { Trip struct { A int `fake:"{digits(3)}"` diff --git a/table_test.go b/table_test.go index 44d8d17..5e8c8e5 100644 --- a/table_test.go +++ b/table_test.go @@ -1,7 +1,6 @@ package fejkdata import ( - "maps" "os" "path/filepath" "reflect" @@ -237,13 +236,10 @@ func TestTableDescendsToALinkedTable(t *testing.T) { func TestTableStepsUpToTheRowItLinksTo(t *testing.T) { files := with(geo(), map[string]string{ - "drawn.json": `"{/locality.code}|{/locality..municipality.code}|{/locality..municipality..region.code}"`, "named.json": `"{/locality as l}{l.code}|{l..municipality.code}|{l..municipality..region.code}"`, - "down.json": `"{/locality.code}|{/locality..municipality.locality.code}"`, "named-down.json": `"{/locality as l}{l.code}|{l..municipality.locality.code}"`, - "twice.json": `"{/locality..municipality.locality.code}|{/locality..municipality.locality.name}"`, "named-twice.json": `"{/locality as l}{l..municipality.locality.code}|{l..municipality.locality.name}"`, - "record.json": `{"format":"","l":"{/locality.code}","m":"{/locality..municipality.code}"}`, + "record.json": `{"format":"","l":"{/locality as x}{x.code}","m":"{x..municipality.code}"}`, }) f := newGenerator(t, writeFiles(t, files), WithSeed(4)) for path, want := range map[string]string{ @@ -264,11 +260,8 @@ func TestTableStepsUpToTheRowItLinksTo(t *testing.T) { names := map[string]string{"L1": "Stockholm", "L2": "Solna", "L3": "Malmö", "L4": "Lund", "L5": "Göteborg", "L6": "Hisingen", "L7": "Sandby", "L8": "Sandby"} afresh := map[string]bool{} for i := 0; i < 200; i++ { - for _, path := range []string{"drawn", "named"} { - parts := strings.Split(fake(t, f, path), "|") - if municipalityOf[parts[0]] != parts[1] || regionOf[parts[1]] != parts[2] { - t.Fatalf("%s = %v, want the locality's own municipality and region", path, parts) - } + if parts := strings.Split(fake(t, f, "named"), "|"); municipalityOf[parts[0]] != parts[1] || regionOf[parts[1]] != parts[2] { + t.Fatalf("named = %v, want the locality's own municipality and region", parts) } if l := fake(t, f, "locality[L4]..municipality.locality.code"); l != "L4" && l != "L7" { t.Fatalf("locality[L4]..municipality.locality.code = %q, outside Lund", l) @@ -278,17 +271,11 @@ func TestTableStepsUpToTheRowItLinksTo(t *testing.T) { if m := fake(t, f, "locality[L4]..municipality..region.municipality.code"); regionOf[m] != "12" { t.Fatalf("locality[L4]..municipality..region.municipality.code = %q, outside 12", m) } - for _, path := range []string{"down", "named-down"} { - parts := strings.Split(fake(t, f, path), "|") - if municipalityOf[parts[0]] != municipalityOf[parts[1]] { - t.Fatalf("%s = %v, want a locality of the first one's municipality", path, parts) - } + if parts := strings.Split(fake(t, f, "named-down"), "|"); municipalityOf[parts[0]] != municipalityOf[parts[1]] { + t.Fatalf("named-down = %v, want a locality of the first one's municipality", parts) } - for _, path := range []string{"twice", "named-twice"} { - parts := strings.Split(fake(t, f, path), "|") - if names[parts[0]] != parts[1] { - t.Fatalf("%s = %v, want both reads of one step down to read one locality", path, parts) - } + if parts := strings.Split(fake(t, f, "named-twice"), "|"); names[parts[0]] != parts[1] { + t.Fatalf("named-twice = %v, want both reads of one step down under a name to read one locality", parts) } down, err := f.FakeRecord("locality[L4]..municipality.locality") if err != nil { @@ -316,11 +303,6 @@ func TestTableStepsUpToTheRowItLinksTo(t *testing.T) { if err := f.FakeStruct(&tagged); err != nil || tagged.M != "Lund" { t.Errorf("a struct tag stepping up = %q, %v; want Lund", tagged.M, err) } - if _, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, with(geo(), map[string]string{ - "x.json": `"{/locality..municipality.code}|{/locality[L1].name}"`, - })))); err == nil || !strings.Contains(err.Error(), "{/locality[L1]..municipality.code}") { - t.Errorf("New = %v, want the fence to name {/locality[L1]..municipality.code}", err) - } for _, p := range f.List() { if strings.Contains(p, "..") { t.Fatalf("List() advertises %q; it lists direct descents only", p) @@ -359,11 +341,12 @@ func TestTableStepUpRefusals(t *testing.T) { func TestLinkedTablesDrawConsistently(t *testing.T) { spellings := map[string]string{ - "leaf first": `{"format":"{l}|{m}|{r}","l":"{/locality.code}","m":"{/municipality.code}","r":"{/region.code}"}`, - "root first": `{"format":"{r}|{m}|{l}","r":"{/region.code}","m":"{/municipality.code}","l":"{/locality.code}"}`, - "skipping one": `{"format":"{r}|{l}","r":"{/region.code}","l":"{/locality.code}"}`, - "nested": `{"format":"{a}|{l}","a":{"format":"{r}|{m}","r":"{/region.code}","m":"{/municipality.code}"},"l":"{/locality.code}"}`, - "a record": `{"format":"","l":"{/locality.code}","m":"{/municipality.code}","r":"{/region.code}"}`, + "leaf first": `{"format":"{l}|{m}|{r}","l":"{/locality as x}{x.code}","m":"{x..municipality.code}","r":"{x..municipality..region.code}"}`, + "root first": `{"format":"{r}|{m}|{l}","r":"{/region as x}{x.code}","m":"{x.municipality.code}","l":"{x.municipality.locality.code}"}`, + "skipping one": `{"format":"{r}|{l}","r":"{/region as x}{x.code}","l":"{x.locality.code}"}`, + "a skip read first": `{"format":"{l}|{m}|{r}","l":"{/region as x}{x.locality.code}","m":"{x.municipality.code}","r":"{x.code}"}`, + "nested": `{"format":"{a}|{l}","a":{"format":"{r}|{m}","r":"{x..municipality..region.code}","m":"{x..municipality.code}"},"l":"{/locality as x}{x.code}"}`, + "a record": `{"format":"","l":"{/locality as x}{x.code}","m":"{x..municipality.code}","r":"{x..municipality..region.code}"}`, } for name, addr := range spellings { f := newGenerator(t, writeFiles(t, with(geo(), map[string]string{"addr.json": addr})), WithSeed(5)) @@ -406,9 +389,9 @@ func TestLinkedTablesDrawConsistently(t *testing.T) { } } -func TestLinkedTablesDrawApartAcrossGroupsAndRepeats(t *testing.T) { +func TestLinkedTablesDrawApartAcrossReadsAndRepeats(t *testing.T) { files := with(geo(), map[string]string{ - "groups.json": `{"format":"{a}|{b}","a":{"format":"{/locality.code}","drawGroup":"g"},"b":"{/locality.code}"}`, + "groups.json": `"{/locality.code}|{/region.locality.code}"`, "many.json": `{"format":"{x}","x":{"format":"{/locality.code}","repeat":8,"separator":"|"}}`, }) f := newGenerator(t, writeFiles(t, files), WithSeed(9)) @@ -421,14 +404,14 @@ func TestLinkedTablesDrawApartAcrossGroupsAndRepeats(t *testing.T) { } } if !differ { - t.Fatalf("%s: every draw agreed in 100 renders, want groups and repeat iterations drawn apart", path) + t.Fatalf("%s: every draw agreed in 100 renders, want reads and repeat iterations drawn apart", path) } } } func TestSiblingTablesDrawInsideOneAncestor(t *testing.T) { files := with(siblings(), map[string]string{ - "addr.json": `{"format":"{s}|{p}|{l}","s":"{/street.name}","p":"{/postal-code.code}","l":"{/locality.code}"}`, + "addr.json": `{"format":"{s}|{p}|{l}","s":"{/locality as x}{x.street.name}","p":"{x.postal-code.code}","l":"{x.code}"}`, }) f := newGenerator(t, writeFiles(t, files), WithSeed(3)) seen := map[string]bool{} @@ -463,7 +446,7 @@ func TestSiblingTablesDrawInsideOneAncestor(t *testing.T) { func TestTableSelectorInAReference(t *testing.T) { files := with(geo(), map[string]string{ - "x.json": `{"format":"{a} {b} {c}","a":"{/region[12].name}","b":"{/region[12].municipality.code}","c":"{/region[12].locality.code}"}`, + "x.json": `{"format":"{a} {b} {c}","a":"{/region[12] as x}{x.name}","b":"{x.municipality.code}","c":"{x.locality.code}"}`, }) f := newGenerator(t, writeFiles(t, files), WithSeed(1)) for i := 0; i < 100; i++ { @@ -487,47 +470,31 @@ func TestTableSelectorInAReference(t *testing.T) { } } -func TestTableSelectionFences(t *testing.T) { - rejected := map[string]struct{ json, want string }{ - "bare beside a path": {`"{/region} {/region.name}"`, "renders its own draw of what"}, - "bare beside a linked path": {`"{/region} {/locality.name}"`, "renders its own draw of what"}, - "selected beside unselected": {`"{/region[12].municipality.name} {/municipality.code}"`, "{/region[12].municipality.code}"}, - "relative selected beside unselected": {`"{.region[12].municipality.name} {.municipality.code}"`, "{.region[12].municipality.code}"}, - "two selections": {`"{/region[12].name} {/region[14].name}"`, "drawGroup"}, - "selection beside a descendant": {`"{/region[12].name} {/locality.code}"`, "{/region[12].locality.code}"}, - } - for name, c := range rejected { - _, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, with(geo(), map[string]string{"x.json": c.json})))) - if err == nil || !strings.Contains(err.Error(), c.want) { - t.Errorf("%s: New = %v, want it rejected mentioning %q", name, err, c.want) - } - } - accepted := map[string]string{ - "a descent beside a path into it": `"{/region.municipality} {/region.municipality.name}"`, - "selections in two groups": `{"format":"{a} {b}","a":{"format":"{/region[12].name}","drawGroup":"a"},"b":"{/region[14].name}"}`, - "one selection twice": `"{/region[12].name} {/region[12].timezone} {/region[12].locality.name}"`, - "a bare table in another group": `{"format":"{a} {b}","a":{"format":"{/region}","drawGroup":"a"},"b":"{/locality.name}"}`, - } - for name, json := range accepted { +func TestTableReadsBesideEachOtherLoad(t *testing.T) { + for _, json := range []string{ + `"{/region} {/region.name}"`, + `"{/region} {/locality.name}"`, + `"{/region[12].municipality.name} {/municipality.code}"`, + `"{.region[12].municipality.name} {.municipality.code}"`, + `"{/region[12].name} {/region[14].name}"`, + `"{/region[12].name} {/locality.code}"`, + `"{/region.municipality} {/region.municipality.name}"`, + `"{/region[12].name} {/region[12].timezone} {/region[12].locality.name}"`, + `"{/region[12].locality[L4].name} {/region[12].locality[L7].name}"`, + `"{/region[14].name} {/locality[L4].name}"`, + } { f, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, with(geo(), map[string]string{"x.json": json}))), WithSeed(1)) if err != nil { - t.Errorf("%s: New = %v, want it accepted", name, err) + t.Errorf("%s: New = %v, want each read a draw of its own", json, err) continue } fake(t, f, "x") } - f := newGenerator(t, writeFiles(t, with(geo(), map[string]string{"x.json": accepted["a descent beside a path into it"]})), WithSeed(1)) - for i := 0; i < 100; i++ { - if parts := strings.Fields(fake(t, f, "x")); parts[0] != parts[1] { - t.Fatalf("x = %v, want the descended row and the path into it to agree", parts) - } - } } -// The family fence replays each read's selectors through the walk the render uses, so -// spellings that pin the same rows by different routes agree, and two rows of one -// table are refused at load rather than found at render. -func TestTableFamilyFenceReplaysPins(t *testing.T) { +// A selector names one row wherever it is written, so spellings that select the same rows by +// different routes agree. +func TestSelectedRowsAgreeAcrossSpellings(t *testing.T) { accepted := map[string]string{ "skip-level selectors": `"{/region[12].locality[L4].name}|{/region[12].name}"`, "descendant then ancestor": `"{/locality[L4].name}|{/region[12].name}"`, @@ -548,16 +515,6 @@ func TestTableFamilyFenceReplaysPins(t *testing.T) { } } } - rejected := map[string]struct{ json, want string }{ - "two rows of one table": {`"{/region[12].locality[L4].name} {/region[12].locality[L7].name}"`, "drawGroup"}, - "a row outside a selected ancestor": {`"{/region[14].name} {/locality[L4].name}"`, "drawGroup"}, - } - for name, c := range rejected { - _, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, with(geo(), map[string]string{"x.json": c.json})))) - if err == nil || !strings.Contains(err.Error(), c.want) { - t.Errorf("%s: New = %v, want it rejected mentioning %q", name, err, c.want) - } - } } func TestTableFences(t *testing.T) { @@ -569,63 +526,52 @@ func TestTableFences(t *testing.T) { files map[string]string want string }{ - "rows names a missing file": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`}, "t.tsv"}, - "a TSV nothing names": {with(base, map[string]string{"stray.tsv": "a\nx\n"}), "stray.tsv"}, - "rows outside its folder": {with(base, map[string]string{"t.json": `{"format":"{code}","rows":"../region.tsv"}`}), "beside"}, - "rows not a tsv": {with(base, map[string]string{"t.json": `{"format":"{code}","rows":"region.txt"}`, "region.txt": "code\n1\n"}), ".tsv"}, - "key names no column": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"b"}`, "t.tsv": "a\nx\ny\n"}, `"b"`}, - "name names no column": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","name":"b"}`, "t.tsv": "a\nx\ny\n"}, `"b"`}, - "weight names no column": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","weight":"b"}`, "t.tsv": "a\nx\ny\n"}, `"b"`}, - "parent names no column": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","parent":"b"}`, "t.tsv": "a\nx\ny\n"}, `"b"`}, - "key equals name": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a","name":"a"}`, "t.tsv": "a\nx\ny\n"}, "drop"}, - "duplicate key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a"}`, "t.tsv": "a\nx\nx\n"}, `"x"`}, - "empty key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a"}`, "t.tsv": "a\tb\n\ty\nx\tz\n"}, "empty"}, - "a bracket in a key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a"}`, "t.tsv": "a\nx[1]\ny\n"}, `"["`}, - "a brace in a name": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a","name":"n"}`, "t.tsv": "a\tn\nx\tx{1}\ny\ty\n"}, `"{"`}, - "name without a key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","name":"a"}`, "t.tsv": "a\nx\nx\n"}, "key"}, - "a name repeating inside one parent row": {with(siblings(), map[string]string{"street.json": `{"format":"{name}","rows":"street.tsv","name":"name","parent":"locality"}`, "street.tsv": "name\tlocality\nAvenyn\tL1\nAvenyn\tL1\nStorgatan\tL2\nStorgatan\tL3\nStorgatan\tL4\nStorgatan\tL5\nStorgatan\tL6\nStorgatan\tL7\nStorgatan\tL8\n"}), `"Avenyn"`}, - "a name that is another row's key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a","name":"n"}`, "t.tsv": "a\tn\nx\ty\ny\tz\n"}, `"y"`}, - "a cell reading its family": {with(geo(), map[string]string{"locality.tsv": "code\tname\tmunicipality\tnote\nL1\tStockholm\t0180\t{/municipality.code}\nL2\tSolna\t0184\t-\nL3\tMalmö\t1280\t-\nL4\tLund\t1281\t-\nL5\tGöteborg\t1480\t-\n"}), "family"}, - "a format reading its family": {with(geo(), map[string]string{"locality.json": `{"format":"{name} {/region.name}","rows":"locality.tsv","key":"code","name":"name","parent":"municipality"}`}), "family"}, - "a format's reference beside a path into it": {map[string]string{"x.json": `{"format":"{v} {/z}","rows":"x.tsv","key":"code"}`, "x.tsv": "code\tv\n1\ta\n2\tb\n", "z.json": `{"format":"{a}","a":["p","q"]}`, "r.json": `"{/x} {/z.a}"`}, "renders its own draw of what {/z.a}"}, - "a selected row's format selecting another row": {map[string]string{"x.json": `{"format":"{v} {/y[1].w}","rows":"x.tsv","key":"code"}`, "x.tsv": "code\tv\n1\ta\n2\tb\n", "y.json": `{"format":"{w}","rows":"y.tsv","key":"k"}`, "y.tsv": "k\tw\n1\tm\n2\tn\n", "r.json": `"{/x[1]} {/y[2].w}"`}, "two rows of y"}, - "a whole table's format selecting another row": {map[string]string{"x.json": `{"format":"{v} {/y[1].w}","rows":"x.tsv","key":"code"}`, "x.tsv": "code\tv\n1\ta\n2\tb\n", "y.json": `{"format":"{w}","rows":"y.tsv","key":"k"}`, "y.tsv": "k\tw\n1\tm\n2\tn\n", "r.json": `"{/x} {/y[2].w}"`}, "two rows of y"}, - "a descendant named like an ancestor's column": {with(geo(), map[string]string{"region.tsv": "code\tname\tpopulation\tlocality\n01\tStockholms län\t2400000\tx\n12\tSkåne län\t1400000\ty\n14\tVästra Götalands län\t1750000\tz\n"}), `"locality"`}, - "weight not a number": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","weight":"w"}`, "t.tsv": "a\tw\nx\tmany\ny\t2\n"}, `"many"`}, - "weight zero": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","weight":"w"}`, "t.tsv": "a\tw\nx\t0\ny\t2\n"}, "0"}, - "weight negative": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","weight":"w"}`, "t.tsv": "a\tw\nx\t-1\ny\t2\n"}, "-1"}, - "reserved column name": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\tb.c\nx\ty\n"}, `"b.c"`}, - "duplicate column": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\ta\nx\ty\n"}, `"a"`}, - "empty column name": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\t\nx\ty\n"}, "empty"}, - "short row": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\tb\nx\ty\nz\n"}, "line 3"}, - "no rows": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\n"}, "no rows"}, - "a blank line after the rows": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\tb\nx\t1\ny\t2\n\n"}, "line 4"}, - "a blank line in one column": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\nx\n\ny\n"}, "line 3"}, - "a table reaching its family through a template": {with(geo(), map[string]string{"addr.json": `"{/locality.name}"`, "region.tsv": "code\tname\tpopulation\ttimezone\tnote\n01\tStockholms län\t2400000\tEurope/Stockholm\t{/addr}\n12\tSkåne län\t1400000\tEurope/Stockholm\t-\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\t-\n"}), "family"}, - "a table reaching its family through a repeat": {with(geo(), map[string]string{"addr.json": `{"format":"{/locality.name} ","repeat":3}`, "region.tsv": "code\tname\tpopulation\ttimezone\tnote\n01\tStockholms län\t2400000\tEurope/Stockholm\t{/addr}\n12\tSkåne län\t1400000\tEurope/Stockholm\t-\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\t-\n"}), "family"}, - "a table reaching its family through a group": {with(geo(), map[string]string{"addr.json": `{"format":"{/locality.name}","drawGroup":"g"}`, "region.tsv": "code\tname\tpopulation\ttimezone\tnote\n01\tStockholms län\t2400000\tEurope/Stockholm\t{/addr}\n12\tSkåne län\t1400000\tEurope/Stockholm\t-\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\t-\n"}), "family"}, - "a selected row reaching its family's other row": {with(geo(), map[string]string{"a.json": `"{/region[01].note}"`, "addr.json": `{"format":"{/region[12].name}","drawGroup":"g"}`, "region.tsv": "code\tname\tpopulation\ttimezone\tnote\n01\tStockholms län\t2400000\tEurope/Stockholm\t{/addr}\n12\tSkåne län\t1400000\tEurope/Stockholm\t-\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\t-\n"}), "family"}, - "one row": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\nx\n"}, "one row"}, - "empty file": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": ""}, "header"}, - "parent is not a table": {map[string]string{"p.json": `"x"`, "t.json": `{"format":"{a}","rows":"t.tsv","parent":"p"}`, "t.tsv": "a\tp\nx\tx\ny\tx\n"}, "not a table"}, - "parent does not exist": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","parent":"p"}`, "t.tsv": "a\tp\nx\tx\ny\tx\n"}, `"p"`}, - "parent in another folder": {map[string]string{"g/p.json": `{"format":"{k}","rows":"p.tsv","key":"k"}`, "g/p.tsv": "k\nx\ny\n", "t.json": `{"format":"{a}","rows":"t.tsv","parent":"p"}`, "t.tsv": "a\tp\nx\tx\ny\ty\n"}, `"p"`}, - "parent has no key": {map[string]string{"p.json": `{"format":"{k}","rows":"p.tsv"}`, "p.tsv": "k\nx\ny\n", "t.json": `{"format":"{a}","rows":"t.tsv","parent":"p"}`, "t.tsv": "a\tp\nx\tx\ny\ty\n"}, "key"}, - "dangling link": {with(base, map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","parent":"region"}`, "t.tsv": "a\tregion\nx\t01\ny\t99\n"}), `"99"`}, - "childless parent row": {with(base, map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","parent":"region"}`, "t.tsv": "a\tregion\nx\t01\ny\t01\n"}), `"12"`}, - "parent cycle": {map[string]string{"a.json": `{"format":"{k}","rows":"a.tsv","key":"k","parent":"b"}`, "a.tsv": "k\tb\nx\tx\ny\ty\n", "b.json": `{"format":"{k}","rows":"b.tsv","key":"k","parent":"a"}`, "b.tsv": "k\ta\nx\tx\ny\ty\n"}, "cycle"}, - "child named like a column": {with(base, map[string]string{"name.json": `{"format":"{a}","rows":"name.tsv","parent":"region"}`, "name.tsv": "a\tregion\nx\t01\ny\t12\n"}), `"name"`}, - "rows nested in a field": {map[string]string{"t.json": `{"format":"{x}","x":{"format":"{a}","rows":"x.tsv"}}`, "x.tsv": "a\nx\ny\n"}, "category"}, - "rows in a choice item": {map[string]string{"t.json": `[{"format":"{a}","rows":"t.tsv"},"y"]`, "t.tsv": "a\nx\ny\n"}, "category"}, - "unknown table option": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","fields":"a"}`, "t.tsv": "a\nx\ny\n"}, "a table takes"}, - "repeat on a table": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","repeat":2}`, "t.tsv": "a\nx\ny\n"}, "a table takes"}, - "drawGroup on a table": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","drawGroup":"g"}`, "t.tsv": "a\nx\ny\n"}, "a table takes"}, - "option not a string": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":1}`, "t.tsv": "a\nx\ny\n"}, "string"}, - "format names no column": {map[string]string{"t.json": `{"format":"{b}","rows":"t.tsv"}`, "t.tsv": "a\nx\ny\n"}, `no column "b"`}, - "format reads into a column": {map[string]string{"t.json": `{"format":"{a.x}","rows":"t.tsv"}`, "t.tsv": "a\nx\ny\n"}, `"a"`}, - "a reference into a column": {with(base, map[string]string{"t.json": `"{/region.name.x}"`}), "column"}, - "a category referencing itself": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\n{/t.a}\ny\n"}, "names the category it sits in"}, - "a cell repeating a reference": {with(base, map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\nx\n{/region} {/region} {uppercase(/region)}\n"}), "t.a, line 3: token {/region} is repeated"}, + "rows names a missing file": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`}, "t.tsv"}, + "a TSV nothing names": {with(base, map[string]string{"stray.tsv": "a\nx\n"}), "stray.tsv"}, + "rows outside its folder": {with(base, map[string]string{"t.json": `{"format":"{code}","rows":"../region.tsv"}`}), "beside"}, + "rows not a tsv": {with(base, map[string]string{"t.json": `{"format":"{code}","rows":"region.txt"}`, "region.txt": "code\n1\n"}), ".tsv"}, + "key names no column": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"b"}`, "t.tsv": "a\nx\ny\n"}, `"b"`}, + "name names no column": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","name":"b"}`, "t.tsv": "a\nx\ny\n"}, `"b"`}, + "weight names no column": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","weight":"b"}`, "t.tsv": "a\nx\ny\n"}, `"b"`}, + "parent names no column": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","parent":"b"}`, "t.tsv": "a\nx\ny\n"}, `"b"`}, + "key equals name": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a","name":"a"}`, "t.tsv": "a\nx\ny\n"}, "drop"}, + "duplicate key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a"}`, "t.tsv": "a\nx\nx\n"}, `"x"`}, + "empty key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a"}`, "t.tsv": "a\tb\n\ty\nx\tz\n"}, "empty"}, + "a bracket in a key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a"}`, "t.tsv": "a\nx[1]\ny\n"}, `"["`}, + "a brace in a name": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a","name":"n"}`, "t.tsv": "a\tn\nx\tx{1}\ny\ty\n"}, `"{"`}, + "name without a key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","name":"a"}`, "t.tsv": "a\nx\nx\n"}, "key"}, + "a name repeating inside one parent row": {with(siblings(), map[string]string{"street.json": `{"format":"{name}","rows":"street.tsv","name":"name","parent":"locality"}`, "street.tsv": "name\tlocality\nAvenyn\tL1\nAvenyn\tL1\nStorgatan\tL2\nStorgatan\tL3\nStorgatan\tL4\nStorgatan\tL5\nStorgatan\tL6\nStorgatan\tL7\nStorgatan\tL8\n"}), `"Avenyn"`}, + "a name that is another row's key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a","name":"n"}`, "t.tsv": "a\tn\nx\ty\ny\tz\n"}, `"y"`}, + "a descendant named like an ancestor's column": {with(geo(), map[string]string{"region.tsv": "code\tname\tpopulation\tlocality\n01\tStockholms län\t2400000\tx\n12\tSkåne län\t1400000\ty\n14\tVästra Götalands län\t1750000\tz\n"}), `"locality"`}, + "weight not a number": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","weight":"w"}`, "t.tsv": "a\tw\nx\tmany\ny\t2\n"}, `"many"`}, + "weight zero": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","weight":"w"}`, "t.tsv": "a\tw\nx\t0\ny\t2\n"}, "0"}, + "weight negative": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","weight":"w"}`, "t.tsv": "a\tw\nx\t-1\ny\t2\n"}, "-1"}, + "reserved column name": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\tb.c\nx\ty\n"}, `"b.c"`}, + "duplicate column": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\ta\nx\ty\n"}, `"a"`}, + "empty column name": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\t\nx\ty\n"}, "empty"}, + "short row": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\tb\nx\ty\nz\n"}, "line 3"}, + "no rows": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\n"}, "no rows"}, + "a blank line after the rows": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\tb\nx\t1\ny\t2\n\n"}, "line 4"}, + "a blank line in one column": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\nx\n\ny\n"}, "line 3"}, + "one row": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\nx\n"}, "one row"}, + "empty file": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": ""}, "header"}, + "parent is not a table": {map[string]string{"p.json": `"x"`, "t.json": `{"format":"{a}","rows":"t.tsv","parent":"p"}`, "t.tsv": "a\tp\nx\tx\ny\tx\n"}, "not a table"}, + "parent does not exist": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","parent":"p"}`, "t.tsv": "a\tp\nx\tx\ny\tx\n"}, `"p"`}, + "parent in another folder": {map[string]string{"g/p.json": `{"format":"{k}","rows":"p.tsv","key":"k"}`, "g/p.tsv": "k\nx\ny\n", "t.json": `{"format":"{a}","rows":"t.tsv","parent":"p"}`, "t.tsv": "a\tp\nx\tx\ny\ty\n"}, `"p"`}, + "parent has no key": {map[string]string{"p.json": `{"format":"{k}","rows":"p.tsv"}`, "p.tsv": "k\nx\ny\n", "t.json": `{"format":"{a}","rows":"t.tsv","parent":"p"}`, "t.tsv": "a\tp\nx\tx\ny\ty\n"}, "key"}, + "dangling link": {with(base, map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","parent":"region"}`, "t.tsv": "a\tregion\nx\t01\ny\t99\n"}), `"99"`}, + "childless parent row": {with(base, map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","parent":"region"}`, "t.tsv": "a\tregion\nx\t01\ny\t01\n"}), `"12"`}, + "parent cycle": {map[string]string{"a.json": `{"format":"{k}","rows":"a.tsv","key":"k","parent":"b"}`, "a.tsv": "k\tb\nx\tx\ny\ty\n", "b.json": `{"format":"{k}","rows":"b.tsv","key":"k","parent":"a"}`, "b.tsv": "k\ta\nx\tx\ny\ty\n"}, "cycle"}, + "child named like a column": {with(base, map[string]string{"name.json": `{"format":"{a}","rows":"name.tsv","parent":"region"}`, "name.tsv": "a\tregion\nx\t01\ny\t12\n"}), `"name"`}, + "rows nested in a field": {map[string]string{"t.json": `{"format":"{x}","x":{"format":"{a}","rows":"x.tsv"}}`, "x.tsv": "a\nx\ny\n"}, "category"}, + "rows in a choice item": {map[string]string{"t.json": `[{"format":"{a}","rows":"t.tsv"},"y"]`, "t.tsv": "a\nx\ny\n"}, "category"}, + "unknown table option": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","fields":"a"}`, "t.tsv": "a\nx\ny\n"}, "a table takes"}, + "repeat on a table": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","repeat":2}`, "t.tsv": "a\nx\ny\n"}, "a table takes"}, + "option not a string": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":1}`, "t.tsv": "a\nx\ny\n"}, "string"}, + "format names no column": {map[string]string{"t.json": `{"format":"{b}","rows":"t.tsv"}`, "t.tsv": "a\nx\ny\n"}, `no column "b"`}, + "format reads into a column": {map[string]string{"t.json": `{"format":"{a.x}","rows":"t.tsv"}`, "t.tsv": "a\nx\ny\n"}, `"a"`}, + "a reference into a column": {with(base, map[string]string{"t.json": `"{/region.name.x}"`}), "column"}, + "a category referencing itself": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\n{/t.a}\ny\n"}, "names the category it sits in"}, } for name, c := range rejected { _, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, c.files))) @@ -637,6 +583,29 @@ func TestTableFences(t *testing.T) { t.Errorf("%s: New = %v, want it to mention %q", name, err, c.want) } } + accepted := map[string]map[string]string{ + "a cell reading its family": with(geo(), map[string]string{"locality.tsv": "code\tname\tmunicipality\tnote\nL1\tStockholm\t0180\t{/municipality.code}\nL2\tSolna\t0184\t-\nL3\tMalmö\t1280\t-\nL4\tLund\t1281\t-\nL5\tGöteborg\t1480\t-\n"}), + "a format reading its family": with(geo(), map[string]string{"locality.json": `{"format":"{name} {/region.name}","rows":"locality.tsv","key":"code","name":"name","parent":"municipality"}`}), + "a format's reference beside a path into it": map[string]string{"x.json": `{"format":"{v} {/z}","rows":"x.tsv","key":"code"}`, "x.tsv": "code\tv\n1\ta\n2\tb\n", "z.json": `{"format":"{a}","a":["p","q"]}`, "r.json": `"{/x} {/z.a}"`}, + "a selected row's format selecting another row": map[string]string{"x.json": `{"format":"{v} {/y[1].w}","rows":"x.tsv","key":"code"}`, "x.tsv": "code\tv\n1\ta\n2\tb\n", "y.json": `{"format":"{w}","rows":"y.tsv","key":"k"}`, "y.tsv": "k\tw\n1\tm\n2\tn\n", "r.json": `"{/x[1]} {/y[2].w}"`}, + "a whole table's format selecting another row": map[string]string{"x.json": `{"format":"{v} {/y[1].w}","rows":"x.tsv","key":"code"}`, "x.tsv": "code\tv\n1\ta\n2\tb\n", "y.json": `{"format":"{w}","rows":"y.tsv","key":"k"}`, "y.tsv": "k\tw\n1\tm\n2\tn\n", "r.json": `"{/x} {/y[2].w}"`}, + "a table reaching its family through a template": with(geo(), map[string]string{"addr.json": `"{/locality.name}"`, "region.tsv": "code\tname\tpopulation\ttimezone\tnote\n01\tStockholms län\t2400000\tEurope/Stockholm\t{/addr}\n12\tSkåne län\t1400000\tEurope/Stockholm\t-\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\t-\n"}), + "a table reaching its family through a repeat": with(geo(), map[string]string{"addr.json": `{"format":"{/locality.name} ","repeat":3}`, "region.tsv": "code\tname\tpopulation\ttimezone\tnote\n01\tStockholms län\t2400000\tEurope/Stockholm\t{/addr}\n12\tSkåne län\t1400000\tEurope/Stockholm\t-\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\t-\n"}), + "a selected row reaching its family's other row": with(geo(), map[string]string{"a.json": `"{/region[01].note}"`, "addr.json": `{"format":"{/region[12].name}","drawGroup":"g"}`, "region.tsv": "code\tname\tpopulation\ttimezone\tnote\n01\tStockholms län\t2400000\tEurope/Stockholm\t{/addr}\n12\tSkåne län\t1400000\tEurope/Stockholm\t-\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\t-\n"}), + "a cell repeating a reference": with(base, map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\nx\n{/region} {/region} {uppercase(/region)}\n"}), + } + for name, files := range accepted { + f, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, files)), WithSeed(1)) + if err != nil { + t.Errorf("%s: New = %v, want each read a draw of its own", name, err) + continue + } + for _, path := range f.List() { + if _, err := f.Fake(path); err != nil { + t.Errorf("%s: Fake(%s) = %v", name, path, err) + } + } + } if _, err := New(WithoutShippedData(), WithDataFS(os.DirFS(writeFiles(t, base)))); err != nil { t.Fatalf("New(WithDataFS) = %v, want a table loaded from any fs.FS", err) } @@ -774,18 +743,29 @@ func TestTableRowsAreAlternatives(t *testing.T) { } } } - rejected := map[string]map[string]string{ - "one cell selecting two rows": {"country.tsv": "alpha2\tname\tmoney\nFI\tFinland\t{/cur[EUR].sym}{/cur[SEK].sym}\nSE\tSweden\t{/cur[SEK].sym}\n"}, - "two tables' cells in one render": {"z.json": `"{/country.money} {/y.sym}"`}, - "the format beside a cell": {"country.json": `{"format":"{money} {/cur[SEK].sym}","rows":"country.tsv","key":"alpha2"}`}, - "two columns of one row": {"country.json": `{"format":"{a} {b}","rows":"country.tsv","key":"alpha2"}`, "country.tsv": "alpha2\ta\tb\nFI\t{/cur[SEK].sym}\t{/cur[EUR].sym}\nSE\t{/cur[EUR].sym}\t{/cur[SEK].sym}\n"}, - "a whole read beside a path": {"country.json": `{"format":"{a} {b}","rows":"country.tsv","key":"alpha2"}`, "country.tsv": "alpha2\ta\tb\nFI\t{/cur}\t{/cur[EUR].sym}\nSE\t{/cur}\t{/cur[SEK].sym}\n"}, - "a cell reaching another's cells": {"y.json": `{"format":"{sym}","rows":"y.tsv","key":"k"}`, "y.tsv": "k\tsym\n1\t{/cur[SEK].sym}\n2\t{/cur[USD].sym}\n", "cur.tsv": "code\tsym\nEUR\t€\nSEK\tkr\nUSD\t$\n", "country.tsv": "alpha2\tname\tmoney\nFI\tFinland\t{/y[1].sym} {/cur[EUR].sym}\nSE\tSweden\t{/cur[SEK].sym}\n"}, + twoRows := map[string]map[string]string{ + "two draws landing on cells selecting two rows": {"z.json": `"{/country}|{/country}"`}, + "one cell selecting two rows": {"country.tsv": "alpha2\tname\tmoney\nFI\tFinland\t{/cur[EUR].sym}{/cur[SEK].sym}\nSE\tSweden\t{/cur[SEK].sym}\n"}, + "two tables' cells in one render": {"z.json": `"{/country.money} {/y.sym}"`}, + "the format beside a cell": {"country.json": `{"format":"{money} {/cur[SEK].sym}","rows":"country.tsv","key":"alpha2"}`}, + "two columns of one row": {"country.json": `{"format":"{a} {b}","rows":"country.tsv","key":"alpha2"}`, "country.tsv": "alpha2\ta\tb\nFI\t{/cur[SEK].sym}\t{/cur[EUR].sym}\nSE\t{/cur[EUR].sym}\t{/cur[SEK].sym}\n"}, + "a whole read beside a path": {"country.json": `{"format":"{a} {b}","rows":"country.tsv","key":"alpha2"}`, "country.tsv": "alpha2\ta\tb\nFI\t{/cur}\t{/cur[EUR].sym}\nSE\t{/cur}\t{/cur[SEK].sym}\n"}, + "a cell reaching another's cells": {"y.json": `{"format":"{sym}","rows":"y.tsv","key":"k"}`, "y.tsv": "k\tsym\n1\t{/cur[SEK].sym}\n2\t{/cur[USD].sym}\n", "cur.tsv": "code\tsym\nEUR\t€\nSEK\tkr\nUSD\t$\n", "country.tsv": "alpha2\tname\tmoney\nFI\tFinland\t{/y[1].sym} {/cur[EUR].sym}\nSE\tSweden\t{/cur[SEK].sym}\n"}, } - for name, more := range rejected { - _, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, with(files, more)))) - if err == nil || !strings.Contains(err.Error(), "drawGroup") { - t.Errorf("%s: New = %v, want it refused", name, err) + for name, more := range twoRows { + g, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, with(files, more))), WithSeed(1)) + if err != nil { + t.Errorf("%s: New = %v, want each read a draw of its own", name, err) + continue + } + paths := []string{"country"} + if _, defined := more["z.json"]; defined { + paths = append(paths, "z") + } + for i := 0; i < 50; i++ { + for _, path := range paths { + fake(t, g, path) + } } } } @@ -913,9 +893,9 @@ func TestShippedTables(t *testing.T) { t.Fatalf("territory draws %d distinct rows in 5000, want the full register", len(count)) } for i := 0; i < 200; i++ { - got := fakeTemplate(t, f, `{/territory.alpha2} {/timezone.territory}`) + got := fakeTemplate(t, f, `{/timezone as z}{z..territory.alpha2} {z.territory}`) if drawn, zone, _ := strings.Cut(got, " "); drawn != zone { - t.Fatalf("%q: a territory and a timezone in one render disagree on the territory", got) + t.Fatalf("%q: a timezone and the territory it steps up to disagree", got) } } var row struct { @@ -1110,86 +1090,20 @@ func TestAmbiguousNameNamesARunnablePath(t *testing.T) { } } -// TestEnteredRowsAgreeWithPinning holds what a fence walk decides about the rows it entered, and -// the rows a render draws, to what pinning refuses: drift between them is data that loads and then -// renders a family that disagrees. A row rendered whole is drawn over the whole table, apart from -// every pin, so it is an alternative only to another row of its own draw. -func TestEnteredRowsAgreeWithPinning(t *testing.T) { +// TestAStepSitsAtTheSegmentItConsumes holds a path's compiled steps to its segments. +func TestAStepSitsAtTheSegmentItConsumes(t *testing.T) { f := newGenerator(t, writeFiles(t, geo()), WithSeed(1)) - var tables []*table - for _, category := range []string{"region", "municipality", "locality"} { - tbl, isTable := f.root.children[category].(*table) - if !isTable { - t.Fatalf("%s is not a table", category) - } - tables = append(tables, tbl) - } - var none pinSet - for _, entered := range tables { - for row := 0; row < entered.rowCount(); row++ { - whole := pathRead{} - whole.branches.wholePins.add(entered, row) - s := none.entered(entered, row) - for _, cand := range tables { - for i := 0; i < 20; i++ { - drawing := s.clone() - if r := cand.drawIn(f.rand, &drawing); s.clash(cand, r) != nil { - t.Errorf("a render draws %s beside %s, which pinning it there refuses", cand.selectorSpelling(r), entered.selectorSpelling(row)) - } - } - for cr := 0; cr < cand.rowCount(); cr++ { - beside := s.clone() - refused := beside.pinRow(cand, cr) != nil - candAt := pathRead{branches: branches{pins: none.entered(cand, cr)}} - if got := coRender(pathRead{branches: branches{pins: s}}, candAt); got == refused { - t.Errorf("coRender(%s, %s) = %v, but pinning both refuses = %v", entered.selectorSpelling(row), cand.selectorSpelling(cr), got, refused) - } - if !coRender(whole, candAt) || !coRender(candAt, whole) { - t.Errorf("%s rendered whole is an alternative to %s pinned, though the whole draw ignores the pin", entered.selectorSpelling(row), cand.selectorSpelling(cr)) - } - other := pathRead{} - other.branches.wholePins.add(cand, cr) - if want := cand == entered && cr != row; coRender(whole, other) == want { - t.Errorf("coRender(%s, %s), both rendered whole, = %v, want %v", entered.selectorSpelling(row), cand.selectorSpelling(cr), want, !want) - } - } - } - } - } - var spilled pinSet - for i := 0; i <= len(spilled.inline); i++ { - spilled = spilled.entered(&table{path: strconv.Itoa(i)}, 0) - } - _ = spilled.entered(&table{path: "past"}, 0) - n := 0 - spilled.each(func(*table, int) { n++ }) - if n != len(spilled.inline)+1 { - t.Errorf("entering a row beside a spilled pin set leaves it holding %d rows, want %d", n, len(spilled.inline)+1) - } -} - -// TestTableReadReportsTheTablesADrawPins holds a path's compiled steps to the render: the tables -// its table read says it draws are those a draw of it pins beyond what its selectors pin, each -// selector is credited to the table it selects a row of, and a step naming a segment sits at it. -func TestTableReadReportsTheTablesADrawPins(t *testing.T) { - f := newGenerator(t, writeFiles(t, geo()), WithSeed(1)) - for _, c := range []struct{ path, sels string }{ - {"locality.code", ""}, - {"municipality.locality.name", ""}, - {"municipality[1281].locality.name", "municipality=municipality[1281]"}, - {"municipality.locality[L4].name", "locality=municipality.locality[L4]"}, - {"region", ""}, - {"region.municipality", ""}, - {"region.municipality.name", ""}, - {"region.locality.name", ""}, - {"region[12].locality", "region=region[12]"}, - {"region[12].locality.code", "region=region[12]"}, - {"region[12].locality[L4].name", "region=region[12] locality=region[12].locality[L4]"}, - {"region[12].municipality.locality.code", "region=region[12]"}, - {"region[12].municipality[1281].locality.code", "region=region[12] municipality=region[12].municipality[1281]"}, - {"region[12].name", "region=region[12]"}, + for _, path := range []string{ + "locality.code", + "municipality.locality.name", + "municipality[1281].locality.name", + "municipality.locality[L4].name", + "region.municipality.name", + "region[12].locality[L4].name", + "region[12].municipality[1281].locality.code", + "region[12].name", } { - a := splitArm(c.path, nil) + a := splitArm(path, nil) head := f.root.children[a.head].(*table) a.steps = compilePath(head, a.tail).steps for _, st := range a.steps { @@ -1203,75 +1117,28 @@ func TestTableReadReportsTheTablesADrawPins(t *testing.T) { continue } if st.at >= len(a.tail) || a.tail[st.at] != seg { - t.Errorf("%s: step %q sits at %d, want the segment it consumes", c.path, seg, st.at) - } - } - tr := tableReadOf(head, a, nil) - var sels []string - for _, s := range tr.sels { - sels = append(sels, s.t.segment+"="+s.spelling) - } - if got := strings.Join(sels, " "); got != c.sels { - t.Errorf("%s: the table read credits the selectors %q, want %q", c.path, got, c.sels) - } - selected, drawn := tr.pins, tr.drawn - for i := 0; i < 20; i++ { - var pins pinSet - drawSteps(f.rand, head, a.steps, &pins, nil, nil) - want := map[*table]bool{} - pins.each(func(tbl *table, _ int) { - if _, isSelected := selected.pinned(tbl); !isSelected { - want[tbl] = true - } - }) - if !maps.Equal(drawn, want) { - t.Fatalf("%s: the table read reports %v drawn, a draw pins %v", c.path, segmentsOf(drawn), segmentsOf(want)) + t.Errorf("%s: step %q sits at %d, want the segment it consumes", path, seg, st.at) } } } } -func segmentsOf(tables map[*table]bool) []string { - var out []string - for tbl := range tables { - out = append(out, tbl.segment) - } - slices.Sort(out) - return out -} - -// TestCellReadsMeetWhereTheRenderPairsTheRows reads what the TSVs below do not show: region 01's -// note holds {/addr} and 1280's, under region 12, holds {/addr.city}, so the two meet only where -// no read pins a region. -func TestCellReadsMeetWhereTheRenderPairsTheRows(t *testing.T) { +// TestCellReadsLoadAndRender reads what the TSVs below do not show: region 01's note holds +// {/addr} and 1280's holds {/addr.city}, and each read of a cell draws on its own. +func TestCellReadsLoadAndRender(t *testing.T) { cells := with(geo(), map[string]string{ "addr.json": `{"format":"{city}","city":["Lund","Malmö"]}`, "municipality.tsv": "code\tname\tregion\tpopulation\tnote\n0180\tStockholm\t01\t980000\t-\n0184\tSolna\t01\t85000\t-\n1280\tMalmö\t12\t360000\t{/addr.city}\n1281\tLund\t12\t130000\t-\n1480\tGöteborg\t14\t590000\t-\n", "region.tsv": "code\tname\tpopulation\tnote\n01\tStockholms län\t2400000\t{/addr}\n12\tSkåne län\t1400000\t-\n14\tVästra Götalands län\t1750000\t-\n", }) - paths := with(cells, map[string]string{"x.json": `"{/region.note} {/municipality.note}"`}) - f, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, paths)), WithSeed(1)) - if err != nil { - t.Fatalf("New = %v, want the two cells accepted", err) - } - fake(t, f, "x") - whole := with(cells, map[string]string{ - "municipality.json": `{"format":"{name}={note}","rows":"municipality.tsv","key":"code","name":"name","parent":"region","weight":"population"}`, - "region.json": `{"format":"{name}={note}","rows":"region.tsv","key":"code","name":"name","weight":"population"}`, - "x.json": `"{/region}|{/municipality}"`, - }) - if _, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, whole)), WithSeed(1)); err == nil || !strings.HasSuffix(err.Error(), "or move {/municipality} into a field with a drawGroup") { - t.Fatalf("New = %v, want the two drawn rows held to one draw of addr, and the drawGroup named on a field around the read", err) - } - nested := with(whole, map[string]string{ - "municipality.tsv": "code\tname\tregion\tpopulation\tnote\n0180\tStockholm\t01\t980000\t-\n0184\tSolna\t01\t85000\t-\n1280\tMalmö\t12\t360000\t{/shop}\n1281\tLund\t12\t130000\t{/addr.city}\n1480\tGöteborg\t14\t590000\t-\n", - "region.tsv": "code\tname\tpopulation\tnote\n01\tStockholms län\t2400000\t-\n12\tSkåne län\t1400000\t-\n14\tVästra Götalands län\t1750000\t-\n", - "shop.json": `{"format":"{name}={note}","rows":"shop.tsv","key":"code","name":"name"}`, - "shop.tsv": "code\tname\tnote\nS1\tKiosk\t{/addr}\nS2\tBod\t-\n", - "x.json": `"{/municipality}"`, - }) - if _, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, nested)), WithSeed(1)); err != nil { - t.Fatalf("New = %v, want 1280's shop and 1281's city kept apart, one draw of municipality rendering one of them", err) + for _, x := range []string{`"{/region.note} {/municipality.note}"`, `"{/region}|{/municipality}"`} { + f, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, with(cells, map[string]string{"x.json": x}))), WithSeed(1)) + if err != nil { + t.Fatalf("New(%s) = %v, want the cells accepted", x, err) + } + for i := 0; i < 50; i++ { + fake(t, f, "x") + } } } diff --git a/transform_test.go b/transform_test.go index b6b2b38..9eca754 100644 --- a/transform_test.go +++ b/transform_test.go @@ -15,9 +15,9 @@ func TestTransforms(t *testing.T) { } } -func TestTransformReadsTheHeldDraw(t *testing.T) { +func TestTransformReadsTheNamedDraw(t *testing.T) { f := engine(2) - src := `{"format":"{p.first}={lowercase(p.first)}","p":[{"format":"{first}","first":"Anna"},{"format":"{first}","first":"Bo"}]}` + src := `{"format":"{p as q}{q.first}={lowercase(q.first)}","p":[{"format":"{first}","first":"Anna"},{"format":"{first}","first":"Bo"}]}` for i := 0; i < 50; i++ { if got := mustRender(t, f, src); got != "Anna=anna" && got != "Bo=bo" { t.Fatalf("render = %q, want the transform over the same draw", got) @@ -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","born":"1990"}]`, - "email": `"{/person.first} {/person.last} <{lowercase(ascii(/person.first))}.{lowercase(ascii(/person.last))}@example.com>"`, + "email": `"{/person as p}{p.first} {p.last} <{lowercase(ascii(p.first))}.{lowercase(ascii(p.last))}@example.com>"`, }) f := newGenerator(t, dir, WithSeed(5)) for i := 0; i < 50; i++ { @@ -45,7 +45,6 @@ func TestTransformArgs(t *testing.T) { `{"format":"{lowercase(nope)}","x":"v"}`, `{"format":"{ascii(x,y)}","x":"v","y":"w"}`, `{"format":"{lowercase(hex(2))}","x":"v"}`, - `{"format":"{lowercase(x)} {x.a}","x":{"format":"{a}","a":"1"}}`, } { if _, err := f.NewTemplate(bad); err == nil { t.Errorf("NewTemplate(%s) = nil error, want it rejected", bad)