tea_dom_search

The whole io surface of the panel: one call, exactly as tea_dom_flags's.

examples/projects/tea_dom_search/src/main.dawn

# The whole io surface of the panel: one call, exactly as tea_dom_flags's.
#
# `serve_with_flags` and not `serve`, because the index is the one thing the
# page has and the guest cannot compute: it is a build product of the site
# generator, fetched beside the reactor and handed over on the first line.
#
#   $ echo '{"op":"init","flags":"{\"root\":\".\",\"index\":{\"v\":1,\
#       \"lang\":\"en\",\"items\":[]}}"}' | dawn run examples/projects/tea_dom_search

use tea_core/app.{update, view}
use tea_dom/node.{Node}
use tea_dom/reactor.{serve_with_flags}
use search.{Model, Msg, init, encode, decode}

pub fn main() -> Unit !io = serve_with_flags(init, encode, decode, upd, vw)

# The two hooks, named rather than passed as lambdas: `serve_with_flags` needs
# a concrete message type to settle `M: Eq` against, and a lambda at the call
# site has no expectation to settle it from.
fn upd(m: Model, msg: Msg) -> Model = update(m, msg)

fn vw(m: Model) -> Node[Msg] = view(m)

examples/projects/tea_dom_search/src/search.dawn

# The pure half of the site's search panel: an index, a query, a selection,
# and the tree those three make. No io, no wire format, no page.
#
# What makes it worth writing in Dawn rather than in the page's own script is
# the three shapes it is built out of, each of which the language has and a
# hand-rolled accumulator does not.
#
#   nested collectors   The result list is grouped, so the view is a collector
#                       inside a collector: [sections] installs one for the
#                       sections and [rows_of] installs another for the rows
#                       of one group, and the inner installation shadows the
#                       outer one for the length of its body. What crosses
#                       from inner to outer is the finished `<li>`, never its
#                       rows. Both are conditional -- a group with no hits
#                       emits nothing at all rather than an empty section --
#                       which is the thing a list of slots cannot express.
#
#   two state cells     [scan] carries `hits` and `chosen` in one
#                       `with handle`. They are not the same quantity: the
#                       first is every match in display order, the second is
#                       the href the selection lands on, and it is decided by
#                       comparing the running ordinal with the selection at
#                       the moment the match is found. Nothing outside the
#                       arm can see the ordinal, and nothing but a second pass
#                       could recover `chosen` from `hits`.
#
#   a reader effect     Every link this view builds needs the page's distance
#                       from the site root, and every label needs its
#                       language. Both are model fields read at the leaves,
#                       nine calls below the model. `Ctx` is installed once at
#                       the top of `view` and asked at the bottom, which is
#                       two parameters that no function in between has to
#                       carry or forward.
#
# The index arrives as flags -- one string the page has and the guest does not
# -- and then rides in the model, because Dawn has no module-level mutable
# state and a reactor turn is a function of its request line. That is a real
# cost and it is stated here rather than hidden: the whole index is decoded on
# every keystroke. It is affordable at this size (a few hundred entries) and
# it is what would have to change first if the index grew.
#
# The bilingual strings below are data, not prose: this application draws
# itself on an English page and on a Chinese one, and the site's own generator
# (site/src/gen/pages.dawn) keeps its two languages in one table for the same
# reason -- a missing one is then a compile error rather than a page that
# renders half-translated.

use std/str
use std/list
use json/value.{Json, JNull, JBool, JInt, JStr, JArr, JObj}
use json/parser.{parse}
use json/render.{render}
use std/map
use tea_core/app.{App, update, view}
use tea_dom/node.{Node}
use tea_dom/dsl.{el, div, span, text, input, on_value, on_key}

## One thing a reader can go to: which part of the site it belongs to, what it
## is called, the one line under the name, and where it lives.
##
## `detail` is a signature for an API entry and the document's name for a
## section; `note` is the first line of a doc comment, or empty. `href` is
## relative to the SITE root, not to the page the panel is open on -- the page
## supplies its own distance and [link] joins the two.
pub type Entry = {
  group: String,
  title: String,
  detail: String,
  note: String,
  href: String,
}

