Add fakes library: recursive JSON template engine, i18n locales, Docker workflow

This commit is contained in:
lilleman
2026-06-05 19:51:18 +02:00
parent 725126a9e9
commit 943a1d9f1b
19 changed files with 1324 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
.git/
.claude/
*.out
+2
View File
@@ -0,0 +1,2 @@
# Coverage output
*.out
+13
View File
@@ -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 ./...
+204
View File
@@ -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 09 |
| `1` | digit 19 |
| `A` | letter AZ |
| `a` | letter az |
| `#` | 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).
+63
View File
@@ -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:
+200
View File
@@ -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)
}
}
}
+80
View File
@@ -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) }
+60
View File
@@ -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")
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/Timewave-AB/fakes
go 1.26
+66
View File
@@ -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
}
+98
View File
@@ -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)
}
}
}
+31
View File
@@ -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"
}
]
}
]
+16
View File
@@ -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
}
]
}
]
+8
View File
@@ -0,0 +1,8 @@
[
{
"format": "(100) 100-0000"
},
{
"format": "100-100-0000"
}
]
+39
View File
@@ -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"]
}
]
+22
View File
@@ -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
}
]
}
]
+12
View File
@@ -0,0 +1,12 @@
[
{
"format": "#070-000 00 00",
"weight": 10
},
{
"format": "#01-000 00 00"
},
{
"format": "#010-000 00 00"
}
]
+236
View File
@@ -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
}
+168
View File
@@ -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")
}
}