feat(setup): add new packages 'lxmfy' and 'websockets' to cx_setup.py; refactor JSON output handling in build_community_interfaces_json.py; improve docstring clarity in move_wheels.py; update datetime import in slsa-predicate.py; enhance performance benchmarks with clearer argument passing; and refactor various test cases for improved readability and consistency.

This commit is contained in:
Ivan committed 2026-04-14 19:36:10 -05:00
1 parent 61ca84990c
commit 66412c7438
38 files changed
+1384 -101

No files matched your search

+2
View File
@@ -34,6 +34,8 @@ packages = [
"RNS.Interfaces",
"LXMF",
"LXST",
"lxmfy",
"websockets",
"pycparser",
"cffi",
"ply",
+2 -1
View File
@@ -85,7 +85,8 @@ def main() -> int:
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8",
json.dumps(doc, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
print(f"Wrote {len(out_list)} interfaces to {args.output}")
return 0
+2 -2
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import json
import os
from datetime import datetime, timezone
from datetime import UTC, datetime
def _source_uri() -> str:
@@ -55,7 +55,7 @@ def main() -> None:
run_id = os.environ.get("GITHUB_RUN_ID", "")
attempt = os.environ.get("GITHUB_RUN_ATTEMPT", "1")
workflow = os.environ.get("GITHUB_WORKFLOW", "")
started = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
started = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
internal = {}
if workflow:
+3 -2
View File
@@ -1,5 +1,6 @@
"""Move Poetry-built wheels from dist/ to python-dist/ to avoid conflicts
with Electron build artifacts.
"""Move Poetry-built wheels from ``dist/`` into ``python-dist/``.
Avoids filename clashes with Electron build outputs.
"""
import shutil
+2 -1
View File
@@ -177,7 +177,8 @@ class PerformanceBenchmarker:
def run_trim():
for _ in range(runs):
self.db.announces.trim_announces_for_aspect(
aspect, max(1, seed_count // 2)
aspect,
max(1, seed_count // 2),
)
self.record_benchmark(
+7 -1
View File
@@ -65,7 +65,13 @@ def test_count_matches_list_for_no_filters(dao):
def test_search_filters(dao):
ih = _id_hash()
dao.insert(
ih, "192.168.99.1", "UniqueSearchUA", LOGIN_PATH, "POST", "success", "detail-x",
ih,
"192.168.99.1",
"UniqueSearchUA",
LOGIN_PATH,
"POST",
"success",
"detail-x",
)
found = dao.list_attempts(search="UniqueSearchUA", limit=50)
assert any(r["user_agent"] == "UniqueSearchUA" for r in found)
@@ -30,7 +30,10 @@ from meshchatx.src.backend.database.access_attempts import (
def _make_req(
ip: str, ua: str, method: str = "POST", xff: str | None = None,
ip: str,
ua: str,
method: str = "POST",
xff: str | None = None,
) -> SimpleNamespace:
h = {"User-Agent": ua}
if xff is not None:
+6 -1
View File
@@ -54,7 +54,12 @@ def test_announce_max_stored_config_fuzz(max_stored):
identity.get_public_key.return_value = b"pub_key"
manager.upsert_announce(
reticulum, identity, b"new_dest", "lxmf.delivery", b"app_data", b"packet",
reticulum,
identity,
b"new_dest",
"lxmf.delivery",
b"app_data",
b"packet",
)
mock_db.announces.upsert_announce.assert_called_once()
+24 -4
View File
@@ -39,7 +39,12 @@ def test_trim_called_when_over_max_stored(mock_db, mock_config):
identity.get_public_key.return_value = b"pub_key"
manager.upsert_announce(
reticulum, identity, b"new_dest", "lxmf.delivery", b"app_data", b"packet_hash",
reticulum,
identity,
b"new_dest",
"lxmf.delivery",
b"app_data",
b"packet_hash",
)
mock_db.announces.upsert_announce.assert_called_once()
@@ -57,7 +62,12 @@ def test_no_trim_without_config(mock_db):
identity.get_public_key.return_value = b"pub_key"
manager.upsert_announce(
reticulum, identity, b"dest", "lxmf.delivery", b"app_data", b"packet",
reticulum,
identity,
b"dest",
"lxmf.delivery",
b"app_data",
b"packet",
)
mock_db.announces.upsert_announce.assert_called_once()
@@ -74,7 +84,12 @@ def test_max_stored_none_skips_trim(mock_db, mock_config):
identity.get_public_key.return_value = b"pub_key"
manager.upsert_announce(
reticulum, identity, b"dest", "lxmf.delivery", b"app_data", b"packet",
reticulum,
identity,
b"dest",
"lxmf.delivery",
b"app_data",
b"packet",
)
mock_db.announces.trim_announces_for_aspect.assert_not_called()
@@ -147,7 +162,12 @@ def test_announce_handles_none_packet_hash(mock_db):
identity.get_public_key.return_value = b"pub_key"
manager.upsert_announce(
reticulum, identity, b"dest", "lxmf.delivery", b"app_data", None,
reticulum,
identity,
b"dest",
"lxmf.delivery",
b"app_data",
None,
)
mock_db.announces.upsert_announce.assert_called_once()
@@ -26,7 +26,12 @@ def test_upsert_announce(mock_db):
identity.get_public_key.return_value = b"pub_key"
manager.upsert_announce(
reticulum, identity, b"dest_hash", "aspect", b"app_data", b"packet_hash",
reticulum,
identity,
b"dest_hash",
"aspect",
b"app_data",
b"packet_hash",
)
mock_db.announces.upsert_announce.assert_called_once()
@@ -54,7 +59,9 @@ def test_get_filtered_announces_count(mock_db):
manager = AnnounceManager(mock_db)
mock_db.provider.fetchone.return_value = {"count": 5}
count = manager.get_filtered_announces_count(
aspect="test", query="q", blocked_identity_hashes=["b1"],
aspect="test",
query="q",
blocked_identity_hashes=["b1"],
)
assert count == 5
@@ -52,7 +52,8 @@ def test_archive_page_enforce_max_versions(mock_db):
# Should delete the 6th version (index 5)
mock_db.provider.execute.assert_any_call(
"DELETE FROM archived_pages WHERE id = ?", (6,),
"DELETE FROM archived_pages WHERE id = ?",
(6,),
)
@@ -69,5 +70,6 @@ def test_archive_page_enforce_storage_limit(mock_db):
manager.archive_page("dest", "/path", "content", max_storage_gb=1)
mock_db.provider.execute.assert_any_call(
"DELETE FROM archived_pages WHERE id = ?", (10,),
"DELETE FROM archived_pages WHERE id = ?",
(10,),
)
@@ -202,7 +202,8 @@ def test_transform_tcp_with_backbone_in_config_and_identity():
st.fixed_dictionaries(
{
"id": st.one_of(
st.none(), st.integers(min_value=-1000, max_value=10000),
st.none(),
st.integers(min_value=-1000, max_value=10000),
),
"name": st.text(max_size=40),
"type": st.sampled_from(["backbone", "tcp", "i2p", "rnode", ""]),
+8 -3
View File
@@ -111,7 +111,9 @@ class TestConcurrencyStress(unittest.TestCase):
"SELECT COUNT(*) as count FROM lxmf_messages",
)["count"]
self.assertEqual(
total, 5 * 50, "Total messages inserted doesn't match expected count",
total,
5 * 50,
"Total messages inserted doesn't match expected count",
)
print(f"Stress test completed. Total messages inserted: {total}")
@@ -132,7 +134,8 @@ class TestConcurrencyStress(unittest.TestCase):
id_thread = threading.Thread(target=identity_worker)
db_thread = threading.Thread(
target=self.db_writer_worker, args=("id_collision",),
target=self.db_writer_worker,
args=("id_collision",),
)
id_thread.start()
@@ -152,7 +155,9 @@ class TestConcurrencyStress(unittest.TestCase):
"SELECT COUNT(*) as count FROM lxmf_messages",
)["count"]
self.assertEqual(
total_messages, 50, "Should have inserted 50 messages during collision test",
total_messages,
50,
"Should have inserted 50 messages during collision test",
)
+14 -3
View File
@@ -226,7 +226,12 @@ class TestMiscDAOFuzzing:
max_examples=40,
)
def test_add_notification_never_crashes(
self, db, ntype, remote_hash, title, content,
self,
db,
ntype,
remote_hash,
title,
content,
):
db.misc.add_notification(ntype, remote_hash, title, content)
notifications = db.misc.get_notifications()
@@ -463,7 +468,9 @@ class TestMapDrawingsDAOFuzzing:
st.builds(
json.dumps,
st.dictionaries(
st.text(max_size=10), st.text(max_size=50), max_size=10,
st.text(max_size=10),
st.text(max_size=50),
max_size=10,
),
),
),
@@ -572,7 +579,11 @@ class TestMessageHandlerFuzzing:
max_examples=40,
)
def test_get_conversation_messages_never_crashes(
self, handler, dest, after_id, before_id,
self,
handler,
dest,
after_id,
before_id,
):
results = handler.get_conversation_messages(
"local_hash",
+12 -5
View File
@@ -17,7 +17,9 @@ class TestDatabaseMigration(unittest.TestCase):
self.identity_hash = "deadbeef"
self.legacy_config_dir = os.path.join(self.test_dir, "legacy_config")
self.legacy_db_subdir = os.path.join(
self.legacy_config_dir, "identities", self.identity_hash,
self.legacy_config_dir,
"identities",
self.identity_hash,
)
os.makedirs(self.legacy_db_subdir, exist_ok=True)
self.legacy_db_path = os.path.join(self.legacy_db_subdir, "database.db")
@@ -119,12 +121,15 @@ class TestDatabaseMigration(unittest.TestCase):
def test_migration_evolution(self):
migrator = LegacyMigrator(
self.db.provider, self.legacy_config_dir, self.identity_hash,
self.db.provider,
self.legacy_config_dir,
self.identity_hash,
)
# Check if should migrate
self.assertTrue(
migrator.should_migrate(), "Should detect legacy database for migration",
migrator.should_migrate(),
"Should detect legacy database for migration",
)
# Perform migration
@@ -136,7 +141,8 @@ class TestDatabaseMigration(unittest.TestCase):
print(f"Config rows: {config_rows}")
config_val = self.db.provider.fetchone(
"SELECT value FROM config WHERE key = ?", ("legacy_key",),
"SELECT value FROM config WHERE key = ?",
("legacy_key",),
)
self.assertIsNotNone(config_val, "legacy_key should have been migrated")
self.assertEqual(config_val["value"], "legacy_value")
@@ -147,7 +153,8 @@ class TestDatabaseMigration(unittest.TestCase):
self.assertEqual(ann_count, 1)
msg = self.db.provider.fetchone(
"SELECT * FROM lxmf_messages WHERE hash = ?", ("msg1",),
"SELECT * FROM lxmf_messages WHERE hash = ?",
("msg1",),
)
self.assertIsNotNone(msg)
self.assertEqual(msg["title"], "Old Title")
+6 -2
View File
@@ -128,7 +128,9 @@ def test_contacts_only_accepts_matching_contact(policy_app):
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_once_with(
CALLER_HASH_HEX,
)
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(caller)
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(
caller
)
policy_app.current_context.telephone_manager.telephone.hangup.assert_not_called()
async_utils.run_async.assert_called_once()
@@ -175,7 +177,9 @@ def test_when_policy_off_stranger_rings(policy_app):
_run_incoming(policy_app, caller)
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_not_called()
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(caller)
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(
caller
)
async_utils.run_async.assert_called_once()
+5 -2
View File
@@ -50,7 +50,9 @@ class TestIntegrityManagerExtensive(unittest.TestCase):
f.write(bytes(range(256)))
# log2(256) = 8
self.assertAlmostEqual(
self.manager._calculate_entropy(max_entropy_file), 8.0, places=5,
self.manager._calculate_entropy(max_entropy_file),
8.0,
places=5,
)
@settings(suppress_health_check=[HealthCheck.too_slow], deadline=None)
@@ -125,7 +127,8 @@ class TestIntegrityManagerExtensive(unittest.TestCase):
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.touch()
self.assertTrue(
self.manager._should_ignore(str(rel_path)), f"Failed to ignore {v}",
self.manager._should_ignore(str(rel_path)),
f"Failed to ignore {v}",
)
def test_critical_file_protection(self):
+26 -18
View File
@@ -1,5 +1,4 @@
"""
Integration tests for LXMF messaging and Reticulum communication.
"""Integration tests for LXMF messaging and Reticulum communication.
Covers stamp proof-of-work, message packing/unpacking, signature validation,
delivery pipelines (direct, opportunistic, propagated), propagation node
@@ -86,8 +85,8 @@ def _cleanup():
# 1. Stamp proof-of-work (in-process, no Reticulum needed)
# ────────────────────────────────────────────────────────────────
class TestStampSolving:
class TestStampSolving:
def test_workblock_deterministic(self):
material = os.urandom(32)
wb1 = LXStamper.stamp_workblock(material, expand_rounds=10)
@@ -137,19 +136,22 @@ class TestStampSolving:
def test_propagation_node_stamp_rounds(self):
mid = os.urandom(32)
stamp, value = LXStamper.generate_stamp(
mid, stamp_cost=2,
mid,
stamp_cost=2,
expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PN,
)
assert stamp is not None
wb = LXStamper.stamp_workblock(
mid, expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PN,
mid,
expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PN,
)
assert LXStamper.stamp_valid(stamp, 2, wb)
def test_peering_key_generation_and_validation(self):
peer_id = os.urandom(32)
stamp, _ = LXStamper.generate_stamp(
peer_id, stamp_cost=2,
peer_id,
stamp_cost=2,
expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PEERING,
)
assert LXStamper.validate_peering_key(peer_id, stamp, 2)
@@ -158,18 +160,21 @@ class TestStampSolving:
peer_a = os.urandom(32)
peer_b = os.urandom(32)
stamp, _ = LXStamper.generate_stamp(
peer_a, stamp_cost=8,
peer_a,
stamp_cost=8,
expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PEERING,
)
assert not LXStamper.validate_peering_key(peer_b, stamp, 8)
def test_pn_stamp_valid_transient_data(self):
from LXMF.LXMessage import LXMessage
overhead = LXMessage.LXMF_OVERHEAD + LXStamper.STAMP_SIZE
fake_lxm = os.urandom(overhead + 64)
t_id = RNS.Identity.full_hash(fake_lxm)
stamp, _ = LXStamper.generate_stamp(
t_id, stamp_cost=2,
t_id,
stamp_cost=2,
expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PN,
)
td = fake_lxm + stamp
@@ -181,6 +186,7 @@ class TestStampSolving:
def test_pn_stamp_rejected_bad_stamp(self):
from LXMF.LXMessage import LXMessage
overhead = LXMessage.LXMF_OVERHEAD + LXStamper.STAMP_SIZE
fake_lxm = os.urandom(overhead + 64)
bad_stamp = os.urandom(LXStamper.STAMP_SIZE)
@@ -190,13 +196,15 @@ class TestStampSolving:
def test_pn_stamp_batch_validation(self):
from LXMF.LXMessage import LXMessage
overhead = LXMessage.LXMF_OVERHEAD + LXStamper.STAMP_SIZE
items = []
for _ in range(5):
fake = os.urandom(overhead + 64)
t_id = RNS.Identity.full_hash(fake)
stamp, _ = LXStamper.generate_stamp(
t_id, stamp_cost=2,
t_id,
stamp_cost=2,
expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PN,
)
items.append(fake + stamp)
@@ -222,8 +230,8 @@ class TestStampSolving:
# 2. LXMessage protocol (subprocess, real crypto)
# ────────────────────────────────────────────────────────────────
class TestLXMessageProtocol:
class TestLXMessageProtocol:
@pytest.mark.integration
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
def test_pack_unpack_roundtrip(self):
@@ -514,8 +522,8 @@ class TestLXMessageProtocol:
# 3. LXMRouter delivery pipeline (subprocess)
# ────────────────────────────────────────────────────────────────
class TestLXMRouterDelivery:
class TestLXMRouterDelivery:
@pytest.mark.integration
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
def test_direct_delivery_via_lxmf_delivery(self):
@@ -840,8 +848,8 @@ class TestLXMRouterDelivery:
# 4. Propagation node operations (subprocess)
# ────────────────────────────────────────────────────────────────
class TestPropagationNode:
class TestPropagationNode:
@pytest.mark.integration
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
def test_enable_disable_propagation(self):
@@ -1031,8 +1039,8 @@ class TestPropagationNode:
# 5. LXMessage state machine and callbacks (subprocess)
# ────────────────────────────────────────────────────────────────
class TestMessageStateAndCallbacks:
class TestMessageStateAndCallbacks:
@pytest.mark.integration
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
def test_message_states_enum(self):
@@ -1141,8 +1149,8 @@ class TestMessageStateAndCallbacks:
# 6. Multi-field message integrity (subprocess)
# ────────────────────────────────────────────────────────────────
class TestMessageFieldIntegrity:
class TestMessageFieldIntegrity:
@pytest.mark.integration
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
def test_fields_survive_roundtrip(self):
@@ -1232,8 +1240,8 @@ class TestMessageFieldIntegrity:
# 7. URI ingestion (subprocess)
# ────────────────────────────────────────────────────────────────
class TestURIIngestion:
class TestURIIngestion:
@pytest.mark.integration
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
def test_paper_uri_roundtrip(self):
@@ -1284,8 +1292,8 @@ class TestURIIngestion:
# 8. Router configuration and state (subprocess)
# ────────────────────────────────────────────────────────────────
class TestRouterConfiguration:
class TestRouterConfiguration:
@pytest.mark.integration
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
def test_single_delivery_identity_per_router(self):
@@ -1419,8 +1427,8 @@ class TestRouterConfiguration:
# 9. End-to-end two-router communication (subprocess)
# ────────────────────────────────────────────────────────────────
class TestTwoRouterCommunication:
class TestTwoRouterCommunication:
@pytest.mark.integration
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
def test_bidirectional_protocol_delivery(self):
@@ -1556,8 +1564,8 @@ class TestTwoRouterCommunication:
# 10. Reticulum identity and destination basics (subprocess)
# ────────────────────────────────────────────────────────────────
class TestReticulumPrimitives:
class TestReticulumPrimitives:
@pytest.mark.integration
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
def test_identity_creation_and_key_operations(self):
+22 -11
View File
@@ -258,10 +258,13 @@ async def test_lxm_ingest_uri_lxma_adds_contact(mock_app):
fake_identity.hash = bytes.fromhex("bb" * 16)
fake_identity.load_public_key.return_value = True
with patch(
"meshchatx.meshchat.AsyncUtils.run_async",
side_effect=lambda coro: asyncio.create_task(coro),
), patch("meshchatx.meshchat.RNS.Identity", return_value=fake_identity):
with (
patch(
"meshchatx.meshchat.AsyncUtils.run_async",
side_effect=lambda coro: asyncio.create_task(coro),
),
patch("meshchatx.meshchat.RNS.Identity", return_value=fake_identity),
):
await mock_app.on_websocket_data_received(
mock_client,
{
@@ -359,10 +362,12 @@ async def test_on_lxmf_sending_state_updated(mock_app):
with (
patch(
"meshchatx.meshchat.convert_lxmf_message_to_dict", return_value={"h": "v"},
"meshchatx.meshchat.convert_lxmf_message_to_dict",
return_value={"h": "v"},
),
patch(
"meshchatx.meshchat.convert_lxmf_state_to_string", return_value="delivered",
"meshchatx.meshchat.convert_lxmf_state_to_string",
return_value="delivered",
),
patch("meshchatx.meshchat.AsyncUtils.run_async") as mock_run_async,
):
@@ -414,7 +419,8 @@ def test_on_lxmf_sending_failed_no_propagation(mock_app):
mock_app.on_lxmf_sending_failed(mock_msg)
mock_app.on_lxmf_sending_state_updated.assert_called_once_with(
mock_msg, context=None,
mock_msg,
context=None,
)
@@ -466,9 +472,12 @@ def test_convert_webm_opus_to_ogg_ffmpeg_fails(mock_app):
def test_convert_webm_opus_to_ogg_exception(mock_app):
webm_data = b"\x1a\x45\xdf\xa3" + b"\x00" * 100
with patch("shutil.which", return_value="/usr/bin/ffmpeg"), patch(
"subprocess.run",
side_effect=subprocess.TimeoutExpired(cmd="ffmpeg", timeout=30),
with (
patch("shutil.which", return_value="/usr/bin/ffmpeg"),
patch(
"subprocess.run",
side_effect=subprocess.TimeoutExpired(cmd="ffmpeg", timeout=30),
),
):
result = mock_app._convert_webm_opus_to_ogg(webm_data)
@@ -512,7 +521,9 @@ async def _run_send(app, destination_hash="aa" * 16, **kwargs):
patch("meshchatx.meshchat.AsyncUtils.run_async"),
):
await app.send_message(
destination_hash=destination_hash, content="hi", **kwargs,
destination_hash=destination_hash,
content="hi",
**kwargs,
)
return fake_lxm
+2 -1
View File
@@ -31,7 +31,8 @@ def test_upsert_lxmf_message(message_dao, mock_provider):
def test_get_lxmf_message_by_hash(message_dao, mock_provider):
message_dao.get_lxmf_message_by_hash("hash1")
mock_provider.fetchone.assert_called_with(
"SELECT * FROM lxmf_messages WHERE hash = ?", ("hash1",),
"SELECT * FROM lxmf_messages WHERE hash = ?",
("hash1",),
)
@@ -72,7 +72,10 @@ def test_get_conversations_base(mock_db):
def test_get_conversations_with_filters(mock_db):
handler = MessageHandler(mock_db)
handler.get_conversations(
"local", search="test", filter_unread=True, filter_failed=True,
"local",
search="test",
filter_unread=True,
filter_failed=True,
)
args, _ = mock_db.provider.fetchall.call_args
@@ -29,7 +29,12 @@ def downloader():
on_failure = MagicMock()
on_progress = MagicMock()
return NomadnetDownloader(
b"dest", "/path", "data", on_success, on_failure, on_progress,
b"dest",
"/path",
"data",
on_success,
on_failure,
on_progress,
)
@@ -120,7 +125,8 @@ async def test_download_uses_path_wait_cache_hit(downloader):
):
with patch.object(downloader, "link_established") as mock_established:
await downloader.download(
path_lookup_timeout=5, link_establishment_timeout=5,
path_lookup_timeout=5,
link_establishment_timeout=5,
)
mock_established.assert_called_once_with(mock_link)
@@ -82,7 +82,9 @@ def test_app_info_dependency_keys_resolve_in_dev_env(package: str):
assert v != "unknown", f"{package} must resolve when installed"
@pytest.mark.skipif(sys.version_info < (3, 13), reason="audioop-lts only on Python 3.13+")
@pytest.mark.skipif(
sys.version_info < (3, 13), reason="audioop-lts only on Python 3.13+"
)
def test_audioop_lts_resolves_when_applicable():
v = ReticulumMeshChat.get_package_version("audioop-lts")
assert v != "unknown"
+24 -6
View File
@@ -268,7 +268,8 @@ def test_interface_config_parser_no_crash(text):
st.text(
min_size=1,
alphabet=st.characters(
blacklist_categories=("Cc", "Cs"), blacklist_characters="[]",
blacklist_categories=("Cc", "Cs"),
blacklist_characters="[]",
),
).filter(lambda x: x.strip() == x and x),
min_size=1,
@@ -279,7 +280,8 @@ def test_interface_config_parser_no_crash(text):
st.text(
min_size=1,
alphabet=st.characters(
blacklist_categories=("Cc", "Cs"), blacklist_characters="[]=",
blacklist_categories=("Cc", "Cs"),
blacklist_characters="[]=",
),
).filter(lambda x: x.strip() == x and x),
min_size=1,
@@ -733,7 +735,11 @@ def test_message_fields_have_attachments_robustness(fields_json):
lxmf_fields=st.dictionaries(
keys=st.integers(),
values=st.one_of(
st.text(), st.binary(), st.integers(), st.booleans(), st.none(),
st.text(),
st.binary(),
st.integers(),
st.booleans(),
st.none(),
),
),
)
@@ -846,7 +852,12 @@ def test_convert_db_lxmf_message_to_dict_extended_robustness(
),
)
def test_lxmf_utils_conversions_robustness(
state_val, method_val, title, content, timestamp, fields,
state_val,
method_val,
title,
content,
timestamp,
fields,
):
from unittest.mock import MagicMock
@@ -980,7 +991,12 @@ class TestCrashRecoveryMathProperties:
derandomize=True,
)
def test_system_entropy_always_finite(
self, low_memory, config_missing, config_invalid, db_type, available_mem_mb,
self,
low_memory,
config_missing,
config_invalid,
db_type,
available_mem_mb,
):
"""Entropy and divergence must always be finite floats for any diagnosis."""
import math as m
@@ -1051,7 +1067,9 @@ class TestCrashRecoveryMathProperties:
@given(
counts=st.lists(
st.integers(min_value=0, max_value=50), min_size=1, max_size=10,
st.integers(min_value=0, max_value=50),
min_size=1,
max_size=10,
),
)
@settings(derandomize=True, deadline=None, max_examples=50)
+4 -1
View File
@@ -32,7 +32,10 @@ def test_rncp_handler_init(rncp_handler, mock_reticulum, mock_identity):
@patch("meshchatx.src.backend.rncp_handler.RNS.Destination")
@patch("meshchatx.src.backend.rncp_handler.RNS.Reticulum")
def test_setup_receive_destination(
mock_rns_reticulum, mock_dest, mock_identity_class, rncp_handler,
mock_rns_reticulum,
mock_dest,
mock_identity_class,
rncp_handler,
):
mock_rns_reticulum.identitypath = "/tmp/rns/identities"
mock_id_obj = MagicMock()
+91
View File
@@ -0,0 +1,91 @@
"""Integration tests for conversation list search and announce search filtering."""
import secrets
import time
from meshchatx.src.backend.announce_manager import (
filter_announced_dicts_by_search_query,
)
from meshchatx.src.backend.message_handler import MessageHandler
from tests.backend.test_performance_hotpaths import make_message
def test_get_conversations_search_finds_peer_via_older_message(db):
handler = MessageHandler(db)
peer = secrets.token_hex(16)
with db.provider:
m_old = make_message(peer, 1)
m_old["content"] = "unique_old_body_xyz"
m_old["title"] = "t1"
m_old["timestamp"] = time.time() - 100
m_new = make_message(peer, 2)
m_new["content"] = "latest"
m_new["title"] = "t2"
m_new["timestamp"] = time.time()
db.messages.upsert_lxmf_message(m_old)
db.messages.upsert_lxmf_message(m_new)
rows = handler.get_conversations("local", search="unique_old_body")
dest_hashes = {r["peer_hash"] for r in rows}
assert peer in dest_hashes
def test_get_conversations_search_matches_custom_display_name(db):
handler = MessageHandler(db)
peer = secrets.token_hex(16)
with db.provider:
db.messages.upsert_lxmf_message(make_message(peer, 0))
db.announces.upsert_custom_display_name(peer, "CustomDisplayUniqueZ")
rows = handler.get_conversations("local", search="CustomDisplayUnique")
assert any(r["peer_hash"] == peer for r in rows)
def test_filter_announced_dicts_by_search_query_display_name():
items = [
{
"display_name": "AlphaOnlyName",
"destination_hash": "c" * 32,
"identity_hash": "e" * 32,
},
{
"display_name": "BetaOtherName",
"destination_hash": "d" * 32,
"identity_hash": "e" * 32,
},
]
out = filter_announced_dicts_by_search_query(items, "AlphaOnly")
assert len(out) == 1
assert out[0]["destination_hash"] == "c" * 32
def test_filter_announced_dicts_by_search_query_custom_display_name():
items = [
{
"display_name": "Anonymous Peer",
"custom_display_name": "CustomNickUniqueSearch",
"destination_hash": "f" * 32,
"identity_hash": "1" * 32,
},
]
out = filter_announced_dicts_by_search_query(items, "customnickunique")
assert len(out) == 1
def test_filter_announced_dicts_by_search_query_destination_hash_substring():
dest = "ab" + "0" * 30
items = [
{"display_name": "X", "destination_hash": dest, "identity_hash": "y" * 32},
]
out = filter_announced_dicts_by_search_query(items, "ab00")
assert len(out) == 1
def test_filter_announced_dicts_by_search_query_case_insensitive():
items = [
{"display_name": "CamelCaseName", "destination_hash": "z" * 32},
]
out = filter_announced_dicts_by_search_query(items, "camelcase")
assert len(out) == 1
+12 -4
View File
@@ -1394,7 +1394,10 @@ def test_nomadnet_page_archive_load_fuzzing(mock_app, archive_id):
),
)
def test_nomadnet_page_archive_add_fuzzing(
mock_app, destination_hash, page_path, content,
mock_app,
destination_hash,
page_path,
content,
):
"""Fuzz nomadnet.page.archive.add WebSocket handler and archive_page."""
import asyncio
@@ -1465,7 +1468,10 @@ def test_nomadnet_file_download_fuzzing(mock_app, destination_hash, file_path):
),
)
def test_nomadnet_page_download_fuzzing(
mock_app, destination_hash, page_path, field_data,
mock_app,
destination_hash,
page_path,
field_data,
):
"""Fuzz nomadnet.page.download WebSocket handler (page_path with backtick, field_data)."""
import asyncio
@@ -2284,7 +2290,8 @@ class TestLxmfFieldHardening:
pass
@settings(
suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None,
suppress_health_check=[HealthCheck.function_scoped_fixture],
deadline=None,
)
@given(
fields_data=st.dictionaries(
@@ -2412,7 +2419,8 @@ class TestStrangerAttachmentBlocking:
"""Text-only messages from strangers are delivered normally."""
source_hash = os.urandom(16)
mock_msg = self._make_mock_message(
source_hash=source_hash, with_attachments=False,
source_hash=source_hash,
with_attachments=False,
)
mock_app.config.block_attachments_from_strangers.get.return_value = True
@@ -71,14 +71,18 @@ class _Query:
@pytest.mark.asyncio
async def test_api_v1_telephone_voicemail_status_json_contract(mock_rns_minimal, temp_dir):
async def test_api_v1_telephone_voicemail_status_json_contract(
mock_rns_minimal, temp_dir
):
with patch("meshchatx.meshchat.generate_ssl_certificate"):
app_instance = ReticulumMeshChat(
identity=mock_rns_minimal,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
handler = _find_handler(app_instance, "/api/v1/telephone/voicemail/status", "GET")
handler = _find_handler(
app_instance, "/api/v1/telephone/voicemail/status", "GET"
)
assert handler is not None
request = MagicMock()
response = await handler(request)
@@ -104,7 +108,9 @@ async def test_api_v1_telephone_voicemails_json_contract(mock_rns_minimal, temp_
@pytest.mark.asyncio
async def test_api_v1_telephone_ringtones_list_json_contract(mock_rns_minimal, temp_dir):
async def test_api_v1_telephone_ringtones_list_json_contract(
mock_rns_minimal, temp_dir
):
with patch("meshchatx.meshchat.generate_ssl_certificate"):
app_instance = ReticulumMeshChat(
identity=mock_rns_minimal,
@@ -120,14 +126,18 @@ async def test_api_v1_telephone_ringtones_list_json_contract(mock_rns_minimal, t
@pytest.mark.asyncio
async def test_api_v1_telephone_ringtones_status_json_contract(mock_rns_minimal, temp_dir):
async def test_api_v1_telephone_ringtones_status_json_contract(
mock_rns_minimal, temp_dir
):
with patch("meshchatx.meshchat.generate_ssl_certificate"):
app_instance = ReticulumMeshChat(
identity=mock_rns_minimal,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
handler = _find_handler(app_instance, "/api/v1/telephone/ringtones/status", "GET")
handler = _find_handler(
app_instance, "/api/v1/telephone/ringtones/status", "GET"
)
assert handler is not None
request = MagicMock()
request.query = _Query({})
@@ -154,7 +164,9 @@ async def test_api_v1_telephone_contacts_list_json_contract(mock_rns_minimal, te
@pytest.mark.asyncio
async def test_api_v1_telephone_contacts_check_json_contract(mock_rns_minimal, temp_dir):
async def test_api_v1_telephone_contacts_check_json_contract(
mock_rns_minimal, temp_dir
):
with patch("meshchatx.meshchat.generate_ssl_certificate"):
app_instance = ReticulumMeshChat(
identity=mock_rns_minimal,
@@ -40,12 +40,16 @@ def test_init_telephone(mock_tel_class, tel_manager):
@patch("meshchatx.src.backend.telephone_manager.Telephone")
def test_init_telephone_applies_config_audio_profile(mock_tel_class, mock_identity, tmp_path):
def test_init_telephone_applies_config_audio_profile(
mock_tel_class, mock_identity, tmp_path
):
storage_dir = tmp_path / "tel"
storage_dir.mkdir()
cfg = MagicMock()
cfg.telephone_audio_profile_id.get.return_value = 96
tm = TelephoneManager(mock_identity, config_manager=cfg, storage_dir=str(storage_dir))
tm = TelephoneManager(
mock_identity, config_manager=cfg, storage_dir=str(storage_dir)
)
tm.init_telephone()
mock_tel_class.return_value.switch_profile.assert_called_with(96)
@@ -74,7 +74,10 @@ def test_translate_argos_cli(mock_run):
with patch("shutil.which", return_value="/usr/bin/argos-translate"):
result = handler.translate_text(
"Hello", source_lang="en", target_lang="es", use_argos=True,
"Hello",
source_lang="en",
target_lang="es",
use_argos=True,
)
assert result["translated_text"] == "Hola"
@@ -29,7 +29,8 @@ def test_find_bundled_binary_not_frozen(voicemail_manager):
def test_find_espeak_shutil(voicemail_manager):
with patch(
"shutil.which", side_effect=lambda x: f"/usr/bin/{x}" if "espeak" in x else None,
"shutil.which",
side_effect=lambda x: f"/usr/bin/{x}" if "espeak" in x else None,
):
path = voicemail_manager._find_espeak()
assert "espeak" in path
+122 -6
View File
@@ -1,4 +1,4 @@
import { mount } from "@vue/test-utils";
import { flushPromises, mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import CallPage from "@/components/call/CallPage.vue";
@@ -26,13 +26,48 @@ describe("CallPage.vue", () => {
if (url.includes("/api/v1/telephone/history")) return Promise.resolve({ data: { call_history: [] } });
if (url.includes("/api/v1/announces")) return Promise.resolve({ data: { announces: [] } });
if (url.includes("/api/v1/telephone/status")) return Promise.resolve({ data: { active_call: null } });
if (url.includes("/api/v1/telephone/voicemail/status"))
return Promise.resolve({ data: { has_espeak: false } });
if (url.includes("/api/v1/telephone/ringtones/status"))
return Promise.resolve({ data: { enabled: true } });
if (url.includes("/api/v1/telephone/ringtones")) return Promise.resolve({ data: { ringtones: [] } });
if (url.includes("/api/v1/telephone/voicemail/status")) {
return Promise.resolve({
data: {
has_espeak: false,
has_ffmpeg: false,
is_recording: false,
is_greeting_recording: false,
has_greeting: false,
},
});
}
if (url.includes("/api/v1/telephone/voicemails")) {
return Promise.resolve({ data: { voicemails: [], unread_count: 0 } });
}
if (url.includes("/api/v1/telephone/ringtones/status")) {
return Promise.resolve({
data: {
has_custom_ringtone: false,
enabled: true,
filename: null,
id: null,
volume: 0.5,
},
});
}
if (url.includes("/api/v1/telephone/ringtones/") && url.includes("/audio")) {
return Promise.resolve({ data: new ArrayBuffer(0) });
}
if (url.includes("/api/v1/telephone/ringtones")) {
return Promise.resolve({ data: [] });
}
if (url.includes("/api/v1/telephone/audio-profiles"))
return Promise.resolve({ data: { audio_profiles: [], default_audio_profile_id: null } });
if (url.includes("/api/v1/telephone/contacts/export")) {
return Promise.resolve({ data: { contacts: [] } });
}
if (url.includes("/api/v1/telephone/contacts/check/")) {
return Promise.resolve({ data: { is_contact: false, contact: null } });
}
if (url.includes("/api/v1/telephone/contacts")) {
return Promise.resolve({ data: { contacts: [], total_count: 0 } });
}
if (url.includes("/api/v1/contacts")) return Promise.resolve({ data: { contacts: [] } });
return Promise.resolve({ data: defaultData });
@@ -226,4 +261,85 @@ describe("CallPage.vue", () => {
await wrapper.vm.ensureWebAudio({ enabled: true });
expect(stop).toHaveBeenCalled();
});
it("getContacts maps telephone contacts list and hydrates visuals", async () => {
const wrapper = mountCallPage();
await flushPromises();
const hydrate = vi.spyOn(wrapper.vm, "hydrateContactVisuals");
axiosMock.get.mockResolvedValueOnce({
data: {
contacts: [{ id: 1, name: "Sam", remote_identity_hash: "ab".repeat(16) }],
total_count: 2,
},
});
await wrapper.vm.getContacts();
expect(wrapper.vm.contacts[0].name).toBe("Sam");
expect(hydrate).toHaveBeenCalled();
});
it("getVoicemails maps voicemails and unread_count", async () => {
const wrapper = mountCallPage();
await flushPromises();
axiosMock.get.mockResolvedValueOnce({
data: {
voicemails: [{ id: 9, remote_identity_hash: "cd".repeat(16), is_read: 0 }],
unread_count: 1,
},
});
await wrapper.vm.getVoicemails();
expect(wrapper.vm.voicemails).toHaveLength(1);
expect(wrapper.vm.unreadVoicemailsCount).toBe(1);
});
it("getRingtones stores API array on ringtones", async () => {
const wrapper = mountCallPage();
await flushPromises();
axiosMock.get.mockResolvedValueOnce({
data: [
{
id: 1,
filename: "x.opus",
display_name: "Test",
is_primary: true,
created_at: "2024-01-01",
},
],
});
await wrapper.vm.getRingtones();
expect(wrapper.vm.ringtones).toHaveLength(1);
expect(wrapper.vm.ringtones[0].filename).toBe("x.opus");
});
it("getVoicemailStatus stores voicemail status payload", async () => {
const wrapper = mountCallPage();
await flushPromises();
axiosMock.get.mockResolvedValueOnce({
data: {
has_espeak: true,
has_ffmpeg: false,
is_recording: false,
is_greeting_recording: false,
has_greeting: true,
},
});
await wrapper.vm.getVoicemailStatus();
expect(wrapper.vm.voicemailStatus.has_greeting).toBe(true);
});
it("getRingtoneStatus stores ringtone status payload", async () => {
const wrapper = mountCallPage();
await flushPromises();
axiosMock.get.mockResolvedValueOnce({
data: {
has_custom_ringtone: true,
enabled: true,
filename: "ring.opus",
id: 3,
volume: 0.8,
},
});
await wrapper.vm.getRingtoneStatus();
expect(wrapper.vm.ringtoneStatus.id).toBe(3);
expect(wrapper.vm.ringtoneStatus.volume).toBe(0.8);
});
});
+48 -2
View File
@@ -24,21 +24,28 @@ describe("CommandPalette.vue", () => {
GlobalEmitter.off("sync-propagation-node");
GlobalEmitter.off("toggle-orbit");
GlobalEmitter.off("toggle-falling-skies");
GlobalEmitter.off("toggle-snake");
GlobalEmitter.off("toggle-pong");
});
afterEach(() => {
delete window.api;
GlobalEmitter.off("sync-propagation-node");
GlobalEmitter.off("toggle-orbit");
GlobalEmitter.off("toggle-falling-skies");
GlobalEmitter.off("toggle-snake");
GlobalEmitter.off("toggle-pong");
vi.clearAllMocks();
});
const mountCommandPalette = () => {
const mountCommandPalette = (route = { name: "messages" }) => {
return mount(CommandPalette, {
global: {
mocks: {
$t: (key) => key,
$router: routerMock,
$route: route,
},
stubs: {
MaterialDesignIcon: { template: '<div class="mdi"></div>' },
@@ -248,7 +255,7 @@ describe("CommandPalette.vue", () => {
});
it("emits toggle-orbit event when orbit action is executed", async () => {
const wrapper = mountCommandPalette();
const wrapper = mountCommandPalette({ name: "network-visualiser" });
const emitSpy = vi.spyOn(GlobalEmitter, "emit");
wrapper.vm.isOpen = true;
await wrapper.vm.$nextTick();
@@ -260,6 +267,45 @@ describe("CommandPalette.vue", () => {
}
});
it("emits toggle-falling-skies when falling skies action is executed", async () => {
const wrapper = mountCommandPalette({ name: "network-visualiser" });
const emitSpy = vi.spyOn(GlobalEmitter, "emit");
wrapper.vm.isOpen = true;
await wrapper.vm.$nextTick();
const result = wrapper.vm.filteredResults.find((r) => r.action === "toggle-falling-skies");
if (result) {
wrapper.vm.executeResult(result);
expect(emitSpy).toHaveBeenCalledWith("toggle-falling-skies");
}
});
it("emits toggle-snake when mesh snake action is executed", async () => {
const wrapper = mountCommandPalette({ name: "network-visualiser" });
const emitSpy = vi.spyOn(GlobalEmitter, "emit");
wrapper.vm.isOpen = true;
await wrapper.vm.$nextTick();
const result = wrapper.vm.filteredResults.find((r) => r.action === "toggle-snake");
if (result) {
wrapper.vm.executeResult(result);
expect(emitSpy).toHaveBeenCalledWith("toggle-snake");
}
});
it("emits toggle-pong when mesh pong action is executed", async () => {
const wrapper = mountCommandPalette({ name: "network-visualiser" });
const emitSpy = vi.spyOn(GlobalEmitter, "emit");
wrapper.vm.isOpen = true;
await wrapper.vm.$nextTick();
const result = wrapper.vm.filteredResults.find((r) => r.action === "toggle-pong");
if (result) {
wrapper.vm.executeResult(result);
expect(emitSpy).toHaveBeenCalledWith("toggle-pong");
}
});
it("closes when ESC key is pressed", async () => {
const wrapper = mountCommandPalette();
wrapper.vm.isOpen = true;
+46 -5
View File
@@ -1,4 +1,4 @@
import { mount } from "@vue/test-utils";
import { flushPromises, mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ContactsPage from "@/components/contacts/ContactsPage.vue";
import WebSocketConnection from "@/js/WebSocketConnection";
@@ -40,14 +40,17 @@ describe("ContactsPage.vue", () => {
},
});
}
if (url === "/api/v1/telephone/contacts") {
return Promise.resolve({ data: { contacts: [], total_count: 0 } });
}
if (url === "/api/v1/telephone/contacts/export") {
return Promise.resolve({ data: { contacts: [] } });
}
if (url.startsWith("/api/v1/telephone/contacts/check/")) {
return Promise.resolve({ data: {} });
return Promise.resolve({ data: { is_contact: false, contact: null } });
}
if (
url === "/api/v1/telephone/contacts" ||
(typeof url === "string" && url.startsWith("/api/v1/telephone/contacts?"))
) {
return Promise.resolve({ data: { contacts: [], total_count: 0 } });
}
return Promise.resolve({ data: {} });
});
@@ -145,4 +148,42 @@ describe("ContactsPage.vue", () => {
expect(html).toContain("contacts.export_contacts");
expect(html).toContain("contacts.import_contacts");
});
it("getContacts maps total_count from telephone contacts API", async () => {
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/config") {
return Promise.resolve({
data: {
config: {
lxmf_address_hash: "a".repeat(32),
identity_public_key: "b".repeat(128),
},
},
});
}
if (
url === "/api/v1/telephone/contacts" ||
(typeof url === "string" && url.startsWith("/api/v1/telephone/contacts?"))
) {
return Promise.resolve({
data: {
contacts: [{ id: 1, name: "One", remote_identity_hash: "c".repeat(32) }],
total_count: 42,
},
});
}
if (url === "/api/v1/telephone/contacts/export") {
return Promise.resolve({ data: { contacts: [] } });
}
if (url.startsWith("/api/v1/telephone/contacts/check/")) {
return Promise.resolve({ data: { is_contact: false, contact: null } });
}
return Promise.resolve({ data: {} });
});
const wrapper = mountPage();
await flushPromises();
expect(wrapper.vm.totalContactsCount).toBe(42);
expect(wrapper.vm.contacts).toHaveLength(1);
expect(wrapper.vm.contacts[0].name).toBe("One");
});
});
+725
View File
@@ -14,6 +14,7 @@ describe("ConversationViewer.vue", () => {
let axiosMock;
beforeEach(() => {
GlobalState.config.theme = "light";
GlobalState.config.message_outbound_bubble_color = "#4f46e5";
GlobalState.config.message_waiting_bubble_color = "#e5e7eb";
WebSocketConnection.connect();
@@ -526,6 +527,29 @@ describe("ConversationViewer.vue", () => {
expect(wrapper.vm.outboundBubbleSurfaceClass(chatItem)).toBe("");
});
it("uses dark neutral waiting bubble when pathfinding in dark theme with default gray", () => {
GlobalState.config.theme = "dark";
GlobalState.config.message_waiting_bubble_color = "#e5e7eb";
const wrapper = mountConversationViewer();
const chatItem = {
type: "lxmf_message",
is_outbound: true,
lxmf_message: {
hash: "h-wait-dark",
state: "sending",
content: "hi",
destination_hash: "test-hash",
source_hash: "my-hash",
fields: {},
_pendingPathfinding: true,
},
};
expect(wrapper.vm.bubbleStyles(chatItem)).toMatchObject({
"background-color": "#3f3f46",
color: "#ffffff",
});
});
it("marks inbound messages with markdown-content--inbound for link styling", async () => {
GlobalState.config.message_outbound_bubble_color = "#4f46e5";
const wrapper = mountConversationViewer();
@@ -803,4 +827,705 @@ describe("ConversationViewer.vue", () => {
expect(drafts["a".repeat(32)]).toBe("save on leave");
});
});
describe("message visibility and empty bubble prevention", () => {
const makeChatItem = (overrides = {}) => ({
type: "lxmf_message",
is_outbound: false,
lxmf_message: {
hash: `hash-${Math.random().toString(36).slice(2, 10)}`,
state: "delivered",
content: "",
destination_hash: "my-hash",
source_hash: "test-hash",
fields: {},
timestamp: 1700000000,
created_at: "2023-11-14T22:13:20.000Z",
...overrides,
},
});
describe("hasRenderableContent", () => {
it("returns true for message with text content", () => {
const wrapper = mountConversationViewer();
expect(wrapper.vm.hasRenderableContent({ content: "hello", fields: {} })).toBe(true);
});
it("returns true for whitespace-padded text", () => {
const wrapper = mountConversationViewer();
expect(wrapper.vm.hasRenderableContent({ content: " hi ", fields: {} })).toBe(true);
});
it("returns false for empty string content and no fields", () => {
const wrapper = mountConversationViewer();
expect(wrapper.vm.hasRenderableContent({ content: "", fields: {} })).toBe(false);
});
it("returns false for whitespace-only content and no fields", () => {
const wrapper = mountConversationViewer();
expect(wrapper.vm.hasRenderableContent({ content: " ", fields: {} })).toBe(false);
});
it("returns false for null content and no fields", () => {
const wrapper = mountConversationViewer();
expect(wrapper.vm.hasRenderableContent({ content: null, fields: {} })).toBe(false);
});
it("returns false for undefined content and no fields", () => {
const wrapper = mountConversationViewer();
expect(wrapper.vm.hasRenderableContent({ fields: {} })).toBe(false);
});
it("returns false for undefined fields", () => {
const wrapper = mountConversationViewer();
expect(wrapper.vm.hasRenderableContent({ content: "" })).toBe(false);
});
it("returns false for null fields", () => {
const wrapper = mountConversationViewer();
expect(wrapper.vm.hasRenderableContent({ content: "", fields: null })).toBe(false);
});
it("returns true for image field", () => {
const wrapper = mountConversationViewer();
expect(wrapper.vm.hasRenderableContent({ content: "", fields: { image: { image_type: "png" } } })).toBe(
true
);
});
it("returns true for audio field", () => {
const wrapper = mountConversationViewer();
expect(wrapper.vm.hasRenderableContent({ content: "", fields: { audio: { audio_mode: 0x10 } } })).toBe(
true
);
});
it("returns true for file attachments", () => {
const wrapper = mountConversationViewer();
expect(
wrapper.vm.hasRenderableContent({
content: "",
fields: { file_attachments: [{ file_name: "doc.pdf" }] },
})
).toBe(true);
});
it("returns true for telemetry", () => {
const wrapper = mountConversationViewer();
expect(
wrapper.vm.hasRenderableContent({
content: "",
fields: { telemetry: { location: { latitude: 0, longitude: 0 } } },
})
).toBe(true);
});
it("returns true for telemetry stream", () => {
const wrapper = mountConversationViewer();
expect(wrapper.vm.hasRenderableContent({ content: "", fields: { telemetry_stream: [{}] } })).toBe(true);
});
it("returns true for location request command", () => {
const wrapper = mountConversationViewer();
expect(
wrapper.vm.hasRenderableContent({
content: "",
fields: { commands: [{ "0x01": true }] },
})
).toBe(true);
});
it("returns true for location request via string key '1'", () => {
const wrapper = mountConversationViewer();
expect(
wrapper.vm.hasRenderableContent({
content: "",
fields: { commands: [{ 1: true }] },
})
).toBe(true);
});
it("returns false for commands with no location request", () => {
const wrapper = mountConversationViewer();
expect(
wrapper.vm.hasRenderableContent({
content: "",
fields: { commands: [{ "0x99": true }] },
})
).toBe(false);
});
it("returns false for empty commands array", () => {
const wrapper = mountConversationViewer();
expect(wrapper.vm.hasRenderableContent({ content: "", fields: { commands: [] } })).toBe(false);
});
it("returns false for unknown/unsupported fields only", () => {
const wrapper = mountConversationViewer();
expect(
wrapper.vm.hasRenderableContent({
content: "",
fields: { some_future_field: { data: 42 }, another_unknown: "value" },
})
).toBe(false);
});
});
describe("isImageOnlyMessage", () => {
it("returns true for image with no text", () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({ fields: { image: { image_type: "png" } } });
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(true);
});
it("returns true for image with null content", () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({ content: null, fields: { image: { image_type: "jpg" } } });
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(true);
});
it("returns true for image with whitespace-only content", () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({ content: " ", fields: { image: { image_type: "png" } } });
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(true);
});
it("returns true for image with auto-generated filename caption", () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({
content: "photo_2024.jpg",
fields: { image: { image_type: "jpg" } },
});
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(true);
});
it("returns false for image with real text caption", () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({
content: "Look at this sunset!",
fields: { image: { image_type: "jpg" } },
});
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
});
it("returns false for image with reply", () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({
reply_to_hash: "abc123",
fields: { image: { image_type: "png" } },
});
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
});
it("returns false for image + audio", () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({
fields: { image: { image_type: "png" }, audio: { audio_mode: 0x10 } },
});
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
});
it("returns false for image + file attachments", () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({
fields: {
image: { image_type: "png" },
file_attachments: [{ file_name: "data.csv" }],
},
});
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
});
it("returns false for image + telemetry", () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({
fields: {
image: { image_type: "png" },
telemetry: { location: { latitude: 1, longitude: 2 } },
},
});
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
});
it("returns false for image + telemetry stream", () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({
fields: { image: { image_type: "png" }, telemetry_stream: [{}] },
});
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
});
it("returns false for image + location request command", () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({
fields: { image: { image_type: "png" }, commands: [{ "0x01": true }] },
});
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
});
it("returns false when there is no image", () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({ content: "just text", fields: {} });
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
});
it("returns true for outbound image-only", () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({
source_hash: "my-hash",
destination_hash: "test-hash",
fields: { image: { image_type: "png" } },
});
item.is_outbound = true;
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(true);
});
});
describe("selectedPeerChatItems filtering", () => {
it("filters out messages with no renderable content", async () => {
const wrapper = mountConversationViewer();
wrapper.vm.chatItems = [
makeChatItem({ content: "visible" }),
makeChatItem({ content: "", fields: {} }),
makeChatItem({ content: "", fields: { some_unknown: true } }),
];
await wrapper.vm.$nextTick();
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1);
expect(wrapper.vm.selectedPeerChatItems[0].lxmf_message.content).toBe("visible");
});
it("keeps image-only messages visible", async () => {
const wrapper = mountConversationViewer();
wrapper.vm.chatItems = [makeChatItem({ fields: { image: { image_type: "png" } } })];
await wrapper.vm.$nextTick();
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1);
});
it("keeps audio-only messages visible", async () => {
const wrapper = mountConversationViewer();
wrapper.vm.chatItems = [makeChatItem({ fields: { audio: { audio_mode: 0x10 } } })];
await wrapper.vm.$nextTick();
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1);
});
it("keeps telemetry messages visible when showTelemetryInChat is true", async () => {
const wrapper = mountConversationViewer();
wrapper.vm.showTelemetryInChat = true;
wrapper.vm.chatItems = [
makeChatItem({
fields: { telemetry: { location: { latitude: 0, longitude: 0 } } },
}),
];
await wrapper.vm.$nextTick();
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1);
});
it("filters out reactions", async () => {
const wrapper = mountConversationViewer();
wrapper.vm.chatItems = [makeChatItem({ content: "reaction", is_reaction: true })];
await wrapper.vm.$nextTick();
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0);
});
it("filters out empty messages even if state is delivered", async () => {
const wrapper = mountConversationViewer();
wrapper.vm.chatItems = [
makeChatItem({ content: "", fields: {}, state: "delivered" }),
makeChatItem({ content: null, fields: {}, state: "delivered" }),
makeChatItem({ content: " ", fields: {}, state: "delivered" }),
];
await wrapper.vm.$nextTick();
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0);
});
it("filters out empty outbound messages", async () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({
content: "",
fields: {},
source_hash: "my-hash",
destination_hash: "test-hash",
});
item.is_outbound = true;
wrapper.vm.chatItems = [item];
await wrapper.vm.$nextTick();
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0);
});
it("keeps file-only messages", async () => {
const wrapper = mountConversationViewer();
wrapper.vm.chatItems = [
makeChatItem({
fields: { file_attachments: [{ file_name: "readme.txt" }] },
}),
];
await wrapper.vm.$nextTick();
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1);
});
it("handles mixed visible and invisible messages correctly", async () => {
const wrapper = mountConversationViewer();
wrapper.vm.chatItems = [
makeChatItem({ content: "text message" }),
makeChatItem({ content: "", fields: {} }),
makeChatItem({ fields: { image: { image_type: "png" } } }),
makeChatItem({ content: "", fields: { weird_field: true } }),
makeChatItem({ fields: { audio: { audio_mode: 0x10 } } }),
makeChatItem({ content: "", fields: {} }),
];
await wrapper.vm.$nextTick();
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(3);
});
});
describe("image strip grouping", () => {
it("groups 2+ consecutive image-only messages from same sender into imageGroup", async () => {
const wrapper = mountConversationViewer();
wrapper.vm.chatItems = [
makeChatItem({ hash: "img1", fields: { image: { image_type: "png" } } }),
makeChatItem({ hash: "img2", fields: { image: { image_type: "jpg" } } }),
];
await wrapper.vm.$nextTick();
const groups = wrapper.vm.selectedPeerChatDisplayGroups;
expect(groups).toHaveLength(1);
expect(groups[0].type).toBe("imageGroup");
expect(groups[0].items).toHaveLength(2);
});
it("does not group a single image-only message", async () => {
const wrapper = mountConversationViewer();
wrapper.vm.chatItems = [makeChatItem({ hash: "solo-img", fields: { image: { image_type: "png" } } })];
await wrapper.vm.$nextTick();
const groups = wrapper.vm.selectedPeerChatDisplayGroups;
expect(groups).toHaveLength(1);
expect(groups[0].type).toBe("single");
});
it("does not merge images with real captions into strip", async () => {
const wrapper = mountConversationViewer();
wrapper.vm.chatItems = [
makeChatItem({
hash: "captioned1",
content: "Beautiful landscape",
fields: { image: { image_type: "png" } },
}),
makeChatItem({
hash: "captioned2",
content: "Another beautiful shot",
fields: { image: { image_type: "png" } },
}),
];
await wrapper.vm.$nextTick();
const groups = wrapper.vm.selectedPeerChatDisplayGroups;
expect(groups).toHaveLength(2);
expect(groups.every((g) => g.type === "single")).toBe(true);
});
it("groups images with filename captions into strip", async () => {
const wrapper = mountConversationViewer();
wrapper.vm.chatItems = [
makeChatItem({
hash: "fn1",
content: "photo_001.jpg",
fields: { image: { image_type: "jpg" } },
}),
makeChatItem({
hash: "fn2",
content: "photo_002.png",
fields: { image: { image_type: "png" } },
}),
];
await wrapper.vm.$nextTick();
const groups = wrapper.vm.selectedPeerChatDisplayGroups;
expect(groups).toHaveLength(1);
expect(groups[0].type).toBe("imageGroup");
});
it("does not merge images from different senders", async () => {
const wrapper = mountConversationViewer();
const inbound = makeChatItem({
hash: "in-img",
fields: { image: { image_type: "png" } },
});
inbound.is_outbound = false;
const outbound = makeChatItem({
hash: "out-img",
source_hash: "my-hash",
destination_hash: "test-hash",
fields: { image: { image_type: "png" } },
});
outbound.is_outbound = true;
wrapper.vm.chatItems = [inbound, outbound];
await wrapper.vm.$nextTick();
const groups = wrapper.vm.selectedPeerChatDisplayGroups;
expect(groups).toHaveLength(2);
expect(groups.every((g) => g.type === "single")).toBe(true);
});
it("does not merge failed images into strip", async () => {
const wrapper = mountConversationViewer();
wrapper.vm.chatItems = [
makeChatItem({
hash: "fail-img",
state: "failed",
fields: { image: { image_type: "png" } },
}),
makeChatItem({
hash: "ok-img",
fields: { image: { image_type: "png" } },
}),
];
await wrapper.vm.$nextTick();
const groups = wrapper.vm.selectedPeerChatDisplayGroups;
expect(groups).toHaveLength(2);
expect(groups.every((g) => g.type === "single")).toBe(true);
});
it("does not merge spam images into strip", async () => {
const wrapper = mountConversationViewer();
wrapper.vm.chatItems = [
makeChatItem({
hash: "spam-img",
is_spam: true,
fields: { image: { image_type: "png" } },
}),
makeChatItem({ hash: "ok-img2", fields: { image: { image_type: "png" } } }),
];
await wrapper.vm.$nextTick();
const groups = wrapper.vm.selectedPeerChatDisplayGroups;
expect(groups).toHaveLength(2);
});
it("does not merge image with reply into strip", async () => {
const wrapper = mountConversationViewer();
wrapper.vm.chatItems = [
makeChatItem({
hash: "reply-img",
reply_to_hash: "some-hash",
fields: { image: { image_type: "png" } },
}),
makeChatItem({ hash: "plain-img", fields: { image: { image_type: "png" } } }),
];
await wrapper.vm.$nextTick();
const groups = wrapper.vm.selectedPeerChatDisplayGroups;
expect(groups).toHaveLength(2);
});
it("caps image strip at 12 images", async () => {
const wrapper = mountConversationViewer();
const items = [];
for (let i = 0; i < 15; i++) {
items.push(makeChatItem({ hash: `strip-${i}`, fields: { image: { image_type: "png" } } }));
}
wrapper.vm.chatItems = items;
await wrapper.vm.$nextTick();
const groups = wrapper.vm.selectedPeerChatDisplayGroups;
const imgGroup = groups.find((g) => g.type === "imageGroup");
expect(imgGroup).toBeDefined();
expect(imgGroup.items.length).toBeLessThanOrEqual(12);
});
});
describe("isLikelyMultiImagePlaceholderCaption", () => {
let wrapper;
beforeEach(() => {
wrapper = mountConversationViewer();
});
it("recognizes standard image filenames", () => {
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.jpg")).toBe(true);
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("IMG_20240101.png")).toBe(true);
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("screenshot.webp")).toBe(true);
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("image.heic")).toBe(true);
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("cat.gif")).toBe(true);
});
it("rejects non-image filenames", () => {
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("document.pdf")).toBe(false);
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("video.mp4")).toBe(false);
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("archive.zip")).toBe(false);
});
it("rejects normal text messages", () => {
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("hello world")).toBe(false);
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("check out this image")).toBe(false);
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("I sent you a .png file")).toBe(false);
});
it("rejects multiline text", () => {
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.jpg\nmore text")).toBe(false);
});
it("rejects text with path separators", () => {
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("/home/user/photo.jpg")).toBe(false);
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("C:\\Users\\photo.jpg")).toBe(false);
});
it("rejects text with special characters", () => {
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("<script>photo.jpg")).toBe(false);
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("[photo].jpg")).toBe(false);
});
it("rejects null or empty", () => {
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption(null)).toBe(false);
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("")).toBe(false);
});
it("rejects very long strings", () => {
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("a".repeat(250) + ".png")).toBe(false);
});
it("handles edge case extensions", () => {
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.jpeg")).toBe(true);
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.JPEG")).toBe(true);
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.PNG")).toBe(true);
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.avif")).toBe(true);
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.svg")).toBe(true);
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.bmp")).toBe(true);
});
});
describe("shouldHideAutoImageCaption", () => {
it("hides filename caption on image message", () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({
content: "photo_2024.jpg",
fields: { image: { image_type: "jpg" } },
});
expect(wrapper.vm.shouldHideAutoImageCaption(item)).toBe(true);
});
it("does not hide real text on image message", () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({
content: "Here is the picture from yesterday",
fields: { image: { image_type: "jpg" } },
});
expect(wrapper.vm.shouldHideAutoImageCaption(item)).toBe(false);
});
it("does not hide anything on non-image message", () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({ content: "photo.jpg", fields: {} });
expect(wrapper.vm.shouldHideAutoImageCaption(item)).toBe(false);
});
it("does not hide empty content", () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({
content: "",
fields: { image: { image_type: "png" } },
});
expect(wrapper.vm.shouldHideAutoImageCaption(item)).toBe(false);
});
});
describe("edge cases with multiple field combinations", () => {
it("image + text shows bubble (not image-only)", async () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({
content: "Check this out",
fields: { image: { image_type: "png" } },
});
wrapper.vm.chatItems = [item];
await wrapper.vm.$nextTick();
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1);
expect(wrapper.vm.isImageOnlyMessage(wrapper.vm.selectedPeerChatItems[0])).toBe(false);
});
it("audio + text renders and is not image-only", () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({
content: "Listen to this",
fields: { audio: { audio_mode: 0x10 } },
});
expect(wrapper.vm.hasRenderableContent(item.lxmf_message)).toBe(true);
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
});
it("file attachment with no text still shows", () => {
const wrapper = mountConversationViewer();
const msg = { content: "", fields: { file_attachments: [{ file_name: "a.zip" }] } };
expect(wrapper.vm.hasRenderableContent(msg)).toBe(true);
});
it("telemetry with text still shows", () => {
const wrapper = mountConversationViewer();
const msg = {
content: "My location",
fields: { telemetry: { location: { latitude: 0, longitude: 0 } } },
};
expect(wrapper.vm.hasRenderableContent(msg)).toBe(true);
});
it("image + audio + text is renderable but not image-only", () => {
const wrapper = mountConversationViewer();
const item = makeChatItem({
content: "multimedia",
fields: {
image: { image_type: "png" },
audio: { audio_mode: 0x10 },
},
});
expect(wrapper.vm.hasRenderableContent(item.lxmf_message)).toBe(true);
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
});
it("message with only unknown future fields is hidden", async () => {
const wrapper = mountConversationViewer();
wrapper.vm.chatItems = [
makeChatItem({
content: "",
fields: { future_feature: { data: "something" } },
}),
];
await wrapper.vm.$nextTick();
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0);
});
it("message with multiple unknown fields is hidden", async () => {
const wrapper = mountConversationViewer();
wrapper.vm.chatItems = [
makeChatItem({
content: "",
fields: {
sticker: { pack: "animals", id: 3 },
icon_appearance: { theme: "dark" },
},
}),
];
await wrapper.vm.$nextTick();
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0);
});
it("message with empty fields object is hidden", async () => {
const wrapper = mountConversationViewer();
wrapper.vm.chatItems = [makeChatItem({ content: "", fields: {} })];
await wrapper.vm.$nextTick();
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0);
});
});
});
});
+42
View File
@@ -59,6 +59,48 @@ describe("MessagesPage.vue", () => {
expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/lxmf/conversations", expect.any(Object));
});
it("debounces conversation search and sends search param to conversations API", async () => {
vi.useFakeTimers();
axiosMock.isCancel = vi.fn(() => false);
const wrapper = mountMessagesPage();
await wrapper.vm.$nextTick();
axiosMock.get.mockClear();
wrapper.vm.onConversationSearchChanged("findme");
await vi.advanceTimersByTimeAsync(100);
expect(axiosMock.get.mock.calls.filter((c) => c[0] === "/api/v1/lxmf/conversations")).toHaveLength(0);
await vi.advanceTimersByTimeAsync(200);
const convCalls = axiosMock.get.mock.calls.filter((c) => c[0] === "/api/v1/lxmf/conversations");
expect(convCalls.length).toBeGreaterThanOrEqual(1);
expect(convCalls[convCalls.length - 1][1].params.search).toBe("findme");
vi.useRealTimers();
});
it("debounces peers search and sends search param to announces API", async () => {
vi.useFakeTimers();
axiosMock.isCancel = vi.fn(() => false);
const wrapper = mountMessagesPage();
await wrapper.vm.$nextTick();
axiosMock.get.mockClear();
wrapper.vm.onPeersSearchChanged("peerq");
await vi.advanceTimersByTimeAsync(400);
expect(
axiosMock.get.mock.calls.filter(
(c) => c[0] === "/api/v1/announces" && c[1]?.params?.aspect === "lxmf.delivery"
)
).toHaveLength(0);
await vi.advanceTimersByTimeAsync(200);
const ann = axiosMock.get.mock.calls.filter(
(c) => c[0] === "/api/v1/announces" && c[1]?.params?.aspect === "lxmf.delivery"
);
expect(ann.length).toBeGreaterThanOrEqual(1);
expect(ann[ann.length - 1][1].params.search).toBe("peerq");
vi.useRealTimers();
});
it("opens ingest paper message modal", async () => {
const wrapper = mountMessagesPage();
await wrapper.vm.$nextTick();
+46
View File
@@ -376,4 +376,50 @@ describe("NetworkVisualiser.vue", () => {
// After unmount, the cache should be empty or the reference should be cleared
expect(Object.keys(cacheRef).length).toBe(0);
});
it("does not add offline interfaces when showDisabledInterfaces is false", async () => {
vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
const wrapper = mountVisualiser();
wrapper.vm.network = {
getPositions: vi.fn().mockReturnValue({}),
setOptions: vi.fn(),
on: vi.fn(),
destroy: vi.fn(),
};
wrapper.vm.config = { display_name: "Me", identity_hash: "abc" };
wrapper.vm.interfaces = [
{ name: "eth_up", status: true, bitrate: 1000, txb: 0, rxb: 0 },
{ name: "eth_down", status: false, bitrate: 0, txb: 0, rxb: 0 },
];
wrapper.vm.showDisabledInterfaces = false;
await wrapper.vm.processVisualization();
expect(wrapper.vm.nodes.getIds()).toContain("eth_up");
expect(wrapper.vm.nodes.getIds()).not.toContain("eth_down");
});
it("keeps node positions from getPositions on subsequent layout passes", async () => {
vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
const wrapper = mountVisualiser();
const getPositions = vi
.fn()
.mockReturnValueOnce({})
.mockReturnValue({
me: { x: 0, y: 0 },
eth0: { x: 301, y: 404 },
});
wrapper.vm.network = {
getPositions,
setOptions: vi.fn(),
on: vi.fn(),
destroy: vi.fn(),
};
wrapper.vm.config = { display_name: "Me", identity_hash: "abc" };
wrapper.vm.interfaces = [{ name: "eth0", status: true, bitrate: 1000, txb: 0, rxb: 0 }];
await wrapper.vm.processVisualization();
await wrapper.vm.processVisualization();
const n = wrapper.vm.nodes.get("eth0");
expect(n.x).toBe(301);
expect(n.y).toBe(404);
expect(getPositions.mock.calls.length).toBeGreaterThanOrEqual(2);
});
});
+17
View File
@@ -63,6 +63,23 @@ describe("NomadNetworkPage.vue", () => {
expect(wrapper.text()).toContain("nomadnet.no_active_node");
});
it("debounces node search and passes search param to announces API", async () => {
vi.useFakeTimers();
axiosMock.isCancel = vi.fn(() => false);
const wrapper = mountNomadNetworkPage();
await wrapper.vm.$nextTick();
axiosMock.get.mockClear();
wrapper.vm.onNodesSearchChanged("nodequery");
await vi.advanceTimersByTimeAsync(500);
const calls = axiosMock.get.mock.calls.filter((c) => c[0] === "/api/v1/announces");
expect(calls.length).toBeGreaterThanOrEqual(1);
const last = calls[calls.length - 1];
expect(last[1].params.aspect).toBe("nomadnetwork.node");
expect(last[1].params.search).toBe("nodequery");
vi.useRealTimers();
});
it("loads node when destinationHash prop is provided", async () => {
const destHash = "0123456789abcdef0123456789abcdef";
axiosMock.get.mockImplementation((url) => {