diff --git a/data_test.go b/data_test.go index 7de80ec..7e0b6b6 100644 --- a/data_test.go +++ b/data_test.go @@ -264,3 +264,40 @@ func luhnValid(s string) bool { } return sum%10 == 0 } + +// swedishName matches one or more letter-words, optionally space/hyphen joined +// ("Storgatan", "Norra Promenaden", "von Flemming"). Used by the composition +// tests so shipped name lists can grow without re-enumerating them here. +var swedishName = regexp.MustCompile(`^\p{L}+([ -]\p{L}+)*$`) + +func TestShippedStreetComposition(t *testing.T) { + // street is a choice of composed {first}{last} templates and literal names. + f := newGenerator(t, "data/sv_SE", WithSeed(5)) + for i := 0; i < 300; i++ { + if s := fake(t, f, "address.street"); !swedishName.MatchString(s) { + t.Fatalf("street %q is not a Swedish street name", s) + } + } +} + +func TestShippedLastNameComposition(t *testing.T) { + // last is a choice of patronymic {first}sson templates, compound + // {first}{last} templates and literal surnames. + f := newGenerator(t, "data/sv_SE", WithSeed(6)) + for i := 0; i < 300; i++ { + if s := fake(t, f, "person.last"); !swedishName.MatchString(s) { + t.Fatalf("last name %q is not a Swedish surname", s) + } + } +} + +func TestShippedStreetNumberFormats(t *testing.T) { + // Reachable via a hyphenated path; covers all five weighted number variants. + f := newGenerator(t, "data/sv_SE", WithSeed(8)) + re := regexp.MustCompile(`^[1-9]\d{0,2}[A-Z]?$`) + for i := 0; i < 300; i++ { + if n := fake(t, f, "address.street-number"); !re.MatchString(n) { + t.Fatalf("street-number %q does not match %s", n, re) + } + } +} diff --git a/edge_test.go b/edge_test.go deleted file mode 100644 index 6dd2c1c..0000000 --- a/edge_test.go +++ /dev/null @@ -1,385 +0,0 @@ -package fejkdata - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "regexp" - "slices" - "strings" - "testing" -) - -// --- format string edge cases --- - -func TestHashIsLiteral(t *testing.T) { - f := engine(1) - cases := map[string]string{ - `{"format":"","x":"v"}`: "", - `{"format":"#","x":"v"}`: "#", - `{"format":"##","x":"v"}`: "##", - `{"format":"#0#1#A#a","x":"v"}`: "#0#1#A#a", - `{"format":"#{x}","x":"v"}`: "#v", - } - for tmpl, want := range cases { - if got := mustRender(t, f, tmpl); got != want { - t.Errorf("render(%s) = %q, want %q", tmpl, got, want) - } - } -} - -func TestMultibyteFormat(t *testing.T) { - // Scanning is rune-aware: multibyte literals coexist with class chars and - // tokens without corrupting indices. - got := mustRender(t, engine(2), `{"format":"Öster{x}-{digits(1)}å","x":"väg"}`) - if !regexp.MustCompile(`^Österväg-[0-9]å$`).MatchString(got) { - t.Fatalf("multibyte format = %q", got) - } -} - -func TestAlternationThreeWay(t *testing.T) { - f := engine(4) - seen := map[string]bool{} - for i := 0; i < 200; i++ { - seen[mustRender(t, f, `{"format":"{a|b|c}","a":"A","b":"B","c":"C"}`)] = true - } - if !seen["A"] || !seen["B"] || !seen["C"] || len(seen) != 3 { - t.Fatalf("3-way alternation produced %v, want A, B and C", seen) - } -} - -func TestNewErrors(t *testing.T) { - // Pointing New at a file (not a directory) fails. - file := filepath.Join(t.TempDir(), "xx_XX") - if err := os.WriteFile(file, []byte("{}"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := New(WithoutShippedData(), WithDataPath(file)); err == nil { - t.Error("New(file) = nil error, want not-a-directory error") - } - // Invalid JSON in a category file fails. - if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"broken": `{ not json`}))); err == nil { - t.Error("New(invalid JSON) = nil error") - } - // An option that cannot take effect, and a category or folder no dot path can - // reach, are mistakes New must name rather than accept and ignore. - rejected := map[string]struct { - files map[string]string - want string - }{ - "separator without repeat": { - map[string]string{"a": `{"format":"{x}","x":"1","separator":","}`}, - "has no effect without a repeat above 1", - }, - "separator with an explicit repeat of 1": { - map[string]string{"a": `{"format":"{x}","x":"1","separator":","}`}, - "has no effect without a repeat above 1", - }, - "weight outside a choice": { - map[string]string{"a": `{"format":"x","weight":5}`}, - "weight only skews a choice's items", - }, - "non-numeric weight outside a choice": { - map[string]string{"a": `{"format":"x","weight":"bad"}`}, - "weight only skews a choice's items", - }, - "weight used as a field": { - map[string]string{"a": `{"format":"{name} {weight}kg","name":"Anvil","weight":["7"]}`}, - "can never be a field", - }, - "an option name used as a token": { - map[string]string{"a": `"{weight}"`}, - `"weight" is an option and can never be a field`, - }, - "category name with a dot": { - map[string]string{"a.b": `"1"`}, - `category "a.b" contains "."`, - }, - "folder name with a dot": { - map[string]string{"a.b/cat": `"1"`}, - `/a.b: folder "a.b" contains "."`, - }, - // The token grammar reserves three more characters. A name carrying one - // still resolves by dot path, but no format can name it, so it is rejected - // where it is authored rather than at the token that cannot reach it. - "field name with a pipe": { - map[string]string{"a": `{"format":"{x}","x":"1","b|c":"2"}`}, - `field "b|c" contains "|"`, - }, - "field name with a paren": { - map[string]string{"a": `{"format":"{x}","x":"1","b(c":"2"}`}, - `field "b(c" contains "("`, - }, - "field name with a closing brace": { - map[string]string{"a": `{"format":"{x}","x":"1","b}c":"2"}`}, - `field "b}c" contains "}"`, - }, - "category name with a pipe": { - map[string]string{"a|b": `"1"`}, - `category "a|b" contains "|"`, - }, - // An empty name is not a path segment, so List never offered it — while a - // bare {}, a trailing dot in Fake("a.") and a {/a.} reference all reached - // it. The engine accepted spellings it would never advertise. - "empty field name": { - map[string]string{"a": `{"format":"[{}]","":"VALUE"}`}, - `field "" is empty`, - }, - "folder name with a paren": { - map[string]string{"a(b/cat": `"1"`}, - `folder "a(b" contains "("`, - }, - // 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", - }, - // No field can be named "", so the token is told that rather than sent to - // name one — the fix "no field" points at is itself a load error. - "repeated empty arm": { - map[string]string{"a": `"{|}"`}, - "a name is never empty", - }, - "bare empty token": { - map[string]string{"a": `{"format":"[{}]","x":"1"}`}, - "a name is never empty", - }, - } - for name, c := range rejected { - _, err := New(WithoutShippedData(), WithDataPath(writeData(t, c.files))) - 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) - } - } - // Data that works today must keep working, and stay reachable. - accepted := map[string]struct { - files map[string]string - path string - want string - }{ - "option name as a field": {map[string]string{"a": `{"format":"{name} {Weight}kg","name":"Anvil","Weight":"7"}`}, "a", "Anvil 7kg"}, - "format spelling as a field": {map[string]string{"a": `{"format":"{Format}","Format":"PDF"}`}, "a", "PDF"}, - "hyphenated field": {map[string]string{"a": `{"format":"{x-y}","x-y":"1"}`}, "a.x-y", "1"}, - "category named Format": {map[string]string{"Format": `"1"`}, "Format", "1"}, - "folder named Repeat": {map[string]string{"Repeat/cat": `"1"`}, "Repeat.cat", "1"}, - "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"}, - } - for name, c := range accepted { - f, err := New(WithoutShippedData(), WithDataPath(writeData(t, c.files))) - if err != nil { - t.Errorf("%s: New = %v, want it accepted", name, err) - continue - } - if got, err := f.Fake(c.path); err != nil || got != c.want { - t.Errorf("%s: Fake(%q) = %q, %v, want %q", name, c.path, got, err, c.want) - } - } -} - -// --- deep path navigation --- - -func TestDeepDottedPath(t *testing.T) { - // A 5-segment path descends through alternating object/array nodes; choices - // on the path are single-variant, so it resolves deterministically. - f := engine(1) - f.categories = map[string]node{ - "deep": compiled(t, `{"format":"{a}","a":{"format":"{b}","b":{"format":"{c}","c":{"format":"{d}","d":"leaf"}}}}`), - } - if got, err := f.Fake("deep.a.b.c.d"); err != nil || got != "leaf" { - t.Fatalf("Fake(deep.a.b.c.d) = %q, %v, want leaf", got, err) - } - // Rendering the whole tree resolves the same chain. - if got, err := f.Fake("deep"); err != nil || got != "leaf" { - t.Fatalf("Fake(deep) = %q, %v, want leaf", got, err) - } -} - -func TestDescendIntoStringErrors(t *testing.T) { - f := engine(1) - f.categories = map[string]node{"greeting": compiled(t, `"hej"`)} - if _, err := f.Fake("greeting.extra"); err == nil || !strings.Contains(err.Error(), `no field "extra"`) { - t.Fatalf("Fake(greeting.extra) = %v, want a no-field error", err) - } -} - -// TestPathThroughChoice pins the rule that keeps a dotted path from rendering on -// one call and failing on the next: every variant must carry the rest of the path. -func TestPathThroughChoice(t *testing.T) { - dir := writeData(t, map[string]string{ - "every": `[{"format":"{f}","f":"1"},{"format":"{f}","f":"2"}]`, - "notall": `[{"format":"{f}","f":"1"},"plain"]`, - "some": `[{"format":"{f}","f":"1"},{"format":"{f}","f":"2","extra":"x"}]`, - }) - f := newGenerator(t, dir, WithSeed(1)) - for i := 0; i < 200; i++ { - if got := fake(t, f, "every.f"); got != "1" && got != "2" { - t.Fatalf("every.f = %q, want 1 or 2", got) - } - } - // A path only some variants carry is reported against what all of them carry. - if _, err := f.Fake("some.extra"); err == nil || !strings.Contains(err.Error(), "all carry [f]") { - t.Errorf("Fake(some.extra) = %v, want it to name what every variant carries", err) - } - var first string - for i := 0; i < 200; i++ { - _, err := f.Fake("notall.f") - if err == nil { - t.Fatal("notall.f = nil error, want the same failure every call") - } - if i == 0 { - first = err.Error() - } else if err.Error() != first { - t.Fatalf("notall.f error varies between calls:\n %s\n %s", first, err.Error()) - } - } -} - -// TestPathKeyIsUnambiguous pins that the one shape which could collide cannot be -// written: a field literally named "a.b" and a field "a" holding "b" would both -// spell "a.b", so a dotted field name is rejected at New and a dot means a path -// wherever it appears. -func TestPathKeyIsUnambiguous(t *testing.T) { - dir := writeData(t, map[string]string{ - "cat": `[{"format":"{a.b}","a.b":"1"},{"format":"{a}","a":{"format":"{b}","b":"2"}}]`, - }) - _, err := New(WithoutShippedData(), WithDataPath(dir)) - if err == nil || !strings.Contains(err.Error(), `field "a.b" contains "."`) { - t.Fatalf("New = %v, want the dotted field name rejected", err) - } - // The same data without the dotted key is fine, and the path resolves. - f := newGenerator(t, writeData(t, map[string]string{ - "cat": `{"format":"{a.b}","a":{"format":"{b}","b":"2"}}`, - }), WithSeed(1)) - if !slices.Contains(f.List(), "cat.a.b") { - t.Error("List() omits cat.a.b, which the data carries") - } - if got := fake(t, f, "cat"); got != "2" { - t.Fatalf("cat = %q, want 2", got) - } -} - -// TestMissingFieldNamesItself keeps the precise diagnosis for the ordinary typo: a -// single-variant choice always picks the same item, so it needs no every-variant -// guard and the error can name the field that is missing. -func TestMissingFieldNamesItself(t *testing.T) { - f := newGenerator(t, "data/sv_SE", WithSeed(1)) - _, err := f.Fake("person.typo") - if err == nil || !strings.Contains(err.Error(), `no field "typo"`) { - t.Errorf("Fake(person.typo) = %v, want it to name the missing field", err) - } -} - -// --- category root shapes --- - -func TestCategoryRootShapes(t *testing.T) { - dir := writeData(t, map[string]string{ - "obj": `"{digits(2)}"`, // object root - "lit": `"hello"`, // bare-string root - }) - f := newGenerator(t, dir, WithSeed(1)) - if got := fake(t, f, "obj"); !regexp.MustCompile(`^\d\d$`).MatchString(got) { - t.Errorf("object-root category = %q, want two digits", got) - } - if got := fake(t, f, "lit"); got != "hello" { - t.Errorf("string-root category = %q, want hello", got) - } -} - -// --- very long lists --- - -func TestLongStringList(t *testing.T) { - const n = 2000 - names := make([]string, n) - for i := range names { - names[i] = fmt.Sprintf("name-%04d", i) - } - list, err := json.Marshal(names) - if err != nil { - t.Fatal(err) - } - f := newGenerator(t, writeData(t, map[string]string{"name": string(list)}), WithSeed(1)) - - valid := map[string]bool{} - for _, v := range names { - valid[v] = true - } - seen := map[string]bool{} - for i := 0; i < 20000; i++ { - v := fake(t, f, "name") - if !valid[v] { - t.Fatalf("got %q, not in the list", v) - } - seen[v] = true - } - if len(seen) < n*8/10 { - t.Fatalf("only %d/%d distinct values seen; selection looks skewed", len(seen), n) - } -} - -// --- composition against the shipped sv_SE data --- - -// swedishName matches one or more letter-words, optionally space/hyphen joined -// ("Storgatan", "Norra Promenaden", "von Flemming"). Used by the composition -// tests so shipped name lists can grow without re-enumerating them here. -var swedishName = regexp.MustCompile(`^\p{L}+([ -]\p{L}+)*$`) - -func TestShippedStreetComposition(t *testing.T) { - // street is a choice of composed {first}{last} templates and literal names. - f := newGenerator(t, "data/sv_SE", WithSeed(5)) - for i := 0; i < 300; i++ { - if s := fake(t, f, "address.street"); !swedishName.MatchString(s) { - t.Fatalf("street %q is not a Swedish street name", s) - } - } -} - -func TestShippedLastNameComposition(t *testing.T) { - // last is a choice of patronymic {first}sson templates, compound - // {first}{last} templates and literal surnames. - f := newGenerator(t, "data/sv_SE", WithSeed(6)) - for i := 0; i < 300; i++ { - if s := fake(t, f, "person.last"); !swedishName.MatchString(s) { - t.Fatalf("last name %q is not a Swedish surname", s) - } - } -} - -func TestShippedStreetNumberFormats(t *testing.T) { - // Reachable via a hyphenated path; covers all five weighted number variants. - f := newGenerator(t, "data/sv_SE", WithSeed(8)) - re := regexp.MustCompile(`^[1-9]\d{0,2}[A-Z]?$`) - for i := 0; i < 300; i++ { - if n := fake(t, f, "address.street-number"); !re.MatchString(n) { - t.Fatalf("street-number %q does not match %s", n, re) - } - } -} diff --git a/bound_test.go b/hold_test.go similarity index 100% rename from bound_test.go rename to hold_test.go diff --git a/loading_test.go b/loading_test.go index b590b6c..be407ed 100644 --- a/loading_test.go +++ b/loading_test.go @@ -1,9 +1,12 @@ package fejkdata import ( + "encoding/json" + "fmt" "os" "path/filepath" "reflect" + "regexp" "slices" "strings" "testing" @@ -201,3 +204,202 @@ func TestRepeatProductAlongAPathIsCapped(t *testing.T) { t.Errorf("New = %v, want 1024 x 1024 along one path accepted", err) } } + +func TestNewErrors(t *testing.T) { + // Pointing New at a file (not a directory) fails. + file := filepath.Join(t.TempDir(), "xx_XX") + if err := os.WriteFile(file, []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := New(WithoutShippedData(), WithDataPath(file)); err == nil { + t.Error("New(file) = nil error, want not-a-directory error") + } + // Invalid JSON in a category file fails. + if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"broken": `{ not json`}))); err == nil { + t.Error("New(invalid JSON) = nil error") + } + // An option that cannot take effect, and a category or folder no dot path can + // reach, are mistakes New must name rather than accept and ignore. + rejected := map[string]struct { + files map[string]string + want string + }{ + "separator without repeat": { + map[string]string{"a": `{"format":"{x}","x":"1","separator":","}`}, + "has no effect without a repeat above 1", + }, + "separator with an explicit repeat of 1": { + map[string]string{"a": `{"format":"{x}","x":"1","separator":","}`}, + "has no effect without a repeat above 1", + }, + "weight outside a choice": { + map[string]string{"a": `{"format":"x","weight":5}`}, + "weight only skews a choice's items", + }, + "non-numeric weight outside a choice": { + map[string]string{"a": `{"format":"x","weight":"bad"}`}, + "weight only skews a choice's items", + }, + "weight used as a field": { + map[string]string{"a": `{"format":"{name} {weight}kg","name":"Anvil","weight":["7"]}`}, + "can never be a field", + }, + "an option name used as a token": { + map[string]string{"a": `"{weight}"`}, + `"weight" is an option and can never be a field`, + }, + "category name with a dot": { + map[string]string{"a.b": `"1"`}, + `category "a.b" contains "."`, + }, + "folder name with a dot": { + map[string]string{"a.b/cat": `"1"`}, + `/a.b: folder "a.b" contains "."`, + }, + // The token grammar reserves three more characters. A name carrying one + // still resolves by dot path, but no format can name it, so it is rejected + // where it is authored rather than at the token that cannot reach it. + "field name with a pipe": { + map[string]string{"a": `{"format":"{x}","x":"1","b|c":"2"}`}, + `field "b|c" contains "|"`, + }, + "field name with a paren": { + map[string]string{"a": `{"format":"{x}","x":"1","b(c":"2"}`}, + `field "b(c" contains "("`, + }, + "field name with a closing brace": { + map[string]string{"a": `{"format":"{x}","x":"1","b}c":"2"}`}, + `field "b}c" contains "}"`, + }, + "category name with a pipe": { + map[string]string{"a|b": `"1"`}, + `category "a|b" contains "|"`, + }, + // An empty name is not a path segment, so List never offered it — while a + // bare {}, a trailing dot in Fake("a.") and a {/a.} reference all reached + // it. The engine accepted spellings it would never advertise. + "empty field name": { + map[string]string{"a": `{"format":"[{}]","":"VALUE"}`}, + `field "" is empty`, + }, + "folder name with a paren": { + map[string]string{"a(b/cat": `"1"`}, + `folder "a(b" contains "("`, + }, + // 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", + }, + // No field can be named "", so the token is told that rather than sent to + // name one — the fix "no field" points at is itself a load error. + "repeated empty arm": { + map[string]string{"a": `"{|}"`}, + "a name is never empty", + }, + "bare empty token": { + map[string]string{"a": `{"format":"[{}]","x":"1"}`}, + "a name is never empty", + }, + } + for name, c := range rejected { + _, err := New(WithoutShippedData(), WithDataPath(writeData(t, c.files))) + 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) + } + } + // Data that works today must keep working, and stay reachable. + accepted := map[string]struct { + files map[string]string + path string + want string + }{ + "option name as a field": {map[string]string{"a": `{"format":"{name} {Weight}kg","name":"Anvil","Weight":"7"}`}, "a", "Anvil 7kg"}, + "format spelling as a field": {map[string]string{"a": `{"format":"{Format}","Format":"PDF"}`}, "a", "PDF"}, + "hyphenated field": {map[string]string{"a": `{"format":"{x-y}","x-y":"1"}`}, "a.x-y", "1"}, + "category named Format": {map[string]string{"Format": `"1"`}, "Format", "1"}, + "folder named Repeat": {map[string]string{"Repeat/cat": `"1"`}, "Repeat.cat", "1"}, + "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"}, + } + for name, c := range accepted { + f, err := New(WithoutShippedData(), WithDataPath(writeData(t, c.files))) + if err != nil { + t.Errorf("%s: New = %v, want it accepted", name, err) + continue + } + if got, err := f.Fake(c.path); err != nil || got != c.want { + t.Errorf("%s: Fake(%q) = %q, %v, want %q", name, c.path, got, err, c.want) + } + } +} + +func TestCategoryRootShapes(t *testing.T) { + dir := writeData(t, map[string]string{ + "obj": `"{digits(2)}"`, // object root + "lit": `"hello"`, // bare-string root + }) + f := newGenerator(t, dir, WithSeed(1)) + if got := fake(t, f, "obj"); !regexp.MustCompile(`^\d\d$`).MatchString(got) { + t.Errorf("object-root category = %q, want two digits", got) + } + if got := fake(t, f, "lit"); got != "hello" { + t.Errorf("string-root category = %q, want hello", got) + } +} + +func TestLongStringList(t *testing.T) { + const n = 2000 + names := make([]string, n) + for i := range names { + names[i] = fmt.Sprintf("name-%04d", i) + } + list, err := json.Marshal(names) + if err != nil { + t.Fatal(err) + } + f := newGenerator(t, writeData(t, map[string]string{"name": string(list)}), WithSeed(1)) + + valid := map[string]bool{} + for _, v := range names { + valid[v] = true + } + seen := map[string]bool{} + for i := 0; i < 20000; i++ { + v := fake(t, f, "name") + if !valid[v] { + t.Fatalf("got %q, not in the list", v) + } + seen[v] = true + } + if len(seen) < n*8/10 { + t.Fatalf("only %d/%d distinct values seen; selection looks skewed", len(seen), n) + } +} diff --git a/path_test.go b/path_test.go new file mode 100644 index 0000000..57a385f --- /dev/null +++ b/path_test.go @@ -0,0 +1,138 @@ +package fejkdata + +import ( + "slices" + "strings" + "testing" +) + +func TestWalkPathStopsAtAMissingSegment(t *testing.T) { + n := compiled(t, `{"format":"{a}","a":{"format":"{b}","b":"leaf"}}`) + var seen []string + walk := pathWalk{ + level: func(tm *template, rest []string) error { seen = append(seen, "level:"+rest[0]); return nil }, + leaf: func(n node) error { seen = append(seen, "leaf"); return nil }, + } + if err := walkPath(n, []string{"a", "b"}, walk); err != nil { + t.Fatalf("walkPath(a.b) = %v", err) + } + if want := []string{"level:a", "level:b", "leaf"}; !slices.Equal(seen, want) { + t.Errorf("walk visited %v, want %v", seen, want) + } + seen = nil + err := walkPath(n, []string{"a", "nope", "deeper"}, walk) + if err == nil || !strings.Contains(err.Error(), `no field "nope"`) { + t.Errorf("walkPath(a.nope.deeper) = %v, want the missing segment named", err) + } + if slices.Contains(seen, "leaf") { + t.Errorf("walk reached a leaf past a missing segment: %v", seen) + } +} + +func TestWalkPathChoiceConsumesNoSegment(t *testing.T) { + n := compiled(t, `[{"format":"{f}","f":"1"},{"format":"{f}","f":"2"}]`) + var leaves []node + err := walkPath(n, []string{"f"}, pathWalk{ + choice: func(c *choice, rest []string) ([]node, error) { + if len(rest) != 1 || rest[0] != "f" { + t.Errorf("choice saw rest %v, want [f]", rest) + } + return c.items, nil + }, + leaf: func(n node) error { leaves = append(leaves, n); return nil }, + }) + if err != nil || len(leaves) != 2 { + t.Fatalf("walkPath through a choice = %v, %d leaves, want both variants' f", err, len(leaves)) + } +} + +func TestDeepDottedPath(t *testing.T) { + // A 5-segment path descends through alternating object/array nodes; choices + // on the path are single-variant, so it resolves deterministically. + f := engine(1) + f.categories = map[string]node{ + "deep": compiled(t, `{"format":"{a}","a":{"format":"{b}","b":{"format":"{c}","c":{"format":"{d}","d":"leaf"}}}}`), + } + if got, err := f.Fake("deep.a.b.c.d"); err != nil || got != "leaf" { + t.Fatalf("Fake(deep.a.b.c.d) = %q, %v, want leaf", got, err) + } + // Rendering the whole tree resolves the same chain. + if got, err := f.Fake("deep"); err != nil || got != "leaf" { + t.Fatalf("Fake(deep) = %q, %v, want leaf", got, err) + } +} + +func TestDescendIntoStringErrors(t *testing.T) { + f := engine(1) + f.categories = map[string]node{"greeting": compiled(t, `"hej"`)} + if _, err := f.Fake("greeting.extra"); err == nil || !strings.Contains(err.Error(), `no field "extra"`) { + t.Fatalf("Fake(greeting.extra) = %v, want a no-field error", err) + } +} + +// TestPathThroughChoice pins the rule that keeps a dotted path from rendering on +// one call and failing on the next: every variant must carry the rest of the path. +func TestPathThroughChoice(t *testing.T) { + dir := writeData(t, map[string]string{ + "every": `[{"format":"{f}","f":"1"},{"format":"{f}","f":"2"}]`, + "notall": `[{"format":"{f}","f":"1"},"plain"]`, + "some": `[{"format":"{f}","f":"1"},{"format":"{f}","f":"2","extra":"x"}]`, + }) + f := newGenerator(t, dir, WithSeed(1)) + for i := 0; i < 200; i++ { + if got := fake(t, f, "every.f"); got != "1" && got != "2" { + t.Fatalf("every.f = %q, want 1 or 2", got) + } + } + // A path only some variants carry is reported against what all of them carry. + if _, err := f.Fake("some.extra"); err == nil || !strings.Contains(err.Error(), "all carry [f]") { + t.Errorf("Fake(some.extra) = %v, want it to name what every variant carries", err) + } + var first string + for i := 0; i < 200; i++ { + _, err := f.Fake("notall.f") + if err == nil { + t.Fatal("notall.f = nil error, want the same failure every call") + } + if i == 0 { + first = err.Error() + } else if err.Error() != first { + t.Fatalf("notall.f error varies between calls:\n %s\n %s", first, err.Error()) + } + } +} + +// TestPathKeyIsUnambiguous pins that the one shape which could collide cannot be +// written: a field literally named "a.b" and a field "a" holding "b" would both +// spell "a.b", so a dotted field name is rejected at New and a dot means a path +// wherever it appears. +func TestPathKeyIsUnambiguous(t *testing.T) { + dir := writeData(t, map[string]string{ + "cat": `[{"format":"{a.b}","a.b":"1"},{"format":"{a}","a":{"format":"{b}","b":"2"}}]`, + }) + _, err := New(WithoutShippedData(), WithDataPath(dir)) + if err == nil || !strings.Contains(err.Error(), `field "a.b" contains "."`) { + t.Fatalf("New = %v, want the dotted field name rejected", err) + } + // The same data without the dotted key is fine, and the path resolves. + f := newGenerator(t, writeData(t, map[string]string{ + "cat": `{"format":"{a.b}","a":{"format":"{b}","b":"2"}}`, + }), WithSeed(1)) + if !slices.Contains(f.List(), "cat.a.b") { + t.Error("List() omits cat.a.b, which the data carries") + } + if got := fake(t, f, "cat"); got != "2" { + t.Fatalf("cat = %q, want 2", got) + } +} + +// TestMissingFieldNamesItself keeps the precise diagnosis for the ordinary typo: a +// single-variant choice always picks the same item, so it needs no every-variant +// guard and the error can name the field that is missing. +func TestMissingFieldNamesItself(t *testing.T) { + f := newGenerator(t, "data/sv_SE", WithSeed(1)) + _, err := f.Fake("person.typo") + if err == nil || !strings.Contains(err.Error(), `no field "typo"`) { + t.Errorf("Fake(person.typo) = %v, want it to name the missing field", err) + } +} diff --git a/template_test.go b/template_test.go index 1f7f8f4..f1ddcb8 100644 --- a/template_test.go +++ b/template_test.go @@ -311,3 +311,39 @@ func quote(s string) string { } return string(b) } + +func TestHashIsLiteral(t *testing.T) { + f := engine(1) + cases := map[string]string{ + `{"format":"","x":"v"}`: "", + `{"format":"#","x":"v"}`: "#", + `{"format":"##","x":"v"}`: "##", + `{"format":"#0#1#A#a","x":"v"}`: "#0#1#A#a", + `{"format":"#{x}","x":"v"}`: "#v", + } + for tmpl, want := range cases { + if got := mustRender(t, f, tmpl); got != want { + t.Errorf("render(%s) = %q, want %q", tmpl, got, want) + } + } +} + +func TestMultibyteFormat(t *testing.T) { + // Scanning is rune-aware: multibyte literals coexist with class chars and + // tokens without corrupting indices. + got := mustRender(t, engine(2), `{"format":"Öster{x}-{digits(1)}å","x":"väg"}`) + if !regexp.MustCompile(`^Österväg-[0-9]å$`).MatchString(got) { + t.Fatalf("multibyte format = %q", got) + } +} + +func TestAlternationThreeWay(t *testing.T) { + f := engine(4) + seen := map[string]bool{} + for i := 0; i < 200; i++ { + seen[mustRender(t, f, `{"format":"{a|b|c}","a":"A","b":"B","c":"C"}`)] = true + } + if !seen["A"] || !seen["B"] || !seen["C"] || len(seen) != 3 { + t.Fatalf("3-way alternation produced %v, want A, B and C", seen) + } +}