## A match, and its ordinal among all the matches in display order. The
## ordinal is what the arrow keys move and what a row compares itself with to
## know whether it is the selected one.
pub type Hit = { entry: Entry, at: Int }

## Everything a scan of the index answers: the matches, and the href the
## current selection resolves to.
pub type Scan = { hits: List[Hit], chosen: String }

## The whole model. `root` and `lang` come from the page and never change;
## `query`, `sel` and `open` are the session; `goto` is the one field that is
## an instruction rather than a state -- it holds the href Enter chose, the
## page's script reads it out of the document, and the next message clears it.
pub type Model = {
  root: String,
  lang: String,
  entries: List[Entry],
  query: String,
  sel: Int,
  open: Bool,
  goto: String,
}

## What the two listeners mean.
pub type Msg =
  | Typed(text: String)
  | Pressed(key: String)
derive Show

## Where the page is and what language it is in, asked for at the leaves.
##
## Both are `String` because an effect operation cannot take a type parameter
## (spec §6.5) and neither needs one: a root is a relative path and a language
## is its `lang` attribute.
pub effect Ctx {
  fn root() -> String
  fn lang() -> String
}

## One matched entry, in the order the body found it.
pub effect Found {
  fn found(e: Entry) -> Unit
}

## One child, in the order the body wants it. The same shape
## scripts/wasm-dom-contract/collect pins, and the same one the counter's view
## uses; it is spelled here rather than imported because a way of *building* a
## child list is not part of `tea_dom`'s vocabulary yet.
pub effect Emit {
  fn emit(w: Node[Msg]) -> Unit
}

# ---- the four groups ------------------------------------------------------

## A group of results, in the order the panel shows them. The id is what an
## index entry's `group` field carries, so this table is also the registration:
## an entry whose group is not one of these four is never shown at all, and
## the test below is what says the index agrees.
type Group = { id: String, en: String, zh: String }

fn groups() -> List[Group] = [
  Group { id: "stdlib", en: "Standard library", zh: "标准库" },
  Group { id: "spec", en: "Spec and design", zh: "规范与设计" },
  Group { id: "tutorial", en: "Tutorial", zh: "教程" },
  Group { id: "examples", en: "Examples and pages", zh: "示例与页面" },
]

fn group_label(g: Group, code: String) -> String =
  if code == "zh-CN" { g.zh } else { g.en }

## What the panel says it searches. The expectation this manages is the one
## the whole design rests on: this is a title and API-name index, not full
## text, so a reader who types a word out of the middle of a paragraph gets
## nothing and has to be told why rather than left to conclude the search is
## broken.
fn scope_line(code: String) -> String =
  if code == "zh-CN" {
    "只搜标题与 API 名,不搜正文。"
  } else {
    "Searches titles and API names, not body text."
  }

fn empty_line(code: String) -> String =
  if code == "zh-CN" { "没有匹配。" } else { "No matches." }

fn prompt_line(code: String) -> String =
  if code == "zh-CN" { "输入以搜索。" } else { "Type to search." }

# ---- scoring --------------------------------------------------------------

## The ranks a match can have, best first. A list rather than a range because
## it is read as an order and not as arithmetic, and because [probe] walks it.
fn ranks() -> List[Int] = [5, 4, 3, 2, 1]

## How many rows the panel will show. A cap rather than a scroll: the panel is
## a keyboard target, and a list nobody can reach the bottom of by pressing
## down is not a list.
fn cap() -> Int = 40

## Where a name can be split for the purpose of "starts a word": the module
## separator, the path separator, the hyphen and the space.
fn is_break(c: Char) -> Bool = c == '.' || c == '/' || c == '-' || c == ' '

## Whether `hay` has `needle` at the start of one of its words.
fn has_word_prefix(hay: String, needle: String) -> Bool = {
  let cs = code_points(hay)
  var found = false
  var i = 0
  while i < len(cs) {
    if i > 0 && is_break(cs[i - 1]) && str.starts_with(str.drop(hay, i), needle) {
      found = true
    }
    i = i + 1
  }
  found
}

