π€ merge: the last five commits of remote main
The merge before this one took a LOCAL main that was five commits behind the remote β I compared the wrong ref and did not notice until a peer session claimed memoria's main was still at draft 3. It is not; local main was simply stale, and the peer was looking at this branch's base commit. Both of us were wrong about the same ref in opposite directions, which is suite AGENTS.md's checkout hazard arriving from two sides at once. Remote main is 9b55ab8, and it carries live search: GET /search renders the results region alone from the same template GET /?q= renders inside a page, so a keystroke can replace that region. Same session gate, no passage the page did not already speak, and deleting the script leaves nothing calling it. GATE 17 CAUGHT IT. Second time in one merge that a route arriving from main could not reach the app until it was declared β five connector routes last time, GET /search this time. That is the gate doing the job it was built for, and it is worth noting that neither set was caught by review. Also corrects a sentence that stopped being true on merge: main's Β§9.1 said gate 17 was "specified and not implemented on this branch β nothing walks the router". It is implemented, on this one. The hand-off ruling and main's route list both stay: one is a fact about what the app serves, the other a ruling about what it refuses. make ci green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
17 files changed,
+1369
−70
docs/superpowers/specs/2026-09-03-memoria-design.md+23 −0internal/memoria/app.go+1 −0internal/memoria/handlers.go+55 −15internal/memoria/index/index.go+67 −2internal/memoria/index/index_test.go+19 −3internal/memoria/index/rank_test.go+244 −0internal/memoria/query/question.go+83 −0internal/memoria/query/question_test.go+89 −0internal/memoria/render.go+44 −12internal/memoria/static/memoria.css+36 −0internal/memoria/static/search.js+346 −0internal/memoria/templates/index.html+20 −35internal/memoria/templates/layout.html+3 −0internal/memoria/templates/results.html+51 −0internal/memoriatest/gates_test.go+6 −1internal/memoriatest/routes_test.go+10 −2internal/memoriatest/search_test.go+272 −0
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md| index b7ff9db..09587ec 100644 |
| --- a/docs/superpowers/specs/2026-09-03-memoria-design.md |
| +++ b/docs/superpowers/specs/2026-09-03-memoria-design.md |
| @@ -2646,6 +2646,29 @@ memoria exposes exactly two surfaces: |
| path**, and a sibling presenting a connector token is refused: the two |
| credential sets are disjoint and separately scoped. |
| +**What the app actually serves.** Gate 17 **is** implemented, as of |
| +2026-09-05 β `TestNoUndeclaredRoutes` walks the router's registrations |
| +and fails any this list does not name. main's copy of this paragraph |
| +said it was specified and not implemented, which was true of that |
| +branch and stopped being true on merge. The list stays written down |
| +anyway, because a gate read from a list that matches the app is worth |
| +more than one read from memory: |
| + |
| +`GET /`, `GET /search`, `GET /documents/{id}`, `POST /documents`, |
| +`POST /documents/{id}/delete`, the connector routes under |
| +`/connectors/β¦`, and the auth routes. |
| + |
| +Two of those need saying out loud. **`GET /query?q=` above is not served |
| +and never has been** β the search page is `GET /?q=`, and the paragraph |
| +above has specified a path the app does not have since draft 1. |
| +**`GET /search` is new on 2026-09-05**: it renders the results region |
| +alone, from the same template `GET /?q=` renders it inside a page, so |
| +that live search can replace that region on a keystroke. It is a |
| +narrower surface than the page, under the same session gate, and it adds |
| +no passage the page did not already speak. It is not a second query |
| +surface and not a step toward one; delete the script and nothing calls |
| +it. |
| + |
| **A hand-off whose sources include memoria-owned material is refused, and |
| that is a real product cost.** Suite Β§7 lets a person hand an artefact to |
| another app through their own session β "save this answer as a Doc" β but |
diff --git a/internal/memoria/app.go b/internal/memoria/app.go| index 6e41aef..eb145ba 100644 |
| --- a/internal/memoria/app.go |
| +++ b/internal/memoria/app.go |
| @@ -78,6 +78,7 @@ func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*ht |
| r.Group(func(r chi.Router) { |
| r.Use(a.requireSession) |
| r.Get("/", a.index) |
| + r.Get("/search", a.searchResults) |
| r.Post("/documents", a.createDocument) |
| r.Get("/documents/{id}", a.showDocument) |
| r.Post("/documents/{id}/delete", a.deleteDocument) |
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go| index 2fd0950..cea5f08 100644 |
| --- a/internal/memoria/handlers.go |
| +++ b/internal/memoria/handlers.go |
| @@ -15,6 +15,8 @@ import ( |
| "github.com/carlosframework/rastrillo/db" |
| "github.com/carlosframework/rastrillo/flash" |
| "github.com/go-chi/chi/v5" |
| + |
| + "amadan.net/oficina/memoria/internal/memoria/query" |
| ) |
| // The three upload limits, and the ONE place any of them is written |
| @@ -136,6 +138,17 @@ type page struct { |
| Results []Result |
| Documents []Summary |
| Drop dropZone |
| + |
| + // SearchFailed is not "no results". A search that errored renders |
| + // its own callout in the results region, because "nothing matched" |
| + // and "the search broke" look identical to a reader and this |
| + // codebase treats that shape as the worst available failure. |
| + SearchFailed bool |
| + |
| + // Primed means the first result is the one Enter goes to. Decided |
| + // here rather than in the browser so that one heuristic has one |
| + // home; search.js only honours the attribute the template writes. |
| + Primed bool |
| } |
| // dropZone is the upload form's limits and ALL of its copy, in one |
| @@ -520,21 +533,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| Searched: q != "", |
| Drop: drop, |
| } |
| - if p.Searched { |
| - results, err := a.store.Search(r.Context(), q, 25) |
| - if err != nil { |
| - // A search that errored must never render as no results. |
| - // "Nothing matched" and "the search broke" look identical to |
| - // a reader, and this codebase treats that shape as the worst |
| - // available failure. |
| - a.logger.Error("search failed", "err", err) |
| - p.Flash = "The search did not run. Nothing was found because nothing was searched." |
| - p.FlashKind = "danger" |
| - p.Searched = false |
| - } else { |
| - p.Results = results |
| - } |
| - } |
| + a.runSearch(r, &p) |
| docs, err := a.store.Summaries(r.Context()) |
| if err != nil { |
| a.logger.Error("list documents", "err", err) |
| @@ -563,6 +562,47 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| render(w, "index", p) |
| } |
| +// searchResults renders the results region alone, for search.js. |
| +// |
| +// It is a real route under the same session gate as everything else, and |
| +// it renders the same template the page does. The script that calls it is |
| +// an enhancement: delete search.js and the form below it still submits to |
| +// "/" and still gets these results, inside a page. |
| +func (a *app) searchResults(w http.ResponseWriter, r *http.Request) { |
| + q := strings.TrimSpace(r.URL.Query().Get("q")) |
| + p := page{Signed: true, Query: q, Searched: q != ""} |
| + a.runSearch(r, &p) |
| + // The passage count is only spoken by the no-match copy, and this |
| + // route runs once per keystroke. Counting only when the count will |
| + // be read keeps a per-keystroke query off the database. |
| + if p.Searched && len(p.Results) == 0 { |
| + p.Chunks = a.chunkCount() |
| + } |
| + renderFragment(w, "results", p) |
| +} |
| + |
| +// runSearch fills in Results, SearchFailed and Primed for a query that |
| +// has already been trimmed onto p. |
| +// |
| +// Shared by the page and the fragment on purpose: the two renderings must |
| +// agree about which result is primed, or where Enter goes would depend on |
| +// whether the script had loaded yet. |
| +func (a *app) runSearch(r *http.Request, p *page) { |
| + if !p.Searched { |
| + return |
| + } |
| + results, err := a.store.Search(r.Context(), p.Query, 25) |
| + if err != nil { |
| + // No query text in the log line: AGENTS.md, and `carlos logs` is |
| + // storage we cannot grep. |
| + a.logger.Error("search failed", "err", err) |
| + p.Searched, p.SearchFailed = false, true |
| + return |
| + } |
| + p.Results = results |
| + p.Primed = query.PrimesFirstResult(p.Query, len(results)) |
| +} |
| + |
| // count reads a non-negative integer out of a query parameter. Anything |
| // unparseable is 0, which is safe here: these are display counts, and |
| // the sentence they build is chosen by the same numbers the handler |
diff --git a/internal/memoria/index/index.go b/internal/memoria/index/index.go| index 07a2dc9..179eb9d 100644 |
| --- a/internal/memoria/index/index.go |
| +++ b/internal/memoria/index/index.go |
| @@ -279,7 +279,7 @@ func MatchQuery(raw string) string { |
| if cur.Len() == 0 { |
| return |
| } |
| - terms = append(terms, `"`+cur.String()+`"`) |
| + terms = append(terms, cur.String()) |
| cur.Reset() |
| } |
| for _, r := range raw { |
| @@ -299,7 +299,72 @@ func MatchQuery(raw string) string { |
| if len(terms) == 0 { |
| return "" |
| } |
| - return strings.Join(terms, " OR ") |
| + // A search box is not a document. Beyond this the OR chain costs |
| + // more than the answer is worth: measured at 2.8ms for 100 terms, |
| + // 97ms for 1000 and 2.25s for 5000 β and Search holds the index's |
| + // read lock throughout, so one pasted essay stalls ingest for |
| + // everyone. 64 is longer than any query a person types and cheap |
| + // (~3ms). Terms past it are dropped, which narrows a query nobody |
| + // typed rather than refusing one somebody did. |
| + // |
| + // Β§9.1 also specifies q capped at 1 KiB, which would bound this at |
| + // the request instead and is not implemented; this cap does not |
| + // depend on that one arriving. |
| + const maxTerms = 64 |
| + if len(terms) > maxTerms { |
| + terms = terms[:maxTerms] |
| + } |
| + quoted := make([]string, 0, len(terms)+1) |
| + for _, t := range terms { |
| + quoted = append(quoted, `"`+t+`"`) |
| + } |
| + // The query's words required to appear TOGETHER, added as a further |
| + // OR operand rather than replacing anything. |
| + // |
| + // This is the ranking half of the OR decision, and it was missing. |
| + // OR keeps recall, and the comment on TestOrRecallStillRanksBestMatchFirst |
| + // says the discriminating is then done by ranking β but BM25 alone |
| + // cannot do it when one of the words is common. FTS5 clamps IDF to |
| + // ~1e-6 for a term in most documents, so "number" contributed |
| + // nothing and "phone number" ranked exactly as "phone" did. A |
| + // passage with "Phone" in a short 3Γ-weighted heading beat the |
| + // passage that actually stated the office phone number. |
| + // |
| + // FTS5 scores each phrase in the expression and sums them, so a |
| + // passage carrying the words together earns a further contribution |
| + // and rises; one carrying them scattered is untouched. Measured on |
| + // the fixture in rank_test.go: the right passage 1.8670 β 4.0322, |
| + // the wrong one unchanged at 2.7670, all fifteen still returned. |
| + // |
| + // An OR operand and not AND, and not on its own: both of those |
| + // return only passages carrying the words together β one result |
| + // where there were twenty-five. Narrowing recall to fix an ordering |
| + // is the plausible-empty-list failure with a better score. |
| + // |
| + // NEAR rather than a plain phrase, and that was a review finding |
| + // rather than a first instinct. A phrase is ORDERED, so "revenue |
| + // june" scored a coincidental adjacency across a full stop above the |
| + // passage whose heading IS Revenue β the same failure this whole |
| + // function exists to fix, reintroduced by the fix. Before the phrase |
| + // existed the two spellings ranked identically, because order could |
| + // not matter; NEAR restores that and is what a person means by |
| + // typing two words. |
| + // |
| + // Distance 2, measured rather than picked. On "office phone number": |
| + // NEAR/0 ranked the WRONG passage first (worse than the phrase), |
| + // NEAR/2 got it right, NEAR/5 promoted a loose match over a tight |
| + // one. Two tolerates the small words English puts between the words |
| + // someone searched for. |
| + // |
| + // What it cannot see is punctuation. FTS5 tokenises it away, so |
| + // "β¦helped revenue. June alsoβ¦" is adjacent here and there is no |
| + // expression that says otherwise β the limit is the tokenizer's, not |
| + // this line's, and the next person should not go looking at the |
| + // weights for it. |
| + if len(terms) > 1 { |
| + quoted = append(quoted, `NEAR(`+strings.Join(quoted, " ")+`, 2)`) |
| + } |
| + return strings.Join(quoted, " OR ") |
| } |
| // randomName gives each in-memory index its own shared-cache key. Random |
diff --git a/internal/memoria/index/index_test.go b/internal/memoria/index/index_test.go| index a9103b5..4c4bc06 100644 |
| --- a/internal/memoria/index/index_test.go |
| +++ b/internal/memoria/index/index_test.go |
| @@ -123,10 +123,26 @@ func TestRemoveDocumentDropsItsChunks(t *testing.T) { |
| } |
| func TestMatchQueryQuotesAndOrs(t *testing.T) { |
| + // The trailing operand requires the query's words to appear |
| + // together. It is a RANKING signal: FTS5 sums per-phrase |
| + // contributions, so a passage carrying the words together scores |
| + // above one carrying them scattered, while both are still returned. |
| + // See MatchQuery. |
| + // |
| + // NEAR and not a phrase, because a phrase is ordered and "revenue |
| + // june" then ranked differently from "june revenue" β see |
| + // TestWordOrderDoesNotChangeTheRanking. |
| + // |
| + // "what's up" is the interesting row. The apostrophe splits the query |
| + // into three terms and they are required together as three, which |
| + // still matches a document's "what's up": the tokenizer is |
| + // `porter unicode61`, and unicode61 drops the apostrophe on both |
| + // sides while porter stems what is left. The phrase logic depends on |
| + // both halves of that tokenizer, so swapping either changes this. |
| for in, want := range map[string]string{ |
| - "june revenue": `"june" OR "revenue"`, |
| - "what's up": `"what" OR "s" OR "up"`, |
| - " spaced out ": `"spaced" OR "out"`, |
| + "june revenue": `"june" OR "revenue" OR NEAR("june" "revenue", 2)`, |
| + "what's up": `"what" OR "s" OR "up" OR NEAR("what" "s" "up", 2)`, |
| + " spaced out ": `"spaced" OR "out" OR NEAR("spaced" "out", 2)`, |
| "!!!": ``, |
| "cafΓ©": `"cafΓ©"`, |
| } { |
diff --git a/internal/memoria/index/rank_test.go b/internal/memoria/index/rank_test.go| new file mode 100644 |
| index 0000000..34eab93 |
| --- /dev/null |
| +++ b/internal/memoria/index/rank_test.go |
| @@ -0,0 +1,244 @@ |
| +package index |
| + |
| +import ( |
| + "fmt" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// The query that found this: "phone number" on the real corpus put a |
| +// passage of Virgin Media ACCOUNT numbers above the passage that states |
| +// the office phone number. Paul hit it on the live instance, and the |
| +// keyboard prime shipped the same day sent Enter to the wrong document. |
| +// |
| +// The corpus below is what makes the test able to fail, and it took two |
| +// attempts to build. A two-document version scored every hit at exactly |
| +// 0.0000: FTS5's bm25 clamps IDF to ~1e-6 for a term that appears in |
| +// every document, so the scores collapse and the order is rowid. "number" |
| +// has to be COMMON here and "phone" RARE, as on the real corpus, or this |
| +// measures nothing. |
| +// |
| +// Measured on that corpus before the fix: |
| +// |
| +// "phone" OR "number" β chunk 1 at 2.5607, chunk 2 at 1.8670 |
| +// "number" alone β every score 0.0000 |
| +// "phone" alone β identical to "phone" OR "number" |
| +// |
| +// The second line is the finding. A common term contributes nothing to |
| +// ranking, so a two-word query ranked exactly as its rarest word alone, |
| +// and "phone number" could not be told from "phone". |
| +func rankCorpus(t *testing.T) *Index { |
| + t.Helper() |
| + ix, err := Open() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { ix.Close() }) |
| + |
| + var entries []Entry |
| + add := func(heading, title, text string) { |
| + entries = append(entries, Entry{ |
| + ChunkID: int64(len(entries) + 1), |
| + DocumentID: int64(len(entries) + 1), |
| + Ordinal: len(entries), |
| + Title: title, |
| + Path: "Team Tito Limited", |
| + Heading: heading, |
| + Text: text, |
| + }) |
| + } |
| + |
| + // Chunk 1: "Phone" in a short, 3Γ-weighted heading, and no phone |
| + // number in the body. Chunk 2: the answer, in the body, twice. |
| + add("Utilities βΊ Internet & Phone", "Office", |
| + "Virgin Media - login - Office 7: account number 80854641 - Office 2: account number 80753900") |
| + add("Office Address", "Office", |
| + "Turn the knob. The office phone number is: +353 (0)1 547 7854. We also have the following unused phone numbers: +353 (0)1 598 1234.") |
| + |
| + // "number" common across the corpus, as on the real one. |
| + add("Business & VAT Numbers", "Business & VAT Numbers", |
| + "Our company number is 566334. Our VAT number is IE3384527RH. Our DUNS/D-U-N-S number is 985632364.") |
| + add("Bank", "Banking", "The account number is 12345678 and the sort code is 93-00-11.") |
| + add("Payroll", "Payroll", "Each employee number is assigned on their first day.") |
| + add("Insurance", "Insurance", "The policy number is renewed every January.") |
| + add("Revenue", "Q3 board pack", "June revenue was 1,350,000 across all regions.") |
| + add("Costs", "Q3 board pack", "Hosting costs fell after the migration.") |
| + add("Employee Offboarding", "Processes", "Collect the laptop and the serial number of any device issued.") |
| + add("Company Registration", "Registration", "The registration number appears on every invoice.") |
| + add("Invoices", "Finance", "Every invoice carries a sequential number for the auditor.") |
| + add("Passport numbers", "Travel", "Each passport number is stored in the safe, never in a document.") |
| + add("Suppliers", "Finance", "Each supplier has a reference number in the ledger.") |
| + add("Meeting, 12 August", "Minutes", "We will not build a suite index app.") |
| + add("Sealing notes", "Sealing", "A sheet's contents are sealed at rest.") |
| + |
| + if err := ix.Add(entries...); err != nil { |
| + t.Fatal(err) |
| + } |
| + return ix |
| +} |
| + |
| +// Broken by removing the NEAR operand from MatchQuery β red, then |
| +// reverted. Broken again by ANDing it rather than ORing it, which |
| +// returns the one passage and drops the other fourteen β red on the |
| +// recall assertion below, then reverted. 2026-09-05. |
| +func TestAdjacentTermsOutrankAScatteredCommonWord(t *testing.T) { |
| + ix := rankCorpus(t) |
| + hits, err := ix.Search("phone number", 25) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(hits) == 0 { |
| + t.Fatal("no hits at all") |
| + } |
| + if hits[0].ChunkID != 2 { |
| + t.Errorf("top hit is chunk %d (%q), want chunk 2 β the passage that states the number", |
| + hits[0].ChunkID, hits[0].Heading) |
| + } |
| + // Recall is the other half, and the half a NEAR-only query loses. |
| + // Every passage carrying either word must still come back: requiring |
| + // the words together would turn a working search into a one-result |
| + // page, which is the plausible-empty-list failure with a better score. |
| + if len(hits) < 10 { |
| + t.Errorf("only %d hits; the boost has narrowed recall instead of reordering it", len(hits)) |
| + } |
| +} |
| + |
| +// The boost must be earned by the words being TOGETHER, not by both |
| +// being present β which is what AND already gave, and what did not help. |
| +// |
| +// The first version of this test could not fail. It used two documents |
| +// and no filler, so both terms appeared in every document, FTS5 clamped |
| +// both IDFs to ~1e-6, every score collapsed to 0 and the order fell back |
| +// to rowid β which happened to be the order it asserted. Removing the fix |
| +// entirely left it green. It needed the filler corpus for the same reason |
| +// the test above does, and it needed the scattered passage to be WINNING |
| +// beforehand: a test where the right answer is already first proves |
| +// nothing about what put it there. |
| +// |
| +// So the scattered passage says both words three times each and the |
| +// adjacent one says them once. On term frequency alone the scattered one |
| +// wins; only togetherness turns it over. |
| +// |
| +// Broken by removing the NEAR operand β red, then reverted. 2026-09-05. |
| +func TestBoostNeedsTheWordsTogether(t *testing.T) { |
| + ix := rankCorpus(t) |
| + if err := ix.Add( |
| + Entry{ChunkID: 90, DocumentID: 90, Ordinal: 0, Heading: "Scattered", Title: "Desk", |
| + Text: "The phone, the phone, the phone. Later, ask for a number, a number, another number."}, |
| + Entry{ChunkID: 91, DocumentID: 91, Ordinal: 0, Heading: "Adjacent", Title: "Desk", |
| + Text: "The phone number is on the desk."}, |
| + ); err != nil { |
| + t.Fatal(err) |
| + } |
| + hits, err := ix.Search("phone number", 25) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var scattered, adjacent int |
| + for i, h := range hits { |
| + switch h.ChunkID { |
| + case 90: |
| + scattered = i + 1 |
| + case 91: |
| + adjacent = i + 1 |
| + } |
| + } |
| + if scattered == 0 || adjacent == 0 { |
| + t.Fatalf("both passages must be returned; scattered=%d adjacent=%d of %d hits", |
| + scattered, adjacent, len(hits)) |
| + } |
| + if adjacent > scattered { |
| + t.Errorf("the scattered passage ranked %d and the adjacent one %d β "+ |
| + "adjacency is not being rewarded, only presence", scattered, adjacent) |
| + } |
| +} |
| + |
| +// A single-word query has nothing to be near, and must not grow a |
| +// spurious operand. |
| +func TestSingleTermQueryIsUnchanged(t *testing.T) { |
| + if got, want := MatchQuery("revenue"), `"revenue"`; got != want { |
| + t.Errorf("MatchQuery(%q) = %q, want %q", "revenue", got, want) |
| + } |
| +} |
| + |
| +// Typing the same two words the other way round must not change what |
| +// comes back first. |
| +// |
| +// This is a regression the FIRST version of the togetherness boost |
| +// introduced, found by review. It used an ordered phrase, so "revenue |
| +// june" scored a coincidental adjacency across a full stop β "β¦helped |
| +// revenue. June also sawβ¦" β above the passage whose heading IS Revenue, |
| +// and the keyboard prime then sent Enter there. Measured before the fix: |
| +// "june revenue" put the right passage at 5.563 and the coincidence at |
| +// 2.994; "revenue june" put the coincidence FIRST at 5.520. |
| +// |
| +// Before any boost existed the two spellings ranked identically, because |
| +// order could not matter. NEAR restores that. |
| +// |
| +// Broken by replacing NEAR with the ordered phrase β red, then reverted. |
| +// 2026-09-05. |
| +func TestWordOrderDoesNotChangeTheRanking(t *testing.T) { |
| + ix := rankCorpus(t) |
| + if err := ix.Add( |
| + Entry{ChunkID: 80, DocumentID: 80, Ordinal: 0, Heading: "Revenue", Title: "Q3 board pack", |
| + Text: "June revenue was 1,350,000 across all regions, up from 1,180,000."}, |
| + Entry{ChunkID: 81, DocumentID: 81, Ordinal: 0, Heading: "Costs", Title: "Q3 board pack", |
| + Text: "Hosting fell, which helped revenue. June also saw the migration finish."}, |
| + ); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + rank := func(q string) []int64 { |
| + hits, err := ix.Search(q, 25) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var ids []int64 |
| + for _, h := range hits { |
| + if h.ChunkID == 80 || h.ChunkID == 81 { |
| + ids = append(ids, h.ChunkID) |
| + } |
| + } |
| + return ids |
| + } |
| + forward, reversed := rank("june revenue"), rank("revenue june") |
| + if len(forward) != 2 || len(reversed) != 2 { |
| + t.Fatalf("both passages must come back either way round: forward=%v reversed=%v", forward, reversed) |
| + } |
| + if forward[0] != 80 { |
| + t.Errorf(`"june revenue" ranked chunk %d first, want 80`, forward[0]) |
| + } |
| + if reversed[0] != forward[0] { |
| + t.Errorf(`"revenue june" ranked chunk %d first but "june revenue" ranked chunk %d β `+ |
| + `word order is changing the answer`, reversed[0], forward[0]) |
| + } |
| +} |
| + |
| +// A search box is not a document. Without a cap the OR chain is |
| +// quadratic: 2.8ms at 100 terms, 97ms at 1000, 2.25s at 5000 β all of it |
| +// under the index's read lock, so one pasted essay stalls ingest. |
| +// |
| +// Broken by removing the cap β red on the timing assertion, then |
| +// reverted. 2026-09-05. |
| +func TestAbsurdlyLongQueryIsCapped(t *testing.T) { |
| + var sb strings.Builder |
| + for i := 0; i < 5000; i++ { |
| + fmt.Fprintf(&sb, "term%d ", i) |
| + } |
| + q := MatchQuery(sb.String()) |
| + if n := strings.Count(q, " OR ") + 1; n > 66 { |
| + t.Errorf("a 5000-term query produced %d operands; the cap is not holding", n) |
| + } |
| + |
| + // And it must still run quickly against a real index, which is the |
| + // property the cap exists for rather than the operand count. |
| + ix := rankCorpus(t) |
| + start := time.Now() |
| + if _, err := ix.Search(sb.String(), 25); err != nil { |
| + t.Fatalf("a very long query errored: %v", err) |
| + } |
| + if d := time.Since(start); d > 250*time.Millisecond { |
| + t.Errorf("a 5000-term search took %s; it holds the index read lock throughout", d) |
| + } |
| +} |
diff --git a/internal/memoria/query/question.go b/internal/memoria/query/question.go| new file mode 100644 |
| index 0000000..e263d44 |
| --- /dev/null |
| +++ b/internal/memoria/query/question.go |
| @@ -0,0 +1,83 @@ |
| +// Package query reads the shape of a search box, not its meaning. |
| +// |
| +// It exists so that one decision β is this person looking something up, |
| +// or asking about the corpus? β has a single home, is unit-testable |
| +// without a browser, and is made on the server. The browser script that |
| +// acts on it only honours a flag the server set; it does not re-derive |
| +// the rule, because two copies of a heuristic drift and the drift is |
| +// silent. |
| +package query |
| + |
| +import ( |
| + "strings" |
| + "unicode" |
| +) |
| + |
| +// openers are the words that, first in a query, mean the person is |
| +// asking rather than looking up. |
| +// |
| +// Interrogatives and auxiliaries only. Each one costs a false positive |
| +// when it is also an ordinary search term ("will" the noun, "can" the |
| +// verb), and that trade is deliberately one-sided: a missed prime makes |
| +// someone press Enter twice, while a wrong prime navigates away from a |
| +// page they were still reading. The safe direction is not priming. |
| +var openers = map[string]bool{ |
| + "who": true, "whom": true, "whose": true, "what": true, "when": true, |
| + "where": true, "which": true, "why": true, "how": true, |
| + |
| + "am": true, "is": true, "are": true, "was": true, "were": true, |
| + "do": true, "does": true, "did": true, |
| + "can": true, "could": true, "should": true, "would": true, "will": true, |
| + "has": true, "have": true, "had": true, "may": true, "might": true, |
| + "must": true, |
| +} |
| + |
| +// PrimesFirstResult reports whether the first of results results should |
| +// be the one Enter goes to. |
| +// |
| +// Both terms of the decision live here rather than at the call site, |
| +// because only one of them is visible from outside. An HTTP test cannot |
| +// see the results > 0 term at all: with no results the template renders |
| +// no list items, so priming nothing and priming a result that does not |
| +// exist produce byte-identical markup. A guard whose failure is |
| +// invisible is a guard written to pass, so this is the level the guard |
| +// is tested at. |
| +func PrimesFirstResult(q string, results int) bool { |
| + return results > 0 && !IsQuestion(q) |
| +} |
| + |
| +// IsQuestion reports whether a query reads as a question about the |
| +// corpus rather than a lookup in it. |
| +// |
| +// Two signals, both cheap and both explicable to the person typing: it |
| +// ends in a question mark, or it opens with an interrogative or an |
| +// auxiliary. Nothing about length β "notes from the March offsite" is |
| +// six words and still a lookup, and a rule that punished it would make |
| +// Enter unreliable in exactly the case it exists to serve. |
| +func IsQuestion(q string) bool { |
| + q = strings.TrimSpace(q) |
| + if q == "" { |
| + return false |
| + } |
| + if strings.HasSuffix(q, "?") { |
| + return true |
| + } |
| + return openers[firstWord(q)] |
| +} |
| + |
| +// firstWord is the leading run of letters, lowercased. |
| +// |
| +// Letters only, and only the leading run, so that "who's" opens with |
| +// "who" while "whatever" and "canary" open with themselves β a prefix |
| +// match here would have made every query beginning "canβ¦" or "willβ¦" |
| +// unprimeable. |
| +func firstWord(q string) string { |
| + var b strings.Builder |
| + for _, r := range q { |
| + if !unicode.IsLetter(r) { |
| + break |
| + } |
| + b.WriteRune(unicode.ToLower(r)) |
| + } |
| + return b.String() |
| +} |
diff --git a/internal/memoria/query/question_test.go b/internal/memoria/query/question_test.go| new file mode 100644 |
| index 0000000..2316998 |
| --- /dev/null |
| +++ b/internal/memoria/query/question_test.go |
| @@ -0,0 +1,89 @@ |
| +package query |
| + |
| +import "testing" |
| + |
| +// Broken by dropping the results > 0 term β red, then reverted. |
| +// 2026-09-05. This test exists because the HTTP-level version of it |
| +// could not fail: with no results the template renders no list items, so |
| +// a wrongly-primed empty page and a correctly-unprimed one are the same |
| +// bytes. |
| +func TestPrimesFirstResult(t *testing.T) { |
| + cases := []struct { |
| + q string |
| + results int |
| + want bool |
| + }{ |
| + {"pass", 3, true}, |
| + {"pass", 1, true}, |
| + {"pass", 0, false}, // nothing to prime |
| + {"what did we decide about passwords", 3, false}, |
| + {"what did we decide about passwords", 0, false}, |
| + {"", 0, false}, |
| + } |
| + for _, c := range cases { |
| + if got := PrimesFirstResult(c.q, c.results); got != c.want { |
| + t.Errorf("PrimesFirstResult(%q, %d) = %v, want %v", c.q, c.results, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +// The whole point of the heuristic is which side of the line a real |
| +// query falls on, so the table is real queries, not shapes. |
| +func TestIsQuestion(t *testing.T) { |
| + lookups := []string{ |
| + "pass", |
| + "password rotation policy", |
| + "notes from the March offsite", |
| + "Q3 board pack", |
| + "revenue", |
| + "DEK", |
| + "hosting migration", |
| + "whatever happened", // not "what" β a prefix must not match |
| + "canary tokens", // not "can" |
| + "willingness to pay", |
| + "doe, jane", |
| + "HOSTING", |
| + "", |
| + " ", |
| + } |
| + for _, q := range lookups { |
| + if IsQuestion(q) { |
| + t.Errorf("IsQuestion(%q) = true, want false: a lookup must prime the first result", q) |
| + } |
| + } |
| + |
| + questions := []string{ |
| + "what did we decide about passwords", |
| + "What did we decide about passwords?", |
| + "who owns billing", |
| + "when did hosting fall", |
| + "where is the DEK stored", |
| + "why did revenue fall", |
| + "how do we rotate the key", |
| + "which region grew", |
| + "whose account is this", |
| + "whom should I ask", |
| + "is the DEK rotated", |
| + "are the backups sealed", |
| + "was the offsite in March", |
| + "were costs down", |
| + "do we have a runbook", |
| + "does anyone own this", |
| + "did we ship it", |
| + "can I delete a document", |
| + "could we index PDFs", |
| + "should the index be rebuilt", |
| + "would that break search", |
| + "will we need SMTP", |
| + "has anyone written this up", |
| + "have we agreed the price", |
| + "am I on the owners list", |
| + "revenue?", // a bare term with a question mark is still a question |
| + " why? ", |
| + } |
| + for _, q := range questions { |
| + if !IsQuestion(q) { |
| + t.Errorf("IsQuestion(%q) = false, want true: a question must not prime the first result", q) |
| + } |
| + } |
| +} |
diff --git a/internal/memoria/render.go b/internal/memoria/render.go| index b02e675..53738ca 100644 |
| --- a/internal/memoria/render.go |
| +++ b/internal/memoria/render.go |
| @@ -25,22 +25,38 @@ var assets = rastrillo.NewAssets(appFS) |
| // template named "content" without colliding with the others. |
| var pages = map[string]*template.Template{} |
| +// fragments holds the templates that are also served on their own, with |
| +// no layout around them. results.html is parsed into both this and every |
| +// page, so the region search.js swaps in on a keystroke and the region a |
| +// scriptless form submit renders are the same markup from the same file. |
| +var fragments *template.Template |
| + |
| +// funcs is shared by both sets. Two call sites, one map: a helper added |
| +// for a page and forgotten for the fragment would fail at execute time, |
| +// on the request, rather than at parse time. |
| +func funcs() template.FuncMap { |
| + return template.FuncMap{ |
| + "asset": assets.Path, |
| + // The app's own icon set (internal/memoria/icons), scaffolded |
| + // by rastrillo new. iconAssets is whatever <head> markup the |
| + // chosen delivery needs β empty for the vendored-inline |
| + // default, so the layout calls it unconditionally and |
| + // switching delivery later needs no template edit. |
| + "icon": icons.Icon, |
| + "iconAssets": icons.Assets, |
| + "safeSnippet": safeSnippet, |
| + } |
| +} |
| + |
| func init() { |
| for _, name := range []string{"index", "document", "signin", "connectors", "notion"} { |
| pages[name] = template.Must(template.New("layout"). |
| - Funcs(template.FuncMap{ |
| - "asset": assets.Path, |
| - // The app's own icon set (internal/memoria/icons), scaffolded |
| - // by rastrillo new. iconAssets is whatever <head> markup the |
| - // chosen delivery needs β empty for the vendored-inline |
| - // default, so the layout calls it unconditionally and |
| - // switching delivery later needs no template edit. |
| - "icon": icons.Icon, |
| - "iconAssets": icons.Assets, |
| - "safeSnippet": safeSnippet, |
| - }). |
| - ParseFS(appFS, "templates/layout.html", "templates/"+name+".html")) |
| + Funcs(funcs()). |
| + ParseFS(appFS, "templates/layout.html", "templates/results.html", "templates/"+name+".html")) |
| } |
| + fragments = template.Must(template.New("fragments"). |
| + Funcs(funcs()). |
| + ParseFS(appFS, "templates/results.html")) |
| } |
| // render executes name's layout into a buffer before anything touches |
| @@ -56,6 +72,22 @@ func render(w http.ResponseWriter, name string, data any) { |
| buf.WriteTo(w) |
| } |
| +// renderFragment executes one template with no layout around it, for the |
| +// endpoints a script calls rather than a person navigates to. |
| +// |
| +// Buffered for the same reason render is: a template error becomes a |
| +// clean 500 rather than half a list of results, and a half-swapped |
| +// results region is the plausible-empty-list failure wearing a hat. |
| +func renderFragment(w http.ResponseWriter, name string, data any) { |
| + var buf bytes.Buffer |
| + if err := fragments.ExecuteTemplate(&buf, name, data); err != nil { |
| + http.Error(w, "something went wrong", http.StatusInternalServerError) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + buf.WriteTo(w) |
| +} |
| + |
| // safeSnippet renders an FTS5 snippet, which arrives as document text with |
| // <mark> tags inserted around the matched terms. |
| // |
diff --git a/internal/memoria/static/memoria.css b/internal/memoria/static/memoria.css| index 5a7a240..4bfb536 100644 |
| --- a/internal/memoria/static/memoria.css |
| +++ b/internal/memoria/static/memoria.css |
| @@ -235,6 +235,42 @@ |
| } |
| [rst-result-meta] { font-size: 0.8125rem; margin: 0; } |
| +/* The results on screen no longer describe what is in the box, because |
| + the last live search did not run. Shown, not merely announced: the |
| + status region is sr-only, so without this a sighted reader sees older |
| + results and nothing saying they are older β the confident-zero failure |
| + AGENTS.md names, in its politest form. |
| + |
| + The sentence comes from the region's own rst-search-stale-note, which |
| + the server wrote from the same {{define "searchFailed"}} as the |
| + server-rendered callout. CSS renders the attribute so the words stay |
| + in the template and no script ever composes a sentence. */ |
| +[rst-search-stale]::before { |
| + color: var(--rst-tone-negative-fg, var(--rst-text)); |
| + content: attr(rst-search-stale-note); |
| + display: block; |
| + font-size: 0.9375rem; |
| + margin-bottom: 0.9rem; |
| +} |
| + |
| +/* The primed result: the one Enter goes to. Deliberately the same mark |
| + as [rst-passage]:target on the document page, because it means the |
| + same thing in both places β this is the one you are being sent to. |
| + Set by the server, never by the script; see internal/memoria/query. */ |
| +[rst-result-primed] { |
| + border-inline-start: 3px solid var(--rst-accent, var(--rst-text)); |
| + padding-inline-start: 0.7rem; |
| +} |
| +[rst-result-primed]:first-child { border-top: 0; } |
| +/* Arrowing into the list moves real focus, so the browser's own ring is |
| + the selection indicator. It is stated here only to give it room: the |
| + default ring sits tight against 600-weight text and is easy to miss |
| + when the eye is on the input. */ |
| +[rst-result-title]:focus-visible { |
| + outline: 2px solid var(--rst-accent, var(--rst-text)); |
| + outline-offset: 3px; |
| +} |
| + |
| [rst-prose] { line-height: 1.6; } |
| [rst-passage] { border-top: 1px solid var(--rst-line); padding: 0.8rem 0; } |
| [rst-passage]:target { border-inline-start: 3px solid var(--rst-accent, var(--rst-text)); padding-inline-start: 0.7rem; } |
diff --git a/internal/memoria/static/search.js b/internal/memoria/static/search.js| new file mode 100644 |
| index 0000000..4542bc2 |
| --- /dev/null |
| +++ b/internal/memoria/static/search.js |
| @@ -0,0 +1,346 @@ |
| +/* search.js β live search, keyboard navigation, and Enter. |
| + * |
| + * App-owned, not vendored: rastrillo.js and select.js come from the |
| + * library and are pinned byte-identical by a gate; this file and drop.js |
| + * are memoria's. |
| + * |
| + * Same house rules as its neighbours: first-party, dependency-free, and |
| + * an enhancement rather than a mechanism. Delete this file and the |
| + * search form is still a GET form to "/" that still returns results β |
| + * what is lost is that the results arrive as you type and that Enter can |
| + * take you straight into the first one. |
| + * |
| + * Three behaviours, in the order they matter: |
| + * |
| + * Live. Debounced fetch of /search?q=β¦, whose response is the same |
| + * results template the page renders. The server sends markup, |
| + * not JSON, so a snippet is escaped once, on the server, by |
| + * the code that already does it β the browser never builds |
| + * HTML out of a document's text. |
| + * |
| + * Enter. Goes to the primed result, if there is one. WHICH result is |
| + * primed, and whether anything is primed at all, is the |
| + * server's decision (internal/memoria/query): a query that |
| + * reads as a question about the corpus primes nothing, because |
| + * navigating away from a page someone is still reading is a |
| + * worse failure than making them press Enter twice. This file |
| + * reads an attribute. It does not re-derive the rule. |
| + * |
| + * Arrows. Move real DOM focus onto the result links, rather than |
| + * mirroring a selection with aria-activedescendant. A focused |
| + * link needs no ARIA to be announced, gets the browser's own |
| + * focus ring, and Enter on it is just a link being followed. |
| + * |
| + * SHARING THE REGION WITH drop.js. Both write to [rst-refresh="results"] |
| + * and neither owns it. drop.js re-fetches pathname+search when a drop |
| + * settles and REPLACES that node, so this file never holds a reference |
| + * to it β every read re-queries. In exchange, this file keeps |
| + * location.search in step with the box, so drop's re-fetch re-renders |
| + * the search that is actually on screen rather than painting the URL's |
| + * older query over it. |
| + * |
| + * WHAT IS ON SCREEN. The server stamps the query it rendered onto the |
| + * region as rst-search-results-for, and that attribute β never the input |
| + * β is the answer to "what do these results describe". The box and the |
| + * screen disagree more often than is comfortable: during a debounce, |
| + * while a fetch is in the air, and after a Back, where the browser |
| + * restores the input's value on a schedule of its own that no script |
| + * should be relying on. Every Enter checks the two against each other, |
| + * and where they differ it runs the search first and acts on the answer, |
| + * so a fast typist gets the same document as a slow one. */ |
| +(function () { |
| + "use strict"; |
| + |
| + var DEBOUNCE_MS = 120; |
| + |
| + var form = document.querySelector("form[rst-search]"); |
| + var input = form && form.querySelector('input[type="search"]'); |
| + var status = document.querySelector("[rst-search-status]"); |
| + if (!form || !input) return; |
| + if (!window.fetch || !window.AbortController) return; |
| + if (!region()) return; |
| + |
| + var timer = null; |
| + var inflight = null; |
| + var inflightFor = null; |
| + |
| + // Re-queried, never cached: drop.js replaces this node outright when a |
| + // drop settles, and a held reference would have this file writing into |
| + // a detached element for the rest of the session. |
| + function region() { |
| + return document.querySelector('[rst-refresh="results"]'); |
| + } |
| + |
| + /* rendered is the query the markup on screen was produced from, as the |
| + SERVER reported it, or null when that cannot be determined β after a |
| + failed fetch, or after drop.js swapped in a region this file has not |
| + read. Null is not "no query": it means Enter must not act on what is |
| + on screen, and falls through to a real form submit. */ |
| + function rendered() { |
| + var el = region(); |
| + if (!el || !el.hasAttribute("rst-search-results-for")) return null; |
| + return el.getAttribute("rst-search-results-for"); |
| + } |
| + |
| + function fresh() { |
| + var r = rendered(); |
| + return r !== null && r === input.value.trim(); |
| + } |
| + |
| + function primedLink() { |
| + var el = region(); |
| + var li = el && el.querySelector("[rst-result-primed]"); |
| + return li && li.querySelector("[rst-result-title]"); |
| + } |
| + |
| + function links() { |
| + var el = region(); |
| + if (!el) return []; |
| + return Array.prototype.slice.call( |
| + el.querySelectorAll("[rst-result-title]") |
| + ); |
| + } |
| + |
| + /* search fetches the results region for q and swaps it in, resolving |
| + once the swap has happened so Enter can wait on it. |
| + |
| + It rejects when the answer never arrived or is no longer wanted, and |
| + the two are not the same thing: a REJECTION IS NOT A FAILURE. An |
| + aborted or superseded fetch was replaced by a newer one that is |
| + still coming, and treating it as a failure is how repeatedly |
| + pressing Enter used to end up on the search page instead of in the |
| + document. Callers check err.stale. */ |
| + function search(q) { |
| + if (inflight) inflight.abort(); |
| + var ctl = new AbortController(); |
| + inflight = ctl; |
| + inflightFor = q; |
| + return fetch("/search?q=" + encodeURIComponent(q), { |
| + signal: ctl.signal, |
| + credentials: "same-origin", |
| + headers: { Accept: "text/html" }, |
| + }) |
| + .then(function (res) { |
| + if (ctl !== inflight) throw stale(); |
| + if (!res.ok) throw new Error("search " + res.status); |
| + // A session that ran out answers a GET with a 303 to /signin, |
| + // which fetch follows and reports as a perfectly good 200. Left |
| + // unchecked, the sign-in page goes into the results region β a |
| + // whole document nested inside a list, with the stamp below |
| + // vouching for it as the answer to what is in the box. drop.js |
| + // refuses the same page for the same reason; this is that guard. |
| + if (res.redirected || new URL(res.url).pathname !== "/search") { |
| + throw new Error("not the results"); |
| + } |
| + return res.text(); |
| + }) |
| + .then(function (html) { |
| + // A response that lost the race must not overwrite a newer one. |
| + // The abort above usually prevents this; this is the check that |
| + // holds when it does not. |
| + if (ctl !== inflight) throw stale(); |
| + inflight = inflightFor = null; |
| + put(html, q); |
| + }) |
| + .catch(function (err) { |
| + if (err && err.stale) throw err; |
| + if (err && err.name === "AbortError") throw stale(); |
| + if (ctl === inflight) { |
| + inflight = inflightFor = null; |
| + // Not "no results". The markup on screen stays β it is the |
| + // last true thing this page had β but it is no longer allowed |
| + // to speak for the box, so the stamp comes off and Enter falls |
| + // through to a real submit rather than following a stale link. |
| + // And the page SAYS so: results that silently describe an |
| + // older query are the confident-zero failure wearing a hat. |
| + var el = region(); |
| + if (el) { |
| + el.removeAttribute("rst-search-results-for"); |
| + // Said out loud AND shown. The status region is sr-only, so |
| + // announcing alone leaves a sighted reader looking at older |
| + // results with nothing on screen admitting it β which is the |
| + // confident-zero failure with better manners. The sentence |
| + // itself stays in the template; CSS renders the attribute. |
| + el.setAttribute("rst-search-stale", ""); |
| + } |
| + announce(el && el.getAttribute("rst-search-stale-note")); |
| + } |
| + throw err; |
| + }); |
| + } |
| + |
| + function stale() { |
| + var e = new Error("superseded"); |
| + e.stale = true; |
| + return e; |
| + } |
| + |
| + /* put swaps the markup in and restores the keyboard. |
| + |
| + Replacing innerHTML under a focused link moves focus to the body, |
| + which on a keyboard-navigation feature means the arrows and Enter |
| + both stop working with nothing on screen saying why. So where focus |
| + was inside the region, it is put back on the result at the same |
| + index β or on the input when the list has grown shorter than the |
| + place the person was standing in. */ |
| + function put(html, q) { |
| + var el = region(); |
| + if (!el) return; |
| + var held = links().indexOf(document.activeElement); |
| + el.innerHTML = html; |
| + el.setAttribute("rst-search-results-for", q); |
| + el.removeAttribute("rst-search-stale"); |
| + if (held >= 0) { |
| + var all = links(); |
| + (all[held] || all[all.length - 1] || input).focus(); |
| + } |
| + // location.search is kept in step so that drop.js's re-fetch of |
| + // pathname+search re-renders THIS search, and so that a reload or a |
| + // bookmark carries the query the screen is showing. replaceState, |
| + // not pushState: a debounce is not a navigation, and forty history |
| + // entries for one typed word is a Back button nobody can use. |
| + if (window.history && history.replaceState) { |
| + var url = q === "" ? location.pathname : location.pathname + "?q=" + encodeURIComponent(q); |
| + history.replaceState(null, "", url); |
| + // drop.js sets the tray's reload link once, at load, from the URL |
| + // as it was then. Its own template promises that link keeps the |
| + // search on screen β true before live search existed, and false |
| + // from the first keystroke unless it is repointed here. |
| + var reload = document.querySelector("[rst-tray-reload]"); |
| + if (reload) reload.setAttribute("href", url); |
| + } |
| + announce(); |
| + } |
| + |
| + /* announce repeats the heading the server just sent into the live |
| + region, so a screen reader hears the count change. Reusing the |
| + server's sentence rather than composing one here keeps every string |
| + a person hears in a template, where it is written and reviewed. |
| + |
| + A fragment with no heading β the search-failed callout β falls back |
| + to that callout's own words, so silence never stands in for a |
| + failure. */ |
| + function announce(override) { |
| + if (!status) return; |
| + if (override) { |
| + status.textContent = override; |
| + return; |
| + } |
| + var el = region(); |
| + var said = el && el.querySelector("h2, [rst-callout-body]"); |
| + status.textContent = said ? said.textContent.trim() : ""; |
| + } |
| + |
| + function schedule() { |
| + if (timer) clearTimeout(timer); |
| + var q = input.value.trim(); |
| + // An in-flight fetch for some OTHER query is now answering a |
| + // question nobody is asking. Dropping it here rather than leaving it |
| + // to land is the difference between a list that matches the box and |
| + // one that quietly describes a keystroke that was taken back. |
| + if (inflight && inflightFor !== q) { |
| + inflight.abort(); |
| + inflight = inflightFor = null; |
| + } |
| + if (rendered() === q) return; |
| + timer = setTimeout(function () { |
| + timer = null; |
| + search(input.value.trim()).catch(function () {}); |
| + }, DEBOUNCE_MS); |
| + } |
| + |
| + /* submit is the fallback for everything this file cannot do |
| + confidently: nothing primed, a failed fetch, a state it cannot |
| + vouch for. form.submit bypasses the submit event, so it cannot |
| + re-enter this handler. */ |
| + function submit() { |
| + form.submit(); |
| + } |
| + |
| + function follow() { |
| + var link = primedLink(); |
| + if (!link) return submit(); |
| + window.location.assign(link.href); |
| + } |
| + |
| + /* Enter is handled on the input's keydown rather than on the form's |
| + submit, because submit cannot tell Enter from a click on the Search |
| + button and the two mean different things: Enter means "take me |
| + there", a click on a button labelled Search means "search". Reading |
| + e.submitter does not separate them β it is the same default button |
| + either way. */ |
| + input.addEventListener("keydown", function (e) { |
| + if (e.key !== "Enter" || e.isComposing || e.keyCode === 229) return; |
| + if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return; |
| + var q = input.value.trim(); |
| + if (q === "") return; // the empty search behaves as it always did |
| + |
| + if (fresh()) { |
| + if (!primedLink()) return; // a question, or nothing found: submit |
| + e.preventDefault(); |
| + follow(); |
| + return; |
| + } |
| + // What is on screen is older than what is in the box. Find out |
| + // before deciding, so a fast typist gets the same answer as a slow |
| + // one rather than the previous query's first result. |
| + e.preventDefault(); |
| + if (timer) { |
| + clearTimeout(timer); |
| + timer = null; |
| + } |
| + search(q).then(follow, function (err) { |
| + // A superseded search means a newer one is already on its way and |
| + // will do this; only a real failure falls back to the form. |
| + if (!err || !err.stale) submit(); |
| + }); |
| + }); |
| + |
| + input.addEventListener("input", schedule); |
| + |
| + /* A Back that restores the box without re-running the script leaves |
| + the input saying one thing and the screen another. The server's |
| + stamp is what settles it: where they differ, ask. pageshow rather |
| + than load, because it fires for a bfcache restore too, which is the |
| + case that has no load at all. */ |
| + window.addEventListener("pageshow", function () { |
| + if (!fresh() && input.value.trim() !== "") schedule(); |
| + }); |
| + |
| + input.addEventListener("keydown", function (e) { |
| + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; |
| + var all = links(); |
| + if (!all.length) return; |
| + e.preventDefault(); |
| + (e.key === "ArrowDown" ? all[0] : all[all.length - 1]).focus(); |
| + }); |
| + |
| + /* Keyboard handling for the list is delegated to the document, not |
| + bound to the region: the links inside it are replaced on every |
| + keystroke, and the region ITSELF is replaced whenever a drop |
| + settles, so a listener on either would not survive. */ |
| + document.addEventListener("keydown", function (e) { |
| + var link = e.target.closest && e.target.closest("[rst-result-title]"); |
| + if (!link || !region() || !region().contains(link)) return; |
| + |
| + if (e.key === "Escape") { |
| + e.preventDefault(); |
| + input.focus(); |
| + return; |
| + } |
| + if (e.key === "ArrowDown" || e.key === "ArrowUp") { |
| + var all = links(); |
| + var i = all.indexOf(link); |
| + e.preventDefault(); |
| + if (e.key === "ArrowUp" && i === 0) return input.focus(); |
| + var next = all[e.key === "ArrowDown" ? i + 1 : i - 1]; |
| + if (next) next.focus(); |
| + return; |
| + } |
| + // Typing while a result is focused means the person has gone back to |
| + // searching. Move the caret without swallowing the character. |
| + if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) { |
| + input.focus(); |
| + } |
| + }); |
| +})(); |
diff --git a/internal/memoria/templates/index.html b/internal/memoria/templates/index.html| index e709867..37185ee 100644 |
| --- a/internal/memoria/templates/index.html |
| +++ b/internal/memoria/templates/index.html |
| @@ -25,41 +25,26 @@ |
| because a swap replaces the element whole: a half-typed paste or a |
| row still uploading would go with it. |
| TestTheRefreshedRegionsCannotTakeTheFormOrTheTrayWithThem holds |
| - that, and it is the property the whole design rests on. */}} |
| -<div rst-refresh="results"> |
| -{{if .Searched}} |
| - <h2>{{len .Results}} result{{if ne (len .Results) 1}}s{{end}} for β{{.Query}}β</h2> |
| - {{if not .Results}} |
| - <div rst-callout="quiet"> |
| - <p rst-callout-body> |
| - {{if lt .Chunks 0}} |
| - Nothing matched β but the passage count could not be read, so |
| - this may be an empty index rather than a real absence. |
| - {{else}} |
| - Nothing matched. There {{if eq .Chunks 1}}is{{else}}are{{end}} {{.Chunks}} passage{{if ne .Chunks 1}}s{{end}} |
| - indexed, so this is a real absence rather than an empty index. |
| - {{end}} |
| - </p> |
| - </div> |
| - {{end}} |
| - <ol rst-results> |
| - {{range .Results}} |
| - <li rst-result> |
| - {{/* Ancestry above the title, heading trail below it. Both, and in |
| - that order, because they answer different questions: the path |
| - says which part of the workspace this came from, the heading |
| - says where in the page. A result showing only the second is |
| - two indistinguishable "Week one"s under different handbooks. */}} |
| - {{if .Path}}<span rst-result-path rst-cell-mut>{{.Path}}</span>{{end}} |
| - <a href="/documents/{{.DocumentID}}#p{{.Ordinal}}" rst-result-title>{{.Title}}</a> |
| - {{if .Heading}}<span rst-result-trail>{{.Heading}}</span>{{end}} |
| - <p rst-result-snippet>{{.Snippet | safeSnippet}}</p> |
| - <p rst-result-meta rst-cell-mut>{{.Source}} Β· passage {{.Ordinal}}</p> |
| - </li> |
| - {{end}} |
| - </ol> |
| -{{end}} |
| -</div> |
| + that, and it is the property the whole design rests on. |
| + |
| + search.js writes into this SAME region β one region, two writers, |
| + deliberately. Two divs would have meant two answers to "what is on |
| + screen" and a drop landing mid-search repainting one of them. |
| + Because drop.js replaces the node rather than its contents, |
| + search.js re-queries this element every time instead of holding a |
| + reference, and it keeps location.search in step so that drop's |
| + re-fetch of pathname+search re-renders the search actually on |
| + screen. |
| + |
| + rst-search-results-for is the query these results were rendered |
| + from, written by the server. search.js reads it instead of assuming |
| + the box and the screen agree: after a Back, a browser restores the |
| + input's value on its own schedule, and a script that trusted the |
| + box would think a stale screen was current. */}} |
| +<p class="rst-sr-only" role="status" rst-search-status></p> |
| +<div rst-refresh="results" |
| + rst-search-results-for="{{.Query}}" |
| + rst-search-stale-note="{{template "searchFailed"}}">{{template "results" .}}</div> |
| <section rst-section> |
| <h2>Add something</h2> |
diff --git a/internal/memoria/templates/layout.html b/internal/memoria/templates/layout.html| index 275d3cb..a8f3b4d 100644 |
| --- a/internal/memoria/templates/layout.html |
| +++ b/internal/memoria/templates/layout.html |
| @@ -19,6 +19,9 @@ |
| linked here rather than in index.html because the layout is where |
| scripts are linked, and it costs a querySelector to load it twice. */}} |
| <script defer src="{{asset "static/drop.js"}}"></script> |
| +{{/* search.js is the same bargain, keyed on [rst-search]. It shares the |
| + results region with drop.js and does not assume it owns it. */}} |
| +<script defer src="{{asset "static/search.js"}}"></script> |
| {{iconAssets}} |
| {{block "head" .}}{{end}} |
| </head> |
diff --git a/internal/memoria/templates/results.html b/internal/memoria/templates/results.html| new file mode 100644 |
| index 0000000..1c9ec8a |
| --- /dev/null |
| +++ b/internal/memoria/templates/results.html |
| @@ -0,0 +1,51 @@ |
| +{{/* The results region, rendered two ways from one definition. |
| + |
| + index.html includes it, so a scriptless form submit gets it inside |
| + the page; GET /search executes it alone, so search.js can swap it in |
| + on every keystroke. One definition because the two must agree about |
| + which result is primed β if they disagreed, where Enter goes would |
| + depend on whether the script had loaded yet. */}} |
| +{{/* One sentence, two places that must not disagree: the callout the |
| + server renders when store.Search fails, and the attribute search.js |
| + reads when its own fetch fails. Both mean "the search did not run", |
| + and a page with two wordings for that would be two pages. */}} |
| +{{define "searchFailed"}}The search did not run. Nothing was found because nothing was searched.{{end}} |
| + |
| +{{define "results"}} |
| +{{if .SearchFailed}} |
| + <div rst-callout="danger"> |
| + <p rst-callout-body>{{template "searchFailed"}}</p> |
| + </div> |
| +{{else if .Searched}} |
| + <h2>{{len .Results}} result{{if ne (len .Results) 1}}s{{end}} for β{{.Query}}β</h2> |
| + {{if not .Results}} |
| + <div rst-callout="quiet"> |
| + <p rst-callout-body> |
| + {{if lt .Chunks 0}} |
| + Nothing matched β but the passage count could not be read, so |
| + this may be an empty index rather than a real absence. |
| + {{else}} |
| + Nothing matched. There {{if eq .Chunks 1}}is{{else}}are{{end}} {{.Chunks}} passage{{if ne .Chunks 1}}s{{end}} |
| + indexed, so this is a real absence rather than an empty index. |
| + {{end}} |
| + </p> |
| + </div> |
| + {{end}} |
| + <ol rst-results> |
| + {{range $i, $r := .Results}} |
| + <li rst-result{{if and $.Primed (eq $i 0)}} rst-result-primed{{end}}> |
| + {{/* Ancestry above the title, heading trail below it. Both, and in |
| + that order, because they answer different questions: the path |
| + says which part of the workspace this came from, the heading |
| + says where in the page. A result showing only the second is |
| + two indistinguishable "Week one"s under different handbooks. */}} |
| + {{if $r.Path}}<span rst-result-path rst-cell-mut>{{$r.Path}}</span>{{end}} |
| + <a href="/documents/{{$r.DocumentID}}#p{{$r.Ordinal}}" rst-result-title>{{$r.Title}}</a> |
| + {{if $r.Heading}}<span rst-result-trail>{{$r.Heading}}</span>{{end}} |
| + <p rst-result-snippet>{{$r.Snippet | safeSnippet}}</p> |
| + <p rst-result-meta rst-cell-mut>{{$r.Source}} Β· passage {{$r.Ordinal}}</p> |
| + </li> |
| + {{end}} |
| + </ol> |
| +{{end}} |
| +{{end}} |
diff --git a/internal/memoriatest/gates_test.go b/internal/memoriatest/gates_test.go| index 3c15b1d..0d5179f 100644 |
| --- a/internal/memoriatest/gates_test.go |
| +++ b/internal/memoriatest/gates_test.go |
| @@ -375,6 +375,8 @@ func TestNoContentInLogs(t *testing.T) { |
| submitJSON(t, h, file{secret + "-async.png", "\x00\x00 binary payload"}) |
| get(t, h, "/?q="+secret) |
| get(t, h, "/?q=revenue") |
| + get(t, h, "/search?q="+secret) |
| + get(t, h, "/search?q=revenue") |
| // Now a file whose store genuinely fails. Last, because it takes the |
| // database away from everything above. |
| @@ -409,7 +411,10 @@ func TestNoContentInLogs(t *testing.T) { |
| // this fail. Reverted. |
| func TestUnauthenticatedAppRefuses(t *testing.T) { |
| h := newProductionApp(t) |
| - for _, target := range []string{"/", "/documents/1", "/?q=revenue"} { |
| + // /search is on this list because it is the newest way to read the |
| + // corpus and the easiest one to mount outside the group by accident: |
| + // it is not a page, so nothing about it looks like a door. |
| + for _, target := range []string{"/", "/documents/1", "/?q=revenue", "/search?q=revenue"} { |
| rec := get(t, h, target) |
| if rec.Code == http.StatusOK { |
| t.Errorf("GET %s served 200 with no sign-in configured; requireSession fails open", target) |
diff --git a/internal/memoriatest/routes_test.go b/internal/memoriatest/routes_test.go| index e262f0e..45bcfb5 100644 |
| --- a/internal/memoriatest/routes_test.go |
| +++ b/internal/memoriatest/routes_test.go |
| @@ -60,8 +60,16 @@ func TestNoUndeclaredRoutes(t *testing.T) { |
| // wrong, which Β§8.4 records. Adding to this list is a decision, which |
| // is the point of it being a list. |
| declaredChi := map[string]bool{ |
| - "GET /signin": true, |
| - "GET /": true, |
| + "GET /signin": true, |
| + "GET /": true, |
| + // GET /search renders the results region alone, from the same |
| + // template GET /?q= renders it inside a page, so live search can |
| + // replace that region on a keystroke. Same session gate, no passage |
| + // the page did not already speak; delete the script and nothing |
| + // calls it. Declared 2026-09-06 when this gate caught it arriving |
| + // from main β the second time in one merge that a route could not |
| + // reach the app until somebody wrote it down. |
| + "GET /search": true, |
| "POST /documents": true, |
| "GET /documents/{id}": true, |
| "POST /documents/{id}/delete": true, |
diff --git a/internal/memoriatest/search_test.go b/internal/memoriatest/search_test.go| new file mode 100644 |
| index 0000000..be72140 |
| --- /dev/null |
| +++ b/internal/memoriatest/search_test.go |
| @@ -0,0 +1,272 @@ |
| +package memoriatest |
| + |
| +import ( |
| + "net/http" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// The corpus every test in this file searches. |
| +// |
| +// Two passages match "june revenue", and that is the point rather than |
| +// an incidental. The first version of this file used a corpus where |
| +// every query returned exactly ONE result, which made "the primed one is |
| +// the first one" unfalsifiable: with a single list item, priming the |
| +// first, priming all and priming the last are the same markup. Fable's |
| +// review caught it by mutating the template to `{{if $.Primed}}` and |
| +// watching the suite stay green. Any test below that asserts a POSITION |
| +// must therefore check it has more than one result to be wrong about. |
| +func withCorpus(t *testing.T) http.Handler { |
| + t.Helper() |
| + h := newApp(t) |
| + if rec := store(t, h, "Q3 board pack", |
| + "## Revenue\n\nJune revenue was 1,350,000 across all regions.\n\n"+ |
| + "## Costs\n\nHosting fell after the migration.\n\n"+ |
| + "## Risks\n\nOne customer is 22% of June revenue, and that has not improved.", |
| + ); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("store: status %d, want 303", rec.Code) |
| + } |
| + return h |
| +} |
| + |
| +// results counts the rendered result items, so a positional assertion |
| +// can refuse to run against a list too short to hold a wrong answer. |
| +func results(body string) int { |
| + return strings.Count(body, "<li rst-result") |
| +} |
| + |
| +// A lookup primes the first result, which is what makes Enter go |
| +// somewhere. The attribute is the whole contract between the server's |
| +// heuristic and search.js: the script never re-derives the rule. |
| +// |
| +// Broken by hard-coding p.Primed to false β red, then reverted. |
| +// 2026-09-05. |
| +func TestALookupPrimesTheFirstResult(t *testing.T) { |
| + h := withCorpus(t) |
| + body := get(t, h, "/?q=june+revenue").Body.String() |
| + // Without at least two results this test cannot tell prime-first |
| + // from prime-anything, so it refuses to pretend it did. |
| + if n := results(body); n < 2 { |
| + t.Fatalf("corpus returned %d results; a positional assertion needs at least 2:\n%s", n, body) |
| + } |
| + if !strings.Contains(body, "rst-result-primed") { |
| + t.Errorf("a lookup did not prime a result, so Enter goes nowhere:\n%s", body) |
| + } |
| + if n := strings.Count(body, "rst-result-primed"); n != 1 { |
| + t.Errorf("primed %d results, want exactly 1", n) |
| + } |
| + // The prime must be on the FIRST result, not merely somewhere. |
| + // Split on the <li> opener rather than comparing indexes, because |
| + // "rst-result" is a prefix of "rst-result-primed" and an index |
| + // comparison would be measuring the same attribute twice. |
| + items := strings.Split(body, "<li rst-result") |
| + if !strings.Contains(items[1], "rst-result-primed") { |
| + t.Errorf("the primed result is not the first one:\n%s", body) |
| + } |
| + for i := 2; i < len(items); i++ { |
| + if strings.Contains(items[i], "rst-result-primed") { |
| + t.Errorf("result %d is primed as well as the first", i) |
| + } |
| + } |
| +} |
| + |
| +// Broken by priming on `len(results) > 0` alone, dropping the question |
| +// term β red, then reverted. 2026-09-05. |
| +func TestAQuestionPrimesNothing(t *testing.T) { |
| + h := withCorpus(t) |
| + body := get(t, h, "/?q=what+was+june+revenue").Body.String() |
| + if !strings.Contains(body, "Q3 board pack") { |
| + t.Fatalf("the question found nothing, so this test proves nothing:\n%s", body) |
| + } |
| + if strings.Contains(body, "rst-result-primed") { |
| + t.Error("a question primed a result; Enter would navigate away from " + |
| + "a page the reader was still reading") |
| + } |
| +} |
| + |
| +// There is deliberately no test here for "a page with no results primes |
| +// nothing". It was written, and it could not fail: with no results the |
| +// template renders no list items, so priming nothing and priming a |
| +// result that does not exist are the same bytes. The invariant is real |
| +// and is guarded where it is visible β query.TestPrimesFirstResult. |
| + |
| +// The live-search endpoint returns the results region and nothing else. |
| +// A fragment that shipped a whole page would nest <html> inside the list |
| +// on every keystroke. |
| +// |
| +// Broken by pointing searchResults at render instead of renderFragment β |
| +// red, then reverted. 2026-09-05. |
| +func TestSearchFragmentIsResultsOnly(t *testing.T) { |
| + h := withCorpus(t) |
| + rec := get(t, h, "/search?q=june+revenue") |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("status %d, want 200", rec.Code) |
| + } |
| + body := rec.Body.String() |
| + if !strings.Contains(body, "Q3 board pack") { |
| + t.Errorf("the fragment carries no result:\n%s", body) |
| + } |
| + if !strings.Contains(body, "rst-result-primed") { |
| + t.Error("the fragment does not prime, so live search and a form " + |
| + "submit would disagree about where Enter goes") |
| + } |
| + // rst-refresh is on the list because a fragment that ever grew the |
| + // wrapper would nest a refreshable region inside one, and drop.js |
| + // would then find two regions with the same name. |
| + for _, chrome := range []string{"<html", "<body", "rst-page-header", "Add something", "rst-refresh"} { |
| + if strings.Contains(body, chrome) { |
| + t.Errorf("the fragment carries page chrome (%q); it would nest a "+ |
| + "whole page inside the results list", chrome) |
| + } |
| + } |
| +} |
| + |
| +// The empty query is the state the box starts in and returns to when it |
| +// is cleared. It must render an empty region, not an error and not the |
| +// whole corpus. |
| +func TestSearchFragmentOfAnEmptyQueryIsEmpty(t *testing.T) { |
| + rec := get(t, withCorpus(t), "/search?q=") |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("status %d, want 200", rec.Code) |
| + } |
| + if body := strings.TrimSpace(rec.Body.String()); body != "" { |
| + t.Errorf("an empty query rendered %q, want nothing", body) |
| + } |
| +} |
| + |
| +// The fragment and the full page must agree, because Enter can land on |
| +// either: live search fills the region, and a scriptless submit renders |
| +// the page. If they disagreed, priming would depend on whether JS had |
| +// loaded yet. |
| +func TestFragmentAndPageAgreeOnPriming(t *testing.T) { |
| + h := withCorpus(t) |
| + // Two queries, not three: the no-match case was here and could not |
| + // disagree, because with nothing to render both sides emit the same |
| + // empty region whatever they decided. A case that cannot fail on a |
| + // list of cases that can reads as coverage it is not. |
| + for _, q := range []string{"june+revenue", "what+was+june+revenue"} { |
| + page := strings.Contains(get(t, h, "/?q="+q).Body.String(), "rst-result-primed") |
| + frag := strings.Contains(get(t, h, "/search?q="+q).Body.String(), "rst-result-primed") |
| + if page != frag { |
| + t.Errorf("q=%q: page primed=%v but fragment primed=%v", q, page, frag) |
| + } |
| + } |
| +} |
| + |
| +// When search.js's own fetch fails there is no server response to render |
| +// a callout from, so the page must arrive already carrying the sentence. |
| +// Without it the script has nothing to say and nothing to show, and the |
| +// older results stand on screen as though they were current β which is |
| +// this codebase's worst failure shape, reached by deleting one attribute. |
| +// |
| +// The sentence must be the SAME one the server's callout uses. Two |
| +// wordings for "the search did not run" is a page arguing with itself. |
| +// |
| +// Broken by deleting rst-search-stale-note from index.html β red, then |
| +// reverted. Broken again by changing its wording so the two disagree β |
| +// red, then reverted. 2026-09-05. |
| +func TestThePageCarriesTheFailureSentenceItMayNeed(t *testing.T) { |
| + h, _, d := newAppWithLog(t) |
| + store(t, h, "Q3 board pack", "June revenue was 1,350,000 across all regions.") |
| + |
| + // The sentence the page hands to search.js, for a fetch that never |
| + // answers and so has no callout of its own to render. |
| + carried := attr(t, get(t, h, "/").Body.String(), "rst-search-stale-note") |
| + if carried == "" { |
| + t.Fatal("the page carries no failure sentence, so a failed live search has nothing to show") |
| + } |
| + |
| + // The sentence the SERVER renders when the search itself fails. |
| + // Taking the database away is how that path is reached; nothing a |
| + // well-formed request can carry makes store.Search fail. |
| + d.Close() |
| + broken := get(t, h, "/?q=june+revenue").Body.String() |
| + if !strings.Contains(broken, `rst-callout="danger"`) { |
| + t.Fatalf("a failed search did not render its callout, so there is nothing to compare:\n%s", broken) |
| + } |
| + // Compare the callout's OWN words to the carried ones. Searching the |
| + // whole page for the carried sentence is what this gate did first, |
| + // and it could not fail: that page also carries the attribute, so |
| + // the sentence always found itself and a drifted callout went |
| + // unnoticed. Caught by mutating the attribute's wording. |
| + spoken := calloutBody(broken) |
| + if spoken == "" { |
| + t.Fatalf("could not read the failure callout's own words:\n%s", broken) |
| + } |
| + if spoken != carried { |
| + t.Errorf("the sentence search.js would show and the one the server shows disagree:\n"+ |
| + " server: %q\n carried: %q", spoken, carried) |
| + } |
| + if strings.Contains(broken, "rst-result-primed") { |
| + t.Error("a search that broke primed a result") |
| + } |
| +} |
| + |
| +// calloutBody is the text of the first danger callout's body, trimmed. |
| +func calloutBody(body string) string { |
| + i := strings.Index(body, `rst-callout="danger"`) |
| + if i < 0 { |
| + return "" |
| + } |
| + rest := body[i:] |
| + open := strings.Index(rest, "<p rst-callout-body>") |
| + if open < 0 { |
| + return "" |
| + } |
| + rest = rest[open+len("<p rst-callout-body>"):] |
| + shut := strings.Index(rest, "</p>") |
| + if shut < 0 { |
| + return "" |
| + } |
| + return strings.TrimSpace(rest[:shut]) |
| +} |
| + |
| +// attr pulls one attribute's value out of rendered markup. Small enough |
| +// to be obvious, and obvious beats a parser for one attribute. |
| +func attr(t *testing.T, body, name string) string { |
| + t.Helper() |
| + i := strings.Index(body, name+`="`) |
| + if i < 0 { |
| + return "" |
| + } |
| + rest := body[i+len(name)+2:] |
| + j := strings.Index(rest, `"`) |
| + if j < 0 { |
| + return "" |
| + } |
| + return rest[:j] |
| +} |
| + |
| +// The stamp is what search.js reads to decide whether the results on |
| +// screen still describe what is in the box. Reading the input instead |
| +// was the bug Fable found: after a Back, a browser restores the field on |
| +// a schedule of its own, and a script that trusted it would treat a |
| +// stale screen as current and send Enter to the wrong document. |
| +// |
| +// Broken by deleting rst-search-results-for from index.html β red, then |
| +// reverted. 2026-09-05. |
| +func TestThePageStampsTheQueryItRendered(t *testing.T) { |
| + h := withCorpus(t) |
| + for _, c := range []struct{ url, want string }{ |
| + {"/?q=june+revenue", `rst-search-results-for="june revenue"`}, |
| + {"/", `rst-search-results-for=""`}, |
| + } { |
| + if body := get(t, h, c.url).Body.String(); !strings.Contains(body, c.want) { |
| + t.Errorf("GET %s does not stamp the query it rendered (want %s):\n%s", c.url, c.want, body) |
| + } |
| + } |
| +} |
| + |
| +// search.js is an enhancement. The page it enhances must work with it |
| +// deleted, which means the form is a real GET form to a real route and |
| +// the script is not what makes results appear. |
| +func TestSearchWorksWithoutTheScript(t *testing.T) { |
| + h := withCorpus(t) |
| + body := get(t, h, "/").Body.String() |
| + if !strings.Contains(body, `action="/"`) || !strings.Contains(body, `method="get"`) { |
| + t.Errorf("the search form is not a plain GET form:\n%s", body) |
| + } |
| + if !strings.Contains(get(t, h, "/?q=june+revenue").Body.String(), "Q3 board pack") { |
| + t.Error("a plain form submit does not produce results") |
| + } |
| +} |