416 lines
15 KiB
Go
416 lines
15 KiB
Go
package fejkdata
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestList pins the discoverable paths: every category, its dotted fields, and
|
|
// folder segments — descending transparently through single-variant choices.
|
|
func TestList(t *testing.T) {
|
|
dir := writeData(t, map[string]string{
|
|
"person": `{"format":"{first} {last}","first":"A","last":"B"}`,
|
|
"word": `["x", "y"]`,
|
|
"geo/city": `"Z"`,
|
|
// A bound {/path} reference is a render edge, not an addressable field.
|
|
"greeting": `{"format":"hej {/person.first} and {own}","own":"x"}`,
|
|
// Only the fields every variant carries are addressable, so "extra" is not.
|
|
"coin": `[{"format":"{code}","code":"A","name":"Aa"},{"format":"{code}","code":"B","name":"Bb","extra":"x"}]`,
|
|
})
|
|
got := newGenerator(t, dir, WithSeed(1)).List()
|
|
want := []string{"coin", "coin.code", "coin.name", "geo.city", "greeting", "greeting.own", "person", "person.first", "person.last", "word"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("List() = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
// writeData builds a temp data directory from a map of relative path (without
|
|
// ".json") -> file content, creating parent folders as needed. The directory's
|
|
// name carries no meaning anymore, so callers pick any layout they like —
|
|
// including nested folders, which become dot-path segments.
|
|
func writeData(t *testing.T, files map[string]string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
for name, content := range files {
|
|
p := filepath.Join(dir, name+".json")
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func TestNewLoadsAnyDirName(t *testing.T) {
|
|
// No locale tag required: a directory named anything loads fine.
|
|
dir := writeData(t, map[string]string{"greeting": `["hej", "hallå"]`})
|
|
f := newGenerator(t, dir, WithSeed(1))
|
|
if got := fake(t, f, "greeting"); got != "hej" && got != "hallå" {
|
|
t.Fatalf("greeting = %q, want hej or hallå", got)
|
|
}
|
|
}
|
|
|
|
func TestNewEmptyDirErrors(t *testing.T) {
|
|
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, nil))); err == nil {
|
|
t.Fatal("New(empty dir) = nil error")
|
|
}
|
|
}
|
|
|
|
// TestFoldersBecomeDotPaths checks the core of the new model: a subfolder is a
|
|
// namespace, so data/<loc>/person.json is reachable as "<loc>.person".
|
|
func TestFoldersBecomeDotPaths(t *testing.T) {
|
|
dir := writeData(t, map[string]string{
|
|
"sv_SE/greeting": `"hej"`,
|
|
"en_US/greeting": `"hi"`,
|
|
})
|
|
f := newGenerator(t, dir, WithSeed(1))
|
|
if got := fake(t, f, "sv_SE.greeting"); got != "hej" {
|
|
t.Fatalf("sv_SE.greeting = %q, want hej", got)
|
|
}
|
|
if got := fake(t, f, "en_US.greeting"); got != "hi" {
|
|
t.Fatalf("en_US.greeting = %q, want hi", got)
|
|
}
|
|
}
|
|
|
|
// TestNestedFoldersAndJSON crosses both nesting kinds in one dot path: folders
|
|
// a/b/c, then deep JSON fields inside the file at the end of that path.
|
|
func TestNestedFoldersAndJSON(t *testing.T) {
|
|
dir := writeData(t, map[string]string{
|
|
"a/b/c/thing": `{"format":"{x}","x":{"format":"{y}","y":{"format":"{z}","z":"leaf"}}}`,
|
|
})
|
|
f := newGenerator(t, dir, WithSeed(1))
|
|
// Folders a.b.c, file thing, then JSON fields x.y.z — one continuous path.
|
|
if got := fake(t, f, "a.b.c.thing.x.y.z"); got != "leaf" {
|
|
t.Fatalf("a.b.c.thing.x.y.z = %q, want leaf", got)
|
|
}
|
|
// Rendering the file resolves the same chain top-down.
|
|
if got := fake(t, f, "a.b.c.thing"); got != "leaf" {
|
|
t.Fatalf("a.b.c.thing = %q, want leaf", got)
|
|
}
|
|
}
|
|
|
|
func TestRenderingAFolderErrors(t *testing.T) {
|
|
dir := writeData(t, map[string]string{"sv_SE/greeting": `"hej"`})
|
|
f := newGenerator(t, dir, WithSeed(1))
|
|
if _, err := f.Fake("sv_SE"); err == nil {
|
|
t.Fatal("Fake(folder) = nil error, want a not-a-value error")
|
|
}
|
|
}
|
|
|
|
// TestMultiPathLastWins loads two dirs; on a name clash the later dir wins, and
|
|
// non-clashing entries from both are reachable (data combines).
|
|
func TestMultiPathLastWins(t *testing.T) {
|
|
a := writeData(t, map[string]string{"greeting": `"from-a"`, "only-a": `"a"`})
|
|
b := writeData(t, map[string]string{"greeting": `"from-b"`, "only-b": `"b"`})
|
|
f := newGeneratorN(t, []string{a, b}, WithSeed(1))
|
|
if got := fake(t, f, "greeting"); got != "from-b" {
|
|
t.Fatalf("greeting = %q, want from-b (last loaded wins)", got)
|
|
}
|
|
if got := fake(t, f, "only-a"); got != "a" {
|
|
t.Fatalf("only-a = %q, want a", got)
|
|
}
|
|
if got := fake(t, f, "only-b"); got != "b" {
|
|
t.Fatalf("only-b = %q, want b", got)
|
|
}
|
|
}
|
|
|
|
// TestMultiPathMergesFolders checks that clashing folders merge by their
|
|
// children rather than replacing wholesale: each dir adds a file to sv_SE, and
|
|
// a per-file clash inside still resolves last-wins.
|
|
func TestMultiPathMergesFolders(t *testing.T) {
|
|
a := writeData(t, map[string]string{"sv_SE/person": `"from-a"`, "sv_SE/shared": `"a"`})
|
|
b := writeData(t, map[string]string{"sv_SE/company": `"from-b"`, "sv_SE/shared": `"b"`})
|
|
f := newGeneratorN(t, []string{a, b}, WithSeed(1))
|
|
if got := fake(t, f, "sv_SE.person"); got != "from-a" {
|
|
t.Fatalf("sv_SE.person = %q, want from-a (folder merged, not replaced)", got)
|
|
}
|
|
if got := fake(t, f, "sv_SE.company"); got != "from-b" {
|
|
t.Fatalf("sv_SE.company = %q, want from-b", got)
|
|
}
|
|
if got := fake(t, f, "sv_SE.shared"); got != "b" {
|
|
t.Fatalf("sv_SE.shared = %q, want b (last loaded wins)", got)
|
|
}
|
|
}
|
|
|
|
// TestListedPathsAllRender is the contract between List and Fake over the shipped
|
|
// tree: everything List advertises renders, every time, and the sub-fields the
|
|
// README advertises are discoverable.
|
|
func TestListedPathsAllRender(t *testing.T) {
|
|
f := newGenerator(t, "data", WithSeed(4))
|
|
paths := f.List()
|
|
for _, p := range paths {
|
|
for i := 0; i < 20; i++ {
|
|
if _, err := f.Fake(p); err != nil {
|
|
t.Fatalf("Fake(%q) = %v, but List() advertises it", p, err)
|
|
}
|
|
}
|
|
}
|
|
for _, p := range []string{"misc.car.maker", "misc.country.alpha2", "misc.currency.symbol", "misc.httpstatus.code", "misc.mimetype.ext"} {
|
|
if !slices.Contains(paths, p) {
|
|
t.Errorf("List() omits %q, which the README advertises and Fake renders", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHiddenEntriesAreSkipped keeps a data directory usable when it is also a
|
|
// checkout or an editor workspace: a dot-prefixed entry is not data, and a folder
|
|
// carrying no JSON never contributed a namespace, so neither may fail the load.
|
|
func TestHiddenEntriesAreSkipped(t *testing.T) {
|
|
dir := writeData(t, map[string]string{
|
|
"cat": `"V"`,
|
|
".git/HEAD": `"ignored"`,
|
|
".hidden": `"ignored"`,
|
|
"empty.folder/doc": `"ignored"`,
|
|
})
|
|
if err := os.Rename(filepath.Join(dir, "empty.folder", "doc.json"), filepath.Join(dir, "empty.folder", "doc.txt")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f := newGenerator(t, dir, WithSeed(1))
|
|
if got := fake(t, f, "cat"); got != "V" {
|
|
t.Fatalf("cat = %q, want V", got)
|
|
}
|
|
for _, p := range f.List() {
|
|
if strings.HasPrefix(p, ".") || strings.Contains(p, "empty.folder") {
|
|
t.Errorf("List() advertises %q, which is not data", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRepeatProductAlongAPathIsCapped(t *testing.T) {
|
|
for name, files := range map[string]map[string]string{
|
|
"nested": {"cat": `{"format":"{a}","repeat":2048,"a":{"format":"{b}","repeat":2048,"b":"x"}}`},
|
|
"through a reference": {
|
|
"a": `{"format":"{/b}","repeat":2048}`,
|
|
"b": `{"format":"x","repeat":2048}`,
|
|
},
|
|
} {
|
|
_, err := New(WithoutShippedData(), WithDataPath(writeData(t, files)))
|
|
if err == nil || !strings.Contains(err.Error(), "repeat") || !strings.Contains(err.Error(), "1048576") {
|
|
t.Errorf("%s: New = %v, want the repeat product rejected naming the maximum", name, err)
|
|
}
|
|
}
|
|
if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{
|
|
"cat": `{"format":"{a}{c}","repeat":1024,"a":{"format":"{b}","repeat":1024,"b":"x"},"c":{"format":"y","repeat":1024}}`,
|
|
}))); err != nil {
|
|
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 "|"`,
|
|
},
|
|
// A bracket is not a token-grammar character, but the CLI reads an argument
|
|
// starting with [ as a JSON array, so a name carrying one would be misread.
|
|
"field name with a bracket": {
|
|
map[string]string{"a": `{"format":"{x}","x":"1","b[c":"2"}`},
|
|
`field "b[c" contains "["`,
|
|
},
|
|
"category name with a bracket": {
|
|
map[string]string{"[abc]": `"1"`},
|
|
`category "[abc]" 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)
|
|
}
|
|
}
|