From 7ff817d5ae02da50ba5fdbb81332543e3d1323e2 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 18 Sep 2026 18:02:25 +0200 Subject: [PATCH] Tests for date() and time(), a named table keyed by its parent, and weighted names and valid ids in both locales --- bench_test.go | 2 +- builtins_test.go | 82 ++++++++++++++++++++++++++++++ data_test.go | 128 ++++++++++++++++++++++++++++++++++++++++------- fejkdata_test.go | 11 ++++ table_test.go | 35 +++++++++++++ 5 files changed, 239 insertions(+), 19 deletions(-) diff --git a/bench_test.go b/bench_test.go index e016d58..6cfbd84 100644 --- a/bench_test.go +++ b/bench_test.go @@ -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 BenchmarkWord(b *testing.B) { benchPath(b, "data", "sv_SE.word") } 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 tmpData(b *testing.B, name, body string) string { diff --git a/builtins_test.go b/builtins_test.go index 31b2f72..b91365b 100644 --- a/builtins_test.go +++ b/builtins_test.go @@ -4,7 +4,9 @@ import ( "encoding/base64" "regexp" "strconv" + "strings" "testing" + "time" ) func TestBuiltinIDGenerators(t *testing.T) { @@ -41,6 +43,7 @@ func TestBuiltinSamplesReproducible(t *testing.T) { `"{uuid()}"`, `"{ulid()}"`, `"{nanoid(12)}"`, `"{int(1,1000000)}"`, `"{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 { 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) + } + } +} diff --git a/data_test.go b/data_test.go index a75f4da..0368184 100644 --- a/data_test.go +++ b/data_test.go @@ -10,7 +10,7 @@ import ( // TestShippedDataCategories asserts every shipped locale emits only well-formed // 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) { letters := regexp.MustCompile(`^[\pL'-]+$`) 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])$`)}, {"time", 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)?$`)}, - {"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}$`)}, + regexp.MustCompile(`^([01]\d|2[0-3]):[0-5]\d$`)}, {"version", semver, semver}, {"email", email, email}, {"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}$`) mac := regexp.MustCompile(`^([0-9a-f]{2}:){5}[0-9a-f]{2}$`) 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++ { + 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) { 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 -// 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. +// TestSwedishPersonnummer checks what the shape regex can't: the date is a real +// calendar date, the birth number is Skatteverket's test series (238 female, 239 +// 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) { 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++ { - v := fake(t, sv, "sv_SE.ssn") + v := fake(t, sv, "sv_SE.personnummer") d := digitsOnly(v) - if len(d) != 10 { - t.Fatalf("ssn %q has %d digits, want 10", v, len(d)) + if !re.MatchString(v) { + 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 - t.Fatalf("ssn %q is not a valid calendar date: %v", v, err) + if _, err := time.Parse("060102", d[:6]); err != nil { + t.Fatalf("personnummer %q is not a valid calendar date: %v", v, err) } 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 = true + sawLongMonthEnd = sawLongMonthEnd || d[4:6] == "31" + 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 { - 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) + } } } diff --git a/fejkdata_test.go b/fejkdata_test.go index 2435ec5..18b5a59 100644 --- a/fejkdata_test.go +++ b/fejkdata_test.go @@ -81,3 +81,14 @@ func TestDifferentSeedsDiffer(t *testing.T) { } 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 +} diff --git a/table_test.go b/table_test.go index 7930844..1de23a1 100644 --- a/table_test.go +++ b/table_test.go @@ -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 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"}, + "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 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"}, @@ -709,3 +710,37 @@ func TestShippedTables(t *testing.T) { 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) + } +}