Add fakes library: recursive JSON template engine, i18n locales, Docker workflow
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
.git/
|
||||||
|
.claude/
|
||||||
|
*.out
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Coverage output
|
||||||
|
*.out
|
||||||
+13
@@ -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 ./...
|
||||||
@@ -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).
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"format": "(100) 100-0000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"format": "100-100-0000"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"format": "#070-000 00 00",
|
||||||
|
"weight": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"format": "#01-000 00 00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"format": "#010-000 00 00"
|
||||||
|
}
|
||||||
|
]
|
||||||
+236
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user