Compare commits

..

2 Commits

Author SHA1 Message Date
lilleman 1d4a0f673f Identify a fence alternative by the set of rows a walk entered, walk only the cell a read pins, and replay reads pairwise
Tests / vet + fmt + tests (pull_request) Successful in 1m8s
Tests / Gitea release from CHANGELOG.md (pull_request) Has been skipped
2026-09-17 16:04:02 +02:00
lilleman 60b9922c7e Test rows of a table reached through a cell as alternatives, a selected row's cell walked alone, and two tables' cells in one render refused 2026-09-17 16:02:32 +02:00
3 changed files with 104 additions and 62 deletions
+63 -19
View File
@@ -271,23 +271,54 @@ type drawWalk struct {
} }
// drawAt is where a walk stands: the draw group it draws in, how the render's root reached it, and // drawAt is where a walk stands: the draw group it draws in, how the render's root reached it, and
// the table row it entered, if any. // the table rows it entered.
type drawAt struct { type drawAt struct {
group string group string
route drawRoute route drawRoute
alt rowAlt alt rowSet
} }
// rowAlt is one row of a table as an alternative: only one row renders, so reads in two rows of // rowSet is the rows a walk entered, one per table: only one row of a table renders, so reads in
// one table never meet, while reads in one row, across its columns and whatever they reach, do. // two rows of one table never meet, while reads in one row, across its columns and whatever they
type rowAlt struct { // reach, do.
t *table type rowSet []tablePin
row int
func (s rowSet) rowOf(t *table) (int, bool) {
for _, p := range s {
if p.t == t {
return p.row, true
}
}
return 0, false
}
// enter is s with row r of t, where t is not in it yet; the set is copied, since walks branch.
func (s rowSet) enter(t *table, r int) rowSet {
if _, in := s.rowOf(t); in {
return s
}
out := append(append(make(rowSet, 0, len(s)+1), s...), tablePin{t, r})
sort.Slice(out, func(i, j int) bool { return out[i].t.category < out[j].t.category })
return out
}
// key spells the set for a map, by the tables' identities.
func (s rowSet) key() string {
var b strings.Builder
for _, p := range s {
fmt.Fprintf(&b, "%p[%d]", p.t, p.row)
}
return b.String()
} }
// alternatives reports whether two reads sit in different rows of one table. // alternatives reports whether two reads sit in different rows of one table.
func alternatives(a, b drawAt) bool { func alternatives(a, b drawAt) bool {
return a.alt.t != nil && a.alt.t == b.alt.t && a.alt.row != b.alt.row for _, p := range a.alt {
if r, in := b.alt.rowOf(p.t); in && r != p.row {
return true
}
}
return false
} }
// drawRoute is how a render reaches a draw: as its author spells it, and the root edge's label. // drawRoute is how a render reaches a draw: as its author spells it, and the root edge's label.
@@ -303,13 +334,13 @@ type pathRead struct {
type drawVisit struct { type drawVisit struct {
n node n node
group string group string
alt rowAlt alt string
} }
type drawKey struct { type drawKey struct {
group string group string
path string path string
alt rowAlt alt string
} }
func newDrawWalk() *drawWalk { func newDrawWalk() *drawWalk {
@@ -319,7 +350,7 @@ func newDrawWalk() *drawWalk {
// walk follows what rendering n renders. A repeat renders over draws of its own, so the walk stops // walk follows what rendering n renders. A repeat renders over draws of its own, so the walk stops
// there. // there.
func (w *drawWalk) walk(n node, at drawAt) { func (w *drawWalk) walk(n node, at drawAt) {
v := drawVisit{n, at.group, at.alt} v := drawVisit{n, at.group, at.alt.key()}
if w.seen[v] { if w.seen[v] {
return return
} }
@@ -330,22 +361,35 @@ func (w *drawWalk) walk(n node, at drawAt) {
if t, isTemplate := n.(*template); isTemplate && t.drawGroupKey != "" { if t, isTemplate := n.(*template); isTemplate && t.drawGroupKey != "" {
at.group = t.drawGroupKey at.group = t.drawGroupKey
} }
_, isColumn := n.(*column)
for _, e := range renderEdges(n) { for _, e := range renderEdges(n) {
// The outermost row is kept: a cell reached through another row's cell renders with it. if cell, isCell := e.to.(*template); isCell && cell.cellOf != nil {
edgeAt := at // A row already entered renders only its own cell of this column.
if cell, _ := e.to.(*template); isColumn && at.alt.t == nil { if r, in := at.alt.rowOf(cell.cellOf); in && r != cell.cellRow {
edgeAt.alt = rowAlt{cell.cellOf, cell.cellRow} continue
}
w.edge(n, e, drawAt{at.group, at.route, at.alt.enter(cell.cellOf, cell.cellRow)})
continue
} }
w.edge(n, e, edgeAt) w.edge(n, e, at)
} }
} }
func (w *drawWalk) edge(from node, e renderEdge, at drawAt) { func (w *drawWalk) edge(from node, e renderEdge, at drawAt) {
var tr *tableRead
if a, reads := refRead(from, e.label); reads { if a, reads := refRead(from, e.label); reads {
if k := (drawKey{at.group, a.path, at.alt}); !w.read[k] { tr = tableReadOf(from.(*template).fields[a.key], a, e.to)
if k := (drawKey{at.group, a.path, at.alt.key()}); !w.read[k] {
w.read[k] = true w.read[k] = true
w.reads = append(w.reads, pathRead{at, a, tableReadOf(from.(*template).fields[a.key], a, e.to)}) w.reads = append(w.reads, pathRead{at, a, tr})
}
}
// A read that pins the column's table renders that row's cell alone.
if c, isColumn := e.to.(*column); isColumn && tr != nil {
if r, pinned := tr.pins.pinned(c.t); pinned {
if cell := c.t.cellNode(r, c.i); cell != nil {
w.walk(cell, drawAt{at.group, at.route, at.alt.enter(c.t, r)})
}
return
} }
} }
w.walk(e.to, at) w.walk(e.to, at)
+17 -39
View File
@@ -240,57 +240,35 @@ func checkFamilies(reads []pathRead) error {
} }
} }
} }
return replayAlternatives(reads) return replayPairs(reads)
} }
// replayAlternatives replays the reads of each draw group into one draws per row // replayPairs replays every two reads of one draw group that can render together into
// alternative — the reads outside any row, then the row's own — so two rows of one // one draws, the earlier read first. A pin conflicts with one earlier pin, never with a
// table are never replayed together. // combination, so pairs find every conflict a full replay would.
func replayAlternatives(reads []pathRead) error { func replayPairs(reads []pathRead) error {
type alt struct { for i, r := range reads {
group string
row rowAlt
}
var alts []alt
outside, inside := map[string][]pathRead{}, map[alt][]pathRead{}
for _, r := range reads {
if r.tr == nil { if r.tr == nil {
continue continue
} }
if r.at.alt.t == nil { for _, o := range reads[i+1:] {
outside[r.at.group] = append(outside[r.at.group], r) if o.tr == nil || o.at.group != r.at.group || alternatives(r.at, o.at) {
continue continue
} }
a := alt{r.at.group, r.at.alt} var d draws
if inside[a] == nil {
alts = append(alts, a)
}
inside[a] = append(inside[a], r)
}
for group := range outside {
alts = append(alts, alt{group, rowAlt{}})
}
sort.Slice(alts, func(i, j int) bool {
return altOrder(alts[i].group, alts[i].row) < altOrder(alts[j].group, alts[j].row)
})
for _, a := range alts {
var d draws
for _, r := range append(append([]pathRead(nil), outside[a.group]...), inside[a]...) {
if err := r.tr.replay(&d); err != nil { if err := r.tr.replay(&d); err != nil {
return fmt.Errorf("%s: %w; select the same rows in every path into the family, or draw them apart with a drawGroup", r.at.route.spelled(r.a.name), err) return conflict(r, err)
}
if err := o.tr.replay(&d); err != nil {
return conflict(o, err)
} }
} }
} }
return nil return nil
} }
// altOrder sorts alternatives by group, the reads outside any row first, then by table and row, func conflict(r pathRead, err error) error {
// so which conflict is reported does not vary. return fmt.Errorf("%s: %w; select the same rows in every path into the family, or draw them apart with a drawGroup", r.at.route.spelled(r.a.name), err)
func altOrder(group string, r rowAlt) string {
if r.t == nil {
return group + "\x00"
}
return fmt.Sprintf("%s\x01%s\x00%08d", group, r.t.category, r.row)
} }
// replay pins the read's rows into d, where they agree with the rows pinned before. // replay pins the read's rows into d, where they agree with the rows pinned before.
+24 -4
View File
@@ -523,12 +523,12 @@ func TestTableInAStructTag(t *testing.T) {
} }
} }
// The cells of one column are alternatives, as a choice's items are: only one row func TestTableRowsAreAlternatives(t *testing.T) {
// renders, so two cells selecting different rows of another table never meet.
func TestColumnCellsAreAlternatives(t *testing.T) {
files := map[string]string{ files := map[string]string{
"cur.json": `{"format":"{code}","rows":"cur.tsv","key":"code"}`, "cur.json": `{"format":"{code}","rows":"cur.tsv","key":"code"}`,
"cur.tsv": "code\tsym\nEUR\t€\nSEK\tkr\n", "cur.tsv": "code\tsym\nEUR\t€\nSEK\tkr\nUSD\t$\n",
"y.json": `{"format":"{sym}","rows":"y.tsv","key":"k"}`,
"y.tsv": "k\tsym\n1\t{/cur[SEK].sym}\n2\t{/cur[USD].sym}\n",
"country.json": `{"format":"{name} {money}","rows":"country.tsv","key":"alpha2"}`, "country.json": `{"format":"{name} {money}","rows":"country.tsv","key":"alpha2"}`,
"country.tsv": "alpha2\tname\tmoney\nFI\tFinland\t{/cur[EUR].sym}\nSE\tSweden\t{/cur[SEK].sym}\n", "country.tsv": "alpha2\tname\tmoney\nFI\tFinland\t{/cur[EUR].sym}\nSE\tSweden\t{/cur[SEK].sym}\n",
} }
@@ -539,8 +539,28 @@ func TestColumnCellsAreAlternatives(t *testing.T) {
if v := fake(t, f, "country[FI].money"); v != "€" { if v := fake(t, f, "country[FI].money"); v != "€" {
t.Fatalf("country[FI].money = %q", v) t.Fatalf("country[FI].money = %q", v)
} }
accepted := map[string]map[string]string{
"a cell reaching a table whose rows are alternatives": {"country.tsv": "alpha2\tname\tmoney\nFI\tFinland\t{/y.sym}\nSE\tSweden\t{/cur[SEK].sym}\n"},
"a cell agreeing with the row it selects": {"country.tsv": "alpha2\tname\tmoney\nFI\tFinland\t{/y[1].sym} {/cur[SEK].sym}\nSE\tSweden\t{/cur[SEK].sym}\n"},
"a selected row's cell beside the row it agrees with": {"z.json": `"{/country[SE].money} {/cur[SEK].sym}"`},
}
for name, more := range accepted {
g, err := New(WithoutShippedData(), WithDataPath(writeFiles(t, with(files, more))), WithSeed(1))
if err != nil {
t.Errorf("%s: New = %v, want it accepted", name, err)
continue
}
for i := 0; i < 50; i++ {
for _, path := range []string{"country", "country[FI]"} {
if v := fake(t, g, path); strings.Contains(v, "€") && strings.Contains(v, "$") || strings.Contains(v, "kr") && strings.Contains(v, "$") {
t.Fatalf("%s: %s = %q, want one currency per row", name, path, v)
}
}
}
}
rejected := map[string]map[string]string{ rejected := map[string]map[string]string{
"one cell selecting two rows": {"country.tsv": "alpha2\tname\tmoney\nFI\tFinland\t{/cur[EUR].sym}{/cur[SEK].sym}\nSE\tSweden\t{/cur[SEK].sym}\n"}, "one cell selecting two rows": {"country.tsv": "alpha2\tname\tmoney\nFI\tFinland\t{/cur[EUR].sym}{/cur[SEK].sym}\nSE\tSweden\t{/cur[SEK].sym}\n"},
"two tables' cells in one render": {"z.json": `"{/country.money} {/y.sym}"`},
"the format beside a cell": {"country.json": `{"format":"{money} {/cur[SEK].sym}","rows":"country.tsv","key":"alpha2"}`}, "the format beside a cell": {"country.json": `{"format":"{money} {/cur[SEK].sym}","rows":"country.tsv","key":"alpha2"}`},
"two columns of one row": {"country.json": `{"format":"{a} {b}","rows":"country.tsv","key":"alpha2"}`, "country.tsv": "alpha2\ta\tb\nFI\t{/cur[SEK].sym}\t{/cur[EUR].sym}\nSE\t{/cur[EUR].sym}\t{/cur[SEK].sym}\n"}, "two columns of one row": {"country.json": `{"format":"{a} {b}","rows":"country.tsv","key":"alpha2"}`, "country.tsv": "alpha2\ta\tb\nFI\t{/cur[SEK].sym}\t{/cur[EUR].sym}\nSE\t{/cur[EUR].sym}\t{/cur[SEK].sym}\n"},
"a whole read beside a path": {"country.json": `{"format":"{a} {b}","rows":"country.tsv","key":"alpha2"}`, "country.tsv": "alpha2\ta\tb\nFI\t{/cur}\t{/cur[EUR].sym}\nSE\t{/cur}\t{/cur[SEK].sym}\n"}, "a whole read beside a path": {"country.json": `{"format":"{a} {b}","rows":"country.tsv","key":"alpha2"}`, "country.tsv": "alpha2\ta\tb\nFI\t{/cur}\t{/cur[EUR].sym}\nSE\t{/cur}\t{/cur[SEK].sym}\n"},