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