···102102 r.broadcast <- &broadcastMsg{data: data, except: except}
103103}
104104105105-// ApplyEdit applies an operation via the OT engine and returns the broadcast message.
106106-func (r *Room) ApplyEdit(op Operation, sender *Client) {
107107- newText, _ := r.ot.ApplyWithVersion(op)
108108-109109- type editMsg struct {
110110- Type string `json:"type"`
111111- Delta Operation `json:"delta"`
112112- Author string `json:"author"`
113113- Content string `json:"content"`
114114- }
115115- msg := editMsg{
116116- Type: "edit",
117117- Delta: op,
118118- Author: sender.DID,
119119- Content: newText,
120120- }
121121- data, err := json.Marshal(msg)
122122- if err != nil {
123123- log.Printf("ApplyEdit: marshal: %v", err)
124124- return
125125- }
126126- r.BroadcastExcept(data, sender)
127127-}
128128-129105// ApplyEdits applies a sequence of operations in order and broadcasts one
130106// combined message to all other clients. Each op is applied to the text
131107// resulting from the previous op, so positions in each op must be relative
···138114 return
139115 }
140116117117+ // Capture the text returned by the final ApplyWithVersion so we don't
118118+ // race against another goroutine calling GetText() separately.
119119+ var finalText string
141120 for i := range ops {
142142- r.ot.ApplyWithVersion(ops[i])
121121+ finalText, _ = r.ot.ApplyWithVersion(ops[i])
143122 }
144123145124 // Include the full document text so receivers can detect and recover from
146125 // divergence without a reconnect.
147147- finalText := r.ot.GetText()
148126 type editsMsg struct {
149127 Type string `json:"type"`
150128 Deltas []Operation `json:"deltas"`
+411
internal/collaboration/hub_test.go
···11+package collaboration
22+33+import (
44+ "encoding/json"
55+ "testing"
66+ "time"
77+)
88+99+// stubClient creates a Client with a real send channel but no WebSocket conn.
1010+// It is usable for hub/room tests that don't exercise the network layer.
1111+func stubClient(hub *Hub, did, name, color, roomKey string) *Client {
1212+ return &Client{
1313+ hub: hub,
1414+ conn: nil, // not used in hub logic
1515+ send: make(chan []byte, 256),
1616+ DID: did,
1717+ Name: name,
1818+ Color: color,
1919+ roomKey: roomKey,
2020+ }
2121+}
2222+2323+// drain reads all messages from a client's send channel within timeout.
2424+func drain(c *Client, timeout time.Duration) [][]byte {
2525+ deadline := time.After(timeout)
2626+ var msgs [][]byte
2727+ for {
2828+ select {
2929+ case msg, ok := <-c.send:
3030+ if !ok {
3131+ return msgs
3232+ }
3333+ msgs = append(msgs, msg)
3434+ case <-deadline:
3535+ return msgs
3636+ }
3737+ }
3838+}
3939+4040+// waitForMessages blocks until n messages arrive on c.send or timeout.
4141+func waitForMessages(c *Client, n int, timeout time.Duration) [][]byte {
4242+ deadline := time.After(timeout)
4343+ var msgs [][]byte
4444+ for len(msgs) < n {
4545+ select {
4646+ case msg, ok := <-c.send:
4747+ if !ok {
4848+ return msgs
4949+ }
5050+ msgs = append(msgs, msg)
5151+ case <-deadline:
5252+ return msgs
5353+ }
5454+ }
5555+ return msgs
5656+}
5757+5858+// --- Hub tests ---
5959+6060+func TestHub_GetOrCreateRoom_CreatesNew(t *testing.T) {
6161+ hub := NewHub()
6262+ room := hub.GetOrCreateRoom("doc1")
6363+ if room == nil {
6464+ t.Fatal("expected non-nil room")
6565+ }
6666+}
6767+6868+func TestHub_GetOrCreateRoom_ReturnsSame(t *testing.T) {
6969+ hub := NewHub()
7070+ r1 := hub.GetOrCreateRoom("doc1")
7171+ r2 := hub.GetOrCreateRoom("doc1")
7272+ if r1 != r2 {
7373+ t.Error("expected same room instance for same rkey")
7474+ }
7575+}
7676+7777+func TestHub_GetOrCreateRoom_DifferentRooms(t *testing.T) {
7878+ hub := NewHub()
7979+ r1 := hub.GetOrCreateRoom("doc1")
8080+ r2 := hub.GetOrCreateRoom("doc2")
8181+ if r1 == r2 {
8282+ t.Error("expected different rooms for different rkeys")
8383+ }
8484+}
8585+8686+func TestHub_GetRoom_NilForUnknown(t *testing.T) {
8787+ hub := NewHub()
8888+ if hub.GetRoom("nonexistent") != nil {
8989+ t.Error("expected nil for unknown room")
9090+ }
9191+}
9292+9393+func TestHub_GetRoom_AfterCreate(t *testing.T) {
9494+ hub := NewHub()
9595+ hub.GetOrCreateRoom("doc1")
9696+ if hub.GetRoom("doc1") == nil {
9797+ t.Error("expected room to be retrievable after creation")
9898+ }
9999+}
100100+101101+// --- Room presence tests ---
102102+103103+func TestRoom_RegisterClient_AppearsInPresence(t *testing.T) {
104104+ hub := NewHub()
105105+ room := hub.GetOrCreateRoom("doc-presence")
106106+ c := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-presence")
107107+108108+ room.RegisterClient(c)
109109+110110+ // Wait for presence broadcast (register → broadcastPresence)
111111+ msgs := waitForMessages(c, 1, 2*time.Second)
112112+ if len(msgs) == 0 {
113113+ t.Fatal("expected presence message after register, got none")
114114+ }
115115+116116+ var pres PresenceMessage
117117+ if err := json.Unmarshal(msgs[0], &pres); err != nil {
118118+ t.Fatalf("unmarshal presence: %v", err)
119119+ }
120120+ if pres.Type != "presence" {
121121+ t.Errorf("expected type=presence, got %q", pres.Type)
122122+ }
123123+ if len(pres.Users) != 1 {
124124+ t.Fatalf("expected 1 user in presence, got %d", len(pres.Users))
125125+ }
126126+ if pres.Users[0].DID != "did:plc:alice" {
127127+ t.Errorf("expected DID did:plc:alice, got %q", pres.Users[0].DID)
128128+ }
129129+}
130130+131131+func TestRoom_MultipleClients_PresenceContainsAll(t *testing.T) {
132132+ hub := NewHub()
133133+ room := hub.GetOrCreateRoom("doc-multi")
134134+135135+ alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-multi")
136136+ bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-multi")
137137+138138+ room.RegisterClient(alice)
139139+ // Drain alice's initial presence (just herself)
140140+ waitForMessages(alice, 1, time.Second)
141141+142142+ room.RegisterClient(bob)
143143+ // Both get a new presence broadcast; wait for 1 more on each
144144+ aliceMsgs := waitForMessages(alice, 1, 2*time.Second)
145145+ bobMsgs := waitForMessages(bob, 1, 2*time.Second)
146146+147147+ checkPresenceCount := func(name string, msgs [][]byte, want int) {
148148+ t.Helper()
149149+ if len(msgs) == 0 {
150150+ t.Fatalf("%s: expected presence message, got none", name)
151151+ }
152152+ var pres PresenceMessage
153153+ if err := json.Unmarshal(msgs[len(msgs)-1], &pres); err != nil {
154154+ t.Fatalf("%s: unmarshal: %v", name, err)
155155+ }
156156+ if len(pres.Users) != want {
157157+ t.Errorf("%s: expected %d users in presence, got %d", name, want, len(pres.Users))
158158+ }
159159+ }
160160+161161+ checkPresenceCount("alice", aliceMsgs, 2)
162162+ checkPresenceCount("bob", bobMsgs, 2)
163163+}
164164+165165+func TestRoom_UnregisterClient_RemovedFromPresence(t *testing.T) {
166166+ hub := NewHub()
167167+ room := hub.GetOrCreateRoom("doc-unreg")
168168+169169+ alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-unreg")
170170+ bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-unreg")
171171+172172+ room.RegisterClient(alice)
173173+ room.RegisterClient(bob)
174174+ // Drain initial presence messages
175175+ time.Sleep(100 * time.Millisecond)
176176+ drain(alice, 200*time.Millisecond)
177177+ drain(bob, 200*time.Millisecond)
178178+179179+ // Now unregister alice
180180+ room.UnregisterClient(alice)
181181+182182+ // Bob should receive a presence update with 1 user
183183+ msgs := waitForMessages(bob, 1, 2*time.Second)
184184+ if len(msgs) == 0 {
185185+ t.Fatal("expected presence update after unregister, got none")
186186+ }
187187+ var pres PresenceMessage
188188+ if err := json.Unmarshal(msgs[len(msgs)-1], &pres); err != nil {
189189+ t.Fatalf("unmarshal: %v", err)
190190+ }
191191+ if len(pres.Users) != 1 {
192192+ t.Errorf("expected 1 user after alice unregisters, got %d", len(pres.Users))
193193+ }
194194+ if pres.Users[0].DID != "did:plc:bob" {
195195+ t.Errorf("expected bob to remain, got %q", pres.Users[0].DID)
196196+ }
197197+}
198198+199199+// --- Room broadcast tests ---
200200+201201+func TestRoom_Broadcast_SendsToAll(t *testing.T) {
202202+ hub := NewHub()
203203+ room := hub.GetOrCreateRoom("doc-broadcast")
204204+205205+ alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-broadcast")
206206+ bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-broadcast")
207207+208208+ room.RegisterClient(alice)
209209+ room.RegisterClient(bob)
210210+ // Drain presence messages
211211+ time.Sleep(100 * time.Millisecond)
212212+ drain(alice, 200*time.Millisecond)
213213+ drain(bob, 200*time.Millisecond)
214214+215215+ room.Broadcast([]byte(`{"type":"ping"}`))
216216+217217+ aliceMsgs := waitForMessages(alice, 1, time.Second)
218218+ bobMsgs := waitForMessages(bob, 1, time.Second)
219219+220220+ if len(aliceMsgs) == 0 {
221221+ t.Error("alice: expected broadcast message")
222222+ }
223223+ if len(bobMsgs) == 0 {
224224+ t.Error("bob: expected broadcast message")
225225+ }
226226+}
227227+228228+func TestRoom_BroadcastExcept_SkipsSender(t *testing.T) {
229229+ hub := NewHub()
230230+ room := hub.GetOrCreateRoom("doc-except")
231231+232232+ alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-except")
233233+ bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-except")
234234+235235+ room.RegisterClient(alice)
236236+ room.RegisterClient(bob)
237237+ time.Sleep(100 * time.Millisecond)
238238+ drain(alice, 200*time.Millisecond)
239239+ drain(bob, 200*time.Millisecond)
240240+241241+ room.BroadcastExcept([]byte(`{"type":"edit"}`), alice)
242242+243243+ // Bob should receive it; alice should not
244244+ bobMsgs := waitForMessages(bob, 1, time.Second)
245245+ aliceMsgs := drain(alice, 300*time.Millisecond)
246246+247247+ if len(bobMsgs) == 0 {
248248+ t.Error("bob: expected broadcast message")
249249+ }
250250+ if len(aliceMsgs) > 0 {
251251+ t.Errorf("alice: should not receive her own broadcast, got %d messages", len(aliceMsgs))
252252+ }
253253+}
254254+255255+// --- Room ApplyEdits tests ---
256256+257257+func TestRoom_ApplyEdit_BroadcastsToOthers(t *testing.T) {
258258+ hub := NewHub()
259259+ room := hub.GetOrCreateRoom("doc-edit")
260260+261261+ alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-edit")
262262+ bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-edit")
263263+264264+ room.RegisterClient(alice)
265265+ room.RegisterClient(bob)
266266+ time.Sleep(100 * time.Millisecond)
267267+ drain(alice, 200*time.Millisecond)
268268+ drain(bob, 200*time.Millisecond)
269269+270270+ room.ApplyEdits([]Operation{{From: 0, To: -1, Insert: "hello world"}}, alice)
271271+272272+ // Bob should receive the edit message; alice should not
273273+ bobMsgs := waitForMessages(bob, 1, time.Second)
274274+ aliceMsgs := drain(alice, 300*time.Millisecond)
275275+276276+ if len(bobMsgs) == 0 {
277277+ t.Fatal("bob: expected edit message, got none")
278278+ }
279279+ if len(aliceMsgs) > 0 {
280280+ t.Errorf("alice: should not receive her own edit, got %d messages", len(aliceMsgs))
281281+ }
282282+283283+ var msg map[string]interface{}
284284+ if err := json.Unmarshal(bobMsgs[0], &msg); err != nil {
285285+ t.Fatalf("unmarshal edit msg: %v", err)
286286+ }
287287+ if msg["type"] != "edit" {
288288+ t.Errorf("expected type=edit, got %q", msg["type"])
289289+ }
290290+ if msg["author"] != "did:plc:alice" {
291291+ t.Errorf("expected author did:plc:alice, got %q", msg["author"])
292292+ }
293293+ if msg["content"] != "hello world" {
294294+ t.Errorf("expected content %q, got %q", "hello world", msg["content"])
295295+ }
296296+}
297297+298298+func TestRoom_ApplyEdit_UpdatesOTState(t *testing.T) {
299299+ hub := NewHub()
300300+ room := hub.GetOrCreateRoom("doc-ot-state")
301301+302302+ alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-ot-state")
303303+ room.RegisterClient(alice)
304304+ time.Sleep(100 * time.Millisecond)
305305+ drain(alice, 200*time.Millisecond)
306306+307307+ room.ApplyEdits([]Operation{{From: 0, To: -1, Insert: "first"}}, alice)
308308+ room.ApplyEdits([]Operation{{From: 5, To: 5, Insert: " second"}}, alice)
309309+310310+ // Give room goroutine time to process
311311+ time.Sleep(100 * time.Millisecond)
312312+313313+ if got := room.ot.GetText(); got != "first second" {
314314+ t.Errorf("OT state: got %q, want %q", got, "first second")
315315+ }
316316+}
317317+318318+func TestRoom_ApplyEdits_MultipleOpsAppliedInOrder(t *testing.T) {
319319+ hub := NewHub()
320320+ room := hub.GetOrCreateRoom("doc-multi-ops")
321321+322322+ alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-multi-ops")
323323+ bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-multi-ops")
324324+ room.RegisterClient(alice)
325325+ room.RegisterClient(bob)
326326+ time.Sleep(100 * time.Millisecond)
327327+ drain(alice, 200*time.Millisecond)
328328+ drain(bob, 200*time.Millisecond)
329329+330330+ ops := []Operation{
331331+ {From: 0, To: -1, Insert: "hello"},
332332+ {From: 5, To: 5, Insert: " world"},
333333+ }
334334+ room.ApplyEdits(ops, alice)
335335+336336+ bobMsgs := waitForMessages(bob, 1, time.Second)
337337+ if len(bobMsgs) == 0 {
338338+ t.Fatal("bob: expected edit message")
339339+ }
340340+341341+ var msg struct {
342342+ Type string `json:"type"`
343343+ Deltas []Operation `json:"deltas"`
344344+ Content string `json:"content"`
345345+ Author string `json:"author"`
346346+ }
347347+ if err := json.Unmarshal(bobMsgs[0], &msg); err != nil {
348348+ t.Fatalf("unmarshal: %v", err)
349349+ }
350350+ if msg.Type != "edit" {
351351+ t.Errorf("type: got %q, want edit", msg.Type)
352352+ }
353353+ if msg.Content != "hello world" {
354354+ t.Errorf("content: got %q, want %q", msg.Content, "hello world")
355355+ }
356356+ if len(msg.Deltas) != 2 {
357357+ t.Errorf("deltas: got %d, want 2", len(msg.Deltas))
358358+ }
359359+ if msg.Author != "did:plc:alice" {
360360+ t.Errorf("author: got %q", msg.Author)
361361+ }
362362+}
363363+364364+func TestRoom_ApplyEdits_UpdatesOTState(t *testing.T) {
365365+ hub := NewHub()
366366+ room := hub.GetOrCreateRoom("doc-multi-ot")
367367+ alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-multi-ot")
368368+ room.RegisterClient(alice)
369369+ time.Sleep(100 * time.Millisecond)
370370+ drain(alice, 200*time.Millisecond)
371371+372372+ room.ApplyEdits([]Operation{
373373+ {From: 0, To: -1, Insert: "abc"},
374374+ {From: 3, To: 3, Insert: "def"},
375375+ }, alice)
376376+377377+ time.Sleep(50 * time.Millisecond)
378378+ if got := room.ot.GetText(); got != "abcdef" {
379379+ t.Errorf("OT state: got %q, want %q", got, "abcdef")
380380+ }
381381+}
382382+383383+func TestRoom_ApplyEdits_EmptyOpsIsNoop(t *testing.T) {
384384+ hub := NewHub()
385385+ room := hub.GetOrCreateRoom("doc-empty-ops")
386386+ alice := stubClient(hub, "did:plc:alice", "Alice", "#ff0000", "doc-empty-ops")
387387+ bob := stubClient(hub, "did:plc:bob", "Bob", "#0000ff", "doc-empty-ops")
388388+ room.RegisterClient(alice)
389389+ room.RegisterClient(bob)
390390+ time.Sleep(100 * time.Millisecond)
391391+ drain(alice, 200*time.Millisecond)
392392+ drain(bob, 200*time.Millisecond)
393393+394394+ room.ApplyEdits(nil, alice)
395395+396396+ bobMsgs := drain(bob, 300*time.Millisecond)
397397+ if len(bobMsgs) > 0 {
398398+ t.Errorf("expected no broadcast for empty ops, got %d messages", len(bobMsgs))
399399+ }
400400+}
401401+402402+// --- GetPresence ---
403403+404404+func TestRoom_GetPresence_Empty(t *testing.T) {
405405+ hub := NewHub()
406406+ room := hub.GetOrCreateRoom("doc-empty-presence")
407407+ users := room.GetPresence()
408408+ if len(users) != 0 {
409409+ t.Errorf("expected 0 users in new room, got %d", len(users))
410410+ }
411411+}