diff --git a/desktop-client/README.md b/desktop-client/README.md index e69de29..afb74d9 100644 --- a/desktop-client/README.md +++ b/desktop-client/README.md @@ -0,0 +1,56 @@ +# VideoSync desktop client + +## Chat + +Chat follows new messages while you are near the bottom. Scroll up to read older +messages without losing your place. Incoming messages appear beneath a **New +messages** divider, and **Jump to latest** shows how many have arrived. Clicking +it, or scrolling back to the bottom, resumes following messages. + +## Ready checks + +Send `/ready` in chat to start a check for everyone in the room. Playback pauses +and a ready panel appears above chat. Each person clicks **I'm ready**; they can +change their answer with **Not ready**. When everyone is connected and ready, +the person who started the check can click **Start together**. They can also +cancel the check. Repeating `/ready` leaves an existing check and its votes intact. + +New participants must confirm readiness. Disconnecting clears that person's +vote; reconnecting restores the panel so they can confirm again. Ordinary play +controls dismiss an active check and continue playback. + +Ready checks require the updated server and desktop clients. The web client +does not yet have ready-check controls. + +## Fight-night commands + +- `/ko` — K.O.: That's a wrap. +- `/bell` — DING!: Back to your corners. +- `/tap` — TAP!: Someone's had enough. +- `/robbery` — a reaction plus a 30-second room vote on the decision. + +Reactions slide into the bottom of the video for 2.2 seconds and keep playback +running. A shared 3-second cooldown limits room-wide effects. Reaction events +stay in chat history; joining or reconnecting does not replay animations. + +Quiet sound effects play automatically with visible reactions. Sounds are +original synthesized effects cached locally, with no external downloads. Overlays and sound are +suppressed while the player is hidden, minimized, or another application is active. +Reactions also work while the detached chat window is focused. + +Robbery votes allow one answer per person, which you can change before the vote +closes. The result remains visible after closing and can be dismissed. Joining +or reconnecting restores the current vote. Fight commands require the updated +server and desktop clients; web clients currently see the chat reaction only. + +## Tests + +With `pytest`, `pytest-qt`, and Bun installed, run from this directory: + +```powershell +$env:QT_QPA_PLATFORM = 'offscreen' +.\.venv\Scripts\python.exe -m pytest test_chat.py test_sync_client.py test_ready_server.py test_fight_reactions.py test_fight_server.py -q +``` + +The ready-check integration test starts an isolated local server; it does not +contact the production service. VLC playback is mocked in the chat UI tests. diff --git a/desktop-client/commands.py b/desktop-client/commands.py index 78be26f..07ec763 100644 --- a/desktop-client/commands.py +++ b/desktop-client/commands.py @@ -6,6 +6,9 @@ HELP_TEXT = """ • /seek [time] - Seek (e.g., 1:23, +30s, -1m)
• /tag [now|time] [name] - Create a highlight
• /time - Show current timestamp
+• /ready - Ask everyone if they are ready to watch
+• /ko, /bell, /tap - Fight-night reactions
+• /robbery - React and open a room vote on the decision
• /help - Show this message

