from unittest.mock import Mock import pytest from PyQt6.QtCore import QObject, pyqtSignal from PyQt6.QtGui import QTextDocument from PyQt6.QtWidgets import QApplication import room_widget class PlayerSignals(QObject): time_changed = pyqtSignal(int) state_changed = pyqtSignal(bool, int) @pytest.fixture 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) widget.resize(1050, 500) widget.show() return widget @pytest.mark.parametrize('text', ["It's someone's & my ", "It's at 1:23 & literal", 'line one\nline two', 'literal ' entity']) def test_message_rendering(room, text): room.add_chat_message("O'Brien", text, 0) bubble = room.chat_content_layout.itemAt(0).widget() doc = QTextDocument() doc.setHtml(bubble.text_lbl.text()) assert doc.toPlainText() == text if '1:23' in text: assert 'href="seek:1:23"' in bubble.text_lbl.text() def test_bubbles_do_not_shrink_or_clip_when_overflowing(room, qtbot): text = 'A wrapped message with several words. ' * 8 room.add_chat_message('Other', text, 0) qtbot.wait(100) first = room.chat_content_layout.itemAt(0).widget() initial_height = first.height() for i in range(35): room.add_chat_message('Me' if i % 2 else 'Other', text, 0) qtbot.wait(200) assert room.chat_messages.verticalScrollBar().maximum() > 0 assert first.height() >= initial_height for width in (220, 450, 280): room.splitter.setSizes([room.width() - width, width]) qtbot.wait(100) for i in range(room.chat_content_layout.count()): bubble = room.chat_content_layout.itemAt(i).widget() label = bubble.text_lbl assert label.height() >= label.heightForWidth(label.width()) assert bubble.bubble.rect().contains(label.geometry()) def test_unread_alert_and_focus_clear(room, qtbot, monkeypatch): alert = Mock() monkeypatch.setattr(QApplication, 'alert', alert) room.activateWindow() room.setFocus() qtbot.wait(50) room.add_chat_message('Other', 'hello', 0) assert room._unread_messages == 1 alert.assert_called_once() room.add_chat_message('Me', 'reply', 0) assert room._unread_messages == 1 room.chat_input.setFocus() qtbot.wait(50) assert room._unread_messages == 0 room.add_chat_message('Other', 'focused', 0) assert room._unread_messages == 0 room.chat_container.hide() room.add_chat_message('Other', 'hidden', 0) assert room._unread_messages == 1 room.clear_chat() assert room._unread_messages == 0 def test_popout_focus_and_wrapping(room, qtbot, monkeypatch): monkeypatch.setattr(QApplication, 'alert', Mock()) room.popout_chat() popout = room.popout_window qtbot.addWidget(popout) popout.activateWindow() room.chat_input.setFocus() qtbot.wait(100) room.add_chat_message('Other', 'Wrapped popout text ' * 40, 0) qtbot.wait(100) assert room._unread_messages == 0 bubble = room.chat_content_layout.itemAt(0).widget() assert bubble.text_lbl.height() >= bubble.text_lbl.heightForWidth(bubble.text_lbl.width()) room.activateWindow() room.setFocus() qtbot.wait(100) 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()