## How well an entry answers a query, 0 for not at all.
##
## Deterministic and total: the same pair always scores the same, and the
## order the panel shows is (group, rank, index order), so nothing here has to
## sort and no two runs can disagree about ties.
##
## `q` arrives already lowercased, once per scan rather than once per entry.
pub fn score(e: Entry, q: String) -> Int = {
  let title = str.to_lower(e.title)
  if q == "" {
    0
  } else if title == q {
    5
  } else if str.starts_with(title, q) {
    4
  } else if has_word_prefix(title, q) {
    3
  } else if str.contains(title, q) {
    2
  } else if str.contains(str.to_lower(e.detail), q) || str.contains(str.to_lower(e.note), q) {
    1
  } else {
    0
  }
}

# ---- the scan: two cells in one handler ------------------------------------

## Every match, numbered, and the href the selection lands on.
##
## The two cells are the reason this is a handler and not a fold. `hits` is
## the accumulation; `chosen` is decided by comparing the running ordinal --
## which is `len(hits)` at the moment the arm runs, and which nothing outside
## the arm can see -- against the selection the model carries. A fold could
## carry both in a pair, at the cost of threading that pair through [probe]
## and everything it calls; the point of the cells is that [probe]'s signature
## says `!Found` and says nothing about what is being accumulated.
pub fn scan(entries: List[Entry], query: String, sel: Int) -> Scan = {
  with handle Found {
    var hits: List[Hit] = []
    var chosen: String = ""
    found(e) => {
      if len(hits) == sel {
        chosen = e.href
      }
      hits = hits ++ [Hit { entry: e, at: len(hits) }]
    }
  }
  probe(entries, str.to_lower(query))
  Scan { hits: hits, chosen: chosen }
}

## Perform `found` once per matching entry, in display order.
##
## The conditional is the whole of the filter: an entry that does not match
## is not emitted at all, rather than emitted with a zero score for somebody
## further on to drop. Scores are computed once per entry and then bucketed,
## because the walk below visits every entry twenty times and computing a
## score is the expensive half.
fn probe(entries: List[Entry], q: String) -> Unit !Found = {
  let scored = list.map(entries, e => (e, score(e, q)))
  var shown = 0
  for g in groups() {
    for rank in ranks() {
      for pair in scored {
        let (e, s) = pair
        if s == rank && e.group == g.id && shown < cap() {
          shown = shown + 1
          found(e)
        }
      }
    }
  }
}

# ---- the view: a collector inside a collector ------------------------------

## Run `body` and answer everything it emitted, in the order it emitted it.
##
## The row `!Emit !Ctx` is what lets this be nested inside itself while the
## reader effect passes straight through: `Emit` is taken off by the handler
## here, `Ctx` is not, so a body that asks the page where it is stays honest
## all the way up to the one installation in [view].
fn collect(body: fn() -> Unit !Emit !Ctx) -> List[Node[Msg]] !Ctx = {
  with handle Emit {
    var acc: List[Node[Msg]] = []
    emit(w) => { acc = acc ++ [w] }
  }
  body()
  acc
}

## An href on this page, given one relative to the site root.
fn link(href: String) -> String !Ctx = "${root()}/$href"

## One result row. `chosen` decides one class and nothing else, so moving the
## selection is one `set-self` per row that changed and no rebuild.
fn row(h: Hit, chosen: Bool) -> Node[Msg] !Ctx = {
  let e = h.entry
  let head: List[Node[Msg]] = [
    span(e.title, class: "search-title"),
    span(e.detail, class: "search-detail"),
  ]
  el("li", class: if chosen { "search-row is-selected" } else { "search-row" }, kids: [
    el("a", [("href", link(e.href))], kids: head ++ note_kids(e)),
  ])
}

## The one-line note, when there is one. A row with no note has no element for
## it rather than an empty one, which is the same rule the sections follow.
fn note_kids(e: Entry) -> List[Node[Msg]] =
  if e.note == "" { [] } else { [span(e.note, class: "search-note")] }

## The rows of one group. The inner collector: its installation shadows the
## outer one for the length of the body, so these `emit`s land here and the
## sections collector never sees them.
fn rows_of(hits: List[Hit], g: Group, sel: Int) -> List[Node[Msg]] !Ctx =
  collect(() => {
    for h in hits {
      if h.entry.group == g.id {
        emit(row(h, h.at == sel))
      }
    }
  })

