tl/scenes/tests.py

192 lines
8.7 KiB
Python
Raw Normal View History

import json
feat: who's viewing, and ad-hoc parties that watch together The project socket already carried scene deltas; it now also carries presence. The top bar grows a Google-Docs-style cluster of faces, and the menu behind it groups the room into parties, then everyone flying solo, with a Join on each. A party has no host — you only join one. Joining someone solo adopts *their cid* as the party id, so two people clicking Join in the same instant converge instead of minting two parties of one, and the party outlives whoever was joined first. Everyone in it drives everyone else: a jump, a scrub or a play from any member moves all the others. That's why being joined needs consent ("Let others join me", on by default, remembered) and why Leave is one click. Ordering was the thing to get right. A save is a PUT and a jump rides the socket, so "create an annotation, then jump into it" can arrive at a peer in the wrong order. Rather than truncate the stack and strand them at the root, a nav naming a group we haven't been told about is parked and replayed the moment the delta lands. Authoring parks it for the same reason: a draft detaches you from the party so hunting for marks is nobody else's business, and closing the form replays the park, putting you exactly where the party got to. Deleting a timeline someone is standing in now pops them out too. Playback ticks stay off the wire — every member runs the same clip off its own clock, so streaming positions would only fight them. Only deliberate moves and transport changes go out, and a play we started because a peer did isn't echoed back at them. The socket now reconnects with backoff and, on the way back, re-states the party id it was carrying and pulls the scene it missed. That catch-up is deliberately additive: "absent from the server" can also mean "saved a moment ago", and a wrong deletion costs someone their work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
from channels.testing import WebsocketCommunicator
from django.contrib.auth import get_user_model
feat: who's viewing, and ad-hoc parties that watch together The project socket already carried scene deltas; it now also carries presence. The top bar grows a Google-Docs-style cluster of faces, and the menu behind it groups the room into parties, then everyone flying solo, with a Join on each. A party has no host — you only join one. Joining someone solo adopts *their cid* as the party id, so two people clicking Join in the same instant converge instead of minting two parties of one, and the party outlives whoever was joined first. Everyone in it drives everyone else: a jump, a scrub or a play from any member moves all the others. That's why being joined needs consent ("Let others join me", on by default, remembered) and why Leave is one click. Ordering was the thing to get right. A save is a PUT and a jump rides the socket, so "create an annotation, then jump into it" can arrive at a peer in the wrong order. Rather than truncate the stack and strand them at the root, a nav naming a group we haven't been told about is parked and replayed the moment the delta lands. Authoring parks it for the same reason: a draft detaches you from the party so hunting for marks is nobody else's business, and closing the form replays the park, putting you exactly where the party got to. Deleting a timeline someone is standing in now pops them out too. Playback ticks stay off the wire — every member runs the same clip off its own clock, so streaming positions would only fight them. Only deliberate moves and transport changes go out, and a play we started because a peer did isn't echoed back at them. The socket now reconnects with backoff and, on the way back, re-states the party id it was carrying and pulls the scene it missed. That catch-up is deliberately additive: "absent from the server" can also mean "saved a moment ago", and a wrong deletion costs someone their work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
from django.contrib.auth.models import AnonymousUser
from django.test import Client, TestCase, TransactionTestCase
from scenes.models import Project, Revision
feat: who's viewing, and ad-hoc parties that watch together The project socket already carried scene deltas; it now also carries presence. The top bar grows a Google-Docs-style cluster of faces, and the menu behind it groups the room into parties, then everyone flying solo, with a Join on each. A party has no host — you only join one. Joining someone solo adopts *their cid* as the party id, so two people clicking Join in the same instant converge instead of minting two parties of one, and the party outlives whoever was joined first. Everyone in it drives everyone else: a jump, a scrub or a play from any member moves all the others. That's why being joined needs consent ("Let others join me", on by default, remembered) and why Leave is one click. Ordering was the thing to get right. A save is a PUT and a jump rides the socket, so "create an annotation, then jump into it" can arrive at a peer in the wrong order. Rather than truncate the stack and strand them at the root, a nav naming a group we haven't been told about is parked and replayed the moment the delta lands. Authoring parks it for the same reason: a draft detaches you from the party so hunting for marks is nobody else's business, and closing the form replays the park, putting you exactly where the party got to. Deleting a timeline someone is standing in now pops them out too. Playback ticks stay off the wire — every member runs the same clip off its own clock, so streaming positions would only fight them. Only deliberate moves and transport changes go out, and a play we started because a peer did isn't echoed back at them. The socket now reconnects with backoff and, on the way back, re-states the party id it was carrying and pulls the scene it missed. That catch-up is deliberately additive: "absent from the server" can also mean "saved a moment ago", and a wrong deletion costs someone their work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
from server.asgi import application
User = get_user_model()
def ann(name, content="", **extra):
return {"type": "annotation", "in": ["root"], "name": name,
"content": content, "marks": [], **extra}
class SceneApiTestCase(TestCase):
def setUp(self):
self.alice = User.objects.create_user("alice", password="pw")
self.bob = User.objects.create_user("bob", password="pw")
self.project = Project.objects.create(owner=self.alice, name="p")
self.project.collaborators.add(self.bob)
self.a = Client(); self.a.login(username="alice", password="pw")
self.b = Client(); self.b.login(username="bob", password="pw")
def put(self, client, **body):
return client.put(f"/api/projects/{self.project.pk}/scene/",
json.dumps(body), content_type="application/json")
def groups(self):
self.project.refresh_from_db()
return self.project.scene.get("groups", {})
class DeltaMergeTests(SceneApiTestCase):
def test_different_annotations_merge_without_clobber(self):
self.put(self.a, changed={"a1": ann("opening")})
self.put(self.b, changed={"a2": ann("beat")})
self.assertEqual(set(self.groups()), {"a1", "a2"})
def test_same_annotation_same_field_is_last_write_wins(self):
self.put(self.a, changed={"a1": ann("x", "first")})
self.put(self.b, changed={"a1": {"content": "second"}})
self.assertEqual(self.groups()["a1"]["content"], "second")
def test_same_annotation_different_fields_merge(self):
self.put(self.a, changed={"a1": ann("x", "first", marks=[{"id": "m0"}])})
self.put(self.a, changed={"a1": {"marks": [{"id": "m1"}]}})
self.put(self.b, changed={"a1": {"content": "second"}})
g = self.groups()["a1"]
self.assertEqual(g["content"], "second")
self.assertEqual(g["marks"], [{"id": "m1"}])
def test_stale_client_neither_resurrects_nor_wipes(self):
# bob only ever knew a2; his delta must not erase alice's a1
self.put(self.a, changed={"a1": ann("x")})
self.put(self.b, changed={"a2": ann("y")})
self.assertEqual(set(self.groups()), {"a1", "a2"})
def test_delete_is_explicit_and_scoped(self):
self.put(self.a, changed={"a1": ann("x"), "a2": ann("y")})
self.put(self.b, deleted=["a1"])
self.assertEqual(set(self.groups()), {"a2"})
class AttributionTests(SceneApiTestCase):
def test_server_stamps_creator_ignoring_client(self):
self.put(self.a, changed={"a1": ann("x", createdBy="hacker", editedBy="hacker")})
g = self.groups()["a1"]
self.assertEqual((g["createdBy"], g["editedBy"]), ("alice", "alice"))
def test_edit_preserves_creator_updates_editor(self):
self.put(self.a, changed={"a1": ann("x")})
self.put(self.b, changed={"a1": ann("x", "edited")})
g = self.groups()["a1"]
self.assertEqual(g["createdBy"], "alice")
self.assertEqual(g["editedBy"], "bob")
def test_unchanged_annotation_is_not_restamped(self):
self.put(self.a, changed={"a1": ann("x")})
before = self.groups()["a1"]["editedAt"]
self.put(self.b, changed={"a1": ann("x")}) # identical content
after = self.groups()["a1"]
self.assertEqual(after["editedAt"], before)
self.assertEqual(after["editedBy"], "alice")
def test_revision_per_real_change_only(self):
self.put(self.a, changed={"a1": ann("x")})
self.put(self.b, changed={"a1": ann("x")}) # no-op → no revision
self.put(self.a, deleted=["a1"])
revs = Revision.objects.filter(project=self.project).order_by("created")
self.assertEqual([r.summary for r in revs],
["+1 ~0 −0 annotations", "+0 ~0 −1 annotations"])
self.assertEqual(revs[0].user, self.alice)
class AccessTests(SceneApiTestCase):
def test_write_requires_authentication(self):
r = Client().put(f"/api/projects/{self.project.pk}/scene/",
json.dumps({"changed": {}}), content_type="application/json")
self.assertEqual(r.status_code, 401)
def test_non_collaborator_cannot_edit(self):
User.objects.create_user("carol", password="pw")
carol = Client(); carol.login(username="carol", password="pw")
self.assertEqual(self.put(carol, changed={"a1": ann("x")}).status_code, 404)
feat: who's viewing, and ad-hoc parties that watch together The project socket already carried scene deltas; it now also carries presence. The top bar grows a Google-Docs-style cluster of faces, and the menu behind it groups the room into parties, then everyone flying solo, with a Join on each. A party has no host — you only join one. Joining someone solo adopts *their cid* as the party id, so two people clicking Join in the same instant converge instead of minting two parties of one, and the party outlives whoever was joined first. Everyone in it drives everyone else: a jump, a scrub or a play from any member moves all the others. That's why being joined needs consent ("Let others join me", on by default, remembered) and why Leave is one click. Ordering was the thing to get right. A save is a PUT and a jump rides the socket, so "create an annotation, then jump into it" can arrive at a peer in the wrong order. Rather than truncate the stack and strand them at the root, a nav naming a group we haven't been told about is parked and replayed the moment the delta lands. Authoring parks it for the same reason: a draft detaches you from the party so hunting for marks is nobody else's business, and closing the form replays the park, putting you exactly where the party got to. Deleting a timeline someone is standing in now pops them out too. Playback ticks stay off the wire — every member runs the same clip off its own clock, so streaming positions would only fight them. Only deliberate moves and transport changes go out, and a play we started because a peer did isn't echoed back at them. The socket now reconnects with backoff and, on the way back, re-states the party id it was carrying and pulls the scene it missed. That catch-up is deliberately additive: "absent from the server" can also mean "saved a moment ago", and a wrong deletion costs someone their work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
class PresenceRelayTests(TransactionTestCase):
perf: hand a joiner the room instead of asking everyone to answer A joining peer learnt the roster by announcing itself and having every member reply with their state. That's one broadcast per member — O(N^2) messages for a room of N — and the in-memory channel layer caps each connection's queue at 100 and drops the overflow silently. Measured with 100 synthetic peers: every peer ended up seeing only 58-96 of the other 100, permanently. Not cosmetic — joinable? and mirrors? both read the roster, so you couldn't join someone you couldn't see, and a mate whose state was dropped wouldn't move you. The consumer now keeps the roster it is already relaying and hands a newcomer a snapshot in one message, so a join costs two broadcasts instead of N. Same 100 peers: roster complete and identical for everyone, 2310 messages sent down to 349, 72.6k deliveries down to 38.6k, server 30% -> 21% of one core, nav latency unchanged at ~14ms p50. This is a cache of relayed gossip, not a source of truth: parties are still worked out entirely in the browsers, and the server still decides nothing about them. It is process-local, like the in-memory channel layer it sits next to — if that ever moves to Redis for multiple workers, this moves too. Measured ceiling for the pathological case (everyone in ONE party, all mirroring each other): 100 peers ~14ms p50 at 21% of a core, 150 still ~14ms at 27%, 250 degrades to ~450ms p50 with the roster incomplete again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 17:01:33 -04:00
"""The socket hands out connection ids, stamps identity, and hands a joiner
the room in one message. The party itself is worked out in the browsers, so
there is nothing here to decide or trust."""
feat: who's viewing, and ad-hoc parties that watch together The project socket already carried scene deltas; it now also carries presence. The top bar grows a Google-Docs-style cluster of faces, and the menu behind it groups the room into parties, then everyone flying solo, with a Join on each. A party has no host — you only join one. Joining someone solo adopts *their cid* as the party id, so two people clicking Join in the same instant converge instead of minting two parties of one, and the party outlives whoever was joined first. Everyone in it drives everyone else: a jump, a scrub or a play from any member moves all the others. That's why being joined needs consent ("Let others join me", on by default, remembered) and why Leave is one click. Ordering was the thing to get right. A save is a PUT and a jump rides the socket, so "create an annotation, then jump into it" can arrive at a peer in the wrong order. Rather than truncate the stack and strand them at the root, a nav naming a group we haven't been told about is parked and replayed the moment the delta lands. Authoring parks it for the same reason: a draft detaches you from the party so hunting for marks is nobody else's business, and closing the form replays the park, putting you exactly where the party got to. Deleting a timeline someone is standing in now pops them out too. Playback ticks stay off the wire — every member runs the same clip off its own clock, so streaming positions would only fight them. Only deliberate moves and transport changes go out, and a play we started because a peer did isn't echoed back at them. The socket now reconnects with backoff and, on the way back, re-states the party id it was carrying and pulls the scene it missed. That catch-up is deliberately additive: "absent from the server" can also mean "saved a moment ago", and a wrong deletion costs someone their work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
def setUp(self):
self.alice = User.objects.create_user("alice", password="pw")
self.project = Project.objects.create(owner=self.alice, name="p")
async def open(self, user=None):
perf: hand a joiner the room instead of asking everyone to answer A joining peer learnt the roster by announcing itself and having every member reply with their state. That's one broadcast per member — O(N^2) messages for a room of N — and the in-memory channel layer caps each connection's queue at 100 and drops the overflow silently. Measured with 100 synthetic peers: every peer ended up seeing only 58-96 of the other 100, permanently. Not cosmetic — joinable? and mirrors? both read the roster, so you couldn't join someone you couldn't see, and a mate whose state was dropped wouldn't move you. The consumer now keeps the roster it is already relaying and hands a newcomer a snapshot in one message, so a join costs two broadcasts instead of N. Same 100 peers: roster complete and identical for everyone, 2310 messages sent down to 349, 72.6k deliveries down to 38.6k, server 30% -> 21% of one core, nav latency unchanged at ~14ms p50. This is a cache of relayed gossip, not a source of truth: parties are still worked out entirely in the browsers, and the server still decides nothing about them. It is process-local, like the in-memory channel layer it sits next to — if that ever moves to Redis for multiple workers, this moves too. Measured ceiling for the pathological case (everyone in ONE party, all mirroring each other): 100 peers ~14ms p50 at 21% of a core, 150 still ~14ms at 27%, 250 degrades to ~450ms p50 with the roster incomplete again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 17:01:33 -04:00
"""Connect and drain the handshake, returning (comm, welcome, roster)."""
feat: who's viewing, and ad-hoc parties that watch together The project socket already carried scene deltas; it now also carries presence. The top bar grows a Google-Docs-style cluster of faces, and the menu behind it groups the room into parties, then everyone flying solo, with a Join on each. A party has no host — you only join one. Joining someone solo adopts *their cid* as the party id, so two people clicking Join in the same instant converge instead of minting two parties of one, and the party outlives whoever was joined first. Everyone in it drives everyone else: a jump, a scrub or a play from any member moves all the others. That's why being joined needs consent ("Let others join me", on by default, remembered) and why Leave is one click. Ordering was the thing to get right. A save is a PUT and a jump rides the socket, so "create an annotation, then jump into it" can arrive at a peer in the wrong order. Rather than truncate the stack and strand them at the root, a nav naming a group we haven't been told about is parked and replayed the moment the delta lands. Authoring parks it for the same reason: a draft detaches you from the party so hunting for marks is nobody else's business, and closing the form replays the park, putting you exactly where the party got to. Deleting a timeline someone is standing in now pops them out too. Playback ticks stay off the wire — every member runs the same clip off its own clock, so streaming positions would only fight them. Only deliberate moves and transport changes go out, and a play we started because a peer did isn't echoed back at them. The socket now reconnects with backoff and, on the way back, re-states the party id it was carrying and pulls the scene it missed. That catch-up is deliberately additive: "absent from the server" can also mean "saved a moment ago", and a wrong deletion costs someone their work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
comm = WebsocketCommunicator(application, f"/ws/projects/{self.project.pk}/")
comm.scope["user"] = user or AnonymousUser()
connected, _ = await comm.connect()
self.assertTrue(connected)
perf: hand a joiner the room instead of asking everyone to answer A joining peer learnt the roster by announcing itself and having every member reply with their state. That's one broadcast per member — O(N^2) messages for a room of N — and the in-memory channel layer caps each connection's queue at 100 and drops the overflow silently. Measured with 100 synthetic peers: every peer ended up seeing only 58-96 of the other 100, permanently. Not cosmetic — joinable? and mirrors? both read the roster, so you couldn't join someone you couldn't see, and a mate whose state was dropped wouldn't move you. The consumer now keeps the roster it is already relaying and hands a newcomer a snapshot in one message, so a join costs two broadcasts instead of N. Same 100 peers: roster complete and identical for everyone, 2310 messages sent down to 349, 72.6k deliveries down to 38.6k, server 30% -> 21% of one core, nav latency unchanged at ~14ms p50. This is a cache of relayed gossip, not a source of truth: parties are still worked out entirely in the browsers, and the server still decides nothing about them. It is process-local, like the in-memory channel layer it sits next to — if that ever moves to Redis for multiple workers, this moves too. Measured ceiling for the pathological case (everyone in ONE party, all mirroring each other): 100 peers ~14ms p50 at 21% of a core, 150 still ~14ms at 27%, 250 degrades to ~450ms p50 with the roster incomplete again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 17:01:33 -04:00
welcome = await comm.receive_json_from()
roster = await comm.receive_json_from()
await comm.receive_json_from() # our own join, echoed back
return comm, welcome, roster
feat: who's viewing, and ad-hoc parties that watch together The project socket already carried scene deltas; it now also carries presence. The top bar grows a Google-Docs-style cluster of faces, and the menu behind it groups the room into parties, then everyone flying solo, with a Join on each. A party has no host — you only join one. Joining someone solo adopts *their cid* as the party id, so two people clicking Join in the same instant converge instead of minting two parties of one, and the party outlives whoever was joined first. Everyone in it drives everyone else: a jump, a scrub or a play from any member moves all the others. That's why being joined needs consent ("Let others join me", on by default, remembered) and why Leave is one click. Ordering was the thing to get right. A save is a PUT and a jump rides the socket, so "create an annotation, then jump into it" can arrive at a peer in the wrong order. Rather than truncate the stack and strand them at the root, a nav naming a group we haven't been told about is parked and replayed the moment the delta lands. Authoring parks it for the same reason: a draft detaches you from the party so hunting for marks is nobody else's business, and closing the form replays the park, putting you exactly where the party got to. Deleting a timeline someone is standing in now pops them out too. Playback ticks stay off the wire — every member runs the same clip off its own clock, so streaming positions would only fight them. Only deliberate moves and transport changes go out, and a play we started because a peer did isn't echoed back at them. The socket now reconnects with backoff and, on the way back, re-states the party id it was carrying and pulls the scene it missed. That catch-up is deliberately additive: "absent from the server" can also mean "saved a moment ago", and a wrong deletion costs someone their work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
async def test_welcome_carries_an_id_and_the_signed_in_name(self):
perf: hand a joiner the room instead of asking everyone to answer A joining peer learnt the roster by announcing itself and having every member reply with their state. That's one broadcast per member — O(N^2) messages for a room of N — and the in-memory channel layer caps each connection's queue at 100 and drops the overflow silently. Measured with 100 synthetic peers: every peer ended up seeing only 58-96 of the other 100, permanently. Not cosmetic — joinable? and mirrors? both read the roster, so you couldn't join someone you couldn't see, and a mate whose state was dropped wouldn't move you. The consumer now keeps the roster it is already relaying and hands a newcomer a snapshot in one message, so a join costs two broadcasts instead of N. Same 100 peers: roster complete and identical for everyone, 2310 messages sent down to 349, 72.6k deliveries down to 38.6k, server 30% -> 21% of one core, nav latency unchanged at ~14ms p50. This is a cache of relayed gossip, not a source of truth: parties are still worked out entirely in the browsers, and the server still decides nothing about them. It is process-local, like the in-memory channel layer it sits next to — if that ever moves to Redis for multiple workers, this moves too. Measured ceiling for the pathological case (everyone in ONE party, all mirroring each other): 100 peers ~14ms p50 at 21% of a core, 150 still ~14ms at 27%, 250 degrades to ~450ms p50 with the roster incomplete again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 17:01:33 -04:00
comm, welcome, _ = await self.open(self.alice)
feat: who's viewing, and ad-hoc parties that watch together The project socket already carried scene deltas; it now also carries presence. The top bar grows a Google-Docs-style cluster of faces, and the menu behind it groups the room into parties, then everyone flying solo, with a Join on each. A party has no host — you only join one. Joining someone solo adopts *their cid* as the party id, so two people clicking Join in the same instant converge instead of minting two parties of one, and the party outlives whoever was joined first. Everyone in it drives everyone else: a jump, a scrub or a play from any member moves all the others. That's why being joined needs consent ("Let others join me", on by default, remembered) and why Leave is one click. Ordering was the thing to get right. A save is a PUT and a jump rides the socket, so "create an annotation, then jump into it" can arrive at a peer in the wrong order. Rather than truncate the stack and strand them at the root, a nav naming a group we haven't been told about is parked and replayed the moment the delta lands. Authoring parks it for the same reason: a draft detaches you from the party so hunting for marks is nobody else's business, and closing the form replays the park, putting you exactly where the party got to. Deleting a timeline someone is standing in now pops them out too. Playback ticks stay off the wire — every member runs the same clip off its own clock, so streaming positions would only fight them. Only deliberate moves and transport changes go out, and a play we started because a peer did isn't echoed back at them. The socket now reconnects with backoff and, on the way back, re-states the party id it was carrying and pulls the scene it missed. That catch-up is deliberately additive: "absent from the server" can also mean "saved a moment ago", and a wrong deletion costs someone their work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
self.assertEqual(welcome["kind"], "welcome")
self.assertEqual(welcome["user"], "alice")
self.assertTrue(welcome["cid"])
await comm.disconnect()
perf: hand a joiner the room instead of asking everyone to answer A joining peer learnt the roster by announcing itself and having every member reply with their state. That's one broadcast per member — O(N^2) messages for a room of N — and the in-memory channel layer caps each connection's queue at 100 and drops the overflow silently. Measured with 100 synthetic peers: every peer ended up seeing only 58-96 of the other 100, permanently. Not cosmetic — joinable? and mirrors? both read the roster, so you couldn't join someone you couldn't see, and a mate whose state was dropped wouldn't move you. The consumer now keeps the roster it is already relaying and hands a newcomer a snapshot in one message, so a join costs two broadcasts instead of N. Same 100 peers: roster complete and identical for everyone, 2310 messages sent down to 349, 72.6k deliveries down to 38.6k, server 30% -> 21% of one core, nav latency unchanged at ~14ms p50. This is a cache of relayed gossip, not a source of truth: parties are still worked out entirely in the browsers, and the server still decides nothing about them. It is process-local, like the in-memory channel layer it sits next to — if that ever moves to Redis for multiple workers, this moves too. Measured ceiling for the pathological case (everyone in ONE party, all mirroring each other): 100 peers ~14ms p50 at 21% of a core, 150 still ~14ms at 27%, 250 degrades to ~450ms p50 with the roster incomplete again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 17:01:33 -04:00
async def test_a_joiner_is_handed_the_whole_room_in_one_message(self):
# the alternative — everyone answering a join — costs a broadcast per
# member, and a room of a hundred loses most of its roster to it
a, a_hello, a_roster = await self.open(self.alice)
self.assertEqual(a_roster["peers"], []) # first one in
await a.send_json_to({"kind": "state", "party": "P", "joinable": False})
await a.receive_json_from()
b, _, b_roster = await self.open()
self.assertEqual([p["cid"] for p in b_roster["peers"]], [a_hello["cid"]])
seen = b_roster["peers"][0]
self.assertEqual((seen["user"], seen["party"], seen["joinable"]),
("alice", "P", False)) # including what they last said
self.assertTrue(await b.receive_nothing(timeout=0.2)) # and nobody answers
await a.disconnect(); await b.disconnect()
async def test_a_departure_is_forgotten_not_handed_to_the_next_joiner(self):
a, _, _ = await self.open(self.alice)
b, b_hello, _ = await self.open()
await a.receive_json_from() # b's join
await b.disconnect()
await a.receive_json_from() # b's leave
c, _, c_roster = await self.open()
self.assertNotIn(b_hello["cid"], [p["cid"] for p in c_roster["peers"]])
await a.disconnect(); await c.disconnect()
feat: who's viewing, and ad-hoc parties that watch together The project socket already carried scene deltas; it now also carries presence. The top bar grows a Google-Docs-style cluster of faces, and the menu behind it groups the room into parties, then everyone flying solo, with a Join on each. A party has no host — you only join one. Joining someone solo adopts *their cid* as the party id, so two people clicking Join in the same instant converge instead of minting two parties of one, and the party outlives whoever was joined first. Everyone in it drives everyone else: a jump, a scrub or a play from any member moves all the others. That's why being joined needs consent ("Let others join me", on by default, remembered) and why Leave is one click. Ordering was the thing to get right. A save is a PUT and a jump rides the socket, so "create an annotation, then jump into it" can arrive at a peer in the wrong order. Rather than truncate the stack and strand them at the root, a nav naming a group we haven't been told about is parked and replayed the moment the delta lands. Authoring parks it for the same reason: a draft detaches you from the party so hunting for marks is nobody else's business, and closing the form replays the park, putting you exactly where the party got to. Deleting a timeline someone is standing in now pops them out too. Playback ticks stay off the wire — every member runs the same clip off its own clock, so streaming positions would only fight them. Only deliberate moves and transport changes go out, and a play we started because a peer did isn't echoed back at them. The socket now reconnects with backoff and, on the way back, re-states the party id it was carrying and pulls the scene it missed. That catch-up is deliberately additive: "absent from the server" can also mean "saved a moment ago", and a wrong deletion costs someone their work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
async def test_presence_is_stamped_with_the_server_side_identity(self):
perf: hand a joiner the room instead of asking everyone to answer A joining peer learnt the roster by announcing itself and having every member reply with their state. That's one broadcast per member — O(N^2) messages for a room of N — and the in-memory channel layer caps each connection's queue at 100 and drops the overflow silently. Measured with 100 synthetic peers: every peer ended up seeing only 58-96 of the other 100, permanently. Not cosmetic — joinable? and mirrors? both read the roster, so you couldn't join someone you couldn't see, and a mate whose state was dropped wouldn't move you. The consumer now keeps the roster it is already relaying and hands a newcomer a snapshot in one message, so a join costs two broadcasts instead of N. Same 100 peers: roster complete and identical for everyone, 2310 messages sent down to 349, 72.6k deliveries down to 38.6k, server 30% -> 21% of one core, nav latency unchanged at ~14ms p50. This is a cache of relayed gossip, not a source of truth: parties are still worked out entirely in the browsers, and the server still decides nothing about them. It is process-local, like the in-memory channel layer it sits next to — if that ever moves to Redis for multiple workers, this moves too. Measured ceiling for the pathological case (everyone in ONE party, all mirroring each other): 100 peers ~14ms p50 at 21% of a core, 150 still ~14ms at 27%, 250 degrades to ~450ms p50 with the roster incomplete again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 17:01:33 -04:00
a, a_hello, _ = await self.open(self.alice)
b, _, _ = await self.open()
feat: who's viewing, and ad-hoc parties that watch together The project socket already carried scene deltas; it now also carries presence. The top bar grows a Google-Docs-style cluster of faces, and the menu behind it groups the room into parties, then everyone flying solo, with a Join on each. A party has no host — you only join one. Joining someone solo adopts *their cid* as the party id, so two people clicking Join in the same instant converge instead of minting two parties of one, and the party outlives whoever was joined first. Everyone in it drives everyone else: a jump, a scrub or a play from any member moves all the others. That's why being joined needs consent ("Let others join me", on by default, remembered) and why Leave is one click. Ordering was the thing to get right. A save is a PUT and a jump rides the socket, so "create an annotation, then jump into it" can arrive at a peer in the wrong order. Rather than truncate the stack and strand them at the root, a nav naming a group we haven't been told about is parked and replayed the moment the delta lands. Authoring parks it for the same reason: a draft detaches you from the party so hunting for marks is nobody else's business, and closing the form replays the park, putting you exactly where the party got to. Deleting a timeline someone is standing in now pops them out too. Playback ticks stay off the wire — every member runs the same clip off its own clock, so streaming positions would only fight them. Only deliberate moves and transport changes go out, and a play we started because a peer did isn't echoed back at them. The socket now reconnects with backoff and, on the way back, re-states the party id it was carrying and pulls the scene it missed. That catch-up is deliberately additive: "absent from the server" can also mean "saved a moment ago", and a wrong deletion costs someone their work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
self.assertEqual((await a.receive_json_from())["kind"], "join")
await b.send_json_to({"kind": "state", "party": a_hello["cid"],
"joinable": True, "joining": a_hello["cid"],
"user": "alice", "cid": "forged"})
seen = await a.receive_json_from()
self.assertEqual(seen["party"], a_hello["cid"])
self.assertIsNone(seen["user"]) # anonymous, not "alice"
self.assertNotEqual(seen["cid"], "forged")
await a.disconnect(); await b.disconnect()
async def test_scene_edits_are_not_accepted_over_the_socket(self):
perf: hand a joiner the room instead of asking everyone to answer A joining peer learnt the roster by announcing itself and having every member reply with their state. That's one broadcast per member — O(N^2) messages for a room of N — and the in-memory channel layer caps each connection's queue at 100 and drops the overflow silently. Measured with 100 synthetic peers: every peer ended up seeing only 58-96 of the other 100, permanently. Not cosmetic — joinable? and mirrors? both read the roster, so you couldn't join someone you couldn't see, and a mate whose state was dropped wouldn't move you. The consumer now keeps the roster it is already relaying and hands a newcomer a snapshot in one message, so a join costs two broadcasts instead of N. Same 100 peers: roster complete and identical for everyone, 2310 messages sent down to 349, 72.6k deliveries down to 38.6k, server 30% -> 21% of one core, nav latency unchanged at ~14ms p50. This is a cache of relayed gossip, not a source of truth: parties are still worked out entirely in the browsers, and the server still decides nothing about them. It is process-local, like the in-memory channel layer it sits next to — if that ever moves to Redis for multiple workers, this moves too. Measured ceiling for the pathological case (everyone in ONE party, all mirroring each other): 100 peers ~14ms p50 at 21% of a core, 150 still ~14ms at 27%, 250 degrades to ~450ms p50 with the roster incomplete again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 17:01:33 -04:00
a, _, _ = await self.open(self.alice)
feat: who's viewing, and ad-hoc parties that watch together The project socket already carried scene deltas; it now also carries presence. The top bar grows a Google-Docs-style cluster of faces, and the menu behind it groups the room into parties, then everyone flying solo, with a Join on each. A party has no host — you only join one. Joining someone solo adopts *their cid* as the party id, so two people clicking Join in the same instant converge instead of minting two parties of one, and the party outlives whoever was joined first. Everyone in it drives everyone else: a jump, a scrub or a play from any member moves all the others. That's why being joined needs consent ("Let others join me", on by default, remembered) and why Leave is one click. Ordering was the thing to get right. A save is a PUT and a jump rides the socket, so "create an annotation, then jump into it" can arrive at a peer in the wrong order. Rather than truncate the stack and strand them at the root, a nav naming a group we haven't been told about is parked and replayed the moment the delta lands. Authoring parks it for the same reason: a draft detaches you from the party so hunting for marks is nobody else's business, and closing the form replays the park, putting you exactly where the party got to. Deleting a timeline someone is standing in now pops them out too. Playback ticks stay off the wire — every member runs the same clip off its own clock, so streaming positions would only fight them. Only deliberate moves and transport changes go out, and a play we started because a peer did isn't echoed back at them. The socket now reconnects with backoff and, on the way back, re-states the party id it was carrying and pulls the scene it missed. That catch-up is deliberately additive: "absent from the server" can also mean "saved a moment ago", and a wrong deletion costs someone their work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
await a.send_json_to({"kind": "scene", "changed": {"a1": ann("x")}})
self.assertTrue(await a.receive_nothing(timeout=0.2))
await a.disconnect()
async def test_leaving_tells_the_room(self):
perf: hand a joiner the room instead of asking everyone to answer A joining peer learnt the roster by announcing itself and having every member reply with their state. That's one broadcast per member — O(N^2) messages for a room of N — and the in-memory channel layer caps each connection's queue at 100 and drops the overflow silently. Measured with 100 synthetic peers: every peer ended up seeing only 58-96 of the other 100, permanently. Not cosmetic — joinable? and mirrors? both read the roster, so you couldn't join someone you couldn't see, and a mate whose state was dropped wouldn't move you. The consumer now keeps the roster it is already relaying and hands a newcomer a snapshot in one message, so a join costs two broadcasts instead of N. Same 100 peers: roster complete and identical for everyone, 2310 messages sent down to 349, 72.6k deliveries down to 38.6k, server 30% -> 21% of one core, nav latency unchanged at ~14ms p50. This is a cache of relayed gossip, not a source of truth: parties are still worked out entirely in the browsers, and the server still decides nothing about them. It is process-local, like the in-memory channel layer it sits next to — if that ever moves to Redis for multiple workers, this moves too. Measured ceiling for the pathological case (everyone in ONE party, all mirroring each other): 100 peers ~14ms p50 at 21% of a core, 150 still ~14ms at 27%, 250 degrades to ~450ms p50 with the roster incomplete again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 17:01:33 -04:00
a, _, _ = await self.open(self.alice)
b, b_hello, _ = await self.open()
feat: who's viewing, and ad-hoc parties that watch together The project socket already carried scene deltas; it now also carries presence. The top bar grows a Google-Docs-style cluster of faces, and the menu behind it groups the room into parties, then everyone flying solo, with a Join on each. A party has no host — you only join one. Joining someone solo adopts *their cid* as the party id, so two people clicking Join in the same instant converge instead of minting two parties of one, and the party outlives whoever was joined first. Everyone in it drives everyone else: a jump, a scrub or a play from any member moves all the others. That's why being joined needs consent ("Let others join me", on by default, remembered) and why Leave is one click. Ordering was the thing to get right. A save is a PUT and a jump rides the socket, so "create an annotation, then jump into it" can arrive at a peer in the wrong order. Rather than truncate the stack and strand them at the root, a nav naming a group we haven't been told about is parked and replayed the moment the delta lands. Authoring parks it for the same reason: a draft detaches you from the party so hunting for marks is nobody else's business, and closing the form replays the park, putting you exactly where the party got to. Deleting a timeline someone is standing in now pops them out too. Playback ticks stay off the wire — every member runs the same clip off its own clock, so streaming positions would only fight them. Only deliberate moves and transport changes go out, and a play we started because a peer did isn't echoed back at them. The socket now reconnects with backoff and, on the way back, re-states the party id it was carrying and pulls the scene it missed. That catch-up is deliberately additive: "absent from the server" can also mean "saved a moment ago", and a wrong deletion costs someone their work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
await a.receive_json_from() # b's join
await b.disconnect()
bye = await a.receive_json_from()
self.assertEqual((bye["kind"], bye["cid"]), ("leave", b_hello["cid"]))
await a.disconnect()