tea_dom_counter_contract

The pure half of the counter the bridge gates drive: a model, four messages, update and view, and the two functions that turn a model into opaque text and back. No io, no wire format, no host -- everything here is testable with ==, which is the point of the vocabulary carrying messages rather than callbacks.

运行

CI 运行的命令,以及它逐字节核对的输出。

sh

dawn run examples/projects/tea_dom_counter_contract

stdin:

{"op":"init"}
{"op":"event","model":"0","path":[3,1],"event":"click"}
{"op":"event","model":"1","path":[3,0],"event":"click"}
{"op":"event","model":"0","path":[3,0],"event":"click"}
{"op":"event","model":"-1","path":[3,3],"event":"click"}
{"op":"event","model":"-1","path":[3,2],"event":"click"}
{"op":"event","model":"0","path":[1],"event":"click"}
not json at all
{"ok":true,"model":"0","patches":[{"path":[],"op":"replace","node":{"t":"elem","tag":"div","props":[["class","counter"]],"on":[],"kids":[{"t":"elem","tag":"h1","props":[],"on":[],"kids":[{"t":"text","s":"dawn counter"}]},{"t":"elem","tag":"p","props":[["class","count"]],"on":[],"kids":[{"t":"text","s":"0"}]},{"t":"elem","tag":"div","props":[["class","bar"]],"on":[],"kids":[]},{"t":"elem","tag":"div","props":[["class","row"]],"on":[],"kids":[{"t":"elem","tag":"button","props":[],"on":["click"],"kids":[{"t":"text","s":"-"}]},{"t":"elem","tag":"button","props":[],"on":["click"],"kids":[{"t":"text","s":"+"}]},{"t":"elem","tag":"button","props":[],"on":["click"],"kids":[{"t":"text","s":"reset"}]},{"t":"elem","tag":"button","props":[],"on":["click"],"kids":[{"t":"text","s":"boom"}]}]}]}}]}
{"ok":true,"model":"1","patches":[{"path":[1,0],"op":"replace","node":{"t":"text","s":"1"}},{"path":[2],"op":"append","nodes":[{"t":"elem","tag":"span","props":[["class","tick"]],"on":[],"kids":[]}]}]}
{"ok":true,"model":"0","patches":[{"path":[1,0],"op":"replace","node":{"t":"text","s":"0"}},{"path":[2],"op":"truncate","keep":0}]}
{"ok":true,"model":"-1","patches":[{"path":[],"op":"set-self","node":{"t":"elem","tag":"div","props":[["class","counter below-zero"]],"on":[]}},{"path":[1,0],"op":"replace","node":{"t":"text","s":"-1"}}]}
{"ok":false,"kind":"panic","error":"boom: update failed on purpose at n=-1 at src/counter.dawn:61:15"}
{"ok":true,"model":"0","patches":[{"path":[],"op":"set-self","node":{"t":"elem","tag":"div","props":[["class","counter"]],"on":[]}},{"path":[1,0],"op":"replace","node":{"t":"text","s":"0"}}]}
{"ok":false,"kind":"no-handler","error":"nothing at [1] listens for `click`"}
{"ok":false,"kind":"bad-request","error":"not JSON: invalid literal at offset 0"}

examples/projects/tea_dom_counter_contract/src/main.dawn

main.dawn
# The whole io surface of the contract counter: one call.
#
# The same binary answers a browser and a terminal, because the boundary is
# lines of JSON on stdin and stdout and nothing else. Built for wasm
# (`dawnc build --target wasm --reactor`) it is a reactor the page calls once
# per event; run from a shell (`dawn run`) it is a session you can type at,
# which is how the example-main contract holds it to a transcript without a
# browser or a wasm engine in sight.
#
#   $ echo '{"op":"init"}' | dawn run examples/projects/tea_dom_counter_contract

use tea_core/app.{update, view}
use tea_dom/reactor.{serve}
use counter.{init, encode, decode}

pub fn main() -> Unit !io =
  serve(init(), encode, decode, update, view)