## Every group that has something to show, in the panel's order.
##
## The outer collector. A group with no rows emits nothing -- not an empty
## section, not a placeholder -- which is why the answer's length is the
## number of groups that matched rather than four.
fn sections(hits: List[Hit], sel: Int) -> List[Node[Msg]] !Ctx =
  collect(() => {
    for g in groups() {
      let rows: List[Node[Msg]] = rows_of(hits, g, sel)
      if len(rows) > 0 {
        emit(section(g, rows))
      }
    }
  })

fn section(g: Group, rows: List[Node[Msg]]) -> Node[Msg] !Ctx =
  el("li", class: "search-group", kids: [
    el("h2", class: "search-group-name", kids: [text(group_label(g, lang()))]),
    el("ul", class: "search-group-rows", kids: rows),
  ])

## The link Enter chose, for the page's script to follow. It carries
## `data-goto` because a page reads markup and not a model: the script looks
## for that attribute after every keystroke and navigates when it appears.
fn goto_kids(m: Model) -> List[Node[Msg]] !Ctx =
  if m.goto == "" {
    []
  } else {
    [el("a", [("href", link(m.goto)), ("data-goto", "1")], class: "search-goto",
      kids: [text(m.goto)])]
  }

## What the list area holds when there is nothing to list: a reason, not a
## blank. The two reasons are different -- an empty query has not been asked
## anything yet, a query with no hits has.
fn status(m: Model) -> List[Node[Msg]] !Ctx =
  if str.trim(m.query) == "" {
    [el("p", class: "search-status", kids: [text(prompt_line(lang()))])]
  } else {
    [el("p", class: "search-status", kids: [text(empty_line(lang()))])]
  }

fn panel(m: Model) -> Node[Msg] !Ctx = {
  let s = scan(m.entries, m.query, m.sel)
  let found: List[Node[Msg]] = sections(s.hits, m.sel)
  let box: Node[Msg] =
    input(class: "search-input", value: m.query,
      on: [on_value("input", Typed), on_key("keydown", Pressed)])
  let list_kids: List[Node[Msg]] =
    if len(found) > 0 { found } else { [el("li", class: "search-none", kids: status(m))] }
  div(class: "search-panel", kids: [
    box,
    el("ul", class: "search-results", kids: list_kids),
    el("p", class: "search-scope", kids: [text(scope_line(lang()))]),
  ] ++ goto_kids(m))
}

# ---- the loop --------------------------------------------------------------

## The model a fresh panel starts from, given what the page already knew.
##
## Total, because a page may hand back anything: no flags, flags that are not
## JSON, JSON that is not an object and an object with no index are four
## things a running application meets, and each falls back to the same visible
## default -- a panel with an empty index, which says "no matches" for
## everything and is therefore obvious rather than silent.
pub fn init(flags: Option[String]) -> Model =
  match flags {
    None -> blank()
    Some(text) ->
      match parse(text) {
        Ok(j) -> {
          let index = jget(j, "index")
          Model {
            root: jstr_or(jget(j, "root"), "."),
            lang: jstr_or(jget(index, "lang"), "en"),
            entries: entries_of(jget(index, "items")),
            query: "",
            sel: 0,
            open: true,
            goto: "",
          }
        }
        Err(_) -> blank()
      }
  }

fn blank() -> Model =
  Model {
    root: ".",
    lang: "en",
    entries: [],
    query: "",
    sel: 0,
    open: true,
    goto: "",
  }

impl App[Model] {
  type Msg = Msg
  type View = Node[Msg]

  fn update(m: Model, msg: Msg) -> Model =
    match msg {
      Typed(text) -> Model { ..m, query: text, sel: 0, goto: "" }
      Pressed(key) -> pressed(m, key)
    }

  ## Closed is an empty tree, not a hidden one: the panel is a whole document
  ## while it is open and nothing at all while it is not, so the page keeps no
  ## stale rows behind a `hidden` attribute.
  fn view(m: Model) -> Node[Msg] = {
    with handle Ctx {
      root() => m.root
      lang() => m.lang
    }
    if m.open { panel(m) } else { div(class: "search-panel is-closed") }
  }
}

