Files
meshcore-bot/tests/unit/test_device_default_scope.py
T
agessaman 16cce92c46 feat(region): set region scopes from the Radio page (#283)
Region scopes had to be hand-edited in config.ini, and the radio's own default
region could not be set from the bot at all. Both now live on the Radio page.

Node Settings gains a Default Region Scope field beside the path hash size,
which is the firmware's own setting — NodePrefs.default_scope_name and
default_scope_key, the region the radio falls back to for any send the bot does
not scope itself. Firmware that has no such command is reported as not having
answered rather than shown as an empty field, and a stored key that is not the
stored name's hash is flagged, because the radio routes by the key and the name
beside it is only a label. A build-flag default stores the bare name while
hashing the '#' form, so the comparison normalizes first.

Clearing it does not go through the meshcore library, because none of its reset
paths can: set_default_flood_scope(None) raises on len(None), "" earns
ILLEGAL_ARG from the firmware, and "*" only works because the frame is padded by
character count rather than by the name it wrote, landing one byte short of the
length the firmware reads as "a scope follows". The firmware's own contract for
clearing is a bare CMD_SET_DEFAULT_FLOOD_SCOPE, so that is what goes out. The
same padding bug is why a device scope name must be ASCII: a multi-byte
character displaces the transport key out of its field.

A separate Region Scopes card covers the bot's side — [Channels] flood_scopes as
an explicit choice between "reply whatever the scope" and an allowlist, and
outgoing_flood_scope_override. It writes config.ini, queues a hot reload, then
polls that reload and reports what the bot did with it, including saying plainly
when nothing picked it up. Per-channel flood_scope.<channel> entries are listed
read-only, since they are the reason a channel can ignore the default.

The two settings interact in a way worth stating on the page: once the bot sends
a scoped message it restores with set_flood_scope("*"), which leaves the radio in
forced-unscoped mode, so the device default stops applying until the bot scopes
another send.

Also from building it:

- flood_scopes left in [Bot] is surfaced rather than hidden. CommandManager
  still honours it when [Channels] is empty, so a page that ignored it would
  show "replies to every scope" while the bot enforced an allowlist — and
  saving "off" hands the allowlist straight back to [Bot], which the banner now
  says instead of claiming the old key stops mattering.
- outgoing_flood_scope_override = none is read as global flood on the send path,
  as it already was everywhere else. send_channel_message tested the raw value
  against a fixed tuple, so the lowercase spelling became the region "#none".
- Scope normalization moved to modules/flood_scope.py so the viewer, a separate
  process, can validate a typed name without importing the bot's command
  machinery. CommandManager delegates to it.
- Dark mode named only .border, so a .border-start divider drew Bootstrap's
  light #dee2e6 onto a dark card. All four directional utilities now match.
2026-09-16 07:59:10 -07:00

201 lines
8.5 KiB
Python

"""The radio's own default flood scope: reading it, writing it, clearing it.
The firmware keeps a default region in NodePrefs (``default_scope_name`` plus
``default_scope_key``) and uses it for any send the app has not scoped itself.
These cover the scheduler operations behind the Radio page's Default Region
Scope field.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, Mock
import pytest
from meshcore.events import EventType
from meshcore.packets import CommandType
from modules import flood_scope
from modules.scheduler import MessageScheduler
def _scheduler(meshcore):
bot = MagicMock()
bot.meshcore = meshcore
scheduler = object.__new__(MessageScheduler)
scheduler.bot = bot
scheduler.logger = Mock()
return scheduler
def _meshcore(**command_attrs):
meshcore = MagicMock()
meshcore.is_connected = True
meshcore.commands = MagicMock()
for name, value in command_attrs.items():
setattr(meshcore.commands, name, value)
return meshcore
def _event(event_type, payload=None):
event = MagicMock()
event.type = event_type
event.payload = payload
return event
class TestReadDefaultScope:
@pytest.mark.asyncio
async def test_a_configured_scope_is_reported_with_its_key(self):
key = flood_scope.scope_key_hex("#west")
meshcore = _meshcore(get_default_flood_scope=AsyncMock(
return_value=_event(
EventType.DEFAULT_FLOOD_SCOPE,
{"scope_name": "#west", "scope_key": key},
)))
result = await _scheduler(meshcore)._read_default_flood_scope(meshcore)
assert result == {
"default_scope_name": "#west",
"default_scope_key": key,
"default_scope_supported": True,
"default_scope_key_matches": True,
}
@pytest.mark.asyncio
async def test_a_build_flag_default_stored_without_its_hash_still_matches(self):
"""The firmware's DEFAULT_FLOOD_SCOPE_NAME build flag stores the bare
name but hashes the '#' form, so comparing raw would report a mismatch
on a radio that is perfectly consistent."""
meshcore = _meshcore(get_default_flood_scope=AsyncMock(
return_value=_event(
EventType.DEFAULT_FLOOD_SCOPE,
{"scope_name": "west", "scope_key": flood_scope.scope_key_hex("#west")},
)))
result = await _scheduler(meshcore)._read_default_flood_scope(meshcore)
assert result["default_scope_key_matches"] is True
@pytest.mark.asyncio
async def test_a_key_that_is_not_the_name_s_hash_is_flagged(self):
meshcore = _meshcore(get_default_flood_scope=AsyncMock(
return_value=_event(
EventType.DEFAULT_FLOOD_SCOPE,
{"scope_name": "#west", "scope_key": "00" * 16},
)))
result = await _scheduler(meshcore)._read_default_flood_scope(meshcore)
assert result["default_scope_key_matches"] is False
@pytest.mark.asyncio
async def test_the_one_byte_sentinel_reads_as_cleared_not_unsupported(self):
"""A radio with no default scope answers with the response code alone,
which the library turns into an empty payload."""
meshcore = _meshcore(get_default_flood_scope=AsyncMock(
return_value=_event(EventType.DEFAULT_FLOOD_SCOPE, {})))
result = await _scheduler(meshcore)._read_default_flood_scope(meshcore)
assert result["default_scope_supported"] is True
assert result["default_scope_name"] == ""
assert "default_scope_key_matches" not in result
@pytest.mark.asyncio
async def test_an_error_reply_reports_nothing_rather_than_cleared(self):
"""Firmware without the command must not be shown as 'no scope set'."""
meshcore = _meshcore(get_default_flood_scope=AsyncMock(
return_value=_event(EventType.ERROR)))
assert await _scheduler(meshcore)._read_default_flood_scope(meshcore) == {}
@pytest.mark.asyncio
async def test_a_raised_error_never_costs_the_caller_the_path_hash_read(self):
meshcore = _meshcore(get_default_flood_scope=AsyncMock(
side_effect=TimeoutError("no answer")))
assert await _scheduler(meshcore)._read_default_flood_scope(meshcore) == {}
@pytest.mark.asyncio
async def test_a_library_without_the_command_reports_nothing(self):
meshcore = _meshcore()
del meshcore.commands.get_default_flood_scope
assert await _scheduler(meshcore)._read_default_flood_scope(meshcore) == {}
@pytest.mark.asyncio
async def test_the_firmware_read_op_carries_the_scope_alongside_the_mode(self):
meshcore = _meshcore(
get_path_hash_mode=AsyncMock(return_value=1),
get_default_flood_scope=AsyncMock(return_value=_event(
EventType.DEFAULT_FLOOD_SCOPE,
{"scope_name": "#west", "scope_key": flood_scope.scope_key_hex("#west")},
)),
)
ok, data = await _scheduler(meshcore)._firmware_read_op()
assert ok is True
assert data["path_hash_mode"] == 1
assert data["default_scope_name"] == "#west"
class TestWriteDefaultScope:
@pytest.mark.asyncio
async def test_setting_a_scope_goes_through_the_library(self):
setter = AsyncMock(return_value=_event(EventType.OK))
meshcore = _meshcore(set_default_flood_scope=setter)
ok, error = await _scheduler(meshcore)._write_default_flood_scope(meshcore, "#west")
assert (ok, error) == (True, "")
setter.assert_awaited_once_with("#west")
@pytest.mark.asyncio
async def test_clearing_sends_the_bare_frame_the_firmware_documents(self):
"""set_default_flood_scope cannot clear: None raises on len(None), ''
earns ILLEGAL_ARG, and '*' only works via a padding off-by-one."""
setter = AsyncMock(return_value=_event(EventType.OK))
send = AsyncMock(return_value=_event(EventType.OK))
meshcore = _meshcore(set_default_flood_scope=setter, send=send)
ok, error = await _scheduler(meshcore)._write_default_flood_scope(meshcore, "")
assert (ok, error) == (True, "")
setter.assert_not_awaited()
frame = send.await_args.args[0]
assert bytes(frame) == bytes([CommandType.SET_DEFAULT_FLOOD_SCOPE.value])
@pytest.mark.asyncio
async def test_a_rejected_write_is_reported_with_the_radio_s_answer(self):
meshcore = _meshcore(set_default_flood_scope=AsyncMock(
return_value=_event(EventType.ERROR)))
ok, error = await _scheduler(meshcore)._write_default_flood_scope(meshcore, "#west")
assert ok is False
assert "set_default_flood_scope(#west) failed" in error
@pytest.mark.asyncio
async def test_a_library_without_the_command_fails_loudly(self):
meshcore = _meshcore()
del meshcore.commands.set_default_flood_scope
ok, error = await _scheduler(meshcore)._write_default_flood_scope(meshcore, "#west")
assert ok is False
assert "meshcore library" in error
@pytest.mark.asyncio
async def test_the_firmware_write_op_carries_both_settings(self):
setter = AsyncMock(return_value=_event(EventType.OK))
meshcore = _meshcore(
set_path_hash_mode=AsyncMock(return_value=_event(EventType.OK)),
set_default_flood_scope=setter,
)
ok, data = await _scheduler(meshcore)._firmware_write_op(
{"path_hash_mode": 1, "default_flood_scope": "#west"})
assert ok is True
assert data["results"] == {"path_hash_mode": True, "default_flood_scope": True}
class TestDeviceScopeNameRules:
def test_a_name_is_normalized_like_every_other_scope(self):
assert flood_scope.validate_device_scope_name("west") == "#west"
def test_a_name_too_long_for_the_radio_s_field_is_refused(self):
"""NodePrefs.default_scope_name is 31 bytes and the firmware demands
room for the terminator, so 30 characters is the ceiling."""
assert flood_scope.validate_device_scope_name("#" + "w" * 29) == "#" + "w" * 29
with pytest.raises(ValueError, match="30"):
flood_scope.validate_device_scope_name("#" + "w" * 30)
def test_a_non_ascii_name_is_refused(self):
"""The library pads the name frame by character count while encoding
UTF-8, so a multi-byte character displaces the transport key."""
with pytest.raises(ValueError, match="ASCII"):
flood_scope.validate_device_scope_name("süd")
def test_global_markers_pass_through_as_the_cleared_state(self):
assert flood_scope.validate_device_scope_name("*") == "*"