meshai/work/tests/test_config_loader.py
malice 24cb6a31df
feat(dashboard): MeshCore transport + per-family routing GUI controls (#10)
* feat(dashboard): MeshCore transport + per-family routing GUI controls

Add Transport mode selector (Meshtastic/MeshCore/Both) and MeshCore
host/port fields to the Config Connection section, and an independent
per-family "MeshCore channel" number input in Notifications (blank = not
broadcast on MeshCore, sends null). Extends the ConnectionConfig and
per-family toggle TS types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(routing): MeshCore routing by channel name, not index

MeshCore channels are {name,PSK} (up to 40+ slots, not Meshtastic's 0-7).
The send index is a fragile slot position, so store the channel NAME per
family and resolve name->slot against the companion's live channel table
at send time; never blind-send to an unresolved slot. GUI field becomes a
channel-name text box. meshtastic path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(routing): thread per-family meshcore_channel through the broadcast send path

MeshBroadcastChannel now carries the rule's meshcore_channel name and
passes it to send_message, so per-family MeshCore routing actually fires
end-to-end (dispatcher -> channel -> composite -> MeshCoreTransport).
Meshtastic path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 16:49:59 -06:00

86 lines
3 KiB
Python

"""Phase 2.16.1: lock in notification-rule coercion in the config loader path.
Regression guard for the bug where the generic nested-dataclass handler in
_dict_to_dataclass shadowed the explicit 'notifications' branch, leaving
cfg.notifications.rules as raw dicts (which crashed Dispatcher._matching_rules
on rule.enabled). config_loader.load_config uses this same _dict_to_dataclass.
"""
from meshai.config import (
Config,
NotificationRuleConfig,
NotificationToggle,
_dataclass_to_dict,
_dict_to_dataclass,
)
def test_multifile_load_coerces_notification_rules():
"""notifications.rules dicts are coerced to NotificationRuleConfig."""
data = {
"notifications": {
"enabled": True,
"rules": [
{
"name": "Test Rule",
"enabled": True,
"trigger_type": "condition",
"categories": ["earthquake_event"],
"min_severity": "routine",
"delivery_type": "mesh_broadcast",
},
{
"name": "Second Rule",
"enabled": False,
"trigger_type": "condition",
"categories": ["wildfire_incident"],
"delivery_type": "email",
},
],
}
}
cfg = _dict_to_dataclass(Config, data)
rules = cfg.notifications.rules
assert len(rules) == 2
# Coerced to the dataclass, NOT left as dicts.
assert all(isinstance(r, NotificationRuleConfig) for r in rules)
# Attribute access (what Dispatcher._matching_rules needs) works.
assert rules[0].enabled is True
assert rules[0].name == "Test Rule"
assert rules[1].enabled is False
def test_rules_attribute_access_does_not_raise():
"""Dispatcher-style attribute access on every rule succeeds."""
data = {
"notifications": {
"rules": [
{"name": "R", "enabled": True, "trigger_type": "condition",
"categories": ["earthquake_event"], "min_severity": "immediate"},
]
}
}
cfg = _dict_to_dataclass(Config, data)
for r in cfg.notifications.rules:
# These are the accesses Dispatcher._matching_rules performs.
_ = r.enabled
_ = r.trigger_type
_ = r.categories
_ = r.min_severity
def test_toggle_meshcore_channel_name_round_trips():
"""A NotificationToggle's meshcore_channel NAME survives dict round-trip.
_dict_to_dataclass drops unknown keys, so this guards that the new
meshcore_channel field is a real dataclass field and persists as a str.
"""
tog = NotificationToggle(name="fire", enabled=True, meshcore_channel="AIDA")
d = _dataclass_to_dict(tog)
assert d["meshcore_channel"] == "AIDA"
restored = _dict_to_dataclass(NotificationToggle, d)
assert restored.meshcore_channel == "AIDA"
# Default stays None when unset.
default = _dict_to_dataclass(NotificationToggle, {"name": "weather"})
assert default.meshcore_channel is None