Tests for date() and time(), a named table keyed by its parent, and weighted names and valid ids in both locales

This commit is contained in:
2026-09-18 18:02:25 +02:00
parent b6219bf76a
commit 7ff817d5ae
5 changed files with 239 additions and 19 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ func BenchmarkPerson(b *testing.B) { benchPath(b, "data", "sv_SE.person") }
func BenchmarkAddress(b *testing.B) { benchPath(b, "data", "sv_SE.address") } func BenchmarkAddress(b *testing.B) { benchPath(b, "data", "sv_SE.address") }
func BenchmarkWord(b *testing.B) { benchPath(b, "data", "sv_SE.word") } func BenchmarkWord(b *testing.B) { benchPath(b, "data", "sv_SE.word") }
func BenchmarkCreditcard(b *testing.B) { benchPath(b, "data", "misc.creditcard") } func BenchmarkCreditcard(b *testing.B) { benchPath(b, "data", "misc.creditcard") }
func BenchmarkSSN(b *testing.B) { benchPath(b, "data", "sv_SE.ssn") } func BenchmarkSSN(b *testing.B) { benchPath(b, "data", "sv_SE.personnummer") }
func BenchmarkUUIDv7(b *testing.B) { benchPath(b, "data", "misc.uuid") } func BenchmarkUUIDv7(b *testing.B) { benchPath(b, "data", "misc.uuid") }
func tmpData(b *testing.B, name, body string) string { func tmpData(b *testing.B, name, body string) string {
+82
View File
@@ -4,7 +4,9 @@ import (
"encoding/base64" "encoding/base64"
"regexp" "regexp"
"strconv" "strconv"
"strings"
"testing" "testing"
"time"
) )
func TestBuiltinIDGenerators(t *testing.T) { func TestBuiltinIDGenerators(t *testing.T) {
@@ -41,6 +43,7 @@ func TestBuiltinSamplesReproducible(t *testing.T) {
`"{uuid()}"`, `"{ulid()}"`, `"{uuid()}"`, `"{ulid()}"`,
`"{nanoid(12)}"`, `"{int(1,1000000)}"`, `"{nanoid(12)}"`, `"{int(1,1000000)}"`,
`"{float(0,1,6)}"`, `"{base64(12)}"`, `"{iban(SE)}"`, `"{float(0,1,6)}"`, `"{base64(12)}"`, `"{iban(SE)}"`,
`"{date(2000-01-01,2020-12-31,'2006-01-02 15:04:05')}"`, `"{time('15:04:05')}"`,
} { } {
if a, b := mustRender(t, engine(7), tmpl), mustRender(t, engine(7), tmpl); a != b { if a, b := mustRender(t, engine(7), tmpl), mustRender(t, engine(7), tmpl); a != b {
t.Fatalf("%s not reproducible: %q != %q", tmpl, a, b) t.Fatalf("%s not reproducible: %q != %q", tmpl, a, b)
@@ -233,3 +236,82 @@ func TestClassBuiltinArgs(t *testing.T) {
} }
} }
} }
// TestBuiltinDateAndTime pins the two clock-free samples: date draws a second in
// [from 00:00:00, to 23:59:59], both days reachable, and renders it in the quoted Go
// layout, commas and English names included; time draws a second within one day.
func TestBuiltinDateAndTime(t *testing.T) {
f := engine(1)
seen := map[string]bool{}
for i := 0; i < 500; i++ {
got := mustRender(t, f, `"{date(1990-01-01,1990-12-31,'2006-01-02')}"`)
if d, err := time.Parse("2006-01-02", got); err != nil || d.Year() != 1990 {
t.Fatalf("date = %q, want a 1990 calendar date (err %v)", got, err)
}
seen[got] = true
}
if len(seen) < 200 {
t.Fatalf("date drew %d distinct days of 365 in 500, want a uniform spread", len(seen))
}
lo, hi := false, false
for i := 0; i < 200; i++ {
got := mustRender(t, f, `"{date(2020-02-28,2020-02-29,'2006-01-02')}"`)
if got != "2020-02-28" && got != "2020-02-29" {
t.Fatalf("date(2020-02-28,2020-02-29) = %q, out of range", got)
}
lo, hi = lo || got == "2020-02-28", hi || got == "2020-02-29"
}
if !lo || !hi {
t.Fatalf("date never hit a bound: lo=%v hi=%v (bounds must be inclusive)", lo, hi)
}
if got := mustRender(t, f, `"{date(2020-07-04,2020-07-05,'January 2, 2006')}"`); got != "July 4, 2020" && got != "July 5, 2020" {
t.Fatalf("date with a comma in its layout = %q", got)
}
rfc := regexp.MustCompile(`^2021-\d\d-\d\dT\d\d:\d\d:\d\dZ$`)
clock := regexp.MustCompile(`^([01]\d|2[0-3]):[0-5]\d$`)
ampm := regexp.MustCompile(`^(1[0-2]|[1-9]):[0-5]\d (AM|PM)$`)
seconds := map[string]bool{}
for i := 0; i < 300; i++ {
got := mustRender(t, f, `"{date(2021-01-01,2021-12-31,'2006-01-02T15:04:05Z07:00')}"`)
if !rfc.MatchString(got) {
t.Fatalf("date in an RFC 3339 layout = %q, want %s", got, rfc)
}
seconds[got[17:19]] = true
if got := mustRender(t, f, `"{time('15:04')}"`); !clock.MatchString(got) {
t.Fatalf("time('15:04') = %q, want %s", got, clock)
}
if got := mustRender(t, f, `"{time('3:04 PM')}"`); !ampm.MatchString(got) {
t.Fatalf("time('3:04 PM') = %q, want %s", got, ampm)
}
got = mustRender(t, f, `"{date(1950-01-01,2000-12-31,'060102')}-238{luhn()}"`)
if d := digitsOnly(got); len(d) != 10 || !luhnValid(d) {
t.Fatalf("a personnummer over date() = %q, want ten Luhn-valid digits", got)
}
}
if len(seconds) < 30 {
t.Fatalf("date drew %d distinct seconds in 300, want the whole day, not midnight", len(seconds))
}
}
// TestBuiltinDateArgs pins the New-time checks: bounds are calendar dates in order,
// the layout is quoted, names a field, and for time names no date field.
func TestBuiltinDateArgs(t *testing.T) {
for tmpl, want := range map[string]string{
`"{date(1990-13-01,1990-12-31,'2006-01-02')}"`: "1990-13-01",
`"{date(1990-12-31,1990-01-01,'2006-01-02')}"`: "before",
`"{date(1990-01-01,1990-01-01,'2006-01-02')}"`: "before",
`"{date(1990-01-01,1990-12-31,2006-01-02)}"`: "'2006-01-02'",
`"{date(1990-01-01,1990-12-31,'January 2, 2006)}"`: "'",
`"{date(1990-01-01,1990-12-31,'x')}"`: "text",
`"{date(1990-01-01,1990-12-31,'')}"`: "text",
`"{date(1990-01-01,1990-12-31)}"`: "3 args",
`"{time(15:04)}"`: "'15:04'",
`"{time('2006-01-02 15:04')}"`: "date(",
`"{time('x')}"`: "text",
} {
_, err := compile(parse(t, tmpl))
if err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("compile(%s) = %v, want an error mentioning %q", tmpl, err, want)
}
}
}
+110 -18
View File
@@ -10,7 +10,7 @@ import (
// TestShippedDataCategories asserts every shipped locale emits only well-formed // TestShippedDataCategories asserts every shipped locale emits only well-formed
// values for each data category. en and sv patterns differ where the format is // values for each data category. en and sv patterns differ where the format is
// locale-specific (date, time, ssn, company, price, ...); the rest are shared. // locale-specific (date, time, company, price, ...); the rest are shared.
func TestShippedDataCategories(t *testing.T) { func TestShippedDataCategories(t *testing.T) {
letters := regexp.MustCompile(`^[\pL'-]+$`) letters := regexp.MustCompile(`^[\pL'-]+$`)
semver := regexp.MustCompile(`^v?\d+\.\d+\.\d+(-(alpha|beta|rc)\.\d+)?$`) semver := regexp.MustCompile(`^v?\d+\.\d+\.\d+(-(alpha|beta|rc)\.\d+)?$`)
@@ -33,10 +33,7 @@ func TestShippedDataCategories(t *testing.T) {
regexp.MustCompile(`^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$`)}, regexp.MustCompile(`^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$`)},
{"time", {"time",
regexp.MustCompile(`^([1-9]|1[0-2]):[0-5]\d (AM|PM)$`), regexp.MustCompile(`^([1-9]|1[0-2]):[0-5]\d (AM|PM)$`),
regexp.MustCompile(`^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$`)}, regexp.MustCompile(`^([01]\d|2[0-3]):[0-5]\d$`)},
{"ssn",
regexp.MustCompile(`^[1-9]\d{2}-\d{2}-\d{4}$`),
regexp.MustCompile(`^\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])-\d{4}$`)},
{"version", semver, semver}, {"version", semver, semver},
{"email", email, email}, {"email", email, email},
{"ip", ip, ip}, {"ip", ip, ip},
@@ -72,7 +69,11 @@ func TestShippedMiscCategories(t *testing.T) {
v4 := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) v4 := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
mac := regexp.MustCompile(`^([0-9a-f]{2}:){5}[0-9a-f]{2}$`) mac := regexp.MustCompile(`^([0-9a-f]{2}:){5}[0-9a-f]{2}$`)
objectid := regexp.MustCompile(`^[0-9a-f]{24}$`) objectid := regexp.MustCompile(`^[0-9a-f]{24}$`)
datetime := regexp.MustCompile(`^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$`)
for i := 0; i < 200; i++ { for i := 0; i < 200; i++ {
if v := fake(t, f, "datetime"); !datetime.MatchString(v) {
t.Fatalf("misc datetime %q is not an RFC 3339 UTC instant", v)
}
if v := fake(t, f, "objectid"); !objectid.MatchString(v) { if v := fake(t, f, "objectid"); !objectid.MatchString(v) {
t.Fatalf("misc objectid %q is not 24 hex chars", v) t.Fatalf("misc objectid %q is not 24 hex chars", v)
} }
@@ -147,30 +148,121 @@ func TestSwedishPersonNamesHaveNoTripleLetter(t *testing.T) {
} }
} }
// TestSwedishPersonnummer checks the two rules the shape regex can't: the date // TestSwedishPersonnummer checks what the shape regex can't: the date is a real
// is a real calendar date (so month-length variants never emit e.g. Apr 31 or // calendar date, the birth number is Skatteverket's test series (238 female, 239
// Feb 30) and the trailing digit is a valid Luhn checksum over the other nine. // male), the trailing digit is a Luhn checksum over the other nine, and a
// samordningsnummer is the same with 60 added to the day.
func TestSwedishPersonnummer(t *testing.T) { func TestSwedishPersonnummer(t *testing.T) {
sv := newGenerator(t, "data", WithSeed(1)) sv := newGenerator(t, "data", WithSeed(1))
sawLongMonthEnd := false re := regexp.MustCompile(`^\d{6}-23[89]\d$`)
sawLongMonthEnd, birth := false, map[string]bool{}
for i := 0; i < 2000; i++ { for i := 0; i < 2000; i++ {
v := fake(t, sv, "sv_SE.ssn") v := fake(t, sv, "sv_SE.personnummer")
d := digitsOnly(v) d := digitsOnly(v)
if len(d) != 10 { if !re.MatchString(v) {
t.Fatalf("ssn %q has %d digits, want 10", v, len(d)) t.Fatalf("personnummer %q, want YYMMDD-238C or YYMMDD-239C", v)
} }
if _, err := time.Parse("060102", d[:6]); err != nil { // 2-digit year, real-date check if _, err := time.Parse("060102", d[:6]); err != nil {
t.Fatalf("ssn %q is not a valid calendar date: %v", v, err) t.Fatalf("personnummer %q is not a valid calendar date: %v", v, err)
} }
if !luhnValid(d) { if !luhnValid(d) {
t.Fatalf("ssn %q fails the Luhn check", v) t.Fatalf("personnummer %q fails the Luhn check", v)
} }
if d[4:6] == "31" { sawLongMonthEnd = sawLongMonthEnd || d[4:6] == "31"
sawLongMonthEnd = true birth[d[6:9]] = true
s := fake(t, sv, "sv_SE.samordningsnummer")
sd := digitsOnly(s)
day, err := strconv.Atoi(sd[4:6])
if !re.MatchString(s) || err != nil || day < 61 || day > 88 || !luhnValid(sd) {
t.Fatalf("samordningsnummer %q, want YYMM(61-88)-23[89]C, Luhn-valid", s)
}
if _, err := time.Parse("0601", sd[:4]); err != nil {
t.Fatalf("samordningsnummer %q is not a valid year and month: %v", s, err)
} }
} }
if !sawLongMonthEnd { if !sawLongMonthEnd {
t.Fatal("never generated a 31st — 31-day months are not reaching their last day") t.Fatal("never generated a 31st")
}
if !birth["238"] || !birth["239"] {
t.Fatalf("birth numbers drawn %v, want both 238 and 239", birth)
}
for i := 0; i < 200; i++ {
got := fakeTemplate(t, sv, `{/sv_SE.person.sex} {/sv_SE.personnummer}`)
if f, m := strings.HasPrefix(got, "kvinna ") && got[14:17] == "238", strings.HasPrefix(got, "man ") && got[11:14] == "239"; !f && !m {
t.Fatalf("%q: a person and a personnummer in one render disagree on sex", got)
}
}
}
// TestShippedUSTaxIds pins the SSA and IRS ranges: an SSN's area is 001-899 but
// 666, its group 01-99 and its serial 0001-9999; an ITIN is 9XX-GG-XXXX with GG
// in 50-65, 70-88, 90-92 or 94-99.
func TestShippedUSTaxIds(t *testing.T) {
f := newGenerator(t, "data", WithSeed(5))
ssn := regexp.MustCompile(`^(\d{3})-(\d{2})-(\d{4})$`)
itin := regexp.MustCompile(`^9\d{2}-(5\d|6[0-5]|7\d|8[0-8]|9[0-24-9])-\d{4}$`)
for i := 0; i < 2000; i++ {
v := fake(t, f, "en_US.ssn")
m := ssn.FindStringSubmatch(v)
if m == nil {
t.Fatalf("ssn %q, want AAA-GG-SSSS", v)
}
area, _ := strconv.Atoi(m[1])
group, _ := strconv.Atoi(m[2])
serial, _ := strconv.Atoi(m[3])
if area < 1 || area > 899 || area == 666 || group < 1 || serial < 1 {
t.Fatalf("ssn %q is in a range the SSA never assigns", v)
}
if v := fake(t, f, "en_US.itin"); !itin.MatchString(v) {
t.Fatalf("itin %q, want %s", v, itin)
}
}
}
// TestShippedPersonNames pins the name tables: a sex pins its first names, a name
// both sexes carry appears under both, and the record's sex agrees with its name.
func TestShippedPersonNames(t *testing.T) {
f := newGenerator(t, "data", WithSeed(9))
for path, want := range map[string]string{
"sv_SE.sex[f]": "kvinna",
"sv_SE.sex[man].code": "m",
"en_US.sex[f]": "female",
"sv_SE.sex[f].first-name[Anna]": "Anna",
"sv_SE.sex[m].first-name[Erik].sex": "m",
"en_US.sex[m].first-name[James]": "James",
"en_US.sex[f].first-name[Taylor].sex": "f",
"en_US.sex[m].first-name[Taylor].sex": "m",
"sv_SE.last-name[Andersson]": "Andersson",
"sv_SE.last-name[Andersson].count": "",
"en_US.last-name[Smith]": "Smith",
} {
got := fake(t, f, path)
if want == "" && !regexp.MustCompile(`^[1-9]\d*$`).MatchString(got) {
t.Errorf("Fake(%q) = %q, want a count", path, got)
} else if want != "" && got != want {
t.Errorf("Fake(%q) = %q, want %q", path, got, want)
}
}
if _, err := f.Fake("en_US.first-name[Taylor]"); err == nil || !strings.Contains(err.Error(), "sex[f].first-name[Taylor]") {
t.Fatalf("Fake(en_US.first-name[Taylor]) = %v, want both sexes' rows listed", err)
}
for _, locale := range []string{"sv_SE", "en_US"} {
seen := map[string]bool{}
for i := 0; i < 2000; i++ {
seen[fake(t, f, locale+".sex[f].first-name")] = true
first, sex := fake(t, f, locale+".person.first"), fake(t, f, locale+".person.sex")
if first == "" || sex == "" {
t.Fatalf("%s.person lacks a first name or a sex", locale)
}
}
if len(seen) < 300 || !seen["Anna"] && locale == "sv_SE" || !seen["Mary"] && locale == "en_US" {
t.Fatalf("%s female first names: %d distinct in 2000, want a weighted register", locale, len(seen))
}
got := fakeTemplate(t, f, `{/`+locale+`.sex.code}|{/`+locale+`.person.first}`)
code, first, _ := strings.Cut(got, "|")
if v := fake(t, f, locale+".sex["+code+"].first-name["+first+"]"); v != first {
t.Fatalf("%s: %q drawn as a %s name is not one", locale, first, code)
}
} }
} }
+11
View File
@@ -81,3 +81,14 @@ func TestDifferentSeedsDiffer(t *testing.T) {
} }
t.Fatal("seeds 1 and 2 produced identical sequences") t.Fatal("seeds 1 and 2 produced identical sequences")
} }
// fakeTemplate renders an inline template against a loaded generator, so its
// references resolve.
func fakeTemplate(t *testing.T, f *Generator, s string) string {
t.Helper()
got, err := f.FakeTemplate(s)
if err != nil {
t.Fatalf("FakeTemplate(%s) = %v", s, err)
}
return got
}
+35
View File
@@ -440,6 +440,7 @@ func TestTableFences(t *testing.T) {
"a bracket in a key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a"}`, "t.tsv": "a\nx[1]\ny\n"}, `"["`}, "a bracket in a key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a"}`, "t.tsv": "a\nx[1]\ny\n"}, `"["`},
"a brace in a name": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a","name":"n"}`, "t.tsv": "a\tn\nx\tx{1}\ny\ty\n"}, `"{"`}, "a brace in a name": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a","name":"n"}`, "t.tsv": "a\tn\nx\tx{1}\ny\ty\n"}, `"{"`},
"name without a key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","name":"a"}`, "t.tsv": "a\nx\nx\n"}, "key"}, "name without a key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","name":"a"}`, "t.tsv": "a\nx\nx\n"}, "key"},
"a name repeating inside one parent row": {with(siblings(), map[string]string{"street.json": `{"format":"{name}","rows":"street.tsv","name":"name","parent":"locality"}`, "street.tsv": "name\tlocality\nAvenyn\tL1\nAvenyn\tL1\nStorgatan\tL2\nStorgatan\tL3\nStorgatan\tL4\nStorgatan\tL5\nStorgatan\tL6\nStorgatan\tL7\nStorgatan\tL8\n"}), `"Avenyn"`},
"a name that is another row's key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a","name":"n"}`, "t.tsv": "a\tn\nx\ty\ny\tz\n"}, `"y"`}, "a name that is another row's key": {map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv","key":"a","name":"n"}`, "t.tsv": "a\tn\nx\ty\ny\tz\n"}, `"y"`},
"a cell reading its family": {with(geo(), map[string]string{"locality.tsv": "code\tname\tmunicipality\tnote\nL1\tStockholm\t0180\t{/municipality.code}\nL2\tSolna\t0184\t-\nL3\tMalmö\t1280\t-\nL4\tLund\t1281\t-\nL5\tGöteborg\t1480\t-\n"}), "family"}, "a cell reading its family": {with(geo(), map[string]string{"locality.tsv": "code\tname\tmunicipality\tnote\nL1\tStockholm\t0180\t{/municipality.code}\nL2\tSolna\t0184\t-\nL3\tMalmö\t1280\t-\nL4\tLund\t1281\t-\nL5\tGöteborg\t1480\t-\n"}), "family"},
"a format reading its family": {with(geo(), map[string]string{"locality.json": `{"format":"{name} {/region.name}","rows":"locality.tsv","key":"code","name":"name","parent":"municipality"}`}), "family"}, "a format reading its family": {with(geo(), map[string]string{"locality.json": `{"format":"{name} {/region.name}","rows":"locality.tsv","key":"code","name":"name","parent":"municipality"}`}), "family"},
@@ -709,3 +710,37 @@ func TestShippedTables(t *testing.T) {
t.Fatalf("country draws %d distinct rows in 5000, want the full register", len(count)) t.Fatalf("country draws %d distinct rows in 5000, want the full register", len(count))
} }
} }
// TestNamedTableWithoutAKeyResolvesInsideItsParent pins a table whose rows are
// told apart only by their parent: a name selects a row inside the parent pinned
// before it, an ambiguous one is listed by its parent's spelling, and a name
// repeating inside one parent row is a load error (see TestTableFences).
func TestNamedTableWithoutAKeyResolvesInsideItsParent(t *testing.T) {
files := siblings()
files["street.json"] = `{"format":"{name}","rows":"street.tsv","name":"name","parent":"locality","weight":"segments"}`
f := newGenerator(t, writeFiles(t, files), WithSeed(1))
for path, want := range map[string]string{
"street[Avenyn]": "Avenyn",
"street[Avenyn].locality": "L5",
"locality[L7].street[Sandbyvägen]": "Sandbyvägen",
"locality[L7].street[Sandbyvägen].segments": "2",
"municipality[0184].locality[Sandby].street[Sandbyvägen]": "Sandbyvägen",
"municipality[0184].locality[Sandby].street[Sandbyvägen].locality": "L8",
} {
if got := fake(t, f, path); got != want {
t.Errorf("Fake(%q) = %q, want %q", path, got, want)
}
}
for path, want := range map[string]string{
"street[Sandbyvägen]": "locality[L7].street[Sandbyvägen]",
"locality[L1].street[Avenyn]": "not inside",
"street[Kungsgatan]": `"Kungsgatan"`,
} {
if _, err := f.Fake(path); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("Fake(%q) = %v, want an error mentioning %s", path, err, want)
}
}
if got := fakeTemplate(t, f, `{/locality[L8].name}: {/locality[L8].street[Sandbyvägen].name}`); got != "Sandby: Sandbyvägen" {
t.Fatalf("a name selected inside a pinned parent = %q", got)
}
}