examples/projects/tea_dom_counter_contract/src/counter.dawn

counter.dawn
# The pure half of the counter the bridge gates drive: a model, four
# messages, `update` and `view`, and the two functions that turn a model into
# opaque text and back. No io, no wire format, no host -- everything here is
# testable with `==`, which is the point of the vocabulary carrying messages
# rather than callbacks.
#
# It is a fixture first and an example second. The counter people read is
# examples/projects/tea_dom_counter; this one carries what the gates need on
# top of it, and was split off when those additions had become most of what a
# reader of the demo saw.
#
# The view is shaped to make the reconciler visible rather than to look good.
# Three things change independently:
#
#   the count text       every message: one `replace` deep in the tree
#   the root's class     only when the sign of the count changes: one
#                        `set-self` at the root, whose payload must not carry
#                        the document underneath it
#   nothing else         the heading and the four buttons are the same nodes
#                        in every frame and no patch may ever mention them
#
# `Boom` is a message whose `update` panics on purpose. A demo does not need
# one; the boundary does, because a guest that cannot survive a failing
# application is a guest that loses the model on the first bug. It is what
# scripts/wasm-dom-contract points at the wasm failure runtime.

use tea_core/app.{App, update, view}
use tea_core/cmd.{Cmd, NoCmd, fold_msg}
use tea_core/diff.{Patch, Replace, AppendKids, TruncateKids, diff}
use tea_dom/node.{Node, Text, Elem}
use tea_dom/dsl.{button, div, el, text}

## The whole model: how many times the user has pressed something, net.
pub type Counter = { n: Int }

## What the four buttons mean.
pub type Msg =
  | Inc
  | Dec
  | Reset
  | Boom
derive Show

## The model a fresh page starts from.
pub fn init() -> Counter = Counter { n: 0 }

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

  # Every arm answers `NoCmd`: a counter has nothing to ask the driver for
  # beyond the model it just returned.
  fn update(m: Counter, msg: Msg) -> (Counter, Cmd[Msg]) =
    match msg {
      Inc -> (Counter { n: m.n + 1 }, NoCmd)
      Dec -> (Counter { n: m.n - 1 }, NoCmd)
      Reset -> (init(), NoCmd)
      # Deliberate, and the only unhandled failure in this tree. `tea_dom`'s
      # `serve` catches it at the boundary and answers with an error reply;
      # on wasm that landing is the A1 shadow stack.
      Boom -> panic("boom: update failed on purpose at n=" ++ to_string(m.n))
    }

  fn view(m: Counter) -> Node[Msg] = {
    let heading: Node[Msg] = el("h1", kids: [text("dawn counter")])
    let count: Node[Msg] = el("p", class: "count", kids: [text(to_string(m.n))])
    let bar: Node[Msg] = div(class: "bar", kids: ticks(m.n))
    let controls: Node[Msg] =
      div(class: "row", kids: [
        button("-", Dec),
        button("+", Inc),
        button("reset", Reset),
        button("boom", Boom),
      ])
    div(class: if m.n < 0 { "counter below-zero" } else { "counter" },
      kids: [heading, count, bar, controls])
  }
}

## One child, in the order the body wants it.
##
## The collector vocabulary lives in this module rather than in
## `packages/tea-dom`, which is where a DSL word belongs until the shape has
## been through a release: the package ships constructors, and a way of
## *building* a child list is a second thing.
effect Emit {
  fn emit(w: Node[Msg]) -> Unit
}

## Run `body` and answer everything it emitted, in the order it emitted it.
##
## The cell is the collector. It is reachable from the arm that writes it and
## from the line below that reads it, and from nowhere else, so `collect` is a
## pure function whose row says nothing about `Emit`: the handler is what
## takes the label off (spec §6.5).
fn collect(body: fn() -> Unit !Emit) -> List[Node[Msg]] = {
  with handle Emit {
    var acc: List[Node[Msg]] = []
    emit(w) => { acc = acc ++ [w] }
  }
  body()
  acc
}