Keyboard Shortcuts:
• Space: Play/Pause
@@ -52,6 +55,14 @@ def parse_time_arg(arg: str, current_ms: int, length_ms: int): def handle_chat_command(cmd: str, parts: list, widget): """Handle a slash command. Returns True if the command was handled.""" + if cmd in ("/ko", "/bell", "/tap", "/robbery"): + widget.fight_action_requested.emit({"type": "reaction", "kind": cmd[1:]}) + return True + + if cmd == "/ready": + widget.ready_action_requested.emit({"type": "ready_check", "action": "begin"}) + return True + if cmd == "/play": if not widget.vlc_player.is_playing: widget.toggle_playback() diff --git a/desktop-client/fight_reactions.py b/desktop-client/fight_reactions.py new file mode 100644 index 0000000..f7120ba --- /dev/null +++ b/desktop-client/fight_reactions.py @@ -0,0 +1,146 @@ +"""Native ringside overlay and small, original synthesized reaction sounds.""" +import math +from pathlib import Path +import struct +import wave + +from PyQt6.QtCore import Qt, QTimer, QPropertyAnimation, QPoint, QEasingCurve, QEvent +from PyQt6.QtWidgets import QApplication, QWidget, QFrame, QLabel, QHBoxLayout, QVBoxLayout + + +REACTIONS = { + "ko": ("K.O.", "That's a wrap.", "#ff5676"), + "bell": ("DING!", "Back to your corners.", "#f5c86c"), + "tap": ("TAP!", "Someone's had enough.", "#e1e1e1"), + "robbery": ("ROBBERY?", "Judges, explain yourselves.", "#f5c86c"), +} + + +def write_reaction_sound(directory, kind): + """Generate mono PCM once per cache directory, with no downloaded samples.""" + if kind not in REACTIONS: + raise ValueError("Unknown reaction") + path = Path(directory) / f"{kind}.wav" + if path.exists(): + return path + path.parent.mkdir(parents=True, exist_ok=True) + rate = 22050 + duration = .85 if kind == "bell" else .4 + samples = bytearray() + for i in range(int(rate * duration)): + t = i / rate + attack = min(1, t / .008) + if kind == "ko": + value = math.sin(2 * math.pi * (130 * t - 100 * t * t)) * math.exp(-13 * t) + elif kind == "bell": + value = sum(math.sin(2 * math.pi * f * t) * a for f, a in + ((880, .55), (1765, .25), (2410, .12))) * math.exp(-5 * t) + elif kind == "tap": + pulse = t if t < .16 else t - .16 + value = math.sin(2 * math.pi * 420 * pulse) * math.exp(-45 * pulse) + else: + value = math.sin(2 * math.pi * 145 * t) * math.sin(2 * math.pi * 22 * t) * math.exp(-8 * t) + release = min(1, (duration - t) / .02) + samples.extend(struct.pack("{html.escape(state.get('requestedBy', 'Someone'))} started a ready check.") + if not self._chat_has_focus(): + self.chat_toggle_btn.setText("💬•") + self.chat_toggle_btn.setToolTip("A ready check is waiting") + self.chat_toggle_btn.setStyleSheet("color: #66b3ff; border: 1px solid #66b3ff; border-radius: 6px;") + QApplication.alert(self.chat_container.window(), 3000) + + def _send_ready_action(self, action, **extra): + if self._ready_check: + self.ready_action_requested.emit({"type": "ready_check", "action": action, + "id": self._ready_check['id'], **extra}) + + def _vote_ready(self): + mine = next((p for p in self._ready_check.get("participants", []) if p['username'] == self.username), {}) + self._send_ready_action("vote", ready=not mine.get("ready", False)) + + def handle_fight_reaction(self, message): + kind = message.get("kind") + if kind not in REACTIONS: + return + position = format_time_short(int(message.get("position", 0) * 1000)) + shown = self.ringside_strip.show_reaction(kind, message.get("username", "Someone"), position) + if shown: + self._play_reaction_sound(kind) + + def _play_reaction_sound(self, kind): + try: + if kind not in self._reaction_sounds: + cache = os.path.join(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.GenericCacheLocation), + "VideoSync", "reaction-sounds-v1") + path = write_reaction_sound(cache, kind) + sound = QSoundEffect(self) + sound.setVolume(.18) + sound.setSource(QUrl.fromLocalFile(str(path))) + self._reaction_sounds[kind] = sound + self._reaction_sounds[kind].play() + except OSError: + # A read-only cache must not prevent the visual reaction. + pass + + def update_fight_poll(self, state): + previous_id = self._fight_poll.get("id") + self._fight_poll = state if state.get("id") else {} + if not self._fight_poll: + self._fight_poll_timer.stop() + self.fight_poll_panel.hide() + return + if previous_id != state.get("id"): + self.fight_poll_panel.show() + self._refresh_fight_poll() + if state.get("active") and state.get("expiresAt", 0) > time.time() * 1000: + self._fight_poll_timer.start() + + def _refresh_fight_poll(self): + if not self._fight_poll: + return + remaining = max(0, int((self._fight_poll.get("expiresAt", 0) - time.time() * 1000 + 999) / 1000)) + active = self._fight_poll.get("active", False) and remaining > 0 + if not active: + self._fight_poll_timer.stop() + votes = self._fight_poll.get("votes", {}) + yes = sum(choice == "yes" for choice in votes.values()) + no = sum(choice == "no" for choice in votes.values()) + status = f"{remaining}s left" if active else "Vote closed" + mine = votes.get(self.username) + self.fight_poll_title.setText(f"Was that a robbery? · {status}") + self.fight_poll_result.setText(f"Absolutely {yes} · Fair decision {no}" + + (" · Your vote is in" if mine else "")) + for button, choice in ((self.fight_yes_btn, "yes"), (self.fight_no_btn, "no")): + button.setEnabled(bool(active and self._connected)) + button.setStyleSheet("border: 1px solid #66b3ff;" if mine == choice else "") + + def _vote_fight_poll(self, choice): + if self._fight_poll: + self.fight_action_requested.emit({"type": "fight_poll_vote", "id": self._fight_poll['id'], "choice": choice}) + + def reset_fight_reactions(self): + self.ringside_strip.dismiss() + self.update_fight_poll({}) + for sound in self._reaction_sounds.values(): + sound.stop() def toPlainText(self): # For compatibility with existing tests @@ -812,10 +1061,16 @@ class RoomWidget(QWidget): def clear_chat(self): self._clear_chat_alert() + self._chat_scroll_timer.stop() + self._follow_chat = True + self._pending_chat_messages = 0 + self._unread_divider = None + self.chat_jump_btn.hide() for i in reversed(range(self.chat_content_layout.count())): item = self.chat_content_layout.itemAt(i) if item.widget(): - item.widget().setParent(None) + item.widget().deleteLater() + self.chat_content_layout.removeWidget(item.widget()) def send_chat(self): text = self.chat_input.text().strip() diff --git a/desktop-client/smoke_commands.py b/desktop-client/smoke_commands.py new file mode 100644 index 0000000..4684c81 --- /dev/null +++ b/desktop-client/smoke_commands.py @@ -0,0 +1,68 @@ +"""Verify a deployed server with two isolated clients: python smoke_commands.py --url wss://host/ws.""" +import argparse +import json +import ssl +import time + +import certifi +from websockets.sync.client import connect + + +def verify_commands(url): + options = {'ssl': ssl.create_default_context(cafile=certifi.where())} if url.startswith('wss:') else {} + + def send(ws, **message): + ws.send(json.dumps(message)) + + def receive(ws, kind): + deadline = time.monotonic() + 8 + while True: + message = json.loads(ws.recv(timeout=max(.01, deadline - time.monotonic()))) + if message['type'] in ('error', 'reaction_error'): + raise RuntimeError(message.get('message')) + if message['type'] == kind: + return message + + with connect(url, **options) as host, connect(url, **options) as guest: + file_info = {'name': 'command-smoke-test.mkv', 'size': 1234, 'duration': 60} + send(host, type='create_room', username='CommandSmokeHost', fileInfo=file_info) + code = receive(host, 'room_created')['code'] + send(guest, type='join_room', username='CommandSmokeGuest', code=code) + receive(guest, 'room_file_check') + send(guest, type='confirm_join', fileInfo=file_info) + receive(guest, 'room_joined') + receive(host, 'user_joined') + send(host, type='ready_check', action='begin') + check = receive(host, 'ready_check') + assert check['active'] and receive(guest, 'ready_check')['id'] == check['id'] + for ws in (host, guest): + send(ws, type='ready_check', action='vote', id=check['id'], ready=True) + for peer in (host, guest): + receive(peer, 'ready_check') + send(host, type='ready_check', action='start', id=check['id']) + for peer in (host, guest): + assert receive(peer, 'sync')['action'] == 'play' + assert not receive(peer, 'ready_check')['active'] + print('PASS /ready: shared panel state, votes, synchronized start', flush=True) + for i, kind in enumerate(('ko', 'bell', 'tap', 'robbery')): + if i: + time.sleep(3.2) + send(host, type='reaction', kind=kind) + for peer in (host, guest): + assert receive(peer, 'reaction')['kind'] == kind + assert receive(peer, 'chat')['message'] == '🥊 /' + kind + print(f'PASS /{kind}: delivered to both clients', flush=True) + poll = receive(host, 'fight_poll') + assert receive(guest, 'fight_poll')['id'] == poll['id'] + send(guest, type='fight_poll_vote', id=poll['id'], choice='yes') + for peer in (host, guest): + assert receive(peer, 'fight_poll')['votes']['CommandSmokeGuest'] == 'yes' + send(host, type='request_state') + assert receive(host, 'state')['state']['playing'] + print('PASS /robbery vote and uninterrupted playback', flush=True) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--url', required=True) + verify_commands(parser.parse_args().url) diff --git a/desktop-client/sync_client.py b/desktop-client/sync_client.py index f1b4299..502c831 100644 --- a/desktop-client/sync_client.py +++ b/desktop-client/sync_client.py @@ -21,6 +21,9 @@ class SyncClientThread(QThread): chat_message = pyqtSignal(str, str, int) # author, text, timestamp system_message = pyqtSignal(str) sync_event = pyqtSignal(dict) + ready_check_updated = pyqtSignal(dict) + fight_reaction = pyqtSignal(dict) + fight_poll_updated = pyqtSignal(dict) latency_updated = pyqtSignal(int) # latency in ms def __init__(self, url="ws://localhost:3000/ws"): @@ -150,7 +153,7 @@ class SyncClientThread(QThread): elif t == "room_file_check": self.file_check_needed.emit(msg) - elif t in ["user_joined", "user_left"]: + elif t in ["user_joined", "user_left", "user_rejoined"]: self.users_updated.emit(msg.get("users", [])) elif t == "chat": @@ -162,3 +165,15 @@ class SyncClientThread(QThread): elif t == "sync": self.sync_event.emit(msg) + + elif t == "ready_check": + self.ready_check_updated.emit(msg) + + elif t == "reaction": + self.fight_reaction.emit(msg) + + elif t == "fight_poll": + self.fight_poll_updated.emit(msg) + + elif t == "reaction_error": + self.system_message.emit(msg.get("message", "Reaction unavailable")) diff --git a/desktop-client/test_chat.py b/desktop-client/test_chat.py index 324e589..4a01153 100644 --- a/desktop-client/test_chat.py +++ b/desktop-client/test_chat.py @@ -17,6 +17,7 @@ class PlayerSignals(QObject): def room(qtbot, monkeypatch): player = Mock(signals=PlayerSignals()) monkeypatch.setattr(room_widget, 'VLCSyncPlayer', lambda frame: player) + monkeypatch.setattr(room_widget.RoomWidget, '_play_reaction_sound', Mock()) widget = room_widget.RoomWidget() widget.username = 'Me' qtbot.addWidget(widget) @@ -101,3 +102,118 @@ def test_popout_focus_and_wrapping(room, qtbot, monkeypatch): room.add_chat_message('Other', 'Background popout', 0) assert room._unread_messages == 1 popout.close() + + +def test_scrolling_preserves_position_and_counts_unread(room, qtbot): + for i in range(30): + room.add_chat_message('Other', f'Message {i}: ' + 'wrapped words ' * 10, 0) + qtbot.wait(150) + bar = room.chat_messages.verticalScrollBar() + assert bar.value() == bar.maximum() + bar.setValue(100) + qtbot.wait(30) + position = bar.value() + room.add_chat_message('Other', 'First unread message', 0) + room.add_chat_message('Other', 'Second unread message', 0) + room.add_system_message('Someone paused') + qtbot.wait(150) + assert bar.value() == position + assert room._pending_chat_messages == 2 + assert not room.chat_jump_btn.isHidden() + assert room._unread_divider is not None + room.chat_input.setFocus() + assert room._pending_chat_messages == 2 + room.jump_to_latest() + qtbot.wait(150) + assert bar.value() == bar.maximum() + assert room.chat_jump_btn.isHidden() + room.add_chat_message('Other', 'Follow latest', 0) + qtbot.wait(150) + assert bar.value() == bar.maximum() + assert room._pending_chat_messages == 0 + + +def test_scroll_back_to_bottom_clears_pending(room, qtbot): + for i in range(20): + room.add_chat_message('Other', 'Words ' * 30, 0) + qtbot.wait(150) + bar = room.chat_messages.verticalScrollBar() + bar.setValue(0) + room.add_chat_message('Other', 'Unread', 0) + qtbot.wait(100) + bar.setValue(bar.maximum()) + assert room._pending_chat_messages == 0 + assert room._follow_chat + assert room.chat_jump_btn.isHidden() + room.clear_chat() + assert room._unread_divider is None + assert room.chat_content_layout.count() == 0 + + +def test_ready_command_and_controls(room, qtbot): + with qtbot.waitSignal(room.ready_action_requested) as signal: + room.chat_input.setText('/ready') + room.send_chat() + assert signal.args == [{'type': 'ready_check', 'action': 'begin'}] + assert room.chat_input.text() == '' + state = {'active': True, 'id': 'check-1', 'requestedBy': 'Me', 'participants': [ + {'username': 'Me', 'ready': False, 'connected': True}, + {'username': "O'Brien", 'ready': True, 'connected': True}]} + room.update_ready_check(state) + assert not room.ready_panel.isHidden() + assert not room.ready_start_btn.isEnabled() + assert "O'Brien" in room.ready_users.text() + with qtbot.waitSignal(room.ready_action_requested) as signal: + room.ready_btn.click() + assert signal.args[0] == {'type': 'ready_check', 'action': 'vote', 'id': 'check-1', 'ready': True} + state['participants'][0]['ready'] = True + room.update_ready_check(state) + assert room.ready_start_btn.isEnabled() + with qtbot.waitSignal(room.ready_action_requested) as signal: + room.ready_start_btn.click() + assert signal.args[0]['action'] == 'start' + state['participants'][1]['connected'] = False + room.update_ready_check(state) + assert not room.ready_start_btn.isEnabled() + assert 'Reconnecting' in room.ready_users.text() + state['requestedBy'] = 'Other' + room.update_ready_check(state) + assert room.ready_start_btn.isHidden() + room.update_ready_check({'active': False}) + assert room.ready_panel.isHidden() + + +def test_ready_panel_buttons_fit_styled_chat(room, qtbot): + from main import VlcSyncApp + VlcSyncApp.apply_stylesheet(room) + room.splitter.setSizes([750, 300]) + room.update_ready_check({'active': True, 'id': 'layout', 'requestedBy': 'Me', 'participants': [ + {'username': 'Me', 'ready': True, 'connected': True}]}) + qtbot.wait(100) + for button in (room.ready_btn, room.ready_start_btn, room.ready_cancel_btn): + assert button.width() >= button.fontMetrics().horizontalAdvance(button.text()) + 20 + room.update_connection_status(False) + room.update_ready_check(room._ready_check) + assert not room.ready_start_btn.isEnabled() + assert not room.ready_btn.isEnabled() + + +def test_app_wires_commands_to_websocket_and_incoming_panels(room, qtbot, monkeypatch): + import main + from sync_client import SyncClientThread + client = SyncClientThread() + client.send_message = Mock() + monkeypatch.setattr(main, 'SyncClientThread', lambda url: client) + app = main.VlcSyncApp() + qtbot.addWidget(app) + for command, expected in [('/ready', {'type': 'ready_check', 'action': 'begin'}), + ('/ko', {'type': 'reaction', 'kind': 'ko'}), + ('/bell', {'type': 'reaction', 'kind': 'bell'})]: + app.room_widget.chat_input.setText(command) + app.room_widget.send_chat() + client.send_message.assert_called_with(expected) + state = {'type': 'ready_check', 'active': True, 'id': 'wired', 'requestedBy': 'Me', + 'participants': [{'username': 'Me', 'ready': False, 'connected': True}]} + client._handle_message(state) + assert app.room_widget._ready_check['id'] == 'wired' + assert not app.room_widget.ready_panel.isHidden() diff --git a/desktop-client/test_fight_reactions.py b/desktop-client/test_fight_reactions.py new file mode 100644 index 0000000..87ad53d --- /dev/null +++ b/desktop-client/test_fight_reactions.py @@ -0,0 +1,149 @@ +import time +import wave +from unittest.mock import Mock + +import pytest +from PyQt6.QtCore import QEvent, QUrl +from PyQt6.QtMultimedia import QSoundEffect +from PyQt6.QtWidgets import QApplication + +from fight_reactions import REACTIONS, write_reaction_sound +from test_chat import room + + +@pytest.mark.parametrize('kind', REACTIONS) +def test_command_dispatch(room, qtbot, kind): + with qtbot.waitSignal(room.fight_action_requested) as event: + room.chat_input.setText('/' + kind) + room.send_chat() + assert event.args == [{'type': 'reaction', 'kind': kind}] + assert room.chat_input.text() == '' + assert room.chat_content_layout.count() == 0 # Wait for the server echo. + + +@pytest.mark.parametrize('kind', REACTIONS) +def test_strip_is_native_positioned_and_does_not_steal_focus(room, qtbot, kind): + room.activateWindow() + room.chat_input.setFocus() + qtbot.wait(30) + room.handle_fight_reaction({'kind': kind, 'username': "O'Brien", 'position': 83}) + strip = room.ringside_strip + assert strip.isWindow() + assert strip.isVisible() + assert strip.headline.text() == REACTIONS[kind][0] + assert "O'Brien" in strip.caption.text() + assert '01:23' in strip.metadata.text() + qtbot.wait(210) + assert QApplication.focusWidget() is room.chat_input + video = room.video_frame + rect = strip.geometry() + from PyQt6.QtCore import QRect, QPoint + bounds = QRect(video.mapToGlobal(QPoint(0, 0)), video.size()) + assert bounds.contains(rect) + room.resize(1150, 650) + qtbot.wait(50) + bounds = QRect(video.mapToGlobal(QPoint(0, 0)), video.size()) + assert bounds.contains(strip.geometry()) + QApplication.sendEvent(room, QEvent(QEvent.Type.WindowDeactivate)) + assert not strip.isVisible() + + +def test_strip_auto_dismiss_and_cleanup(room, qtbot): + room.activateWindow() + qtbot.wait(30) + room.handle_fight_reaction({'kind': 'ko', 'username': 'Alex', 'position': 0}) + assert room.ringside_strip.isVisible() + room.ringside_strip.dismiss_timer.start(30) + qtbot.waitUntil(lambda: not room.ringside_strip.isVisible()) + room.handle_fight_reaction({'kind': 'bell', 'username': 'Alex', 'position': 0}) + room.cleanup() + assert not room.ringside_strip.isVisible() + assert not room.ringside_strip.dismiss_timer.isActive() + assert not room._fight_poll_timer.isActive() + + +def test_reactions_work_with_chat_popout_focused(room, qtbot): + room.popout_chat() + popout = room.popout_window + qtbot.addWidget(popout) + popout.activateWindow() + room.chat_input.setFocus() + qtbot.wait(50) + room.handle_fight_reaction({'kind': 'ko', 'username': 'Other', 'position': 0}) + assert room.ringside_strip.isVisible() + assert QApplication.focusWidget() is room.chat_input + popout.close() + + +def test_sound_is_enabled_by_default_and_background_effects_are_suppressed(room, qtbot, monkeypatch): + play = Mock() + monkeypatch.setattr(room, '_play_reaction_sound', play) + room.activateWindow() + qtbot.wait(30) + room.handle_fight_reaction({'kind': 'ko', 'position': 0}) + play.assert_called_once_with('ko') + play.reset_mock() + room.handle_fight_reaction({'kind': 'bell', 'position': 0}) + play.assert_called_once_with('bell') + room.hide() + room.handle_fight_reaction({'kind': 'tap', 'position': 0}) + play.assert_called_once_with('bell') + assert not room.ringside_strip.isVisible() + + +@pytest.mark.parametrize('kind', REACTIONS) +def test_original_sound_files_are_valid_and_loadable(qtbot, tmp_path, kind): + path = write_reaction_sound(tmp_path, kind) + assert write_reaction_sound(tmp_path, kind) == path + with wave.open(str(path)) as audio: + assert audio.getnchannels() == 1 + assert audio.getsampwidth() == 2 + assert audio.getframerate() == 22050 + assert .35 <= audio.getnframes() / 22050 <= .9 + assert any(audio.readframes(audio.getnframes())) + sound = QSoundEffect() + sound.setSource(QUrl.fromLocalFile(str(path))) + qtbot.waitUntil(lambda: sound.status() in (QSoundEffect.Status.Ready, QSoundEffect.Status.Error)) + assert sound.status() == QSoundEffect.Status.Ready + + +def test_robbery_vote_controls_expiry_and_reconnect(room, qtbot): + poll = {'id': 'vote-1', 'active': True, 'expiresAt': time.time() * 1000 + 30000, + 'votes': {'Other': 'yes'}} + room.update_fight_poll(poll) + assert not room.fight_poll_panel.isHidden() + assert 'Absolutely 1' in room.fight_poll_result.text() + with qtbot.waitSignal(room.fight_action_requested) as event: + room.fight_no_btn.click() + assert event.args == [{'type': 'fight_poll_vote', 'id': 'vote-1', 'choice': 'no'}] + poll['votes']['Me'] = 'no' + room.update_fight_poll(poll) + assert 'Fair decision 1' in room.fight_poll_result.text() + assert 'Your vote is in' in room.fight_poll_result.text() + room.update_connection_status(False) + assert not room.fight_yes_btn.isEnabled() + room.update_connection_status(True) + assert room.fight_yes_btn.isEnabled() + poll['expiresAt'] = time.time() * 1000 - 1000 + room.update_fight_poll(poll) + assert 'Vote closed' in room.fight_poll_title.text() + assert not room.fight_yes_btn.isEnabled() + assert not room._fight_poll_timer.isActive() + room.reset_fight_reactions() + assert room.fight_poll_panel.isHidden() + + +def test_styled_vote_controls_fit_and_dismiss_stays_dismissed(room, qtbot): + from main import VlcSyncApp + VlcSyncApp.apply_stylesheet(room) + room.splitter.setSizes([750, 350]) + poll = {'id': 'styled', 'active': True, 'expiresAt': time.time() * 1000 + 30000, 'votes': {}} + room.update_fight_poll(poll) + qtbot.wait(50) + for button in (room.fight_yes_btn, room.fight_no_btn, room.fight_poll_dismiss_btn): + assert button.width() >= button.fontMetrics().horizontalAdvance(button.text()) + 10 + room.fight_poll_dismiss_btn.click() + room.update_fight_poll(poll) + assert room.fight_poll_panel.isHidden() + room.update_fight_poll({**poll, 'id': 'new-poll'}) + assert not room.fight_poll_panel.isHidden() diff --git a/desktop-client/test_fight_server.py b/desktop-client/test_fight_server.py new file mode 100644 index 0000000..f038681 --- /dev/null +++ b/desktop-client/test_fight_server.py @@ -0,0 +1,104 @@ +import json +import time + +import pytest +from websockets.sync.client import connect + +from test_ready_server import server_url, send, receive + + +def join_pair(host, guest): + file_info = {'name': 'fight.mkv', 'size': 1234, 'duration': 60} + send(host, type='create_room', username='Host', fileInfo=file_info) + code = receive(host, 'room_created')['code'] + send(guest, type='join_room', username='Guest', code=code) + receive(guest, 'room_file_check') + send(guest, type='confirm_join', fileInfo=file_info) + receive(guest, 'room_joined') + receive(host, 'user_joined') + return code, file_info + + +@pytest.mark.parametrize('kind', ['ko', 'bell', 'tap', 'robbery']) +def test_reactions_are_room_wide_and_preserve_playback(server_url, kind): + with connect(server_url) as host, connect(server_url) as guest: + code, file_info = join_pair(host, guest) + # An unconfirmed client cannot broadcast or consume the cooldown. + with connect(server_url) as pending: + send(pending, type='join_room', username='Pending', code=code) + receive(pending, 'room_file_check') + send(pending, type='reaction', kind=kind) + send(pending, type='ping', timestamp=1) + assert receive(pending, 'pong')['timestamp'] == 1 + send(host, type='reaction', kind='invalid') + assert 'Unknown' in receive(host, 'reaction_error')['message'] + send(host, type='sync', action='play', position=18) + receive(guest, 'sync') + send(host, type='reaction', kind=kind, username='Spoofed') + reaction = receive(host, 'reaction') + assert reaction['kind'] == kind and reaction['username'] == 'Host' + assert reaction['position'] >= 18 + assert receive(guest, 'reaction') == reaction + assert receive(host, 'chat')['message'] == '🥊 /' + kind + receive(guest, 'chat') + if kind == 'robbery': + assert receive(host, 'fight_poll')['active'] + receive(guest, 'fight_poll') + send(guest, type='reaction', kind='bell') + assert 'cooldown' in receive(guest, 'reaction_error')['message'] + send(host, type='request_state') + state = receive(host, 'state')['state'] + assert state['playing'] and state['position'] >= 18 + with pytest.raises(TimeoutError): + host.recv(timeout=.1) + # Reconnecting restores chat history without replaying transient effects. + guest.close() + with connect(server_url) as restored: + send(restored, type='rejoin_room', username='Guest', code=code) + snapshot = receive(restored, 'room_rejoined') + assert snapshot['chatHistory'][-1]['message'] == '🥊 /' + kind + with pytest.raises(TimeoutError): + restored.recv(timeout=.1) + + +def test_robbery_votes_are_unique_restored_and_expire(server_url): + with connect(server_url) as host, connect(server_url) as guest: + code, file_info = join_pair(host, guest) + send(host, type='reaction', kind='robbery') + poll = receive(host, 'fight_poll') + poll_id = poll['id'] + assert poll['votes'] == {} and poll['active'] + receive(guest, 'fight_poll') + send(host, type='fight_poll_vote', id=poll_id, choice='yes') + assert receive(host, 'fight_poll')['votes'] == {'Host': 'yes'} + receive(guest, 'fight_poll') + send(host, type='fight_poll_vote', id=poll_id, choice='no') + assert receive(host, 'fight_poll')['votes'] == {'Host': 'no'} + receive(guest, 'fight_poll') + send(guest, type='fight_poll_vote', id=poll_id, choice='yes', username='Host') + assert receive(host, 'fight_poll')['votes'] == {'Host': 'no', 'Guest': 'yes'} + receive(guest, 'fight_poll') + for vote_id, choice in [('stale', 'yes'), (poll_id, 'invalid')]: + send(guest, type='fight_poll_vote', id=vote_id, choice=choice) + send(guest, type='ping', timestamp=2) + assert json.loads(guest.recv(timeout=3))['type'] == 'pong' + guest.close() + with connect(server_url) as restored: + send(restored, type='rejoin_room', username='Guest', code=code) + snapshot = receive(restored, 'room_rejoined')['fightPoll'] + assert snapshot['id'] == poll_id and snapshot['votes']['Guest'] == 'yes' + receive(host, 'user_rejoined') + time.sleep(3.1) + send(restored, type='reaction', kind='robbery') + assert 'already open' in receive(restored, 'reaction_error')['message'] + assert receive(restored, 'fight_poll')['id'] == poll_id + closed = json.loads(host.recv(timeout=35)) + assert closed['type'] == 'fight_poll' and not closed['active'] + assert closed['votes'] == {'Host': 'no', 'Guest': 'yes'} + assert not receive(restored, 'fight_poll')['active'] + send(restored, type='fight_poll_vote', id=poll_id, choice='no') + send(restored, type='ping', timestamp=3) + assert json.loads(restored.recv(timeout=3))['type'] == 'pong' + send(host, type='reaction', kind='robbery') + replacement = receive(host, 'fight_poll') + assert replacement['id'] != poll_id and replacement['votes'] == {} diff --git a/desktop-client/test_ready_server.py b/desktop-client/test_ready_server.py new file mode 100644 index 0000000..9975889 --- /dev/null +++ b/desktop-client/test_ready_server.py @@ -0,0 +1,133 @@ +"""Exercise ready checks through real WebSockets against an isolated local server.""" +import json +import os +from pathlib import Path +import shutil +import socket +import subprocess +import time + +import pytest +from websockets.sync.client import connect + + +@pytest.fixture +def server_url(): + with socket.socket() as reservation: + reservation.bind(('127.0.0.1', 0)) + port = reservation.getsockname()[1] + bun = shutil.which('bun') + assert bun, 'Bun is required to test the local server' + process = subprocess.Popen( + [bun, 'run', 'server.ts'], cwd=Path(__file__).resolve().parent.parent, + env={**os.environ, 'PORT': str(port)}, stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) + try: + deadline = time.monotonic() + 5 + while True: + if process.poll() is not None: + pytest.fail(process.stderr.read().decode()) + try: + with socket.create_connection(('127.0.0.1', port), timeout=.1): + break + except OSError: + if time.monotonic() >= deadline: + pytest.fail('Local server did not start') + time.sleep(.05) + yield f'ws://127.0.0.1:{port}/ws' + finally: + process.terminate() + process.wait(timeout=5) + process.stderr.close() + + +def send(ws, **message): + ws.send(json.dumps(message)) + + +def receive(ws, kind, predicate=lambda message: True): + deadline = time.monotonic() + 3 + while True: + message = json.loads(ws.recv(timeout=max(.01, deadline - time.monotonic()))) + if message['type'] == kind and predicate(message): + return message + + +def test_room_ready_check_lifecycle(server_url): + file_info = {'name': 'test.mkv', 'size': 1234, 'duration': 60} + with connect(server_url) as host, connect(server_url) as guest: + send(host, type='create_room', username='Host', fileInfo=file_info) + code = receive(host, 'room_created')['code'] + send(guest, type='join_room', username='Guest', code=code) + receive(guest, 'room_file_check') + send(guest, type='confirm_join', fileInfo=file_info) + receive(guest, 'room_joined') + receive(host, 'user_joined') + send(host, type='sync', action='play', position=12) + receive(guest, 'sync') + send(host, type='ready_check', action='begin') + pause = receive(host, 'sync') + assert pause['action'] == 'pause' and pause['position'] >= 12 + check = receive(host, 'ready_check') + check_id = check['id'] + assert check['requestedBy'] == 'Host' + assert not any(p['ready'] for p in check['participants']) + assert receive(guest, 'ready_check')['id'] == check_id + + # Unconfirmed peers cannot create or vote in a check. + with connect(server_url) as pending: + send(pending, type='join_room', username='Pending', code=code) + receive(pending, 'room_file_check') + send(pending, type='ready_check', action='begin') + send(pending, type='ready_check', action='vote', id=check_id, ready=True) + send(host, type='ready_check', action='start', id=check_id) + send(host, type='ready_check', action='vote', id=check_id, ready=True) + check = receive(host, 'ready_check') + assert check['active'] and sum(p['ready'] for p in check['participants']) == 1 + receive(guest, 'ready_check') + send(host, type='ready_check', action='begin') + repeated = receive(host, 'ready_check') + assert repeated['id'] == check_id + assert sum(p['ready'] for p in repeated['participants']) == 1 + receive(guest, 'ready_check') + send(guest, type='ready_check', action='vote', id=check_id, ready=True) + assert all(p['ready'] for p in receive(host, 'ready_check')['participants']) + receive(guest, 'ready_check') + + # Non-initiators cannot start. Stale IDs cannot change votes. + send(guest, type='ready_check', action='start', id=check_id) + send(guest, type='ready_check', action='vote', id='stale', ready=False) + send(guest, type='ready_check', action='begin') + assert all(p['ready'] for p in receive(host, 'ready_check')['participants']) + receive(guest, 'ready_check') + + # Dropping a connection revokes readiness and blocks starting. + guest.close() + disconnected = receive(host, 'ready_check') + guest_state = next(p for p in disconnected['participants'] if p['username'] == 'Guest') + assert not guest_state['connected'] and not guest_state['ready'] + send(host, type='ready_check', action='start', id=check_id) + with connect(server_url) as rejoined: + send(rejoined, type='rejoin_room', username='Guest', code=code) + restored = receive(rejoined, 'room_rejoined')['readyCheck'] + assert restored['id'] == check_id + assert not next(p for p in restored['participants'] if p['username'] == 'Guest')['ready'] + receive(host, 'ready_check') + receive(rejoined, 'ready_check') + send(rejoined, type='ready_check', action='vote', id=check_id, ready=True) + receive(host, 'ready_check') + receive(rejoined, 'ready_check') + send(host, type='ready_check', action='start', id=check_id) + assert receive(host, 'sync')['action'] == 'play' + assert receive(rejoined, 'sync')['action'] == 'play' + assert not receive(host, 'ready_check')['active'] + assert not receive(rejoined, 'ready_check')['active'] + + send(host, type='ready_check', action='begin') + new_check = receive(host, 'ready_check') + assert new_check['id'] != check_id + assert not any(p['ready'] for p in new_check['participants']) + receive(rejoined, 'ready_check') + send(host, type='ready_check', action='cancel', id=new_check['id']) + assert not receive(host, 'ready_check')['active'] + assert not receive(rejoined, 'ready_check')['active'] diff --git a/desktop-client/test_sync_client.py b/desktop-client/test_sync_client.py index c76d47e..0eb1b5a 100644 --- a/desktop-client/test_sync_client.py +++ b/desktop-client/test_sync_client.py @@ -22,3 +22,23 @@ def test_certificate_error_finds_nested_verification_error(): def test_non_certificate_error_is_ignored(): assert SyncClientThread._certificate_error(ConnectionError("offline")) is None + + +def test_ready_check_event_dispatch(qtbot): + client = SyncClientThread() + message = {'type': 'ready_check', 'active': True, 'id': 'check'} + with qtbot.waitSignal(client.ready_check_updated) as signal: + client._handle_message(message) + assert signal.args == [message] + + +def test_fight_event_dispatch_and_nonfatal_feedback(qtbot): + client = SyncClientThread() + for event_type, signal in (('reaction', client.fight_reaction), ('fight_poll', client.fight_poll_updated)): + message = {'type': event_type, 'kind': 'ko'} + with qtbot.waitSignal(signal) as event: + client._handle_message(message) + assert event.args == [message] + with qtbot.waitSignal(client.system_message) as event: + client._handle_message({'type': 'reaction_error', 'message': 'Cooldown'}) + assert event.args == ['Cooldown'] diff --git a/server.ts b/server.ts index 09f47f0..762efdc 100644 --- a/server.ts +++ b/server.ts @@ -16,6 +16,9 @@ interface RoomState { lastUpdate: number; // timestamp when position was last set users: Map; chatHistory: ChatMessage[]; + readyCheck?: { id: string; requestedBy: string; ready: Set }; + lastReactionAt?: number; + fightPoll?: { id: string; expiresAt: number; votes: Map }; } interface ChatMessage { @@ -56,6 +59,29 @@ function getUserList(room: RoomState): string[] { return Array.from(room.users.keys()); } +function readyCheckState(room: RoomState) { + if (!room.readyCheck) return { type: "ready_check", active: false }; + return { + type: "ready_check", active: true, + id: room.readyCheck.id, requestedBy: room.readyCheck.requestedBy, + participants: Array.from(room.users, ([username, ws]) => ({ + username, ready: room.readyCheck!.ready.has(username), connected: ws.readyState === 1, + })), + }; +} + +function broadcastReadyCheck(room: RoomState) { + broadcastToRoom(room, readyCheckState(room)); +} + +function fightPollState(room: RoomState) { + const poll = room.fightPoll; + if (!poll) return { type: "fight_poll", active: false }; + return { type: "fight_poll", id: poll.id, active: Date.now() < poll.expiresAt, + expiresAt: poll.expiresAt, + votes: Object.fromEntries(Array.from(poll.votes).filter(([username]) => room.users.has(username))) }; +} + // --- MIME Types --- const mimeTypes: Record = { ".html": "text/html", @@ -253,6 +279,8 @@ const server = Bun.serve({ position: getCurrentPosition(room), }, chatHistory: room.chatHistory.slice(-50), + readyCheck: readyCheckState(room), + fightPoll: fightPollState(room), }) ); @@ -267,12 +295,17 @@ const server = Bun.serve({ ws as unknown as WebSocket ); console.log(`[Room ${code}] ${username} joined`); + if (room.readyCheck) broadcastReadyCheck(room); break; } case "sync": { const room = rooms.get(ws.data.roomCode); if (!room) return; + if (msg.action === "play" && room.readyCheck) { + room.readyCheck = undefined; + broadcastReadyCheck(room); + } // Update authoritative state if (msg.action === "play") { @@ -305,6 +338,89 @@ const server = Bun.serve({ break; } + case "ready_check": { + const room = rooms.get(ws.data.roomCode); + const username = ws.data.username; + if (!room || room.users.get(username) !== (ws as unknown as WebSocket)) return; + if (msg.action === "begin") { + // Repeating /ready shows the existing check without resetting votes. + if (!room.readyCheck) { + room.readyCheck = { id: crypto.randomUUID(), requestedBy: username, ready: new Set() }; + room.position = getCurrentPosition(room); + room.playing = false; + room.lastUpdate = Date.now(); + broadcastToRoom(room, { type: "sync", action: "pause", position: room.position, playing: false }); + } + } else { + const check = room.readyCheck; + if (!check || msg.id !== check.id) return; + if (msg.action === "vote" && typeof msg.ready === "boolean") { + if (msg.ready) check.ready.add(username); + else check.ready.delete(username); + } else if (msg.action === "cancel" && check.requestedBy === username) { + room.readyCheck = undefined; + } else if (msg.action === "start" && check.requestedBy === username) { + const everyoneReady = Array.from(room.users).every(([name, peer]) => + peer.readyState === 1 && check.ready.has(name)); + if (!everyoneReady) return; + room.readyCheck = undefined; + room.playing = true; + room.lastUpdate = Date.now(); + broadcastToRoom(room, { type: "sync", action: "play", position: room.position, playing: true }); + } else return; + } + broadcastReadyCheck(room); + break; + } + + case "reaction": { + const room = rooms.get(ws.data.roomCode); + if (!room || room.users.get(ws.data.username) !== (ws as unknown as WebSocket)) return; + if (!["ko", "bell", "tap", "robbery"].includes(msg.kind)) { + ws.send(JSON.stringify({ type: "reaction_error", message: "Unknown fight reaction." })); + return; + } + const now = Date.now(); + if (room.lastReactionAt !== undefined && now - room.lastReactionAt < 3000) { + ws.send(JSON.stringify({ type: "reaction_error", message: "Fight reactions have a 3-second room cooldown." })); + return; + } + if (msg.kind === "robbery" && room.fightPoll && now < room.fightPoll.expiresAt) { + ws.send(JSON.stringify({ type: "reaction_error", message: "A robbery vote is already open." })); + ws.send(JSON.stringify(fightPollState(room))); + return; + } + room.lastReactionAt = now; + broadcastToRoom(room, { type: "reaction", kind: msg.kind, + username: ws.data.username, position: getCurrentPosition(room), timestamp: now }); + const chatMsg = { username: ws.data.username, message: `🥊 /${msg.kind}`, timestamp: now }; + room.chatHistory.push(chatMsg); + if (room.chatHistory.length > 200) room.chatHistory = room.chatHistory.slice(-100); + broadcastToRoom(room, { type: "chat", ...chatMsg }); + if (msg.kind === "robbery") { + const poll = { id: crypto.randomUUID(), expiresAt: now + 30_000, votes: new Map() }; + room.fightPoll = poll; + broadcastToRoom(room, fightPollState(room)); + setTimeout(() => { + if (rooms.get(room.code) === room && room.fightPoll?.id === poll.id) { + broadcastToRoom(room, fightPollState(room)); + } + }, 30_000); + } + break; + } + + case "fight_poll_vote": { + const room = rooms.get(ws.data.roomCode); + if (!room || room.users.get(ws.data.username) !== (ws as unknown as WebSocket)) return; + const poll = room.fightPoll; + if (!poll || poll.id !== msg.id || Date.now() >= poll.expiresAt) return; + if (msg.choice !== "yes" && msg.choice !== "no") return; + poll.votes.set(ws.data.username, msg.choice); + broadcastToRoom(room, fightPollState(room)); + break; + } + case "chat": { const room = rooms.get(ws.data.roomCode); if (!room) return; @@ -386,6 +502,8 @@ const server = Bun.serve({ position: getCurrentPosition(room), }, chatHistory: room.chatHistory.slice(-50), + readyCheck: readyCheckState(room), + fightPoll: fightPollState(room), }) ); @@ -400,6 +518,7 @@ const server = Bun.serve({ ws as unknown as WebSocket ); console.log(`[Room ${code}] ${username} rejoined`); + if (room.readyCheck) broadcastReadyCheck(room); break; } } @@ -411,6 +530,11 @@ const server = Bun.serve({ const room = rooms.get(roomCode); if (!room) return; + if (room.users.get(username) !== (ws as unknown as WebSocket)) return; + if (room.readyCheck) { + room.readyCheck.ready.delete(username); + broadcastReadyCheck(room); + } // Don't remove immediately — give a 90s grace period for reconnection const disconnectKey = `${roomCode}:${username}`; @@ -425,6 +549,15 @@ const server = Bun.serve({ const currentWs = currentRoom.users.get(username); if (currentWs === (ws as unknown as WebSocket)) { currentRoom.users.delete(username); + if (currentRoom.fightPoll) { + currentRoom.fightPoll.votes.delete(username); + broadcastToRoom(currentRoom, fightPollState(currentRoom)); + } + if (currentRoom.readyCheck) { + currentRoom.readyCheck.ready.delete(username); + if (currentRoom.readyCheck.requestedBy === username) currentRoom.readyCheck = undefined; + broadcastReadyCheck(currentRoom); + } console.log(`[Room ${roomCode}] ${username} removed (grace period expired)`); if (currentRoom.users.size === 0) {