Running it
What CI runs and the output it checks, byte for byte.
dawn run examples/projects/tea_dom_counter_contract
with this on 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
# 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
# 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")] }
}
}