## One `span` per unit of a positive count, and none at all below zero.
##
## This is the part of the view whose *child list length* depends on the
## model, and it is here for the reconciler rather than for the user: without
## it the app only ever produces `replace` and `set-self`, and the two tail
## ops (`append`, `truncate`) would never cross the boundary in a running
## program. Counting up appends one span, counting down truncates one, and
## the spans already on screen are never mentioned.
##
## Written as a loop that emits, which is what the accumulator recursion this
## used to be was standing in for. A negative count is an empty range rather
## than a base case, so "none at all below zero" is the loop not running.
fn ticks(n: Int) -> List[Node[Msg]] =
  collect(() => {
    for _i in 0..n {
      emit(tick())
    }
  })

fn tick() -> Node[Msg] = el("span", class: "tick")

## A model as text, and back. Opaque to everything but this module: the wire
## carries it as a JSON string and the host hands it back untouched.
pub fn encode(m: Counter) -> String = to_string(m.n)

## Total, because a host may hand back anything: an unreadable model reads as
## the initial one rather than as a panic, which keeps a corrupted page
## recoverable by clicking.
pub fn decode(s: String) -> Counter =
  match parse_int(s) {
    Some(n) -> Counter { n: n }
    None -> init()
  }

# One turn as a driver commits it: `update` plus whatever its command asked
# for. The assertions below are written on this rather than on the pair,
# because the pair's first half is not what the page ends up holding.
fn turn(m: Counter, msg: Msg) -> Counter = fold_msg(m, msg, update)

test "update folds messages and the model round-trips through its text" {
  let m = init().turn(Inc).turn(Inc)
  assert m == Counter { n: 2 }
  assert turn(m, Dec) == Counter { n: 1 }
  assert turn(m, Reset) == init()
  assert m.encode().decode() == m
  assert encode(Counter { n: -17 }).decode() == Counter { n: -17 }
  assert decode("not a number") == init()
}

test "this application commands nothing, so a turn is exactly its update" {
  let (m, c) = init().update(Inc)
  assert m == Counter { n: 1 }
  let nothing: Cmd[Msg] = NoCmd
  assert c == nothing
}

test "the view is a value, so a frame is one assertion" {
  let want: Node[Msg] =
    div(class: "counter", kids: [
      el("h1", kids: [text("dawn counter")]),
      el("p", class: "count", kids: [text("0")]),
      div(class: "bar"),
      div(class: "row", kids: [
        button("-", Dec),
        button("+", Inc),
        button("reset", Reset),
        button("boom", Boom),
      ]),
    ])
  assert init().view() == want
}

test "the bar is the only child list whose length the model decides" {
  # The two tail ops exist because of this: counting up is one `append` at
  # the bar and one `replace` at the count, and the spans already there are
  # not mentioned. Below zero the bar is empty rather than negative.
  let none: List[Node[Msg]] = []
  assert ticks(0) == none
  assert ticks(-3) == none
  assert ticks(3).len() == 3
  assert ticks(3) == [tick(), tick(), tick()]
  let two: List[Patch[Node[Msg]]] = diff(view(Counter { n: 1 }), view(Counter { n: 2 }))
  assert len(two) == 2
  assert two[0] == Patch { path: [1, 0], op: Replace(w: text("2")) }
  assert two[1] == Patch { path: [2], op: AppendKids(ws: [tick()]) }
  let back: List[Patch[Node[Msg]]] = diff(view(Counter { n: 2 }), view(Counter { n: 1 }))
  assert back[1] == Patch { path: [2], op: TruncateKids(keep: 1) }
}

test "the root's class is the only thing the sign of the count changes" {
  let below = view(Counter { n: -1 })
  match below {
    Text(_) -> { assert false }
    Elem(_, props, ..) -> { assert props == [("class", "counter below-zero")] }
  }
  match view(Counter { n: 0 }) {
    Text(_) -> { assert false }
    Elem(_, props, ..) -> { assert props == [("class", "counter")] }
  }
}