Fact box narrowing: answer the second review (GPT-6 Astra)
Astra's second pass found the spec faithful to Paul's ruling and no
regression in authorization or model labels. It asked for stronger
guards, and flagged one placement bug.
Placement: the narrowing line rendered after every row, so in a
two-kind box it sat under the wrong kind. The note now travels on its
row (Fact.Note, replacing Page.FactNotes) and renders directly under
it. The row divider still draws after a note.
Guards: gate 40 asserts the narrowed row's whole tuple and the note's
every field, and gains fixtures for:
- a short and an over-long matching label (two matches);
- a value over-long-labelled in two documents (one candidate);
- an authored label identical to its heading;
- five candidates;
- email as a second kind.
The page test checks the narrowed row's citation. A new page test pins
the note between its own row and the next kind's. Fact tests add the
mid-line hyphen ("Never use - Office"), the + marker, and generic
query words.
Astra's twelve surviving mutations and two placement mutations: all
fourteen built and red.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+182
−34
internal/memoria/fact/fact_test.go+13 −0internal/memoria/handlers.go+0 −3internal/memoria/narrow_test.go+120 −18internal/memoria/static/memoria.css+2 −1internal/memoria/store.go+10 −7internal/memoria/templates/results.html+6 −5internal/memoriatest/factbox_test.go+31 −0
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ? amadan.net/oficina/memoria/cmd/memoria [no test files] ok amadan.net/oficina/memoria/internal/memoria 3.445s ok amadan.net/oficina/memoria/internal/memoria/chunk 0.007s ok amadan.net/oficina/memoria/internal/memoria/fact 0.533s ? amadan.net/oficina/memoria/internal/memoria/icons [no test files] ok amadan.net/oficina/memoria/internal/memoria/index 0.036s ok amadan.net/oficina/memoria/internal/memoria/notion 0.022s ok amadan.net/oficina/memoria/internal/memoria/provider 1.132s ok amadan.net/oficina/memoria/internal/memoria/query 0.004s ok amadan.net/oficina/memoria/internal/memoria/seal 0.005s ok amadan.net/oficina/memoria/internal/memoria/sibling 3.021s ok amadan.net/oficina/memoria/internal/memoriatest 1.692s ? amadan.net/oficina/memoria/scripts/writeestilo [no test files] ? amadan.net/oficina/memoria/tools/bench [no test files] ? amadan.net/oficina/memoria/tools/drive [no test files] ? amadan.net/oficina/memoria/tools/seed [no test files] ? amadan.net/oficina/memoria/tools/vendorassets [no test files] === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/internal/memoria/fact/fact_test.go b/internal/memoria/fact/fact_test.go| index 815d581..a24a637 100644 |
| --- a/internal/memoria/fact/fact_test.go |
| +++ b/internal/memoria/fact/fact_test.go |
| @@ -691,6 +691,7 @@ func TestWordBounded(t *testing.T) { |
| // - single-character tokens kept → `["s" "tito"]` |
| // - "for" dropped from the stopwords → `["for" "dublin" "office"]` |
| // - repeats kept → `["office" "office"]` |
| +// - generic words kept (Astra's) → `["office" "numbers"]` |
| func TestQualifiersOnRealisticQueries(t *testing.T) { |
| for q, want := range map[string][]string{ |
| "what is the office phone number?": {"office"}, |
| @@ -702,6 +703,9 @@ func TestQualifiersOnRealisticQueries(t *testing.T) { |
| "support email": {"support"}, |
| "phone number": nil, |
| "office office phone": {"office"}, |
| + "office phone numbers": {"office"}, |
| + "office phone no": {"office"}, |
| + "office phone nr": {"office"}, |
| } { |
| if got := Qualifiers(q); !reflect.DeepEqual(got, want) { |
| t.Errorf("Qualifiers(%q) = %q, want %q", q, got, want) |
| @@ -736,6 +740,10 @@ func TestLabelMatchesWholeTokensAndEveryQualifier(t *testing.T) { |
| // `Scan("- Office: …") label = "- Office"`. A guard for "only when |
| // something follows the marker" was also mutated away and stayed green: |
| // no input can reach it, so it was deleted rather than kept. |
| +// |
| +// Astra's, 2026-09-29, both red now: the pattern's ^ anchor removed → |
| +// "Never use - Office" loses its warning and becomes "Office"; the + |
| +// marker dropped → "+ Office". |
| func TestListMarkersAreNotPartOfTheLabel(t *testing.T) { |
| for _, c := range []struct { |
| text, label string |
| @@ -744,6 +752,11 @@ func TestListMarkersAreNotPartOfTheLabel(t *testing.T) { |
| {"- Office: +353 (0)1 547 7854", "Office", true}, |
| {" - Office: +353 (0)1 547 7854", "Office", true}, |
| {"* Office: +353 (0)1 547 7854", "Office", true}, |
| + {"+ Office: +353 (0)1 547 7854", "Office", true}, |
| + // A hyphen INSIDE a label is the document's words, not a marker: |
| + // an unanchored pattern would delete the warning and leave |
| + // "Office". |
| + {"Never use - Office: +353 (0)1 547 7854", "Never use - Office", true}, |
| {"• Office: +353 (0)1 547 7854", "Office", true}, |
| {"- [x] Office: +353 (0)1 547 7854", "Office", true}, |
| {"- [ ] Office: +353 (0)1 547 7854", "Office", true}, |
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go| index 8d82ea0..bc9e64f 100644 |
| --- a/internal/memoria/handlers.go |
| +++ b/internal/memoria/handlers.go |
| @@ -170,8 +170,6 @@ type page struct { |
| // never "no phone number found". The corpus is not the world, and a |
| // confident absence is this codebase's worst failure shape. |
| Facts []Fact |
| - // FactNotes say a kind was narrowed by the reader's words (§8.8). |
| - FactNotes []FactNote |
| // SearchFailed is not "no results". A search that errored renders |
| // its own callout in the results region, because "nothing matched" |
| @@ -665,7 +663,6 @@ func (a *app) runSearch(r *http.Request, p *page) { |
| // Primed is decided here: the page and the fragment must not be able |
| // to disagree about whether there is a box. |
| p.Facts = found.Facts |
| - p.FactNotes = found.FactNotes |
| p.Primed = query.PrimesFirstResult(p.Query, len(found.Results)) |
| } |
diff --git a/internal/memoria/narrow_test.go b/internal/memoria/narrow_test.go| index 3ccfb13..b1dcf58 100644 |
| --- a/internal/memoria/narrow_test.go |
| +++ b/internal/memoria/narrow_test.go |
| @@ -61,25 +61,53 @@ const officePage = "## Office Address\n\n" + |
| // - a matching over-long label not silencing the kind → its case |
| // - Others counted as len(cs) → "want one with 3 others" |
| // |
| +// SECOND ROUND, 2026-09-29: GPT-6 Astra's own mutations, twelve of |
| +// which passed the gate above. Each is red now, built and reverted: |
| +// - the narrowed row rebuilt from {Kind, Span, Label} → "row = … |
| +// Title: Trail: … DocumentID:0" |
| +// - an over-long label reported Own=false → "a short and an over-long |
| +// matching label are two matches" |
| +// - a NoBox occurrence not marked seen → "a repeated over-long value |
| +// is one candidate" |
| +// - Own inferred from the label differing from trail and title → |
| +// "an authored label identical to its heading matches" |
| +// - narrowing for phone only → "another kind narrows too" |
| +// - every note's Kind "phone" → the same case, on the note |
| +// - only the first qualifier kept → the Dublin case's note |
| +// - Others hardcoded to 3 → "five candidates leave four others" |
| +// |
| // Not mutated, and why: the counts' after-the-ruling property is |
| // structural — found is built from p.Results and nothing else — and the |
| // two fixtures for it are the ones that would see a change of source. |
| func TestGate40NarrowingPicksExactlyOneOverTheCap(t *testing.T) { |
| roster := docsRoster() |
| visible := func() Custody { return &perItemCustody{apps: roster} } |
| - one := func(t *testing.T, p Page, want string, others int) { |
| + // one asserts a narrowed box: exactly one row, with the value |
| + // wanted, and its note in full — kind, every matched word, the count. |
| + one := func(t *testing.T, p Page, want string, note FactNote) { |
| t.Helper() |
| if got := spans(p); !reflect.DeepEqual(got, []string{want}) { |
| t.Fatalf("spans = %q, want [%s]", got, want) |
| } |
| - if len(p.FactNotes) != 1 || p.FactNotes[0].Others != others { |
| - t.Errorf("notes = %+v, want one with %d others", p.FactNotes, others) |
| + if p.Facts[0].Note == nil || !reflect.DeepEqual(*p.Facts[0].Note, note) { |
| + t.Errorf("note = %+v, want %+v", p.Facts[0].Note, note) |
| } |
| } |
| + phone := func(others int, matched ...string) FactNote { |
| + return FactNote{Kind: "phone", Matched: matched, Others: others} |
| + } |
| none := func(t *testing.T, p Page) { |
| t.Helper() |
| - if len(p.Facts) != 0 || len(p.FactNotes) != 0 { |
| - t.Errorf("facts = %q, notes = %+v; want no box", spans(p), p.FactNotes) |
| + if len(p.Facts) != 0 { |
| + t.Errorf("facts = %q, want no box", spans(p)) |
| + } |
| + } |
| + noNotes := func(t *testing.T, p Page) { |
| + t.Helper() |
| + for _, f := range p.Facts { |
| + if f.Note != nil { |
| + t.Errorf("row %q carries a note %+v; nothing was narrowed", f.Span, *f.Note) |
| + } |
| } |
| } |
| page := func(t *testing.T, body string) *Store { |
| @@ -93,12 +121,20 @@ func TestGate40NarrowingPicksExactlyOneOverTheCap(t *testing.T) { |
| t.Run("the motivating page", func(t *testing.T) { |
| s := factCorpus(t, visible(), [][3]string{{"o", "Office", officePage}}) |
| p := searchFacts(t, s, "what is the office phone number?") |
| - one(t, p, "+353 (0)1 547 7854", 3) |
| - if p.Facts[0].Label != "The office phone number is" { |
| - t.Errorf("label = %q, want the document's own, without the bullet", p.Facts[0].Label) |
| + one(t, p, "+353 (0)1 547 7854", phone(3, "office")) |
| + // The whole row, citation included: a narrowed row rebuilt from |
| + // the value alone would lose where it came from. |
| + if len(p.Results) != 1 { |
| + t.Fatalf("%d results, want the one passage", len(p.Results)) |
| + } |
| + r := p.Results[0] |
| + want := Fact{ |
| + Kind: "phone", Title: "Office", Trail: "Office Address", |
| + Label: "The office phone number is", Span: "+353 (0)1 547 7854", |
| + DocumentID: r.DocumentID, Ordinal: r.Ordinal, Note: p.Facts[0].Note, |
| } |
| - if got := p.FactNotes[0].Matched; !reflect.DeepEqual(got, []string{"office"}) { |
| - t.Errorf("matched = %q, want [office]", got) |
| + if !reflect.DeepEqual(p.Facts[0], want) { |
| + t.Errorf("row = %+v, want %+v", p.Facts[0], want) |
| } |
| none(t, searchFacts(t, s, "phone number")) |
| }) |
| @@ -115,21 +151,22 @@ func TestGate40NarrowingPicksExactlyOneOverTheCap(t *testing.T) { |
| t.Run("under the cap nothing changes", func(t *testing.T) { |
| s := page(t, "## Office\n\nOffice phone: +353 (0)1 547 0001\n\nReception: +353 (0)1 547 0002\n\nSales: +353 (0)1 547 0003") |
| p := searchFacts(t, s, "office phone") |
| - if len(p.Facts) != 3 || len(p.FactNotes) != 0 { |
| - t.Errorf("facts = %q, notes = %+v; want all three and no note", spans(p), p.FactNotes) |
| + if len(p.Facts) != 3 { |
| + t.Errorf("facts = %q, want all three", spans(p)) |
| } |
| + noNotes(t, p) |
| }) |
| t.Run("whole tokens, not substrings", func(t *testing.T) { |
| s := page(t, "## Office\n\nOffice phone: +353 (0)1 547 0001\n\nOffices phone: +353 (0)1 547 0002\n\n"+ |
| "Backoffice line: +353 (0)1 547 0003\n\nReception: +353 (0)1 547 0004") |
| - one(t, searchFacts(t, s, "office phone"), "+353 (0)1 547 0001", 3) |
| + one(t, searchFacts(t, s, "office phone"), "+353 (0)1 547 0001", phone(3, "office")) |
| }) |
| t.Run("every qualifier: the one label with both", func(t *testing.T) { |
| s := page(t, "## Dublin office\n\nDublin office phone: +353 (0)1 547 0001\n\nOffice phone: +353 (0)1 547 0002\n\n"+ |
| "Dublin reception: +353 (0)1 547 0003\n\nSales: +353 (0)1 547 0004") |
| - one(t, searchFacts(t, s, "Dublin office phone"), "+353 (0)1 547 0001", 3) |
| + one(t, searchFacts(t, s, "Dublin office phone"), "+353 (0)1 547 0001", phone(3, "dublin", "office")) |
| }) |
| t.Run("every qualifier: no label with both", func(t *testing.T) { |
| s := page(t, "## Dublin office\n\nOffice phone: +353 (0)1 547 0001\n\nReception: +353 (0)1 547 0002\n\n"+ |
| @@ -152,7 +189,52 @@ func TestGate40NarrowingPicksExactlyOneOverTheCap(t *testing.T) { |
| t.Run("an over-long label that does not match leaves the match", func(t *testing.T) { |
| s := page(t, "## Office\n\nOffice phone: +353 (0)1 547 0001\n\nThe number you must never give to anyone at all "+long+": +353 (0)1 547 0002\n\n"+ |
| "Sales: +353 (0)1 547 0003\n\nFax: +353 (0)1 547 0004") |
| - one(t, searchFacts(t, s, "office phone"), "+353 (0)1 547 0001", 3) |
| + one(t, searchFacts(t, s, "office phone"), "+353 (0)1 547 0001", phone(3, "office")) |
| + }) |
| + |
| + // A short matching label and an over-long one that also matches are |
| + // TWO matches, so nothing renders. Reporting an over-long label as |
| + // not the document's own (Own=false) leaves the short one alone, and |
| + // renders it. |
| + t.Run("a short and an over-long matching label are two matches", func(t *testing.T) { |
| + s := page(t, "## Office\n\nOffice phone: +353 (0)1 547 0001\n\nThe office number you must never give to anyone "+long+": +353 (0)1 547 0002\n\n"+ |
| + "Sales: +353 (0)1 547 0003\n\nFax: +353 (0)1 547 0004") |
| + none(t, searchFacts(t, s, "office phone")) |
| + }) |
| + |
| + // One value, over-long-labelled in two documents, is ONE candidate: |
| + // three in all, under the cap, and that candidate silences the kind. |
| + // Counted twice it makes four, narrowing runs, and the match renders. |
| + t.Run("a repeated over-long value is one candidate", func(t *testing.T) { |
| + s := factCorpus(t, visible(), [][3]string{ |
| + {"a", "Dublin", "## Dublin office\n\nOffice phone: +353 (0)1 547 0001"}, |
| + {"b", "Cork", "## Cork office phone\n\nThe number you must never give to anyone at all " + long + ": +353 (0)1 547 0002"}, |
| + {"c", "Sligo", "## Sligo office phone\n\nThe number you must never give to anyone at all " + long + ": +353 (0)1 547 0002"}, |
| + {"d", "Galway", "## Galway office\n\nSales phone: +353 (0)1 547 0003"}, |
| + }) |
| + none(t, searchFacts(t, s, "office phone")) |
| + }) |
| + |
| + // A document can write a label identical to its own heading, and it |
| + // is still the document's label. Provenance inferred by comparing |
| + // the label with the heading gets this wrong. |
| + t.Run("an authored label identical to its heading matches", func(t *testing.T) { |
| + s := page(t, "## Office phone\n\nOffice phone: +353 (0)1 547 0001\n\nReception: +353 (0)1 547 0002\n\n"+ |
| + "Sales: +353 (0)1 547 0003\n\nFax: +353 (0)1 547 0004") |
| + one(t, searchFacts(t, s, "office phone"), "+353 (0)1 547 0001", phone(3, "office")) |
| + }) |
| + |
| + t.Run("five candidates leave four others", func(t *testing.T) { |
| + s := page(t, "## Office\n\nOffice phone: +353 (0)1 547 0001\n\nReception: +353 (0)1 547 0002\n\n"+ |
| + "Sales: +353 (0)1 547 0003\n\nFax: +353 (0)1 547 0004\n\nSupport: +353 (0)1 547 0005") |
| + one(t, searchFacts(t, s, "office phone"), "+353 (0)1 547 0001", phone(4, "office")) |
| + }) |
| + |
| + t.Run("another kind narrows too", func(t *testing.T) { |
| + s := page(t, "## Office email\n\nOffice email: office@tito.io\n\nSupport: support@tito.io\n\n"+ |
| + "Sales: sales@tito.io\n\nBilling: billing@tito.io") |
| + one(t, searchFacts(t, s, "office email"), "office@tito.io", |
| + FactNote{Kind: "email", Matched: []string{"office"}, Others: 3}) |
| }) |
| // Gate 34's rule, both halves. Counting matches over the unruled set |
| @@ -170,7 +252,7 @@ func TestGate40NarrowingPicksExactlyOneOverTheCap(t *testing.T) { |
| {"e", "Limerick", "## Limerick office\n\nSales phone: +353 (0)61 547 0005"}, |
| {"f", "Waterford", "## Waterford office\n\nFax phone: +353 (0)51 547 0006"}, |
| }) |
| - one(t, searchFacts(t, s, "office phone"), "+353 (0)1 547 0001", 3) |
| + one(t, searchFacts(t, s, "office phone"), "+353 (0)1 547 0001", phone(3, "office")) |
| }) |
| // And the cap itself is tested over the ruled set: three visible, a |
| // fourth withheld, is under the cap — all three, no narrowing. |
| @@ -185,8 +267,28 @@ func TestGate40NarrowingPicksExactlyOneOverTheCap(t *testing.T) { |
| {"f", "Waterford", "## Waterford office\n\nFax phone: +353 (0)51 547 0006"}, |
| }) |
| p := searchFacts(t, s, "office phone") |
| - if len(p.Facts) != 3 || len(p.FactNotes) != 0 { |
| - t.Errorf("facts = %q, notes = %+v; want all three and no note", spans(p), p.FactNotes) |
| + if len(p.Facts) != 3 { |
| + t.Errorf("facts = %q, want all three", spans(p)) |
| } |
| + noNotes(t, p) |
| }) |
| } |
| + |
| +// The note's sentence: every matched word, the kind's own noun. |
| +func TestFactNoteText(t *testing.T) { |
| + for _, c := range []struct { |
| + n FactNote |
| + want string |
| + }{ |
| + {FactNote{Kind: "phone", Matched: []string{"office"}, Others: 3}, |
| + "Matched “office”. 3 other phone numbers are in these results."}, |
| + {FactNote{Kind: "email", Matched: []string{"dublin", "office"}, Others: 4}, |
| + "Matched “dublin” and “office”. 4 other email addresses are in these results."}, |
| + {FactNote{Kind: "vat", Matched: []string{"main", "dublin", "office"}, Others: 5}, |
| + "Matched “main”, “dublin” and “office”. 5 other VAT numbers are in these results."}, |
| + } { |
| + if got := c.n.Text(); got != c.want { |
| + t.Errorf("Text(%+v) = %q, want %q", c.n, got, c.want) |
| + } |
| + } |
| +} |
diff --git a/internal/memoria/static/memoria.css b/internal/memoria/static/memoria.css| index 6d37e63..e4f2d4c 100644 |
| --- a/internal/memoria/static/memoria.css |
| +++ b/internal/memoria/static/memoria.css |
| @@ -311,7 +311,8 @@ |
| gap: 0.2rem 0.6rem; |
| margin: 0; |
| } |
| -[rst-fact] + [rst-fact] { |
| +[rst-fact] + [rst-fact], |
| +[rst-fact-note] + [rst-fact] { |
| margin-top: 0.5rem; |
| padding-top: 0.5rem; |
| border-top: 1px solid var(--rst-line); |
diff --git a/internal/memoria/store.go b/internal/memoria/store.go| index 01d2c84..012b8d2 100644 |
| --- a/internal/memoria/store.go |
| +++ b/internal/memoria/store.go |
| @@ -117,13 +117,11 @@ type Page struct { |
| // **Derived from Results and from nothing else**, which is what makes |
| // it safe by construction rather than by care. See addFacts. |
| Facts []Fact |
| - // FactNotes says, per kind, that the box was narrowed by the |
| - // reader's own words and how many candidates that left out (§8.8, |
| - // Paul 2026-09-29). Counted over Results, like everything above. |
| - FactNotes []FactNote |
| } |
| -// FactNote is the line under a narrowed kind's rows. |
| +// FactNote is the line under a narrowed kind's row: the box chose it on |
| +// the reader's own words, and this many others were left out (§8.8, Paul |
| +// 2026-09-29). Counted over Results, like everything else in the box. |
| type FactNote struct { |
| Kind string |
| // Matched are the qualifiers every shown row's label contains. |
| @@ -1057,6 +1055,11 @@ type Fact struct { |
| // distinguish the two at a glance. Decision 4's naming rule, reaching |
| // a box that has no answer paragraph. |
| Model string |
| + // Note is set on the one row narrowing left for its kind, and nil on |
| + // every other row. It is carried by the row rather than the page so |
| + // that it renders under the row it describes, not under whichever |
| + // kind happens to render last. |
| + Note *FactNote |
| } |
| // addFacts fills p.Facts for the kinds this query names. |
| @@ -1195,10 +1198,10 @@ func (s *Store) addFacts(ctx context.Context, p *Page, query string) { |
| if silenced || collides(rows) { |
| continue |
| } |
| - p.Facts = append(p.Facts, rows...) |
| if note != nil { |
| - p.FactNotes = append(p.FactNotes, *note) |
| + rows[0].Note = note |
| } |
| + p.Facts = append(p.Facts, rows...) |
| } |
| // §8.9, strictly after everything above. **Eligibility is the kind, |
diff --git a/internal/memoria/templates/results.html b/internal/memoria/templates/results.html| index e9ffa98..046c804 100644 |
| --- a/internal/memoria/templates/results.html |
| +++ b/internal/memoria/templates/results.html |
| @@ -59,12 +59,13 @@ |
| {{if .Model}}<span rst-fact-by title="Found by a model, not validated">🤖 {{.Provider}} · {{.Model}}</span>{{end}} |
| <a href="/documents/{{.DocumentID}}#p{{.Ordinal}}" rst-fact-cite>{{.Title}}{{if .Trail}} › {{.Trail}}{{end}}</a> |
| </p> |
| + {{/* §8.8's narrowing line, under the row it describes: the box |
| + chose this row on the reader's own words, and how many it left |
| + out. It keeps the others visible, which is the one thing |
| + standing between a planted label and a single confident |
| + answer. */}} |
| + {{with .Note}}<p rst-fact-note>{{.Text}}</p>{{end}} |
| {{end}} |
| - {{/* §8.8's narrowing line: the box chose among candidates on the |
| - reader's own words, and how many it left out. It keeps the |
| - others visible, which is the one thing standing between a |
| - planted label and a single confident answer. */}} |
| - {{range .FactNotes}}<p rst-fact-note>{{.Text}}</p>{{end}} |
| </div> |
| {{end}} |
| {{if not .Results}} |
diff --git a/internal/memoriatest/factbox_test.go b/internal/memoriatest/factbox_test.go| index 0df1fa9..657e1a6 100644 |
| --- a/internal/memoriatest/factbox_test.go |
| +++ b/internal/memoriatest/factbox_test.go |
| @@ -419,5 +419,36 @@ func TestTheNarrowedBoxReachesThePageWithItsLine(t *testing.T) { |
| if got := capture(factNoteRe, body); len(got) != 1 || got[0] != want { |
| t.Errorf("%s: note = %q, want %q", name, got, want) |
| } |
| + // The citation survives narrowing: title, trail and a link to the |
| + // passage, not just the value. |
| + if !regexp.MustCompile(`<a href="/documents/\d+#p\d+" rst-fact-cite>Office › Office Address</a>`).MatchString(body) { |
| + t.Errorf("%s: the narrowed row's citation is missing or wrong:\n%s", name, body) |
| + } |
| + } |
| +} |
| + |
| +// The note sits under the row it describes, not under whichever kind |
| +// renders last. Phone renders before VAT, so a note placed after every |
| +// row would sit under the VAT number and describe the wrong thing. |
| +// |
| +// MUTATION-TESTED 2026-09-29, each reverted: the note rendered after all |
| +// rows (Astra's finding) → "order is phone 2432, note 2818, vat 2668"; |
| +// the note rendered above its row → "phone 2525, note 2362". |
| +func TestTheNarrowingLineSitsUnderItsOwnRow(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Office", "## Office\n\nOffice phone: +353 (0)1 547 0001\n\nReception: +353 (0)1 547 0002\n\n"+ |
| + "Sales: +353 (0)1 547 0003\n\nFax: +353 (0)1 547 0004\n\nOffice VAT: IE3384527RH") |
| + body := search(t, h, "office phone and vat") |
| + if got := factValues(body); len(got) != 2 { |
| + t.Fatalf("values = %q, want the narrowed phone and the VAT number", got) |
| + } |
| + phone := strings.Index(body, "<span rst-fact-value>+353 (0)1 547 0001</span>") |
| + note := strings.Index(body, "<p rst-fact-note>") |
| + vat := strings.Index(body, "<span rst-fact-value>IE3384527RH</span>") |
| + if phone < 0 || note < 0 || vat < 0 { |
| + t.Fatalf("phone %d, note %d, vat %d: something is missing:\n%s", phone, note, vat, body) |
| + } |
| + if !(phone < note && note < vat) { |
| + t.Errorf("order is phone %d, note %d, vat %d; the note must sit between its row and the next", phone, note, vat) |
| } |
| } |