From 1a022dbd791080f09fe83af07a069a0ef2e04101 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 7 Oct 2026 22:51:44 +0200 Subject: [PATCH] Test a declared datatype over a column read, repeats counting as written, null alone on stdin, one leading slash, and the CLI's lone reference against the grammar --- cmd/fejkdata/lone_test.go | 47 +++++++++++++++++++++++ cmd/fejkdata/main_test.go | 3 ++ datatype_test.go | 35 ++++++++++++++--- imports_test.go | 7 ++++ inline_test.go | 20 +++++----- loading_test.go | 28 ++------------ name_test.go | 36 ++++++++++------- node_test.go | 1 - path_test.go | 9 +++-- one_spelling_test.go => spellings_test.go | 28 ++++++++------ struct_test.go | 3 ++ 11 files changed, 146 insertions(+), 71 deletions(-) create mode 100644 cmd/fejkdata/lone_test.go rename one_spelling_test.go => spellings_test.go (75%) diff --git a/cmd/fejkdata/lone_test.go b/cmd/fejkdata/lone_test.go new file mode 100644 index 0000000..eacbb2d --- /dev/null +++ b/cmd/fejkdata/lone_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "testing" + + "github.com/larvit/fejkdata/internal/grammar" +) + +// grammarLoneReference is the path a format reads when the grammar parses it as one path +// read of one reference from the root, and nothing else. +func grammarLoneReference(text string) (string, bool) { + toks, err := grammar.ParseFormat(text) + if err != nil || len(toks) != 1 || toks[0].Kind != grammar.PathRead || len(toks[0].Arms) != 1 { + return "", false + } + sigil, path, err := grammar.RefShape(toks[0].Arms[0]) + if err != nil || sigil != "/" { + return "", false + } + return path, true +} + +func TestLoneReferenceAgreesWithTheGrammar(t *testing.T) { + for _, text := range []string{ + "{/sv_SE.person}", + "{/misc.currency[US Dollar (Next day)]}", + "{/t[a as b].c}", + "{/t[f(x)]}", + "{/a}{/b}", + "{/x}}", + "{/}", + "{/.x}", + " {/x}", + "{/a|/b}", + "{/x as p}", + "{//x}", + "{/x(1)}", + "{.x}", + "x", + } { + path, lone := loneReference(text) + want, wantLone := grammarLoneReference(text) + if lone != wantLone || path != want { + t.Errorf("loneReference(%q) = %q, %v; the grammar reads %q, %v", text, path, lone, want, wantLone) + } + } +} diff --git a/cmd/fejkdata/main_test.go b/cmd/fejkdata/main_test.go index 8637126..975a8d0 100644 --- a/cmd/fejkdata/main_test.go +++ b/cmd/fejkdata/main_test.go @@ -249,6 +249,7 @@ func TestRunTakesNoOperand(t *testing.T) { want string }{ {[]string{"sv_SE.person"}, "echo '{/sv_SE.person}' | fejkdata"}, + {[]string{"/en_US.address"}, "echo '{/en_US.address}' | fejkdata"}, {[]string{"{/en_US.person}"}, "echo '{/en_US.person}' | fejkdata"}, {[]string{"name: {/sv_SE.person.last}"}, "echo 'name: {/sv_SE.person.last}' | fejkdata"}, {[]string{"geo.US.locality[O'Fallon].name"}, `echo '{/geo.US.locality[O'\''Fallon].name}' | fejkdata`}, @@ -494,6 +495,8 @@ func TestRunTemplateMisuse(t *testing.T) { "42": "number", "42\n": "number", "true\n": "boolean", + "null\n": "only null", + " null ": "only null", "{//sv_SE.person}": "write {/sv_SE.person}", } { code, out, errb := runOut(stdin, "--seed", "1") diff --git a/datatype_test.go b/datatype_test.go index 1c1bfa2..74adee7 100644 --- a/datatype_test.go +++ b/datatype_test.go @@ -48,17 +48,16 @@ func TestDatatypeRejectsAValueItsTypeRejects(t *testing.T) { {"a weighted item beside a typed one", `[{"format":"1","datatype":"integer"},{"format":"2","weight":3}]`, `give it "datatype": "integer"`}, {"an item beside a typed column it reads", `["{/src.score}","5"]`, `write it as {"format":"5","datatype":"integer"}`}, {"a typed item beside a string column it reads", `["{/src.code}",{"format":"1","datatype":"integer"}]`, `write it as {"format":"{/src.code}","datatype":"integer"}`}, - {"text beside a typed column it reads", `["{/src.score}","n/a"]`, `to read that column as text, write {"format":"{text}","text":"{/src.score}"}`}, - {"a datatype over a typed column", `{"format":"{/src.score}","datatype":"integer"}`, `{/src.score} takes datatype integer from the column it reads; drop "datatype"`}, + {"text beside a typed column it reads", `["{/src.score}","n/a"]`, `to read that column as text, write "{/src.score}" as {"format":"{/src.score}","datatype":"string"}`}, {"a datatype a typed column's values reject", `{"format":"{/src.score}","datatype":"boolean"}`, "{int(1,9)} prints an integer, not a boolean"}, - {"two typed columns it reads", `["{/src.score}","{/src.flag}"]`, `so to read "{/src.score}" as text, write {"format":"{text}","text":"{/src.score}"}`}, + {"two typed columns it reads", `["{/src.score}","{/src.flag}"]`, `so to read "{/src.score}" as text, write "{/src.score}" as {"format":"{/src.score}","datatype":"string"}`}, {"a typed column read that holds the datatype declared beside it", `[{"format":"1.5","datatype":"number"},"{/src.score}"]`, `so write "{/src.score}" as {"format":"{/src.score}","datatype":"number"}`}, {"a typed column read holding the datatype of another", `["{/src.ratio}","{/src.score}"]`, `so write "{/src.ratio}" as {"format":"{/src.ratio}","datatype":"integer"}`}, - {"text before a typed column it reads", `["n/a","{/src.score}"]`, `to read that column as text, write {"format":"{text}","text":"{/src.score}"}`}, + {"text before a typed column it reads", `["n/a","{/src.score}"]`, `to read that column as text, write "{/src.score}" as {"format":"{/src.score}","datatype":"string"}`}, {"a weighted item beside a typed column it reads", `["{/src.score}",{"format":"5","weight":2}]`, `give it "datatype": "integer"`}, {"a typed column read before the datatype it holds", `["{/src.score}",{"format":"1.5","datatype":"number"}]`, `so write "{/src.score}" as {"format":"{/src.score}","datatype":"number"}`}, - {"a typed column read beside a datatype it does not hold", `["{/src.score}",{"format":"true","datatype":"boolean"}]`, `so to read "{/src.score}" as text, write {"format":"{text}","text":"{/src.score}"}`}, - {"text beside a weighted typed column read", `[{"format":"{/src.score}","weight":3},"n/a"]`, `to read that column as text, set its "format" to "{text}" and add "text": "{/src.score}"`}, + {"a typed column read beside a datatype it does not hold", `["{/src.score}",{"format":"true","datatype":"boolean"}]`, `so to read "{/src.score}" as text, write "{/src.score}" as {"format":"{/src.score}","datatype":"string"}`}, + {"text beside a weighted typed column read", `[{"format":"{/src.score}","weight":3},"n/a"]`, `to read that column as text, give "{/src.score}" "datatype": "string"`}, {"a value of the column it reads", `{"format":"{/src.code}","datatype":"integer"}`, `"2x" is not an integer`}, {"a null read into text", `{"format":"{x}","x":"{/src.score}","datatype":"integer"}`, "reads a null"}, {"a sample with leading zeros", `{"format":"{digits(3)}","datatype":"integer"}`, "{digits(3)} prints text, not an integer"}, @@ -233,3 +232,27 @@ func TestASignedZeroIsZero(t *testing.T) { t.Errorf("columns = %+v, want each written as its format", c) } } + +func TestADeclaredDatatypeOverAColumnRead(t *testing.T) { + f := newGenerator(t, writeData(t, map[string]string{ + "src": `{"format":"","score":[null,{"format":"{int(1,9)}","datatype":"integer"}]}`, + "row": `{"format":"","same":{"format":"{/src.score}","datatype":"integer"},"text":{"format":"{/src.score}","datatype":"string"}}`, + }), WithSeed(1)) + empty := 0 + for i := 0; i < 50; i++ { + r, err := f.FakeRecord("row") + if err != nil { + t.Fatal(err) + } + same, text := r.Columns()[0], r.Columns()[1] + if same.DataType != DataTypeInteger || text.DataType != DataTypeString || text.Null { + t.Fatalf("columns = %+v, want an integer column restated and a string column never null", r.Columns()) + } + if text.Value == "" { + empty++ + } + } + if empty == 0 { + t.Error(`50 records never read a null as "", want the string column to render one so`) + } +} diff --git a/imports_test.go b/imports_test.go index 2c59138..854d118 100644 --- a/imports_test.go +++ b/imports_test.go @@ -40,6 +40,9 @@ func TestPackageImports(t *testing.T) { "internal/proven": {"internal/datatype", "internal/grammar", "internal/invariant"}, "internal/rows": {"internal/drawstate", "internal/grammar", "internal/invariant"}, } + testsAlso := map[string][]string{ + "cmd/fejkdata": {"internal/grammar"}, + } seen := map[string]bool{} eachPackage(t, func(dir string, pkg *build.Package) { seen[dir] = true @@ -49,6 +52,10 @@ func TestPackageImports(t *testing.T) { return } for _, imp := range slices.Concat(pkg.Imports, pkg.TestImports, pkg.XTestImports) { + may := may + if !slices.Contains(pkg.Imports, imp) { + may = slices.Concat(may, testsAlso[dir]) + } if imp != module && !strings.HasPrefix(imp, module+"/") { continue } diff --git a/inline_test.go b/inline_test.go index 98d7cd0..62026df 100644 --- a/inline_test.go +++ b/inline_test.go @@ -175,22 +175,22 @@ func TestIsTemplate(t *testing.T) { "{//sv_SE.person}": true, "{/misc.currency[US Dollar (Next day)]}": true, "/sv_SE.person": false, - "//sv_SE.person": false, } { if got, err := IsTemplate(arg); err != nil || got != want { t.Errorf("IsTemplate(%q) = %v, %v; want %v", arg, got, err, want) } } for arg, want := range map[string]string{ - "[abc]": `starts with "["`, - "[abc].field": `starts with "["`, - "x[1]y": `"]"`, - "x[[1]]": `"["`, - "x[]": "empty", - "a]b": `holds a "]"`, - "a}b": `holds a "}"`, - `"abc`: `holds a "\""`, - `"a]b`: `holds a "\""`, // the opener the reader typed, not the bracket behind it + "[abc]": `starts with "["`, + "[abc].field": `starts with "["`, + "x[1]y": `"]"`, + "x[[1]]": `"["`, + "x[]": "empty", + "//sv_SE.person": "write /sv_SE.person", + "a]b": `holds a "]"`, + "a}b": `holds a "}"`, + `"abc`: `holds a "\""`, + `"a]b`: `holds a "\""`, // the opener the reader typed, not the bracket behind it } { if _, err := IsTemplate(arg); err == nil || !strings.Contains(err.Error(), want) { t.Errorf("IsTemplate(%q) = %v; want it rejected naming %s", arg, err, want) diff --git a/loading_test.go b/loading_test.go index d8cdac8..5746340 100644 --- a/loading_test.go +++ b/loading_test.go @@ -302,27 +302,6 @@ func TestNewErrors(t *testing.T) { map[string]string{"a": `{"format":"{x}","x":"1","-":"2"}`}, `field "-" is reserved`, }, - // A repeated arm skews an alternation, which weight is the spelling for. - "repeated alternation arm": { - map[string]string{"a": `{"format":"{x|x}","x":"1"}`}, - `arm "x" is repeated`, - }, - "repeated arm among others": { - map[string]string{"a": `{"format":"{x|y|x}","x":"1","y":"2"}`}, - `arm "x" is repeated`, - }, - "repeated path arm": { - map[string]string{"a": `{"format":"{p.v|p.v}","p":{"format":"{v}","v":"1"}}`}, - `arm "p.v" is repeated`, - }, - // A reference arm is the only kind that reaches the repeat check by passing - // the per-arm checks rather than falling through them. - "repeated reference arm": { - map[string]string{"a": `"x"`, "b": `"{/a|/a}"`}, - `arm "/a" is repeated`, - }, - // An arm that is broken on its own terms is reported as that, not as a - // repeat: the repeat is a consequence of the real mistake. "repeated arm with no path": { map[string]string{"a": `"{/|/}"`}, "reference has no path", @@ -361,9 +340,10 @@ func TestNewErrors(t *testing.T) { "folder named Repeat": {map[string]string{"Repeat/cat": `"1"`}, "Repeat.cat", "1"}, "field with a closing paren": {map[string]string{"a": `{"format":"{b)c}","b)c":"2"}`}, "a", "2"}, "repeat without a separator": {map[string]string{"a": `{"format":"{x}","repeat":3,"x":"1"}`}, "a", "111"}, - // One name in two separate tokens is two independent draws, not a repeated - // arm; only a repeat within one alternation is rejected. - "one name in two tokens": {map[string]string{"a": `{"format":"{x}{x}","x":"1"}`}, "a", "11"}, + "one name in two tokens": {map[string]string{"a": `{"format":"{x}{x}","x":"1"}`}, "a", "11"}, + "repeated alternation arm": {map[string]string{"a": `{"format":"{x|x}","x":"1"}`}, "a", "1"}, + "repeated path arm": {map[string]string{"a": `{"format":"{p.v|p.v}","p":{"format":"{v}","v":"1"}}`}, "a", "1"}, + "repeated reference arm": {map[string]string{"a": `"x"`, "b": `"{/a|/a}"`}, "b", "x"}, } for name, c := range accepted { f, err := New(WithoutShippedData(), WithDataPath(writeData(t, c.files))) diff --git a/name_test.go b/name_test.go index 63d5660..29d397e 100644 --- a/name_test.go +++ b/name_test.go @@ -186,8 +186,6 @@ func TestNameErrors(t *testing.T) { `"format" is an option and can never be a name`}, {"no reference or field", map[string]string{"card": `{"format":"{word as w}{w}{w}","words":["a","b"]}`}, `no field "word"; a binding names a field, or a reference`}, - {"read once through a field", map[string]string{"card": `{"format":"{place as p}{p.x}","place":{"format":"{x}","x":["a","b"]}}`}, - ""}, {"read once through a path that does not resolve", map[string]string{"word": `{"format":"{w}","w":["a","b"]}`, "card": `"{/word as a}{a.zz}"`}, `no field "zz"`}, {"read once through a field path that does not resolve", map[string]string{"card": `{"format":"{place as p}{p.zz}","place":{"format":"{x}","x":["a","b"]}}`}, @@ -198,22 +196,10 @@ func TestNameErrors(t *testing.T) { ""}, {"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", map[string]string{"word": `{"format":"{w}","w":["a","b"]}`, "card": `"{/word as a}{a.w} {/word.w}"`}, - ""}, - {"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}"`}, - ""}, - {"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}"`}, - ""}, - {"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}"}`}, - ""}, - {"read once whole, as a transform's operand", map[string]string{"word": `["a","b"]`, "card": `"{/word as w}{uppercase(w)}"`}, - ""}, {"rendered twice where a path reads it", map[string]string{"word": `{"format":"{w}-{w}","w":["a","b"]}`, "card": `"{/word as n}{n}|{n.w}"`}, `{n} renders field "w" twice`}, {"a binding in a cell", map[string]string{"word": `["a","b"]`, "t.json": `{"format":"{k}","rows":"t.tsv","key":"k"}`, "t.tsv": "k\tc\nx\t{/word as w}{w}{w}\ny\tz\n"}, @@ -258,6 +244,28 @@ func TestNameErrors(t *testing.T) { } } +func TestANameReadOnceLoads(t *testing.T) { + for name, files := range map[string]map[string]string{ + "through a path": {"word": `{"format":"{w}","w":["a","b"]}`, "card": `"{/word as a}{a.w} {/word.w}"`}, + "whole, bound to a reference path": {"word": `{"format":"{w}","w":["a","b"]}`, "card": `"{/word.w as c}{c} {/word.w}"`}, + "through a step up": {"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}"`}, + "as a calc operand": {"word": `["1","2"]`, "card": `"{/word as w}{calc(w * 2)}"`}, + "whole": {"word": `["a","b"]`, "card": `{"format":"{/word as w}{x}","x":"{w}"}`}, + "whole, as a transform's operand": {"word": `["a","b"]`, "card": `"{/word as w}{uppercase(w)}"`}, + } { + withJSON := map[string]string{} + for file, body := range files { + if !strings.Contains(file, ".") { + file += ".json" + } + withJSON[file] = body + } + if _, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, withJSON))); err != nil { + t.Errorf("%s: New() = %v, want it to load", name, err) + } + } +} + func TestANameBindsAReferenceFromItsFolder(t *testing.T) { dir := writeData(t, map[string]string{ "sv/word": `["a","b","c","d","e","f","g","h"]`, diff --git a/node_test.go b/node_test.go index c2f13aa..d405dde 100644 --- a/node_test.go +++ b/node_test.go @@ -59,7 +59,6 @@ func TestNodeCompileErrors(t *testing.T) { `[1, 2]`, // a choice of numbers `5`, // unsupported node type `[]`, // empty choice - `["a","a"]`, // a repeated item is a weight `[{"format":"A","weight":-1},"B"]`, // negative weight `[{"format":"A","weight":0},{"format":"B","weight":0}]`, // weights sum to zero `[{"format":"A","weight":1e308},{"format":"B","weight":1e308}]`, // weights overflow to +Inf diff --git a/path_test.go b/path_test.go index 008758d..270f4d4 100644 --- a/path_test.go +++ b/path_test.go @@ -187,10 +187,11 @@ func TestFakePathNavigation(t *testing.T) { func TestALeadingSlashIsThePath(t *testing.T) { f := newGenerator(t, writeData(t, map[string]string{"person": `{"format":"{first}","first":"Ada"}`}), WithSeed(1)) - for _, path := range []string{"/person", "//person.first"} { - if v := fake(t, f, path); v != "Ada" { - t.Errorf("Fake(%s) = %q, want Ada", path, v) - } + if v := fake(t, f, "/person"); v != "Ada" { + t.Errorf("Fake(/person) = %q, want Ada", v) + } + if _, err := f.Fake("//person"); err == nil || !strings.Contains(err.Error(), "write /person") { + t.Errorf("Fake(//person) = %v, want it refused naming /person", err) } r, err := f.FakeRecord("/person") if err != nil || len(r.Columns()) != 1 || r.Columns()[0].Value != "Ada" { diff --git a/one_spelling_test.go b/spellings_test.go similarity index 75% rename from one_spelling_test.go rename to spellings_test.go index c4ea6ed..9c05ffa 100644 --- a/one_spelling_test.go +++ b/spellings_test.go @@ -29,25 +29,29 @@ func TestOneItemChoiceIsItsItem(t *testing.T) { if r, isTemplate := compiled(t, `[{"format":"","d":"x"}]`).(*template); !isTemplate || !r.isRecord { t.Error("a one-item choice of a record is no record, want the record it holds") } + if _, err := resolved(t, `[{"format":"a","weight":2}]`); err == nil || !strings.Contains(err.Error(), "no effect here") { + t.Errorf("a weight on a one-item choice's item = %v, want it refused as doing nothing", err) + } } -func TestRepeatedChoiceItemIsRejected(t *testing.T) { - for src, want := range map[string]string{ - `["a", "a", "b"]`: `{ "format": "a", "weight": 2 }`, - `[{"format":"{x}","x":"1"},{"format":"{x}","x":"1"}]`: "repeats item", - `{"format":"{w}","w":["", "", "x"]}`: `{ "format": "", "weight": 2 }`, - `{"format":"","w":[null,null,"a"]}`: "a null takes no weight", - } { - if _, err := resolved(t, src); err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("compile(%s) = %v, want an error naming %s", src, err, want) +func TestARepeatedItemCountsAsWritten(t *testing.T) { + for _, src := range []string{`["a", "a", "b"]`, `[{"format":"{x}","x":"1"},{"format":"{x}","x":"1"}]`, `{"format":"{w}","w":["", "", "x"]}`, `{"format":"","w":[null,null,"a"]}`, `{"format":"{x|x|y}","x":"1","y":"2"}`} { + if _, err := resolved(t, src); err != nil { + t.Errorf("compile(%s) = %v, want it loaded", src, err) } } - if _, err := resolved(t, `[{"format":"a","weight":2}, "b"]`); err != nil { - t.Errorf("compile(weighted a, b) = %v", err) + f, n, as := engine(1), compiled(t, `["a", "a", "b"]`), 0 + for i := 0; i < 600; i++ { + if renderOnce(f.drawState, n) == "a" { + as++ + } + } + if as < 340 || as > 460 { + t.Errorf(`["a","a","b"] drew a %d times in 600, want about 400`, as) } } -func TestInertObjectIsRejected(t *testing.T) { +func TestASeparatorWithoutARepeatIsRejected(t *testing.T) { for src, want := range map[string]string{ `{"format":"{x}","x":"v","separator":","}`: "separator", } { diff --git a/struct_test.go b/struct_test.go index 287464f..6a06ec9 100644 --- a/struct_test.go +++ b/struct_test.go @@ -269,6 +269,9 @@ func TestFakeStructErrors(t *testing.T) { {&struct { A int `fake:"{\"format\":\"{int(1,9)}\",\"datatype\":\"integer\"}"` }{}, `its Go type int sets the datatype; drop "datatype"`}, + {&struct { + A string `fake:"{\"format\":\"x\",\"datatype\":\"string\"}"` + }{}, `its Go type string sets the datatype; drop "datatype"`}, {&struct { A int `fake:"[null,\"{int(1,9)}\"]"` }{}, "can draw null, which int cannot hold; make it *int"},