package fejkdata import ( "strings" "testing" ) // TestRootReferenceAcrossFolders is the headline case: a category in one folder // pulls a value from another via a {/path} reference resolved from the data root. func TestRootReferenceAcrossFolders(t *testing.T) { dir := writeData(t, map[string]string{ "en_US/person": `"Pat Smith"`, "sv_SE/greeting": `"Hej, {/en_US.person}!"`, }) f := newGenerator(t, dir, WithSeed(1)) if got := fake(t, f, "sv_SE.greeting"); got != "Hej, Pat Smith!" { t.Fatalf("greeting = %q, want \"Hej, Pat Smith!\"", got) } } // TestReferenceIntoAField reaches a field inside a referenced category. func TestReferenceIntoAField(t *testing.T) { dir := writeData(t, map[string]string{ "who": `{"format":"{first} {last}","first":"Ada","last":"Byron"}`, "card": `"signed {/who.last}"`, }) f := newGenerator(t, dir, WithSeed(1)) if got := fake(t, f, "card"); got != "signed Byron" { t.Fatalf("card = %q, want \"signed Byron\"", got) } } // TestReferenceInAlternation lets a reference stand as one arm of a {a|..b} // alternation, so a field and a cross-file value share one slot. func TestReferenceInAlternation(t *testing.T) { dir := writeData(t, map[string]string{ "far": `"X"`, "near": `{"format":"{here|..far}","here":"H"}`, }) f := newGenerator(t, dir, WithSeed(2)) seen := map[string]bool{} for i := 0; i < 100; i++ { seen[fake(t, f, "near")] = true } if !seen["H"] || !seen["X"] || len(seen) != 2 { t.Fatalf("alternation produced %v, want both H and X", seen) } } // TestReferenceCombinesLoadedPaths is the point of references over the merge // model: data layered from two dirs can point at each other through the root. func TestReferenceCombinesLoadedPaths(t *testing.T) { a := writeData(t, map[string]string{"en_US/word": `"river"`}) b := writeData(t, map[string]string{"mine/slug": `"the-{/en_US.word}"`}) f := newGeneratorN(t, []string{a, b}, WithSeed(1)) if got := fake(t, f, "mine.slug"); got != "the-river" { t.Fatalf("slug = %q, want the-river", got) } } // TestReferenceChain follows a reference to a node that is itself a reference, so // linking order cannot matter. func TestReferenceChain(t *testing.T) { dir := writeData(t, map[string]string{ "a": `"{/b}"`, "b": `"{/c}"`, "c": `"deep"`, }) f := newGenerator(t, dir, WithSeed(1)) if got := fake(t, f, "a"); got != "deep" { t.Fatalf("a = %q, want deep", got) } } // TestReferenceErrors lists the references New must reject up front, so a bad path // fails at load, never at a random render. func TestReferenceErrors(t *testing.T) { cases := map[string]map[string]string{ "missing target": {"card": `"{/nope.gone}"`}, "folder target": {"en_US/word": `"w"`, "card": `"{/en_US}"`}, "a variant on the path lacks the field": { "who": `[{"format":"{f}","f":"1"},{"format":"{g}","g":"2"}]`, "card": `"{/who.f}"`, }, "empty reference path": {"card": `"{/}"`}, // A reference that leads back to its own value never terminates at render, // so New must reject the cycle up front (direct, mutual, or chained). "direct cycle": {"a": `"x{/a}"`}, "mutual cycle": {"a": `"{/b}"`, "b": `"{/a}"`}, "chain cycle": {"a": `"{/b}"`, "b": `"{/c}"`, "c": `"{/a}"`}, // calc renders its operands, so a cycle through one must be caught too. "calc operand cycle": {"x": `{"format":"{calc(y)}","y":"{/x}"}`}, // A field its parent's format never renders is still reachable by dot path, // so a cycle hiding in one must fail at New rather than at render. "cycle in an unrendered field": {"cat": `{"format":"hi","x":"{/cat.x}"}`}, "mutual cycle between unrendered fields": { "cat": `{"format":"hi","x":"{/cat.y}","y":"{/cat.x}"}`, }, "cycle in an unrendered field of a choice arm": { "cat": `{"format":"hi","x":"{/cat.x}"}`, }, // The shipped layout puts categories in folders, so a cycle one level down // is the common case, not an edge case. "cycle in a subfolder": {"sv_SE/a": `"x{/sv_SE.a}"`}, "mutual cycle within a subfolder": {"sv_SE/a": `"{/sv_SE.b}"`, "sv_SE/b": `"{/sv_SE.a}"`}, "mutual cycle across two folders": {"en_US/a": `"{/sv_SE.b}"`, "sv_SE/b": `"{/en_US.a}"`}, // ".." is reserved for bound references, so an authored key using it would // name a node nothing can reach and nothing would validate. "field key using the reference prefix": {"cat": `{"format":"hi","..x":"{/nope}"}`}, } for name, files := range cases { if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err == nil { t.Errorf("%s: New = nil error, want a reference error", name) } } } // TestDotPrefixedDataEntriesAreSkipped pins what a leading dot means on disk: the // entry is hidden, not data, so a data directory can also be a checkout. A name // starting with the reference prefix is covered by that same rule, since ".." // starts with "." — it is skipped, not rejected. func TestDotPrefixedDataEntriesAreSkipped(t *testing.T) { f := newGenerator(t, writeData(t, map[string]string{ "sv_SE/ok": `"fine"`, "sv_SE/..bad": `"{/nope}"`, "sv_SE/..y/ct": `"{/nope}"`, ".git/config": `"not data"`, }), WithSeed(1)) if got := f.List(); len(got) != 1 || got[0] != "sv_SE.ok" { t.Fatalf("List() = %v, want only sv_SE.ok", got) } } // TestReferenceFromUnrenderedFieldTerminates guards the cycle check against // over-rejecting: a field the format never renders may point back at its own // category, which terminates, and stays renderable by path. func TestReferenceFromUnrenderedFieldTerminates(t *testing.T) { dir := writeData(t, map[string]string{"cat": `{"format":"hi","x":"see {/cat}"}`}) f := newGenerator(t, dir, WithSeed(1)) if got := fake(t, f, "cat"); got != "hi" { t.Fatalf("cat = %q, want hi", got) } if got := fake(t, f, "cat.x"); got != "see hi" { t.Fatalf("cat.x = %q, want \"see hi\"", got) } } // TestNewErrorIsDeterministic pins one message per broken data set: map iteration // order must not decide which of several problems the user is told about, whether // they sit in separate categories or in one template's fields. func TestNewErrorIsDeterministic(t *testing.T) { cases := map[string]map[string]string{ "three bad references": { "a": `"{/nope.one}"`, "b": `"{/nope.two}"`, "c": `"{/nope.three}"`, }, "two bad fields in one template": { "cat": `{"format":"hi","aaa":{"no":1},"zzz":{"no":2}}`, }, } for name, files := range cases { dir := writeData(t, files) var first string for i := 0; i < 50; i++ { _, err := New(WithoutShippedData(), WithDataPath(dir)) if err == nil { t.Fatalf("%s: New = nil error, want a load error", name) } if i == 0 { first = err.Error() continue } if err.Error() != first { t.Fatalf("%s: New error varies between runs:\n %s\n %s", name, first, err.Error()) } } t.Logf("%s -> %s", name, first) } } // TestNewErrorPathIsCanonical pins the node path a load error names: a choice arm // adds no segment, and a bound {/path} reference is not a containment segment at // all, so a bad reference is reported against the node that holds it. func TestNewErrorPathIsCanonical(t *testing.T) { cases := []struct { name string files map[string]string want string }{ { "cycle inside a choice arm", map[string]string{"cat": `{"format":"hi","x":"{/cat.x}"}`}, "fejkdata: reference cycle: cat.x -> ..cat.x", }, { "bad reference reached through another reference", map[string]string{"a": `"{/b}"`, "b": `"{/nope}"`}, `fejkdata: b: reference {/nope}: no entry "nope"`, }, } for _, c := range cases { _, err := New(WithoutShippedData(), WithDataPath(writeData(t, c.files))) if err == nil { t.Errorf("%s: New = nil error", c.name) continue } if err.Error() != c.want { t.Errorf("%s:\n got %s\n want %s", c.name, err, c.want) } } } func TestReferencePathIsHeld(t *testing.T) { dir := writeData(t, map[string]string{ "person": `[{"format":"{first} {last}","first":"Anna","last":"Andersson"},{"format":"{first} {last}","first":"Bo","last":"Berg"}]`, "card": `"{/person.first} {/person.last}"`, }) f := newGenerator(t, dir, WithSeed(3)) seen := map[string]bool{} for i := 0; i < 50; i++ { got := fake(t, f, "card") if got != "Anna Andersson" && got != "Bo Berg" { t.Fatalf("card = %q, want one person's first and last name", got) } seen[got] = true } if len(seen) != 2 { t.Fatalf("card only ever rendered %v", seen) } } func TestReferenceThroughChoiceNeedsEveryVariant(t *testing.T) { _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ "who": `[{"format":"{f}{h}","f":"1","h":"x"},{"format":"{g}{h}","g":"2","h":"y"}]`, "card": `"{/who.f}"`, }))) if err == nil || !strings.Contains(err.Error(), "not every variant") { t.Fatalf("New = %v, want the missing variant named", err) } } func TestBareReferenceDrawsEachTime(t *testing.T) { dir := writeData(t, map[string]string{ "die": `["1","2","3","4","5","6"]`, "roll": `"{/die} {/die}"`, }) f := newGenerator(t, dir, WithSeed(1)) for i := 0; i < 50; i++ { if got := fake(t, f, "roll"); got[0] != got[2] { return } } t.Fatal("two bare references always agreed; each should be its own draw") } func TestReferenceOverlapIsRejected(t *testing.T) { for name, file := range map[string]string{ "head beside a path": `{"format":"{/cat.p} {/cat.p.first}","p":[{"format":"{first}","first":"A"},{"format":"{first}","first":"B"}]}`, "sibling path beside a reference path": `{"format":"{p.first} {/cat.p.last}","p":[{"format":"{first}","first":"A","last":"1"},{"format":"{first}","first":"B","last":"2"}]}`, } { _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file}))) if err == nil || !strings.Contains(err.Error(), "reads a path into") { t.Errorf("%s: New = %v, want the overlap rejected", name, err) } } } func TestRelativeReferences(t *testing.T) { dir := writeData(t, map[string]string{ "sv_SE/username": `"bob"`, "sv_SE/email": `"{.username}@example.com"`, "sv_SE/deep/card": `"{..username} via {/sv_SE.username}"`, "top": `"{.sv_SE.username}"`, }) f := newGenerator(t, dir, WithSeed(1)) for path, want := range map[string]string{ "sv_SE.email": "bob@example.com", "sv_SE.deep.card": "bob via bob", "top": "bob", } { if got := fake(t, f, path); got != want { t.Errorf("%s = %q, want %q", path, got, want) } } } func TestRelativeAndRootSpellingsBindOneDraw(t *testing.T) { dir := writeData(t, map[string]string{ "sv_SE/person": `[{"format":"{first} {last}","first":"Anna","last":"Andersson"},{"format":"{first} {last}","first":"Bo","last":"Berg"}]`, "sv_SE/card": `"{.person.first} {/sv_SE.person.last}"`, }) f := newGenerator(t, dir, WithSeed(3)) for i := 0; i < 50; i++ { if got := fake(t, f, "sv_SE.card"); got != "Anna Andersson" && got != "Bo Berg" { t.Fatalf("card = %q, want both spellings to read one person", got) } } } func TestReferenceSigilErrors(t *testing.T) { for name, files := range map[string]map[string]string{ "no folder above the root": {"a": `"{..b}"`, "b": `"x"`}, "three dots": {"a": `"{...b}"`, "b": `"x"`}, "root sigil alone": {"a": `"{/}"`}, "dot alone": {"a": `"{.}"`}, "missing sibling": {"sv_SE/a": `"{.nope}"`}, "slash in a field name": {"a": `{"format":"{x}","x":"1","a/b":"2"}`}, } { if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err == nil { t.Errorf("%s: New = nil error, want a reference error", name) } } }