## What a key means. Everything not named here means nothing, which is the
## half that keeps typing cheap: an ordinary character is an `input` event and
## a `keydown` this ignores, and ignoring it answers with no patches at all.
fn pressed(m: Model, key: String) -> Model = {
  let s = scan(m.entries, m.query, m.sel)
  let last = len(s.hits) - 1
  if key == "ArrowDown" {
    Model { ..m, sel: if m.sel < last { m.sel + 1 } else { m.sel }, goto: "" }
  } else if key == "ArrowUp" {
    Model { ..m, sel: if m.sel > 0 { m.sel - 1 } else { 0 }, goto: "" }
  } else if key == "Enter" {
    Model { ..m, goto: s.chosen }
  } else if key == "Escape" {
    Model { ..m, open: false, query: "", sel: 0, goto: "" }
  } else {
    m
  }
}

# ---- the model as text -----------------------------------------------------

fn jget(j: Json, key: String) -> Json =
  match j {
    JObj(entries) -> unwrap_or(map.get(entries, key), JNull)
    _ -> JNull
  }

fn jstr_or(j: Json, fallback: String) -> String =
  match j {
    JStr(s) -> s
    _ -> fallback
  }

fn jint_or(j: Json, fallback: Int) -> Int =
  match j {
    JInt(v) -> v
    _ -> fallback
  }

fn jbool_or(j: Json, fallback: Bool) -> Bool =
  match j {
    JBool(b) -> b
    _ -> fallback
  }

fn jarr(j: Json) -> List[Json] =
  match j {
    JArr(items) -> items
    _ -> []
  }

## The index, read out of the five-string rows both halves of this feature
## agree on. site/src/gen/search.dawn writes exactly this shape and pins the
## same literal in a test of its own; the two are separate programs, so the
## pair of tests is what keeps them level.
pub fn entries_of(j: Json) -> List[Entry] = {
  var out: List[Entry] = []
  for item in jarr(j) {
    let fs = jarr(item)
    if len(fs) == 5 {
      out = out
        ++ [Entry {
          group: jstr_or(fs[0], ""),
          title: jstr_or(fs[1], ""),
          detail: jstr_or(fs[2], ""),
          note: jstr_or(fs[3], ""),
          href: jstr_or(fs[4], ""),
        }]
    }
  }
  out
}

fn quoted(s: String) -> String = render(JStr(value: s))

fn items_json(entries: List[Entry]) -> String =
  "[" ++ join(list.map(entries, e =>
      "[" ++ join([quoted(e.group), quoted(e.title), quoted(e.detail), quoted(e.note),
        quoted(e.href)], ",") ++ "]"), ",") ++ "]"

## The model as one line of JSON, and back.
##
## Written out rather than assembled through `JObj`, because a map's iteration
## order is a fact about a hash and this text is compared byte for byte in
## scripts/example-main-contract/registry.json. Field order here is the field
## order there.
pub fn encode(m: Model) -> String =
  "{\"root\":" ++ quoted(m.root)
  ++ ",\"lang\":" ++ quoted(m.lang)
  ++ ",\"q\":" ++ quoted(m.query)
  ++ ",\"sel\":" ++ to_string(m.sel)
  ++ ",\"open\":" ++ (if m.open { "true" } else { "false" })
  ++ ",\"goto\":" ++ quoted(m.goto)
  ++ ",\"items\":" ++ items_json(m.entries)
  ++ "}"

## Total, because a host may hand back anything: an unreadable model reads as
## a fresh one rather than as a panic, which keeps a corrupted page
## recoverable by typing.
pub fn decode(s: String) -> Model =
  match parse(s) {
    Ok(j) ->
      match j {
        JObj(_) ->
          Model {
            root: jstr_or(jget(j, "root"), "."),
            lang: jstr_or(jget(j, "lang"), "en"),
            entries: entries_of(jget(j, "items")),
            query: jstr_or(jget(j, "q"), ""),
            sel: jint_or(jget(j, "sel"), 0),
            open: jbool_or(jget(j, "open"), true),
            goto: jstr_or(jget(j, "goto"), ""),
          }
        _ -> blank()
      }
    Err(_) -> blank()
  }

