Read one value when a bound path is read twice

This commit is contained in:
M
2026-08-28 15:06:10 +02:00
committed by lilleman-tw
parent 12100239f5
commit a25b42ace2
3 changed files with 62 additions and 20 deletions
+26
View File
@@ -176,6 +176,32 @@ func TestDottedTokenErrors(t *testing.T) {
} }
} }
func TestSamePathReadTwiceReadsOneValue(t *testing.T) {
// A path is drawn once per expansion, so reading it twice reads one value —
// what a name shown in a display form and again in an address needs.
f := engine(7)
for i := 0; i < 300; i++ {
got := mustRender(t, f, `{"format":"{p.first}|{p.first}",
"p":[{"format":"{first}","first":["Anna","Astrid","Elin","Karin"]}]}`)
parts := strings.Split(got, "|")
if parts[0] != parts[1] {
t.Fatalf("draw %d = %q, want one value read twice", i, got)
}
}
}
func TestDifferentTailsUnderOneHeadShareTheRow(t *testing.T) {
// Two tails of one head stay in the same variant, each keeping its own value.
f := engine(8)
for i := 0; i < 300; i++ {
got := mustRender(t, f, `{"format":"{p.a}{p.b}",
"p":[{"format":"x","a":"A","b":"1"},{"format":"y","a":"B","b":"2"}]}`)
if got != "A1" && got != "B2" {
t.Fatalf("draw %d = %q, want a row's own pair", i, got)
}
}
}
func TestBoundPathIsReachableByFake(t *testing.T) { func TestBoundPathIsReachableByFake(t *testing.T) {
// Binding changes how a format reads a sibling, not what List and Fake offer: // Binding changes how a format reads a sibling, not what List and Fake offer:
// the sub-fields stay addressable on their own. // the sub-fields stay addressable on their own.
+31 -16
View File
@@ -139,9 +139,12 @@ func expand(s *session, t *template) string {
b.Grow(t.grow) b.Grow(t.grow)
// One draw per bound head, held for this expansion only: a nested template and // One draw per bound head, held for this expansion only: a nested template and
// each repeat iteration get their own, since each is its own expansion. // each repeat iteration get their own, since each is its own expansion.
var bound map[string]node var bound *draws
if len(t.bound) > 0 { if len(t.bound) > 0 {
bound = make(map[string]node, len(t.bound)) bound = &draws{
variant: make(map[string]node, len(t.bound)),
value: make(map[string]string, len(t.bound)),
}
} }
for i := range t.ops { for i := range t.ops {
o := &t.ops[i] o := &t.ops[i]
@@ -159,30 +162,42 @@ func expand(s *session, t *template) string {
return b.String() return b.String()
} }
// draws is what an expansion has already drawn for its bound heads: the variant
// each head was drawn as, so every path under it reads one row, and the value each
// path read, so the same path read twice reads one value.
type draws struct {
variant map[string]node
value map[string]string
}
// resolve renders one field alternation: the '|' alternatives, one picked at // resolve renders one field alternation: the '|' alternatives, one picked at
// random. An arm's key is a sibling field or a {..path} reference, which linkRefs // random. An arm's key is a sibling field or a {..path} reference, which linkRefs
// bound into fields too. A head the format addresses by dotted path is drawn once // bound into fields too. A field the format addresses by dotted path is bound: it
// and held in bound, so {place.postal-code} and {place.locality} read one row; // is drawn once for the expansion, so {place.postal-code} and {place.locality}
// the arm's tail then walks into that draw. checkTokens, checkPath and linkRefs // read one row and either read twice gives one value. checkTokens, checkPath and
// prove every step, so this cannot fail. // linkRefs prove every step, so this cannot fail.
func resolve(s *session, arms []arm, t *template, bound map[string]node) string { func resolve(s *session, arms []arm, t *template, bound *draws) string {
a := arms[s.IntN(len(arms))] a := arms[s.IntN(len(arms))]
n := t.fields[a.key] if !t.bound[a.key] {
if t.bound[a.key] { return render(s, t.fields[a.key])
held, drew := bound[a.key]
if !drew {
held = drawn(s, n)
bound[a.key] = held
} }
n = held if v, read := bound.value[a.name]; read {
return v
}
n, drew := bound.variant[a.key]
if !drew {
n = drawn(s, t.fields[a.key])
bound.variant[a.key] = n
} }
if len(a.tail) > 0 { if len(a.tail) > 0 {
var err error var err error
if n, err = descend(s, n, a.tail); err != nil { if n, err = descend(s, n, a.tail); err != nil {
panic(fmt.Sprintf("fakes: %s: %v", strings.Join(append([]string{a.key}, a.tail...), "."), err)) panic(fmt.Sprintf("fakes: %s: %v", a.name, err))
} }
} }
return render(s, n) v := render(s, n)
bound.value[a.name] = v
return v
} }
// drawn resolves a choice to one variant, so a bound head is a concrete node the // drawn resolves a choice to one variant, so a bound head is a concrete node the
+4 -3
View File
@@ -177,6 +177,7 @@ func fieldTokens(format string) []string {
// bound under) and the tail of a dotted path into it. A non-empty tail is what makes // bound under) and the tail of a dotted path into it. A non-empty tail is what makes
// the arm a bound draw: its head is drawn once per expansion (see boundHeads). // the arm a bound draw: its head is drawn once per expansion (see boundHeads).
type arm struct { type arm struct {
name string // as written, and the key a bound draw's value is held under
key string key string
tail []string tail []string
} }
@@ -185,13 +186,13 @@ type arm struct {
// dots — linkRefs binds it whole — so only a sibling name reads as a path. // dots — linkRefs binds it whole — so only a sibling name reads as a path.
func splitArm(name string) arm { func splitArm(name string) arm {
if isRef(name) { if isRef(name) {
return arm{key: name} return arm{name: name, key: name}
} }
head, tail, dotted := strings.Cut(name, ".") head, tail, dotted := strings.Cut(name, ".")
if !dotted { if !dotted {
return arm{key: name} return arm{name: name, key: name}
} }
return arm{key: head, tail: strings.Split(tail, ".")} return arm{name: name, key: head, tail: strings.Split(tail, ".")}
} }
// splitArms splits a token body's '|' alternatives. // splitArms splits a token body's '|' alternatives.