| 1 | package memoriatest |
| 2 | |
| 3 | import ( |
| 4 | "html" |
| 5 | "net/http" |
| 6 | "net/url" |
| 7 | "regexp" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | // The fact box, end to end: through the real handler, the real template |
| 13 | // and the real escaping. |
| 14 | // |
| 15 | // **Through HTTP and not against the struct, deliberately, and gate 34 is |
| 16 | // the opposite deliberately.** Gate 34 is about what the box may CONTAIN, |
| 17 | // where two dispositions render identical pages and only the struct can |
| 18 | // tell them apart. These are about what reaches the reader, where a |
| 19 | // template that escapes a span, drops a label or renders the box outside |
| 20 | // the swapped region is exactly the defect — and a struct assertion |
| 21 | // cannot see any of it. AGENTS.md: a template test proves markup exists, |
| 22 | // not that it renders. |
| 23 | |
| 24 | var factValueRe = regexp.MustCompile(`<span rst-fact-value>([^<]*)</span>`) |
| 25 | var factLabelRe = regexp.MustCompile(`<span rst-fact-label>([^<]*)</span>`) |
| 26 | |
| 27 | func factValues(body string) []string { return capture(factValueRe, body) } |
| 28 | func factLabels(body string) []string { return capture(factLabelRe, body) } |
| 29 | func hasFactBox(body string) bool { return strings.Contains(body, "<div rst-fact-box>") } |
| 30 | |
| 31 | // capture pulls the rendered text out of the box and UNESCAPES it. |
| 32 | // |
| 33 | // html/template escapes "+" to "+" in text content, which is correct |
| 34 | // and which the reader never sees. Gate 36 is about the bytes that reach |
| 35 | // the READER — "byte-identical to the document" is a claim about what is |
| 36 | // displayed, not about the wire — so comparing raw markup would fail the |
| 37 | // gate on the escaping working properly, and "fixing" that would mean |
| 38 | // unescaping the span into the page, which is an injection. |
| 39 | func capture(re *regexp.Regexp, s string) []string { |
| 40 | var out []string |
| 41 | for _, m := range re.FindAllStringSubmatch(s, -1) { |
| 42 | out = append(out, html.UnescapeString(m[1])) |
| 43 | } |
| 44 | return out |
| 45 | } |
| 46 | |
| 47 | func search(t *testing.T, h http.Handler, q string) string { |
| 48 | t.Helper() |
| 49 | return get(t, h, "/?q="+url.QueryEscape(q)).Body.String() |
| 50 | } |
| 51 | |
| 52 | // GATE 35 — candidates are canonical values, ambiguity is enumerated |
| 53 | // rather than collapsed, and the cap is three (§8.8, §10). |
| 54 | // |
| 55 | // One test rather than five, because the rules share a definition and a |
| 56 | // gate that splits them cannot see the interactions: the same number in |
| 57 | // five documents is one candidate AND therefore does not pass the cap. |
| 58 | // |
| 59 | // Mutations run, each verified red and reverted, 2026-09-14: |
| 60 | // - returning found[k][:1] when there is more than one — "two distinct" |
| 61 | // goes red. |
| 62 | // - keying dedupe on Span instead of Canonical — "two spellings" red. |
| 63 | // - keying dedupe on the passage rather than the value — "five |
| 64 | // documents" red. |
| 65 | // - cap written len(cs) >= MaxFacts — "three still renders" red, and |
| 66 | // nothing else, which is the point of having it. |
| 67 | // - cap and collision counted across kinds rather than per kind — the |
| 68 | // two-kind case red. |
| 69 | func TestGate35CandidatesAmbiguityAndTheCap(t *testing.T) { |
| 70 | t.Run("presence: one number, one box, one value", func(t *testing.T) { |
| 71 | h := newApp(t) |
| 72 | store(t, h, "Tito office", "## Contact\n\nOffice phone: +353 (0)1 547 7854") |
| 73 | body := search(t, h, "phone number") |
| 74 | if got := factValues(body); len(got) != 1 || got[0] != "+353 (0)1 547 7854" { |
| 75 | t.Fatalf("values = %q, want the one number", got) |
| 76 | } |
| 77 | }) |
| 78 | |
| 79 | // Two right answers to an underspecified question. §8.7 refuses in |
| 80 | // this position; §8.8 enumerates, because two phone numbers are not |
| 81 | // a wrong average — and the labels are what let the reader choose. |
| 82 | t.Run("two distinct numbers are both named, neither picked", func(t *testing.T) { |
| 83 | h := newApp(t) |
| 84 | store(t, h, "Tito office", "## Contact\n\nOffice phone: +353 (0)1 547 7854") |
| 85 | store(t, h, "Support", "## Contact\n\nSupport phone: +353 (0)1 598 1234") |
| 86 | got := factValues(search(t, h, "phone number")) |
| 87 | if len(got) != 2 { |
| 88 | t.Fatalf("values = %q, want both", got) |
| 89 | } |
| 90 | }) |
| 91 | |
| 92 | // A dedupe keyed on the span passes any fixture that spells the |
| 93 | // number the same way twice, which is why this one does not. |
| 94 | t.Run("two spellings of one number are one candidate", func(t *testing.T) { |
| 95 | h := newApp(t) |
| 96 | store(t, h, "Tito office", "## Contact\n\nOffice phone: +353 (0)1 547 7854") |
| 97 | store(t, h, "Handbook", "## Contact\n\nOffice phone: 01 547 7854") |
| 98 | if got := factValues(search(t, h, "phone number")); len(got) != 1 { |
| 99 | t.Fatalf("values = %q, want one: the same number written two ways", got) |
| 100 | } |
| 101 | }) |
| 102 | |
| 103 | // The commonest real shape — a signature repeated through forwarded |
| 104 | // mail. An implementation counting passages rather than values shows |
| 105 | // five rows here, or passes the cap and shows none. |
| 106 | t.Run("the same number in five documents is one candidate", func(t *testing.T) { |
| 107 | h := newApp(t) |
| 108 | for _, title := range []string{"One", "Two", "Three", "Four", "Five"} { |
| 109 | store(t, h, title, "## Contact\n\nOffice phone: +353 (0)1 547 7854") |
| 110 | } |
| 111 | if got := factValues(search(t, h, "phone number")); len(got) != 1 { |
| 112 | t.Fatalf("values = %q, want one", got) |
| 113 | } |
| 114 | }) |
| 115 | |
| 116 | // index/rank_test.go's own paragraph, which came off the live |
| 117 | // instance. An implementation treating the CHUNK as the unit hides |
| 118 | // the second number — the one the document calls unused. |
| 119 | t.Run("two numbers in one passage are two candidates", func(t *testing.T) { |
| 120 | h := newApp(t) |
| 121 | store(t, h, "Office address", "## Office Address\n\nTurn the knob. "+ |
| 122 | "The office phone number is: +353 (0)1 547 7854. "+ |
| 123 | "We also have the following unused phone numbers: +353 (0)1 598 1234.") |
| 124 | body := search(t, h, "phone number") |
| 125 | if got := factValues(body); len(got) != 2 { |
| 126 | t.Fatalf("values = %q, want both", got) |
| 127 | } |
| 128 | // And the labels are what make the enumeration honest: the |
| 129 | // document said which is unused, so the page does. |
| 130 | labels := factLabels(body) |
| 131 | if len(labels) != 2 || !strings.Contains(labels[1], "unused") { |
| 132 | t.Fatalf("labels = %q; the document's own word for the second number is missing", labels) |
| 133 | } |
| 134 | }) |
| 135 | |
| 136 | // **Three AND four.** A cap written `>= MaxFacts` passes a suite that |
| 137 | // tests only one, two and four. |
| 138 | t.Run("three renders, four renders nothing", func(t *testing.T) { |
| 139 | nums := []string{"+353 (0)1 547 7854", "+353 (0)1 598 1234", "+353 (0)1 555 0101", "+353 (0)1 555 0202"} |
| 140 | for _, n := range []int{3, 4} { |
| 141 | h := newApp(t) |
| 142 | for i := 0; i < n; i++ { |
| 143 | store(t, h, "Office "+string(rune('A'+i)), "## Contact\n\nOffice phone: "+nums[i]) |
| 144 | } |
| 145 | body := search(t, h, "phone number") |
| 146 | switch { |
| 147 | case n == 3 && len(factValues(body)) != 3: |
| 148 | t.Errorf("three candidates rendered %q, want three", factValues(body)) |
| 149 | case n == 4 && hasFactBox(body): |
| 150 | t.Errorf("four candidates rendered a box: %q", factValues(body)) |
| 151 | } |
| 152 | } |
| 153 | }) |
| 154 | |
| 155 | // Every rule is per kind. Counted across kinds, two of each passes |
| 156 | // the cap and both boxes vanish. |
| 157 | t.Run("a two-kind query renders both kinds", func(t *testing.T) { |
| 158 | h := newApp(t) |
| 159 | store(t, h, "Tito office", "## Contact\n\nOffice phone: +353 (0)1 547 7854\n\nEircode: D02 X285") |
| 160 | store(t, h, "Support", "## Contact\n\nSupport phone: +353 (0)1 598 1234\n\nEircode: A65 F4E2") |
| 161 | got := factValues(search(t, h, "phone number and eircode")) |
| 162 | if len(got) != 4 { |
| 163 | t.Fatalf("values = %q, want two of each kind", got) |
| 164 | } |
| 165 | }) |
| 166 | |
| 167 | // The whole citation line collides, so the page would print one |
| 168 | // identical row twice against two different values. |
| 169 | t.Run("colliding rows render no box", func(t *testing.T) { |
| 170 | h := newApp(t) |
| 171 | store(t, h, "Suppliers", "## Phone\n\n- +353 (0)1 555 0101\n- +353 (0)1 555 0202") |
| 172 | if body := search(t, h, "phone number"); hasFactBox(body) { |
| 173 | t.Errorf("rendered a box with two identical rows: %q / %q", factLabels(body), factValues(body)) |
| 174 | } |
| 175 | }) |
| 176 | |
| 177 | // §10 says this gate asserts first-seen order, and for a while it |
| 178 | // asserted only the count. A box sorted by canonical value is |
| 179 | // deterministic and passes every length check, and the two-values-in- |
| 180 | // one-passage fixture happens to sort the same way it was seen — so |
| 181 | // nothing here could see it. Found by review, 2026-09-14. |
| 182 | t.Run("rows are in first-seen order", func(t *testing.T) { |
| 183 | h := newApp(t) |
| 184 | // Both orderings a wrong implementation would pick disagree with |
| 185 | // document order here: sorted by VALUE the 547 number comes |
| 186 | // first, and sorted by LABEL "Alternate" sorts before "Work". |
| 187 | // Only first-seen gives 598, so this one fixture holds against |
| 188 | // both — and the version it replaces held against neither, |
| 189 | // because its two labels happened to sort as they were seen. |
| 190 | store(t, h, "Tito office", "## Contact\n\n"+ |
| 191 | "Work phone is: +353 (0)1 598 1234. "+ |
| 192 | "Alternate phone is: +353 (0)1 547 7854.") |
| 193 | got := factValues(search(t, h, "phone number")) |
| 194 | if len(got) != 2 { |
| 195 | t.Fatalf("values = %q, want two", got) |
| 196 | } |
| 197 | if got[0] != "+353 (0)1 598 1234" { |
| 198 | t.Errorf("first row = %q; the box is not in the order the document states them", got[0]) |
| 199 | } |
| 200 | }) |
| 201 | |
| 202 | // Every collision fixture above is a PAIR, so a check comparing only |
| 203 | // neighbours passes all of them. Three rows whose first and third are |
| 204 | // identical is the shape that sees it. |
| 205 | t.Run("a collision between non-adjacent rows still suppresses", func(t *testing.T) { |
| 206 | h := newApp(t) |
| 207 | store(t, h, "Tito office", "## Contact\n\n"+ |
| 208 | "Ring the office phone on 01 547 7854 any weekday. "+ |
| 209 | "Support phone: 01 598 1234. "+ |
| 210 | "Or dial the phone on 01 555 0101 instead.") |
| 211 | if body := search(t, h, "phone number"); hasFactBox(body) { |
| 212 | t.Errorf("rendered a box whose first and third rows are identical: %q / %q", |
| 213 | factLabels(body), factValues(body)) |
| 214 | } |
| 215 | }) |
| 216 | |
| 217 | // The mirror of the title case below, for the trail. Same title, same |
| 218 | // label, different headings — the trail is what tells them apart, and |
| 219 | // a collision check ignoring it loses a real answer. |
| 220 | t.Run("the same label under different headings still renders", func(t *testing.T) { |
| 221 | h := newApp(t) |
| 222 | store(t, h, "Tito office", "## Office\n\nPhone: +353 (0)1 547 7854\n\n## Support\n\nPhone: +353 (0)1 598 1234") |
| 223 | if got := factValues(search(t, h, "phone number")); len(got) != 2 { |
| 224 | t.Fatalf("values = %q, want both: the headings tell them apart", got) |
| 225 | } |
| 226 | }) |
| 227 | |
| 228 | // A different title disambiguates perfectly, so this one MUST box. |
| 229 | // Keying collision on the label alone loses a real answer here. |
| 230 | t.Run("the same label under different titles still renders", func(t *testing.T) { |
| 231 | h := newApp(t) |
| 232 | store(t, h, "Tito office", "## Contact\n\nOffice phone: +353 (0)1 547 7854") |
| 233 | store(t, h, "Acme supplier", "## Contact\n\nOffice phone: +353 (0)1 598 1234") |
| 234 | if got := factValues(search(t, h, "phone number")); len(got) != 2 { |
| 235 | t.Fatalf("values = %q, want both: the titles tell them apart", got) |
| 236 | } |
| 237 | }) |
| 238 | } |
| 239 | |
| 240 | // GATE 36 — the displayed value is byte-identical to the document's |
| 241 | // (§8.8, §10). The canonical form exists for matching and must never |
| 242 | // reach the page. |
| 243 | // |
| 244 | // Mutation: canonicalise on the display path — one line's difference from |
| 245 | // what the code already does for identity — and every case goes red. |
| 246 | func TestGate36TheDisplayedValueIsTheDocumentsBytes(t *testing.T) { |
| 247 | for _, c := range []struct{ name, body, want string }{ |
| 248 | {"as written", "## Contact\n\nOffice phone: +353 (0)1 547 7854", "+353 (0)1 547 7854"}, |
| 249 | {"with an extension", "## Contact\n\nOffice phone: +353 (0)1 547 7854 ext. 12", "+353 (0)1 547 7854 ext. 12"}, |
| 250 | // Pasted from Word or Notion. Go's \s does not match U+00A0, so |
| 251 | // a recogniser written with it finds nothing here and this case |
| 252 | // fails on PRESENCE rather than on the bytes — which is the |
| 253 | // design demand and not an accident. |
| 254 | {"pasted with non-breaking spaces", "## Contact\n\nOffice phone: +353 (0)1 547 7854", "+353 (0)1 547 7854"}, |
| 255 | } { |
| 256 | t.Run(c.name, func(t *testing.T) { |
| 257 | h := newApp(t) |
| 258 | store(t, h, "Tito office", c.body) |
| 259 | got := factValues(search(t, h, "phone number")) |
| 260 | if len(got) != 1 { |
| 261 | t.Fatalf("values = %q, want one", got) |
| 262 | } |
| 263 | if got[0] != c.want { |
| 264 | t.Errorf("value = %q, want %q", got[0], c.want) |
| 265 | } |
| 266 | }) |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | // GATE 37 — the label is the document's, and it reaches the page. |
| 271 | // |
| 272 | // The exhaustive matrix — bullets, adjacent values, abbreviations, the |
| 273 | // cap boundary, the no-heading fallback — is unit-tested in |
| 274 | // internal/memoria/fact, where each rule can be driven directly. What |
| 275 | // this gate adds is the half a unit test cannot see: that the label |
| 276 | // survives the store, the template and the escaping, and that a value the |
| 277 | // document labelled too long to show renders NO BOX rather than a vaguer |
| 278 | // substitute. |
| 279 | func TestGate37TheLabelReachesThePage(t *testing.T) { |
| 280 | t.Run("the document's own label is what is shown", func(t *testing.T) { |
| 281 | h := newApp(t) |
| 282 | store(t, h, "Tito office", "## Contact\n\nOffice phone: +353 (0)1 547 7854") |
| 283 | if got := factLabels(search(t, h, "phone number")); len(got) != 1 || got[0] != "Office phone" { |
| 284 | t.Fatalf("labels = %q, want [Office phone]", got) |
| 285 | } |
| 286 | }) |
| 287 | |
| 288 | t.Run("no label in the line falls to the heading trail", func(t *testing.T) { |
| 289 | h := newApp(t) |
| 290 | store(t, h, "Tito office", "## Contact\n\nRing the office phone on +353 (0)1 547 7854 any weekday.") |
| 291 | if got := factLabels(search(t, h, "phone number")); len(got) != 1 || got[0] != "Contact" { |
| 292 | t.Fatalf("labels = %q, want [Contact]", got) |
| 293 | } |
| 294 | }) |
| 295 | |
| 296 | // Substituting the trail would replace the document's own warning |
| 297 | // with a vaguer word, beside a number the document did not warn |
| 298 | // about. Dropping just this candidate would turn a two-candidate |
| 299 | // ambiguity into a confident single answer. Both are worse than |
| 300 | // silence. |
| 301 | t.Run("a label too long to show renders no box at all", func(t *testing.T) { |
| 302 | h := newApp(t) |
| 303 | store(t, h, "Tito office", "## Contact\n\nReception phone: +353 (0)1 547 7854\n\n"+ |
| 304 | "The number you must never give to any customer because it rings the chief executive directly is: +353 (0)1 598 1234") |
| 305 | if body := search(t, h, "phone number"); hasFactBox(body) { |
| 306 | t.Errorf("rendered a box: %q / %q", factLabels(body), factValues(body)) |
| 307 | } |
| 308 | }) |
| 309 | } |
| 310 | |
| 311 | // The box lives INSIDE the results define, so the fragment search.js |
| 312 | // swaps carries it. Outside, query N-1's answer would sit over query N's |
| 313 | // results on every keystroke — §8.4 inverted, several times a second. |
| 314 | // |
| 315 | // This is the page-and-fragment agreement that TestALookupPrimesTheFirstResult |
| 316 | // makes for priming, and it is needed for the same reason: two renderings |
| 317 | // of one region that can disagree are two regions. |
| 318 | func TestTheFactBoxIsInsideTheSwappedRegion(t *testing.T) { |
| 319 | h := newApp(t) |
| 320 | store(t, h, "Tito office", "## Contact\n\nOffice phone: +353 (0)1 547 7854") |
| 321 | page := search(t, h, "phone number") |
| 322 | fragment := get(t, h, "/search?q="+url.QueryEscape("phone number")).Body.String() |
| 323 | if !hasFactBox(page) { |
| 324 | t.Fatal("no box on the page") |
| 325 | } |
| 326 | if !hasFactBox(fragment) { |
| 327 | t.Error("no box in the fragment: it would not survive a keystroke") |
| 328 | } |
| 329 | if a, b := factValues(page), factValues(fragment); len(a) != len(b) || a[0] != b[0] { |
| 330 | t.Errorf("page %q and fragment %q disagree", a, b) |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | // Enter goes to the first RESULT, never to the box's citation. §8.8: |
| 335 | // changing what Enter means depending on whether a regular expression |
| 336 | // matched is the unreliable-Enter failure PrimesFirstResult exists to |
| 337 | // avoid. search.js targets rst-result-title, so the guard is that the |
| 338 | // box's link never wears it. |
| 339 | func TestTheFactBoxCitationIsNotAnEnterTarget(t *testing.T) { |
| 340 | h := newApp(t) |
| 341 | store(t, h, "Tito office", "## Contact\n\nOffice phone: +353 (0)1 547 7854") |
| 342 | body := search(t, h, "phone number") |
| 343 | if !hasFactBox(body) { |
| 344 | t.Fatal("no box to check: this assertion needs one to be wrong about") |
| 345 | } |
| 346 | box := body[strings.Index(body, "<div rst-fact-box>"):] |
| 347 | box = box[:strings.Index(box, "</div>")] |
| 348 | if strings.Contains(box, "rst-result-title") { |
| 349 | t.Errorf("the box carries search.js's Enter target:\n%s", box) |
| 350 | } |
| 351 | } |
| 352 | |
| 353 | // **The reachability limit, pinned rather than discovered.** |
| 354 | // |
| 355 | // §8.8 states it: the box only sees what retrieval returned, so a fact in |
| 356 | // a document the query does not reach is a fact the box does not have. |
| 357 | // This is not a small edge — it is the ordinary case for a contact page |
| 358 | // that writes "Office: +353 …" and never uses the word "phone" — and |
| 359 | // writing the gates is what made it concrete, because every fixture above |
| 360 | // had to be given a word the query could find. |
| 361 | // |
| 362 | // Asserted in BOTH directions, because a limit tested only in the |
| 363 | // direction that fails is indistinguishable from the feature not working |
| 364 | // at all: |
| 365 | // |
| 366 | // - a document sharing no word with the query renders no box; |
| 367 | // - the SAME document, reached by a query that shares one, does. |
| 368 | // |
| 369 | // §12 carries the fix and names what it must not be. Until then this test |
| 370 | // is the honest statement of what the feature does, and it is here so |
| 371 | // that the day somebody builds the lift, this goes red and gets deleted |
| 372 | // on purpose. |
| 373 | func TestTheBoxOnlySeesWhatRetrievalReturned(t *testing.T) { |
| 374 | const body = "## Contact\n\nOffice: +353 (0)1 547 7854" |
| 375 | |
| 376 | h := newApp(t) |
| 377 | store(t, h, "Tito office", body) |
| 378 | |
| 379 | // "phone number" appears nowhere in that document, so nothing ranks |
| 380 | // and there is nothing to extract from. |
| 381 | if got := search(t, h, "phone number"); hasFactBox(got) { |
| 382 | t.Errorf("a box appeared for a query the document does not match: %q", factValues(got)) |
| 383 | } |
| 384 | |
| 385 | // One shared word — "office", in the title and in the line — and the |
| 386 | // same fact is found. This is the half that proves the absence above |
| 387 | // is the limit rather than a broken recogniser. |
| 388 | if got := factValues(search(t, h, "office phone number")); len(got) != 1 { |
| 389 | t.Fatalf("values = %q, want the number: the query shares a word with the document", got) |
| 390 | } |
| 391 | } |
| 392 | |
| 393 | var factNoteRe = regexp.MustCompile(`<p rst-fact-note>([^<]*)</p>`) |
| 394 | |
| 395 | // The page that motivated §8.8's narrowing (Paul, 2026-09-29), through |
| 396 | // the real handler and template, on the page and in the fragment |
| 397 | // search.js swaps. Gate 40 holds the rule at store level; this holds that |
| 398 | // the row and its line reach the reader, and that the bullet is gone. |
| 399 | func TestTheNarrowedBoxReachesThePageWithItsLine(t *testing.T) { |
| 400 | h := newApp(t) |
| 401 | store(t, h, "Office", "## Office Address\n\n"+ |
| 402 | "- Turn the knob\n"+ |
| 403 | "- The office phone number is: +353 (0)1 547 7854\n"+ |
| 404 | "- We also have the following unused phone numbers: +353 (0)1 598 1234\n"+ |
| 405 | " - +353 (0)1 598 1235\n"+ |
| 406 | " - +353 (0)1 598 1236\n") |
| 407 | q := "what is the office phone number?" |
| 408 | for name, body := range map[string]string{ |
| 409 | "page": search(t, h, q), |
| 410 | "fragment": get(t, h, "/search?q="+url.QueryEscape(q)).Body.String(), |
| 411 | } { |
| 412 | if got := factValues(body); len(got) != 1 || got[0] != "+353 (0)1 547 7854" { |
| 413 | t.Fatalf("%s: values = %q, want the office number alone", name, got) |
| 414 | } |
| 415 | if got := factLabels(body); len(got) != 1 || got[0] != "The office phone number is" { |
| 416 | t.Errorf("%s: labels = %q, want the document's own without its bullet", name, got) |
| 417 | } |
| 418 | want := "Matched “office”. 3 other phone numbers are in these results." |
| 419 | if got := capture(factNoteRe, body); len(got) != 1 || got[0] != want { |
| 420 | t.Errorf("%s: note = %q, want %q", name, got, want) |
| 421 | } |
| 422 | // The citation survives narrowing: title, trail and a link to the |
| 423 | // passage, not just the value. |
| 424 | if !regexp.MustCompile(`<a href="/documents/\d+#p\d+" rst-fact-cite>Office › Office Address</a>`).MatchString(body) { |
| 425 | t.Errorf("%s: the narrowed row's citation is missing or wrong:\n%s", name, body) |
| 426 | } |
| 427 | } |
| 428 | } |
| 429 | |
| 430 | // The note sits under the row it describes, not under whichever kind |
| 431 | // renders last. Phone renders before VAT, so a note placed after every |
| 432 | // row would sit under the VAT number and describe the wrong thing. |
| 433 | // |
| 434 | // MUTATION-TESTED 2026-09-29, each reverted: the note rendered after all |
| 435 | // rows (Astra's finding) → "order is phone 2432, note 2818, vat 2668"; |
| 436 | // the note rendered above its row → "phone 2525, note 2362". |
| 437 | func TestTheNarrowingLineSitsUnderItsOwnRow(t *testing.T) { |
| 438 | h := newApp(t) |
| 439 | store(t, h, "Office", "## Office\n\nOffice phone: +353 (0)1 547 0001\n\nReception: +353 (0)1 547 0002\n\n"+ |
| 440 | "Sales: +353 (0)1 547 0003\n\nFax: +353 (0)1 547 0004\n\nOffice VAT: IE3384527RH") |
| 441 | body := search(t, h, "office phone and vat") |
| 442 | if got := factValues(body); len(got) != 2 { |
| 443 | t.Fatalf("values = %q, want the narrowed phone and the VAT number", got) |
| 444 | } |
| 445 | phone := strings.Index(body, "<span rst-fact-value>+353 (0)1 547 0001</span>") |
| 446 | note := strings.Index(body, "<p rst-fact-note>") |
| 447 | vat := strings.Index(body, "<span rst-fact-value>IE3384527RH</span>") |
| 448 | if phone < 0 || note < 0 || vat < 0 { |
| 449 | t.Fatalf("phone %d, note %d, vat %d: something is missing:\n%s", phone, note, vat, body) |
| 450 | } |
| 451 | if !(phone < note && note < vat) { |
| 452 | t.Errorf("order is phone %d, note %d, vat %d; the note must sit between its row and the next", phone, note, vat) |
| 453 | } |
| 454 | } |
| 455 | |