From 943a1d9f1b352511cc624a7f825f451bcb266dea Mon Sep 17 00:00:00 2001 From: lilleman Date: Fri, 5 Jun 2026 19:51:18 +0200 Subject: [PATCH] Add fakes library: recursive JSON template engine, i18n locales, Docker workflow --- .dockerignore | 3 + .gitignore | 2 + Dockerfile | 13 ++ README.md | 204 ++++++++++++++++++++++++++++++++ compose.yaml | 63 ++++++++++ edge_test.go | 200 +++++++++++++++++++++++++++++++ fakes.go | 80 +++++++++++++ fakes_test.go | 60 ++++++++++ go.mod | 3 + locale.go | 66 +++++++++++ locale_test.go | 98 +++++++++++++++ locales/en_US/address.json | 31 +++++ locales/en_US/person.json | 16 +++ locales/en_US/phone.json | 8 ++ locales/sv_SE/address.json | 39 ++++++ locales/sv_SE/person.json | 22 ++++ locales/sv_SE/phone.json | 12 ++ template.go | 236 +++++++++++++++++++++++++++++++++++++ template_test.go | 168 ++++++++++++++++++++++++++ 19 files changed, 1324 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 compose.yaml create mode 100644 edge_test.go create mode 100644 fakes.go create mode 100644 fakes_test.go create mode 100644 go.mod create mode 100644 locale.go create mode 100644 locale_test.go create mode 100644 locales/en_US/address.json create mode 100644 locales/en_US/person.json create mode 100644 locales/en_US/phone.json create mode 100644 locales/sv_SE/address.json create mode 100644 locales/sv_SE/person.json create mode 100644 locales/sv_SE/phone.json create mode 100644 template.go create mode 100644 template_test.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..03c946c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git/ +.claude/ +*.out diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3e7912 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Coverage output +*.out diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cc1e323 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +# Reproducible test/build image. `docker build .` fails if vet or tests fail, +# so it doubles as CI. Day-to-day, prefer the Makefile (bind-mounts source, +# no rebuilds). Pin matches the latest stable Go at time of writing. +FROM golang:1.26.4 + +WORKDIR /app + +# Module layer cached separately from source. +COPY go.mod ./ +RUN go mod download + +COPY . . +RUN go vet ./... && go test ./... diff --git a/README.md b/README.md new file mode 100644 index 0000000..8fdb287 --- /dev/null +++ b/README.md @@ -0,0 +1,204 @@ +# fakes + +A Go library for generating fake data, built for **internationalization**. It +exists because the existing Go fake libraries lacked the locale coverage and +format control we needed. + +- **Standards first** — formats, names and structures follow international and + local standards first and foremost. +- **Locale-aware** — names, addresses, postal codes and phone numbers follow + per-locale data and formats. Locales are always full language + territory + tags (`sv_SE`, never `sv`). +- **Data lives in JSON** — all source data is recursive JSON on disk, read when + you create a faker and then served from memory. Add or change locales without + touching the library. +- **Composable** — templates nest without limit: weighted choices, character + classes and sub-templates combine to model any format. +- **Reproducible** — seed a faker and it emits the same sequence every time. +- **Zero dependencies** — standard library only. + +## Install + +```sh +go get github.com/Timewave-AB/fakes +``` + +Requires Go 1.26+. + +## Usage + +Point `New` at a locale directory, then generate values by category path with +`Fake`. The first path segment names a category (a JSON file); deeper dotted +segments descend into it. + +```go +package main + +import ( + "fmt" + "log" + + "github.com/Timewave-AB/fakes" +) + +func main() { + f, err := fakes.New("./locales/sv_SE") + if err != nil { + log.Fatal(err) + } + + for _, path := range []string{"person", "address", "phone", "address.locality"} { + v, err := f.Fake(path) + if err != nil { + log.Fatal(err) + } + fmt.Printf("%-18s %s\n", path, v) + } +} +``` + +``` +person Sara Eriksson +address Kungsvägen 68 + 379 17 Stockholm +phone 072-402 91 67 +address.locality Linköping +``` + +Seed a faker for reproducible output — same seed + locale yields an identical +sequence, handy for stable tests: + +```go +a, _ := fakes.New("./locales/sv_SE", fakes.WithSeed(42)) +b, _ := fakes.New("./locales/sv_SE", fakes.WithSeed(42)) +av, _ := a.Fake("person") +bv, _ := b.Fake("person") +av == bv // true +``` + +A `*Fakes` is **not** safe for concurrent use — create one per goroutine. + +### Locales + +The library ships a ready-to-use set under [`locales/`](locales) (`en_US`, +`sv_SE`). Pass `New` the path to one of them, a copy, or your own directory — +anywhere on disk. + +The directory's name is the locale and must be a full tag (`sv_SE`, never +`sv`). Any casing or separator is accepted and canonicalised (`sv-se`, +`SV_SE` → `sv_SE`); a non-full name returns an error. + +## Locale data format + +Each JSON file in a locale directory is a **category** named after the file +(`address.json` → `address`), rendered by `Fake("address")`. Drop in a new file +or folder — no code change, no recompile. + +Every value is a **node**, one of three shapes, nestable without limit: + +| Node | JSON | Meaning | +|------|------|---------| +| literal | `"Malmö"` | emitted verbatim — never formatted | +| choice | `["a", "b", …]` | one element, picked at random | +| template | `{"format": "…", …}` | a format string plus the named sub-nodes it references | + +**Weight.** A template node may carry a `weight` (default `1`) to skew its odds +within a choice: + +```json +[ + { "format": "#070-000 00 00", "weight": 10 }, + { "format": "#01-000 00 00" }, + { "format": "#010-000 00 00" } +] +``` + +**Format string.** Every character is literal except: + +| Token | Expands to | +|-------|-----------| +| `0` | digit 0–9 | +| `1` | digit 1–9 | +| `A` | letter A–Z | +| `a` | letter a–z | +| `#` | escape — the next char is literal (`#0` → `0`, `##` → `#`) | +| `{name}` | render the sibling field `name` | + +`{a|b}` renders one of the sibling fields `a` or `b`, chosen at random. + +**Putting it together** (`person.json`): + +```json +[ + { + "format": "{prefix}{femalefirst|malefirst} {last}", + "femalefirst": ["Anna", "Astrid", "Elin"], + "malefirst": ["Anders", "Erik", "Gustav"], + "last": [ + { "format": "{first}sson", "first": ["Ander", "Erik", "Karl"] }, + ["Berg", "von Flemming"] + ], + "prefix": [ + "", + { "format": "{string} ", "string": ["dr", "prof"], "weight": 0.05 } + ] + } +] +``` + +This yields e.g. `Anna Eriksson`, `Erik Berg`, or rarely `dr Astrid von Flemming`. +Any field is reachable by dotted path — `Fake("person.last")` renders just a +surname; choices along the path are resolved at random. + +### Performance + +Each file is parsed, validated and weight-indexed once, in `New`. After that a +`Fake` call costs about what its output costs — it scans the chosen format and +renders nested tokens, independent of how large your lists are: + +- Picking from a list is **O(1)** whatever its length — a 10-name list and a + 100 000-name list cost the same. +- Giving entries a `weight` makes that list's pick **O(log n)** instead (a + search over cumulative weights). Still tiny, but an unweighted list is the + cheapest — only add `weight` where you actually want skew. +- Long `format` strings, deep nesting and many `{tokens}` add cost in + proportion to the output produced. + +## Development + +Everything runs in Docker — **no local tooling beyond Docker is needed**. +Source is bind-mounted; build caches persist in the `gocache` volume. + +```sh +docker compose run --rm test # run tests +docker compose run --rm ci # vet + format check + tests +docker compose run --rm cover # tests with coverage +docker compose run --rm build # compile the library +docker compose run --rm vet # go vet +docker compose run --rm dev # interactive shell +``` + +Commands that rewrite source keep your file ownership when run with `--user`: + +```sh +docker compose run --rm --user "$(id -u):$(id -g)" fmt # gofmt -w . +docker compose run --rm --user "$(id -u):$(id -g)" tidy # go mod tidy +``` + +`docker build .` runs `go vet` and the tests, so it works as a CI gate too. + +## Layout + +``` +fakes.go Fakes, New, options, seeding +template.go Fake, the recursive renderer (choices, format strings, paths) +locale.go locale loading + tag parsing +locales/ shipped locale data (JSON) +``` + +To add a category, drop a JSON file into a locale directory; to add a locale, +add a directory named with its full tag. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..f101449 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,63 @@ +# Docker-based dev workflow — no local tooling beyond Docker is required. +# +# docker compose run --rm test +# docker compose run --rm ci +# docker compose run --rm vet +# docker compose run --rm build +# +# Build caches persist in the `gocache` volume, so re-runs are fast and the +# host tree stays clean. Commands that rewrite source (fmt, tidy) keep your +# ownership when run with `--user "$(id -u):$(id -g)"`. + +x-go: &go + image: golang:1.26.4 + working_dir: /app + volumes: + - .:/app + - gocache:/cache + environment: + HOME: /cache + GOCACHE: /cache/build + GOMODCACHE: /cache/mod + +services: + test: + <<: *go + command: go test ./... + + cover: + <<: *go + command: go test -cover ./... + + vet: + <<: *go + command: go vet ./... + + build: + <<: *go + command: go build ./... + + fmt: + <<: *go + command: gofmt -w . + + tidy: + <<: *go + command: go mod tidy + + # vet + format check + tests, in one container. + ci: + <<: *go + command: > + sh -c 'go vet ./... && + { unformatted=$$(gofmt -l .); test -z "$$unformatted" || + { echo "unformatted files:"; echo "$$unformatted"; exit 1; }; } && + go test ./...' + + # Interactive shell for ad-hoc work: docker compose run --rm dev + dev: + <<: *go + command: sh + +volumes: + gocache: diff --git a/edge_test.go b/edge_test.go new file mode 100644 index 0000000..5b14596 --- /dev/null +++ b/edge_test.go @@ -0,0 +1,200 @@ +package fakes + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "testing" +) + +// --- format string edge cases --- + +func TestEscapeEdgeCases(t *testing.T) { + f := engine(1) + cases := map[string]string{ + `{"format":""}`: "", // empty format + `{"format":"#"}`: "#", // trailing escape is a literal # + `{"format":"##"}`: "#", // escaped hash + `{"format":"#0#1#A#a"}`: "01Aa", // escaped class chars stay literal + `{"format":"#{x#}"}`: "{x}", // escaping braces disables tokens + `{"format":"x}y"}`: "x}y", // an unmatched } is literal (x, y aren't classes) + } + for tmpl, want := range cases { + if got := render(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 := render(t, engine(2), `{"format":"Öster{x}-0å","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[render(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 TestEmptyChoiceErrors(t *testing.T) { + if _, err := engine(1).render(compiled(t, `[]`)); err == nil { + t.Fatal("render([]) = nil error, want empty-choice error") + } +} + +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(file); err == nil { + t.Error("New(file) = nil error, want not-a-directory error") + } + // Invalid JSON in a category file fails. + if _, err := New(writeLocale(t, "xx_XX", map[string]string{"broken": `{ not json`})); err == nil { + t.Error("New(invalid JSON) = nil error") + } +} + +func TestFakeSurfacesErrors(t *testing.T) { + f := engine(1) + f.categories = map[string]node{ + "bad": compiled(t, `[{"format":"{y}","x":["Q"]}]`), // token names a missing field -> render error + "empty": compiled(t, `[]`), // empty choice met on a path -> descend error + } + if _, err := f.Fake("bad"); err == nil { + t.Error("Fake(bad) = nil error, want render error") + } + if _, err := f.Fake("empty.foo"); err == nil { + t.Error("Fake(empty.foo) = nil error, want empty-choice error") + } +} + +// --- 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 TestDescendIntoLiteralErrors(t *testing.T) { + f := engine(1) + f.categories = map[string]node{"greeting": compiled(t, `["hej"]`)} + if _, err := f.Fake("greeting.extra"); err == nil { + t.Fatal("Fake(greeting.extra) = nil error, want descend-into-literal error") + } +} + +// --- category root shapes --- + +func TestCategoryRootShapes(t *testing.T) { + dir := writeLocale(t, "xx_XX", map[string]string{ + "obj": `{"format":"00"}`, // object root + "lit": `"hello"`, // bare-string root + }) + f := newFakes(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 := newFakes(t, writeLocale(t, "xx_XX", 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 --- + +func TestShippedStreetComposition(t *testing.T) { + // street is a choice of a composed {first}{last} template and a literal list; + // every result must be one of the valid combinations or literals. + valid := map[string]bool{"Vintjärn": true, "Staby": true, "Promenaden": true} + for _, first := range []string{"Bergs", "Kungs", "Stor"} { + for _, last := range []string{"gatan", "vägen", "stigen"} { + valid[first+last] = true + } + } + f := newFakes(t, "locales/sv_SE", WithSeed(5)) + for i := 0; i < 300; i++ { + if s := fake(t, f, "address.street"); !valid[s] { + t.Fatalf("street %q is not a valid composition or literal", s) + } + } +} + +func TestShippedLastNameComposition(t *testing.T) { + valid := map[string]bool{"Berg": true, "von Flemming": true, "Eismar": true} + for _, first := range []string{"Ander", "Erik", "Johan", "Karl", "Lar"} { + valid[first+"sson"] = true + } + f := newFakes(t, "locales/sv_SE", WithSeed(6)) + for i := 0; i < 300; i++ { + if s := fake(t, f, "person.last"); !valid[s] { + t.Fatalf("last name %q not in valid set", s) + } + } +} + +func TestShippedStreetNumberFormats(t *testing.T) { + // Reachable via a hyphenated path; covers all five weighted number variants. + f := newFakes(t, "locales/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/fakes.go b/fakes.go new file mode 100644 index 0000000..c101180 --- /dev/null +++ b/fakes.go @@ -0,0 +1,80 @@ +// Package fakes generates fake data from recursive, locale-specific JSON +// templates. +// +// Locale data lives in JSON on disk, not in Go. Point [New] at a locale +// directory, then generate values by category path: +// +// f, _ := fakes.New("./locales/sv_SE", fakes.WithSeed(42)) +// f.Fake("address") // "Storgatan 12\n234 56 Göteborg" +// f.Fake("address.locality") // "Göteborg" +// +// The directory's name is the locale and must be a full tag ("sv_SE", never +// "sv"). The JSON template format is documented in the README. +// +// A [Fakes] is not safe for concurrent use; create one per goroutine. +package fakes + +import ( + crand "crypto/rand" + "encoding/binary" + "fmt" + "math/rand/v2" + "path/filepath" +) + +// Fakes generates fake data for a single locale. Create one with [New]. +type Fakes struct { + rand *rand.Rand + locale string + categories map[string]node // category name -> compiled node tree +} + +type config struct { + seed uint64 + seeded bool +} + +// Option configures a [Fakes]. +type Option func(*config) + +// WithSeed makes output reproducible: two fakers with the same seed and locale +// emit identical sequences. +func WithSeed(seed uint64) Option { + return func(c *config) { c.seed, c.seeded = seed, true } +} + +// New builds a faker from a locale directory (e.g. "./locales/sv_SE"). The +// directory's name is the locale tag and must be a full tag like "sv_SE"; each +// JSON file in it becomes a category named after the file (address.json -> +// "address"). It errors on a non-full tag, a missing directory, or invalid JSON. +func New(dir string, opts ...Option) (*Fakes, error) { + name, ok := canonicalLocale(filepath.Base(dir)) + if !ok { + return nil, fmt.Errorf("fakes: %q is not a full locale tag like \"sv_SE\"", filepath.Base(dir)) + } + cats, err := loadLocale(dir) + if err != nil { + return nil, fmt.Errorf("fakes: %s: %w", name, err) + } + var c config + for _, opt := range opts { + opt(&c) + } + return &Fakes{rand: newRand(c.seed, c.seeded), locale: name, categories: cats}, nil +} + +// Locale returns the faker's canonical locale tag. +func (f *Fakes) Locale() string { return f.locale } + +func newRand(seed uint64, seeded bool) *rand.Rand { + if !seeded { + var b [16]byte + _, _ = crand.Read(b[:]) + return rand.New(rand.NewPCG(binary.LittleEndian.Uint64(b[:8]), binary.LittleEndian.Uint64(b[8:]))) + } + return rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15)) +} + +// intn returns a random int in [0, n). It panics for n <= 0, matching the +// stdlib contract. +func (f *Fakes) intn(n int) int { return f.rand.IntN(n) } diff --git a/fakes_test.go b/fakes_test.go new file mode 100644 index 0000000..e005c9d --- /dev/null +++ b/fakes_test.go @@ -0,0 +1,60 @@ +package fakes + +import ( + "strings" + "testing" +) + +// newFakes creates a faker for a locale directory, failing the test on error. +func newFakes(t *testing.T, dir string, opts ...Option) *Fakes { + t.Helper() + f, err := New(dir, opts...) + if err != nil { + t.Fatalf("New(%q): %v", dir, err) + } + return f +} + +// fake generates a value, failing the test on error. +func fake(t *testing.T, f *Fakes, path string) string { + t.Helper() + s, err := f.Fake(path) + if err != nil { + t.Fatalf("Fake(%q): %v", path, err) + } + return s +} + +func TestNewRejectsNonFullLocale(t *testing.T) { + for _, bad := range []string{"locales/sv", "locales/se", "locales/swedish", "locales/sv_S"} { + if _, err := New(bad); err == nil { + t.Errorf("New(%q) = nil error, want full-locale error", bad) + } + } +} + +func TestNewMissingDirectory(t *testing.T) { + _, err := New("locales/de_DE") + if err == nil || !strings.Contains(err.Error(), "de_DE") { + t.Fatalf("New(missing) error = %v, want it to name the locale", err) + } +} + +func TestWithSeedIsDeterministic(t *testing.T) { + a, b := newFakes(t, "locales/sv_SE", WithSeed(42)), newFakes(t, "locales/sv_SE", WithSeed(42)) + for i := 0; i < 50; i++ { + if x, y := fake(t, a, "person"), fake(t, b, "person"); x != y { + t.Fatalf("same seed diverged at %d: %q != %q", i, x, y) + } + } +} + +func TestDifferentSeedsDiffer(t *testing.T) { + a, b := newFakes(t, "locales/en_US", WithSeed(1)), newFakes(t, "locales/en_US", WithSeed(2)) + for i := 0; i < 50; i++ { + if fake(t, a, "person") != fake(t, b, "person") { + return + } + } + t.Fatal("seeds 1 and 2 produced identical sequences") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c623d15 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/Timewave-AB/fakes + +go 1.26 diff --git a/locale.go b/locale.go new file mode 100644 index 0000000..aff22e5 --- /dev/null +++ b/locale.go @@ -0,0 +1,66 @@ +package fakes + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// loadLocale parses and compiles every JSON file in dir into a category node +// tree, keyed by the file's base name without extension (address.json -> +// "address"). Compiling here means structural errors surface at New, not at +// Fake time. +func loadLocale(dir string) (map[string]node, error) { + info, err := os.Stat(dir) + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("%s is not a directory", dir) + } + files, err := filepath.Glob(filepath.Join(dir, "*.json")) + if err != nil { + return nil, err + } + sort.Strings(files) + if len(files) == 0 { + return nil, fmt.Errorf("no .json files in %s", dir) + } + categories := make(map[string]node, len(files)) + for _, file := range files { + b, err := os.ReadFile(file) + if err != nil { + return nil, err + } + var raw any + if err := json.Unmarshal(b, &raw); err != nil { + return nil, fmt.Errorf("%s: %w", filepath.Base(file), err) + } + n, err := compile(raw) + if err != nil { + return nil, fmt.Errorf("%s: %w", filepath.Base(file), err) + } + categories[strings.TrimSuffix(filepath.Base(file), ".json")] = n + } + return categories, nil +} + +var localeTag = regexp.MustCompile(`^[a-z]{2,3}_[A-Z]{2}$`) + +// canonicalLocale normalises a tag to "lang_REGION" form, accepting '-' or '_' +// separators and any casing. It reports whether the result is a full tag. +func canonicalLocale(s string) (string, bool) { + parts := strings.Split(strings.ReplaceAll(strings.TrimSpace(s), "-", "_"), "_") + if len(parts) != 2 { + return "", false + } + tag := strings.ToLower(parts[0]) + "_" + strings.ToUpper(parts[1]) + if !localeTag.MatchString(tag) { + return "", false + } + return tag, true +} diff --git a/locale_test.go b/locale_test.go new file mode 100644 index 0000000..0d18e04 --- /dev/null +++ b/locale_test.go @@ -0,0 +1,98 @@ +package fakes + +import ( + "os" + "path/filepath" + "regexp" + "testing" +) + +// writeLocale creates a locale directory named tag under a temp root, with one +// JSON file per category, and returns its path. +func writeLocale(t *testing.T, tag string, categories map[string]string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), tag) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for name, content := range categories { + if err := os.WriteFile(filepath.Join(dir, name+".json"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + return dir +} + +func TestNewLoadsCustomLocale(t *testing.T) { + dir := writeLocale(t, "is_IS", map[string]string{"greeting": `["hej", "hallå"]`}) + f := newFakes(t, dir, WithSeed(1)) + if got := fake(t, f, "greeting"); got != "hej" && got != "hallå" { + t.Fatalf("greeting = %q, want hej or hallå", got) + } +} + +func TestNewCanonicalizesDirName(t *testing.T) { + // A directory named with a lowercase region resolves to the canonical tag. + dir := writeLocale(t, "sv_se", map[string]string{"x": `["a"]`}) + if got := newFakes(t, dir).Locale(); got != "sv_SE" { + t.Fatalf("Locale() = %q, want sv_SE", got) + } +} + +func TestNewEmptyLocaleErrors(t *testing.T) { + if _, err := New(writeLocale(t, "xx_XX", nil)); err == nil { + t.Fatal("New(empty locale) = nil error") + } +} + +func TestShippedSwedishPhone(t *testing.T) { + f := newFakes(t, "locales/sv_SE", WithSeed(11)) + re := regexp.MustCompile(`^0\d{1,2}-\d{3} \d{2} \d{2}$`) + for i := 0; i < 50; i++ { + if n := fake(t, f, "phone"); !re.MatchString(n) { + t.Fatalf("phone %q does not match %s", n, re) + } + } +} + +func TestShippedSwedishAddress(t *testing.T) { + f := newFakes(t, "locales/sv_SE", WithSeed(3)) + digit := regexp.MustCompile(`\d`) + for i := 0; i < 30; i++ { + a := fake(t, f, "address") + if !regexp.MustCompile(`\n`).MatchString(a) || !digit.MatchString(a) { + t.Fatalf("address %q is not a multi-line address with a number", a) + } + } + + localities := map[string]bool{ + "Göteborg": true, "Linköping": true, "Malmö": true, "Stockholm": true, + "Uppsala": true, "Västerås": true, "Örebro": true, + } + for i := 0; i < 30; i++ { + if c := fake(t, f, "address.locality"); !localities[c] { + t.Fatalf("locality %q not in sv_SE data", c) + } + } +} + +func TestShippedPersonHasParts(t *testing.T) { + for _, locale := range []string{"locales/sv_SE", "locales/en_US"} { + f := newFakes(t, locale, WithSeed(7)) + for i := 0; i < 30; i++ { + if name := fake(t, f, "person"); len(name) < 3 || !regexp.MustCompile(`\S \S`).MatchString(name) { + t.Fatalf("%s person %q lacks first and last name", locale, name) + } + } + } +} + +func TestShippedUSPhone(t *testing.T) { + f := newFakes(t, "locales/en_US", WithSeed(11)) + re := regexp.MustCompile(`^(\(\d{3}\) \d{3}-\d{4}|\d{3}-\d{3}-\d{4})$`) + for i := 0; i < 50; i++ { + if n := fake(t, f, "phone"); !re.MatchString(n) { + t.Fatalf("phone %q does not match %s", n, re) + } + } +} diff --git a/locales/en_US/address.json b/locales/en_US/address.json new file mode 100644 index 0000000..9584026 --- /dev/null +++ b/locales/en_US/address.json @@ -0,0 +1,31 @@ +[ + { + "format": "{street-number} {street}\n{locality}, {region} {postal-code}", + "street": [ + { + "format": "{name} {suffix}", + "name": ["Cedar", "Elm", "Lincoln", "Maple", "Oak", "Pine", "Washington"], + "suffix": ["Street", "Avenue", "Drive", "Lane"] + } + ], + "street-number": [ + { + "format": "10" + }, + { + "format": "100" + }, + { + "format": "1000", + "weight": 0.5 + } + ], + "locality": ["Austin", "Boston", "Chicago", "Denver", "Houston", "Phoenix", "Portland", "Seattle"], + "region": ["CA", "IL", "MA", "NY", "TX", "WA"], + "postal-code": [ + { + "format": "10000" + } + ] + } +] diff --git a/locales/en_US/person.json b/locales/en_US/person.json new file mode 100644 index 0000000..87a7738 --- /dev/null +++ b/locales/en_US/person.json @@ -0,0 +1,16 @@ +[ + { + "format": "{prefix}{femalefirst|malefirst} {last}", + "femalefirst": ["Amelia", "Ava", "Charlotte", "Emma", "Evelyn", "Harper", "Isabella", "Mia", "Olivia", "Sophia"], + "malefirst": ["Benjamin", "Elijah", "Henry", "James", "Liam", "Lucas", "Mason", "Noah", "Oliver", "William"], + "last": ["Brown", "Davis", "Garcia", "Johnson", "Jones", "Miller", "Smith", "Taylor", "Williams", "Wilson"], + "prefix": [ + "", + { + "format": "{title} ", + "title": ["Dr", "Mr", "Mrs", "Ms"], + "weight": 0.1 + } + ] + } +] diff --git a/locales/en_US/phone.json b/locales/en_US/phone.json new file mode 100644 index 0000000..09ba1ed --- /dev/null +++ b/locales/en_US/phone.json @@ -0,0 +1,8 @@ +[ + { + "format": "(100) 100-0000" + }, + { + "format": "100-100-0000" + } +] diff --git a/locales/sv_SE/address.json b/locales/sv_SE/address.json new file mode 100644 index 0000000..5868504 --- /dev/null +++ b/locales/sv_SE/address.json @@ -0,0 +1,39 @@ +[ + { + "format": "{street} {street-number}\n{postal-code} {locality}", + "street": [ + { + "format": "{first}{last}", + "first": ["Bergs", "Kungs", "Stor"], + "last": ["gatan", "vägen", "stigen"] + }, + ["Vintjärn", "Staby", "Promenaden"] + ], + "street-number": [ + { + "format": "1" + }, + { + "format": "10" + }, + { + "format": "100", + "weight": 0.2 + }, + { + "format": "1A", + "weight": 0.2 + }, + { + "format": "10A", + "weight": 0.1 + } + ], + "postal-code": [ + { + "format": "100 10" + } + ], + "locality": ["Göteborg", "Linköping", "Malmö", "Stockholm", "Uppsala", "Västerås", "Örebro"] + } +] \ No newline at end of file diff --git a/locales/sv_SE/person.json b/locales/sv_SE/person.json new file mode 100644 index 0000000..57e2cff --- /dev/null +++ b/locales/sv_SE/person.json @@ -0,0 +1,22 @@ +[ + { + "format": "{prefix}{femalefirst|malefirst} {last}", + "femalefirst": ["Anna", "Astrid", "Elin", "Emma", "Ingrid", "Karin", "Linnéa", "Maria", "Sara", "Sofia"], + "malefirst": ["Anders", "Erik", "Gustav", "Johan", "Karl", "Lars", "Mikael", "Nils", "Oskar", "Per"], + "last": [ + { + "format": "{first}sson", + "first": ["Ander", "Erik", "Johan", "Karl", "Lar"] + }, + ["Berg", "von Flemming", "Eismar"] + ], + "prefix": [ + "", + { + "format": "{string} ", + "string": ["dr", "prof"], + "weight": 0.05 + } + ] + } +] diff --git a/locales/sv_SE/phone.json b/locales/sv_SE/phone.json new file mode 100644 index 0000000..a5df498 --- /dev/null +++ b/locales/sv_SE/phone.json @@ -0,0 +1,12 @@ +[ + { + "format": "#070-000 00 00", + "weight": 10 + }, + { + "format": "#01-000 00 00" + }, + { + "format": "#010-000 00 00" + } +] diff --git a/template.go b/template.go new file mode 100644 index 0000000..e880f6a --- /dev/null +++ b/template.go @@ -0,0 +1,236 @@ +package fakes + +import ( + "fmt" + "sort" + "strings" +) + +// node is a compiled template element: literal, choice, or template. Compiling +// JSON into these once (see compile) means rendering never re-inspects the raw +// JSON or re-sums weights. +type node interface{ isNode() } + +// literal is emitted verbatim, never formatted. +type literal string + +func (literal) isNode() {} + +// choice picks one of its items. cum holds cumulative weights for a weighted +// pick; when nil the choice is uniform and selection is O(1). +type choice struct { + items []node + cum []float64 +} + +func (*choice) isNode() {} + +// template renders a format string, substituting {tokens} from fields. +type template struct { + format string + fields map[string]node +} + +func (*template) isNode() {} + +// Fake generates a value for a category path. The first segment names a +// category (a JSON file); further dot-separated segments descend into named +// fields, e.g. "address" or "address.street". Choices along the way are +// resolved at random. +func (f *Fakes) Fake(path string) (string, error) { + segments := strings.Split(path, ".") + n, ok := f.categories[segments[0]] + if !ok { + return "", fmt.Errorf("fakes: unknown category %q", segments[0]) + } + n, err := f.descend(n, segments[1:]) + if err != nil { + return "", fmt.Errorf("fakes: %s: %w", path, err) + } + s, err := f.render(n) + if err != nil { + return "", fmt.Errorf("fakes: %s: %w", path, err) + } + return s, nil +} + +// descend walks named fields, resolving choices it meets along the way. +func (f *Fakes) descend(n node, segments []string) (node, error) { + if len(segments) == 0 { + return n, nil + } + switch n := n.(type) { + case *template: + child, ok := n.fields[segments[0]] + if !ok { + return nil, fmt.Errorf("no field %q", segments[0]) + } + return f.descend(child, segments[1:]) + case *choice: + chosen, err := f.pick(n) + if err != nil { + return nil, err + } + return f.descend(chosen, segments) // a choice consumes no path segment + default: + return nil, fmt.Errorf("cannot descend into %T at %q", n, segments[0]) + } +} + +// render evaluates a node to a string. +func (f *Fakes) render(n node) (string, error) { + switch n := n.(type) { + case literal: + return string(n), nil + case *choice: + chosen, err := f.pick(n) + if err != nil { + return "", err + } + return f.render(chosen) + case *template: + return f.expand(n.format, n.fields) + default: + return "", fmt.Errorf("unsupported node %T", n) + } +} + +// pick selects one item. Uniform choices are O(1); weighted choices are an +// O(log n) search over precomputed cumulative weights. +func (f *Fakes) pick(c *choice) (node, error) { + if len(c.items) == 0 { + return nil, fmt.Errorf("empty choice") + } + if c.cum == nil { + return c.items[f.intn(len(c.items))], nil + } + r := f.rand.Float64() * c.cum[len(c.cum)-1] + i := sort.Search(len(c.cum), func(i int) bool { return c.cum[i] > r }) + if i >= len(c.items) { + i = len(c.items) - 1 + } + return c.items[i], nil +} + +// expand renders a format string. Character classes: '0' digit 0-9, '1' digit +// 1-9, 'A' letter A-Z, 'a' letter a-z. '#' escapes the next character to a +// literal ("#0" -> "0", "##" -> "#"). "{token}" is substituted via resolve; +// every other rune is literal. +func (f *Fakes) expand(format string, fields map[string]node) (string, error) { + var b strings.Builder + rs := []rune(format) + for i := 0; i < len(rs); i++ { + switch c := rs[i]; c { + case '#': + if i++; i < len(rs) { + b.WriteRune(rs[i]) + } else { + b.WriteRune('#') + } + case '0': + b.WriteByte(byte('0' + f.intn(10))) + case '1': + b.WriteByte(byte('1' + f.intn(9))) + case 'A': + b.WriteByte(byte('A' + f.intn(26))) + case 'a': + b.WriteByte(byte('a' + f.intn(26))) + case '{': + end := i + 1 + for end < len(rs) && rs[end] != '}' { + end++ + } + if end >= len(rs) { + return "", fmt.Errorf("unterminated '{' in %q", format) + } + s, err := f.resolve(string(rs[i+1:end]), fields) + if err != nil { + return "", err + } + b.WriteString(s) + i = end + default: + b.WriteRune(c) + } + } + return b.String(), nil +} + +// resolve evaluates a "{token}" body: one or more field names separated by '|', +// of which one is chosen at random and rendered. +func (f *Fakes) resolve(token string, fields map[string]node) (string, error) { + names := strings.Split(token, "|") + name := names[f.intn(len(names))] + child, ok := fields[name] + if !ok { + return "", fmt.Errorf("token {%s}: no field %q", token, name) + } + return f.render(child) +} + +// compile converts parsed JSON into a node tree, validating structure up front. +func compile(v any) (node, error) { + switch v := v.(type) { + case string: + return literal(v), nil + case []any: + return compileChoice(v) + case map[string]any: + return compileTemplate(v) + default: + return nil, fmt.Errorf("unsupported node type %T", v) + } +} + +func compileChoice(items []any) (node, error) { + c := &choice{items: make([]node, len(items))} + cum := make([]float64, len(items)) + var total float64 + weighted := false + for i, raw := range items { + w := weightOf(raw) + if w != 1 { + weighted = true + } + total += w + cum[i] = total + n, err := compile(raw) + if err != nil { + return nil, err + } + c.items[i] = n + } + if weighted { // uniform choices skip the weight table and pick in O(1) + c.cum = cum + } + return c, nil +} + +func compileTemplate(m map[string]any) (node, error) { + format, ok := m["format"].(string) + if !ok { + return nil, fmt.Errorf("template object missing string \"format\"") + } + t := &template{format: format, fields: make(map[string]node, len(m))} + for k, v := range m { + if k == "format" || k == "weight" { + continue + } + n, err := compile(v) + if err != nil { + return nil, fmt.Errorf("field %q: %w", k, err) + } + t.fields[k] = n + } + return t, nil +} + +// weightOf reads a node's "weight" (default 1) from its raw JSON form. +func weightOf(raw any) float64 { + if m, ok := raw.(map[string]any); ok { + if w, ok := m["weight"].(float64); ok { + return w + } + } + return 1 +} diff --git a/template_test.go b/template_test.go new file mode 100644 index 0000000..229a9a7 --- /dev/null +++ b/template_test.go @@ -0,0 +1,168 @@ +package fakes + +import ( + "encoding/json" + "regexp" + "strings" + "testing" +) + +// engine builds a seeded faker with no loaded categories, for rendering tests. +func engine(seed uint64) *Fakes { return &Fakes{rand: newRand(seed, true)} } + +// parse unmarshals a JSON template fragment into its dynamic form. +func parse(t *testing.T, s string) any { + t.Helper() + var v any + if err := json.Unmarshal([]byte(s), &v); err != nil { + t.Fatalf("parse %q: %v", s, err) + } + return v +} + +// compiled parses and compiles a JSON fragment into a node. +func compiled(t *testing.T, s string) node { + t.Helper() + n, err := compile(parse(t, s)) + if err != nil { + t.Fatalf("compile %q: %v", s, err) + } + return n +} + +func render(t *testing.T, f *Fakes, s string) string { + t.Helper() + out, err := f.render(compiled(t, s)) + if err != nil { + t.Fatalf("render %q: %v", s, err) + } + return out +} + +func TestLiteralStringIsVerbatim(t *testing.T) { + // A bare string is a literal, never formatted: 'a'/'A' must survive. + if got := render(t, engine(1), `"Malmö"`); got != "Malmö" { + t.Fatalf("literal = %q, want Malmö", got) + } +} + +func TestCharacterClasses(t *testing.T) { + cases := map[string]*regexp.Regexp{ + `{"format":"0"}`: regexp.MustCompile(`^[0-9]$`), + `{"format":"1"}`: regexp.MustCompile(`^[1-9]$`), + `{"format":"A"}`: regexp.MustCompile(`^[A-Z]$`), + `{"format":"a"}`: regexp.MustCompile(`^[a-z]$`), + } + f := engine(7) + for tmpl, re := range cases { + for i := 0; i < 100; i++ { + if got := render(t, f, tmpl); !re.MatchString(got) { + t.Fatalf("%s produced %q, want %s", tmpl, got, re) + } + } + } +} + +func TestEscapeAndLiteralChars(t *testing.T) { + // '#' escapes the next char; non-class chars (7, x, -) are literal. + if got := render(t, engine(1), `{"format":"#0#1#A#a## x7-z"}`); got != "01Aa# x7-z" { + t.Fatalf("escape = %q, want \"01Aa# x7-z\"", got) + } +} + +func TestTokenSubstitution(t *testing.T) { + if got := render(t, engine(1), `{"format":"{x}sson","x":["Erik"]}`); got != "Eriksson" { + t.Fatalf("token = %q, want Eriksson", got) + } +} + +func TestAlternationPicksOneField(t *testing.T) { + f := engine(3) + seen := map[string]bool{} + for i := 0; i < 100; i++ { + seen[render(t, f, `{"format":"{a|b}","a":["A"],"b":["B"]}`)] = true + } + if !seen["A"] || !seen["B"] || len(seen) != 2 { + t.Fatalf("alternation produced %v, want both A and B", seen) + } +} + +func TestWeightZeroNeverChosen(t *testing.T) { + f := engine(5) + for i := 0; i < 200; i++ { + if got := render(t, f, `[{"format":"X","weight":0},"Y"]`); got != "Y" { + t.Fatalf("weight-0 variant chosen: %q", got) + } + } +} + +func TestWeightSkewsDistribution(t *testing.T) { + f := engine(9) + heavy := 0 + for i := 0; i < 1000; i++ { + if render(t, f, `[{"format":"H","weight":10},{"format":"L"}]`) == "H" { + heavy++ + } + } + if heavy < 800 { // expected ~909 + t.Fatalf("heavy variant chosen %d/1000, want a clear majority", heavy) + } +} + +func TestRecursionHasNoDepthLimit(t *testing.T) { + // Build {format:{a}, a:[{format:{a}, a:[ ... "deep" ]]}} 50 levels deep. + tmpl := `"deep"` + for i := 0; i < 50; i++ { + tmpl = `{"format":"{a}","a":[` + tmpl + `]}` + } + if got := render(t, engine(1), tmpl); got != "deep" { + t.Fatalf("deep recursion = %q, want deep", got) + } +} + +func TestCompileErrors(t *testing.T) { + // Structural problems are caught up front, at compile/New time. + for _, bad := range []string{ + `{"x":["Q"]}`, // object without "format" + `{"format":"{y}","x":1}`, // a field is a bare number + `[1, 2]`, // a choice of numbers + `5`, // unsupported node type + } { + if _, err := compile(parse(t, bad)); err == nil { + t.Errorf("compile(%s) = nil error, want error", bad) + } + } +} + +func TestRenderErrors(t *testing.T) { + // These compile, but fail when rendered. + f := engine(1) + for _, bad := range []string{ + `{"format":"{x"}`, // unterminated brace + `{"format":"{y}","x":["Q"]}`, // token names a missing field + `[]`, // empty choice + } { + if _, err := f.render(compiled(t, bad)); err == nil { + t.Errorf("render(%s) = nil error, want error", bad) + } + } +} + +func TestFakePathNavigation(t *testing.T) { + f := engine(1) + f.categories = map[string]node{ + "addr": compiled(t, `[{"format":"{street}","street":["Main"]}]`), + } + if got, err := f.Fake("addr"); err != nil || !strings.Contains(got, "Main") { + t.Fatalf("Fake(addr) = %q, %v", got, err) + } + if got, err := f.Fake("addr.street"); err != nil || got != "Main" { + t.Fatalf("Fake(addr.street) = %q, %v, want Main", got, err) + } + if _, err := f.Fake("addr.nope"); err == nil { + t.Error("Fake(addr.nope) = nil error, want missing-field error") + } + if _, err := f.Fake("missing"); err == nil { + t.Error("Fake(missing) = nil error, want unknown-category error") + } +}