# ---- tests -----------------------------------------------------------------

fn probe_entry(group: String, title: String, note: String, href: String) -> Entry =
  Entry { group: group, title: title, detail: "fn $title()", note: note, href: href }

fn probe_index() -> List[Entry] = [
  probe_entry("stdlib", "str.trim", "Remove surrounding whitespace.", "stdlib.html#std-str-trim"),
  probe_entry("stdlib", "str.split", "", "stdlib.html#std-str-split"),
  probe_entry("stdlib", "list.map", "", "stdlib.html#std-list-map"),
  probe_entry("spec", "6.5 Named effects", "", "spec.html#s6-5"),
  probe_entry("tutorial", "Effects", "", "tutorial/09.html"),
]

fn shown(m: Model) -> List[String] = {
  let s = scan(m.entries, m.query, m.sel)
  list.map(s.hits, h => h.entry.title)
}

test "a query answers the entries that match it, best rank first" {
  let m = Model { ..blank(), entries: probe_index() }
  # an exact title beats a prefix beats a word prefix beats a substring
  assert shown(Model { ..m, query: "str.trim" }) == ["str.trim"]
  assert shown(Model { ..m, query: "str." }) == ["str.trim", "str.split"]
  # `map` is a word of `list.map` and a substring of nothing else here
  assert shown(Model { ..m, query: "map" }) == ["list.map"]
  # the note is the last resort, and only when nothing in a title matched
  assert shown(Model { ..m, query: "whitespace" }) == ["str.trim"]
  # an empty query asks nothing, so nothing answers
  let none: List[String] = []
  assert shown(Model { ..m, query: "" }) == none
  assert shown(Model { ..m, query: "nothing here" }) == none
}

## The group order is the panel's order, not the index's: a query that matches
## in three groups shows them in the order [groups] lists, whatever order the
## index happened to be built in.
test "hits are grouped in the panel's order and numbered across the groups" {
  # `eff` starts a word of the spec section and the whole of the tutorial
  # chapter, and appears nowhere in the three stdlib rows -- so the index's
  # own order (stdlib first) is not the order the panel shows.
  let m = Model { ..blank(), entries: probe_index(), query: "eff" }
  let s = scan(m.entries, m.query, m.sel)
  assert list.map(s.hits, h => h.entry.group) == ["spec", "tutorial"]
  assert list.map(s.hits, h => h.at) == [0, 1]
}

## The second cell. `chosen` is not a function of `hits` alone -- it is the
## href at the ordinal the model is pointing at, decided inside the arm while
## the ordinal still exists.
test "the selection resolves to one href, and out of range resolves to none" {
  let m = Model { ..blank(), entries: probe_index(), query: "str." }
  assert scan(m.entries, m.query, 0).chosen == "stdlib.html#std-str-trim"
  assert scan(m.entries, m.query, 1).chosen == "stdlib.html#std-str-split"
  assert scan(m.entries, m.query, 7).chosen == ""
  # and with nothing matched there is nothing to choose
  assert scan(m.entries, "nothing here", 0).chosen == ""
}

## Every group an index entry can name is a group the panel draws. An entry in
## an unregistered group would be scored, matched, and then silently dropped
## by [rows_of].
test "the registered groups are the ones an index may name" {
  let ids = list.map(groups(), g => g.id)
  assert ids == ["stdlib", "spec", "tutorial", "examples"]
  for e in probe_index() {
    assert list.contains(ids, e.group)
  }
  for g in groups() {
    assert group_label(g, "en") != ""
    assert group_label(g, "zh-CN") != ""
    assert group_label(g, "en") != group_label(g, "zh-CN")
  }
}

## The nesting rule, on the tree rather than on the collector: what crosses
## from the inner installation to the outer one is one `<li class="search-
## group">` per group, never its rows. A collector that let them leak would
## produce the same rows in the same order under no section at all.
test "each group's rows stay inside that group's section" {
  let m = Model { ..blank(), entries: probe_index(), query: "eff" }
  let v = view(m)
  # two groups matched, so the results list has exactly two children
  assert count_class(v, "search-group") == 2
  assert count_class(v, "search-row") == 2
  # and the rows are underneath the sections rather than beside them: the
  # results list holds two sections and no row, and each section holds one row
  assert kid_classes(v, "search-results") == ["search-group", "search-group"]
  assert list.map(all_with_class(v, "search-group-rows"), n => len(kids_of(n))) == [1, 1]
}

