changeset 1551:00d04f51787e

tests: add `calls` unit tests: fix 423
author Goffi <goffi@goffi.org>
date Wed, 09 Aug 2023 00:22:18 +0200
parents 4b0464b2a12b
children c62027660ec1
files pyproject.toml tests/browser/unit/test_calls.py
diffstat 2 files changed, 327 insertions(+), 1 deletions(-) [+]
line wrap: on
line diff
--- a/pyproject.toml	Wed Aug 09 00:22:18 2023 +0200
+++ b/pyproject.toml	Wed Aug 09 00:22:18 2023 +0200
@@ -59,7 +59,9 @@
 [tool.hatch.envs.dev]
 dependencies = [
     "ipdb",
-    "pudb"
+    "pudb",
+    "pytest",
+    "pytest-asyncio"
 ]
 
 [tool.hatch.metadata]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/browser/unit/test_calls.py	Wed Aug 09 00:22:18 2023 +0200
@@ -0,0 +1,324 @@
+#!/usr/bin/env python3
+
+# Libervia Web
+# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+import pytest
+from unittest.mock import MagicMock, AsyncMock
+
+# Mock all non-stdlib modules
+
+
+class CancelError(Exception):
+    pass
+
+
+sys.modules["bridge"] = MagicMock(AsyncBridge=AsyncMock)
+browser = sys.modules["browser"] = MagicMock(
+    aio=MagicMock(), console=MagicMock(), document=MagicMock(), window=MagicMock()
+)
+sys.modules["cache"] = MagicMock(cache=MagicMock())
+dialog = sys.modules["dialog"] = MagicMock()
+dialog.CancelError = CancelError
+confirm_mock = dialog.Confirm.return_value = MagicMock()
+confirm_ashow = confirm_mock.ashow = AsyncMock()
+sys.modules["jid"] = MagicMock(JID=MagicMock)
+sys.modules["jid_search"] = MagicMock(JidSearch=MagicMock())
+sys.modules["loading"] = MagicMock()
+sys.modules["template"] = MagicMock(Template=MagicMock())
+sys.modules["webrtc"] = MagicMock(WebRTC=MagicMock())
+
+# After mocking the modules, import the actual module
+from libervia.web.pages.calls import _browser as calls
+
+calls.cache.fill_identities = AsyncMock()
+
+
+class TestCallUI:
+    @pytest.fixture
+    def call_ui(self):
+        ui = calls.CallUI()
+        ui.webrtc.sid = None
+        ui.call_status_tpl = MagicMock()
+        ui.call_status_wrapper_elt = MagicMock()
+        ui.call_box_elt = MagicMock()
+        ui._update_call_button = MagicMock()
+        ui.audio_player_elt = MagicMock()
+        ui.switch_mode = MagicMock()
+        ui.incoming_call_dialog_elt = MagicMock()
+
+        ui.call_status_wrapper_elt.appended_elements = []
+
+        # Mock the `<=` operator for call_status_wrapper_elt
+        def mock_append_child(parent, child):
+            # Store the child for later assertion
+            parent.appended_elements.append(child)
+            return child
+
+        ui.call_status_wrapper_elt.__le__ = mock_append_child
+
+        return ui
+
+    def test_status(self, call_ui):
+        """Allowed values are set"""
+        # Set initial status
+        call_ui._status = None
+        assert call_ui.status == None
+
+        # Change status
+        call_ui.status = "dialing"
+        assert call_ui.status == "dialing"
+
+    def test_disallowed_status(self, call_ui):
+        """Not allowed status raises an Exception"""
+        with pytest.raises(Exception, match="this status is not allowed"):
+            call_ui.status = "disallowed_status"
+
+    def test_status_element_update(self, call_ui):
+        """A status change update the status element"""
+        call_ui._callee = "test_callee"
+        mock_cache = calls.cache
+        mock_cache.identities = {"test_callee": {"nicknames": ["Test Callee Nickname"]}}
+
+        status_elt_mock = MagicMock()
+        call_ui.call_status_tpl.get_elt.return_value = status_elt_mock
+
+        call_ui.status = "dialing"
+        call_ui.call_status_tpl.get_elt.assert_called_with(
+            {
+                "entity": "test_callee",
+                "status": "dialing",
+                "name": "Test Callee Nickname",
+            }
+        )
+        assert call_ui.call_status_wrapper_elt.appended_elements[-1] == status_elt_mock
+
+    def test_status_element_updates_with_unknown_callee(self, call_ui):
+        """A status change update the element even if callee is not known"""
+        call_ui._callee = "test_callee"
+        mock_cache = calls.cache
+        mock_cache.identities = {}
+
+        call_ui.status = "dialing"
+        call_ui.call_status_tpl.get_elt.assert_called_with(
+            {"entity": "test_callee", "status": "dialing", "name": "test_callee"}
+        )
+
+    def test_call_mode(self, call_ui):
+        call_ui.call_mode = calls.AUDIO
+        assert call_ui.call_mode == calls.AUDIO
+        call_ui._update_call_button.assert_called_once()
+        call_ui.call_box_elt.select.assert_called_with(".is-video-only")
+
+        call_ui.call_mode = calls.VIDEO
+        assert call_ui.call_mode == calls.VIDEO
+        call_ui.call_box_elt.select.assert_called_with(".is-video-only")
+
+    def test_call_mode_invalid_mode(self, call_ui):
+        with pytest.raises(ValueError, match="Invalid call mode"):
+            call_ui.call_mode = "invalid_mode"
+
+    def test_switch_call_mode(self, call_ui):
+        call_ui._call_mode = calls.AUDIO
+        call_ui.switch_call_mode(None)
+        assert call_ui.call_mode == calls.VIDEO
+
+        call_ui._call_mode = calls.VIDEO
+        call_ui.switch_call_mode(None)
+        assert call_ui.call_mode == calls.AUDIO
+
+    @pytest.mark.asyncio
+    async def test_ignore_new_call_when_call_in_progress(self, call_ui):
+        """Ignoring new incoming call when a call is already in progress."""
+        call_ui.sid = "some_sid"
+        await call_ui.on_action_new(
+            {
+                "from_jid": "test@example.org",
+                "session_id": "session123",
+                "sub_type": "audio",
+            },
+            "action_id",
+        )
+        call_ui.audio_player_elt.play.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_action_new_fill_identities(self, call_ui):
+        """Filling identities from cache with the correct JID."""
+        calls.cache.identities = {"test@example.org": {"nicknames": ["Test User"]}}
+
+        await call_ui.on_action_new(
+            {
+                "from_jid": "test@example.org",
+                "session_id": "session123",
+                "sub_type": "audio",
+            },
+            "action_id",
+        )
+        calls.cache.fill_identities.assert_called_once_with(["test@example.org"])
+
+    @pytest.mark.asyncio
+    async def test_incoming_call_triggers_audio(self, call_ui):
+        """Incoming call triggering the play action on the audio element."""
+        await call_ui.on_action_new(
+            {
+                "from_jid": "test@example.org",
+                "session_id": "session123",
+                "sub_type": "audio",
+            },
+            "action_id",
+        )
+        call_ui.audio_player_elt.play.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_user_accepts_call(self, call_ui):
+        """User accepting the incoming call."""
+        confirm_ashow.return_value = True
+        await call_ui.on_action_new(
+            {
+                "from_jid": "test@example.org",
+                "session_id": "session123",
+                "sub_type": "video",
+            },
+            "action_id",
+        )
+        call_ui.switch_mode.assert_called_once_with("call")
+
+    @pytest.mark.asyncio
+    async def test_user_rejects_call(self, call_ui):
+        """User rejecting the incoming call."""
+        confirm_ashow.return_value = False
+        await call_ui.on_action_new(
+            {
+                "from_jid": "test@example.org",
+                "session_id": "session123",
+                "sub_type": "video",
+            },
+            "action_id",
+        )
+        call_ui.switch_mode.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_call_gets_cancelled(self, call_ui):
+        """Incoming call gets cancelled."""
+        confirm_ashow.side_effect = dialog.CancelError
+        await call_ui.on_action_new(
+            {
+                "from_jid": "test@example.org",
+                "session_id": "session123",
+                "sub_type": "audio",
+            },
+            "action_id",
+        )
+        assert call_ui.sid is None
+        call_ui.switch_mode.assert_not_called()
+
+    def test_toggle_fullscreen_enter(self, call_ui, monkeypatch):
+        """Entering fullscreen mode for video elements."""
+        monkeypatch.setattr(browser.document, "fullscreenElement", None)
+        monkeypatch.setattr(call_ui.call_box_elt, "requestFullscreen", MagicMock())
+
+        btn_mock_show = MagicMock()
+        monkeypatch.setattr("dialog.notification.show", btn_mock_show)
+        btn_mock_add = MagicMock()
+        btn_mock_remove = MagicMock()
+
+        full_screen_mock = MagicMock(classList=MagicMock(add=btn_mock_add))
+        exit_full_screen_mock = MagicMock(classList=MagicMock(remove=btn_mock_remove))
+
+        mock_document = {
+            "full_screen_btn": full_screen_mock,
+            "exit_full_screen_btn": exit_full_screen_mock,
+        }
+
+        getitem_mock = MagicMock()
+        monkeypatch.setattr(browser.document, "__getitem__", getitem_mock)
+        getitem_mock.side_effect = mock_document.__getitem__
+
+        call_ui.toggle_fullscreen(True)
+
+        call_ui.call_box_elt.requestFullscreen.assert_called_once()
+        btn_mock_add.assert_called_with("is-hidden")
+        btn_mock_remove.assert_called_with("is-hidden")
+        btn_mock_show.assert_not_called()
+
+    def test_toggle_fullscreen_exit(self, call_ui, monkeypatch):
+        """Exiting fullscreen mode for video elements."""
+        # As it's not None, we're in fullscreen
+        monkeypatch.setattr(browser.document, "fullscreenElement", "mocked_value")
+        monkeypatch.setattr(browser.document, "exitFullscreen", MagicMock())
+
+        btn_mock_show = MagicMock()
+        monkeypatch.setattr("dialog.notification.show", btn_mock_show)
+        btn_mock_add = MagicMock()
+        btn_mock_remove = MagicMock()
+
+        full_screen_mock = MagicMock(classList=MagicMock(remove=btn_mock_remove))
+        exit_full_screen_mock = MagicMock(classList=MagicMock(add=btn_mock_add))
+
+        mock_document = {
+            "full_screen_btn": full_screen_mock,
+            "exit_full_screen_btn": exit_full_screen_mock,
+        }
+
+        getitem_mock = MagicMock()
+        monkeypatch.setattr(browser.document, "__getitem__", getitem_mock)
+        getitem_mock.side_effect = mock_document.__getitem__
+
+        call_ui.toggle_fullscreen(False)
+
+        browser.document.exitFullscreen.assert_called_once()
+        btn_mock_add.assert_called_with("is-hidden")
+        btn_mock_remove.assert_called_with("is-hidden")
+        btn_mock_show.assert_not_called()
+
+    def test_toggle_audio_mute(self, call_ui, monkeypatch):
+        """Toggle audio mute."""
+        mock_toggle_audio_mute = MagicMock(
+            return_value=True
+        )
+        monkeypatch.setattr(call_ui.webrtc, "toggle_audio_mute", mock_toggle_audio_mute)
+
+        btn_mock_show = MagicMock()
+        monkeypatch.setattr("dialog.notification.show", btn_mock_show)
+
+        evt_mock = MagicMock(currentTarget=MagicMock(classList=MagicMock()))
+
+        call_ui.toggle_audio_mute(evt_mock)
+
+        mock_toggle_audio_mute.assert_called_once()
+        evt_mock.currentTarget.classList.remove.assert_called_with("is-success")
+        evt_mock.currentTarget.classList.add.assert_called_with("muted", "is-warning")
+        btn_mock_show.assert_called_once_with("audio is now muted", level="info", delay=2)
+
+    def test_toggle_video_mute(self, call_ui, monkeypatch):
+        """Toggle video mute."""
+        mock_toggle_video_mute = MagicMock(
+            return_value=True
+        )
+        monkeypatch.setattr(call_ui.webrtc, "toggle_video_mute", mock_toggle_video_mute)
+
+        btn_mock_show = MagicMock()
+        monkeypatch.setattr("dialog.notification.show", btn_mock_show)
+
+        evt_mock = MagicMock(currentTarget=MagicMock(classList=MagicMock()))
+
+        call_ui.toggle_video_mute(evt_mock)
+
+        mock_toggle_video_mute.assert_called_once()
+        evt_mock.currentTarget.classList.remove.assert_called_with("is-success")
+        evt_mock.currentTarget.classList.add.assert_called_with("muted", "is-warning")
+        btn_mock_show.assert_called_once_with("video is now muted", level="info", delay=2)