diff options
| -rw-r--r-- | examples/shared-board.aup | 96 | ||||
| -rw-r--r-- | tests/check.py | 109 |
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)) |
