From 39a331450a3804b9bbb0079927984b414e080669 Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 23 Apr 2026 02:28:52 -0500 Subject: [PATCH] feat(tests): add backend test coverage with new repository server manager tests and additional web audio bridge scenarios --- tests/backend/test_map_manager_extended.py | 16 +- .../backend/test_repository_server_manager.py | 210 ++++++++++++ tests/backend/test_web_audio_bridge.py | 317 +++++++++++++++++- 3 files changed, 541 insertions(+), 2 deletions(-) create mode 100644 tests/backend/test_repository_server_manager.py diff --git a/tests/backend/test_map_manager_extended.py b/tests/backend/test_map_manager_extended.py index 09dd67f..e8ba06b 100644 --- a/tests/backend/test_map_manager_extended.py +++ b/tests/backend/test_map_manager_extended.py @@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch import pytest -from meshchatx.src.backend.map_manager import MapManager +from meshchatx.src.backend.map_manager import MAX_EXPORT_TILES, MapManager @pytest.fixture @@ -108,3 +108,17 @@ def test_start_export_status(mock_config, temp_dir): assert export_id == "test_id" status = mm.get_export_status(export_id) assert status["status"] == "starting" + + +def test_count_export_tiles_world_low_zoom(mock_config, temp_dir): + mm = MapManager(mock_config, temp_dir) + bbox = [-180, -85.051129, 180, 85.051129] + n = mm.count_export_tiles(bbox, 0, 4) + assert n > 0 + assert n < MAX_EXPORT_TILES + + +def test_count_export_tiles_dedupes(mock_config, temp_dir): + mm = MapManager(mock_config, temp_dir) + single = mm.count_export_tiles([0, 0, 0.0001, 0.0001], 2, 2) + assert single > 0 diff --git a/tests/backend/test_repository_server_manager.py b/tests/backend/test_repository_server_manager.py new file mode 100644 index 0000000..0fe79bb --- /dev/null +++ b/tests/backend/test_repository_server_manager.py @@ -0,0 +1,210 @@ +# SPDX-License-Identifier: 0BSD + +import time +import urllib.request +from subprocess import CompletedProcess +from unittest.mock import patch + +from meshchatx.src.backend.repository_server_manager import ( + RepositoryServerManager, + build_repository_index_html, + bundled_pip_targets, + download_bundled_wheels_to_directory, + meshchat_bundle_project_root, +) + + +def test_bundled_pip_targets_includes_extra_from_env(monkeypatch): + monkeypatch.setenv("MESHCHAT_REPOSITORY_EXTRA_PIP", "foo, bar") + names = bundled_pip_targets() + assert "foo" in names + assert "bar" in names + assert "rns" in names + assert "rnspure" in names + + +def test_seed_copies_bundled_wheels_from_public(tmp_path): + identity = tmp_path / "identity" + identity.mkdir() + public = tmp_path / "public" + bundled_pub = public / "repository-server-bundled" / "bundled" + bundled_pub.mkdir(parents=True) + (bundled_pub / "from_build.whl").write_bytes(b"wheeldata") + mgr = RepositoryServerManager(str(identity), public_dir=str(public)) + rows = mgr.list_entries() + bundled_names = [r["name"] for r in rows if r["source"] == "bundled"] + assert "from_build.whl" in bundled_names + + +def test_seed_skips_when_wheel_already_present(tmp_path): + identity = tmp_path / "identity" + identity.mkdir() + public = tmp_path / "public" + bundled_pub = public / "repository-server-bundled" / "bundled" + bundled_pub.mkdir(parents=True) + (bundled_pub / "same.whl").write_bytes(b"from_public") + dest = identity / "repository-server" / "bundled" + dest.mkdir(parents=True) + (dest / "same.whl").write_bytes(b"user_keeps") + RepositoryServerManager(str(identity), public_dir=str(public)) + assert (dest / "same.whl").read_bytes() == b"user_keeps" + + +def test_meshchat_bundle_project_root_exists(): + root = meshchat_bundle_project_root() + assert root is not None + assert (root / "pyproject.toml").is_file() + assert (root / "meshchatx").is_dir() + + +def test_build_repository_index_html_lists_files(tmp_path): + bundled = tmp_path / "bundled" + uploads = tmp_path / "uploads" + bundled.mkdir() + uploads.mkdir() + (bundled / "pkg.whl").write_bytes(b"x") + (uploads / "note.txt").write_bytes(b"ab") + html_out = build_repository_index_html(str(bundled), str(uploads)) + assert "MeshChatX repository" in html_out + assert "pkg.whl" in html_out + assert "note.txt" in html_out + assert 'href="bundled/' in html_out + assert 'href="uploads/' in html_out + + +def test_save_list_delete_upload(tmp_path): + mgr = RepositoryServerManager(str(tmp_path)) + ok, err = mgr.save_upload("test.whl", b"abc") + assert ok and err is None + rows = mgr.list_entries() + assert len(rows) == 1 + assert rows[0]["name"] == "test.whl" + assert rows[0]["source"] == "upload" + ok2, err2 = mgr.delete_upload("test.whl") + assert ok2 and err2 is None + assert mgr.list_entries() == [] + + +def test_save_rejects_bad_filename(tmp_path): + mgr = RepositoryServerManager(str(tmp_path)) + ok, err = mgr.save_upload("../evil.whl", b"x") + assert not ok + + +@patch( + "meshchatx.src.backend.repository_server_manager.download_bundled_wheels_to_directory" +) +def test_refresh_invokes_bundled_downloader(mock_dl, tmp_path): + mock_dl.return_value = {"ok": True, "downloaded": ["rns"], "failed": {}} + mgr = RepositoryServerManager(str(tmp_path)) + out = mgr.refresh_bundled_wheels() + assert out["ok"] is True + mock_dl.assert_called_once() + assert mock_dl.call_args.kwargs.get("pip") is None + + +@patch("meshchatx.src.backend.repository_server_manager._download_wheel_via_pypi_index") +@patch("meshchatx.src.backend.repository_server_manager.subprocess.run") +@patch("meshchatx.src.backend.repository_server_manager.shutil.which") +def test_download_bundled_wheels_to_directory( + mock_which, mock_run, mock_pypi, tmp_path, monkeypatch +): + monkeypatch.setenv("MESHCHAT_REPOSITORY_EXTRA_PIP", "") + mock_which.return_value = "/fake/pip3" + mock_pypi.return_value = (False, "offline") + + def fake_run(cmd, **kwargs): + assert cmd[0] == "/fake/pip3" + assert cmd[1] in ("download", "wheel") + assert cmd[2] == "--no-deps" + assert cmd[3] in ("-d", "-w") + return CompletedProcess(cmd, 0, "", "") + + mock_run.side_effect = fake_run + dest = tmp_path / "out" + out = download_bundled_wheels_to_directory(dest, "/fake/pip3") + assert out["ok"] is True + n = len(bundled_pip_targets()) + assert mock_run.call_count == n + assert mock_pypi.call_count == n - 1 + + +@patch("meshchatx.src.backend.repository_server_manager._download_wheel_via_pypi_index") +@patch("meshchatx.src.backend.repository_server_manager.subprocess.run") +@patch("meshchatx.src.backend.repository_server_manager.shutil.which") +def test_refresh_calls_pip_after_pypi_fails( + mock_which, mock_run, mock_pypi, tmp_path, monkeypatch +): + monkeypatch.setenv("MESHCHAT_REPOSITORY_EXTRA_PIP", "") + mock_which.return_value = "/fake/pip3" + mock_pypi.return_value = (False, "offline") + + def fake_run(cmd, **kwargs): + assert cmd[0] == "/fake/pip3" + assert cmd[1] in ("download", "wheel") + assert cmd[2] == "--no-deps" + assert cmd[3] in ("-d", "-w") + return CompletedProcess(cmd, 0, "", "") + + mock_run.side_effect = fake_run + mgr = RepositoryServerManager(str(tmp_path)) + out = mgr.refresh_bundled_wheels() + assert out["ok"] is True + n = len(bundled_pip_targets()) + assert mock_run.call_count == n + assert mock_pypi.call_count == n - 1 + + +def test_http_start_stop_and_status(tmp_path): + mgr = RepositoryServerManager(str(tmp_path)) + assert mgr.status()["http"]["running"] is False + first = mgr.start_http_server("127.0.0.1", 0) + assert first["ok"] is True + assert first["port"] > 0 + time.sleep(0.05) + st = mgr.status()["http"] + assert st["running"] is True + assert st["url"] == f"http://127.0.0.1:{first['port']}/" + assert st["last_port"] == first["port"] + second = mgr.start_http_server("127.0.0.1", first["port"]) + assert second["ok"] is False + assert second["error"] == "already_running" + mgr.stop_http_server() + assert mgr.status()["http"]["running"] is False + + +def test_http_invalid_host(tmp_path): + mgr = RepositoryServerManager(str(tmp_path)) + out = mgr.start_http_server(" ", 8787) + assert out["ok"] is False + assert out["error"] == "invalid_host" + + +def test_http_get_root(tmp_path): + mgr = RepositoryServerManager(str(tmp_path)) + mgr.save_upload("listed.whl", b"wheel") + out = mgr.start_http_server("127.0.0.1", 0) + assert out["ok"] is True + time.sleep(0.08) + url = out["url"] + try: + req = urllib.request.Request(url, method="GET") + with urllib.request.urlopen(req, timeout=5) as resp: + assert resp.status == 200 + body = resp.read().decode("utf-8") + assert "listed.whl" in body + assert "uploads/" in body + finally: + mgr.stop_http_server() + + +def test_http_restart_uses_last_listen(tmp_path): + mgr = RepositoryServerManager(str(tmp_path)) + a = mgr.start_http_server("127.0.0.1", 0) + assert a["ok"] is True + port = a["port"] + mgr.stop_http_server() + b = mgr.restart_http_server(None, None) + assert b["ok"] is True + assert b["port"] == port + mgr.stop_http_server() diff --git a/tests/backend/test_web_audio_bridge.py b/tests/backend/test_web_audio_bridge.py index 8462bd0..340488f 100644 --- a/tests/backend/test_web_audio_bridge.py +++ b/tests/backend/test_web_audio_bridge.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: 0BSD import asyncio +import json from unittest.mock import AsyncMock, MagicMock, patch import numpy as np @@ -32,6 +33,28 @@ def test_web_audio_source_pushes_frames(): assert len(sink.frames) == 1 +def test_web_audio_source_empty_pcm_does_not_push(): + sink = _DummySink() + src = WebAudioSource(target_frame_ms=60, sink=sink) + src.push_pcm(b"") + assert len(sink.frames) == 0 + + +def test_web_audio_source_respects_sink_can_receive_false(): + sink = MagicMock() + sink.can_receive.return_value = False + src = WebAudioSource(target_frame_ms=60, sink=sink) + src.push_pcm(np.zeros(8, dtype=np.int16).tobytes()) + sink.handle_frame.assert_not_called() + + +def test_web_audio_source_can_receive_and_handle_frame_are_safe(): + sink = _DummySink() + src = WebAudioSource(target_frame_ms=0, sink=sink) + assert src.can_receive() is True + src.handle_frame(None, None) + + def test_web_audio_sink_encodes_and_sends_bytes(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -47,6 +70,36 @@ def test_web_audio_sink_encodes_and_sends_bytes(): assert sent, "expected audio bytes to be queued for sending" +@pytest.mark.asyncio +async def test_web_audio_sink_forwards_non_numpy_frame_unchanged(): + sent = [] + + async def _send_bytes(data): + sent.append(data) + + sink = WebAudioSink(asyncio.get_running_loop(), _send_bytes) + sink.handle_frame(b"\xff\x00", MagicMock()) + await asyncio.sleep(0.02) + assert sent == [b"\xff\x00"] + + +@pytest.mark.asyncio +async def test_web_audio_sink_numpy_frame_clips_to_int16_range(): + sent = [] + + async def _send_bytes(data): + sent.append(data) + + sink = WebAudioSink(asyncio.get_running_loop(), _send_bytes) + arr = np.array([[2.0], [-2.0]], dtype=np.float32) + sink.handle_frame(arr, None) + await asyncio.sleep(0.02) + assert len(sent) == 1 + out = np.frombuffer(sent[0], dtype=np.int16) + assert out[0] == 32767 + assert out[1] == -32767 + + @pytest.mark.asyncio async def test_web_audio_bridge_lazy_loop(): """Test that WebAudioBridge retrieves the loop lazily to avoid startup crashes.""" @@ -78,6 +131,14 @@ def test_web_audio_config_disabled_without_config_manager(): assert not bridge.config_enabled() +def test_web_audio_config_enabled_false_without_flag_attribute(): + class _Cfg: + pass + + bridge = WebAudioBridge(None, _Cfg()) + assert bridge.config_enabled() is False + + def test_web_audio_allow_fallback_follows_config(): cfg = MagicMock() cfg.telephone_web_audio_allow_fallback.get.return_value = True @@ -87,6 +148,14 @@ def test_web_audio_allow_fallback_follows_config(): assert bridge.allow_fallback() is False +def test_web_audio_allow_fallback_false_without_flag_attribute(): + class _Cfg: + pass + + bridge = WebAudioBridge(None, _Cfg()) + assert bridge.allow_fallback() is False + + def test_web_audio_bridge_asyncutils_fallback(): """Test that WebAudioBridge falls back to AsyncUtils.main_loop if no loop is running.""" from meshchatx.src.backend.async_utils import AsyncUtils @@ -113,9 +182,255 @@ def test_attach_client_returns_false_without_active_call(): tele_mgr.telephone.active_call = None bridge = WebAudioBridge(tele_mgr, MagicMock()) - attached = bridge.attach_client(MagicMock()) + mock_client = MagicMock() + attached = bridge.attach_client(mock_client) assert attached is False + assert mock_client not in bridge.clients + + +class _TeleMgrNoTelephone: + """Telephone manager shape without a ``telephone`` attribute (edge case).""" + + +def test_tele_returns_none_when_manager_has_no_telephone(): + bridge = WebAudioBridge(_TeleMgrNoTelephone(), MagicMock()) + assert bridge._tele() is None + + +def test_push_client_frame_no_op_without_tx_source(): + bridge = WebAudioBridge(MagicMock(), MagicMock()) + bridge.tx_source = None + bridge.push_client_frame(b"ignored") + + +def test_push_client_frame_forwards_to_tx_source(): + bridge = WebAudioBridge(MagicMock(), MagicMock()) + mock_tx = MagicMock() + bridge.tx_source = mock_tx + bridge.push_client_frame(b"\x01\x02") + mock_tx.push_pcm.assert_called_once_with(b"\x01\x02") + + +@pytest.mark.asyncio +async def test_send_status_uses_tele_frame_ms(): + tele = MagicMock() + tele.target_frame_time_ms = 52 + tele_mgr = MagicMock() + tele_mgr.telephone = tele + bridge = WebAudioBridge(tele_mgr, MagicMock()) + client = AsyncMock() + await bridge.send_status(client) + payload = json.loads(client.send_str.await_args.args[0]) + assert payload == {"type": "web_audio.ready", "frame_ms": 52} + + +@pytest.mark.asyncio +async def test_send_status_defaults_frame_ms_when_missing(): + tele = MagicMock(spec=["active_call"]) + tele_mgr = MagicMock() + tele_mgr.telephone = tele + bridge = WebAudioBridge(tele_mgr, MagicMock()) + client = AsyncMock() + await bridge.send_status(client) + payload = json.loads(client.send_str.await_args.args[0]) + assert payload["type"] == "web_audio.ready" + assert payload["frame_ms"] == 60 + + +@pytest.mark.asyncio +async def test_send_status_defaults_when_no_telephone(): + bridge = WebAudioBridge(_TeleMgrNoTelephone(), MagicMock()) + client = AsyncMock() + await bridge.send_status(client) + payload = json.loads(client.send_str.await_args.args[0]) + assert payload == {"type": "web_audio.ready", "frame_ms": 60} + + +@patch("meshchatx.src.backend.web_audio_bridge.Pipeline") +def test_attach_client_success_wires_telephony_and_dedupes_client(mock_pipeline_cls): + """LXST ``Pipeline`` validates sources; mock it so we only assert bridge wiring.""" + mock_receive_pipeline = MagicMock() + mock_pipeline_cls.return_value = mock_receive_pipeline + tele = MagicMock() + tele.active_call = object() + tele.target_frame_time_ms = 48 + tele.audio_input = MagicMock() + tele.transmit_mixer = MagicMock() + tele.transmit_mixer.should_run = False + tele.audio_output = MagicMock() + tele.receive_mixer = MagicMock() + tele.receive_pipeline = None + tele_mgr = MagicMock() + tele_mgr.telephone = tele + bridge = WebAudioBridge(tele_mgr, MagicMock()) + client = MagicMock() + + assert bridge.attach_client(client) is True + assert client in bridge.clients + assert isinstance(bridge.tx_source, WebAudioSource) + assert tele.audio_input is bridge.tx_source + tele.transmit_mixer.start.assert_called_once() + assert bridge.rx_tee is not None + assert tele.audio_output is bridge.rx_tee + assert tele.receive_pipeline is mock_receive_pipeline + mock_receive_pipeline.start.assert_called_once() + mock_pipeline_cls.assert_called_once() + call_kw = mock_pipeline_cls.call_args.kwargs + assert call_kw["source"] is tele.receive_mixer + assert call_kw["sink"] is bridge.rx_tee + + assert bridge.attach_client(client) is True + assert len(bridge.clients) == 1 + mock_pipeline_cls.assert_called_once() + + +@patch("meshchatx.src.backend.web_audio_bridge.Pipeline") +def test_attach_rx_tee_includes_base_sink_when_audio_output_exists(mock_pipeline_cls): + mock_pipeline_cls.return_value = MagicMock() + base_out = MagicMock() + tele = MagicMock() + tele.active_call = object() + tele.target_frame_time_ms = 60 + tele.audio_input = None + tele.transmit_mixer = MagicMock() + tele.transmit_mixer.should_run = True + tele.audio_output = base_out + tele.receive_mixer = MagicMock() + tele.receive_pipeline = None + tele_mgr = MagicMock() + tele_mgr.telephone = tele + bridge = WebAudioBridge(tele_mgr, MagicMock()) + assert bridge.attach_client(MagicMock()) is True + assert len(bridge.rx_tee.sinks) == 2 + assert bridge.rx_tee.sinks[0] is base_out + assert bridge.rx_tee.sinks[1] is bridge.rx_sink + + +@patch("meshchatx.src.backend.web_audio_bridge.Pipeline") +def test_attach_rx_tee_single_sink_when_no_base_audio_output(mock_pipeline_cls): + mock_pipeline_cls.return_value = MagicMock() + tele = MagicMock() + tele.active_call = object() + tele.target_frame_time_ms = 60 + tele.audio_input = None + tele.transmit_mixer = MagicMock() + tele.transmit_mixer.should_run = True + tele.audio_output = None + tele.receive_mixer = MagicMock() + tele.receive_pipeline = None + tele_mgr = MagicMock() + tele_mgr.telephone = tele + bridge = WebAudioBridge(tele_mgr, MagicMock()) + assert bridge.attach_client(MagicMock()) is True + assert len(bridge.rx_tee.sinks) == 1 + assert bridge.rx_tee.sinks[0] is bridge.rx_sink + + +def test_attach_client_returns_false_when_telephone_is_none(): + tele_mgr = MagicMock() + tele_mgr.telephone = None + bridge = WebAudioBridge(tele_mgr, MagicMock()) + c = MagicMock() + assert bridge.attach_client(c) is False + assert c not in bridge.clients + + +def test_detach_client_ignores_unknown_client(): + bridge = WebAudioBridge(MagicMock(), MagicMock()) + bridge.clients.add(MagicMock()) + with patch.object(bridge, "_restore_host_audio") as restore: + bridge.detach_client(MagicMock()) + restore.assert_not_called() + + +def test_detach_non_last_client_does_not_restore_host_audio(): + cfg = MagicMock() + cfg.telephone_web_audio_allow_fallback.get.return_value = True + bridge = WebAudioBridge(MagicMock(), cfg) + c1, c2 = MagicMock(), MagicMock() + bridge.clients.update({c1, c2}) + with patch.object(bridge, "_restore_host_audio") as restore: + bridge.detach_client(c1) + restore.assert_not_called() + assert c2 in bridge.clients + + +def test_detach_last_client_restores_when_allow_fallback_enabled(): + cfg = MagicMock() + cfg.telephone_web_audio_allow_fallback.get.return_value = True + bridge = WebAudioBridge(MagicMock(), cfg) + c = MagicMock() + bridge.clients.add(c) + with patch.object(bridge, "_restore_host_audio") as restore: + bridge.detach_client(c) + restore.assert_called_once() + assert len(bridge.clients) == 0 + + +def test_detach_last_client_skips_restore_when_allow_fallback_disabled(): + cfg = MagicMock() + cfg.telephone_web_audio_allow_fallback.get.return_value = False + bridge = WebAudioBridge(MagicMock(), cfg) + c = MagicMock() + bridge.clients.add(c) + with patch.object(bridge, "_restore_host_audio") as restore: + bridge.detach_client(c) + restore.assert_not_called() + + +def test_on_call_ended_clears_clients_and_pipeline_handles(): + bridge = WebAudioBridge(MagicMock(), MagicMock()) + bridge.clients.update({MagicMock(), MagicMock()}) + bridge.tx_source = MagicMock() + bridge.rx_sink = MagicMock() + bridge.rx_tee = MagicMock() + bridge.on_call_ended() + assert len(bridge.clients) == 0 + assert bridge.tx_source is None + assert bridge.rx_sink is None + assert bridge.rx_tee is None + + +def test_restore_host_audio_no_telephone_is_safe(): + bridge = WebAudioBridge(MagicMock(), MagicMock()) + bridge.telephone_manager = MagicMock() + bridge.telephone_manager.telephone = None + bridge.rx_tee = MagicMock() + bridge._restore_host_audio() + + +@patch("meshchatx.src.backend.web_audio_bridge.RNS.log") +def test_ensure_remote_tx_swallows_source_init_failure(mock_log): + tele = MagicMock() + tele.transmit_mixer = MagicMock() + tele.transmit_mixer.should_run = True + bridge = WebAudioBridge(MagicMock(), MagicMock()) + bridge.tx_source = None + with patch( + "meshchatx.src.backend.web_audio_bridge.WebAudioSource", + side_effect=RuntimeError("init failed"), + ): + bridge._ensure_remote_tx(tele) + assert bridge.tx_source is None + mock_log.assert_called() + + +@patch("meshchatx.src.backend.web_audio_bridge.RNS.log") +def test_ensure_rx_tee_swallows_web_audio_sink_init_failure(mock_log): + tele = MagicMock() + tele.audio_output = None + tele.receive_mixer = MagicMock() + tele.receive_pipeline = None + bridge = WebAudioBridge(MagicMock(), MagicMock()) + bridge.rx_sink = None + with patch( + "meshchatx.src.backend.web_audio_bridge.WebAudioSink", + side_effect=RuntimeError("sink init failed"), + ): + bridge._ensure_rx_tee(tele) + assert bridge.rx_sink is None + mock_log.assert_called() @pytest.mark.asyncio