| 1 | package hub |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "io" |
| 8 | "log/slog" |
| 9 | "sync" |
| 10 | "testing" |
| 11 | "time" |
| 12 | ) |
| 13 | |
| 14 | // rosterMedia finds one peer's media in a roster, and whether the peer |
| 15 | // is in it at all. |
| 16 | func rosterMedia(ro *Roster, peer PeerID) (*MediaState, bool) { |
| 17 | if ro == nil { |
| 18 | return nil, false |
| 19 | } |
| 20 | for _, rp := range ro.Peers { |
| 21 | if rp.Peer == peer { |
| 22 | return rp.Media, true |
| 23 | } |
| 24 | } |
| 25 | return nil, false |
| 26 | } |
| 27 | |
| 28 | // TestSetMediaFansOutAChangeAndNothingElse is §15.19's delivery: every |
| 29 | // other admitted peer hears a change, in an envelope the hub built, and |
| 30 | // nobody else hears anything — not the sender, not a waiting guest, and |
| 31 | // nothing in any ring. A repeat of the same state is accepted and silent. |
| 32 | func TestSetMediaFansOutAChangeAndNothingElse(t *testing.T) { |
| 33 | now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC) |
| 34 | h := testHub(&now) |
| 35 | ctx := context.Background() |
| 36 | a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| 37 | h.Join(ctx, "r1", "sa", a.Peer) |
| 38 | b, _ := h.Open("r1", "sb", 2, "Bob", KindMember) |
| 39 | h.Join(ctx, "r1", "sb", b.Peer) |
| 40 | g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest) |
| 41 | h.Join(ctx, "r1", "sg", g.Peer) // waiting |
| 42 | drain(a.Sub) |
| 43 | drain(b.Sub) |
| 44 | drain(g.Sub) |
| 45 | |
| 46 | if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Muted: true}); err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | got := drain(b.Sub) |
| 50 | if len(got) != 1 { |
| 51 | t.Fatalf("Bob got %d events, want 1: %v", len(got), types(got)) |
| 52 | } |
| 53 | m := got[0] |
| 54 | if m.Type != TypeMedia || m.From != a.Peer || m.Name != "Ann" { |
| 55 | t.Fatalf("Bob got %+v, want media from Ann", m) |
| 56 | } |
| 57 | if string(m.Payload) != `{"muted":true,"cameraOff":false}` { |
| 58 | t.Fatalf("payload %s, want the canonical two booleans", m.Payload) |
| 59 | } |
| 60 | if m.To != "" || m.Seq != 0 || m.Gen != 0 || m.Roster != nil { |
| 61 | t.Fatalf("Bob got %+v, want nothing but type, from, name and payload", m) |
| 62 | } |
| 63 | if n := len(drain(a.Sub)); n != 0 { |
| 64 | t.Fatalf("the sender must not hear its own report, got %d", n) |
| 65 | } |
| 66 | if n := len(drain(g.Sub)); n != 0 { |
| 67 | t.Fatalf("a waiting guest must hear nothing, got %d", n) |
| 68 | } |
| 69 | if n := ringLen(h, "r1", "sb", b.Peer); n != 0 { |
| 70 | t.Fatalf("media is state, never ringed; ring has %d", n) |
| 71 | } |
| 72 | |
| 73 | // The same state again: accepted, and the room hears nothing. |
| 74 | if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Muted: true}); err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | if n := len(drain(b.Sub)); n != 0 { |
| 78 | t.Fatalf("a report that changes nothing must send nothing, got %d", n) |
| 79 | } |
| 80 | // A different state is a change again. |
| 81 | if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Muted: true, CameraOff: true}); err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | got = drain(b.Sub) |
| 85 | if len(got) != 1 || string(got[0].Payload) != `{"muted":true,"cameraOff":true}` { |
| 86 | t.Fatalf("camera off should be one more media event, got %v", got) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | // TestRostersCarryMediaAndUnknownIsAbsent is the "state, not event" |
| 91 | // half: a peer admitted after the report learns it from its roster, the |
| 92 | // room learns a newcomer's from the peer event once it has one, and a |
| 93 | // peer that has never reported — every client older than §15.19 — has |
| 94 | // no media at all rather than a guessed false. |
| 95 | func TestRostersCarryMediaAndUnknownIsAbsent(t *testing.T) { |
| 96 | now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC) |
| 97 | h := testHub(&now) |
| 98 | ctx := context.Background() |
| 99 | a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| 100 | h.Join(ctx, "r1", "sa", a.Peer) |
| 101 | b, _ := h.Open("r1", "sb", 2, "Bob", KindMember) |
| 102 | h.Join(ctx, "r1", "sb", b.Peer) // never reports: an old client |
| 103 | if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Muted: true}); err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | drain(a.Sub) |
| 107 | drain(b.Sub) |
| 108 | |
| 109 | c, _ := h.Open("r1", "sc", 3, "Cy", KindMember) |
| 110 | h.Join(ctx, "r1", "sc", c.Peer) |
| 111 | cev := drain(c.Sub) |
| 112 | var ro *Roster |
| 113 | for _, m := range cev { |
| 114 | if m.Type == TypeRoster { |
| 115 | ro = m.Roster |
| 116 | } |
| 117 | } |
| 118 | am, ok := rosterMedia(ro, a.Peer) |
| 119 | if !ok || am == nil || !am.Muted || am.CameraOff { |
| 120 | t.Fatalf("Cy's roster should say Ann is muted, got %+v (present %v)", am, ok) |
| 121 | } |
| 122 | bm, ok := rosterMedia(ro, b.Peer) |
| 123 | if !ok || bm != nil { |
| 124 | t.Fatalf("Bob never reported, so his roster entry must carry no media, got %+v (present %v)", bm, ok) |
| 125 | } |
| 126 | // And on the wire, absent rather than a zero object: that is what |
| 127 | // an old client ignores and a new one reads as unknown. |
| 128 | raw, _ := json.Marshal(RosterPeer{Peer: b.Peer, Name: "Bob", Kind: "member"}) |
| 129 | if string(raw) != `{"peer":"`+string(b.Peer)+`","name":"Bob","kind":"member"}` { |
| 130 | t.Fatalf("an unreported peer marshals as %s, want no media key", raw) |
| 131 | } |
| 132 | |
| 133 | // The newcomer's own first report reaches everyone already there, |
| 134 | // the old client included: it ignores the type, and it is not the |
| 135 | // hub's business to know that. |
| 136 | drain(a.Sub) |
| 137 | drain(b.Sub) |
| 138 | if err := h.SetMedia(ctx, "r1", "sc", c.Peer, MediaState{CameraOff: true}); err != nil { |
| 139 | t.Fatal(err) |
| 140 | } |
| 141 | for _, s := range []*Sub{a.Sub, b.Sub} { |
| 142 | ev := drain(s) |
| 143 | if len(ev) != 1 || ev[0].Type != TypeMedia || ev[0].From != c.Peer { |
| 144 | t.Fatalf("Cy's report should reach both others once, got %v", types(ev)) |
| 145 | } |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | // TestPeerEventCarriesMediaFromTheParticipation: the `peer` event is |
| 150 | // built from the participation, like the roster, so whatever media the |
| 151 | // participation holds at admission goes with it. Today a report needs |
| 152 | // admission first, so this is nil in practice — the test pins the |
| 153 | // contract so that loosening that rule later cannot forget the event. |
| 154 | func TestPeerEventCarriesMediaFromTheParticipation(t *testing.T) { |
| 155 | now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC) |
| 156 | h := testHub(&now) |
| 157 | ctx := context.Background() |
| 158 | a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| 159 | h.Join(ctx, "r1", "sa", a.Peer) |
| 160 | drain(a.Sub) |
| 161 | b, _ := h.Open("r1", "sb", 2, "Bob", KindMember) |
| 162 | // Bob's media is set directly under the lock, as if he had reported |
| 163 | // before this admission; the hub's own paths only allow a report once |
| 164 | // admitted, so this is the peer event's contract, not a flow. |
| 165 | h.mu.Lock() |
| 166 | p, _ := h.findLocked("r1", "sb", b.Peer) |
| 167 | p.Media = &MediaState{Muted: true} |
| 168 | h.mu.Unlock() |
| 169 | h.Join(ctx, "r1", "sb", b.Peer) |
| 170 | ev := drain(a.Sub) |
| 171 | if len(ev) != 1 || ev[0].Type != TypePeer { |
| 172 | t.Fatalf("Ann should get one peer event, got %v", types(ev)) |
| 173 | } |
| 174 | bm, ok := rosterMedia(ev[0].Roster, b.Peer) |
| 175 | if !ok || bm == nil || !bm.Muted { |
| 176 | t.Fatalf("the peer event should carry Bob's media, got %+v", bm) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | // TestResumeRosterCarriesMediaMissedWhileDetached: a change made while a |
| 181 | // peer's stream is down is not ringed and not replayed, and it does not |
| 182 | // need to be — the roster the resume is answered with says it. |
| 183 | func TestResumeRosterCarriesMediaMissedWhileDetached(t *testing.T) { |
| 184 | now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC) |
| 185 | h := testHub(&now) |
| 186 | ctx := context.Background() |
| 187 | a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| 188 | h.Join(ctx, "r1", "sa", a.Peer) |
| 189 | b, _ := h.Open("r1", "sb", 2, "Bob", KindMember) |
| 190 | h.Join(ctx, "r1", "sb", b.Peer) |
| 191 | if err := h.SetMedia(ctx, "r1", "sb", b.Peer, MediaState{Muted: true}); err != nil { |
| 192 | t.Fatal(err) |
| 193 | } |
| 194 | drain(a.Sub) |
| 195 | drain(b.Sub) |
| 196 | |
| 197 | h.Detach("r1", b.Peer, b.Sub) |
| 198 | if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Muted: true}); err != nil { |
| 199 | t.Fatal(err) |
| 200 | } |
| 201 | if n := ringLen(h, "r1", "sb", b.Peer); n != 0 { |
| 202 | t.Fatalf("a media report to a detached peer must not be ringed, ring has %d", n) |
| 203 | } |
| 204 | res, err := h.Resume(ctx, "r1", "sb", b.Peer, map[PeerID]uint64{}) |
| 205 | if err != nil { |
| 206 | t.Fatal(err) |
| 207 | } |
| 208 | if len(res.Replay) != 0 || len(res.Gap) != 0 { |
| 209 | t.Fatalf("nothing to replay and no gap, got %d replayed and %d gaps", len(res.Replay), len(res.Gap)) |
| 210 | } |
| 211 | am, _ := rosterMedia(res.Roster, a.Peer) |
| 212 | if am == nil || !am.Muted { |
| 213 | t.Fatalf("the resume roster should say Ann is muted, got %+v", am) |
| 214 | } |
| 215 | // Bob's own state survived his detach: the participation holds it. |
| 216 | bm, _ := rosterMedia(res.Roster, b.Peer) |
| 217 | if bm == nil || !bm.Muted { |
| 218 | t.Fatalf("Bob's own report should survive his resume, got %+v", bm) |
| 219 | } |
| 220 | |
| 221 | // A leave ends the participation, and with it the state: a fresh |
| 222 | // join starts unknown. |
| 223 | if err := h.Leave("r1", "sb", b.Peer); err != nil { |
| 224 | t.Fatal(err) |
| 225 | } |
| 226 | b2, _ := h.Open("r1", "sb", 2, "Bob", KindMember) |
| 227 | h.Join(ctx, "r1", "sb", b2.Peer) |
| 228 | ev := drain(a.Sub) |
| 229 | var fresh *Roster |
| 230 | for _, m := range ev { |
| 231 | if m.Type == TypePeer && m.Peer == b2.Peer { |
| 232 | fresh = m.Roster |
| 233 | } |
| 234 | } |
| 235 | if bm, ok := rosterMedia(fresh, b2.Peer); !ok || bm != nil { |
| 236 | t.Fatalf("a rejoin must start with no media, got %+v (present %v)", bm, ok) |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | // TestSetMediaAuthority is Fanout's authority, reused: bound to this |
| 241 | // room and session, admitted, and a member's standing re-derived. |
| 242 | func TestSetMediaAuthority(t *testing.T) { |
| 243 | now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC) |
| 244 | standing := map[int64]bool{1: true, 2: true, 100: true} |
| 245 | h := New(Config{ |
| 246 | Now: func() time.Time { return now }, |
| 247 | Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), |
| 248 | StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) { |
| 249 | if id >= 100 { |
| 250 | return KindGuest, standing[id], nil |
| 251 | } |
| 252 | return KindMember, standing[id], nil |
| 253 | }, |
| 254 | }) |
| 255 | ctx := context.Background() |
| 256 | m := MediaState{Muted: true} |
| 257 | a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| 258 | if err := h.SetMedia(ctx, "r1", "sa", a.Peer, m); !errors.Is(err, ErrNotAdmitted) { |
| 259 | t.Fatalf("unjoined: %v", err) |
| 260 | } |
| 261 | h.Join(ctx, "r1", "sa", a.Peer) |
| 262 | if err := h.SetMedia(ctx, "r1", "sb", a.Peer, m); !errors.Is(err, ErrUnknownPeer) { |
| 263 | t.Fatalf("another session: %v", err) |
| 264 | } |
| 265 | if err := h.SetMedia(ctx, "r2", "sa", a.Peer, m); !errors.Is(err, ErrUnknownPeer) { |
| 266 | t.Fatalf("another room: %v", err) |
| 267 | } |
| 268 | g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest) |
| 269 | h.Join(ctx, "r1", "sg", g.Peer) |
| 270 | if err := h.SetMedia(ctx, "r1", "sg", g.Peer, m); !errors.Is(err, ErrNotAdmitted) { |
| 271 | t.Fatalf("waiting guest: %v", err) |
| 272 | } |
| 273 | b, _ := h.Open("r1", "sb", 2, "Bob", KindMember) |
| 274 | h.Join(ctx, "r1", "sb", b.Peer) |
| 275 | drain(a.Sub) |
| 276 | drain(b.Sub) |
| 277 | |
| 278 | standing[1] = false |
| 279 | if err := h.SetMedia(ctx, "r1", "sa", a.Peer, m); !errors.Is(err, ErrNotYours) { |
| 280 | t.Fatalf("member whose standing is gone: %v", err) |
| 281 | } |
| 282 | ev := drain(b.Sub) |
| 283 | if len(ev) != 1 || ev[0].Type != TypeGone { |
| 284 | t.Fatalf("Bob should see Ann gone and no media, got %v", types(ev)) |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | // TestSetMediaUnderConcurrency is for `make race`: reports, resumes, |
| 289 | // rosters and a tick all at once, over the same participations. Media |
| 290 | // is a pointer handed to rosters that are serialised on other |
| 291 | // goroutines, so a write through it anywhere would show up here. |
| 292 | func TestSetMediaUnderConcurrency(t *testing.T) { |
| 293 | now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC) |
| 294 | var clock sync.Mutex |
| 295 | h := New(Config{ |
| 296 | Now: func() time.Time { |
| 297 | clock.Lock() |
| 298 | defer clock.Unlock() |
| 299 | return now |
| 300 | }, |
| 301 | Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), |
| 302 | StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) { |
| 303 | return KindMember, true, nil |
| 304 | }, |
| 305 | }) |
| 306 | ctx := context.Background() |
| 307 | a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| 308 | h.Join(ctx, "r1", "sa", a.Peer) |
| 309 | b, _ := h.Open("r1", "sb", 2, "Bob", KindMember) |
| 310 | h.Join(ctx, "r1", "sb", b.Peer) |
| 311 | |
| 312 | var wg sync.WaitGroup |
| 313 | stop := make(chan struct{}) |
| 314 | // Readers: marshal whatever arrives, the way the stream handler does. |
| 315 | for _, s := range []*Sub{a.Sub, b.Sub} { |
| 316 | wg.Add(1) |
| 317 | go func(s *Sub) { |
| 318 | defer wg.Done() |
| 319 | for { |
| 320 | select { |
| 321 | case <-stop: |
| 322 | return |
| 323 | case m, ok := <-s.C(): |
| 324 | if !ok { |
| 325 | return |
| 326 | } |
| 327 | _, _ = json.Marshal(m) |
| 328 | } |
| 329 | } |
| 330 | }(s) |
| 331 | } |
| 332 | var writers sync.WaitGroup |
| 333 | for i, who := range []struct { |
| 334 | session string |
| 335 | peer PeerID |
| 336 | }{{"sa", a.Peer}, {"sb", b.Peer}} { |
| 337 | writers.Add(1) |
| 338 | go func(i int, session string, peer PeerID) { |
| 339 | defer writers.Done() |
| 340 | for n := 0; n < 200; n++ { |
| 341 | _ = h.SetMedia(ctx, "r1", session, peer, MediaState{Muted: n%2 == 0, CameraOff: (n+i)%3 == 0}) |
| 342 | } |
| 343 | }(i, who.session, who.peer) |
| 344 | } |
| 345 | writers.Add(1) |
| 346 | go func() { |
| 347 | defer writers.Done() |
| 348 | for n := 0; n < 50; n++ { |
| 349 | h.Tick(ctx) |
| 350 | c, err := h.Open("r1", "sc", 3, "Cy", KindMember) |
| 351 | if err == nil { |
| 352 | _ = h.Join(ctx, "r1", "sc", c.Peer) |
| 353 | _, _ = json.Marshal(drain(c.Sub)) |
| 354 | _ = h.Leave("r1", "sc", c.Peer) |
| 355 | } |
| 356 | } |
| 357 | }() |
| 358 | writers.Wait() |
| 359 | close(stop) |
| 360 | wg.Wait() |
| 361 | } |
| 362 | |
| 363 | // TestSetMediaCarriesTheReceivingHalf is §15.20 in the hub: the report's |
| 364 | // optional members are part of the state — a change to either is fanned |
| 365 | // out, the same list again is silent, and the hub keeps its own copy of |
| 366 | // the list so a caller that reuses its slice cannot rewrite a roster |
| 367 | // already sent. |
| 368 | func TestSetMediaCarriesTheReceivingHalf(t *testing.T) { |
| 369 | now := time.Date(2026, 9, 25, 9, 0, 0, 0, time.UTC) |
| 370 | h := testHub(&now) |
| 371 | ctx := context.Background() |
| 372 | a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| 373 | h.Join(ctx, "r1", "sa", a.Peer) |
| 374 | b, _ := h.Open("r1", "sb", 2, "Bob", KindMember) |
| 375 | h.Join(ctx, "r1", "sb", b.Peer) |
| 376 | drain(a.Sub) |
| 377 | drain(b.Sub) |
| 378 | |
| 379 | list := []PeerID{b.Peer} |
| 380 | if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{AudioBlocked: true, NoSoundFrom: list}); err != nil { |
| 381 | t.Fatal(err) |
| 382 | } |
| 383 | got := drain(b.Sub) |
| 384 | if len(got) != 1 { |
| 385 | t.Fatalf("Bob got %d events, want 1", len(got)) |
| 386 | } |
| 387 | want := `{"muted":false,"cameraOff":false,"audioBlocked":true,"noSoundFrom":["` + string(b.Peer) + `"]}` |
| 388 | if string(got[0].Payload) != want { |
| 389 | t.Fatalf("payload %s, want %s", got[0].Payload, want) |
| 390 | } |
| 391 | list[0] = "rewritten" |
| 392 | c, _ := h.Open("r1", "sc", 3, "Cy", KindMember) |
| 393 | h.Join(ctx, "r1", "sc", c.Peer) |
| 394 | var ro *Roster |
| 395 | for _, ev := range drain(c.Sub) { |
| 396 | if ev.Type == TypeRoster { |
| 397 | ro = ev.Roster |
| 398 | } |
| 399 | } |
| 400 | drain(b.Sub) |
| 401 | m, _ := rosterMedia(ro, a.Peer) |
| 402 | if m == nil || len(m.NoSoundFrom) != 1 || m.NoSoundFrom[0] != b.Peer { |
| 403 | t.Fatalf("roster media %+v, want the hub's own copy of the list", m) |
| 404 | } |
| 405 | |
| 406 | if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{AudioBlocked: true, NoSoundFrom: []PeerID{b.Peer}}); err != nil { |
| 407 | t.Fatal(err) |
| 408 | } |
| 409 | if got := drain(b.Sub); len(got) != 0 { |
| 410 | t.Fatalf("the same report again reached Bob: %v", types(got)) |
| 411 | } |
| 412 | if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{}); err != nil { |
| 413 | t.Fatal(err) |
| 414 | } |
| 415 | got = drain(b.Sub) |
| 416 | if len(got) != 1 || string(got[0].Payload) != `{"muted":false,"cameraOff":false}` { |
| 417 | t.Fatalf("clearing the bad news sent %v", got) |
| 418 | } |
| 419 | } |
| 420 | |
| 421 | // §15.24: hearing is kept with its emptiness — [] is "nobody yet", nil is |
| 422 | // a client that does not say — carried after the other lists, copied so |
| 423 | // nothing the caller holds is shared with a roster, and a change between |
| 424 | // nil and [] is a change. |
| 425 | func TestSetMediaCarriesHearing(t *testing.T) { |
| 426 | now := time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC) |
| 427 | h := testHub(&now) |
| 428 | ctx := context.Background() |
| 429 | a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| 430 | h.Join(ctx, "r1", "sa", a.Peer) |
| 431 | b, _ := h.Open("r1", "sb", 2, "Bob", KindMember) |
| 432 | h.Join(ctx, "r1", "sb", b.Peer) |
| 433 | drain(a.Sub) |
| 434 | drain(b.Sub) |
| 435 | |
| 436 | if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Hearing: []PeerID{}}); err != nil { |
| 437 | t.Fatal(err) |
| 438 | } |
| 439 | got := drain(b.Sub) |
| 440 | if len(got) != 1 || string(got[0].Payload) != `{"muted":false,"cameraOff":false,"hearing":[]}` { |
| 441 | t.Fatalf("empty hearing sent %v", got) |
| 442 | } |
| 443 | list := []PeerID{b.Peer} |
| 444 | if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{ScreenStalled: list, Hearing: list}); err != nil { |
| 445 | t.Fatal(err) |
| 446 | } |
| 447 | got = drain(b.Sub) |
| 448 | want := `{"muted":false,"cameraOff":false,"screenStalled":["` + string(b.Peer) + `"],"hearing":["` + string(b.Peer) + `"]}` |
| 449 | if len(got) != 1 || string(got[0].Payload) != want { |
| 450 | t.Fatalf("payload %v, want %s", got, want) |
| 451 | } |
| 452 | list[0] = "rewritten" |
| 453 | c, _ := h.Open("r1", "sc", 3, "Cy", KindMember) |
| 454 | h.Join(ctx, "r1", "sc", c.Peer) |
| 455 | var ro *Roster |
| 456 | for _, ev := range drain(c.Sub) { |
| 457 | if ev.Type == TypeRoster { |
| 458 | ro = ev.Roster |
| 459 | } |
| 460 | } |
| 461 | drain(b.Sub) |
| 462 | m, _ := rosterMedia(ro, a.Peer) |
| 463 | if m == nil || len(m.Hearing) != 1 || m.Hearing[0] != b.Peer { |
| 464 | t.Fatalf("roster media %+v, want the hub's own copy of the list", m) |
| 465 | } |
| 466 | // Back to []: a change. Then nil: a change again, and the two-key report. |
| 467 | if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{Hearing: []PeerID{}}); err != nil { |
| 468 | t.Fatal(err) |
| 469 | } |
| 470 | if got := drain(b.Sub); len(got) != 1 { |
| 471 | t.Fatalf("back to empty sent %d", len(got)) |
| 472 | } |
| 473 | if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{}); err != nil { |
| 474 | t.Fatal(err) |
| 475 | } |
| 476 | got = drain(b.Sub) |
| 477 | if len(got) != 1 || string(got[0].Payload) != `{"muted":false,"cameraOff":false}` { |
| 478 | t.Fatalf("nil after [] sent %v", got) |
| 479 | } |
| 480 | if err := h.SetMedia(ctx, "r1", "sa", a.Peer, MediaState{}); err != nil { |
| 481 | t.Fatal(err) |
| 482 | } |
| 483 | if got := drain(b.Sub); len(got) != 0 { |
| 484 | t.Fatalf("the same report again reached Bob: %v", types(got)) |
| 485 | } |
| 486 | var round MediaState |
| 487 | if err := json.Unmarshal([]byte(`{"muted":false,"cameraOff":false,"hearing":[]}`), &round); err != nil || round.Hearing == nil { |
| 488 | t.Fatalf("[] must decode as an empty, non-nil list: %+v %v", round, err) |
| 489 | } |
| 490 | } |
| 491 | |