From 99d8aa0fb72aa296bea5e1bfabdf6dbe1df33979 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 2 Sep 2026 12:26:05 +0200 Subject: [PATCH] Tests for literal format text, brace escapes and the class builtins --- bench_test.go | 10 +-- bound_test.go | 4 +- builtins_test.go | 19 ++++- edge_test.go | 25 +++--- template_stability_test.go | 6 +- template_test.go | 153 ++++++++++++++++++++++++------------- 6 files changed, 137 insertions(+), 80 deletions(-) diff --git a/bench_test.go b/bench_test.go index 6022f31..0b850ed 100644 --- a/bench_test.go +++ b/bench_test.go @@ -48,7 +48,7 @@ func BenchmarkCalc(b *testing.B) { } func BenchmarkLongLiteral(b *testing.B) { - dir := tmpData(b, "sql", `{"format":"INSERT INTO customers (id, name, city) V#ALUES (#1#2#3, '{word}', '{word}');","word":["alpha","beta","gamma","delta"]}`) + dir := tmpData(b, "sql", `{"format":"INSERT INTO customers (id, name, city) VALUES (123, '{word}', '{word}');","word":["alpha","beta","gamma","delta"]}`) benchPath(b, dir, "sql") } @@ -62,14 +62,14 @@ func BenchmarkRepeat(b *testing.B) { // two independent fields. func BenchmarkBound(b *testing.B) { dir := tmpData(b, "addr", `{"format":"{place.postal-code} {place.locality}","place":[ - {"format":"{locality}","locality":"Stockholm","postal-code":{"format":"#100 00"}}, - {"format":"{locality}","locality":"Tranås","postal-code":{"format":"#5#7#3 00"}}]}`) + {"format":"{locality}","locality":"Stockholm","postal-code":{"format":"1{digits(2)} {digits(2)}"}}, + {"format":"{locality}","locality":"Tranås","postal-code":{"format":"573 {digits(2)}"}}]}`) benchPath(b, dir, "addr") } func BenchmarkUnbound(b *testing.B) { dir := tmpData(b, "addr", `{"format":"{postal-code} {locality}", - "postal-code":[{"format":"#100 00"},{"format":"#5#7#3 00"}], + "postal-code":[{"format":"1{digits(2)} {digits(2)}"},{"format":"573 {digits(2)}"}], "locality":["Stockholm","Tranås"]}`) benchPath(b, dir, "addr") } @@ -78,7 +78,7 @@ func BenchmarkUnbound(b *testing.B) { // depth question is about. func BenchmarkBoundDeep(b *testing.B) { dir := tmpData(b, "addr", `{"format":"{p.addr.city} {p.addr.zip}","p":[ - {"format":"{addr}","addr":{"format":"{city}","city":["Stockholm","Tranås"],"zip":{"format":"#100 00"}}}]}`) + {"format":"{addr}","addr":{"format":"{city}","city":["Stockholm","Tranås"],"zip":{"format":"1{digits(2)} {digits(2)}"}}}]}`) benchPath(b, dir, "addr") } diff --git a/bound_test.go b/bound_test.go index 36ea30c..a209949 100644 --- a/bound_test.go +++ b/bound_test.go @@ -16,8 +16,8 @@ import ( // swedishPlaces is a two-variant sibling whose variants pair a locality with the // postal-code prefix that really belongs to it. const swedishPlaces = `{"format":"%s","place":[ - {"format":"{locality}","locality":"Stockholm","postal-code":{"format":"#100 00"}}, - {"format":"{locality}","locality":"Tranås","postal-code":{"format":"#5#7#3 00"}} + {"format":"{locality}","locality":"Stockholm","postal-code":{"format":"1{digits(2)} {digits(2)}"}}, + {"format":"{locality}","locality":"Tranås","postal-code":{"format":"573 {digits(2)}"}} ]}` // agree reports whether a rendered "postcode locality" pair is a real pairing. diff --git a/builtins_test.go b/builtins_test.go index fcac338..13af539 100644 --- a/builtins_test.go +++ b/builtins_test.go @@ -83,9 +83,9 @@ func TestBuiltinBase64(t *testing.T) { // hand-computed check, like the luhn test. mod-11 emits X when it would be 10. func TestBuiltinChecksums(t *testing.T) { cases := map[string]string{ - `{"format":"#1#2#3#4#5#6#7#8{mod11()}"}`: "123456785", // weights 2..7 from the right - `{"format":"#6{mod11()}"}`: "6X", // remainder 10 -> X - `{"format":"#4#0#0#6#3#8#1#3#3#3#9#3{ean()}"}`: "4006381333931", // EAN-13 (= ISBN-13) check digit + `{"format":"12345678{mod11()}"}`: "123456785", // weights 2..7 from the right + `{"format":"6{mod11()}"}`: "6X", // remainder 10 -> X + `{"format":"400638133393{ean()}"}`: "4006381333931", // EAN-13 (= ISBN-13) check digit } f := engine(1) for tmpl, want := range cases { @@ -217,3 +217,16 @@ func mustPanic(t *testing.T, name string, call func()) { }() call() } + +func TestClassBuiltinArgs(t *testing.T) { + for _, bad := range []string{`"{digits(0)}"`, `"{digits(2000000000)}"`, `"{upper(-1)}"`, `"{lower(x)}"`, `"{digits()}"`, `"{upper(1,2)}"`} { + if _, err := compile(parse(t, bad)); err == nil { + t.Errorf("compile(%s) = nil error, want the arg rejected", bad) + } + } + for _, ok := range []string{`"{digits(1048576)}"`, `"{upper(1)}"`, `"{lower(26)}"`} { + if _, err := compile(parse(t, ok)); err != nil { + t.Errorf("compile(%s) = %v", ok, err) + } + } +} diff --git a/edge_test.go b/edge_test.go index 223e31b..4103385 100644 --- a/edge_test.go +++ b/edge_test.go @@ -13,15 +13,14 @@ import ( // --- format string edge cases --- -func TestEscapeEdgeCases(t *testing.T) { +func TestHashIsLiteral(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) + `{"format":"","x":["v"]}`: "", + `{"format":"#","x":["v"]}`: "#", + `{"format":"##","x":["v"]}`: "##", + `{"format":"#0#1#A#a","x":["v"]}`: "#0#1#A#a", + `{"format":"#{x}","x":["v"]}`: "#v", } for tmpl, want := range cases { if got := mustRender(t, f, tmpl); got != want { @@ -33,7 +32,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 := mustRender(t, engine(2), `{"format":"Öster{x}-0å","x":["väg"]}`) + got := mustRender(t, engine(2), `{"format":"Öster{x}-{digits(1)}å","x":["väg"]}`) if !regexp.MustCompile(`^Österväg-[0-9]å$`).MatchString(got) { t.Fatalf("multibyte format = %q", got) } @@ -224,11 +223,11 @@ func TestDeepDottedPath(t *testing.T) { } } -func TestDescendIntoLiteralErrors(t *testing.T) { +func TestDescendIntoStringErrors(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") + if _, err := f.Fake("greeting.extra"); err == nil || !strings.Contains(err.Error(), `no field "extra"`) { + t.Fatalf("Fake(greeting.extra) = %v, want a no-field error", err) } } @@ -303,8 +302,8 @@ func TestMissingFieldNamesItself(t *testing.T) { func TestCategoryRootShapes(t *testing.T) { dir := writeData(t, map[string]string{ - "obj": `{"format":"00"}`, // object root - "lit": `"hello"`, // bare-string root + "obj": `{"format":"{digits(2)}"}`, // object root + "lit": `"hello"`, // bare-string root }) f := newGenerator(t, dir, WithSeed(1)) if got := fake(t, f, "obj"); !regexp.MustCompile(`^\d\d$`).MatchString(got) { diff --git a/template_stability_test.go b/template_stability_test.go index cf16d86..67dc7f1 100644 --- a/template_stability_test.go +++ b/template_stability_test.go @@ -9,10 +9,10 @@ func TestSeededOutputIsStable(t *testing.T) { dir := writeData(t, map[string]string{ "alt": `{"format":"{a|b}","a":["A"],"b":["B"]}`, "calc": `{"format":"{net} x {qty} = {calc(net * qty, 2)}","net":["19.99","5.00","100.00"],"qty":["2","3","7"]}`, - "classes": `{"format":"00-11-AA-aa"}`, - "escapes": `{"format":"#0#1#A#a##{x}","x":["!"]}`, + "classes": `{"format":"{digits(2)}-{int(1,9)}{int(1,9)}-{upper(2)}-{lower(2)}"}`, + "escapes": `{"format":"01Aa#{x}","x":["!"]}`, "funcs": `{"format":"{hex(6)} {int(10,99)} {float(0,1,3)} {nanoid(5)} {seq()}"}`, - "nested": `{"format":"{outer}","outer":[{"format":"{inner}-00","inner":["i"]}]}`, + "nested": `{"format":"{outer}","outer":[{"format":"{inner}-{digits(2)}","inner":["i"]}]}`, "ref": `{"format":"see {..alt}"}`, "repeat": `{"format":"{w}","repeat":4,"separator":",","w":["x","y","z"]}`, "sums": `{"format":"9{d}{luhn()} {e}{ean()} {m}{mod11()}","d":["012345678901234"],"e":["123456789012"],"m":["12345678"]}`, diff --git a/template_test.go b/template_test.go index ee56174..33887a8 100644 --- a/template_test.go +++ b/template_test.go @@ -35,19 +35,33 @@ func mustRender(t *testing.T, f *Generator, s string) string { 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 := mustRender(t, engine(1), `"Malmö"`); got != "Malmö" { - t.Fatalf("literal = %q, want Malmö", got) +func TestStringIsAFormat(t *testing.T) { + f := engine(1) + for src, want := range map[string]string{ + `"Malmö"`: "Malmö", + `"100 Main St, Apt 1A #0"`: "100 Main St, Apt 1A #0", + `"{{x}}"`: "{x}", + } { + if got := mustRender(t, f, src); got != want { + t.Errorf("render(%s) = %q, want %q", src, got, want) + } + } + if got := mustRender(t, f, `"{digits(3)}"`); !regexp.MustCompile(`^[0-9]{3}$`).MatchString(got) { + t.Errorf(`render("{digits(3)}") = %q, want three digits`, got) + } + if _, err := compile(parse(t, `"{x}"`)); err == nil || !strings.Contains(err.Error(), `no field "x"`) { + t.Errorf(`compile("{x}") = %v, want a no-field error`, err) } } -func TestCharacterClasses(t *testing.T) { +func TestClassBuiltins(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]$`), + `"{digits(1)}"`: regexp.MustCompile(`^[0-9]$`), + `"{digits(3)}"`: regexp.MustCompile(`^[0-9]{3}$`), + `"{int(1,9)}"`: regexp.MustCompile(`^[1-9]$`), + `"{upper(1)}"`: regexp.MustCompile(`^[A-Z]$`), + `"{lower(1)}"`: regexp.MustCompile(`^[a-z]$`), + `"{upper(2)}{lower(2)}"`: regexp.MustCompile(`^[A-Z]{2}[a-z]{2}$`), } f := engine(7) for tmpl, re := range cases { @@ -59,10 +73,34 @@ func TestCharacterClasses(t *testing.T) { } } -func TestEscapeAndLiteralChars(t *testing.T) { - // '#' escapes the next char; non-class chars (7, x, -) are literal. - 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 TestTextIsLiteral(t *testing.T) { + if got := mustRender(t, engine(1), `{"format":"100 Main St #1 {x}","x":["A"]}`); got != "100 Main St #1 A" { + t.Fatalf("text = %q, want it verbatim", got) + } +} + +func TestBraceEscapes(t *testing.T) { + f := engine(1) + for src, want := range map[string]string{ + `{"format":"{{","x":["v"]}`: "{", + `{"format":"}}","x":["v"]}`: "}", + `{"format":"{{x}}","x":["v"]}`: "{x}", + `{"format":"{{{x}}}","x":["v"]}`: "{v}", + `{"format":"a{{{{b}}}}","x":["v"]}`: "a{{b}}", + } { + if got := mustRender(t, f, src); got != want { + t.Errorf("render(%s) = %q, want %q", src, got, want) + } + } + for src, want := range map[string]string{ + `{"format":"x}y"}`: "}}", + `{"format":"}"}`: "}}", + `{"format":"{a{b}","a":["Q"]}`: "'{'", + `{"format":"{x"}`: "unterminated", + } { + if _, err := compile(parse(t, src)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("compile(%s) = %v, want an error mentioning %s", src, err, want) + } } } @@ -118,7 +156,7 @@ func TestRepeatRendersFormatNTimes(t *testing.T) { 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}`) + got := mustRender(t, f, `{"format":"{digits(1)}","repeat":4}`) if !re.MatchString(got) { t.Fatalf("repeat-4 = %q, want 4 digits", got) } @@ -135,9 +173,9 @@ func TestFunctionTokenLuhn(t *testing.T) { // buffer, so a value is never re-rendered. Bodies are escaped to fix input. f := engine(1) cases := map[string]string{ - `{"format":"#8#1#1#2#1#8#9#8#7{luhn()}"}`: "8112189876", // personnummer body - `{"format":"#7#9#9#2#7#3#9#8#7#1{luhn()}"}`: "79927398713", // classic Luhn vector - `{"format":"#8#1#1#2#1#8-#9#8#7{luhn()}"}`: "811218-9876", // '-' skipped, kept + `{"format":"811218987{luhn()}"}`: "8112189876", // personnummer body + `{"format":"7992739871{luhn()}"}`: "79927398713", // classic Luhn vector + `{"format":"811218-987{luhn()}"}`: "811218-9876", // '-' skipped, kept `{"format":"{n}{luhn()}","n":["811218987"]}`: "8112189876", // over a rendered token } for tmpl, want := range cases { @@ -162,45 +200,51 @@ func TestCompileErrors(t *testing.T) { // 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 - `[]`, // 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 + `{"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":"x}y"}`, // a lone } must be written }} + `{"format":"{a{b}","a":["Q"]}`, // a brace inside a token + `"{x}"`, // a bare string has no fields to name + `{"format":"{digits(0)}"}`, // count must be positive + `{"format":"{upper(x)}"}`, // count must be an integer + `{"format":"{lower()}"}`, // wrong arity + `{"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 - `{"format":"{nope()}"}`, // unknown function - `{"format":"{luhn(x)}"}`, // function given args it takes none of - `{"format":"{luhn(}"}`, // malformed function token - `{"format":"{int(1)}"}`, // wrong arity - `{"format":"{int(a,b)}"}`, // non-integer args - `{"format":"{int(5,1)}"}`, // min > max - `{"format":"{hex(0)}"}`, // count must be positive - `{"format":"{nanoid(-1)}"}`, // negative count - `{"format":"{base64(0)}"}`, // count must be positive - `{"format":"{float(1,2)}"}`, // wrong arity - `{"format":"{float(1,2,-1)}"}`, // negative decimals - `{"format":"{iban(US)}"}`, // unsupported country - `{"format":"{seq(a,b)}"}`, // seq takes at most one name - `{"format":"{calc()}"}`, // calc needs an expression - `{"format":"{calc(1 +)}"}`, // dangling operator - `{"format":"{calc((1 + 2)}"}`, // unbalanced parenthesis - `{"format":"{calc(1 2)}"}`, // two operands, no operator - `{"format":"{calc(price)}"}`, // operand names no field - `{"format":"{calc(1, 2, 3)}"}`, // too many args - `{"format":"{calc(1, x)}"}`, // decimals arg not an integer - `{"format":"{calc(1, -1)}"}`, // decimals negative + `{"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 + `{"format":"{nope()}"}`, // unknown function + `{"format":"{luhn(x)}"}`, // function given args it takes none of + `{"format":"{luhn(}"}`, // malformed function token + `{"format":"{int(1)}"}`, // wrong arity + `{"format":"{int(a,b)}"}`, // non-integer args + `{"format":"{int(5,1)}"}`, // min > max + `{"format":"{hex(0)}"}`, // count must be positive + `{"format":"{nanoid(-1)}"}`, // negative count + `{"format":"{base64(0)}"}`, // count must be positive + `{"format":"{float(1,2)}"}`, // wrong arity + `{"format":"{float(1,2,-1)}"}`, // negative decimals + `{"format":"{iban(US)}"}`, // unsupported country + `{"format":"{seq(a,b)}"}`, // seq takes at most one name + `{"format":"{calc()}"}`, // calc needs an expression + `{"format":"{calc(1 +)}"}`, // dangling operator + `{"format":"{calc((1 + 2)}"}`, // unbalanced parenthesis + `{"format":"{calc(1 2)}"}`, // two operands, no operator + `{"format":"{calc(price)}"}`, // operand names no field + `{"format":"{calc(1, 2, 3)}"}`, // too many args + `{"format":"{calc(1, x)}"}`, // decimals arg not an integer + `{"format":"{calc(1, -1)}"}`, // decimals negative } { if _, err := compile(parse(t, bad)); err == nil { t.Errorf("compile(%s) = nil error, want error", bad) @@ -235,8 +279,9 @@ func TestGrowIsALowerBound(t *testing.T) { for _, format := range []string{ "", "plain literal", - "00-11-AA-aa", - "#0#1#A#a## literal", + "{digits(2)}-{int(1,9)}{int(1,9)}-{upper(2)}-{lower(2)}", + "01Aa# literal", + "{{}} {{{x}}}", "Ö dag åäö 日本語", "{x}{x}{x}", "{hex(8)}-{int(10,99)}-{nanoid(5)}",