Replace locale dirs with a data-folder namespace model: New([]paths), folder dot-paths, last-wins merge
This commit is contained in:
@@ -7,10 +7,11 @@ lacked the locale coverage and format control we needed.
|
|||||||
- **Standards first** — formats, names and structures follow international and
|
- **Standards first** — formats, names and structures follow international and
|
||||||
local standards first and foremost.
|
local standards first and foremost.
|
||||||
- **Locale-aware** — names, addresses, postal codes and phone numbers follow
|
- **Locale-aware** — names, addresses, postal codes and phone numbers follow
|
||||||
per-locale data and formats. Locales are always full language + territory
|
per-locale data and formats. The shipped data is organised by full locale tag
|
||||||
tags (`sv_SE`, never `sv`).
|
(`sv_SE`), but the engine treats folders as plain namespaces — name yours
|
||||||
|
anything.
|
||||||
- **Data lives in JSON** — all source data is recursive JSON on disk, read when
|
- **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
|
you create a faker and then served from memory. Add or change data without
|
||||||
touching the library. Behavior belongs in data too: the engine grows a
|
touching the library. Behavior belongs in data too: the engine grows a
|
||||||
built-in function only for what data can't express (a checksum, a time-based
|
built-in function only for what data can't express (a checksum, a time-based
|
||||||
id), never for what character classes and choices already do.
|
id), never for what character classes and choices already do.
|
||||||
@@ -23,25 +24,27 @@ lacked the locale coverage and format control we needed.
|
|||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
Install the `fakes` command, then point it at a locale directory and a category
|
Install the `fakes` command, then point it at one or more data directories and a
|
||||||
path — it prints one value to stdout. The first path segment names a category (a
|
path — it prints one value to stdout. Each dot segment descends one level:
|
||||||
JSON file); deeper dotted segments descend into it.
|
folders, then the category (a JSON file), then fields inside it.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go install github.com/Timewave-AB/fakes/cmd/fakes@latest
|
go install github.com/Timewave-AB/fakes/cmd/fakes@latest
|
||||||
|
|
||||||
fakes ./locales/sv_SE person # Sara Eriksson
|
fakes ./data/sv_SE person # Sara Eriksson
|
||||||
fakes ./locales/sv_SE person.last # Eriksson (dotted path into a category)
|
fakes ./data/sv_SE person.last # Eriksson (dotted path into a category)
|
||||||
fakes -seed 42 ./locales/sv_SE address
|
fakes ./data sv_SE.person # point at the tree; the folder is a segment
|
||||||
|
fakes ./data/sv_SE ./mydata word # layer dirs; the last wins a name clash
|
||||||
|
fakes -seed 42 ./data/sv_SE address
|
||||||
```
|
```
|
||||||
|
|
||||||
Without installing, run it from a checkout with `go run ./cmd/fakes …`. Exit
|
Without installing, run it from a checkout with `go run ./cmd/fakes …`. Exit
|
||||||
codes: `0` success, `1` runtime error (bad locale, unknown path), `2` misuse.
|
codes: `0` success, `1` runtime error (missing dir, unknown path), `2` misuse.
|
||||||
|
|
||||||
### Generating a file from a custom template
|
### Generating a file from a custom template
|
||||||
|
|
||||||
A category is just a JSON file in the locale directory, so you can drop in your
|
A category is just a JSON file in a data directory, so you can drop in your
|
||||||
own and render it — no code change. Save this as `locales/sv_SE/sql.json`:
|
own and render it — no code change. Save this as `data/sv_SE/sql.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -58,10 +61,10 @@ own and render it — no code change. Save this as `locales/sv_SE/sql.json`:
|
|||||||
`sql-username` renders `'{username}'` `repeat` times and joins the results with
|
`sql-username` renders `'{username}'` `repeat` times and joins the results with
|
||||||
the `),(` separator; the outer `V#ALUES(…)` wraps that into one valid row list.
|
the `),(` separator; the outer `V#ALUES(…)` wraps that into one valid row list.
|
||||||
(`#A` escapes the literal `A`, which a format string would otherwise read as a
|
(`#A` escapes the literal `A`, which a format string would otherwise read as a
|
||||||
letter token — see [Locale data format](#locale-data-format).)
|
letter token — see [Data format](#data-format).)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
fakes -seed 1 ./locales/sv_SE sql
|
fakes -seed 1 ./data/sv_SE sql
|
||||||
# INSERT INTO users VALUES('zoom'),('wahoo'),('blip');
|
# INSERT INTO users VALUES('zoom'),('wahoo'),('blip');
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -69,7 +72,7 @@ Raise `repeat` for more rows per statement, or loop in the shell to build a
|
|||||||
whole seed file:
|
whole seed file:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
for _ in $(seq 100); do fakes ./locales/sv_SE sql; done > seed.sql
|
for _ in $(seq 100); do fakes ./data/sv_SE sql; done > seed.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
## Library
|
## Library
|
||||||
@@ -78,9 +81,9 @@ for _ in $(seq 100); do fakes ./locales/sv_SE sql; done > seed.sql
|
|||||||
go get github.com/Timewave-AB/fakes # requires Go 1.22+ (for math/rand/v2)
|
go get github.com/Timewave-AB/fakes # requires Go 1.22+ (for math/rand/v2)
|
||||||
```
|
```
|
||||||
|
|
||||||
Point `New` at a locale directory, then generate values by category path with
|
Point `New` at one or more data directories, then generate values by path with
|
||||||
`Fake`. The first path segment names a category (a JSON file); deeper dotted
|
`Fake`. Each dot segment descends one level: folders, then the category (a JSON
|
||||||
segments descend into it.
|
file), then fields inside it.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package main
|
package main
|
||||||
@@ -93,7 +96,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
f, err := fakes.New("./locales/sv_SE")
|
f, err := fakes.New([]string{"./data/sv_SE"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -120,8 +123,8 @@ Seed a faker for reproducible output — same seed + locale yields an identical
|
|||||||
sequence, handy for stable tests:
|
sequence, handy for stable tests:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
a, _ := fakes.New("./locales/sv_SE", fakes.WithSeed(42))
|
a, _ := fakes.New([]string{"./data/sv_SE"}, fakes.WithSeed(42))
|
||||||
b, _ := fakes.New("./locales/sv_SE", fakes.WithSeed(42))
|
b, _ := fakes.New([]string{"./data/sv_SE"}, fakes.WithSeed(42))
|
||||||
av, _ := a.Fake("person")
|
av, _ := a.Fake("person")
|
||||||
bv, _ := b.Fake("person")
|
bv, _ := b.Fake("person")
|
||||||
av == bv // true
|
av == bv // true
|
||||||
@@ -129,25 +132,34 @@ av == bv // true
|
|||||||
|
|
||||||
A `*Fakes` is **not** safe for concurrent use — create one per goroutine.
|
A `*Fakes` is **not** safe for concurrent use — create one per goroutine.
|
||||||
|
|
||||||
## Locales
|
## Data
|
||||||
|
|
||||||
The library ships a ready-to-use set under [`locales/`](locales) (`en_US`,
|
The library ships a ready-to-use set under [`data/`](data), organised by locale
|
||||||
`sv_SE`). Point either tool at one of them, a copy, or your own directory —
|
(`en_US`, `sv_SE`). Point either tool at the whole tree, a single locale, a
|
||||||
anywhere on disk.
|
copy, or your own directory — anywhere on disk; no naming rules.
|
||||||
|
|
||||||
Each ships these categories, formatted per locale (e.g. `date` is `MM/DD/YYYY`
|
A directory is just a namespace. Each JSON file is a category named after the
|
||||||
in `en_US`, `YYYY-MM-DD` in `sv_SE`; `ssn` is a US SSN vs a Swedish
|
file; each subdirectory is a dot-path segment — folders nest exactly like JSON
|
||||||
personnummer): `address`, `color`, `company`, `date`, `email`, `ip`, `person`,
|
objects do. So `data/sv_SE/person.json` is `Fake("person")` when you point at
|
||||||
`phone`, `price`, `sentence`, `ssn`, `time`, `url`, `username`, `uuid`,
|
`data/sv_SE`, or `Fake("sv_SE.person")` when you point at `data`.
|
||||||
`version`, `word`.
|
|
||||||
|
|
||||||
The directory's name is the locale and must be a full tag (`sv_SE`, never
|
Pass several directories and they merge, left to right: matching folders combine
|
||||||
`sv`). Any casing or separator is accepted and canonicalised (`sv-se`,
|
by their children, and any other clash is won by the last directory loaded. That
|
||||||
`SV_SE` → `sv_SE`); a non-full name returns an error.
|
lets you layer your own data over the built-ins without copying them:
|
||||||
|
|
||||||
## Locale data format
|
```go
|
||||||
|
fakes.New([]string{"./data/sv_SE", "./mydata"}) // mydata overrides on a clash
|
||||||
|
```
|
||||||
|
|
||||||
Each JSON file in a locale directory is a **category** named after the file
|
Each shipped locale carries these categories, formatted per locale (e.g. `date`
|
||||||
|
is `MM/DD/YYYY` in `en_US`, `YYYY-MM-DD` in `sv_SE`; `ssn` is a US SSN vs a
|
||||||
|
Swedish personnummer): `address`, `color`, `company`, `date`, `email`, `ip`,
|
||||||
|
`person`, `phone`, `price`, `sentence`, `ssn`, `time`, `url`, `username`,
|
||||||
|
`uuid`, `version`, `word`.
|
||||||
|
|
||||||
|
## Data format
|
||||||
|
|
||||||
|
Each JSON file in a data directory is a **category** named after the file
|
||||||
(`address.json` → `address`), rendered by `Fake("address")`. Drop in a new file
|
(`address.json` → `address`), rendered by `Fake("address")`. Drop in a new file
|
||||||
or folder — no code change, no recompile.
|
or folder — no code change, no recompile.
|
||||||
|
|
||||||
@@ -297,13 +309,13 @@ docker compose run --rm test # latest
|
|||||||
```
|
```
|
||||||
fakes.go Fakes, New, options, seeding
|
fakes.go Fakes, New, options, seeding
|
||||||
template.go Fake, the recursive renderer (choices, format strings, paths)
|
template.go Fake, the recursive renderer (choices, format strings, paths)
|
||||||
locale.go locale loading + tag parsing
|
data.go data loading: folders/files -> namespace tree, multi-path merge
|
||||||
cmd/fakes/ the `fakes` CLI (New + Fake over stdout)
|
cmd/fakes/ the `fakes` CLI (New + Fake over stdout)
|
||||||
locales/ shipped locale data (JSON)
|
data/ shipped data (JSON), organised by locale
|
||||||
```
|
```
|
||||||
|
|
||||||
To add a category, drop a JSON file into a locale directory; to add a locale,
|
To add a category, drop a JSON file into a data directory; to add a locale, add
|
||||||
add a directory named with its full tag.
|
a subdirectory of JSON files.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+15
-12
@@ -1,10 +1,12 @@
|
|||||||
// Command fakes prints one fake value from a locale directory.
|
// Command fakes prints one fake value from one or more data directories.
|
||||||
//
|
//
|
||||||
// fakes ./locales/sv_SE person # a full person
|
// fakes ./data/sv_SE person # a full person
|
||||||
// fakes ./locales/sv_SE person.last # just the surname (dotted path)
|
// fakes ./data/sv_SE person.last # just the surname (dotted path)
|
||||||
// fakes -seed 42 ./locales/sv_SE address
|
// fakes ./data sv_SE.person # point at the tree, address by folder
|
||||||
|
// fakes ./data/sv_SE ./mydata person # layer custom data; last dir wins
|
||||||
|
// fakes -seed 42 ./data/sv_SE address
|
||||||
//
|
//
|
||||||
// It is a thin CLI over the fakes library: New(dir) then Fake(path).
|
// It is a thin CLI over the fakes library: New(dirs) then Fake(path).
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -16,11 +18,11 @@ import (
|
|||||||
"github.com/Timewave-AB/fakes"
|
"github.com/Timewave-AB/fakes"
|
||||||
)
|
)
|
||||||
|
|
||||||
const usage = `Usage: fakes [-seed N] <locale-dir> <path>
|
const usage = `Usage: fakes [-seed N] <data-dir>... <path>
|
||||||
|
|
||||||
<locale-dir> a locale directory, e.g. ./locales/sv_SE
|
<data-dir> one or more data directories, e.g. ./data/sv_SE (last wins on clash)
|
||||||
<path> a category, or a dotted path into one (person, person.last)
|
<path> a category, or a dotted path into one (person, person.last)
|
||||||
-seed N seed for reproducible output`
|
-seed N seed for reproducible output`
|
||||||
|
|
||||||
func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) }
|
func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) }
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
if fs.NArg() != 2 {
|
if fs.NArg() < 2 {
|
||||||
fs.Usage()
|
fs.Usage()
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
@@ -46,12 +48,13 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
f, err := fakes.New(fs.Arg(0), opts...)
|
dirs, path := fs.Args()[:fs.NArg()-1], fs.Arg(fs.NArg()-1)
|
||||||
|
f, err := fakes.New(dirs, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(stderr, err)
|
fmt.Fprintln(stderr, err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
v, err := f.Fake(fs.Arg(1))
|
v, err := f.Fake(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(stderr, err)
|
fmt.Fprintln(stderr, err)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
+20
-5
@@ -6,7 +6,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
const svSE = "../../locales/sv_SE"
|
const (
|
||||||
|
svSE = "../../data/sv_SE"
|
||||||
|
enUS = "../../data/en_US"
|
||||||
|
)
|
||||||
|
|
||||||
// runOut runs the CLI and returns exit code, stdout, stderr.
|
// runOut runs the CLI and returns exit code, stdout, stderr.
|
||||||
func runOut(args ...string) (int, string, string) {
|
func runOut(args ...string) (int, string, string) {
|
||||||
@@ -54,8 +57,9 @@ func TestRunSeedDeterministic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunUsageOnWrongArgCount(t *testing.T) {
|
func TestRunUsageOnTooFewArgs(t *testing.T) {
|
||||||
for _, args := range [][]string{{}, {svSE}, {svSE, "person", "extra"}} {
|
// Need at least one data dir plus a path; fewer is misuse.
|
||||||
|
for _, args := range [][]string{{}, {svSE}} {
|
||||||
code, _, errb := runOut(args...)
|
code, _, errb := runOut(args...)
|
||||||
if code != 2 {
|
if code != 2 {
|
||||||
t.Errorf("run(%v) = %d, want 2", args, code)
|
t.Errorf("run(%v) = %d, want 2", args, code)
|
||||||
@@ -66,6 +70,17 @@ func TestRunUsageOnWrongArgCount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunMultipleDirs(t *testing.T) {
|
||||||
|
// Several data dirs then a final path: all but the last arg are dirs.
|
||||||
|
code, out, errb := runOut(enUS, svSE, "person")
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("run(multi-dir) = %d, stderr=%q", code, errb)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out) == "" {
|
||||||
|
t.Fatal("empty output for multi-dir run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunUnknownCategoryFails(t *testing.T) {
|
func TestRunUnknownCategoryFails(t *testing.T) {
|
||||||
code, _, errb := runOut(svSE, "nope")
|
code, _, errb := runOut(svSE, "nope")
|
||||||
if code != 1 {
|
if code != 1 {
|
||||||
@@ -76,8 +91,8 @@ func TestRunUnknownCategoryFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunBadLocaleFails(t *testing.T) {
|
func TestRunMissingDirFails(t *testing.T) {
|
||||||
code, _, errb := runOut("../../locales/sv", "person")
|
code, _, errb := runOut("../../data/nope", "person")
|
||||||
if code != 1 {
|
if code != 1 {
|
||||||
t.Fatalf("run = %d, want 1", code)
|
t.Fatalf("run = %d, want 1", code)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package fakes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadData loads every given directory into one namespace tree and returns its
|
||||||
|
// root children. A directory becomes a group; each *.json file in it compiles to
|
||||||
|
// a node keyed by its base name (address.json -> "address"); each subdirectory
|
||||||
|
// becomes a nested group, so folders turn into dot-path segments. Paths are
|
||||||
|
// merged left to right: matching groups merge by their children, and any other
|
||||||
|
// clash (a leaf, or a leaf-vs-group) is won by the last directory loaded.
|
||||||
|
func loadData(paths []string) (map[string]node, error) {
|
||||||
|
root := map[string]node{}
|
||||||
|
for _, p := range paths {
|
||||||
|
g, err := loadDir(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mergeChildren(root, g.children)
|
||||||
|
}
|
||||||
|
if len(root) == 0 {
|
||||||
|
return nil, fmt.Errorf("no .json data found in %v", paths)
|
||||||
|
}
|
||||||
|
return root, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadDir compiles one directory into a group. os.ReadDir yields entries sorted
|
||||||
|
// by name, so the tree is built deterministically. Empty subdirectories (no JSON
|
||||||
|
// anywhere under them) are skipped rather than added as empty namespaces.
|
||||||
|
func loadDir(dir string) (*group, 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)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
g := &group{children: map[string]node{}}
|
||||||
|
for _, e := range entries {
|
||||||
|
full := filepath.Join(dir, e.Name())
|
||||||
|
if e.IsDir() {
|
||||||
|
child, err := loadDir(full)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(child.children) > 0 {
|
||||||
|
g.children[e.Name()] = child
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(e.Name(), ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(full)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var raw any
|
||||||
|
if err := json.Unmarshal(b, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", full, err)
|
||||||
|
}
|
||||||
|
n, err := compile(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", full, err)
|
||||||
|
}
|
||||||
|
g.children[strings.TrimSuffix(e.Name(), ".json")] = n
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeChildren overlays src onto dst. Two groups under the same key merge
|
||||||
|
// recursively (so locales/categories from several paths combine); every other
|
||||||
|
// key is replaced, making the last-loaded directory win on a conflict.
|
||||||
|
func mergeChildren(dst, src map[string]node) {
|
||||||
|
for k, v := range src {
|
||||||
|
if dg, ok := dst[k].(*group); ok {
|
||||||
|
if sg, ok := v.(*group); ok {
|
||||||
|
mergeChildren(dg.children, sg.children)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dst[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
-3
@@ -52,8 +52,8 @@ func TestShippedDataCategories(t *testing.T) {
|
|||||||
regexp.MustCompile(`^\d{1,3}( \d{3})?(,\d{2})? kr$`)},
|
regexp.MustCompile(`^\d{1,3}( \d{3})?(,\d{2})? kr$`)},
|
||||||
}
|
}
|
||||||
|
|
||||||
en := newFakes(t, "locales/en_US", WithSeed(1))
|
en := newFakes(t, "data/en_US", WithSeed(1))
|
||||||
sv := newFakes(t, "locales/sv_SE", WithSeed(1))
|
sv := newFakes(t, "data/sv_SE", WithSeed(1))
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
for i := 0; i < 200; i++ {
|
for i := 0; i < 200; i++ {
|
||||||
if v := fake(t, en, c.path); !c.en.MatchString(v) {
|
if v := fake(t, en, c.path); !c.en.MatchString(v) {
|
||||||
@@ -70,7 +70,7 @@ func TestShippedDataCategories(t *testing.T) {
|
|||||||
// is a real calendar date (so month-length variants never emit e.g. Apr 31 or
|
// is a real calendar date (so month-length variants never emit e.g. Apr 31 or
|
||||||
// Feb 30) and the trailing digit is a valid Luhn checksum over the other nine.
|
// Feb 30) and the trailing digit is a valid Luhn checksum over the other nine.
|
||||||
func TestSwedishPersonnummer(t *testing.T) {
|
func TestSwedishPersonnummer(t *testing.T) {
|
||||||
sv := newFakes(t, "locales/sv_SE", WithSeed(1))
|
sv := newFakes(t, "data/sv_SE", WithSeed(1))
|
||||||
sawLongMonthEnd := false
|
sawLongMonthEnd := false
|
||||||
for i := 0; i < 2000; i++ {
|
for i := 0; i < 2000; i++ {
|
||||||
v := fake(t, sv, "ssn")
|
v := fake(t, sv, "ssn")
|
||||||
@@ -93,6 +93,69 @@ func TestSwedishPersonnummer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestShippedSwedishPhone(t *testing.T) {
|
||||||
|
f := newFakes(t, "data/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, "data/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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
locality := regexp.MustCompile(`^\p{L}+( \p{L}+)*$`)
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
if c := fake(t, f, "address.locality"); !locality.MatchString(c) {
|
||||||
|
t.Fatalf("locality %q is not a Swedish place name", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShippedPersonHasParts(t *testing.T) {
|
||||||
|
for _, dir := range []string{"data/sv_SE", "data/en_US"} {
|
||||||
|
f := newFakes(t, dir, 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", dir, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShippedUSPhone(t *testing.T) {
|
||||||
|
f := newFakes(t, "data/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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestShippedNamespacedTree loads the whole data/ tree (not a single locale) and
|
||||||
|
// reaches each locale through its folder segment: data/sv_SE/person -> sv_SE.person.
|
||||||
|
func TestShippedNamespacedTree(t *testing.T) {
|
||||||
|
f := newFakes(t, "data", WithSeed(1))
|
||||||
|
for _, path := range []string{"sv_SE.person", "en_US.person", "sv_SE.address.locality"} {
|
||||||
|
if got := fake(t, f, path); got == "" {
|
||||||
|
t.Fatalf("Fake(%q) returned empty", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := f.Fake("person"); err == nil {
|
||||||
|
t.Fatal("Fake(person) = nil error; categories should be namespaced under the locale folder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func digitsOnly(s string) string {
|
func digitsOnly(s string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
|
|||||||
+7
-7
@@ -54,11 +54,11 @@ func TestNewErrors(t *testing.T) {
|
|||||||
if err := os.WriteFile(file, []byte("{}"), 0o644); err != nil {
|
if err := os.WriteFile(file, []byte("{}"), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := New(file); err == nil {
|
if _, err := New([]string{file}); err == nil {
|
||||||
t.Error("New(file) = nil error, want not-a-directory error")
|
t.Error("New(file) = nil error, want not-a-directory error")
|
||||||
}
|
}
|
||||||
// Invalid JSON in a category file fails.
|
// Invalid JSON in a category file fails.
|
||||||
if _, err := New(writeLocale(t, "xx_XX", map[string]string{"broken": `{ not json`})); err == nil {
|
if _, err := New([]string{writeData(t, map[string]string{"broken": `{ not json`})}); err == nil {
|
||||||
t.Error("New(invalid JSON) = nil error")
|
t.Error("New(invalid JSON) = nil error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@ func TestDescendIntoLiteralErrors(t *testing.T) {
|
|||||||
// --- category root shapes ---
|
// --- category root shapes ---
|
||||||
|
|
||||||
func TestCategoryRootShapes(t *testing.T) {
|
func TestCategoryRootShapes(t *testing.T) {
|
||||||
dir := writeLocale(t, "xx_XX", map[string]string{
|
dir := writeData(t, map[string]string{
|
||||||
"obj": `{"format":"00"}`, // object root
|
"obj": `{"format":"00"}`, // object root
|
||||||
"lit": `"hello"`, // bare-string root
|
"lit": `"hello"`, // bare-string root
|
||||||
})
|
})
|
||||||
@@ -117,7 +117,7 @@ func TestLongStringList(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
f := newFakes(t, writeLocale(t, "xx_XX", map[string]string{"name": string(list)}), WithSeed(1))
|
f := newFakes(t, writeData(t, map[string]string{"name": string(list)}), WithSeed(1))
|
||||||
|
|
||||||
valid := map[string]bool{}
|
valid := map[string]bool{}
|
||||||
for _, v := range names {
|
for _, v := range names {
|
||||||
@@ -145,7 +145,7 @@ var swedishName = regexp.MustCompile(`^\p{L}+([ -]\p{L}+)*$`)
|
|||||||
|
|
||||||
func TestShippedStreetComposition(t *testing.T) {
|
func TestShippedStreetComposition(t *testing.T) {
|
||||||
// street is a choice of composed {first}{last} templates and literal names.
|
// street is a choice of composed {first}{last} templates and literal names.
|
||||||
f := newFakes(t, "locales/sv_SE", WithSeed(5))
|
f := newFakes(t, "data/sv_SE", WithSeed(5))
|
||||||
for i := 0; i < 300; i++ {
|
for i := 0; i < 300; i++ {
|
||||||
if s := fake(t, f, "address.street"); !swedishName.MatchString(s) {
|
if s := fake(t, f, "address.street"); !swedishName.MatchString(s) {
|
||||||
t.Fatalf("street %q is not a Swedish street name", s)
|
t.Fatalf("street %q is not a Swedish street name", s)
|
||||||
@@ -155,7 +155,7 @@ func TestShippedStreetComposition(t *testing.T) {
|
|||||||
|
|
||||||
func TestShippedLastNameComposition(t *testing.T) {
|
func TestShippedLastNameComposition(t *testing.T) {
|
||||||
// last is a choice of patronymic {first}sson templates and literal surnames.
|
// last is a choice of patronymic {first}sson templates and literal surnames.
|
||||||
f := newFakes(t, "locales/sv_SE", WithSeed(6))
|
f := newFakes(t, "data/sv_SE", WithSeed(6))
|
||||||
for i := 0; i < 300; i++ {
|
for i := 0; i < 300; i++ {
|
||||||
if s := fake(t, f, "person.last"); !swedishName.MatchString(s) {
|
if s := fake(t, f, "person.last"); !swedishName.MatchString(s) {
|
||||||
t.Fatalf("last name %q is not a Swedish surname", s)
|
t.Fatalf("last name %q is not a Swedish surname", s)
|
||||||
@@ -165,7 +165,7 @@ func TestShippedLastNameComposition(t *testing.T) {
|
|||||||
|
|
||||||
func TestShippedStreetNumberFormats(t *testing.T) {
|
func TestShippedStreetNumberFormats(t *testing.T) {
|
||||||
// Reachable via a hyphenated path; covers all five weighted number variants.
|
// Reachable via a hyphenated path; covers all five weighted number variants.
|
||||||
f := newFakes(t, "locales/sv_SE", WithSeed(8))
|
f := newFakes(t, "data/sv_SE", WithSeed(8))
|
||||||
re := regexp.MustCompile(`^[1-9]\d{0,2}[A-Z]?$`)
|
re := regexp.MustCompile(`^[1-9]\d{0,2}[A-Z]?$`)
|
||||||
for i := 0; i < 300; i++ {
|
for i := 0; i < 300; i++ {
|
||||||
if n := fake(t, f, "address.street-number"); !re.MatchString(n) {
|
if n := fake(t, f, "address.street-number"); !re.MatchString(n) {
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
// Package fakes generates fake data from recursive, locale-specific JSON
|
// Package fakes generates fake data from recursive JSON templates.
|
||||||
// templates.
|
|
||||||
//
|
//
|
||||||
// Locale data lives in JSON on disk, not in Go. Point [New] at a locale
|
// Data lives in JSON on disk, not in Go. Point [New] at one or more data
|
||||||
// directory, then generate values by category path:
|
// directories; folders and files become a dot-path namespace, then generate
|
||||||
|
// values by path:
|
||||||
//
|
//
|
||||||
// f, _ := fakes.New("./locales/sv_SE", fakes.WithSeed(42))
|
// f, _ := fakes.New([]string{"./data/sv_SE"}, fakes.WithSeed(42))
|
||||||
// f.Fake("address") // "Storgatan 12\n234 56 Göteborg"
|
// f.Fake("address") // "Storgatan 12\n234 56 Göteborg"
|
||||||
// f.Fake("address.locality") // "Göteborg"
|
// f.Fake("address.locality") // "Göteborg"
|
||||||
//
|
//
|
||||||
// The directory's name is the locale and must be a full tag ("sv_SE", never
|
// A subdirectory is a namespace segment, so pointing at "./data" instead reaches
|
||||||
// "sv"). The JSON template format is documented in the README.
|
// a category as "sv_SE.address". Several directories are merged left to right;
|
||||||
|
// the last one wins on a name clash, so you can layer custom data over the
|
||||||
|
// built-ins. The JSON template format is documented in the README.
|
||||||
//
|
//
|
||||||
// A [Fakes] is not safe for concurrent use; create one per goroutine.
|
// A [Fakes] is not safe for concurrent use; create one per goroutine.
|
||||||
package fakes
|
package fakes
|
||||||
@@ -19,14 +21,12 @@ import (
|
|||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"path/filepath"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fakes generates fake data for a single locale. Create one with [New].
|
// Fakes generates fake data from a loaded namespace tree. Create one with [New].
|
||||||
type Fakes struct {
|
type Fakes struct {
|
||||||
rand *rand.Rand
|
rand *rand.Rand
|
||||||
locale string
|
categories map[string]node // root namespace: name -> compiled node tree
|
||||||
categories map[string]node // category name -> compiled node tree
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
@@ -43,29 +43,23 @@ func WithSeed(seed uint64) Option {
|
|||||||
return func(c *config) { c.seed, c.seeded = seed, true }
|
return func(c *config) { c.seed, c.seeded = seed, true }
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds a faker from a locale directory (e.g. "./locales/sv_SE"). The
|
// New builds a faker from one or more data directories (e.g. "./data/sv_SE").
|
||||||
// directory's name is the locale tag and must be a full tag like "sv_SE"; each
|
// Each JSON file becomes a category named after the file (address.json ->
|
||||||
// JSON file in it becomes a category named after the file (address.json ->
|
// "address") and each subdirectory a namespace segment; directories are merged
|
||||||
// "address"). It errors on a non-full tag, a missing directory, or invalid JSON.
|
// in order, the last winning a name clash. It errors on a missing directory,
|
||||||
func New(dir string, opts ...Option) (*Fakes, error) {
|
// invalid JSON, or no data found.
|
||||||
name, ok := canonicalLocale(filepath.Base(dir))
|
func New(paths []string, opts ...Option) (*Fakes, error) {
|
||||||
if !ok {
|
cats, err := loadData(paths)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("fakes: %s: %w", name, err)
|
return nil, fmt.Errorf("fakes: %w", err)
|
||||||
}
|
}
|
||||||
var c config
|
var c config
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(&c)
|
opt(&c)
|
||||||
}
|
}
|
||||||
return &Fakes{rand: newRand(c.seed, c.seeded), locale: name, categories: cats}, nil
|
return &Fakes{rand: newRand(c.seed, c.seeded), 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 {
|
func newRand(seed uint64, seeded bool) *rand.Rand {
|
||||||
if !seeded {
|
if !seeded {
|
||||||
var b [16]byte
|
var b [16]byte
|
||||||
|
|||||||
+13
-15
@@ -5,12 +5,18 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newFakes creates a faker for a locale directory, failing the test on error.
|
// newFakes creates a faker over a single data directory, failing on error. Most
|
||||||
|
// tests load one dir; newFakesN loads several (last-loaded wins on conflicts).
|
||||||
func newFakes(t *testing.T, dir string, opts ...Option) *Fakes {
|
func newFakes(t *testing.T, dir string, opts ...Option) *Fakes {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
f, err := New(dir, opts...)
|
return newFakesN(t, []string{dir}, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakesN(t *testing.T, dirs []string, opts ...Option) *Fakes {
|
||||||
|
t.Helper()
|
||||||
|
f, err := New(dirs, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New(%q): %v", dir, err)
|
t.Fatalf("New(%q): %v", dirs, err)
|
||||||
}
|
}
|
||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
@@ -25,23 +31,15 @@ func fake(t *testing.T, f *Fakes, path string) string {
|
|||||||
return s
|
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) {
|
func TestNewMissingDirectory(t *testing.T) {
|
||||||
_, err := New("locales/de_DE")
|
_, err := New([]string{"data/de_DE"})
|
||||||
if err == nil || !strings.Contains(err.Error(), "de_DE") {
|
if err == nil || !strings.Contains(err.Error(), "de_DE") {
|
||||||
t.Fatalf("New(missing) error = %v, want it to name the locale", err)
|
t.Fatalf("New(missing) error = %v, want it to name the path", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWithSeedIsDeterministic(t *testing.T) {
|
func TestWithSeedIsDeterministic(t *testing.T) {
|
||||||
a, b := newFakes(t, "locales/sv_SE", WithSeed(42)), newFakes(t, "locales/sv_SE", WithSeed(42))
|
a, b := newFakes(t, "data/sv_SE", WithSeed(42)), newFakes(t, "data/sv_SE", WithSeed(42))
|
||||||
for i := 0; i < 50; i++ {
|
for i := 0; i < 50; i++ {
|
||||||
if x, y := fake(t, a, "person"), fake(t, b, "person"); x != y {
|
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)
|
t.Fatalf("same seed diverged at %d: %q != %q", i, x, y)
|
||||||
@@ -50,7 +48,7 @@ func TestWithSeedIsDeterministic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDifferentSeedsDiffer(t *testing.T) {
|
func TestDifferentSeedsDiffer(t *testing.T) {
|
||||||
a, b := newFakes(t, "locales/en_US", WithSeed(1)), newFakes(t, "locales/en_US", WithSeed(2))
|
a, b := newFakes(t, "data/en_US", WithSeed(1)), newFakes(t, "data/en_US", WithSeed(2))
|
||||||
for i := 0; i < 50; i++ {
|
for i := 0; i < 50; i++ {
|
||||||
if fake(t, a, "person") != fake(t, b, "person") {
|
if fake(t, a, "person") != fake(t, b, "person") {
|
||||||
return
|
return
|
||||||
|
|||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
package fakes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeData builds a temp data directory from a map of relative path (without
|
||||||
|
// ".json") -> file content, creating parent folders as needed. The directory's
|
||||||
|
// name carries no meaning anymore, so callers pick any layout they like —
|
||||||
|
// including nested folders, which become dot-path segments.
|
||||||
|
func writeData(t *testing.T, files map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
for name, content := range files {
|
||||||
|
p := filepath.Join(dir, name+".json")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLoadsAnyDirName(t *testing.T) {
|
||||||
|
// No locale tag required: a directory named anything loads fine.
|
||||||
|
dir := writeData(t, map[string]string{"greeting": `["hej", "hallå"]`})
|
||||||
|
f := 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 TestNewEmptyDirErrors(t *testing.T) {
|
||||||
|
if _, err := New([]string{writeData(t, nil)}); err == nil {
|
||||||
|
t.Fatal("New(empty dir) = nil error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFoldersBecomeDotPaths checks the core of the new model: a subfolder is a
|
||||||
|
// namespace, so data/<loc>/person.json is reachable as "<loc>.person".
|
||||||
|
func TestFoldersBecomeDotPaths(t *testing.T) {
|
||||||
|
dir := writeData(t, map[string]string{
|
||||||
|
"sv_SE/greeting": `["hej"]`,
|
||||||
|
"en_US/greeting": `["hi"]`,
|
||||||
|
})
|
||||||
|
f := newFakes(t, dir, WithSeed(1))
|
||||||
|
if got := fake(t, f, "sv_SE.greeting"); got != "hej" {
|
||||||
|
t.Fatalf("sv_SE.greeting = %q, want hej", got)
|
||||||
|
}
|
||||||
|
if got := fake(t, f, "en_US.greeting"); got != "hi" {
|
||||||
|
t.Fatalf("en_US.greeting = %q, want hi", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNestedFoldersAndJSON crosses both nesting kinds in one dot path: folders
|
||||||
|
// a/b/c, then deep JSON fields inside the file at the end of that path.
|
||||||
|
func TestNestedFoldersAndJSON(t *testing.T) {
|
||||||
|
dir := writeData(t, map[string]string{
|
||||||
|
"a/b/c/thing": `{"format":"{x}","x":{"format":"{y}","y":{"format":"{z}","z":["leaf"]}}}`,
|
||||||
|
})
|
||||||
|
f := newFakes(t, dir, WithSeed(1))
|
||||||
|
// Folders a.b.c, file thing, then JSON fields x.y.z — one continuous path.
|
||||||
|
if got := fake(t, f, "a.b.c.thing.x.y.z"); got != "leaf" {
|
||||||
|
t.Fatalf("a.b.c.thing.x.y.z = %q, want leaf", got)
|
||||||
|
}
|
||||||
|
// Rendering the file resolves the same chain top-down.
|
||||||
|
if got := fake(t, f, "a.b.c.thing"); got != "leaf" {
|
||||||
|
t.Fatalf("a.b.c.thing = %q, want leaf", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderingAFolderErrors(t *testing.T) {
|
||||||
|
dir := writeData(t, map[string]string{"sv_SE/greeting": `["hej"]`})
|
||||||
|
f := newFakes(t, dir, WithSeed(1))
|
||||||
|
if _, err := f.Fake("sv_SE"); err == nil {
|
||||||
|
t.Fatal("Fake(folder) = nil error, want a not-a-value error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMultiPathLastWins loads two dirs; on a name clash the later dir wins, and
|
||||||
|
// non-clashing entries from both are reachable (data combines).
|
||||||
|
func TestMultiPathLastWins(t *testing.T) {
|
||||||
|
a := writeData(t, map[string]string{"greeting": `["from-a"]`, "only-a": `["a"]`})
|
||||||
|
b := writeData(t, map[string]string{"greeting": `["from-b"]`, "only-b": `["b"]`})
|
||||||
|
f := newFakesN(t, []string{a, b}, WithSeed(1))
|
||||||
|
if got := fake(t, f, "greeting"); got != "from-b" {
|
||||||
|
t.Fatalf("greeting = %q, want from-b (last loaded wins)", got)
|
||||||
|
}
|
||||||
|
if got := fake(t, f, "only-a"); got != "a" {
|
||||||
|
t.Fatalf("only-a = %q, want a", got)
|
||||||
|
}
|
||||||
|
if got := fake(t, f, "only-b"); got != "b" {
|
||||||
|
t.Fatalf("only-b = %q, want b", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMultiPathMergesFolders checks that clashing folders merge by their
|
||||||
|
// children rather than replacing wholesale: each dir adds a file to sv_SE, and
|
||||||
|
// a per-file clash inside still resolves last-wins.
|
||||||
|
func TestMultiPathMergesFolders(t *testing.T) {
|
||||||
|
a := writeData(t, map[string]string{"sv_SE/person": `["from-a"]`, "sv_SE/shared": `["a"]`})
|
||||||
|
b := writeData(t, map[string]string{"sv_SE/company": `["from-b"]`, "sv_SE/shared": `["b"]`})
|
||||||
|
f := newFakesN(t, []string{a, b}, WithSeed(1))
|
||||||
|
if got := fake(t, f, "sv_SE.person"); got != "from-a" {
|
||||||
|
t.Fatalf("sv_SE.person = %q, want from-a (folder merged, not replaced)", got)
|
||||||
|
}
|
||||||
|
if got := fake(t, f, "sv_SE.company"); got != "from-b" {
|
||||||
|
t.Fatalf("sv_SE.company = %q, want from-b", got)
|
||||||
|
}
|
||||||
|
if got := fake(t, f, "sv_SE.shared"); got != "b" {
|
||||||
|
t.Fatalf("sv_SE.shared = %q, want b (last loaded wins)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
locality := regexp.MustCompile(`^\p{L}+( \p{L}+)*$`)
|
|
||||||
for i := 0; i < 30; i++ {
|
|
||||||
if c := fake(t, f, "address.locality"); !locality.MatchString(c) {
|
|
||||||
t.Fatalf("locality %q is not a Swedish place name", 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+21
-10
@@ -24,6 +24,13 @@ type literal string
|
|||||||
|
|
||||||
func (literal) isNode() {}
|
func (literal) isNode() {}
|
||||||
|
|
||||||
|
// group is a namespace of named children, built from a directory of JSON files
|
||||||
|
// and subdirectories. It has no value of its own: descend into a named child by
|
||||||
|
// dot path; rendering one is an error (see Fake).
|
||||||
|
type group struct{ children map[string]node }
|
||||||
|
|
||||||
|
func (*group) isNode() {}
|
||||||
|
|
||||||
// choice picks one of its items. cum holds cumulative weights for a weighted
|
// 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).
|
// pick; when nil the choice is uniform and selection is O(1).
|
||||||
type choice struct {
|
type choice struct {
|
||||||
@@ -45,20 +52,18 @@ type template struct {
|
|||||||
|
|
||||||
func (*template) isNode() {}
|
func (*template) isNode() {}
|
||||||
|
|
||||||
// Fake generates a value for a category path. The first segment names a
|
// Fake generates a value for a dot path. Each segment descends one level: folder
|
||||||
// category (a JSON file); further dot-separated segments descend into named
|
// names and the category (JSON file) come first, then named fields within it,
|
||||||
// fields, e.g. "address" or "address.street". Choices along the way are
|
// e.g. "sv_SE.address" or "sv_SE.address.street". Choices along the way are
|
||||||
// resolved at random.
|
// resolved at random. A path naming a folder (no value of its own) is an error.
|
||||||
func (f *Fakes) Fake(path string) (string, error) {
|
func (f *Fakes) Fake(path string) (string, error) {
|
||||||
segments := strings.Split(path, ".")
|
n, err := descend(f.rand, &group{children: f.categories}, strings.Split(path, "."))
|
||||||
n, ok := f.categories[segments[0]]
|
|
||||||
if !ok {
|
|
||||||
return "", fmt.Errorf("fakes: unknown category %q", segments[0])
|
|
||||||
}
|
|
||||||
n, err := descend(f.rand, n, segments[1:])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fakes: %s: %w", path, err)
|
return "", fmt.Errorf("fakes: %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
if _, ok := n.(*group); ok {
|
||||||
|
return "", fmt.Errorf("fakes: %s names a folder, not a value", path)
|
||||||
|
}
|
||||||
return render(f.rand, n), nil
|
return render(f.rand, n), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +75,12 @@ func descend(r rng, n node, segments []string) (node, error) {
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
switch n := n.(type) {
|
switch n := n.(type) {
|
||||||
|
case *group:
|
||||||
|
child, ok := n.children[segments[0]]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("no entry %q", segments[0])
|
||||||
|
}
|
||||||
|
return descend(r, child, segments[1:])
|
||||||
case *template:
|
case *template:
|
||||||
child, ok := n.fields[segments[0]]
|
child, ok := n.fields[segments[0]]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
Reference in New Issue
Block a user