"""Tests for the channel-management feature: pure logic, QR rendering, and the tool. The logic (secret derivation, share-URL round-tripping) is hardware-free; the tool actions run against the :class:`MockDevice` simulator and a temporary database. """ from __future__ import annotations import re from hashlib import sha256 from pathlib import Path import pytest from rich.console import Console from meshterm.context import AppContext from meshterm.core.admin_store import AdminStore from meshterm.core.channels import ( CHANNEL_SECRET_BYTES, CHANNEL_SLOT_EMPTY_RUN, CHANNEL_SLOT_PROBE_CAP, DEFAULT_PUBLIC_SECRET, channel_hash, decrypt_channel_text, derive_secret, full_channel_hash, normalize_secret, parse_share_url, random_secret, share_url, ) from meshterm.core.config import Settings from meshterm.core.device_store import DeviceStore from meshterm.persistence.repository import Repository from meshterm.services.chat_service import ChatService from meshterm.tools.channels import ChannelsTool from meshterm.ui.channels import ( _CREATE, ChannelSlot, _apply_order, _next_free_slot, read_channel_slots, ) from meshterm.ui.qr import qr_text _ANSI = re.compile(r"\x1b\[[0-9;]*m") # -- pure channel logic ------------------------------------------------------- def test_derive_secret_matches_firmware_formula() -> None: """A derived key is sha256(name)[:16] — the firmware's public-channel scheme.""" assert derive_secret("#public") == sha256(b"#public").digest()[:16] assert len(derive_secret("#anything")) == CHANNEL_SECRET_BYTES assert derive_secret("#a") == derive_secret("#a") # deterministic def test_random_secret_is_16_unique_bytes() -> None: """A random private key is 16 bytes and (practically) never repeats.""" a, b = random_secret(), random_secret() assert len(a) == CHANNEL_SECRET_BYTES and len(b) == CHANNEL_SECRET_BYTES assert a != b def test_normalize_secret_accepts_common_shapes() -> None: """A pasted key is accepted with 0x, spaces, or colons; length is enforced.""" raw = "000102030405060708090a0b0c0d0e0f" expected = bytes(range(16)) assert normalize_secret(raw) == expected assert normalize_secret("0x" + raw) == expected assert normalize_secret("0001 0203 0405 0607 0809 0a0b 0c0d 0e0f") == expected assert normalize_secret("00:01:02:03:04:05:06:07:08:09:0a:0b:0c:0d:0e:0f") == expected @pytest.mark.parametrize("bad", ["", "abcd", "zz" * 16, "00" * 15, "00" * 17]) def test_normalize_secret_rejects_bad_keys(bad: str) -> None: """Non-hex or wrong-length keys raise, so the prompt can re-ask.""" with pytest.raises(ValueError): normalize_secret(bad) def test_channel_hash_is_two_hex_chars() -> None: """The channel hash is the leading byte of sha256(secret), as the companion reports.""" secret = bytes(range(16)) assert channel_hash(secret) == sha256(secret).hexdigest()[:2] assert len(channel_hash(secret)) == 2 def test_full_channel_hash_is_the_whole_digest_led_by_the_short_hash() -> None: """The full hash is the complete sha256(secret) digest, opening with the short hash.""" secret = bytes(range(16)) full = full_channel_hash(secret) assert full == sha256(secret).hexdigest() assert len(full) == 64 assert full[:2] == channel_hash(secret) # the highlighted first byte def test_share_url_round_trips_with_encoding() -> None: """A share URL builds and parses back to the same name and secret, encoding included.""" name, secret = "Ops Team #1", bytes(range(16)) url = share_url(name, secret) assert url.startswith("meshcore://channel/add?") assert "Ops%20Team" in url # the space is percent-encoded parsed = parse_share_url(url) assert parsed == (name, secret) def test_parse_share_url_rejects_non_channel_links() -> None: """Anything that isn't a valid channel-add link parses to None.""" assert parse_share_url("https://example.com") is None assert parse_share_url("meshcore://contact/add?name=x&public_key=ab") is None assert parse_share_url("meshcore://channel/add?name=x") is None # missing secret assert parse_share_url("meshcore://channel/add?name=x&secret=nothex") is None assert parse_share_url("meshcore://channel/add?secret=" + "00" * 16) is None # no name # -- channel-text decryption --------------------------------------------------- def _grp_txt_frame( name: str, secret: bytes, text: str, *, timestamp: int = 1_700_000_000, attempt: int = 0 ) -> tuple[str, str, str]: """Build a firmware-shaped GRP_TXT frame (chan_hash, cipher_mac, crypted), all hex. Mirrors the encoding :func:`~meshterm.core.channels.decrypt_channel_text` reverses: a 4-byte little-endian timestamp, an attempt/type byte, the text, zero-padded to a whole AES block, encrypted ECB, then MAC'd with HMAC-SHA256 truncated to 2 bytes. """ from Crypto.Cipher import AES from Crypto.Hash import HMAC, SHA256 from meshterm.core.channels import effective_secret key = effective_secret(name, secret) plain = timestamp.to_bytes(4, "little") + bytes([attempt & 3]) + text.encode("utf-8") plain += b"\x00" * (-len(plain) % 16) crypted = AES.new(key, AES.MODE_ECB).encrypt(plain) mac = HMAC.new(key, digestmod=SHA256) mac.update(crypted) return channel_hash(key), mac.digest()[:2].hex(), crypted.hex() def test_decrypt_channel_text_round_trips_a_known_channel() -> None: """A frame encrypted for a channel we hold the key for decodes cleanly.""" name, secret = "#general", derive_secret("#general") chash, mac, crypted = _grp_txt_frame(name, secret, "hello mesh") result = decrypt_channel_text(chash, mac, crypted, [(name, secret)]) assert result is not None assert result.channel_name == name assert result.text == "hello mesh" assert result.attempt == 0 assert result.sent_at is not None def test_decrypt_channel_text_reports_a_resend_attempt() -> None: """The sender's resend counter survives into the decrypted result.""" name, secret = "Ops", random_secret() chash, mac, crypted = _grp_txt_frame(name, secret, "again", attempt=2) result = decrypt_channel_text(chash, mac, crypted, [(name, secret)]) assert result is not None and result.attempt == 2 def test_decrypt_channel_text_returns_none_for_an_unknown_channel() -> None: """A frame from a channel we don't hold the key for decrypts to nothing.""" name, secret = "#general", derive_secret("#general") chash, mac, crypted = _grp_txt_frame(name, secret, "hello mesh") other = ("Ops", random_secret()) assert decrypt_channel_text(chash, mac, crypted, [other]) is None assert decrypt_channel_text(chash, mac, crypted, []) is None def test_decrypt_channel_text_rejects_a_fingerprint_collision() -> None: """A candidate sharing the frame's hash byte but not its key fails the MAC check. The one-byte channel-hash fingerprint can collide between unrelated channels; the MAC is what actually confirms the key, so a same-fingerprint wrong channel must not be mistaken for a match, and decryption must fall through to try the next (correct) candidate instead of stopping at the collision. """ name, secret = "#general", derive_secret("#general") chash, mac, crypted = _grp_txt_frame(name, secret, "hello mesh") # Brute-force a same-fingerprint, wrong-key decoy (a 1-byte hash, so cheap to find). decoy = next( s for s in (random_secret() for _ in range(10_000)) if channel_hash(s) == chash and s != secret ) result = decrypt_channel_text(chash, mac, crypted, [("decoy", decoy), (name, secret)]) assert result is not None and result.text == "hello mesh" # falls through to the real key def test_decrypt_channel_text_rejects_malformed_hex() -> None: """Garbage hex fields fail closed instead of raising.""" assert decrypt_channel_text("zz", "zzzz", "zzzzzzzzzzzzzzzz", [("x", random_secret())]) is None # a 15-byte body is not block-aligned assert decrypt_channel_text("00", "0000", "00" * 15, [("x", random_secret())]) is None # -- QR rendering ------------------------------------------------------------- def test_qr_text_renders_square_with_quiet_zone() -> None: """qr_text produces block rows framed by a light quiet zone, wider than the data.""" console = Console(force_terminal=True, width=120, file=__import__("io").StringIO()) console.print(qr_text("meshcore://channel/add?name=Test&secret=" + "ab" * 16)) plain = _ANSI.sub("", console.file.getvalue()).rstrip("\n") lines = plain.splitlines() assert lines, "QR produced no output" # A version-appropriate QR for this URL is at least ~25 modules wide plus an 8-module # quiet zone; the finder pattern makes the code non-trivial. assert len(lines[0]) >= 30 assert any("█" in line for line in lines) # dark modules were drawn # -- channel slot model ------------------------------------------------------- def test_channel_slot_classifies_public_and_private() -> None: """A #-named or name-derived slot reads as public; a random-key slot as private.""" public = ChannelSlot(idx=0, name="#public", secret=derive_secret("#public")) assert public.is_public derived = ChannelSlot(idx=1, name="general", secret=derive_secret("general")) assert derived.is_public # key derived from the name, even without a leading # private = ChannelSlot(idx=2, name="Ops", secret=bytes(range(16))) assert not private.is_public assert not private.is_name_derived # The firmware default "Public" is public but keyed by a fixed well-known secret, not by # its name — so it reads as public without being name-derived (its key must be preserved). default_public = ChannelSlot(idx=0, name="Public", secret=DEFAULT_PUBLIC_SECRET) assert default_public.is_public assert not default_public.is_name_derived assert channel_hash(DEFAULT_PUBLIC_SECRET) == "11" # the fingerprint devices report for it assert private.conversation.is_channel and private.conversation.channel_idx == 2 assert private.conversation.label == "Ops" # raw name, no forced leading '#' # The conversation is keyed by the channel's identity, not its slot. assert private.conversation.channel_id == private.identity assert private.conversation.key == f"chan:{private.identity}" def test_next_free_slot_finds_gaps_and_full() -> None: """The next free slot skips used indices and is None when every slot is taken.""" slots = [ ChannelSlot(idx=0, name="a", secret=b"\x00" * 16), ChannelSlot(idx=2, name="c", secret=b"\x00" * 16), ] assert _next_free_slot(slots, capacity=8) == 1 full = [ChannelSlot(idx=i, name=str(i), secret=b"\x00" * 16) for i in range(8)] assert _next_free_slot(full, capacity=8) is None # With a larger discovered capacity the same "full-at-8" set still has room. assert _next_free_slot(full, capacity=16) == 8 async def test_channel_capacity_is_probed_not_assumed(ctx: AppContext) -> None: """Capacity comes from probing the device, and counts slots, not their occupants.""" device = await ctx.device() assert await device.channel_capacity() == 8 # the mock models stock 8-slot firmware # Occupying some slots must not change the ceiling — capacity is how many slots exist. await device.set_channel(0, "Alpha", bytes(range(16))) await device.set_channel(3, "Bravo", bytes(range(16))) assert await device.channel_capacity() == 8 async def test_channel_capacity_tracks_a_larger_ceiling(ctx: AppContext) -> None: """Firmware with more slots is discovered as such — no hard-coded 8 anywhere. This is exactly why the empty-run bound is *not* applied to capacity discovery: a larger firmware whose upper slots are empty must still be found by reaching its rejection, not guessed short from a run of empties. """ device = await ctx.device() device._max_channels = 12 # simulate a firmware build with a larger slot table assert await device.channel_capacity() == 12 async def test_read_channel_slots_bounds_a_never_rejecting_probe() -> None: """A probe that is never rejected still bounds itself. On firmware that answers every slot, the configured-slot scan stops after a run of empty slots instead of walking all of ``CHANNEL_SLOT_PROBE_CAP``. """ class NeverRejects: """A device that reports two channels then empty slots forever, never raising.""" def __init__(self) -> None: self.reads = 0 async def get_channel(self, idx: int): self.reads += 1 if idx in (0, 1): return {"channel_name": f"c{idx}", "channel_secret": b"\x00" * 16} return None # an empty slot — and it will never reject a higher index device = NeverRejects() slots = await read_channel_slots(device) # type: ignore[arg-type] assert [s.name for s in slots] == ["c0", "c1"] # both real channels found # Stopped after the two channels plus one run of empties — not the full 64-slot walk. assert device.reads == 2 + CHANNEL_SLOT_EMPTY_RUN assert device.reads < CHANNEL_SLOT_PROBE_CAP async def test_read_channel_slots_scans_past_gaps_within_capacity() -> None: """A cleared middle slot (a gap) never truncates the scan: channels above it are still read. The empty-run bound only ends the scan after a full stock-capacity's worth of consecutive empties, which a within-capacity gap can never reach — so a channel sitting above a gap on never-rejecting firmware is still found. """ class NeverRejects: def __init__(self, occupied: dict[int, str]) -> None: self.occupied = occupied self.reads = 0 async def get_channel(self, idx: int): self.reads += 1 name = self.occupied.get(idx) if name is None: return None return {"channel_name": name, "channel_secret": b"\x00" * 16} # Channels at slots 0 and 7 with 1..6 empty (a 6-slot gap, one short of the stop run). device = NeverRejects({0: "low", 7: "high"}) slots = await read_channel_slots(device) # type: ignore[arg-type] assert [s.idx for s in slots] == [0, 7] # the gap did not end the scan early async def test_apply_order_relays_channels_into_new_positions(ctx: AppContext) -> None: """Applying a new order re-lays channels across the same slots in display order.""" device = await ctx.device() await device.set_channel(0, "Alpha", bytes(range(16))) # private await device.set_channel(1, "#beta", None) # public, key derived from the name await device.set_channel(2, "Gamma", bytes(range(16, 32))) # private slots = await read_channel_slots(device) # Reverse the display order: the row that was third moves first, first moves last. writes = await _apply_order(ctx, device, slots, [2, 1, 0]) assert writes == 2 # the middle channel keeps its slot; the two ends swap after = {s.idx: s for s in await read_channel_slots(device)} assert after[0].name == "Gamma" and after[0].secret == bytes(range(16, 32)) assert after[1].name == "#beta" and after[1].is_public # public key re-derived in place assert after[2].name == "Alpha" and after[2].secret == bytes(range(16)) def test_channel_identity_is_stable_across_slot_moves() -> None: """A channel's identity depends on its key material, not on which slot it occupies.""" at_two = ChannelSlot(idx=2, name="Ops", secret=bytes(range(16))) at_five = ChannelSlot(idx=5, name="Ops", secret=bytes(range(16))) assert at_two.identity == at_five.identity # same channel, different slot → same identity other = ChannelSlot(idx=2, name="Ops", secret=bytes(range(16, 32))) assert other.identity != at_two.identity # a different key → a different channel class _ScriptedUi: """A UI surface that replays queued answers, for driving the channel manager headless. ``select``/``text``/``dialog`` each pop their next scripted answer; the display methods are no-ops. Enough of the :class:`~meshterm.ui.surface.Ui` contract for the channel manager's create/clear flows. """ def __init__(self, selects: list, texts: list, dialogs: list) -> None: self._selects = list(selects) self._texts = list(texts) self._dialogs = list(dialogs) def show(self, *renderables) -> None: # noqa: ANN002 pass def note(self, markup: str) -> None: pass def ack(self, markup: str) -> None: pass async def view(self, renderable, *, title: str = "", footer_hint: str = "") -> None: # noqa: ANN001 pass async def select(self, title: str, items: list, *, default=None): # noqa: ANN001, ANN201 return self._selects.pop(0) if self._selects else "__back__" async def text( # noqa: ANN001, ANN201 self, title: str, *, default: str = "", validate=None, help_text: str = "", password: bool = False, ): return self._texts.pop(0) async def dialog( # noqa: ANN001, ANN201 self, prompt, buttons, *, title: str = "", default: int = 0, keys=None, danger: bool = False, destructive: bool = False, ): return self._dialogs.pop(0) async def test_recreating_a_slot_refiles_messages_to_the_new_channel(ctx: AppContext) -> None: """Clearing a channel and creating another that reuses its slot must not cross history. Reproduces the reported bug: inbound channel messages carry only a slot index, which the chat service maps to a channel identity through a cache. When a slot is cleared and a new channel takes it, a stale cache filed the newcomer's messages under the old channel — so opening the old channel showed the new one's transcript. The manager must refresh the cache after the mutation so a message on the reused slot lands in the right channel. """ from meshterm.core.channels import channel_identity from meshterm.core.events import MeshEvent from meshterm.core.models import Message from meshterm.ui.channels import _CLEAR, _CREATE, manage_channels device = await ctx.device() await device.set_channel(0, "Public", DEFAULT_PUBLIC_SECRET) public_id = channel_identity("Public", DEFAULT_PUBLIC_SECRET) await ctx.chat.start() # prime the slot→identity cache (slot 0 == Public) try: # A message on Public arrives before we touch anything — files under Public. The # inbound worker resolves it against the slot as it stands now (Public), which is # exactly what happens live: messages are recorded on arrival, ahead of any later edit. ctx.events.publish( MeshEvent.message_event(Message(text="hi public", channel=0, is_channel=True)) ) await ctx.chat._queue.join() # Drive the manager: open Public's detail → clear it, then create a new private # channel (which reuses freed slot 0), then leave with Esc. ctx.ui = _ScriptedUi( selects=[0, _CLEAR, _CREATE, None], texts=["Ops"], # the new channel's name dialogs=["clear"], # confirm the clear on its Cancel/Clear dialog ) await manage_channels(ctx) ops = next(s for s in await read_channel_slots(device) if s.name == "Ops") assert ops.idx == 0 # the new channel took the freed slot # A message now arrives on slot 0 — which is Ops, not Public. ctx.events.publish( MeshEvent.message_event(Message(text="ops secret", channel=0, is_channel=True)) ) await ctx.chat._queue.join() public_msgs = ctx.repo.recent_chat_messages(is_channel=True, channel_id=public_id) ops_msgs = ctx.repo.recent_chat_messages(is_channel=True, channel_id=ops.identity) assert [m.text for m in public_msgs] == ["hi public"] # unchanged, no leakage assert [m.text for m in ops_msgs] == ["ops secret"] # filed under the right channel finally: await ctx.chat.stop() async def test_reorder_keeps_history_because_key_is_intrinsic(ctx: AppContext) -> None: """Reordering channels leaves each channel's transcript with it — no migration needed. Reproduces the reported bug's setup (a channel moved to a new slot) and asserts the fix: because history is keyed by channel identity rather than slot, the moved channel keeps its own messages and the slot it landed on inherits none. """ device = await ctx.device() await device.set_channel(0, "Alpha", bytes(range(16))) await device.set_channel(1, "Beta", bytes(range(16, 32))) chat = ChatService(ctx) await chat.start() try: await chat.send_channel(0, "hello from alpha", label="Alpha") await chat.send_channel(1, "hello from beta", label="Beta") slots = await read_channel_slots(device) before = {s.name: s for s in slots} alpha_id, beta_id = before["Alpha"].identity, before["Beta"].identity # Swap the two channels' slots, then refresh the service's slot→identity cache. writes = await _apply_order(ctx, device, slots, [1, 0]) assert writes == 2 await chat.refresh_channels() after = {s.name: s for s in await read_channel_slots(device)} assert after["Alpha"].idx == 1 and after["Beta"].idx == 0 # slots actually swapped assert after["Alpha"].identity == alpha_id # identity is unchanged by the move # History still resolves per channel by identity — no transcript was inherited. alpha_msgs = ctx.repo.recent_chat_messages(is_channel=True, channel_id=alpha_id) beta_msgs = ctx.repo.recent_chat_messages(is_channel=True, channel_id=beta_id) assert [m.text for m in alpha_msgs] == ["hello from alpha"] assert [m.text for m in beta_msgs] == ["hello from beta"] finally: await chat.stop() # -- message statistics in the manager ---------------------------------------- def test_channel_stats_aggregates_totals_window_and_recency(ctx: AppContext) -> None: """Per-channel stats count all messages, the trailing-window slice, and the last time.""" from datetime import timedelta from meshterm.core.models import ChatMessage, utcnow stale = utcnow() - timedelta(days=30) # outside the activity window fresh = utcnow() - timedelta(minutes=2) for text, when in (("old a", stale), ("old b", stale), ("new", fresh)): ctx.repo.record_chat_message( ChatMessage(text=text, is_channel=True, channel_id="ops", created_at=when) ) ctx.repo.record_chat_message( # a direct message must not leak into channel stats ChatMessage(text="dm", is_channel=False, peer="abc123", created_at=fresh) ) ctx.repo.record_chat_message( # a legacy row without an identity has nothing to count under ChatMessage(text="legacy", is_channel=True, channel_id=None, created_at=fresh) ) stats = ctx.repo.channel_stats() assert set(stats) == {"ops"} ops = stats["ops"] assert ops.total == 3 assert ops.recent == 1 # only the fresh message falls inside the window assert ops.last_at is not None assert abs((ops.last_at - fresh).total_seconds()) < 1 # The histogram spans the window newest-first: the 2-minute-old message lands in the # "now" (first) bucket and the 30-day-old ones land nowhere. It carries the full # window (deeper than the sparkline draws) so the shared scaling peak has history. assert len(ops.histogram) == 72 assert ops.histogram[0] == 1 and sum(ops.histogram) == 1 def test_activity_sparkline_packs_24_buckets_into_braille() -> None: """The sparkline draws two buckets per cell, scaled to the shared peak, newest right.""" from meshterm.ui.channels import _activity_sparkline assert _activity_sparkline((0,) * 24, 0).plain == "⣀" * 12 # silent window: a flatline assert _activity_sparkline((), 0).plain == "⣀" * 12 # a channel with no stats at all assert _activity_sparkline((21,) * 24, 21).plain == "⣿" * 12 # every bucket at the peak # A bucket well under the shared peak rides the baseline as a single dot in the # *final* cell's right column (now sits at the right edge) — so the glyph matches # the flatline and the ok-green style is what marks it as traffic. lone = _activity_sparkline((1,) + (0,) * 23, 16) assert lone.plain == "⣀" * 12 styles = [span.style for span in lone.spans] assert styles[-1] == "ok" and set(styles[:-1]) == {"faint"} # Heights scale to the peak: a 16/12/8/4 ramp climbs quarter→full, the newest # (fullest here) pair in the rightmost cell. ramp = _activity_sparkline((16, 12, 8, 4) + (0,) * 20, 16).plain assert ramp[-2:] == chr(0x2800 | 0x40 | 0xA0) + chr(0x2800 | 0x46 | 0xB8) async def test_channel_rows_carry_stats_unread_and_lanes(ctx: AppContext) -> None: """A list row shows the channel's type, unread badge, counts, age, and activity meter.""" from meshterm.core.models import ChatMessage from meshterm.ui.channels import _LiveStats, _menu_items from meshterm.ui.tui import Choice device = await ctx.device() await device.set_channel(0, "Ops", bytes(range(16))) slots = await read_channel_slots(device) slot = slots[0] for text in ("one", "two"): ctx.repo.record_chat_message( ChatMessage(text=text, is_channel=True, channel_id=slot.identity) ) ctx.chat._unread[slot.conversation.key] = 2 # as the service would after two arrivals title, items = _menu_items(ctx, slots, 8, _LiveStats(ctx)) assert title == "Channels — 1/8 slots" row = next(it for it in items if isinstance(it, Choice) and it.value == 0) plain = row.label.plain # the title is a live callable; .label resolves it assert "Ops" in plain # No TYPE or HASH lane: the glyph carries openness, Show key carries the hash — # the freed cells keep the activity lane visible at 72 columns. assert "private" not in plain and slot.hash not in plain assert "● 2" in plain # the unread badge assert "now" in plain # the just-recorded message's age # Both just-recorded messages sit in the sparkline's newest bucket. With only this # channel carrying traffic it *is* the shared peak, so the final cell's right column # fills to full height at the row's right edge, the rest of the window on the flatline. assert plain.rstrip().endswith("⣀" * 11 + chr(0x2800 | 0x40 | 0xB8)) label = row.label assert label.spans[-1].style == "ok" # …and its newest cell reads as live traffic async def test_standard_public_row_appears_only_while_it_is_absent(ctx: AppContext) -> None: """The 'Standard Public channel' add-action shows until a slot holds the fixed-key default.""" from meshterm.core.channels import DEFAULT_PUBLIC_SECRET from meshterm.ui.channels import _DEFAULT_PUBLIC, _add_default_public, _LiveStats, _menu_items from meshterm.ui.tui import Choice device = await ctx.device() def has_default_row(slots: list) -> bool: _, items = _menu_items(ctx, slots, 8, _LiveStats(ctx)) return any(isinstance(it, Choice) and it.value == _DEFAULT_PUBLIC for it in items) # Absent on an empty table and while an unrelated channel occupies a slot. assert has_default_row([]) await device.set_channel(1, "Ops", bytes(range(16))) slots = await read_channel_slots(device) assert has_default_row(slots) # Adding it lands the well-known secret on the next free slot and retires the offer. added = await _add_default_public(ctx, device, slots, 8) assert added == 1 slots = await read_channel_slots(device) public = next(s for s in slots if s.secret == DEFAULT_PUBLIC_SECRET) assert public.name == "Public" and public.is_public assert not has_default_row(slots) async def test_clearing_a_channel_reads_off_its_unread(ctx: AppContext) -> None: """Clearing a channel drops its unread from the global total, not just the slot.""" from meshterm.ui.channels import _clear device = await ctx.device() await device.set_channel(0, "Ops", bytes(range(16))) slot = (await read_channel_slots(device))[0] ctx.chat._unread[slot.conversation.key] = 3 # as the service would after three arrivals assert ctx.chat.unread_total() == 3 ctx.ui = _ScriptedUi(selects=[], texts=[], dialogs=["clear"]) assert await _clear(ctx, device, slot) is True assert ctx.chat.unread_total() == 0 async def test_show_key_popup_wraps_values_under_header_labels() -> None: """The key popup is labelled blocks, not a table, and long values wrap whole.""" from types import SimpleNamespace from meshterm.ui.channels import _show_key from meshterm.ui.tui.render import render_lines captured: dict = {} async def view(renderable, *, title="", footer_hint=""): # noqa: ANN001, ANN202 captured["renderable"] = renderable captured["title"] = title slot = ChannelSlot(idx=0, name="Ops", secret=bytes(range(16))) await _show_key(SimpleNamespace(ui=SimpleNamespace(view=view)), slot) assert captured["title"] == "Key — Ops" # Render at a deliberately narrow dialog width: every value must survive whole, # wrapped across lines, with no ellipsis truncation anywhere. lines = [_ANSI.sub("", line) for line in render_lines(captured["renderable"], 40)] text = "\n".join(lines) for label in ("NAME", "TYPE", "HASH", "KEY", "LINK"): assert label in text assert "…" not in text joined = text.replace("\n", "").replace(" ", "") assert slot.secret.hex() in joined # the 32-hex key, reassembled across wraps assert full_channel_hash(slot.secret) in joined # the 64-hex hash likewise assert share_url("Ops", slot.secret).replace(" ", "") in joined async def test_detail_summary_reads_slot_totals_and_unread(ctx: AppContext) -> None: """The detail screen's vital-signs line covers slot, totals, unread, and recency.""" from meshterm.core.models import ChatMessage from meshterm.ui.channels import _detail_summary, _LiveStats device = await ctx.device() await device.set_channel(3, "Ops", bytes(range(16))) slot = next(s for s in await read_channel_slots(device) if s.idx == 3) assert _detail_summary(ctx, slot, _LiveStats(ctx)) == "Slot 3 · no messages recorded yet" ctx.repo.record_chat_message(ChatMessage(text="hi", is_channel=True, channel_id=slot.identity)) ctx.chat._unread[slot.conversation.key] = 1 summary = _detail_summary(ctx, slot, _LiveStats(ctx)) # A fresh age reads as bare "now" (the app-wide format_ago grammar — never "now ago"). assert summary == "Slot 3 · 1 message · 1 unread · last message now" # -- muting channel notifications --------------------------------------------- def test_mute_store_round_trips_and_persists(tmp_path: Path) -> None: """A muted channel is remembered across store reloads; unmuting forgets it.""" from meshterm.core.mute_store import MuteStore path = tmp_path / "mutes.json" store = MuteStore(path) assert store.is_muted("wardriving-id") is False assert store.is_muted(None) is False # an unresolved channel is never muted store.set_muted("wardriving-id", True) assert store.is_muted("wardriving-id") is True assert MuteStore(path).is_muted("wardriving-id") is True # survived a reload from disk store.set_muted("wardriving-id", False) assert store.is_muted("wardriving-id") is False assert MuteStore(path).muted() == set() # the unmute persisted too async def test_muting_suppresses_unread_but_still_records(ctx: AppContext) -> None: """A muted channel's inbound message lands in history but never bumps the unread badge.""" from meshterm.core.events import MeshEvent from meshterm.core.models import Message device = await ctx.device() await device.set_channel(0, "#wardriving", None) # public, key derived from the name slot = (await read_channel_slots(device))[0] ctx.mute_store.set_muted(slot.identity, True) await ctx.chat.start() try: ctx.events.publish( MeshEvent.message_event(Message(text="auto beacon", channel=0, is_channel=True)) ) await ctx.chat._queue.join() # Muted: no unread accrued anywhere, but the transcript is all there. assert ctx.chat.unread(slot.conversation.key) == 0 assert ctx.chat.unread_total() == 0 stored = ctx.repo.recent_chat_messages(is_channel=True, channel_id=slot.identity) assert [m.text for m in stored] == ["auto beacon"] finally: await ctx.chat.stop() async def test_unmuted_channel_still_bumps_unread(ctx: AppContext) -> None: """The control case: an ordinary (unmuted) channel does raise the unread badge.""" from meshterm.core.events import MeshEvent from meshterm.core.models import Message device = await ctx.device() await device.set_channel(0, "Ops", bytes(range(16))) slot = (await read_channel_slots(device))[0] await ctx.chat.start() try: ctx.events.publish(MeshEvent.message_event(Message(text="hey", channel=0, is_channel=True))) await ctx.chat._queue.join() assert ctx.chat.unread(slot.conversation.key) == 1 finally: await ctx.chat.stop() async def test_toggle_mute_zeros_unread_and_persists(ctx: AppContext) -> None: """Muting through the detail toggle flips the store and clears the channel's unread.""" from meshterm.ui.channels import _is_muted, _toggle_mute device = await ctx.device() await device.set_channel(0, "Ops", bytes(range(16))) slot = (await read_channel_slots(device))[0] ctx.chat._unread[slot.conversation.key] = 4 # as the service would after four arrivals assert ctx.chat.unread_total() == 4 _toggle_mute(ctx, slot) # mute assert _is_muted(ctx, slot) is True assert ctx.chat.unread(slot.conversation.key) == 0 # muting zeroed it assert ctx.chat.unread_total() == 0 _toggle_mute(ctx, slot) # unmute assert _is_muted(ctx, slot) is False async def test_muted_channel_row_shows_bell_not_unread(ctx: AppContext) -> None: """A muted channel's list row carries the 🔕 glyph in place of an unread badge.""" from meshterm.ui.channels import _LiveStats, _menu_items from meshterm.ui.tui import Choice device = await ctx.device() await device.set_channel(0, "#wardriving", None) slots = await read_channel_slots(device) slot = slots[0] ctx.chat._unread[slot.conversation.key] = 3 # stale unread that muting must not display ctx.mute_store.set_muted(slot.identity, True) _, items = _menu_items(ctx, slots, 8, _LiveStats(ctx)) row = next(it for it in items if isinstance(it, Choice) and it.value == 0) plain = row.label.plain assert "🔕" in plain assert "● 3" not in plain # the mute glyph replaces the unread badge async def test_detail_mute_row_reflects_state(ctx: AppContext) -> None: """The detail's notifications row reads 'Mute' when on and 'Unmute' once muted.""" from meshterm.ui.channels import _detail_items from meshterm.ui.tui import Choice device = await ctx.device() await device.set_channel(0, "Ops", bytes(range(16))) slot = (await read_channel_slots(device))[0] def mute_label() -> str: items = _detail_items(ctx, slot) row = next(it for it in items if isinstance(it, Choice) and it.value == "mute") return row.label.plain assert "🔕 Mute notifications" in mute_label() ctx.mute_store.set_muted(slot.identity, True) assert "🔔 Unmute notifications" in mute_label() # -- the tool against the simulator ------------------------------------------- @pytest.fixture() def ctx(tmp_path: Path) -> AppContext: """A mock-backed application context with the plain (console) UI surface.""" settings = Settings(config_dir=tmp_path, db_path=tmp_path / "chan.db") context = AppContext( console=Console(file=__import__("io").StringIO()), settings=settings, repo=Repository(settings.db_path), device_store=DeviceStore(tmp_path / "devices.json"), admin_store=AdminStore(tmp_path / "admin.json"), mock=True, ) yield context context.repo.close() async def test_cli_add_private_generates_key_and_lists(ctx: AppContext) -> None: """`add` with no secret creates a private channel with a random key; `list` sees it.""" tool = ChannelsTool() result = await tool.run(ctx, {"cli_action": "add", "index": 1, "name": "Ops", "secret": None}) assert result.summary == {"index": 1, "name": "Ops"} slots = await read_channel_slots(await ctx.device()) slot = next(s for s in slots if s.idx == 1) assert slot.name == "Ops" assert not slot.is_public # a random key, not derived from the name async def test_cli_add_public_derives_key(ctx: AppContext) -> None: """`add` with a #-name creates a public channel keyed from the name.""" tool = ChannelsTool() await tool.run(ctx, {"cli_action": "add", "index": 2, "name": "#general", "secret": None}) slot = next(s for s in await read_channel_slots(await ctx.device()) if s.idx == 2) assert slot.name == "#general" and slot.is_public async def test_cli_join_and_import_round_trip(ctx: AppContext) -> None: """A channel joined by key can be shared and re-imported to the same secret.""" tool = ChannelsTool() secret = bytes(range(16)) await tool.run(ctx, {"cli_action": "join", "index": 3, "name": "Squad", "secret": secret.hex()}) slot = next(s for s in await read_channel_slots(await ctx.device()) if s.idx == 3) assert slot.name == "Squad" and slot.secret == secret # Importing the channel's own share link onto another slot reproduces it. await tool.run(ctx, {"cli_action": "import", "index": 4, "url": share_url("Squad", secret)}) imported = next(s for s in await read_channel_slots(await ctx.device()) if s.idx == 4) assert imported.name == "Squad" and imported.secret == secret async def test_cli_import_rejects_bad_link(ctx: AppContext) -> None: """Importing a non-channel link is a clean parameter error, not a crash.""" import typer tool = ChannelsTool() with pytest.raises(typer.BadParameter): await tool.run(ctx, {"cli_action": "import", "index": 5, "url": "https://nope"}) async def test_cli_clear_removes_the_channel_from_its_slot(ctx: AppContext) -> None: """`clear` empties a configured slot; `list` no longer sees it.""" tool = ChannelsTool() await tool.run(ctx, {"cli_action": "add", "index": 1, "name": "Ops", "secret": None}) assert any(s.idx == 1 for s in await read_channel_slots(await ctx.device())) result = await tool.run(ctx, {"cli_action": "clear", "index": 1}) assert result.summary == {"index": 1, "cleared": True} assert not any(s.idx == 1 for s in await read_channel_slots(await ctx.device())) async def test_cli_clear_of_an_empty_slot_is_a_no_op(ctx: AppContext) -> None: """Clearing an already-empty slot reports nothing cleared rather than erroring.""" tool = ChannelsTool() result = await tool.run(ctx, {"cli_action": "clear", "index": 7}) assert result.summary == {"index": 7, "cleared": False} class _RecordingSession: """Records which surface :meth:`TuiUi.present` chose — the popup or the result window.""" def __init__(self) -> None: self.shown: list[tuple[str, str]] = [] async def message_dialog(self, message, title: str = "") -> None: # noqa: ANN001 self.shown.append(("popup", title)) async def scroll(self, renderable, *, title: str = "", footer_hint: str = "") -> None: # noqa: ANN001 self.shown.append(("window", title)) class _PresentingUi(_ScriptedUi): """Scripted answers, but real note buffering — so what the visit *shows* can be asserted.""" def __init__(self, selects: list, texts: list, dialogs: list) -> None: from meshterm.ui.surface import TuiUi super().__init__(selects, texts, dialogs) self.session_spy = _RecordingSession() self.surface = TuiUi(self.session_spy) # no ``.session`` attribute: stays headless def note(self, markup: str) -> None: self.surface.note(markup) def ack(self, markup: str) -> None: self.surface.ack(markup) async def test_a_busy_visit_still_reports_itself_in_a_popup(ctx: AppContext) -> None: """Three changes in one visit must acknowledge as a dialog, not the full result window. Reproduces the reported bug: every action banked its own "✓ created …" note, so the outcome grew a line per change and the third one pushed it past the popup's budget — the same visit reported itself as a tidy dialog or as a full-frame window depending on how much had been done in it. The visit now acknowledges once, in the tool's summary. """ ui = _PresentingUi( selects=[_CREATE, _CREATE, _CREATE, None], # create three channels, then Esc texts=["One", "Two", "Three"], dialogs=[], ) ctx.ui = ui result = await ChannelsTool().run(ctx, {}) assert result.summary == {"changes": 3} # The menu's own tail: note the tool's message, then present what the run buffered. if result.message: ui.surface.note(result.message) await ui.surface.present(title="Channels") assert ui.session_spy.shown == [("popup", "Channels")]