## The conditional half of the same rule: a group with no hits is absent, not
## empty. With one group matched there is one section and no second heading.
test "a group with no hits emits no section at all" {
  let m = Model { ..blank(), entries: probe_index(), query: "map" }
  let v = view(m)
  assert count_class(v, "search-group") == 1
  assert count_class(v, "search-row") == 1
  # nothing matched at all: no sections, and a reason in their place
  let no_hits = view(Model { ..m, query: "nothing here" })
  assert count_class(no_hits, "search-group") == 0
  assert count_class(no_hits, "search-status") == 1
}

## The reader effect, end to end: the root and the language reach the leaves.
## A link is the page's own distance plus the site-root href, and a heading is
## in the page's language -- neither of which any function between [view] and
## the leaf mentions.
test "the page's root and language reach the leaves through the handler" {
  let en = Model { ..blank(), entries: probe_index(), query: "str.trim", root: ".." }
  assert hrefs(view(en)) == ["../stdlib.html#std-str-trim"]
  assert str.contains(show_tree(view(en)), "Standard library")
  let zh = Model { ..en, lang: "zh-CN", root: "../.." }
  assert hrefs(view(zh)) == ["../../stdlib.html#std-str-trim"]
  assert str.contains(show_tree(view(zh)), "标准库")
  # the scope line follows it too, which is the expectation this panel manages
  assert str.contains(show_tree(view(en)), "Searches titles and API names")
  assert str.contains(show_tree(view(zh)), "只搜标题与 API 名")
}

test "arrows move the selection and stop at both ends" {
  let m = Model { ..blank(), entries: probe_index(), query: "str." }
  assert update(m, Pressed(key: "ArrowUp")).sel == 0
  let down = update(m, Pressed(key: "ArrowDown"))
  assert down.sel == 1
  # two hits, so the second is the last one
  assert update(down, Pressed(key: "ArrowDown")).sel == 1
  assert update(down, Pressed(key: "ArrowUp")).sel == 0
  # the selected row is the one that says so, and it is the only one
  assert selected_titles(view(down)) == ["str.split"]
  # a key nothing is bound to leaves the model alone, so the turn has no patches
  assert update(down, Pressed(key: "x")) == down
}

test "typing replaces the query and puts the selection back at the top" {
  let m = Model { ..blank(), entries: probe_index(), query: "str.", sel: 1 }
  let typed = update(m, Typed(text: "list"))
  assert typed.query == "list"
  assert typed.sel == 0
  assert typed.goto == ""
}

## Enter is the one message that leaves an instruction in the tree, and Escape
## is the one that empties it.
test "enter marks a destination and escape renders nothing at all" {
  let m = Model { ..blank(), entries: probe_index(), query: "str.", sel: 1 }
  let go = update(m, Pressed(key: "Enter"))
  assert go.goto == "stdlib.html#std-str-split"
  assert str.contains(show_tree(view(go)), "data-goto")
  # the next message clears it, so one Enter is one navigation
  assert update(go, Typed(text: "str.")).goto == ""
  let shut = update(go, Pressed(key: "Escape"))
  assert not shut.open
  assert shut.query == ""
  let closed: List[Node[Msg]] = []
  assert view(shut) == div(class: "search-panel is-closed", kids: closed)
}

test "flags carry the index, and every way of not having them is an empty one" {
  let flags = "{\"root\":\"..\",\"index\":{\"v\":1,\"lang\":\"zh-CN\","
    ++ "\"items\":[[\"stdlib\",\"str.trim\",\"fn trim(s: String) -> String\","
    ++ "\"Remove surrounding whitespace.\",\"zh/stdlib.html#std-str-trim\"]]}}"
  let m = init(Some(flags))
  assert m.root == ".."
  assert m.lang == "zh-CN"
  assert len(m.entries) == 1
  assert m.entries[0] == Entry {
    group: "stdlib",
    title: "str.trim",
    detail: "fn trim(s: String) -> String",
    note: "Remove surrounding whitespace.",
    href: "zh/stdlib.html#std-str-trim",
  }
  let none: Option[String] = None
  assert init(none) == blank()
  assert init(Some("")) == blank()
  assert init(Some("not json at all")) == blank()
  assert init(Some("[1,2]")) == blank()
  assert init(Some("{\"root\":\"..\"}")) == Model { ..blank(), root: ".." }
}

