handle([ 'type' => 'offer', 'peer' => 'p1', 'sdp' => 'OFFER-SDP', ]); $this->assertSame(['type' => 'answer', 'peer' => 'p1', 'sdp' => 'answer-for-p1'], $result); $this->assertSame(['offer', 'p1', 'OFFER-SDP'], $engine->calls[0]); } public function test_ice_is_forwarded_and_needs_no_reply(): void { $engine = new FakeMediaEngine; $result = (new SignalingHandler($engine))->handle([ 'type' => 'ice', 'peer' => 'p1', 'candidate' => ['candidate' => 'a=x'], ]); $this->assertNull($result); $this->assertSame(['ice', 'p1', ['candidate' => 'a=x']], $engine->calls[0]); } public function test_record_connect_bridge_close_are_acked_and_routed(): void { $engine = new FakeMediaEngine; $handler = new SignalingHandler($engine); $this->assertSame(['type' => 'ack', 'peer' => 'p1'], $handler->handle(['type' => 'record', 'peer' => 'p1', 'path' => '/tmp/a.opus'])); $this->assertSame(['type' => 'ack', 'peer' => 'p1'], $handler->handle(['type' => 'connect', 'peer' => 'p1', 'other' => 'p2'])); $this->assertSame(['type' => 'ack', 'peer' => 'p1'], $handler->handle(['type' => 'bridge', 'peer' => 'p1', 'options' => ['model' => 'gpt-realtime']])); $this->assertSame(['type' => 'ack', 'peer' => 'p1'], $handler->handle(['type' => 'close', 'peer' => 'p1'])); $this->assertSame(['record', 'p1', '/tmp/a.opus'], $engine->calls[0]); $this->assertSame(['connect', 'p1', 'p2'], $engine->calls[1]); $this->assertSame(['bridge', 'p1', ['model' => 'gpt-realtime']], $engine->calls[2]); $this->assertSame(['close', 'p1'], $engine->calls[3]); } public function test_a_missing_peer_is_an_error(): void { $result = (new SignalingHandler(new FakeMediaEngine))->handle(['type' => 'offer']); $this->assertSame('error', $result['type']); } public function test_an_unknown_type_is_an_error(): void { $result = (new SignalingHandler(new FakeMediaEngine))->handle(['type' => 'wat', 'peer' => 'p1']); $this->assertSame('error', $result['type']); $this->assertStringContainsString('wat', $result['error']); } public function test_an_engine_exception_is_caught_as_an_error_reply(): void { $engine = new FakeMediaEngine; $engine->throwOnOffer = new \RuntimeException('boom'); $result = (new SignalingHandler($engine))->handle(['type' => 'offer', 'peer' => 'p1', 'sdp' => 'x']); $this->assertSame(['type' => 'error', 'peer' => 'p1', 'error' => 'boom'], $result); } public function test_join_meshes_a_new_peer_with_those_already_present(): void { $engine = new FakeMediaEngine; $handler = new SignalingHandler($engine); $this->assertSame( ['type' => 'joined', 'peer' => 'p1', 'room' => 'lobby', 'peers' => []], $handler->handle(['type' => 'join', 'peer' => 'p1', 'room' => 'lobby']), ); $this->assertSame( ['type' => 'joined', 'peer' => 'p2', 'room' => 'lobby', 'peers' => ['p1']], $handler->handle(['type' => 'join', 'peer' => 'p2', 'room' => 'lobby']), ); $this->assertContains(['connect', 'p2', 'p1'], $engine->calls); } public function test_join_without_a_room_is_an_error(): void { $result = (new SignalingHandler(new FakeMediaEngine))->handle(['type' => 'join', 'peer' => 'p1']); $this->assertSame('error', $result['type']); } public function test_leave_reports_the_remaining_peers(): void { $handler = new SignalingHandler(new FakeMediaEngine); $handler->handle(['type' => 'join', 'peer' => 'p1', 'room' => 'lobby']); $handler->handle(['type' => 'join', 'peer' => 'p2', 'room' => 'lobby']); $this->assertSame( ['type' => 'left', 'peer' => 'p2', 'peers' => ['p1']], $handler->handle(['type' => 'leave', 'peer' => 'p2']), ); } public function test_close_also_removes_the_peer_from_its_room(): void { $engine = new FakeMediaEngine; $handler = new SignalingHandler($engine); $handler->handle(['type' => 'join', 'peer' => 'p1', 'room' => 'lobby']); $this->assertSame(['type' => 'ack', 'peer' => 'p1'], $handler->handle(['type' => 'close', 'peer' => 'p1'])); $this->assertNull($handler->rooms()->roomOf('p1')); $this->assertContains(['close', 'p1'], $engine->calls); } }