Add repeat/separator templates; validate at compile time; support Go 1.22+ via GO_VERSION
This commit is contained in:
+5
-3
@@ -1,7 +1,9 @@
|
||||
# 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
|
||||
# so it doubles as CI. Day-to-day, prefer `docker compose run` (bind-mounts
|
||||
# source, no rebuilds). GO_VERSION defaults to the latest stable Go; override it
|
||||
# to test the lowest supported version: docker build --build-arg GO_VERSION=1.22 .
|
||||
ARG GO_VERSION=1.26.4
|
||||
FROM golang:${GO_VERSION}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ format control we needed.
|
||||
go get github.com/Timewave-AB/fakes
|
||||
```
|
||||
|
||||
Requires Go 1.26+.
|
||||
Requires Go 1.22+ (for `math/rand/v2`).
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -119,6 +119,22 @@ within a choice:
|
||||
]
|
||||
```
|
||||
|
||||
Only template (object) nodes carry `weight` — a bare string or nested array in a
|
||||
choice always counts as `1`. Weights are checked when you create the faker: a
|
||||
negative, non-numeric, or all-zero set is rejected at `New`, so a typo fails
|
||||
fast instead of silently skewing output.
|
||||
|
||||
**Repeat.** A template node may carry a `repeat` (default `1`) to render its
|
||||
`format` that many times — each render an independent pick — joined by
|
||||
`separator` (default `""`):
|
||||
|
||||
```json
|
||||
{ "format": "{word}", "repeat": 3, "separator": " ", "word": ["foo", "bar", "baz"] }
|
||||
```
|
||||
|
||||
This yields e.g. `bar foo baz`. `repeat` must be a positive integer and
|
||||
`separator` a string, both checked at `New`.
|
||||
|
||||
**Format string.** Every character is literal except:
|
||||
|
||||
| Token | Expands to |
|
||||
@@ -193,6 +209,14 @@ 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.
|
||||
|
||||
Tests run against the latest Go by default. Set `GO_VERSION` to check the lowest
|
||||
supported version too:
|
||||
|
||||
```sh
|
||||
GO_VERSION=1.22 docker compose run --rm test # lowest supported
|
||||
docker compose run --rm test # latest
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
|
||||
+4
-1
@@ -8,9 +8,12 @@
|
||||
# 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)"`.
|
||||
#
|
||||
# GO_VERSION picks the toolchain image (default: latest stable). Test the lowest
|
||||
# supported Go with: GO_VERSION=1.22 docker compose run --rm test
|
||||
|
||||
x-go: &go
|
||||
image: golang:1.26.4
|
||||
image: golang:${GO_VERSION:-1.26.4}
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
|
||||
+3
-23
@@ -22,7 +22,7 @@ func TestEscapeEdgeCases(t *testing.T) {
|
||||
`{"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 {
|
||||
if got := mustRender(t, f, tmpl); got != want {
|
||||
t.Errorf("render(%s) = %q, want %q", tmpl, got, want)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ func TestEscapeEdgeCases(t *testing.T) {
|
||||
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"]}`)
|
||||
got := mustRender(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)
|
||||
}
|
||||
@@ -41,19 +41,13 @@ 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
|
||||
seen[mustRender(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")
|
||||
@@ -69,20 +63,6 @@ func TestNewErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -74,7 +74,3 @@ func newRand(seed uint64, seeded bool) *rand.Rand {
|
||||
}
|
||||
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) }
|
||||
|
||||
+149
-73
@@ -2,6 +2,7 @@ package fakes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
@@ -11,6 +12,13 @@ import (
|
||||
// JSON or re-sums weights.
|
||||
type node interface{ isNode() }
|
||||
|
||||
// rng is the randomness the renderer draws from. Passing it in keeps the render
|
||||
// functions a pure core over an explicit effect; *rand.Rand satisfies it.
|
||||
type rng interface {
|
||||
IntN(n int) int
|
||||
Float64() float64
|
||||
}
|
||||
|
||||
// literal is emitted verbatim, never formatted.
|
||||
type literal string
|
||||
|
||||
@@ -25,10 +33,14 @@ type choice struct {
|
||||
|
||||
func (*choice) isNode() {}
|
||||
|
||||
// template renders a format string, substituting {tokens} from fields.
|
||||
// template renders a format string, substituting {tokens} from fields. repeat
|
||||
// (default 1) renders that format that many times and joins the results with
|
||||
// separator (default ""), each render an independent pick.
|
||||
type template struct {
|
||||
format string
|
||||
fields map[string]node
|
||||
format string
|
||||
fields map[string]node
|
||||
repeat int
|
||||
separator string
|
||||
}
|
||||
|
||||
func (*template) isNode() {}
|
||||
@@ -43,19 +55,17 @@ func (f *Fakes) Fake(path string) (string, error) {
|
||||
if !ok {
|
||||
return "", fmt.Errorf("fakes: unknown category %q", segments[0])
|
||||
}
|
||||
n, err := f.descend(n, segments[1:])
|
||||
n, err := descend(f.rand, 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
|
||||
return render(f.rand, n), nil
|
||||
}
|
||||
|
||||
// descend walks named fields, resolving choices it meets along the way.
|
||||
func (f *Fakes) descend(n node, segments []string) (node, error) {
|
||||
// descend walks named fields, resolving choices it meets along the way. It is
|
||||
// the one render-side step that can fail, because the path comes from the
|
||||
// caller and may name a field that does not exist.
|
||||
func descend(r rng, n node, segments []string) (node, error) {
|
||||
if len(segments) == 0 {
|
||||
return n, nil
|
||||
}
|
||||
@@ -65,58 +75,57 @@ func (f *Fakes) descend(n node, segments []string) (node, error) {
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no field %q", segments[0])
|
||||
}
|
||||
return f.descend(child, segments[1:])
|
||||
return descend(r, 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
|
||||
return descend(r, pick(r, n), 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) {
|
||||
// render evaluates a compiled node to a string. compile validates every node up
|
||||
// front, so rendering a compiled tree cannot fail.
|
||||
func render(r rng, n node) string {
|
||||
switch n := n.(type) {
|
||||
case literal:
|
||||
return string(n), nil
|
||||
return string(n)
|
||||
case *choice:
|
||||
chosen, err := f.pick(n)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return f.render(chosen)
|
||||
return render(r, pick(r, n))
|
||||
case *template:
|
||||
return f.expand(n.format, n.fields)
|
||||
if n.repeat == 1 {
|
||||
return expand(r, n.format, n.fields)
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < n.repeat; i++ {
|
||||
if i > 0 {
|
||||
b.WriteString(n.separator)
|
||||
}
|
||||
b.WriteString(expand(r, n.format, n.fields))
|
||||
}
|
||||
return b.String()
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported node %T", n)
|
||||
panic(fmt.Sprintf("fakes: uncompiled 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")
|
||||
}
|
||||
// O(log n) search over precomputed cumulative weights. compile guarantees a
|
||||
// non-empty choice and a finite positive total, so the index is always in range.
|
||||
func pick(r rng, c *choice) node {
|
||||
if c.cum == nil {
|
||||
return c.items[f.intn(len(c.items))], nil
|
||||
return c.items[r.IntN(len(c.items))]
|
||||
}
|
||||
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
|
||||
x := r.Float64() * c.cum[len(c.cum)-1]
|
||||
i := sort.Search(len(c.cum), func(i int) bool { return c.cum[i] > x })
|
||||
return c.items[i]
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// every other rune is literal. checkTokens validated the braces and tokens at
|
||||
// compile time, so this scan cannot fail.
|
||||
func expand(r rng, format string, fields map[string]node) string {
|
||||
var b strings.Builder
|
||||
rs := []rune(format)
|
||||
for i := 0; i < len(rs); i++ {
|
||||
@@ -128,44 +137,32 @@ func (f *Fakes) expand(format string, fields map[string]node) (string, error) {
|
||||
b.WriteRune('#')
|
||||
}
|
||||
case '0':
|
||||
b.WriteByte(byte('0' + f.intn(10)))
|
||||
b.WriteByte(byte('0' + r.IntN(10)))
|
||||
case '1':
|
||||
b.WriteByte(byte('1' + f.intn(9)))
|
||||
b.WriteByte(byte('1' + r.IntN(9)))
|
||||
case 'A':
|
||||
b.WriteByte(byte('A' + f.intn(26)))
|
||||
b.WriteByte(byte('A' + r.IntN(26)))
|
||||
case 'a':
|
||||
b.WriteByte(byte('a' + f.intn(26)))
|
||||
b.WriteByte(byte('a' + r.IntN(26)))
|
||||
case '{':
|
||||
end := i + 1
|
||||
for end < len(rs) && rs[end] != '}' {
|
||||
for rs[end] != '}' { // checkTokens guarantees a closing '}'
|
||||
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)
|
||||
b.WriteString(resolve(r, string(rs[i+1:end]), fields))
|
||||
i = end
|
||||
default:
|
||||
b.WriteRune(c)
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// resolve renders a "{token}" body: one or more field names separated by '|',
|
||||
// of which one is chosen at random. checkTokens guarantees every name exists.
|
||||
func resolve(r rng, token string, fields map[string]node) string {
|
||||
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)
|
||||
return render(r, fields[names[r.IntN(len(names))]])
|
||||
}
|
||||
|
||||
// compile converts parsed JSON into a node tree, validating structure up front.
|
||||
@@ -183,12 +180,18 @@ func compile(v any) (node, error) {
|
||||
}
|
||||
|
||||
func compileChoice(items []any) (node, error) {
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("empty choice")
|
||||
}
|
||||
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)
|
||||
w, err := weightOf(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w != 1 {
|
||||
weighted = true
|
||||
}
|
||||
@@ -201,6 +204,9 @@ func compileChoice(items []any) (node, error) {
|
||||
c.items[i] = n
|
||||
}
|
||||
if weighted { // uniform choices skip the weight table and pick in O(1)
|
||||
if total <= 0 || math.IsInf(total, 1) {
|
||||
return nil, fmt.Errorf("choice weights must sum to a finite positive number, got %v", total)
|
||||
}
|
||||
c.cum = cum
|
||||
}
|
||||
return c, nil
|
||||
@@ -211,9 +217,19 @@ func compileTemplate(m map[string]any) (node, error) {
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("template object missing string \"format\"")
|
||||
}
|
||||
t := &template{format: format, fields: make(map[string]node, len(m))}
|
||||
repeat, err := repeatOf(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sep := ""
|
||||
if sv, ok := m["separator"]; ok {
|
||||
if sep, ok = sv.(string); !ok {
|
||||
return nil, fmt.Errorf("separator must be a string, got %T", sv)
|
||||
}
|
||||
}
|
||||
t := &template{format: format, fields: make(map[string]node, len(m)), repeat: repeat, separator: sep}
|
||||
for k, v := range m {
|
||||
if k == "format" || k == "weight" {
|
||||
if k == "format" || k == "weight" || k == "repeat" || k == "separator" {
|
||||
continue
|
||||
}
|
||||
n, err := compile(v)
|
||||
@@ -222,15 +238,75 @@ func compileTemplate(m map[string]any) (node, error) {
|
||||
}
|
||||
t.fields[k] = n
|
||||
}
|
||||
if err := checkTokens(format, t.fields); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
// checkTokens validates a format string the way expand scans it, so every
|
||||
// "{token}" is balanced and names an existing field. This makes a typo'd or
|
||||
// dangling reference a New-time error, never a random render-time one.
|
||||
func checkTokens(format string, fields map[string]node) error {
|
||||
rs := []rune(format)
|
||||
for i := 0; i < len(rs); i++ {
|
||||
switch rs[i] {
|
||||
case '#':
|
||||
i++ // an escaped char is literal, never a token delimiter
|
||||
case '{':
|
||||
end := i + 1
|
||||
for end < len(rs) && rs[end] != '}' {
|
||||
end++
|
||||
}
|
||||
if end >= len(rs) {
|
||||
return fmt.Errorf("unterminated '{' in %q", format)
|
||||
}
|
||||
body := string(rs[i+1 : end])
|
||||
for _, name := range strings.Split(body, "|") {
|
||||
if _, ok := fields[name]; !ok {
|
||||
return fmt.Errorf("token {%s}: no field %q", body, name)
|
||||
}
|
||||
}
|
||||
i = end
|
||||
}
|
||||
}
|
||||
return 1
|
||||
return nil
|
||||
}
|
||||
|
||||
// repeatOf reads a template's "repeat" (default 1): how many times its format
|
||||
// is rendered and concatenated. A present one must be a positive integer.
|
||||
func repeatOf(m map[string]any) (int, error) {
|
||||
rv, ok := m["repeat"]
|
||||
if !ok {
|
||||
return 1, nil
|
||||
}
|
||||
r, ok := rv.(float64)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("repeat must be a number, got %T", rv)
|
||||
}
|
||||
if math.IsNaN(r) || math.IsInf(r, 0) || r < 1 || r != math.Trunc(r) {
|
||||
return 0, fmt.Errorf("repeat must be a positive integer, got %v", rv)
|
||||
}
|
||||
return int(r), nil
|
||||
}
|
||||
|
||||
// weightOf reads a node's "weight" (default 1) from its raw JSON form. Only
|
||||
// template objects carry weight; a present one must be finite and non-negative.
|
||||
func weightOf(raw any) (float64, error) {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return 1, nil
|
||||
}
|
||||
wv, ok := m["weight"]
|
||||
if !ok {
|
||||
return 1, nil
|
||||
}
|
||||
w, ok := wv.(float64)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("weight must be a number, got %T", wv)
|
||||
}
|
||||
if w < 0 || math.IsNaN(w) || math.IsInf(w, 0) {
|
||||
return 0, fmt.Errorf("weight must be finite and non-negative, got %v", w)
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
+54
-33
@@ -30,18 +30,14 @@ func compiled(t *testing.T, s string) node {
|
||||
return n
|
||||
}
|
||||
|
||||
func render(t *testing.T, f *Fakes, s string) string {
|
||||
func mustRender(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
|
||||
return render(f.rand, compiled(t, s))
|
||||
}
|
||||
|
||||
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ö" {
|
||||
if got := mustRender(t, engine(1), `"Malmö"`); got != "Malmö" {
|
||||
t.Fatalf("literal = %q, want Malmö", got)
|
||||
}
|
||||
}
|
||||
@@ -56,7 +52,7 @@ func TestCharacterClasses(t *testing.T) {
|
||||
f := engine(7)
|
||||
for tmpl, re := range cases {
|
||||
for i := 0; i < 100; i++ {
|
||||
if got := render(t, f, tmpl); !re.MatchString(got) {
|
||||
if got := mustRender(t, f, tmpl); !re.MatchString(got) {
|
||||
t.Fatalf("%s produced %q, want %s", tmpl, got, re)
|
||||
}
|
||||
}
|
||||
@@ -65,13 +61,13 @@ func TestCharacterClasses(t *testing.T) {
|
||||
|
||||
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" {
|
||||
if got := mustRender(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" {
|
||||
if got := mustRender(t, engine(1), `{"format":"{x}sson","x":["Erik"]}`); got != "Eriksson" {
|
||||
t.Fatalf("token = %q, want Eriksson", got)
|
||||
}
|
||||
}
|
||||
@@ -80,7 +76,7 @@ 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
|
||||
seen[mustRender(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)
|
||||
@@ -90,7 +86,7 @@ func TestAlternationPicksOneField(t *testing.T) {
|
||||
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" {
|
||||
if got := mustRender(t, f, `[{"format":"X","weight":0},"Y"]`); got != "Y" {
|
||||
t.Fatalf("weight-0 variant chosen: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -100,7 +96,7 @@ 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" {
|
||||
if mustRender(t, f, `[{"format":"H","weight":10},{"format":"L"}]`) == "H" {
|
||||
heavy++
|
||||
}
|
||||
}
|
||||
@@ -109,24 +105,63 @@ func TestWeightSkewsDistribution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatRendersFormatNTimes(t *testing.T) {
|
||||
// repeat N concatenates N independent renders of the format ('x','y' are
|
||||
// literal, not character classes).
|
||||
if got := mustRender(t, engine(1), `{"format":"xy","repeat":3}`); got != "xyxyxy" {
|
||||
t.Fatalf("repeat literal = %q, want xyxyxy", got)
|
||||
}
|
||||
// separator joins renders (N-1 of them, no trailing one).
|
||||
if got := mustRender(t, engine(1), `{"format":"xy","repeat":3,"separator":"-"}`); got != "xy-xy-xy" {
|
||||
t.Fatalf("repeat with separator = %q, want xy-xy-xy", got)
|
||||
}
|
||||
f, re := engine(7), regexp.MustCompile(`^[0-9]{4}$`)
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 50; i++ {
|
||||
got := mustRender(t, f, `{"format":"0","repeat":4}`)
|
||||
if !re.MatchString(got) {
|
||||
t.Fatalf("repeat-4 = %q, want 4 digits", got)
|
||||
}
|
||||
seen[got] = true
|
||||
}
|
||||
if len(seen) < 2 {
|
||||
t.Fatalf("repeat never varied across renders: %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
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" {
|
||||
if got := mustRender(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.
|
||||
// Every structural problem is caught up front, at compile/New time, never
|
||||
// deferred to a random render that happens to hit the bad branch.
|
||||
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
|
||||
`{"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
|
||||
`[]`, // empty choice
|
||||
`{"format":"{x"}`, // unterminated brace
|
||||
`{"format":"{y}","x":["Q"]}`, // token names a missing field
|
||||
`{"format":"{}"}`, // empty token name
|
||||
`{"format":"{a|}","a":["Q"]}`, // empty alternation segment
|
||||
`[{"format":"A","weight":-1},{"format":"B"}]`, // negative weight
|
||||
`[{"format":"A","weight":0},{"format":"B","weight":0}]`, // weights sum to zero
|
||||
`[{"format":"A","weight":1e308},{"format":"B","weight":1e308}]`, // weights overflow to +Inf
|
||||
`[{"format":"A","weight":"heavy"}]`, // non-numeric weight
|
||||
`{"format":"x","repeat":0}`, // repeat below 1
|
||||
`{"format":"x","repeat":-2}`, // negative repeat
|
||||
`{"format":"x","repeat":1.5}`, // non-integer repeat
|
||||
`{"format":"x","repeat":"two"}`, // non-numeric repeat
|
||||
`{"format":"x","repeat":2,"separator":5}`, // non-string separator
|
||||
} {
|
||||
if _, err := compile(parse(t, bad)); err == nil {
|
||||
t.Errorf("compile(%s) = nil error, want error", bad)
|
||||
@@ -134,20 +169,6 @@ func TestCompileErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
|
||||
Reference in New Issue
Block a user