Files
MeshTerm/tests/test_rooms.py
T
Jean-Pierre MartineauandClaude Opus 5.5 2d2128718a ^O opens the author's node page, and ^G logs in to a room
In a channel, a DM, or a room, ^O on a selected message opens the node page
of its author, the same page the Contacts list opens: our own for a message
of ours, the contact the author's key finds otherwise (ContactPages, the twin
of the map's Locator). It opens to look, without the management verbs, which
belong to the list a contact is in. The hint names ^O only where it acts; the
handheld chip is Author, on Shift+F3 behind Map, since F1-F3 are full.

The room's login moves from ^O to ^G.

The Unreleased changelog now also says where the Map chip went, and covers
the shared lane and the route-end chips.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 00:25:35 -04:00

1435 lines
60 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Room servers: joining one, reading its board under the author of each post, and posting.
A room server is a bulletin board on a radio. It keeps the latest posts of its members.
It sends each member the posts that the member missed. It sends them as direct messages
from itself, and each message has the first four bytes of the key of its author as a
signature. Thus these tests check the facts that a room adds to a direct exchange: who
wrote a post (``author``), what a login let us do
(:class:`~meshterm.core.models.RoomAccess`), and when a room needs a new login. They also
check that the posts of a room stay a board. The code removes duplicates, keeps the posts
apart from the command replies of the room, and raises the unread badge. The posts must
not go into anything else.
All the tests run against the simulator and temporary stores. They do not use hardware.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
import pytest
from rich.cells import cell_len
from meshterm.core.admin_store import AdminStore
from meshterm.core.config import Settings
from meshterm.core.connection import MeshCoreDevice, MockDevice, message_from_event
from meshterm.core.events import MeshEvent
from meshterm.core.models import (
NODE_TYPE_CHAT,
NODE_TYPE_REPEATER,
NODE_TYPE_ROOM,
ChatMessage,
Contact,
Conversation,
LoginResult,
Message,
RoomAccess,
RoomLogin,
is_direct_messageable,
is_room,
)
from meshterm.core.preferences import Preferences
from meshterm.core.remote_config import get_setting
from meshterm.core.room_store import RoomStore
from meshterm.persistence.repository import Repository
from meshterm.services.chat_service import ChatService
from meshterm.services.event_hub import EventHub
from meshterm.services.rooms import RoomService
from meshterm.ui.room import LOGGING_IN, NO_REPLY, NOT_JOINED, RoomScreen, author_label
from meshterm.ui.tui import fkeys
from tests.conftest import plain
from tests.test_admin_login import _device, _Event, _FakeMeshCore, quick_budget # noqa: F401
#: The room server of the simulator, in the same form as MockDevice lists it.
ROOM = Contact(
name="Lakeside BBS",
public_key="f6a7b8c9" + "0" * 56,
key_prefix="f6a7b8c9",
node_type=NODE_TYPE_ROOM,
route_hops=("a1b2c3d4",),
)
ALICE_KEY = "d4e5f6a7"
#: A node that no contact names (the neighbour of the simulator that never advertised to us).
STRANGER_KEY = "e5f6a7b8"
NOW = datetime(2026, 10, 4, 12, 0, tzinfo=timezone.utc)
def _post(text: str, author: str = ALICE_KEY, *, minutes: int = 0) -> Message:
"""One room post in the form that arrives on the wire: from the room, signed by its author."""
return Message(
text=text,
sender=ROOM.key_prefix,
sender_timestamp=NOW + timedelta(minutes=minutes),
author=author,
)
@pytest.fixture()
def repo(tmp_path: Path) -> Repository:
"""A Repository on a temporary database."""
r = Repository(tmp_path / "rooms.db")
yield r
r.close()
# --- the model ------------------------------------------------------------------------
def test_only_a_room_server_is_a_room() -> None:
"""A room is its own section, and it is not a recipient of direct messages."""
assert is_room(NODE_TYPE_ROOM)
assert not is_room(NODE_TYPE_CHAT) and not is_room(None) and not is_room(NODE_TYPE_REPEATER)
assert not is_direct_messageable(NODE_TYPE_ROOM)
assert ROOM.is_room
assert Conversation(label=ROOM.name, is_channel=False, contact=ROOM).is_room
assert not Conversation(label="#x", is_channel=True, channel_id="c").is_room
@pytest.mark.parametrize(
"payload,access",
[
({"acl_permissions": 3, "permissions": 1}, RoomAccess.ADMIN),
({"acl_permissions": 2, "permissions": 0}, RoomAccess.MEMBER),
# Role 1 is the "read-only" of the firmware, set by hand. The room still keeps its posts.
({"acl_permissions": 1, "permissions": 0}, RoomAccess.MEMBER),
({"acl_permissions": 0, "permissions": 2}, RoomAccess.READ_ONLY),
# Only the high bits are different. The role is the low two bits.
({"acl_permissions": 0x80 | 2}, RoomAccess.MEMBER),
# A reply from before the permissions byte: only the legacy flag.
({"permissions": 1}, RoomAccess.ADMIN),
({"permissions": 2}, RoomAccess.READ_ONLY),
({"permissions": 0}, RoomAccess.MEMBER),
({}, RoomAccess.MEMBER),
],
)
def test_the_access_a_login_reply_grants(payload: dict, access: RoomAccess) -> None:
"""The code reads the access from the access-list byte if it exists, or else the legacy flag."""
assert RoomAccess.from_login(payload) is access
def test_only_a_read_only_member_cannot_post() -> None:
"""The room drops the posts of only one access."""
assert RoomAccess.ADMIN.can_post and RoomAccess.MEMBER.can_post
assert not RoomAccess.READ_ONLY.can_post
def test_a_room_login_is_truthy_only_when_accepted() -> None:
"""The ``if not login`` idiom keeps the meaning of LoginResult."""
assert RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER)
assert not RoomLogin(LoginResult.NO_REPLY)
assert not RoomLogin(LoginResult.REFUSED)
def test_a_post_keeps_its_author_and_the_time_it_was_posted() -> None:
"""The room is the sender, the author is a separate field, and the time is that of the post."""
chat = ChatMessage.from_message(_post("swap meet?"))
assert chat.peer == ROOM.key_prefix
assert chat.author == ALICE_KEY
assert chat.is_post
assert chat.created_at == NOW
assert chat.key == f"dm:{ROOM.key_prefix}" # the board of the room, with the key of the room
# --- the wire -------------------------------------------------------------------------
def _received(txt_type: int, **extra) -> SimpleNamespace:
payload = {
"type": "PRIV",
"pubkey_prefix": "f6a7b8c90000",
"txt_type": txt_type,
"sender_timestamp": int(NOW.timestamp()),
"text": "hello",
**extra,
}
return SimpleNamespace(payload=payload)
def test_a_signed_message_names_its_author() -> None:
"""The ``signature`` of the library is the key prefix of the author. It is not a signature."""
message = message_from_event(_received(2, signature="E5F6A7B8"))
assert message.author == STRANGER_KEY
assert message.is_post
assert message.sender == "f6a7b8c90000"
@pytest.mark.parametrize("txt_type", [0, 1])
def test_plain_text_and_command_replies_have_no_author(txt_type: int) -> None:
"""A command reply of a room has the same key as its posts. Only a post has an author."""
message = message_from_event(_received(txt_type, signature="e5f6a7b8"))
assert message.author is None and not message.is_post
def test_a_channel_message_never_has_an_author() -> None:
"""Only room posts have authors. A channel names its sender in the text."""
event = SimpleNamespace(
payload={"type": "CHAN", "channel_idx": 0, "txt_type": 2, "signature": "ab", "text": "x"}
)
assert message_from_event(event).author is None
def test_a_room_login_reads_the_access_from_the_reply(quick_budget) -> None: # noqa: ANN001, F811
"""One login exchange, read for the role that the room gave us."""
from meshcore import EventType
answer = _Event(
EventType.LOGIN_SUCCESS,
{"pubkey_prefix": "f6a7b8c90000", "permissions": 2, "acl_permissions": 0},
)
mc = _FakeMeshCore(answer=answer)
login = asyncio.run(_device(mc).room_login(ROOM, "anything"))
assert login.result is LoginResult.ACCEPTED
assert login.access is RoomAccess.READ_ONLY
assert mc.sent == [(ROOM.public_key, "anything")]
def test_a_room_that_stays_silent_is_no_reply_with_no_access(quick_budget) -> None: # noqa: ANN001, F811
"""A room says nothing to a wrong password. It is silent, and it never refuses."""
login = asyncio.run(_device(_FakeMeshCore()).room_login(ROOM, "wrong"))
assert login == RoomLogin(LoginResult.NO_REPLY)
class _CommandMeshCore:
"""A replacement for a meshcore client for an admin command: it arms, sends, then hears replies.
The class delivers ``replies`` after the command goes out. Each reply goes through the
attribute filter of the subscription, in the same way as the dispatcher of the library
applies it.
"""
def __init__(self, replies: list[dict]) -> None:
self._replies = replies
self._subs: list = []
self.commands = self
def subscribe(self, event_type, callback, attribute_filters=None): # noqa: ANN001, ANN201
sub = SimpleNamespace(callback=callback, filters=attribute_filters or {})
sub.unsubscribe = lambda: self._subs.remove(sub) if sub in self._subs else None
self._subs.append(sub)
return sub
async def send_cmd(self, pub, cmd): # noqa: ANN001, ANN201
for payload in self._replies:
event = SimpleNamespace(payload=payload)
for sub in list(self._subs):
if all(payload.get(k) == v for k, v in sub.filters.items()):
sub.callback(event)
return None
def test_an_admin_command_reply_is_not_a_post_or_someone_elses_message() -> None:
"""An admin command reply is not a post, and it is not a message from another node.
This is the main trap of a room: the posts of its members arrive on the same channel as
the command reply. In the past, a post that the room pushed was taken for the answer of
the node. A message of a companion that arrived during the command was also taken for
the answer.
"""
room_prefix = ROOM.public_key[:12]
mc = _CommandMeshCore(
[
{"pubkey_prefix": "d4e5f6a70000", "txt_type": 0, "text": "hi from Alice"},
{"pubkey_prefix": room_prefix, "txt_type": 2, "text": "a member's post"},
{"pubkey_prefix": room_prefix, "txt_type": 1, "text": "> hello"},
]
)
device = MeshCoreDevice(port="mock")
device._mc = mc
assert asyncio.run(device._send_admin_cmd(ROOM, "get guest.password", timeout=1)) == "> hello"
# --- the room store -------------------------------------------------------------------
@pytest.fixture()
def rooms(tmp_path: Path) -> RoomStore:
"""A room store on a temporary file."""
return RoomStore(tmp_path / "rooms.json")
def test_an_accepted_member_login_remembers_the_room_password(rooms: RoomStore) -> None:
"""The password that gave us access is the password that opens the room again."""
rooms.record(ROOM, "hello", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER))
assert rooms.password(ROOM) == "hello"
assert rooms.access(ROOM) is RoomAccess.MEMBER
def test_an_open_room_remembers_its_empty_password(rooms: RoomStore) -> None:
"""``""`` is a password that worked. It is not an absence."""
rooms.record(ROOM, "", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER))
assert rooms.password(ROOM) == ""
def test_an_admin_login_remembers_the_access_but_not_the_password(rooms: RoomStore) -> None:
"""The admin password is in one place, where Repeater admin can change it."""
rooms.record(ROOM, "s3cret", RoomLogin(LoginResult.ACCEPTED, RoomAccess.ADMIN))
assert rooms.password(ROOM) is None
assert rooms.access(ROOM) is RoomAccess.ADMIN
def test_silence_neither_remembers_nor_forgets(rooms: RoomStore) -> None:
"""Silence does not store a room, and it does not forget a room.
A room is silent for a wrong password, so silence does not prove anything.
"""
rooms.record(ROOM, "typo", RoomLogin(LoginResult.NO_REPLY))
assert rooms.membership(ROOM) is None
rooms.record(ROOM, "hello", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER))
rooms.record(ROOM, "hello", RoomLogin(LoginResult.NO_REPLY))
assert rooms.password(ROOM) == "hello"
def test_a_refusal_forgets_the_room(rooms: RoomStore) -> None:
"""The node said no, so the code removes the password that the node refused."""
rooms.record(ROOM, "hello", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER))
rooms.record(ROOM, "hello", RoomLogin(LoginResult.REFUSED))
assert rooms.membership(ROOM) is None
# --- the room service -----------------------------------------------------------------
class _Ctx:
"""The part of AppContext that the room service and the chat recorder read."""
def __init__(self, tmp_path: Path, device: MockDevice, repo: Repository | None = None):
self._device = device
self.repo = repo
self.log = logging.getLogger("test.rooms")
self.profile_name = None
self.settings = Settings()
self.preferences = Preferences()
self.admin_store = AdminStore(tmp_path / "admin.json")
self.room_store = RoomStore(tmp_path / "rooms.json")
self.events = EventHub(self)
self.rooms = RoomService(self)
async def device(self) -> MockDevice:
await self._device.connect()
return self._device
def test_the_admin_password_is_tried_before_the_room_password(tmp_path: Path) -> None:
"""The code tries the admin password first. A login replaces the role of a known member.
Thus the room password demotes an owner.
"""
ctx = _Ctx(tmp_path, MockDevice())
assert ctx.rooms.password(ROOM) is None and not ctx.rooms.joined(ROOM)
ctx.room_store.record(ROOM, "hello", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER))
assert ctx.rooms.password(ROOM) == "hello"
ctx.admin_store.remember(ROOM, "admin")
assert ctx.rooms.password(ROOM) == "admin"
def test_an_admin_password_alone_is_not_a_join(tmp_path: Path) -> None:
"""The Repeater admin password lets a join go through with no question. A join is a login."""
ctx = _Ctx(tmp_path, MockDevice())
ctx.admin_store.remember(ROOM, "admin")
assert ctx.rooms.password(ROOM) == "admin"
assert not ctx.rooms.joined(ROOM) and not ctx.rooms.login_due(ROOM)
async def test_forgetting_a_room_leaves_it(tmp_path: Path) -> None:
"""The membership and its password are removed. The code transmits nothing."""
ctx = _Ctx(tmp_path, MockDevice())
await ctx.rooms.login(ROOM, "hello")
ctx.rooms.forget(ROOM)
assert not ctx.rooms.joined(ROOM) and ctx.rooms.password(ROOM) is None
assert not ctx.rooms.heard_recently(ROOM)
def test_read_only_never_replaces_the_room_password(rooms: RoomStore) -> None:
"""A read-only login never replaces the room password.
A room that lets read-only members in accepts any password. This does not prove that a
password is correct.
"""
rooms.record(ROOM, "hello", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER))
rooms.record(ROOM, "helo", RoomLogin(LoginResult.ACCEPTED, RoomAccess.READ_ONLY))
assert rooms.password(ROOM) == "hello"
assert rooms.access(ROOM) is RoomAccess.READ_ONLY
first = RoomStore(rooms._path.with_name("other.json"))
first.record(ROOM, "guess", RoomLogin(LoginResult.ACCEPTED, RoomAccess.READ_ONLY))
assert first.password(ROOM) == "guess", "with nothing better, it is what gets back in"
def test_a_blank_login_never_replaces_a_password(rooms: RoomStore) -> None:
"""A blank login shows that the room knows us, until the room starts and forgets a member."""
rooms.record(ROOM, "hello", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER))
rooms.record(ROOM, "", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER))
assert rooms.password(ROOM) == "hello"
rooms.record(ROOM, "", RoomLogin(LoginResult.ACCEPTED, RoomAccess.ADMIN))
assert rooms.password(ROOM) == "hello"
def test_an_admin_login_keeps_the_room_password_beside_it(rooms: RoomStore) -> None:
"""The admin password is in the admin store. The member password stays here as a fallback."""
rooms.record(ROOM, "hello", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER))
rooms.record(ROOM, "admin", RoomLogin(LoginResult.ACCEPTED, RoomAccess.ADMIN))
assert rooms.password(ROOM) == "hello" and rooms.access(ROOM) is RoomAccess.ADMIN
def test_a_refusal_forgets_only_the_password_it_refused(rooms: RoomStore) -> None:
"""If a node refuses one password, this does not show anything about another password."""
rooms.record(ROOM, "hello", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER))
rooms.record(ROOM, "other", RoomLogin(LoginResult.REFUSED))
assert rooms.password(ROOM) == "hello"
async def test_a_blank_admin_login_leaves_the_admin_password_alone(tmp_path: Path) -> None:
"""Repeater admin uses the same password, so a blank probe must not delete it."""
ctx = _Ctx(tmp_path, MockDevice())
await ctx.rooms.login(ROOM, "admin")
login = await ctx.rooms.login(ROOM, "")
assert login.access is RoomAccess.ADMIN
assert ctx.admin_store.get(ROOM) == "admin"
async def test_one_login_to_a_room_at_a_time(tmp_path: Path) -> None:
"""If the user closes and opens a view during a login, the view uses that login."""
device = MockDevice()
ctx = _Ctx(tmp_path, device)
ctx.room_store.record(ROOM, "hello", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER))
sent: list[str] = []
original = device.room_login
async def slow(room, password): # noqa: ANN001, ANN202
sent.append(password)
await asyncio.sleep(0.05)
return await original(room, password)
device.room_login = slow
first = asyncio.ensure_future(ctx.rooms.login(ROOM, "hello"))
await asyncio.sleep(0)
assert not ctx.rooms.login_due(ROOM), "a login on its way is not due again"
second = await ctx.rooms.login(ROOM, "hello")
assert (await first) == second
assert sent == ["hello"]
third = await ctx.rooms.login(ROOM, "hello") # after the answer, the code can send a new login
assert third and sent == ["hello", "hello"]
async def test_joining_with_the_room_password_makes_us_a_member(tmp_path: Path) -> None:
"""The stock room password, in the room of the simulator, makes us a member."""
ctx = _Ctx(tmp_path, MockDevice())
login = await ctx.rooms.login(ROOM, "hello")
assert login.access is RoomAccess.MEMBER
assert ctx.rooms.joined(ROOM) and ctx.rooms.access(ROOM) is RoomAccess.MEMBER
assert ctx.admin_store.get(ROOM) is None # a member password is never an admin password
async def test_joining_with_the_admin_password_remembers_it_for_repeater_admin(
tmp_path: Path,
) -> None:
"""If the user types the admin password in a join prompt, it also administers the room."""
ctx = _Ctx(tmp_path, MockDevice())
login = await ctx.rooms.login(ROOM, "admin")
assert login.access is RoomAccess.ADMIN
assert ctx.admin_store.get(ROOM) == "admin"
assert ctx.room_store.password(ROOM) is None
assert ctx.rooms.password(ROOM) == "admin"
async def test_a_wrong_password_is_silence_and_is_never_remembered(tmp_path: Path) -> None:
"""The code never stores a password that did not work."""
ctx = _Ctx(tmp_path, MockDevice())
login = await ctx.rooms.login(ROOM, "nope")
assert login.result is LoginResult.NO_REPLY
assert not ctx.rooms.joined(ROOM)
async def test_a_room_heard_lately_needs_no_login(tmp_path: Path) -> None:
"""A login is a transmission. A room that still sends to us needs no login."""
ctx = _Ctx(tmp_path, MockDevice())
assert not ctx.rooms.login_due(ROOM), "nothing to log in with yet"
ctx.room_store.record(ROOM, "hello", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER))
assert ctx.rooms.login_due(ROOM), "joined, but not heard this session"
ctx.rooms.heard("F6A7B8C90000") # a post, with its prefix at the width of the wire
assert not ctx.rooms.login_due(ROOM)
async def test_a_login_counts_as_hearing_the_room(tmp_path: Path) -> None:
"""A room that just gave us access is sending. The code sends nothing when it opens it again."""
ctx = _Ctx(tmp_path, MockDevice())
await ctx.rooms.login(ROOM, "hello")
assert not ctx.rooms.login_due(ROOM)
# --- the simulator's room -------------------------------------------------------------
async def test_the_simulators_room_sends_its_board_after_a_login(tmp_path: Path) -> None:
"""The room sends the oldest post first, under its author. It never sends our posts."""
device = MockDevice()
heard: list[Message] = []
unsubscribe = await device.subscribe_events(
lambda event: heard.append(event.message) if event.message is not None else None
)
try:
heard.clear() # the first burst of the stream has a synthetic DM of its own
assert not (await device.send_direct_message(ROOM, "before joining")).acked
login = await device.room_login(ROOM, "hello")
assert login.access is RoomAccess.MEMBER
while len([m for m in heard if m.is_post]) < 5:
await asyncio.sleep(0.01)
posts = [m for m in heard if m.is_post]
assert all(m.sender == ROOM.key_prefix for m in posts)
assert [m.sender_timestamp for m in posts] == sorted(m.sender_timestamp for m in posts)
assert {m.author for m in posts} == {ALICE_KEY, "c3d4e5f6", STRANGER_KEY, "f6a7b8c9"}
assert (await device.send_direct_message(ROOM, "mine")).acked # kept, acked
heard.clear()
await device.room_login(ROOM, "") # the room knows us. Nothing is new, except our post
await asyncio.sleep(0.2)
assert not [m for m in heard if m.is_post]
finally:
unsubscribe()
await device.disconnect()
async def test_a_read_only_member_is_heard_but_not_kept(tmp_path: Path) -> None:
"""``allow.read.only`` accepts any password, and it drops what the member posts."""
device = MockDevice()
device._remote_config(ROOM)["allow.read.only"] = "on"
login = await device.room_login(ROOM, "anything")
assert login.access is RoomAccess.READ_ONLY
assert not (await device.send_direct_message(ROOM, "lost")).acked
await device.disconnect()
async def test_an_admin_posts_in_the_rooms_own_name(tmp_path: Path) -> None:
"""``room.post`` adds a notice that the room itself is the author of."""
device = MockDevice()
assert await device.admin_login(ROOM, "admin") is LoginResult.ACCEPTED
assert await device.send_remote_command(ROOM, "room.post Meeting at 7") == "OK"
assert device._room_board[-1][0] == "f6a7b8c9"
assert await device.send_remote_command(ROOM, "get guest.password") == "> hello"
await device.disconnect()
# --- history and the recorder ---------------------------------------------------------
def test_a_rooms_board_leaves_out_its_command_replies(repo: Repository) -> None:
"""The board has the posts and our posts. A reply under the key of the room is not on it."""
peer = ROOM.key_prefix
repo.record_chat_message(ChatMessage.from_message(_post("first", minutes=1)))
repo.record_chat_message(ChatMessage(text="OK", peer=peer)) # the reply to an admin command
repo.record_chat_message(ChatMessage(text="mine", outbound=True, peer=peer, acked=True))
board = repo.recent_chat_messages(is_channel=False, peer=peer, posts_only=True)
assert [m.text for m in board] == ["first", "mine"]
assert board[0].author == ALICE_KEY
everything = repo.recent_chat_messages(is_channel=False, peer=peer)
assert [m.text for m in everything] == ["first", "OK", "mine"]
repo.record_chat_message(ChatMessage(text="OK again", peer=peer))
assert repo.last_chat_messages()[f"dm:{peer}"].text == "OK again"
assert repo.last_chat_messages(rooms=[peer])[f"dm:{peer}"].text == "mine"
def test_a_post_already_stored_is_recognised(repo: Repository) -> None:
"""The same room, author, time, and text identify one post."""
post = ChatMessage.from_message(_post("swap meet?"))
assert not repo.has_room_post(post)
repo.record_chat_message(post)
assert repo.has_room_post(ChatMessage.from_message(_post("swap meet?")))
assert not repo.has_room_post(ChatMessage.from_message(_post("swap meet?", minutes=1)))
assert not repo.has_room_post(ChatMessage.from_message(_post("swap meet?", STRANGER_KEY)))
class _GatedCtx(_Ctx):
"""A context with the device-state cache, so that the badge rule can place a sender."""
def __init__(self, tmp_path: Path, device: MockDevice, repo: Repository) -> None:
super().__init__(tmp_path, device, repo)
from meshterm.services.device_state import DeviceState
self.devstate = DeviceState(self)
async def _record(chat: ChatService, ctx, message: Message) -> None: # noqa: ANN001
ctx.events.publish(MeshEvent.message_event(message))
await chat._queue.join()
def _join(ctx) -> None: # noqa: ANN001
"""Store a membership, as an accepted login does."""
ctx.room_store.record(ROOM, "hello", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER))
async def test_a_room_not_joined_records_its_posts_but_raises_no_badge(
tmp_path: Path, repo: Repository
) -> None:
"""Chat has no row for a room that is not joined, so the badge points at nothing."""
ctx = _GatedCtx(tmp_path, MockDevice(), repo)
chat = ChatService(ctx)
await chat.start()
try:
await _record(chat, ctx, _post("still sending after we forgot it"))
assert chat.unread(f"dm:{ROOM.key_prefix}") == 0
board = repo.recent_chat_messages(is_channel=False, peer=ROOM.key_prefix, posts_only=True)
assert [m.text for m in board] == ["still sending after we forgot it"]
finally:
await chat.stop()
async def test_a_post_is_recorded_once_and_raises_the_badge_once(
tmp_path: Path, repo: Repository
) -> None:
"""A post that the room sends again arrives two times on the wire and one time in history."""
ctx = _GatedCtx(tmp_path, MockDevice(), repo)
_join(ctx)
chat = ChatService(ctx)
await chat.start()
try:
await _record(chat, ctx, _post("swap meet?"))
await _record(chat, ctx, _post("swap meet?"))
key = f"dm:{ROOM.key_prefix}"
assert chat.unread(key) == 1
board = repo.recent_chat_messages(is_channel=False, peer=ROOM.key_prefix, posts_only=True)
assert [(m.text, m.author) for m in board] == [("swap meet?", ALICE_KEY)]
assert ctx.rooms.heard_recently(ROOM)
finally:
await chat.stop()
async def test_a_rooms_command_reply_is_recorded_but_silent(
tmp_path: Path, repo: Repository
) -> None:
"""The board is listed. Other data from a room has no row for the badge to point at."""
ctx = _GatedCtx(tmp_path, MockDevice(), repo)
chat = ChatService(ctx)
await chat.start()
try:
await _record(chat, ctx, Message(text="> hello", sender=ROOM.key_prefix))
assert chat.unread(f"dm:{ROOM.key_prefix}") == 0
assert [m.text for m in repo.recent_chat_messages(is_channel=False, peer=ROOM.key_prefix)]
finally:
await chat.stop()
async def test_posting_records_the_post_under_the_room(tmp_path: Path, repo: Repository) -> None:
"""An acknowledgement means that the room kept the post. It does not keep a read-only post."""
device = MockDevice()
ctx = _Ctx(tmp_path, device, repo)
chat = ChatService(ctx)
await device.room_login(ROOM, "hello")
kept = await chat.send_post(ROOM, "count me in")
assert kept.acked is True and kept.outbound and kept.peer == ROOM.key_prefix
device._room_access[device._mock_key(ROOM)] = RoomAccess.READ_ONLY
dropped = await chat.send_post(ROOM, "and me")
assert dropped.acked is False
board = repo.recent_chat_messages(is_channel=False, peer=ROOM.key_prefix, posts_only=True)
assert [m.text for m in board] == ["count me in", "and me"]
async def test_a_post_is_never_soft_retried(tmp_path: Path, repo: Repository) -> None:
"""A retry is a new post, and each member receives it two times."""
device = MockDevice()
ctx = _Ctx(tmp_path, device, repo)
ctx.preferences.direct_message_soft_retries = 2
sends: list[str] = []
original = device.send_direct_message
async def counting(contact, text): # noqa: ANN001, ANN202
sends.append(text)
return await original(contact, text)
device.send_direct_message = counting
await ChatService(ctx).send_post(ROOM, "unacked") # we are not a member, so there is no ack
assert sends == ["unacked"]
# --- the repeater-admin page in the words of a room server ---------------------------
def test_a_room_servers_guest_password_is_its_room_password() -> None:
"""The key is the same on both firmwares. The page of a room uses the name that fits there."""
spec = get_setting("guest.password")
assert spec.for_node(NODE_TYPE_ROOM).label == "Room password"
assert spec.for_node(NODE_TYPE_REPEATER).label == "Guest password"
assert spec.for_node(NODE_TYPE_ROOM).key == "guest.password"
plain_spec = get_setting("txdelay")
assert plain_spec.for_node(NODE_TYPE_ROOM) is plain_spec
# --- the room view --------------------------------------------------------------------
class _Session:
def __init__(self) -> None:
self.invalidations = 0
def invalidate(self) -> None:
self.invalidations += 1
def run_detached(self, work): # noqa: ANN001, ANN201
return asyncio.ensure_future(work)
def _resolve(hash_: str | None) -> str | None:
"""The key of Alice names her, and the key of the room names it. Nothing names the stranger."""
return {ALICE_KEY: "Alice", "f6a7b8c9": ROOM.name}.get(hash_ or "", hash_)
def _view(
messages: list[ChatMessage] | None = None,
*,
access: RoomAccess | None = RoomAccess.MEMBER,
joined: bool = True,
login: RoomLogin | None = None,
locator: object = None,
pages: object = None,
) -> tuple[RoomScreen, dict]:
"""A board with stub hooks. ``calls`` records the requests to the hooks."""
calls: dict = {"auto": 0, "joins": [], "sent": []}
outcome = login if login is not None else RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER)
async def auto_login() -> RoomLogin | None:
calls["auto"] += 1
await asyncio.sleep(0) # an exchange on the air: the board continues to draw
return outcome
async def join(ask: bool) -> RoomLogin | None:
calls["joins"].append(ask)
await asyncio.sleep(0)
return outcome
async def send(text: str) -> ChatMessage:
calls["sent"].append(text)
return ChatMessage(text=text, outbound=True, peer=ROOM.key_prefix, acked=True)
screen = RoomScreen(
Conversation(label=ROOM.name, is_channel=False, contact=ROOM),
messages or [],
send=send,
names={},
session=_Session(),
resolve=_resolve,
auto_login=auto_login,
join=join,
joined=joined,
access=access,
locator=locator, # type: ignore[arg-type]
pages=pages, # type: ignore[arg-type]
)
return screen, calls
async def _settled(screen: RoomScreen) -> None:
while screen._login_open:
await asyncio.sleep(0)
def _board() -> list[ChatMessage]:
return [
ChatMessage.from_message(_post("Anyone driving Saturday?", minutes=-30)),
ChatMessage.from_message(_post("Is the north repeater down?", STRANGER_KEY, minutes=-20)),
ChatMessage.from_message(_post("Board keeps 32 posts.", "f6a7b8c9", minutes=-10)),
ChatMessage(text="I'll check it", outbound=True, peer=ROOM.key_prefix, acked=True),
]
def _compose(screen: RoomScreen) -> str:
return plain("\n".join(screen.render_body(72))).splitlines()[-1]
def test_an_author_is_named_by_key_or_stands_as_a_grey_hash() -> None:
"""A key that something names shows as that name, in its hue. Other keys stay a hash."""
assert author_label(ALICE_KEY, _resolve) == ("Alice", ALICE_KEY)
assert author_label(STRANGER_KEY, _resolve) == (STRANGER_KEY, None)
def test_the_board_draws_each_post_under_its_author() -> None:
"""A contact, a stranger, the room's own notice, and us: each has its own chip."""
screen, _ = _view(_board())
text = plain("\n".join(screen.render_body(72)))
for expected in ("Alice", STRANGER_KEY, ROOM.name, "Anyone driving Saturday?", "I'll check it"):
assert expected in text
assert screen.title == f"{ROOM.name} · member"
def test_a_post_that_arrives_twice_is_shown_once_and_a_reply_not_at_all() -> None:
"""A post that arrives two times shows one time. A reply does not show at all.
The live view applies the same two rules as the recorder.
"""
screen, _ = _view(_board())
screen.append(ChatMessage.from_message(_post("Board keeps 32 posts.", "f6a7b8c9", minutes=-10)))
screen.append(ChatMessage(text="> hello", peer=ROOM.key_prefix)) # a command reply
assert len(screen._messages) == 4
screen.append(ChatMessage.from_message(_post("New one", minutes=1)))
assert screen._messages[-1].text == "New one"
async def test_a_member_posts_from_the_compose_line() -> None:
"""Enter posts, through the same path as a direct message."""
screen, calls = _view()
for ch in "hi":
screen.handle("text", ch)
screen.handle("enter")
while screen._sending:
await asyncio.sleep(0)
assert calls["sent"] == ["hi"]
async def test_the_compose_line_waits_for_the_quiet_login() -> None:
"""A room that is quiet logs in in the background. Posting opens after the room accepts us."""
screen, calls = _view(access=RoomAccess.MEMBER)
screen.begin_auto_login()
assert screen.title == f"{ROOM.name} · {LOGGING_IN}"
assert "posting opens once" in _compose(screen)
screen.handle("text", "x")
assert screen._editor.text == "", "nothing typed while the room may not know us"
await _settled(screen)
assert calls["auto"] == 1 and screen.title == f"{ROOM.name} · member"
assert screen._composing
async def test_after_silence_nothing_can_be_posted_and_the_line_says_why() -> None:
"""The reported bug: a login got silence, and the compose line continued to take posts."""
screen, calls = _view(login=RoomLogin(LoginResult.NO_REPLY, flood=True))
screen.begin_auto_login()
await _settled(screen)
assert screen.title.endswith(NO_REPLY)
assert "no answer" in _compose(screen) and "^G" in _compose(screen)
for ch in "hi":
screen.handle("text", ch)
screen.handle("enter")
await asyncio.sleep(0)
assert calls["sent"] == []
assert "Enter send" not in screen.footer_hint
async def test_control_l_runs_the_explained_login() -> None:
"""^G gives the login to the Rooms flow, with its dialogs over the board, and shows the end."""
screen, calls = _view(access=None, login=RoomLogin(LoginResult.ACCEPTED, RoomAccess.ADMIN))
screen.handle("login")
assert screen._login_open
assert "^G log in" not in screen.footer_hint # a second press does nothing
await _settled(screen)
assert calls["joins"] == [False]
assert screen.title == f"{ROOM.name} · admin" and screen._composing
async def test_a_read_only_member_is_asked_for_another_password_and_offered_no_retry() -> None:
"""^G asks, because the room password lets a read-only member post. ^R is only dropped."""
unacked = ChatMessage(text="lost", outbound=True, peer=ROOM.key_prefix, acked=False)
screen, calls = _view([unacked], access=RoomAccess.READ_ONLY)
assert "read-only" in _compose(screen)
assert screen._retry_target() is None and "^R" not in screen.footer_hint
screen.handle("login")
await _settled(screen)
assert calls["joins"] == [True]
def test_a_board_not_joined_takes_no_posts() -> None:
"""The user reads the stored posts on the Rooms page. To post, the user must join first."""
screen, _ = _view(_board(), access=None, joined=False)
assert screen.title.endswith(NOT_JOINED)
assert "not joined" in _compose(screen) and not screen._composing
async def test_enter_on_a_picked_post_replies_to_its_author() -> None:
"""A board has many authors, so Enter on a post prepares an @mention, as a channel does."""
screen, _ = _view(_board())
for _ in range(3): # our post, the notice of the room, then the post of the stranger
screen.handle("up")
screen.handle("enter")
assert screen._editor.text == f"@[{STRANGER_KEY}] "
async def test_a_read_only_member_picks_a_post_to_see_its_paths_not_to_reply() -> None:
"""A read-only member has no compose line, so Enter on a post has nothing to prepare.
A paste has no place to go.
"""
screen, _ = _view(_board(), access=RoomAccess.READ_ONLY)
opened: list[ChatMessage] = []
async def paths(message: ChatMessage) -> None:
opened.append(message)
screen._paths = paths
screen.handle("paste", "pasted text")
assert not screen._paste_open and screen._editor.text == ""
screen.handle("up")
assert screen.footer_hint.startswith("Enter paths")
screen.handle("enter")
await asyncio.sleep(0)
assert screen._editor.text == ""
assert [m.text for m in opened] == ["I'll check it"]
def test_the_hint_names_log_in_and_fits() -> None:
"""The hint names ^G where it acts, in 72 cells, with or without a selected post."""
screen, _ = _view(_board())
assert "^G log in" in screen.footer_hint
assert cell_len(screen.footer_hint) <= 72
screen.handle("up")
assert cell_len(screen.footer_hint) <= 72
def test_the_handheld_lane_puts_log_in_behind_f2() -> None:
"""Each chord has a chip where the keyboard has no Ctrl key to reach it.
Log in is a verb of the room only, so it is in the Shift bank, next to Retry.
"""
screen, _ = _view(_board())
lane = screen.picocalc_lyra_lane
assert lane[1].opp_label == "Log in"
assert fkeys.PICOCALC_LYRA_DECK.action_for(lane, 7) == "login"
assert lane[0].opp_label == "Retry"
# The plain bank of F1 to F3 is the one of each conversation (refer to test_chat).
assert [pair.label for pair in lane[:2]] == ["Paths", "URL→QR"]
async def test_o_opens_the_node_page_of_the_author_of_a_post() -> None:
"""^O opens the page of the contact that signed the selected post, by its key prefix."""
from tests.conftest import recording_pages
alice = Contact(name="Alice", public_key=ALICE_KEY + "0" * 56, key_prefix=ALICE_KEY + "0000")
pages = recording_pages([alice])
screen, _ = _view(_board(), pages=pages)
for _ in range(4):
screen.handle("up") # the oldest post, by Alice
assert "^O author" in screen.footer_hint
screen.handle("author_page")
await asyncio.sleep(0)
assert pages.shown == [alice]
# No contact has the key of the stranger, so ^O does nothing, and the hint does not name it.
screen.handle("down")
assert "^O" not in screen.footer_hint
screen.handle("author_page")
await asyncio.sleep(0)
assert pages.shown == [alice]
assert screen.picocalc_lyra_lane[2].opp_label == "Author"
def test_the_login_key_is_g_and_o_opens_the_author_page() -> None:
"""^G logs in to a room, and ^O opens the page of the author of a message (JP, 2026-10-11)."""
from meshterm.ui.tui.session import _CTRL_LETTER_CHORDS
assert _CTRL_LETTER_CHORDS["g"] == "login"
assert _CTRL_LETTER_CHORDS["o"] == "author_page"
async def test_l_opens_the_map_on_the_author_of_a_post() -> None:
"""^L opens the map on the author of the selected post. ^G keeps the login."""
from meshterm.core.models import MapMarker
from tests.conftest import recording_locator
alice = MapMarker(label="Alice", lat=45.5, lon=-73.6, key=ALICE_KEY + "0" * 56)
locator = recording_locator([alice])
screen, _ = _view(_board(), locator=locator)
for _ in range(4):
screen.handle("up") # the oldest post, by Alice
assert "^L map" in screen.footer_hint
screen.handle("locate")
await asyncio.sleep(0)
assert locator.shown == [alice]
# No marker places the stranger, so ^L does nothing, and the hint does not name it.
screen.handle("down")
assert "^L" not in screen.footer_hint
screen.handle("locate")
await asyncio.sleep(0)
assert locator.shown == [alice]
# Map is on F3, as in each conversation, and Log in is on the Shift half of F2.
assert screen.picocalc_lyra_lane[2].label == "Map"
assert screen.picocalc_lyra_lane[1].opp_label == "Log in"
# --- joining: the explained flow ------------------------------------------------------
class _ScriptedUi:
"""A UI surface that answers the prompt and the dialogs of the join flow from a script.
``choices`` are button labels, and the script presses them in order. The class keeps each
dialog that it shows as ``(text, labels, default label)``, so that the test can read it.
"""
def __init__(self, *, passwords=(), choices=()) -> None: # noqa: ANN001
self.passwords = list(passwords)
self.choices = list(choices)
self.asked: list[str] = []
self.defaults: list[str] = []
self.dialogs: list[tuple[str, list[str], str]] = []
async def text(self, title: str, **kwargs): # noqa: ANN003, ANN201
self.asked.append(title)
self.defaults.append(kwargs.get("default", ""))
assert kwargs["validate"]("x" * 16) is not True, "a password the radio would cut"
return self.passwords.pop(0) if self.passwords else None
async def dialog(self, prompt, buttons, **kwargs): # noqa: ANN001, ANN003, ANN201
labels = [label for label, _ in buttons]
default = labels[kwargs.get("default", 0)]
self.dialogs.append((getattr(prompt, "plain", prompt), labels, default))
pressed = self.choices.pop(0) if self.choices else labels[0]
return dict(buttons)[pressed]
def busy_dialog(self, message: str = "", *, title: str = ""): # noqa: ANN201
from contextlib import asynccontextmanager
@asynccontextmanager
async def card(): # noqa: ANN202
yield SimpleNamespace(message=message)
return card()
class _JoinCtx(_Ctx):
"""A context with a scripted UI and a device-state stub that the flow invalidates."""
def __init__(self, tmp_path: Path, device: MockDevice, ui: _ScriptedUi) -> None:
super().__init__(tmp_path, device)
self.ui = ui
self.devstate = SimpleNamespace(invalidate_contacts=lambda: None)
async def test_joining_with_the_right_password_says_nothing_more(tmp_path: Path) -> None:
"""A login that lets us post ends the flow with no message. The page shows the access."""
from meshterm.ui.rooms import join_room
ui = _ScriptedUi(passwords=["hello"])
ctx = _JoinCtx(tmp_path, MockDevice(), ui)
login = await join_room(ctx, ROOM)
assert login.access is RoomAccess.MEMBER and ctx.rooms.joined(ROOM)
assert ui.asked == [f"Join — {ROOM.name}"] and ui.dialogs == []
async def test_a_stale_route_is_explained_and_a_flood_gets_through(tmp_path: Path) -> None:
"""The case on hardware: a learned route that is dead with no error, and a flood that works."""
from meshterm.ui.rooms import join_room
device = MockDevice()
device._stale_routes.add(ROOM.name)
ui = _ScriptedUi(passwords=["hello"], choices=["Try by flood"])
ctx = _JoinCtx(tmp_path, device, ui)
login = await join_room(ctx, ROOM)
assert login.access is RoomAccess.MEMBER and login.flood is True
text, labels, default = ui.dialogs[0]
assert text.startswith(f"{ROOM.name} didn't answer.")
assert "route your radio learned to the room (1 hop)" in text
assert labels == ["Cancel", "Try by flood"] and default == "Try by flood"
assert len(ui.asked) == 1, "the flood carries the same password"
async def test_silence_by_flood_names_both_causes_and_offers_another_password(
tmp_path: Path,
) -> None:
"""A password that never worked here can be the problem, and the dialog says so."""
from meshterm.ui.rooms import join_room
device = MockDevice()
await device.reset_route(ROOM) # no route: each login floods
ui = _ScriptedUi(passwords=["nope", "hello"], choices=["Try again…"])
ctx = _JoinCtx(tmp_path, device, ui)
login = await join_room(ctx, ROOM)
assert login.access is RoomAccess.MEMBER and len(ui.asked) == 2
text, labels, default = ui.dialogs[0]
assert "went out to the whole mesh" in text and "the password is wrong" in text
assert labels == ["Cancel", "Try again…"] and default == "Try again…"
assert ui.defaults == ["", "nope"], "the second prompt holds the password just tried"
async def test_silence_with_a_password_that_worked_points_at_reach(tmp_path: Path) -> None:
"""A stored password is still correct. The room most likely did not hear us."""
from meshterm.ui.rooms import join_room
device = MockDevice()
await device.reset_route(ROOM)
ui = _ScriptedUi(choices=["Cancel"])
ctx = _JoinCtx(tmp_path, device, ui)
await ctx.rooms.login(ROOM, "hello")
await device.reset_route(ROOM) # the answer gave it a route, so the next login floods as well
device._unreachable.add(ROOM.name)
login = await join_room(ctx, ROOM)
assert login.result is LoginResult.NO_REPLY and ui.asked == []
text, labels, _ = ui.dialogs[0]
assert "got you in before" in text and labels == ["Cancel", "Try again"]
assert ctx.rooms.password(ROOM) == "hello", "silence keeps the password"
async def test_read_only_is_explained_and_another_password_offered(tmp_path: Path) -> None:
"""The user has access, but not as a member. The dialog says what this means and why."""
from meshterm.ui.rooms import join_room
device = MockDevice()
device._remote_config(ROOM)["allow.read.only"] = "on"
ui = _ScriptedUi(passwords=["guess"], choices=["Keep read-only"])
ctx = _JoinCtx(tmp_path, device, ui)
login = await join_room(ctx, ROOM)
assert login.access is RoomAccess.READ_ONLY
text, labels, default = ui.dialogs[0]
assert "read-only" in text and labels == ["Keep read-only", "Try again…"]
async def test_a_room_the_radio_forgot_is_put_back_and_tried_again(tmp_path: Path) -> None:
"""A login that the radio did not send is a separate case. The radio says why.
The correction is one write.
"""
from meshterm.ui.rooms import join_room
device = MockDevice()
device._contacts = [c for c in device._contacts if not c.is_room]
ui = _ScriptedUi(passwords=["hello"], choices=["Add it & try again"])
ctx = _JoinCtx(tmp_path, device, ui)
login = await join_room(ctx, ROOM)
assert login.access is RoomAccess.MEMBER
text, labels, _ = ui.dialogs[0]
assert text == "Your radio didn't send the login: this node isn't in the radio's contacts."
assert labels == ["Cancel", "Add it & try again"]
async def test_backing_out_of_the_prompt_sends_nothing(tmp_path: Path) -> None:
"""Esc on the password prompt ends the flow, and the code sends no login."""
from meshterm.ui.rooms import join_room
device = MockDevice()
ui = _ScriptedUi()
ctx = _JoinCtx(tmp_path, device, ui)
assert await join_room(ctx, ROOM) is None
assert not device._room_access
def test_a_password_the_radio_would_cut_is_refused() -> None:
"""MeshCore sends 15 bytes of a password. A longer password cannot work."""
from meshterm.ui.rooms import valid_password
assert valid_password("x" * 15) is True
assert valid_password("x" * 16) is not True
assert valid_password("é" * 8) is not True, "bytes, not characters"
def test_how_long_since_the_room_was_heard_is_part_of_the_explanation(tmp_path: Path) -> None:
"""Never heard, heard just now: this is the evidence that the user uses to judge the reach."""
from dataclasses import replace
from meshterm.ui.rooms import silence_explanation
ctx = _Ctx(tmp_path, MockDevice())
silent = RoomLogin(LoginResult.NO_REPLY, flood=True)
never = silence_explanation(ctx, replace(ROOM, last_seen=None), silent, "x")
assert "The room has never been heard here." in never
now = datetime.now(timezone.utc)
fresh = silence_explanation(ctx, replace(ROOM, last_seen=now), silent, "x")
assert "The room was heard just now." in fresh
# --- the radio's confirmation ---------------------------------------------------------
class _ConfirmingMeshCore:
"""A replacement for meshcore. Its login sends confirmations and answers as the script says."""
def __init__(self, frames: list[tuple[str, dict]]) -> None:
self.frames = frames
self.subs: list = []
self.commands = self
def subscribe(self, event_type, callback, attribute_filters=None): # noqa: ANN001, ANN201
sub = SimpleNamespace(event_type=event_type, callback=callback)
sub.unsubscribe = lambda: self.subs.remove(sub) if sub in self.subs else None
self.subs.append(sub)
return sub
async def send_login_sync(self, pub, password): # noqa: ANN001, ANN201
from meshcore import EventType
for name, payload in self.frames:
event = SimpleNamespace(type=getattr(EventType, name), payload=payload)
for sub in list(self.subs):
if sub.event_type is event.type:
sub.callback(event)
return None
def _confirmed_login(frames: list[tuple[str, dict]]) -> RoomLogin:
device = MeshCoreDevice(port="mock")
device._mc = _ConfirmingMeshCore(frames)
return asyncio.run(device.room_login(ROOM, "hello"))
def test_the_radios_confirmation_says_how_the_login_went_out(quick_budget) -> None: # noqa: ANN001, F811
"""The code matches the confirmation by the expected ack of the login.
The expected ack is the first four key bytes of the room.
"""
ours = bytes.fromhex(ROOM.public_key[:8])
flood = _confirmed_login(
[("MSG_SENT", {"type": 1, "expected_ack": ours}), ("LOGIN_SUCCESS", {"acl_permissions": 2})]
)
assert flood.access is RoomAccess.MEMBER and flood.flood is True
direct = _confirmed_login([("MSG_SENT", {"type": 0, "expected_ack": ours})])
assert direct.result is LoginResult.NO_REPLY and direct.flood is False
def test_another_commands_confirmation_is_not_ours(quick_budget) -> None: # noqa: ANN001, F811
"""A MSG_SENT for another command in progress does not show anything about this login."""
other = _confirmed_login([("MSG_SENT", {"type": 1, "expected_ack": b"\x00\x01\x02\x03"})])
assert other.flood is None and other.radio_error is None
def test_a_login_the_radio_would_not_send_carries_its_reason(quick_budget) -> None: # noqa: ANN001, F811
"""There is no confirmation, and the radio gives an error: the room is not in its contacts."""
refused = _confirmed_login([("ERROR", {"error_code": 2})])
assert refused.flood is None
assert refused.radio_error == "this node isn't in the radio's contacts"
async def test_the_simulator_floods_once_its_route_is_forgotten(tmp_path: Path) -> None:
"""A stale route gets silence. If the code forgets the route, the login floods.
The answer then gives a new route.
"""
device = MockDevice()
device._stale_routes.add(ROOM.name)
stale = await device.room_login(ROOM, "hello")
assert stale.result is LoginResult.NO_REPLY and stale.flood is False
await device.reset_route(ROOM)
flooded = await device.room_login(ROOM, "hello")
assert flooded.access is RoomAccess.MEMBER and flooded.flood is True
again = await device.room_login(ROOM, "")
assert again.flood is False and again.access is RoomAccess.MEMBER
# --- the Rooms page -------------------------------------------------------------------
def test_the_page_lists_joined_rooms_first(tmp_path: Path, repo: Repository) -> None:
"""The joined rooms are first. The other rooms follow, with the most recently heard first.
Each row has its lanes.
"""
from dataclasses import replace
from meshterm.ui.rooms import _page_items
ctx = _Ctx(tmp_path, MockDevice(), repo)
ctx.chat = SimpleNamespace(unread=lambda key: 0)
now = datetime.now(timezone.utc)
joined = replace(ROOM, last_seen=now)
hilltop = Contact(
name="Hilltop Swap", public_key="b7" * 32, key_prefix="b7" * 6, node_type=NODE_TYPE_ROOM
)
_join(ctx)
title, items = _page_items(ctx, [hilltop, joined])
assert title == "Rooms · 1 joined"
rows = [i.title().plain for i in items if hasattr(i, "value")]
assert rows[0].startswith("■ Lakeside BBS") and "member" in rows[0] and "now" in rows[0]
assert rows[1].startswith("■ Hilltop Swap") and "never" in rows[1]
# --- end to end, through the real session ---------------------------------------------
async def test_joining_from_the_rooms_page_end_to_end(tmp_path: Path) -> None:
"""Rooms, the page of the room, Join…, the password, Open the board: read, post, leave."""
from prompt_toolkit.input.defaults import create_pipe_input
from prompt_toolkit.output import DummyOutput
from rich.console import Console
from meshterm.context import AppContext
from meshterm.core.device_store import DeviceStore
from meshterm.ui.rooms import manage_rooms
from meshterm.ui.surface import TuiUi
from meshterm.ui.tui.session import TuiSession
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "e2e.db")
ctx = AppContext(
console=Console(),
settings=settings,
repo=Repository(settings.db_path),
device_store=DeviceStore(tmp_path / "devices.json"),
admin_store=AdminStore(tmp_path / "admin.json"),
mock=True,
)
def board() -> list[ChatMessage]:
return ctx.repo.recent_chat_messages(
is_channel=False, peer=ROOM.key_prefix, posts_only=True
)
def top() -> str:
return getattr(session._stack[-1], "title", "") if session._stack else ""
async def until(check, what: str) -> None: # noqa: ANN001
for _ in range(500):
if check():
return
await asyncio.sleep(0.01)
raise AssertionError(f"never reached: {what} (top is {top()!r})")
try:
with create_pipe_input() as inp:
session = TuiSession(input=inp, output=DummyOutput())
ctx.ui = TuiUi(session)
async def drive() -> None:
await until(lambda: top().startswith("Rooms"), "the Rooms page")
inp.send_text("\r") # the one room, under Not joined
await until(lambda: top() == f"Room — {ROOM.name}", "the room's page")
inp.send_text("\r") # Join…
await until(lambda: top() == f"Join — {ROOM.name}", "the password prompt")
inp.send_text("hello\r")
await until(lambda: ctx.rooms.joined(ROOM), "the join")
await until(lambda: top() == f"Room — {ROOM.name}", "back on the room's page")
inp.send_text("\r") # Open the board
await until(lambda: top().startswith(f"{ROOM.name} ·"), "the board")
await until(lambda: len([m for m in board() if m.is_post]) >= 5, "the catch-up")
inp.send_text("count me in\r")
await until(lambda: [m for m in board() if m.outbound], "the post")
inp.send_text("\x1b") # from the board, back to the page of the room
await until(lambda: top() == f"Room — {ROOM.name}", "the room's page again")
inp.send_text("\x1b") # from the page of the room, back to Rooms
await until(lambda: top().startswith("Rooms"), "the Rooms page again")
inp.send_text("\x1b")
async def main() -> None:
driver = asyncio.ensure_future(drive())
try:
await manage_rooms(ctx)
finally:
driver.cancel()
await asyncio.wait_for(session.run(main()), timeout=20)
mine = [m for m in board() if m.outbound]
assert [(m.text, m.acked) for m in mine] == [("count me in", True)]
assert ctx.room_store.password(ROOM) == "hello"
finally:
await ctx.aclose()
# --- the command line -----------------------------------------------------------------
@pytest.fixture()
def cli(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): # noqa: ANN201 - a closure
"""The real CLI against the simulator, in its own home (refer to test_cli_contract)."""
from typer.testing import CliRunner
from meshterm.cli import app
monkeypatch.setenv("MESHTERM_HOME", str(tmp_path / "home"))
runner = CliRunner()
db = tmp_path / "cli.db"
def invoke(*args: str): # noqa: ANN202
return runner.invoke(app, ["--mock", "--db", str(db), *args])
invoke.db = db
return invoke
def test_chat_list_names_a_room_as_one(cli) -> None: # noqa: ANN001
"""A room is its own type of conversation, on both faces."""
import json
assert "Lakeside BBS room" in cli("chat", "list").stdout
rows = json.loads(cli("--json", "chat", "list").stdout)
kinds = {r["node"]["name"]: r["kind"] for r in rows if r["node"]}
assert kinds["Lakeside BBS"] == "room" and kinds["Alice"] == "direct"
def test_rooms_join_answers_with_the_access_won(cli) -> None: # noqa: ANN001
"""The plain face shows only the access word. JSON has the room, the access, and the route."""
import json
from meshterm.core import exitcodes
unknown = cli("rooms", "join", "Lakeside BBS")
assert unknown.exit_code == exitcodes.USAGE, "nothing remembered, nothing sent"
assert cli("rooms", "join", "Alice", "--password", "x").exit_code == exitcodes.USAGE
long = cli("rooms", "join", "Lakeside BBS", "--password", "x" * 16)
assert long.exit_code == exitcodes.USAGE, "a password the radio would cut"
silent = cli("rooms", "join", "Lakeside BBS", "--password", "nope")
assert silent.exit_code == exitcodes.DEVICE
assert silent.stdout == "" and "didn't answer" in silent.stderr
joined = cli("rooms", "join", "Lakeside BBS", "--password", "hello")
assert joined.exit_code == exitcodes.OK and joined.stdout == "member\n"
again = json.loads(cli("--json", "rooms", "join", "Lakeside BBS").stdout) # a stored password
assert again["access"] == "member" and again["route"] == "direct"
assert again["room"]["name"] == "Lakeside BBS" and again["room"]["type"] == "room server"
flooded = json.loads(cli("--json", "rooms", "join", "Lakeside BBS", "--flood").stdout)
assert flooded["route"] == "flood"
def test_rooms_list_and_forget(cli) -> None: # noqa: ANN001
"""The list has each room that the radio knows, joined or not. A forget is local and silent."""
import json
from meshterm.core import exitcodes
header, *rows = cli("rooms", "list").stdout.splitlines()
assert header.split() == ["ROOM", "ACCESS", "HEARD", "LAST_POST", "UNREAD"]
assert rows[0].startswith("Lakeside BBS")
cli("rooms", "join", "Lakeside BBS", "--password", "hello")
docs = json.loads(cli("--json", "rooms", "list").stdout)
assert docs[0]["joined"] is True and docs[0]["access"] == "member"
forgot = cli("rooms", "forget", "Lakeside BBS")
assert forgot.exit_code == exitcodes.OK and forgot.stdout == ""
assert cli("rooms", "forget", "Lakeside BBS").exit_code == exitcodes.NO_RESULT
assert json.loads(cli("--json", "rooms", "list").stdout)[0]["joined"] is False
def test_chat_history_reads_a_room_by_author(cli) -> None: # noqa: ANN001
"""AUTHOR is in the place of PEER. A command reply is not on the board. The form is the same."""
import json
repo = Repository(cli.db)
try:
repo.record_chat_message(ChatMessage.from_message(_post("swap meet?")))
repo.record_chat_message(ChatMessage.from_message(_post("north down?", STRANGER_KEY)))
repo.record_chat_message(ChatMessage(text="> hello", peer=ROOM.key_prefix))
finally:
repo.close()
header, *rows = cli("chat", "history", "--to", "Lakeside BBS").stdout.splitlines()
assert header.split() == ["TIME", "DIR", "AUTHOR", "SNR_DB", "TEXT"]
assert "Alice (d4e5f6a7)" in rows[0] and rows[0].endswith("swap meet?")
assert STRANGER_KEY in rows[1] and "(" not in rows[1].split("north")[0]
assert len(rows) == 2
docs = json.loads(cli("--json", "chat", "history", "--to", "Lakeside BBS").stdout)
assert docs[0]["author"]["name"] == "Alice" and docs[0]["author"]["hash"] == ALICE_KEY
assert docs[1]["author"] == {
"name": None,
"key": None,
"hash": STRANGER_KEY,
"type": None,
"self": False,
}
def test_chat_send_to_a_room_is_a_post(cli) -> None: # noqa: ANN001
"""The receipt says that the message went to a room."""
import json
receipt = json.loads(cli("--json", "chat", "send", "--to", "Lakeside BBS", "hi").stdout)
assert receipt["kind"] == "room"
assert receipt["acked"] is False, "this simulator never joined, so the room drops it"