| 1 | package memoriatest |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | ) |
| 8 | |
| 9 | // The corpus every test in this file searches. |
| 10 | // |
| 11 | // Two passages match "june revenue", and that is the point rather than |
| 12 | // an incidental. The first version of this file used a corpus where |
| 13 | // every query returned exactly ONE result, which made "the primed one is |
| 14 | // the first one" unfalsifiable: with a single list item, priming the |
| 15 | // first, priming all and priming the last are the same markup. Fable's |
| 16 | // review caught it by mutating the template to `{{if $.Primed}}` and |
| 17 | // watching the suite stay green. Any test below that asserts a POSITION |
| 18 | // must therefore check it has more than one result to be wrong about. |
| 19 | func withCorpus(t *testing.T) http.Handler { |
| 20 | t.Helper() |
| 21 | h := newApp(t) |
| 22 | if rec := store(t, h, "Q3 board pack", |
| 23 | "## Revenue\n\nJune revenue was 1,350,000 across all regions.\n\n"+ |
| 24 | "## Costs\n\nHosting fell after the migration.\n\n"+ |
| 25 | "## Risks\n\nOne customer is 22% of June revenue, and that has not improved.", |
| 26 | ); rec.Code != http.StatusSeeOther { |
| 27 | t.Fatalf("store: status %d, want 303", rec.Code) |
| 28 | } |
| 29 | return h |
| 30 | } |
| 31 | |
| 32 | // results counts the rendered result items, so a positional assertion |
| 33 | // can refuse to run against a list too short to hold a wrong answer. |
| 34 | func results(body string) int { |
| 35 | return strings.Count(body, "<li rst-result") |
| 36 | } |
| 37 | |
| 38 | // A lookup primes the first result, which is what makes Enter go |
| 39 | // somewhere. The attribute is the whole contract between the server's |
| 40 | // heuristic and search.js: the script never re-derives the rule. |
| 41 | // |
| 42 | // Broken by hard-coding p.Primed to false — red, then reverted. |
| 43 | // 2026-09-05. |
| 44 | func TestALookupPrimesTheFirstResult(t *testing.T) { |
| 45 | h := withCorpus(t) |
| 46 | body := get(t, h, "/?q=june+revenue").Body.String() |
| 47 | // Without at least two results this test cannot tell prime-first |
| 48 | // from prime-anything, so it refuses to pretend it did. |
| 49 | if n := results(body); n < 2 { |
| 50 | t.Fatalf("corpus returned %d results; a positional assertion needs at least 2:\n%s", n, body) |
| 51 | } |
| 52 | if !strings.Contains(body, "rst-result-primed") { |
| 53 | t.Errorf("a lookup did not prime a result, so Enter goes nowhere:\n%s", body) |
| 54 | } |
| 55 | if n := strings.Count(body, "rst-result-primed"); n != 1 { |
| 56 | t.Errorf("primed %d results, want exactly 1", n) |
| 57 | } |
| 58 | // The prime must be on the FIRST result, not merely somewhere. |
| 59 | // Split on the <li> opener rather than comparing indexes, because |
| 60 | // "rst-result" is a prefix of "rst-result-primed" and an index |
| 61 | // comparison would be measuring the same attribute twice. |
| 62 | items := strings.Split(body, "<li rst-result") |
| 63 | if !strings.Contains(items[1], "rst-result-primed") { |
| 64 | t.Errorf("the primed result is not the first one:\n%s", body) |
| 65 | } |
| 66 | for i := 2; i < len(items); i++ { |
| 67 | if strings.Contains(items[i], "rst-result-primed") { |
| 68 | t.Errorf("result %d is primed as well as the first", i) |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | // Broken by priming on `len(results) > 0` alone, dropping the question |
| 74 | // term — red, then reverted. 2026-09-05. |
| 75 | func TestAQuestionPrimesNothing(t *testing.T) { |
| 76 | h := withCorpus(t) |
| 77 | body := get(t, h, "/?q=what+was+june+revenue").Body.String() |
| 78 | if !strings.Contains(body, "Q3 board pack") { |
| 79 | t.Fatalf("the question found nothing, so this test proves nothing:\n%s", body) |
| 80 | } |
| 81 | if strings.Contains(body, "rst-result-primed") { |
| 82 | t.Error("a question primed a result; Enter would navigate away from " + |
| 83 | "a page the reader was still reading") |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // There is deliberately no test here for "a page with no results primes |
| 88 | // nothing". It was written, and it could not fail: with no results the |
| 89 | // template renders no list items, so priming nothing and priming a |
| 90 | // result that does not exist are the same bytes. The invariant is real |
| 91 | // and is guarded where it is visible — query.TestPrimesFirstResult. |
| 92 | |
| 93 | // The live-search endpoint returns the results region and nothing else. |
| 94 | // A fragment that shipped a whole page would nest <html> inside the list |
| 95 | // on every keystroke. |
| 96 | // |
| 97 | // Broken by pointing searchResults at render instead of renderFragment — |
| 98 | // red, then reverted. 2026-09-05. |
| 99 | func TestSearchFragmentIsResultsOnly(t *testing.T) { |
| 100 | h := withCorpus(t) |
| 101 | rec := get(t, h, "/search?q=june+revenue") |
| 102 | if rec.Code != http.StatusOK { |
| 103 | t.Fatalf("status %d, want 200", rec.Code) |
| 104 | } |
| 105 | body := rec.Body.String() |
| 106 | if !strings.Contains(body, "Q3 board pack") { |
| 107 | t.Errorf("the fragment carries no result:\n%s", body) |
| 108 | } |
| 109 | if !strings.Contains(body, "rst-result-primed") { |
| 110 | t.Error("the fragment does not prime, so live search and a form " + |
| 111 | "submit would disagree about where Enter goes") |
| 112 | } |
| 113 | // rst-refresh is on the list because a fragment that ever grew the |
| 114 | // wrapper would nest a refreshable region inside one, and drop.js |
| 115 | // would then find two regions with the same name. |
| 116 | for _, chrome := range []string{"<html", "<body", "rst-page-header", "Add something", "rst-refresh"} { |
| 117 | if strings.Contains(body, chrome) { |
| 118 | t.Errorf("the fragment carries page chrome (%q); it would nest a "+ |
| 119 | "whole page inside the results list", chrome) |
| 120 | } |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | // The empty query is the state the box starts in and returns to when it |
| 125 | // is cleared. It must render an empty region, not an error and not the |
| 126 | // whole corpus. |
| 127 | func TestSearchFragmentOfAnEmptyQueryIsEmpty(t *testing.T) { |
| 128 | rec := get(t, withCorpus(t), "/search?q=") |
| 129 | if rec.Code != http.StatusOK { |
| 130 | t.Fatalf("status %d, want 200", rec.Code) |
| 131 | } |
| 132 | if body := strings.TrimSpace(rec.Body.String()); body != "" { |
| 133 | t.Errorf("an empty query rendered %q, want nothing", body) |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | // The fragment and the full page must agree, because Enter can land on |
| 138 | // either: live search fills the region, and a scriptless submit renders |
| 139 | // the page. If they disagreed, priming would depend on whether JS had |
| 140 | // loaded yet. |
| 141 | func TestFragmentAndPageAgreeOnPriming(t *testing.T) { |
| 142 | h := withCorpus(t) |
| 143 | // Two queries, not three: the no-match case was here and could not |
| 144 | // disagree, because with nothing to render both sides emit the same |
| 145 | // empty region whatever they decided. A case that cannot fail on a |
| 146 | // list of cases that can reads as coverage it is not. |
| 147 | for _, q := range []string{"june+revenue", "what+was+june+revenue"} { |
| 148 | page := strings.Contains(get(t, h, "/?q="+q).Body.String(), "rst-result-primed") |
| 149 | frag := strings.Contains(get(t, h, "/search?q="+q).Body.String(), "rst-result-primed") |
| 150 | if page != frag { |
| 151 | t.Errorf("q=%q: page primed=%v but fragment primed=%v", q, page, frag) |
| 152 | } |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | // When search.js's own fetch fails there is no server response to render |
| 157 | // a callout from, so the page must arrive already carrying the sentence. |
| 158 | // Without it the script has nothing to say and nothing to show, and the |
| 159 | // older results stand on screen as though they were current — which is |
| 160 | // this codebase's worst failure shape, reached by deleting one attribute. |
| 161 | // |
| 162 | // The sentence must be the SAME one the server's callout uses. Two |
| 163 | // wordings for "the search did not run" is a page arguing with itself. |
| 164 | // |
| 165 | // Broken by deleting rst-search-stale-note from index.html — red, then |
| 166 | // reverted. Broken again by changing its wording so the two disagree — |
| 167 | // red, then reverted. 2026-09-05. |
| 168 | func TestThePageCarriesTheFailureSentenceItMayNeed(t *testing.T) { |
| 169 | h, _, d := newAppWithLog(t) |
| 170 | store(t, h, "Q3 board pack", "June revenue was 1,350,000 across all regions.") |
| 171 | |
| 172 | // The sentence the page hands to search.js, for a fetch that never |
| 173 | // answers and so has no callout of its own to render. |
| 174 | carried := attr(t, get(t, h, "/").Body.String(), "rst-search-stale-note") |
| 175 | if carried == "" { |
| 176 | t.Fatal("the page carries no failure sentence, so a failed live search has nothing to show") |
| 177 | } |
| 178 | |
| 179 | // The sentence the SERVER renders when the search itself fails. |
| 180 | // Taking the database away is how that path is reached; nothing a |
| 181 | // well-formed request can carry makes store.Search fail. |
| 182 | d.Close() |
| 183 | broken := get(t, h, "/?q=june+revenue").Body.String() |
| 184 | // **The ATTRIBUTE tone is `rst-tone`**, and this assertion has now |
| 185 | // been wrong twice. tokens.css pairs every kind: the class spelling |
| 186 | // takes `data-tone`, the attribute spelling takes `rst-tone`, and the |
| 187 | // header says so — "rst-tone carries the four tones that data-tone |
| 188 | // carries today". `[rst-callout]` is the attribute spelling, so it |
| 189 | // wants `rst-tone`. |
| 190 | // |
| 191 | // Attempt one asserted `rst-callout="danger"`, a value on the kind |
| 192 | // attribute that nothing selects. Attempt two asserted `data-tone`, |
| 193 | // which is a real string in the stylesheet — inside the CLASS |
| 194 | // selector next to the one needed here — so it confirmed a spelling |
| 195 | // the markup could not use. Both times the callout rendered untoned |
| 196 | // and both times a test said it was fine. |
| 197 | if !strings.Contains(broken, `rst-tone="negative"`) { |
| 198 | t.Fatalf("a failed search did not render its callout, so there is nothing to compare:\n%s", broken) |
| 199 | } |
| 200 | // Compare the callout's OWN words to the carried ones. Searching the |
| 201 | // whole page for the carried sentence is what this gate did first, |
| 202 | // and it could not fail: that page also carries the attribute, so |
| 203 | // the sentence always found itself and a drifted callout went |
| 204 | // unnoticed. Caught by mutating the attribute's wording. |
| 205 | spoken := calloutBody(broken) |
| 206 | if spoken == "" { |
| 207 | t.Fatalf("could not read the failure callout's own words:\n%s", broken) |
| 208 | } |
| 209 | if spoken != carried { |
| 210 | t.Errorf("the sentence search.js would show and the one the server shows disagree:\n"+ |
| 211 | " server: %q\n carried: %q", spoken, carried) |
| 212 | } |
| 213 | if strings.Contains(broken, "rst-result-primed") { |
| 214 | t.Error("a search that broke primed a result") |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | // calloutBody is the text of the first danger callout's body, trimmed. |
| 219 | func calloutBody(body string) string { |
| 220 | i := strings.Index(body, `rst-tone="negative"`) |
| 221 | if i < 0 { |
| 222 | return "" |
| 223 | } |
| 224 | rest := body[i:] |
| 225 | open := strings.Index(rest, "<div rst-callout-body><p>") |
| 226 | if open < 0 { |
| 227 | return "" |
| 228 | } |
| 229 | rest = rest[open+len("<div rst-callout-body><p>"):] |
| 230 | shut := strings.Index(rest, "</p>") |
| 231 | if shut < 0 { |
| 232 | return "" |
| 233 | } |
| 234 | return strings.TrimSpace(rest[:shut]) |
| 235 | } |
| 236 | |
| 237 | // attr pulls one attribute's value out of rendered markup. Small enough |
| 238 | // to be obvious, and obvious beats a parser for one attribute. |
| 239 | func attr(t *testing.T, body, name string) string { |
| 240 | t.Helper() |
| 241 | i := strings.Index(body, name+`="`) |
| 242 | if i < 0 { |
| 243 | return "" |
| 244 | } |
| 245 | rest := body[i+len(name)+2:] |
| 246 | j := strings.Index(rest, `"`) |
| 247 | if j < 0 { |
| 248 | return "" |
| 249 | } |
| 250 | return rest[:j] |
| 251 | } |
| 252 | |
| 253 | // The stamp is what search.js reads to decide whether the results on |
| 254 | // screen still describe what is in the box. Reading the input instead |
| 255 | // was the bug Fable found: after a Back, a browser restores the field on |
| 256 | // a schedule of its own, and a script that trusted it would treat a |
| 257 | // stale screen as current and send Enter to the wrong document. |
| 258 | // |
| 259 | // Broken by deleting rst-search-results-for from index.html — red, then |
| 260 | // reverted. 2026-09-05. |
| 261 | func TestThePageStampsTheQueryItRendered(t *testing.T) { |
| 262 | h := withCorpus(t) |
| 263 | for _, c := range []struct{ url, want string }{ |
| 264 | {"/?q=june+revenue", `rst-search-results-for="june revenue"`}, |
| 265 | {"/", `rst-search-results-for=""`}, |
| 266 | } { |
| 267 | if body := get(t, h, c.url).Body.String(); !strings.Contains(body, c.want) { |
| 268 | t.Errorf("GET %s does not stamp the query it rendered (want %s):\n%s", c.url, c.want, body) |
| 269 | } |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | // search.js is an enhancement. The page it enhances must work with it |
| 274 | // deleted, which means the form is a real GET form to a real route and |
| 275 | // the script is not what makes results appear. |
| 276 | func TestSearchWorksWithoutTheScript(t *testing.T) { |
| 277 | h := withCorpus(t) |
| 278 | body := get(t, h, "/").Body.String() |
| 279 | if !strings.Contains(body, `action="/"`) || !strings.Contains(body, `method="get"`) { |
| 280 | t.Errorf("the search form is not a plain GET form:\n%s", body) |
| 281 | } |
| 282 | if !strings.Contains(get(t, h, "/?q=june+revenue").Body.String(), "Q3 board pack") { |
| 283 | t.Error("a plain form submit does not produce results") |
| 284 | } |
| 285 | } |
| 286 | |