Files
MeshTerm/tests/test_send_scope.py
T
Jean-Pierre MartineauandClaude Opus 5.5 444b0ea669 A channel, a DM, and a room share one F-key lane, and the day jump leaves it
F1-F3 hold what the three conversations share, in the hint's order: Paths,
QR, Map. The Shift bank holds each screen's own verbs: ^R on Shift+F1 (Retry
in a DM and a room, Resend in a channel) and the room's Log in on Shift+F2.
F4/F5 stay the pager with Latest/Oldest behind Shift.

Day ↑ and Day ↓ are gone from the lane; the desktop keeps the day jump on
^PgUp/^PgDn.

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

783 lines
30 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Tests for the sending side of region scopes.
The scope of a channel is a window on the one *session* scope of the companion. MeshTerm
sets the scope, sends the message, and puts the scope back. No other transmission can go
out between these steps. First, these tests check this order against the simulator. They
also check what happens when a step fails. Then they test everything that depends on it:
* the scope that MeshTerm records,
* the title of the chat and its unscoped resend,
* the line of the paths dialog,
* the picker on the channel page,
* the CLI,
* Device config, which teaches the default.
All the tests run against :class:`~meshterm.core.connection.MockDevice` and scratch files.
Nothing transmits.
"""
from __future__ import annotations
import asyncio
import json
import logging
from pathlib import Path
from types import SimpleNamespace
import pytest
from meshterm.core.channel_probe import ChannelSlot
from meshterm.core.config import Settings
from meshterm.core.connection import (
DeviceCommandError,
FloodScopeError,
MockDevice,
firmware_version,
)
from meshterm.core.models import ChatMessage, Conversation
from meshterm.core.preferences import Preferences
from meshterm.core.region_store import RegionStore
from meshterm.core.regions import RegionNameError
from meshterm.core.transmit_lock import TransmitLock
from meshterm.persistence import db as db_module
from meshterm.persistence.repository import Repository
from meshterm.services.chat_service import ChatService
from meshterm.services.event_hub import EventHub
from meshterm.ui.chat import ChatScreen, _sent_scope_line
from meshterm.ui.tui import Choice, fkeys
# -- a device that records each command that it gets, in order -----------------------------
class _TracingDevice(MockDevice):
"""The simulator, with a log of each scope change and each send, in order.
``refuse`` makes the named scope command fail in the same way as old firmware.
``version`` is the firmware build that the device query gives.
"""
def __init__(self, *, refuse: set[str] | None = None, version: str = "mock") -> None:
super().__init__()
self.trail: list[tuple] = []
self._refuse = refuse or set()
self._version = version
self.fail_restore = False
async def set_flood_scope(self, region: str | None) -> None:
key = region if region else None
if key in self._refuse or (key is None and self.fail_restore):
raise DeviceCommandError("ERR_CODE_UNSUPPORTED_CMD")
await super().set_flood_scope(region)
self.trail.append(("scope", key))
async def send_channel_message(self, index: int, text: str) -> None:
await super().send_channel_message(index, text)
self.trail.append(("channel", text, self._send_scope))
async def send_direct_message(self, contact, text): # noqa: ANN001, ANN201
ack = await super().send_direct_message(contact, text)
self.trail.append(("dm", text, self._send_scope))
return ack
async def get_device_info(self) -> dict:
return {**await super().get_device_info(), "ver": self._version}
# -- the transmit lock -----------------------------------------------------------------
async def test_transmit_lock_reenters_for_its_holder_and_blocks_everyone_else() -> None:
"""The scoped send enters its own lock again. Another task waits for the whole window."""
lock = TransmitLock()
order: list[str] = []
async def other() -> None:
async with lock.held():
order.append("other")
async with lock.held():
async with lock.held(): # the same task: this must not deadlock
order.append("inner")
task = asyncio.ensure_future(other())
await asyncio.sleep(0.01)
assert order == ["inner"] # the other task still waits for us
order.append("outer-done")
await task
assert order == ["inner", "outer-done", "other"]
assert not lock.locked
# -- the three-step window ---------------------------------------------------------------
async def test_scoped_send_sets_then_sends_then_restores() -> None:
"""MeshTerm sets the scope, sends the channel message under it, and restores the default.
The steps are in this order.
"""
device = _TracingDevice()
await device.send_channel_in_scope(0, "hello", "lakeside")
assert device.trail == [
("scope", "lakeside"),
("channel", "hello", "lakeside"),
("scope", None),
]
assert device._send_scope == ""
async def test_plain_send_never_touches_the_session_scope() -> None:
"""If the caller gives no scope, the message goes out under the default.
MeshTerm sets nothing.
"""
device = _TracingDevice()
await device.send_channel_in_scope(0, "hello", None)
assert device.trail == [("channel", "hello", "")]
async def test_a_dm_cannot_interleave_into_the_window() -> None:
"""A DM that starts in the middle of the window waits until the scope is restored.
Then it goes out unscoped.
"""
device = _TracingDevice()
await device.connect()
alice = next(c for c in await device.get_contacts() if c.name == "Alice")
entered = asyncio.Event()
release = asyncio.Event()
original = device.send_channel_message
async def slow_send(index: int, text: str) -> None:
entered.set()
await release.wait()
await original(index, text)
device.send_channel_message = slow_send # type: ignore[method-assign]
scoped = asyncio.ensure_future(device.send_channel_in_scope(0, "scoped", "lakeside"))
await entered.wait()
dm = asyncio.ensure_future(device.send_direct_message(alice, "dm"))
await asyncio.sleep(0.01)
assert not dm.done() # the DM waits for the window
release.set()
await asyncio.gather(scoped, dm)
assert device.trail == [
("scope", "lakeside"),
("channel", "scoped", "lakeside"),
("scope", None),
("dm", "dm", ""),
]
async def test_a_refused_scope_sends_nothing() -> None:
"""Old firmware refuses the scope.
The result is ``FloodScopeError``, and no message goes out.
"""
device = _TracingDevice(refuse={"lakeside"})
with pytest.raises(FloodScopeError) as caught:
await device.send_channel_in_scope(0, "hello", "lakeside")
assert caught.value.scope == "lakeside"
assert "1.10" in str(caught.value) and "Nothing was sent" in str(caught.value)
assert device.trail == []
async def test_a_send_that_fails_still_restores_the_scope() -> None:
"""If the channel send raises an error, the session scope must not stay on its region."""
device = _TracingDevice()
async def boom(index: int, text: str) -> None:
raise DeviceCommandError("rejected")
device.send_channel_message = boom # type: ignore[method-assign]
with pytest.raises(DeviceCommandError):
await device.send_channel_in_scope(0, "hello", "lakeside")
assert device.trail == [("scope", "lakeside"), ("scope", None)]
async def test_a_failed_restore_is_repaired_before_the_next_transmission() -> None:
"""The next send tries a restore that failed again, before that send goes out."""
device = _TracingDevice()
device.fail_restore = True
await device.send_channel_in_scope(0, "first", "lakeside") # the message went out
assert device._send_scope == "lakeside"
device.fail_restore = False
await device.send_advert()
assert device.trail[-1] == ("scope", None)
assert device._send_scope == ""
async def test_an_invalid_region_is_refused_before_the_radio_is_asked() -> None:
"""A name that the firmware cannot hold never reaches the device."""
device = _TracingDevice()
with pytest.raises(RegionNameError):
await device.send_channel_in_scope(0, "hello", "two words")
assert device.trail == []
# -- unscoped (*) and the firmware that can or cannot do it --------------------------------
def test_firmware_version_reads_the_leading_release() -> None:
"""``v1.15.0``, ``1.16.2-dev``, and ``1.9`` all compare.
A text that is not a version gives None.
"""
assert firmware_version({"ver": "v1.15.0"}) == (1, 15, 0)
assert firmware_version({"ver": "1.16.2-dev"}) == (1, 16, 2)
assert firmware_version({"ver": "1.9"}) == (1, 9, 0)
assert firmware_version({"ver": "mock"}) is None
assert firmware_version({}) is None
async def test_unscoped_uses_the_override_on_new_firmware() -> None:
"""On 1.16 and later, MeshTerm sets ``*`` as the session scope.
Then it sends the message and restores the scope.
"""
device = _TracingDevice(version="v1.16.0")
await device.send_channel_in_scope(0, "hello", "*")
assert device.trail == [("scope", "*"), ("channel", "hello", "*"), ("scope", None)]
async def test_unscoped_on_old_firmware_without_a_default_is_the_plain_send() -> None:
"""On 1.15 with no default scope, a plain flood is already unscoped, so MeshTerm sends it."""
device = _TracingDevice(version="v1.15.0")
await device.send_channel_in_scope(0, "hello", "*")
assert device.trail == [("channel", "hello", "")]
async def test_unscoped_on_old_firmware_over_a_default_is_refused() -> None:
"""On 1.15 with a default scope, MeshTerm cannot override it.
The send is refused, and nothing is sent.
"""
device = _TracingDevice(version="v1.15.0")
await device.set_default_flood_scope("lakeside")
with pytest.raises(FloodScopeError) as caught:
await device.send_channel_in_scope(0, "hello", "*")
assert "1.16" in str(caught.value) and "lakeside" in str(caught.value)
assert device.trail == []
# -- the chat service: which scope, and what MeshTerm records -------------------------------
class _Ctx:
"""The part of AppContext that the chat service reads, with a real region store."""
def __init__(self, device: MockDevice, repo: Repository, tmp: Path) -> None:
self._device = device
self.repo = repo
self.log = logging.getLogger("test.scope")
self.profile_name = None
self.settings = Settings()
self.preferences = Preferences()
self.events = EventHub(self)
self.region_store = RegionStore(tmp / "regions.json")
self.devstate = SimpleNamespace(default_scope=self._default_scope)
self.default = ""
async def _default_scope(self) -> str:
return self.default
async def device(self) -> MockDevice:
await self._device.connect()
return self._device
@pytest.fixture()
def repo(tmp_path: Path) -> Repository:
"""A Repository on a scratch database. The fixture closes it when the test ends."""
r = Repository(tmp_path / "scope.db")
yield r
r.close()
async def test_a_channel_send_goes_under_its_scope_and_records_it(
tmp_path: Path, repo: Repository
) -> None:
"""MeshTerm finds the scope of the channel by identity and sends under it.
It keeps the scope with the message.
"""
device = _TracingDevice()
ctx = _Ctx(device, repo, tmp_path)
chat = ChatService(ctx)
channel_id = await chat.channel_id_for(0)
ctx.region_store.set_channel_scope(channel_id, "lakeside")
sent = await chat.send_channel(0, "hello", label="Public")
assert sent.scope == "lakeside"
assert ("channel", "hello", "lakeside") in device.trail
stored = repo.recent_chat_messages(is_channel=True, channel_id=channel_id)
assert stored[-1].scope == "lakeside" and stored[-1].row_id == sent.row_id
async def test_a_channel_without_a_scope_records_the_default_or_unscoped(
tmp_path: Path, repo: Repository
) -> None:
"""If the channel has no scope, MeshTerm records the default of the device.
If there is no default, it records ``*``.
"""
ctx = _Ctx(_TracingDevice(), repo, tmp_path)
chat = ChatService(ctx)
assert (await chat.send_channel(0, "a")).scope == "*"
ctx.default = "harbour"
assert (await chat.send_channel(0, "b")).scope == "harbour"
async def test_the_one_shot_override_sends_unscoped(tmp_path: Path, repo: Repository) -> None:
"""``scope="*"`` overrides the scope of the channel for one message, and MeshTerm records it."""
device = _TracingDevice()
ctx = _Ctx(device, repo, tmp_path)
chat = ChatService(ctx)
ctx.region_store.set_channel_scope(await chat.channel_id_for(0), "lakeside")
sent = await chat.send_channel(0, "again", scope="*")
assert sent.scope == "*"
assert ("channel", "again", "*") in device.trail
async def test_a_refused_scope_records_nothing(tmp_path: Path, repo: Repository) -> None:
"""A message that the radio does not send under its scope is not in the transcript."""
ctx = _Ctx(_TracingDevice(refuse={"lakeside"}), repo, tmp_path)
chat = ChatService(ctx)
channel_id = await chat.channel_id_for(0)
ctx.region_store.set_channel_scope(channel_id, "lakeside")
with pytest.raises(FloodScopeError):
await chat.send_channel(0, "hello")
assert repo.recent_chat_messages(is_channel=True, channel_id=channel_id) == []
# -- the schema --------------------------------------------------------------------------
def test_a_v16_database_gains_the_scope_column(tmp_path: Path) -> None:
"""If MeshTerm opens a database from before v17, it adds ``messages.scope``.
The old rows are NULL.
"""
path = tmp_path / "old.db"
conn = db_module.connect(path)
conn.execute("ALTER TABLE messages DROP COLUMN scope")
conn.execute(
"INSERT INTO messages (outbound, is_channel, text, created_at) "
"VALUES (1, 1, 'old', '2026-09-01T00:00:00+00:00')"
)
conn.commit()
conn.close()
conn = db_module.connect(path)
cols = {row["name"] for row in conn.execute("PRAGMA table_info(messages)")}
assert "scope" in cols
assert conn.execute("SELECT scope FROM messages").fetchone()["scope"] is None
version = conn.execute("SELECT value FROM schema_meta WHERE key = 'version'").fetchone()
assert int(version["value"]) >= 17
conn.close()
# -- the chat screen -----------------------------------------------------------------------
class _Session:
def __init__(self, answer: bool = True) -> None:
self.answer = answer
self.dialogs: list[str] = []
def invalidate(self) -> None:
pass
def run_detached(self, work): # noqa: ANN001, ANN201
return asyncio.ensure_future(work)
async def button_dialog(self, prompt, buttons, **kwargs): # noqa: ANN001, ANN003, ANN201
self.dialogs.append(kwargs.get("title", ""))
return self.answer
def _channel_chat(session, messages, *, scope="lakeside", resend=None) -> ChatScreen: # noqa: ANN001
conv = Conversation(label="#ops", is_channel=True, channel_idx=1, channel_id="cid")
return ChatScreen(
conv,
messages,
send=None,
names={},
session=session,
scope=scope,
resend_unscoped=resend,
)
def test_the_chat_title_carries_the_scope_as_a_status_atom() -> None:
"""The title is ``#ops · lakeside``. If the channel has no scope, there is no atom."""
assert _channel_chat(_Session(), []).title == "#ops · lakeside"
assert _channel_chat(_Session(), [], scope=None).title == "#ops"
async def test_resend_unscoped_targets_only_a_newest_scoped_message() -> None:
"""^R offers the newest sent message when it went out under a region, and no other message."""
async def resend(message: ChatMessage) -> ChatMessage:
return ChatMessage(text=message.text, outbound=True, is_channel=True, scope="*")
scoped = ChatMessage(text="hi", outbound=True, is_channel=True, scope="lakeside")
screen = _channel_chat(_Session(), [scoped], resend=resend)
assert screen._retry_target() is scoped
assert "^R resend unscoped" in screen.footer_hint
lane = screen.picocalc_lyra_lane
assert lane[0].opp_label == "Resend" and lane[0].opp_enabled # Shift+F1, the ^R of each chat
screen.handle("retry")
await asyncio.sleep(0.05)
assert screen._messages[-1].scope == "*"
assert screen._retry_target() is None # the unscoped copy is the newest message now
assert "^R" not in screen.footer_hint
assert "unscoped" in _plain_lines(screen)
async def test_resend_unscoped_takes_the_picked_message_over_the_newest() -> None:
"""If the user selects a message, ^R resends that message.
This is only true if the message is one of ours and it has a scope.
"""
sent: list[str] = []
async def resend(message: ChatMessage) -> ChatMessage:
sent.append(message.text)
return ChatMessage(text=message.text, outbound=True, is_channel=True, scope="*")
older = ChatMessage(text="older", outbound=True, is_channel=True, scope="lakeside")
heard = ChatMessage(text="heard", outbound=False, is_channel=True)
newest = ChatMessage(text="newest", outbound=True, is_channel=True, scope="lakeside")
screen = _channel_chat(_Session(), [older, heard, newest], resend=resend)
assert screen._retry_target() is newest # nothing is selected: the newest message
screen._selected = 0 # the older message with a scope
assert screen._retry_target() is older
assert "^R resend unscoped" in screen.footer_hint
assert len(screen.footer_hint) <= 72
screen._selected = 1 # a message from another node: nothing to resend, and no fallback
assert screen._retry_target() is None
assert "^R" not in screen.footer_hint
screen._selected = 0
screen.handle("retry")
await asyncio.sleep(0.05)
assert sent == ["older"]
assert screen._selected is None # the selection is done, and the resend is at the end
assert screen._messages[-1].text == "older" and screen._messages[-1].scope == "*"
async def test_a_declined_resend_sends_nothing() -> None:
"""Cancel on the amber confirm does not change the transcript."""
calls: list[str] = []
async def resend(message: ChatMessage) -> ChatMessage:
calls.append(message.text)
return message
scoped = ChatMessage(text="hi", outbound=True, is_channel=True, scope="lakeside")
session = _Session(answer=False)
screen = _channel_chat(session, [scoped], resend=resend)
screen.handle("retry")
await asyncio.sleep(0.05)
assert session.dialogs == ["Resend unscoped"] and calls == []
def test_a_channel_with_nothing_to_resend_dims_the_chip() -> None:
"""If the newest message is unscoped, Resend stays on the lane, dim.
It is there, but it is not available.
"""
unscoped = ChatMessage(text="hi", outbound=True, is_channel=True, scope="*")
async def resend(message: ChatMessage) -> ChatMessage: # pragma: no cover - never called
return message
screen = _channel_chat(_Session(), [unscoped], resend=resend)
lane = screen.picocalc_lyra_lane
assert lane[0].opp_label == "Resend" and not lane[0].opp_enabled
assert fkeys.PICOCALC_LYRA_DECK.lane_text(lane, shifted=True).plain
def _plain_lines(screen: ChatScreen) -> str:
from tests.conftest import plain
return plain(screen.render_body(72))
# -- the line of the paths dialog -----------------------------------------------------------
def test_the_paths_line_says_when_no_known_repeater_carries_the_scope(tmp_path: Path) -> None:
"""If the message has a scope, nothing relayed it, and no carrier is known, the line says this.
A known carrier changes the end of the line.
"""
store = RegionStore(tmp_path / "regions.json")
ctx = SimpleNamespace(region_store=store)
sent = ChatMessage(text="hi", outbound=True, is_channel=True, scope="lakeside")
line = _sent_scope_line(ctx, sent, relayed=False)
assert line.plain == "sent under scope lakeside — no repeater known here carries it"
store.learn("lakeside", "repeater", repeater="3d63c6429436")
assert _sent_scope_line(ctx, sent, relayed=False).plain == (
"sent under scope lakeside · 1 known repeater carries it"
)
unscoped = ChatMessage(text="hi", outbound=True, is_channel=True, scope="*")
assert _sent_scope_line(ctx, unscoped, relayed=True).plain == "sent unscoped"
inbound = ChatMessage(text="hi", is_channel=True)
assert _sent_scope_line(ctx, inbound, relayed=True) is None
# -- the picker on the channel page ----------------------------------------------------------
class _PickerUi:
"""It plays the scripted select and text answers.
It records the rows that each select offered.
"""
def __init__(self, selects: list, texts: list) -> None:
self._selects = list(selects)
self._texts = list(texts)
self.offered: list[list] = []
async def select(self, title, items, *, prompt="", default=None): # noqa: ANN001, ANN201
self.offered.append(items)
return self._selects.pop(0)
async def text(self, title, *, prompt="", default="", validate=None): # noqa: ANN001, ANN201
answer = self._texts.pop(0)
if answer is not None and validate is not None:
assert validate(answer) is True
return answer
async def test_the_picker_sets_types_steps_back_and_clears(tmp_path: Path) -> None:
"""The user can select a known region, type one, or clear it.
Esc on the field returns to the list.
"""
from meshterm.ui.channels import _NO_SCOPE, _TYPE_REGION, _detail_summary, _pick_scope
store = RegionStore(tmp_path / "regions.json")
store.learn("harbour", "repeater", repeater="3d63c6429436")
slot = ChannelSlot(idx=1, name="Ops", secret=bytes(range(16)))
ui = _PickerUi(selects=["harbour"], texts=[])
ctx = SimpleNamespace(region_store=store, ui=ui)
assert await _pick_scope(ctx, slot) is True
assert store.channel_scope(slot.identity) == "harbour"
labels = [item.label.plain for item in ui.offered[0] if isinstance(item, Choice)]
assert labels[1] == "harbour · 1 repeater"
ctx.ui = _PickerUi(selects=[_TYPE_REGION, _TYPE_REGION], texts=[None, "yul"])
assert await _pick_scope(ctx, slot) is True
assert store.channel_scope(slot.identity) == "yul"
assert "typed" in store.get("yul").sources
ctx.ui = _PickerUi(selects=[_NO_SCOPE], texts=[])
assert await _pick_scope(ctx, slot) is True
assert store.channel_scope(slot.identity) is None
ctx.ui = _PickerUi(selects=[None], texts=[])
assert await _pick_scope(ctx, slot) is False # Esc keeps the value
store.set_channel_scope(slot.identity, "yul")
stats = SimpleNamespace(get=lambda identity: None)
summary_ctx = SimpleNamespace(
region_store=store,
chat=SimpleNamespace(unread=lambda key: 0),
mute_store=SimpleNamespace(is_muted=lambda identity: False),
)
assert "scope yul" in _detail_summary(summary_ctx, slot, stats)
# -- the CLI ------------------------------------------------------------------------------
@pytest.fixture()
def run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): # noqa: ANN201
"""The real Typer app with the simulator, in a config directory of its own."""
from typer.testing import CliRunner
from meshterm.cli import app
monkeypatch.setenv("MESHTERM_HOME", str(tmp_path / "home"))
runner = CliRunner()
def invoke(*args: str): # noqa: ANN202
return runner.invoke(app, ["--mock", "--db", str(tmp_path / "test.db"), *args])
return invoke
async def test_cli_channel_scope_reads_sets_and_clears(tmp_path: Path) -> None:
"""``channels scope`` reads, sets, and clears. The channel is the key.
A read with no scope gives ``-`` and exit 0.
"""
from io import StringIO
from rich.console import Console
from meshterm.context import AppContext
from meshterm.core.admin_store import AdminStore
from meshterm.core.device_store import DeviceStore
from meshterm.tools.channels import ChannelsTool
from meshterm.ui import script
from meshterm.ui.renderers import PlainRenderer
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "chan.db")
ctx = AppContext(
console=Console(file=StringIO()),
settings=settings,
repo=Repository(settings.db_path),
device_store=DeviceStore(tmp_path / "devices.json"),
admin_store=AdminStore(tmp_path / "admin.json"),
mock=True,
)
tool = ChannelsTool()
try:
await tool.run(ctx, {"cli_action": "add", "index": 1, "name": "Ops", "secret": None})
read = {"cli_action": "scope", "index": 1, "region": None, "clear": False}
empty = await tool.run(ctx, read)
assert empty.summary["scope"] is None and empty.exit_code == 0
buffer = StringIO()
PlainRenderer(script.console(buffer)).render(empty.report)
assert buffer.getvalue().strip() == "-"
await tool.run(ctx, {**read, "region": "lakeside"})
again = await tool.run(ctx, read)
assert again.summary["scope"] == "lakeside" and again.exit_code == 0
assert again.report[0].values["channel"].name == "Ops"
listed = await tool.run(ctx, {"cli_action": "list"})
assert [row["scope"] for row in listed.report[0].rows] == ["lakeside"]
cleared = await tool.run(ctx, {**read, "clear": True})
assert cleared.summary["scope"] is None and cleared.exit_code == 0
finally:
ctx.repo.close()
def test_cli_channel_scope_refuses_bad_arguments(run) -> None: # noqa: ANN001
"""A name that is not correct, a name with --clear, or a write to an empty slot is an error.
The error is a usage error. A read of an empty slot is exit 5 with its document, in the
same way as ``share`` answers for an empty slot. A write there did nothing, and exit 5
would tell a script that it did something.
"""
assert run("channels", "scope", "0", "two words").exit_code == 2
assert run("channels", "scope", "0", "yul", "--clear").exit_code == 2
assert run("channels", "scope", "0", "yul").exit_code == 2
assert run("channels", "scope", "0", "--clear").exit_code == 2
result = run("--json", "channels", "scope", "0")
assert result.exit_code == 5
assert json.loads(result.stdout) == {"channel": None, "scope": None}
def test_cli_send_scope_is_a_channel_option_and_reports_its_scope(run) -> None: # noqa: ANN001
"""``--scope *`` goes out unscoped and says so.
On a DM, or with a name that is not correct, it is a usage error.
"""
doc = json.loads(run("--json", "chat", "send", "hi", "--channel", "0", "--scope", "*").stdout)
assert doc["scope"] == {"state": "unscoped", "region": None, "code": None}
doc = json.loads(run("--json", "chat", "send", "hi", "--channel", "0", "--scope", "yul").stdout)
assert doc["scope"]["region"] == "yul"
assert run("chat", "send", "hi", "--to", "Alice", "--scope", "yul").exit_code == 2
assert run("chat", "send", "hi", "--channel", "0", "--scope", "$x").exit_code == 2
def test_the_entry_point_never_expands_a_wildcard(monkeypatch: pytest.MonkeyPatch) -> None:
"""``--scope '*'`` reaches the command as ``*``, not as the file names of the directory.
Click expands globs in the arguments on Windows, also the arguments in quotation marks.
The entry point of the console script turns this off, because ``*`` is the wildcard
region and no argument takes a glob.
"""
import meshterm.cli as cli
seen: dict = {}
monkeypatch.setattr(cli, "app", lambda **kwargs: seen.update(kwargs))
cli.main()
assert seen == {"windows_expand_args": False}
# -- Device config teaches the default ---------------------------------------------------------
def test_a_config_read_learns_the_default_scope(tmp_path: Path) -> None:
"""A snapshot that has the default scope teaches the store and fills the cache."""
from meshterm.tools.config import learn_default_scope
noted: list = []
store = RegionStore(tmp_path / "regions.json")
ctx = SimpleNamespace(
region_store=store, devstate=SimpleNamespace(note_default_scope=noted.append)
)
learn_default_scope(ctx, {"flood_scope": "#harbour"})
assert store.get("harbour").sources == ("default",)
assert noted == ["harbour"]
learn_default_scope(ctx, {}) # the read failed: nothing is learned, nothing is noted
assert noted == ["harbour"]
learn_default_scope(ctx, {"flood_scope": ""})
assert noted == ["harbour", ""]
async def test_a_channel_chat_sends_under_its_scope_end_to_end(tmp_path: Path) -> None:
"""If the user types in the chat of a channel with a scope, the real session sends under it."""
from io import StringIO
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.admin_store import AdminStore
from meshterm.core.device_store import DeviceStore
from meshterm.ui.chat import open_chat
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(file=StringIO()),
settings=settings,
repo=Repository(settings.db_path),
device_store=DeviceStore(tmp_path / "devices.json"),
admin_store=AdminStore(tmp_path / "admin.json"),
mock=True,
)
try:
device = await ctx.device()
await device.set_channel(0, "Public", bytes(16))
channel_id = await ctx.chat.channel_id_for(0)
ctx.region_store.set_channel_scope(channel_id, "lakeside")
conv = Conversation(label="Public", is_channel=True, channel_idx=0, channel_id=channel_id)
with create_pipe_input() as inp:
session = TuiSession(input=inp, output=DummyOutput())
ctx.ui = TuiUi(session)
async def main() -> None:
inp.send_text("hi\r")
task = asyncio.ensure_future(open_chat(ctx, conv))
await asyncio.sleep(0.3)
inp.send_text("\x1b")
await task
await asyncio.wait_for(session.run(main()), timeout=5)
stored = ctx.repo.recent_chat_messages(is_channel=True, channel_id=channel_id)
assert [(m.text, m.scope) for m in stored] == [("hi", "lakeside")]
assert device.sent_channel == [(0, "hi", "lakeside")]
assert device._send_scope == "" # restored after the send
finally:
await ctx.aclose()