aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--examples/shared-board.aup96
-rw-r--r--tests/check.py109
2 files changed, 204 insertions, 1 deletions
diff --git a/examples/shared-board.aup b/examples/shared-board.aup
new file mode 100644
index 0000000..a591c49
--- /dev/null
+++ b/examples/shared-board.aup
@@ -0,0 +1,96 @@
+# A board two nodes share. A change to a card goes whole to the other node
+# (send changed { ..at }), and a card the other node sends is taken whole
+# (cards[k] = Card { ..remote }) when it is the later change: by its time,
+# then by the node's name. So both boards end the same, whatever order the
+# changes come in, and after the nodes were cut apart and joined again.
+#
+# Each node runs it with a key of its own and links to the other, sealed:
+# pure-serve --module shared-board.pmod --store a --key a.key --name a \
+# --link-port 7001 --send changed=pure://127.0.0.1:7002/remote \
+# --seal changed=B_KEY --sender b.changed=B_KEY
+# then tells the app its name: POST /inputs/name {"name": "a"}. Upgraded
+# one node after the other, a node takes the other's changes by field name
+# (Pure docs/design/014-fields-by-name.md): add a field to the card and the
+# output, nowhere else.
+use vega
+
+record Card {
+ title: text
+ who: text # whose card it is
+ lane = 0 # 0 to do, 1 doing, 2 done
+ node: text # the node that changed it last
+ since = 0 # when: milliseconds since 1970
+}
+
+state cards: list of Card by title
+state at: index of cards
+state me: text = "me"
+
+input add = text
+input forward = key ArrowRight
+input back = key ArrowLeft
+input up = key ArrowUp
+input down = key ArrowDown
+input name = message {
+ name: text
+}
+input assign = message {
+ title: text
+ who: text
+}
+output changed = message {
+ title: text
+ who: text
+ lane: u32
+ node: text
+ since: number
+}
+input remote = message like changed
+
+fn lane_name(lane) = if lane == 0 { "to do" } else if lane == 1 { "doing" } else { "done" }
+
+on add {
+ cards.add(Card { title: add, since: now, node: me })
+ at = cards[add]
+ send changed { ..at }
+}
+on forward.down {
+ at.lane = min(at.lane + 1, 2)
+ at.since = now
+ at.node = me
+ send changed { ..at }
+}
+on back.down {
+ at.lane = max(at.lane - 1, 0)
+ at.since = now
+ at.node = me
+ send changed { ..at }
+}
+on up.down { at = at - 1 }
+on down.down { at = at + 1 }
+on name { me = name.name }
+on assign {
+ require cards.has(assign.title)
+ cards[assign.title].who = assign.who
+ cards[assign.title].since = now
+ cards[assign.title].node = me
+ send changed { ..cards[assign.title] }
+}
+on remote {
+ if !cards.has(remote.title) {
+ cards.add(Card { ..remote })
+ } else if (remote.since > cards[remote.title].since ||
+ (remote.since == cards[remote.title].since && remote.node > cards[remote.title].node)) {
+ cards[remote.title] = Card { ..remote }
+ }
+}
+
+view title = "{me}'s board"
+view counts = "{count(c in cards where c.lane == 0)} to do, {count(c in cards where c.lane == 1)} doing, {count(c in cards where c.lane == 2)} done"
+
+view cards_list = each c in cards {
+ label = c.title
+ badge = if c.who == "" { lane_name(c.lane) } else { "{c.who}, {lane_name(c.lane)}" }
+ tone = if c.lane == 2 { vega.muted } else { vega.when(c.lane == 1, vega.accent) }
+}
+view cards_list_focus = at.position
diff --git a/tests/check.py b/tests/check.py
index b556b3a..7bb4c41 100644
--- a/tests/check.py
+++ b/tests/check.py
@@ -1701,6 +1701,112 @@ def width_of(character):
return width(character) or 0
+
+def shared_board(serve, module, work):
+ """Two nodes of examples/shared-board.aup, Alice's and Bob's, each with a
+ key of its own, linked both ways over pure:// (sealed, each taking the
+ other as its sender). Cards added, moved and given on either reach the
+ other, and both boards end the same. Then Bob's node is cut off (it
+ listens and sends where no one is) while both give one card to someone,
+ Bob first and Alice after, and Bob adds a card; joined again, both
+ boards take Alice's, the later change, and Bob's card."""
+ import http.client
+ import select
+ import signal
+ import socket
+ import time
+ nodes, keys = {}, {}
+
+ def udp_port():
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
+ probe.bind(('127.0.0.1', 0))
+ return probe.getsockname()[1]
+
+ ports = {'alice': udp_port(), 'bob': udp_port()}
+
+ def launch(args):
+ process = subprocess.Popen([str(a) for a in args], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ ready, _, _ = select.select([process.stdout], [], [], 10)
+ line = process.stdout.readline() if ready else b''
+ assert line, 'node did not start'
+ return process, json.loads(line)
+
+ def stop(name):
+ process = nodes.pop(name)[0]
+ process.send_signal(signal.SIGTERM)
+ _, error = process.communicate(timeout=30)
+ assert process.returncode == 0, error[-800:]
+
+ for name in ('alice', 'bob'): # the keys first
+ process, first = launch([serve, '--module', module, '--store', work / f'board-{name}', '--port', 0,
+ '--key', work / f'board-{name}.key'])
+ keys[name] = first['public_key']
+ nodes[name] = (process, 0)
+ stop(name)
+
+ def start(name, cut=False):
+ other = 'bob' if name == 'alice' else 'alice'
+ listen, to = (udp_port(), udp_port()) if cut else (ports[name], ports[other])
+ process, first = launch([serve, '--module', module, '--store', work / f'board-{name}', '--port', 0,
+ '--key', work / f'board-{name}.key', '--name', name, '--link-port', listen,
+ '--send', f'changed=pure://127.0.0.1:{to}/remote', '--seal', f'changed={keys[other]}',
+ '--sender', f'{other}.changed={keys[other]}'])
+ assert first['name'] == name, first
+ nodes[name] = (process, first['port'])
+
+ def call(name, path, body=None):
+ connection = http.client.HTTPConnection('127.0.0.1', nodes[name][1], timeout=10)
+ headers = {}
+ if body is not None:
+ data = body.encode() if isinstance(body, str) else json.dumps(body).encode()
+ headers = {'Content-Length': str(len(data)),
+ 'Content-Type': 'text/plain' if isinstance(body, str) else 'application/json'}
+ connection.request('POST' if body is not None else 'GET', path, data if body is not None else None, headers)
+ answer = connection.getresponse()
+ text = answer.read().decode()
+ connection.close()
+ return answer.status, text
+
+ def board(name):
+ return sorted((c['label'], c['badge']) for c in json.loads(call(name, '/views/cards_list')[1])['items'])
+
+ def agree(expected=None, seconds=20):
+ deadline = time.time() + seconds
+ while time.time() < deadline:
+ a, b = board('alice'), board('bob')
+ if a == b and (expected is None or a == expected):
+ return a
+ time.sleep(0.05)
+ raise AssertionError((board('alice'), board('bob'), expected))
+
+ try:
+ for name in ('alice', 'bob'):
+ start(name)
+ assert call(name, '/inputs/name', {'name': name})[0] == 200
+ for title in ('plan the week', 'write the tests'):
+ assert call('alice', '/inputs/add', title)[0] == 200
+ assert call('bob', '/inputs/add', 'fix the build')[0] == 200
+ assert call('alice', '/inputs/forward', '')[0] == 200 # write the tests: doing
+ agree(seconds=20)
+ assert call('bob', '/inputs/assign', {'title': 'plan the week', 'who': 'bob'})[0] == 200
+ together = agree([('fix the build', 'to do'), ('plan the week', 'bob, to do'), ('write the tests', 'doing')])
+ stop('bob')
+ start('bob', cut=True)
+ assert call('bob', '/inputs/assign', {'title': 'fix the build', 'who': 'bob'})[0] == 200
+ time.sleep(0.05) # Alice's is the later change
+ assert call('alice', '/inputs/assign', {'title': 'fix the build', 'who': 'alice'})[0] == 200
+ assert call('bob', '/inputs/add', 'made while cut off')[0] == 200
+ time.sleep(0.5)
+ assert ('fix the build', 'bob, to do') in board('bob') and ('fix the build', 'alice, to do') in board('alice')
+ stop('bob')
+ start('bob')
+ joined = agree([('fix the build', 'alice, to do'), ('made while cut off', 'to do'), ('plan the week', 'bob, to do'),
+ ('write the tests', 'doing')])
+ finally:
+ for name in list(nodes):
+ stop(name)
+ return {'cards': len(together), 'after_joining': len(joined)}
+
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--vega', type=Path, required=True)
@@ -1717,7 +1823,7 @@ def main():
work = Path(directory)
env = dict(os.environ, AUP_PATH=str(ROOT / 'lib'))
modules = {}
- for name in ('todo', 'scores', 'today', 'stopwatch', 'board', 'messenger'):
+ for name in ('todo', 'scores', 'today', 'stopwatch', 'board', 'messenger', 'shared-board'):
modules[name] = work / f'{name}.pmod'
built = run(aup, 'build', ROOT / 'examples' / f'{name}.aup', '-o', modules[name], env=env)
assert built.returncode == 0, built.stderr
@@ -1751,6 +1857,7 @@ def main():
report['live_display'] = live_display(vega, modules['todo'], work)
report['node_display'] = node_display(vega, args.serve.resolve(), modules['todo'], work)
report['messenger'] = messenger(vega, args.serve.resolve(), modules['messenger'], work)
+ report['shared_board'] = shared_board(args.serve.resolve(), modules['shared-board'], work)
print(json.dumps(report))