diff --git a/hold_test.go b/hold_test.go index ab5a637..53f6f9f 100644 --- a/hold_test.go +++ b/hold_test.go @@ -91,17 +91,28 @@ func TestReferenceNamingABoundLevelIsRejected(t *testing.T) { // A reference can name a bound level from the data root, which renders it // afresh beside the path that reads its held draw — the same overlap by // another spelling. - rejected := map[string]string{ - "reference names the head": `{"format":"{p.first}|{/cat.p}","p":{"format":"{first}","first":["Anna","Bo"]}}`, - "reference names the leaf": `{"format":"{p.addr}|{/cat.p.addr}","p":{"format":"x","addr":["A","B","C","D"]}}`, + // A category never references itself, so each reference back into cat sits in a + // second category cat renders. + rejected := map[string]map[string]string{ + "reference names the head": { + "cat": `{"format":"{p.first}|{/hop}","p":{"format":"{first}","first":["Anna","Bo"]}}`, + "hop": `"{/cat.p}"`, + }, + "reference names the leaf": { + "cat": `{"format":"{p.addr}|{/hop}","p":{"format":"x","addr":["A","B","C","D"]}}`, + "hop": `"{/cat.p.addr}"`, + }, // The reference need not sit in the format that binds: any field it renders // reaches the level just the same, however deep. - "reference from a sibling field": `{"format":"{p.first}|{inner}","p":[` + - `{"format":"{first}-{last}","first":"A","last":"1"},{"format":"{first}-{last}","first":"B","last":"2"}],` + - `"inner":"{/cat.p}"}`, + "reference from a sibling field": { + "cat": `{"format":"{p.first}|{inner}","p":[` + + `{"format":"{first}-{last}","first":"A","last":"1"},{"format":"{first}-{last}","first":"B","last":"2"}],` + + `"inner":"{/hop}"}`, + "hop": `"{/cat.p}"`, + }, } - for name, file := range rejected { - _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file}))) + for name, files := range rejected { + _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))) if err == nil || !strings.Contains(err.Error(), "reads a path into") { t.Errorf("%s: New = %v, want the reference rejected as an overlap", name, err) } @@ -120,7 +131,8 @@ func TestCycleReachedOnlyByAPathTokenIsRejected(t *testing.T) { // cycle walk has to follow it there. A head whose own format names nothing // would otherwise hide the cycle until render, where it is fatal. _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "a": `{"format":"{p.x}","p":{"format":"static","x":"{/a}"}}`, + "a": `{"format":"{p.x}","p":{"format":"static","x":"{/b}"}}`, + "b": `"{/a}"`, }))) if err == nil || !strings.Contains(err.Error(), "reference cycle") { t.Fatalf("New = %v, want the cycle through {p.x} rejected", err) @@ -131,8 +143,9 @@ func TestALevelRenderedOnlyByAPathTokenIsHeld(t *testing.T) { // {p.a} renders q, so it is a route to the level {q.x} holds — even though p's // own format names nothing. _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "thing": `{"format":"{p.a} {q.x}","p":{"format":"static","a":"{/thing.q}"},` + + "thing": `{"format":"{p.a} {q.x}","p":{"format":"static","a":"{/hop}"},` + `"q":{"format":"{x}","x":["1","2"]}}`, + "hop": `"{/thing.q}"`, }))) if err == nil || !strings.Contains(err.Error(), "reads a path into") { t.Fatalf("New = %v, want the second route to q rejected", err) @@ -149,16 +162,20 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) { files map[string]string want string // the route the error names, spelled as the author wrote it }{ + // A category never references itself, so each route back into cat sits in a + // second category cat renders. "a reference beside the operand": { map[string]string{ - "cat": `{"format":"{/cat.net} x 2 = {calc(net * 2, 2)}","net":["10.00","20.00"]}`, + "cat": `{"format":"{/hop} x 2 = {calc(net * 2, 2)}","net":["10.00","20.00"]}`, + "hop": `"{/cat.net}"`, }, - `{/cat.net} renders "net"`, + `{/hop} renders "net"`, }, "a reference one level down": { map[string]string{ "cat": `{"format":"{calc(net * 2, 2)} {q}","net":["10.00","20.00"],` + - `"q":"{/cat.net}"}`, + `"q":"{/hop}"}`, + "hop": `"{/cat.net}"`, }, `{q} renders "net"`, }, @@ -167,7 +184,8 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) { "a reference to an operand wrapped in a choice": { map[string]string{ "cat": `{"format":"{calc(n * 2, 2)} {q}","n":{"format":"{v}","v":["1","2"]},` + - `"q":"{/cat.n}"}`, + `"q":"{/hop}"}`, + "hop": `"{/cat.n}"`, }, `{q} renders "n"`, }, @@ -176,7 +194,8 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) { "an operand reaching another operand": { map[string]string{ "cat": `{"format":"{calc(a + b, 0)}","a":{"format":"{x}","x":["1","2"]},` + - `"b":"{/cat.a}"}`, + `"b":"{/hop}"}`, + "hop": `"{/cat.a}"`, }, `calc operand "b" renders "a"`, }, @@ -185,15 +204,17 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) { // rejected, so the reference spelling has to be. "a reference into the operand": { map[string]string{ - "cat": `{"format":"{calc(net * 2, 2)}|{/cat.net.v}",` + + "cat": `{"format":"{calc(net * 2, 2)}|{/hop}",` + `"net":{"format":"{v}","v":["10.00","20.00"]}}`, + "hop": `"{/cat.net.v}"`, }, - `{/cat.net.v} renders "net"`, + `{/hop} renders "net"`, }, "a reference into the operand one level down": { map[string]string{ "cat": `{"format":"{calc(net * 2, 2)}|{q}","net":{"format":"{v}","v":["10.00","20.00"]},` + - `"q":"{/cat.net.v}"}`, + `"q":"{/hop}"}`, + "hop": `"{/cat.net.v}"`, }, `{q} renders "net"`, }, @@ -203,7 +224,8 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) { "a violation on the second of two held heads": { map[string]string{ "cat": `{"format":"{calc(a + b, 0)} {w}","a":{"format":"{x}","x":["1","2"]},` + - `"b":{"format":"{y}","y":["3","4"]},"w":"{/cat.b}"}`, + `"b":{"format":"{y}","y":["3","4"]},"w":"{/hop}"}`, + "hop": `"{/cat.b}"`, }, `{w} renders "b"`, }, @@ -230,7 +252,8 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) { "a reference to a sibling the operand never renders": { "cat": `{"format":"{calc(net * 2, 2)} {unit}",` + `"net":{"format":"{v}","v":["1","2"],"spare":["kg","lb"]},` + - `"unit":"{/cat.net.spare}"}`, + `"unit":"{/hop}"}`, + "hop": `"{/cat.net.spare}"`, }, // Two operands drawing from one source are two names, so two draws: each is // held under its own name and shown once, and neither can disagree. @@ -247,7 +270,8 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) { }, // A fixed string cannot disagree with itself, so it needs no fence. "a literal operand named twice": { - "cat": `{"format":"{calc(n * 2, 0)} {/cat.n}","n":"5"}`, + "cat": `{"format":"{calc(n * 2, 0)} {/hop}","n":"5"}`, + "hop": `"{/cat.n}"`, }, // One node reached twice while walking the operand: the walk must not // revisit it, and the repeat is not a second route to anything. @@ -267,19 +291,28 @@ func TestAPathReachesEveryVariantItMightDraw(t *testing.T) { // all of them. Reaching only the first leaves whatever hides in a later // variant to be found at render — a cycle there is fatal, and a second route // to a held level disagrees silently. - rejected := map[string]struct{ file, want string }{ + rejected := map[string]struct { + files map[string]string + want string + }{ "a cycle in a later variant": { - `{"format":"{p.x}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{/cat}"}]}`, + map[string]string{ + "cat": `{"format":"{p.x}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{/hop}"}]}`, + "hop": `"{/cat}"`, + }, "reference cycle", }, "a second route in a later variant": { - `{"format":"{p.x} {q.y}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{/cat.q}"}],` + - `"q":{"format":"{y}","y":["1","2"]}}`, + map[string]string{ + "cat": `{"format":"{p.x} {q.y}","p":[{"format":"h","x":"safe"},{"format":"h","x":"{/hop}"}],` + + `"q":{"format":"{y}","y":["1","2"]}}`, + "hop": `"{/cat.q}"`, + }, "reads a path into", }, } for name, c := range rejected { - _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": c.file}))) + _, err := New(WithoutShippedData(), WithDataPath(writeData(t, c.files))) if err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("%s: New = %v, want it to mention %q", name, err, c.want) } @@ -289,15 +322,22 @@ func TestAPathReachesEveryVariantItMightDraw(t *testing.T) { func TestALevelAPathNeverRendersIsAccepted(t *testing.T) { // A path token does not expand its head's format, so a reference sitting in // that format is not a second route to anything: it is never rendered by the - // path at all. Both orders must load. - accepted := map[string]string{ - "reference in the head's own format": `{"format":"{p.first} {q.a}",` + - `"p":{"format":"{first} {/thing.q}","first":["A","B"]},"q":{"format":"{a}","a":["1","2"]}}`, - "the mirror shape": `{"format":"{p.first} {q.a}",` + - `"p":{"format":"{first}","first":["A","B"]},"q":{"format":"{a} {/thing.p}","a":["1","2"]}}`, + // path at all. Both orders must load. p and q sit one level down, so they are + // one column of thing rather than two that would read one another. + accepted := map[string]map[string]string{ + "reference in the head's own format": { + "thing": `{"format":"{inner}","inner":{"format":"{p.first} {q.a}",` + + `"p":{"format":"{first} {/hop}","first":["A","B"]},"q":{"format":"{a}","a":["1","2"]}}}`, + "hop": `"{/thing.inner.q}"`, + }, + "the mirror shape": { + "thing": `{"format":"{inner}","inner":{"format":"{p.first} {q.a}",` + + `"p":{"format":"{first}","first":["A","B"]},"q":{"format":"{a} {/hop}","a":["1","2"]}}}`, + "hop": `"{/thing.inner.p}"`, + }, } - for name, file := range accepted { - if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"thing": file}))); err != nil { + for name, files := range accepted { + if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, files))); err != nil { t.Errorf("%s: New = %v, want it accepted", name, err) } } @@ -673,7 +713,8 @@ func TestCycleThroughAPathTokenIsRejected(t *testing.T) { // must be caught at New. Reaching render would be fatal: the recursion never // terminates, and a stack overflow cannot be recovered. _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "a": `{"format":"{p.x}","p":{"format":"{x}","x":"{/a}"}}`, + "a": `{"format":"{p.x}","p":{"format":"{x}","x":"{/b}"}}`, + "b": `"{/a}"`, }))) if err == nil || !strings.Contains(err.Error(), "reference cycle") { t.Fatalf("New = %v, want the cycle through {p.x} rejected", err) diff --git a/record_test.go b/record_test.go index ee8b97f..30a938a 100644 --- a/record_test.go +++ b/record_test.go @@ -217,18 +217,14 @@ func TestRecordRejectsOverlappingReferenceColumns(t *testing.T) { {"as a builtin operand", `{"format":"","whole":"{uppercase(/cat.a)}","inner":"{/cat.a.b}"}`}, {"a bare reference beside a path", `{"format":"","whole":"{/cat}","inner":"{/cat.a.b}"}`}, } { - f := newGenerator(t, writeData(t, map[string]string{"cat": cat, "row": c.row}), WithSeed(1)) - _, err := f.FakeRecord("row") + _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": cat, "row": c.row}))) if err == nil || !strings.Contains(err.Error(), "reads a path into") { - t.Errorf("%s: FakeRecord = %v, want the overlap rejected the way one format is", c.name, err) + t.Errorf("%s: New = %v, want the overlap refused at load, the way one format is", c.name, err) continue } if !strings.Contains(err.Error(), `"whole"`) || !strings.Contains(err.Error(), `"inner"`) { t.Errorf("%s: error %q names neither column; it must name both", c.name, err) } - if _, err := f.Fake("row"); err != nil { - t.Errorf("%s: Fake(row) = %v, want the string view untouched", c.name, err) - } } } @@ -250,9 +246,9 @@ func TestRecordRejectsAColumnReadingItsOwnRecord(t *testing.T) { {"the record as an operand", `"up":"{uppercase(/person)}"`}, } { person := `{"format":"{first} {last}","first":["Ada","Bo"],"last":["Lovelace","Ek"],` + c.column + `}` - f := newGenerator(t, writeData(t, map[string]string{"person": person}), WithSeed(1)) - if _, err := f.FakeRecord("person"); err == nil || !strings.Contains(err.Error(), "points back at this record") { - t.Errorf("%s: FakeRecord = %v, want it refused; the column would contradict the columns beside it", c.name, err) + _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"person": person}))) + if err == nil || !strings.Contains(err.Error(), "names the category it sits in") { + t.Errorf("%s: New = %v, want it refused at load; the column would contradict the columns beside it", c.name, err) } } } diff --git a/reference_test.go b/reference_test.go index 62cedbf..8219fd9 100644 --- a/reference_test.go +++ b/reference_test.go @@ -83,27 +83,25 @@ func TestReferenceErrors(t *testing.T) { "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 reference that leads back to its own value never terminates at render, so + // New must reject the cycle up front (mutual or chained). One into its own + // category is refused before the cycle walk reaches it, as a unit rule. + "a category referencing itself": {"a": `"x{/a}"`}, + "mutual cycle": {"a": `"{/b}"`, "b": `"{/a}"`}, + "chain cycle": {"a": `"{/b}"`, "b": `"{/c}"`, "c": `"{/a}"`}, + // calc renders its operands, so a reference through one is caught too. + "a calc operand into its own category": {"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": { + // so what hides in one must fail at New rather than at render. + "an unrendered field into its own category": {"cat": `{"format":"hi","x":"{/cat.x}"}`}, + "two unrendered fields into their own category": { "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}"`}, + // The shipped layout puts categories in folders, so one level down is the + // common case, not an edge case. + "a category in a subfolder referencing itself": {"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}"}`}, @@ -131,17 +129,26 @@ func TestDotPrefixedDataEntriesAreSkipped(t *testing.T) { } } -// 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) +// TestReferenceIntoItsOwnCategoryIsRejected pins the unit a category is: a reference +// back into it describes a draw other than the fields beside it, whether the format +// renders that field or not, so it is refused at load rather than left to disagree in +// the record view. +func TestReferenceIntoItsOwnCategoryIsRejected(t *testing.T) { + for name, file := range map[string]string{ + "the category whole": `{"format":"hi","x":"see {/cat}"}`, + "a field of its own": `{"format":"hi","x":"{/cat.y}","y":"1"}`, + "a path the format reads": `{"format":"{/cat.y}","y":"1"}`, + } { + _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file}))) + if err == nil || !strings.Contains(err.Error(), "names the category it sits in") { + t.Errorf("%s: New = %v, want the reference into its own category refused", name, err) + } } - if got := fake(t, f, "cat.x"); got != "see hi" { - t.Fatalf("cat.x = %q, want \"see hi\"", got) + // Reading a sibling as a path is the spelling that stays. + if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ + "cat": `{"format":"{y.v}","y":{"format":"{v}","v":["1","2"]}}`, + }))); err != nil { + t.Errorf("New = %v, want the sibling path accepted", err) } } @@ -189,9 +196,9 @@ func TestNewErrorPathIsCanonical(t *testing.T) { want string }{ { - "cycle inside a choice arm", - map[string]string{"cat": `{"format":"hi","x":"{/cat.x}"}`}, - "fejkdata: reference cycle: cat.x -> /cat.x", + "cycle through another category", + map[string]string{"cat": `{"format":"hi","x":"{/hop}"}`, "hop": `"{/cat.x}"`}, + "fejkdata: reference cycle: cat.x -> /hop -> /cat.x", }, { "bad reference reached through another reference", @@ -255,21 +262,30 @@ func TestBareReferenceDrawsEachTime(t *testing.T) { } func TestReferenceOverlapIsRejected(t *testing.T) { - p := `[{"format":"{first}","first":"A","last":"1"},{"format":"{first}","first":"B","last":"2"}]` - for name, file := range map[string]string{ - "head beside a path": `{"format":"{/cat.p} {/cat.p.first}","p":` + p + `}`, - "sibling path beside a reference path": `{"format":"{p.first} {/cat.p.last}","p":` + p + `}`, - "sibling fields reading a level and a path into it": `{"format":"{a} {b}","a":"{/cat.p}","b":"{/cat.p.first}","p":` + p + `}`, - "a field rendering the level a nested reference reads into": `{"format":"{x} {p}","x":"{/cat.p.first}","p":` + p + `}`, - "a bare reference beside a path into what it never renders": `{"format":"{a} {b}","a":"{/other}","b":"{/other.p.first}"}`, + // A category never references itself, so the reads sit in a second category. + cat := `{"format":"x","p":[{"format":"{first}","first":"A","last":"1"},{"format":"{first}","first":"B","last":"2"}]}` + for name, row := range map[string]string{ + "head beside a path": `"{/cat.p} {/cat.p.first}"`, + "sibling fields reading a level and a path into it": `{"format":"{a} {b}","a":"{/cat.p}","b":"{/cat.p.first}"}`, + "a field rendering the level a nested reference reads into": `{"format":"{x} {p}","x":"{/cat.p.first}","p":"{/cat.p}"}`, + "a bare reference beside a path into what it never renders": `{"format":"{a} {b}","a":"{/cat}","b":"{/cat.p.first}"}`, } { - _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": file, "other": `{"format":"x","p":` + p + `}`}))) + _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": cat, "row": row}))) if err == nil || !strings.Contains(err.Error(), "reads a path into") { t.Errorf("%s: New = %v, want the overlap rejected", name, err) } } + // A sibling path and a reference into the level it holds are the same overlap, + // and the reference reaches it from a category row renders. if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ - "cat": `{"format":"{a} {b}","a":{"format":"{/cat.p}","drawGroup":"g"},"b":"{/cat.p.first}","p":` + p + `}`, + "row": `{"format":"{p.first} {/hop}","p":[{"format":"{first}","first":"A","last":"1"},{"format":"{first}","first":"B","last":"2"}]}`, + "hop": `"{/row.p.last}"`, + }))); err == nil || !strings.Contains(err.Error(), "reads a path into") { + t.Errorf("New = %v, want a sibling path beside a reference into it rejected", err) + } + if _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ + "cat": cat, + "row": `{"format":"{a} {b}","a":{"format":"{/cat.p}","drawGroup":"g"},"b":"{/cat.p.first}"}`, }))); err != nil { t.Errorf("New = %v, want a level and a path into it accepted in groups of their own", err) }