## The shape the site generator writes. This literal is the contract between
## two programs that cannot share a line of code, and the same one is pinned
## in site/src/gen/search.dawn's own test.
test "an index row is five strings, in this order" {
  match parse("[[\"spec\",\"6.5 Named effects\",\"Spec\",\"\",\"spec.html#s6-5\"]]") {
    Ok(j) -> {
      assert entries_of(j) == [Entry {
        group: "spec",
        title: "6.5 Named effects",
        detail: "Spec",
        note: "",
        href: "spec.html#s6-5",
      }]
    }
    Err(e) -> panic("the pinned index row must parse: $e")
  }
}

test "the model round-trips through its text, index and all" {
  let m = update(Model { ..blank(), entries: probe_index(), root: ".." }, Typed(text: "str."))
  assert decode(encode(m)) == m
  assert decode(encode(update(m, Pressed(key: "Escape")))).open == false
  assert decode("not json at all") == blank()
  assert decode("[1,2]") == blank()
  # an object missing every field is the blank model, field by field
  assert decode("{}") == blank()
}

# The helpers the assertions above read trees with. `Show` on a `Node` is the
# whole tree as text, which is enough for "does this string appear anywhere in
# the document" and useless for "where"; everything that needs structure walks
# the tree instead. A class is matched as a WORD of the class attribute, not
# as the whole of it -- a selected row's class is `search-row is-selected`,
# and a check that compared the attribute would have stopped counting it.

fn show_tree(w: Node[Msg]) -> String = to_string(w)

fn count_class(w: Node[Msg], cls: String) -> Int = len(all_with_class(w, cls))

## The class attribute of each child of the first element carrying `cls`.
fn kid_classes(w: Node[Msg], cls: String) -> List[String] =
  match get(all_with_class(w, cls), 0) {
    None -> []
    Some(found) -> list.map(kids_of(found), k => class_of(k))
  }

fn selected_titles(w: Node[Msg]) -> List[String] = {
  var out: List[String] = []
  for row in all_with_class(w, "is-selected") {
    match get(kids_of(row), 0) {
      Some(anchor) ->
        match get(kids_of(anchor), 0) {
          Some(title) -> {
            out = out ++ [text_of(title)]
          }
          None -> ()
        }
      None -> ()
    }
  }
  out
}

## Every `href` prop in the tree, in document order.
fn hrefs(w: Node[Msg]) -> List[String] = {
  var out: List[String] = []
  match w {
    Text(_) -> ()
    Elem(_, props, ..) -> {
      for p in props {
        let (k, v) = p
        if k == "href" {
          out = out ++ [v]
        }
      }
    }
  }
  for k in kids_of(w) {
    out = out ++ hrefs(k)
  }
  out
}

fn class_of(w: Node[Msg]) -> String =
  match w {
    Text(_) -> ""
    Elem(_, props, ..) -> {
      var found = ""
      for p in props {
        let (k, v) = p
        if k == "class" {
          found = v
        }
      }
      found
    }
  }

fn kids_of(w: Node[Msg]) -> List[Node[Msg]] =
  match w {
    Text(_) -> []
    Elem(_, _, _, kids, ..) -> kids
  }

fn text_of(w: Node[Msg]) -> String =
  match w {
    Text(s) -> s
    Elem(_, _, _, kids, ..) -> join(list.map(kids, k => text_of(k)), "")
  }

fn has_class(w: Node[Msg], cls: String) -> Bool =
  list.contains(str.split(class_of(w), " "), cls)

fn all_with_class(w: Node[Msg], cls: String) -> List[Node[Msg]] = {
  var out: List[Node[Msg]] = []
  if has_class(w, cls) {
    out = out ++ [w]
  }
  for k in kids_of(w) {
    out = out ++ all_with_class(k, cls)
  }
  out
}