diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index 1a0c4d6..2d26ab6 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -1040,7 +1040,8 @@ class MySeaViewTest(TestCase): def test_gear_nvm_navs_to_gameboard_on_landing_phase(self): """Landing-phase NVM = "back out of my-sea entirely" → /gameboard/.""" response = self.client.get(reverse("my_sea")) - self.assertIn("location.href='/gameboard/'", response.content.decode()) + self.assertIn( + "mySeaGuardedNav(event, '/gameboard/')", response.content.decode()) def test_sea_btn_is_inactive_on_landing_phase(self): """Sea sub-btn flips ACTIVE only when the user is in the picker @@ -1120,7 +1121,7 @@ class MySeaViewTest(TestCase): ) response = self.client.get(reverse("my_sea")) self.assertIn( - "location.href='/gameboard/my-sea/?phase=landing'", + "mySeaGuardedNav(event, '/gameboard/my-sea/?phase=landing')", response.content.decode(), ) @@ -2473,8 +2474,10 @@ class MySeaGateViewTest(TestCase): 2026-05-26 so the gatekeeper exit lands one step back.""" response = self.client.get(reverse("my_sea_gate")) body = response.content.decode() - self.assertIn("location.href='/gameboard/my-sea/'", body) - self.assertNotIn("location.href='/gameboard/'", body) + # NVM routes through mySeaGuardedNav (voice-disconnect guard, 2026-05-29) + # but still carries the same target URL. + self.assertIn("mySeaGuardedNav(event, '/gameboard/my-sea/')", body) + self.assertNotIn("mySeaGuardedNav(event, '/gameboard/')", body) def test_gate_view_renders_burger_btn_and_fan(self): response = self.client.get(reverse("my_sea_gate")) diff --git a/src/apps/voice/static/apps/voice/voice-glow.js b/src/apps/voice/static/apps/voice/voice-glow.js index 7e404c9..d02df40 100644 --- a/src/apps/voice/static/apps/voice/voice-glow.js +++ b/src/apps/voice/static/apps/voice/voice-glow.js @@ -75,6 +75,12 @@ st = st || {}; inCall = !!st.inCall; peerCount = st.peerCount || 0; + // Keep the voice btn's own flags in sync with the mesh truth so a + // rejoin starts clean (dataset.inCall gates join-vs-mute in + // burger-btn.js) and the render fallback can't get stuck "live". + voice.classList.toggle('in-call', inCall); + voice.classList.toggle('muted', inCall && !!st.muted); + if (!inCall) { delete voice.dataset.inCall; } render(); } diff --git a/src/apps/voice/static/apps/voice/voice-mesh.js b/src/apps/voice/static/apps/voice/voice-mesh.js index 588ef5f..3c0898d 100644 --- a/src/apps/voice/static/apps/voice/voice-mesh.js +++ b/src/apps/voice/static/apps/voice/voice-mesh.js @@ -180,6 +180,7 @@ }); }).then(function (stream) { self.localStream = stream; + self._applyMute(); // honor a mute toggled during the permission prompt var scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'; self.ws = new WebSocket(scheme + '://' + window.location.host + '/ws/voice/' + roomId + '/'); self.ws.onmessage = function (e) { self._onMessage(JSON.parse(e.data)); }; @@ -189,13 +190,23 @@ }); }; + // Push the current `muted` flag onto the live audio tracks. Extracted so + // BOTH toggleMute AND join (post-getUserMedia) call it: on desktop the + // first join pops a mic-permission prompt, and a mute toggled while that + // prompt is open used to be lost because the stream didn't exist yet — + // the fresh tracks then came up enabled regardless. Re-applying after the + // stream resolves makes the mute stick (Bug B, 2026-05-29). + VoiceRoom.prototype._applyMute = function () { + if (!this.localStream) return; + var enabled = !this.muted; + this.localStream.getAudioTracks().forEach(function (t) { + t.enabled = enabled; + }); + }; + VoiceRoom.prototype.toggleMute = function () { this.muted = !this.muted; - if (this.localStream) { - this.localStream.getAudioTracks().forEach(function (t) { - t.enabled = !this.muted; - }, this); - } + this._applyMute(); this._notify(); return this.muted; }; @@ -206,7 +217,8 @@ this.localStream.getTracks().forEach(function (t) { t.stop(); }); this.localStream = null; } - this._notify(); // call ended — back to not-in-call + this.muted = false; // a rejoin starts unmuted + this._notify(); // call ended — back to not-in-call }; VoiceRoom.prototype.leave = function () { diff --git a/src/functional_tests/test_game_my_sea.py b/src/functional_tests/test_game_my_sea.py index 3a8929a..d8d259f 100644 --- a/src/functional_tests/test_game_my_sea.py +++ b/src/functional_tests/test_game_my_sea.py @@ -2143,3 +2143,56 @@ class MySeaVisitSpectatorCrossTest(FunctionalTest): self.assertTrue(self.browser.find_elements(By.ID, "id_sea_stage")) self.assertFalse(self.browser.find_elements( By.CSS_SELECTOR, ".my-sea-scroll")) + + +class MySeaVisitVoiceGuardTest(FunctionalTest): + """Phase 4 (2026-05-29) — when voice is LIVE, the gear-menu NVM that leaves + my_sea warns via the shared guard portal before disconnecting; with no live + mic it navigates straight through. (Mesh stubbed — no real WebRTC.)""" + + def setUp(self): + super().setUp() + _seed_earthman_sig_pile() + _seed_gameboard_applets() + self.owner = User.objects.create(email="owner@test.io", username="owner") + _assign_sig(self.owner) + self.visitor_email = "viz@test.io" + self.visitor = User.objects.create( + email=self.visitor_email, username="viz") + from apps.gameboard.models import SeaInvite + SeaInvite.objects.create( + owner=self.owner, invitee=self.visitor, + invitee_email=self.visitor_email, + status=SeaInvite.ACCEPTED, accepted_at=timezone.now(), + token_deposited_at=timezone.now(), + voice_until=timezone.now() + timedelta(hours=24), + ) + + def _open_nvm(self): + gear = self.wait_for(lambda: self.browser.find_element( + By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_my_sea_menu']")) + self.browser.execute_script("arguments[0].click()", gear) + return self.wait_for(lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_my_sea_menu .btn-cancel")) + + def test_nvm_warns_before_disconnecting_when_voice_live(self): + self.create_pre_authenticated_session(self.visitor_email) + self.browser.get( + self.live_server_url + f"/gameboard/my-sea/visit/{self.owner.id}/") + self.browser.execute_script("window.VoiceRoom = {localStream: {}};") + self._open_nvm().click() + portal = self.wait_for(lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_guard_portal.active")) + self.assertIn( + "disconnect from voice", + portal.find_element(By.CSS_SELECTOR, ".guard-message").text.lower()) + self.assertIn(f"/my-sea/visit/{self.owner.id}/", self.browser.current_url) + + def test_nvm_navigates_directly_when_no_live_voice(self): + self.create_pre_authenticated_session(self.visitor_email) + self.browser.get( + self.live_server_url + f"/gameboard/my-sea/visit/{self.owner.id}/") + self.browser.execute_script("window.VoiceRoom = {localStream: null};") + self._open_nvm().click() + self.wait_for(lambda: self.assertNotIn( + "/my-sea/visit/", self.browser.current_url)) diff --git a/src/static/tests/VoiceMeshSpec.js b/src/static/tests/VoiceMeshSpec.js index de8a3c2..c69b111 100644 --- a/src/static/tests/VoiceMeshSpec.js +++ b/src/static/tests/VoiceMeshSpec.js @@ -40,3 +40,56 @@ describe('voice-mesh tuneOpus', function () { expect(window.tuneOpus('')).toBe(''); }); }); + +// Mute path — the one deterministic slice of the mesh we can unit-test. The +// _applyMute extraction fixes Bug B (2026-05-29): a mute toggled while the +// desktop getUserMedia permission prompt is open must stick once the stream +// resolves (join re-applies it post-stream). +describe('voice-mesh mute', function () { + var vr, trackA, trackB; + + beforeEach(function () { + vr = window.VoiceRoom; + trackA = { enabled: true }; + trackB = { enabled: true }; + vr.localStream = { getAudioTracks: function () { return [trackA, trackB]; } }; + vr.muted = false; + vr._onState = null; + }); + + afterEach(function () { + vr.localStream = null; + vr.muted = false; + vr._onState = null; + }); + + it('toggleMute disables the audio tracks + returns muted', function () { + expect(vr.toggleMute()).toBe(true); + expect(trackA.enabled).toBe(false); + expect(trackB.enabled).toBe(false); + }); + + it('toggleMute twice re-enables the tracks', function () { + vr.toggleMute(); + expect(vr.toggleMute()).toBe(false); + expect(trackA.enabled).toBe(true); + expect(trackB.enabled).toBe(true); + }); + + it('_applyMute honors a mute set before the stream existed (Bug B)', function () { + // muted toggled while the permission prompt was open (no stream yet)… + vr.localStream = null; + vr.muted = true; + // …then the stream arrives and join calls _applyMute. + vr.localStream = { getAudioTracks: function () { return [trackA]; } }; + vr._applyMute(); + expect(trackA.enabled).toBe(false); + }); + + it('notifies subscribers with the muted flag on toggle', function () { + var seen = null; + vr.setOnStateChange(function (st) { seen = st; }); + vr.toggleMute(); + expect(seen.muted).toBe(true); + }); +}); diff --git a/src/static_src/tests/VoiceMeshSpec.js b/src/static_src/tests/VoiceMeshSpec.js index de8a3c2..c69b111 100644 --- a/src/static_src/tests/VoiceMeshSpec.js +++ b/src/static_src/tests/VoiceMeshSpec.js @@ -40,3 +40,56 @@ describe('voice-mesh tuneOpus', function () { expect(window.tuneOpus('')).toBe(''); }); }); + +// Mute path — the one deterministic slice of the mesh we can unit-test. The +// _applyMute extraction fixes Bug B (2026-05-29): a mute toggled while the +// desktop getUserMedia permission prompt is open must stick once the stream +// resolves (join re-applies it post-stream). +describe('voice-mesh mute', function () { + var vr, trackA, trackB; + + beforeEach(function () { + vr = window.VoiceRoom; + trackA = { enabled: true }; + trackB = { enabled: true }; + vr.localStream = { getAudioTracks: function () { return [trackA, trackB]; } }; + vr.muted = false; + vr._onState = null; + }); + + afterEach(function () { + vr.localStream = null; + vr.muted = false; + vr._onState = null; + }); + + it('toggleMute disables the audio tracks + returns muted', function () { + expect(vr.toggleMute()).toBe(true); + expect(trackA.enabled).toBe(false); + expect(trackB.enabled).toBe(false); + }); + + it('toggleMute twice re-enables the tracks', function () { + vr.toggleMute(); + expect(vr.toggleMute()).toBe(false); + expect(trackA.enabled).toBe(true); + expect(trackB.enabled).toBe(true); + }); + + it('_applyMute honors a mute set before the stream existed (Bug B)', function () { + // muted toggled while the permission prompt was open (no stream yet)… + vr.localStream = null; + vr.muted = true; + // …then the stream arrives and join calls _applyMute. + vr.localStream = { getAudioTracks: function () { return [trackA]; } }; + vr._applyMute(); + expect(trackA.enabled).toBe(false); + }); + + it('notifies subscribers with the muted flag on toggle', function () { + var seen = null; + vr.setOnStateChange(function (st) { seen = st; }); + vr.toggleMute(); + expect(seen.muted).toBe(true); + }); +}); diff --git a/src/templates/apps/gameboard/_partials/_my_sea_gear.html b/src/templates/apps/gameboard/_partials/_my_sea_gear.html index 9d6e86d..18d292e 100644 --- a/src/templates/apps/gameboard/_partials/_my_sea_gear.html +++ b/src/templates/apps/gameboard/_partials/_my_sea_gear.html @@ -9,8 +9,33 @@ {# ` + {# Spectator-only BYE (Phase B) — drops presence (status=LEFT, frees seat #} {# 2C, kills voice). Rendered below NVM only when the caller passes a #} {# `leave_url`; the owner's pages never do, so their menu is unchanged. #}