mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-10-22 22:11:15 +00:00
infra: initial test suite and project tracking files
Test modules: - test_enums: enum values and flag combinations - test_models: MeshMessage dataclass field and type validation - test_transmission_tracker: full TransmissionTracker coverage - test_message_handler: path parsing, RF correlation, message routing - test_repeater_manager: role detection, ACL, device type classification - test_core: config loading, radio settings, reload paths Tracking files: - BUGS.md: known bugs and fix history log - TESTING.md: test strategy, coverage targets, and how-to guide - TODO.md: feature and task backlog with completion status - scripts/update_todos.py: scans source for # TODO/FIXME/HACK markers and regenerates the Inline TODOs section in TODO.md
This commit is contained in:
1 parent
ba32accc44
commit
9de9230c2b
10 files changed
+2080
No files matched your search
@@ -0,0 +1,70 @@
|
||||
# BUGS
|
||||
|
||||
Tracking of known bugs, fixed issues, and outstanding defects in meshcore-bot.
|
||||
|
||||
---
|
||||
|
||||
## Fixed Bugs
|
||||
|
||||
### v0.8.3 and recent history
|
||||
|
||||
| Commit | Summary |
|
||||
|--------|---------|
|
||||
| `1264f49` | Fixed repeater manager auto-purge ignoring `auto_manage_contacts` config — purge ran unconditionally regardless of setting (issue #50) |
|
||||
| `1264f49` | Fixed web viewer responses returning stale or incorrect repeater data |
|
||||
| `5c8ee35` | Fixed timezone handling in `format_elapsed_display` — elapsed times displayed incorrectly in non-UTC timezones (issue #75) |
|
||||
| `1cc41bc` | Fixed repeater usage and web viewer response formatting (PR #67) |
|
||||
| `1474174` | Fixed `TraceCommand` path truncation — return paths were being cut short |
|
||||
| `1a576e8` | Fixed reversed path nodes in `TraceCommand` — trace direction was backwards |
|
||||
| `5a96dec` | Fixed incorrect hop labeling logic in `TraceCommand` |
|
||||
| `2178a80` | Fixed log spam during shutdown — cleanup methods logged errors after streams were closed |
|
||||
| `e9f17ec` | Fixed incomplete shutdown — scheduler thread and meshcore disconnect were not always joined cleanly |
|
||||
| `217d2a4` | Fixed database connection handling across modules — connections were not always properly closed |
|
||||
| `d084c6b` | Fixed `PrefixCommand` not supporting multi-byte hex prefix lookups |
|
||||
| `6c81513` | Fixed `MeshGraph` edge promotion logic — edges were not promoted correctly under some conditions |
|
||||
| `36a8a67` | Fixed prefix handling incompatibility when transitioning from 1-byte to 2-byte prefixes |
|
||||
| `0c060a5` | Fixed chunked message sending race with rate limiter — second chunk could be blocked |
|
||||
| `58deb12` | Fixed `RepeaterManager` ignoring `auto_manage_contacts = false` |
|
||||
|
||||
---
|
||||
|
||||
## Outstanding Known Issues
|
||||
|
||||
### High Priority
|
||||
|
||||
| ID | Module | Description | Workaround |
|
||||
|----|--------|-------------|------------|
|
||||
| BUG-001 | `web_viewer` | Web viewer has **no authentication** — exposes all contact data and packet history on the configured host/port | Set `host = 127.0.0.1` in `[Web_Viewer]` config; use firewall rules or SSH tunnel for remote access |
|
||||
| BUG-002 | `db_manager` | Moving a database from an older install can cause `"no such column"` errors at startup if schema migrations haven't run yet | Start bot once to trigger migrations; if errors persist, clear stale `feed_message_queue` and `channel_operations` rows |
|
||||
| BUG-003 | `repeater_manager` | Geocoding runs at most once per packet hash within a 60-second window, but concurrent adverts from the same node within that window skip geocoding silently | None; by design for rate limiting, but location may lag for rapid-fire adverts |
|
||||
|
||||
### Medium Priority
|
||||
|
||||
| ID | Module | Description | Workaround |
|
||||
|----|--------|-------------|------------|
|
||||
| BUG-004 | `message_handler` | RF data correlation (SNR/RSSI) can miss messages if the RF log event arrives more than `rf_data_timeout` (default 15s) after the message | Increase `rf_data_timeout` in `[Bot]` config |
|
||||
| BUG-005 | `scheduler` | On Raspberry Pi Zero 2 W, bot + web viewer together use ~300 MB RAM, leaving little headroom under load | Disable web viewer (`[Web_Viewer] enabled = false`) or tune mesh graph settings (`graph_startup_load_days = 7`) |
|
||||
| BUG-006 | `feed_manager` | Stale rows in `feed_message_queue` from an old install can cause repeated scheduler errors after a database migration | Clear pending queue: `DELETE FROM feed_message_queue WHERE sent_at IS NULL` |
|
||||
| BUG-007 | `discord_bridge_service` | Discord webhook rate limit is 30 requests/minute; bot warns at 20% exhaustion but does not queue excess messages — they are dropped | Keep bridged channels low-traffic; consider rate-limiting at mesh level |
|
||||
| BUG-008 | `telegram_bridge_service` | Telegram `message_thread_id` (forum/topic support) is not implemented — messages go to the main group channel only | Manual: add thread ID mapping in a future plugin iteration |
|
||||
|
||||
### Low Priority / By Design
|
||||
|
||||
| ID | Module | Description | Notes |
|
||||
|----|--------|-------------|-------|
|
||||
| BUG-009 | `discord_bridge_service` | DMs are never bridged to Discord or Telegram — hardcoded exclusion | By design; DMs contain private communications |
|
||||
| BUG-010 | `wx_command` | Weather alerts and NOAA data are US-only | Use `wx_international.py` alternative in `modules/commands/alternatives/` for non-US deployments |
|
||||
| BUG-011 | `repeater_manager` | MeshCore device hard-limits contacts to 300; auto-purge threshold is 280 — purging 20 contacts at a time may not be enough on very busy meshes | Tune `auto_purge_threshold` and ensure `auto_manage_contacts` is enabled |
|
||||
| BUG-012 | `plugin_loader` | Local plugins with the same name as a built-in plugin are skipped — no override-by-name is possible | Rename your local plugin to a unique name |
|
||||
| BUG-013 | `core.py` | Some older MeshCore firmware versions do not support `get_time` or `set_name` commands — bot logs a warning and continues without those features | Upgrade firmware; no functional impact on message processing |
|
||||
| BUG-014 | `packet_capture_service` | Packet hash calculation silently uses a default hash value on failure (`pass # Use default hash if calculation fails`) | Low impact; affects deduplication accuracy only |
|
||||
|
||||
---
|
||||
|
||||
## Reporting New Bugs
|
||||
|
||||
Open an issue at the project repository. Include:
|
||||
- Bot version (`git describe --tags`)
|
||||
- Relevant section of `config.ini` (redact keys/tokens)
|
||||
- Log output (`logs/meshcore_bot.log`) around the time of the issue
|
||||
- Steps to reproduce
|
||||
+550
@@ -0,0 +1,550 @@
|
||||
# Testing
|
||||
|
||||
Complete reference for the meshcore-bot test suite: how to run tests, what each file covers, and how to extend coverage.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Create and activate a virtual environment (first time only)
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install the package with test dependencies
|
||||
pip install -e ".[test]"
|
||||
|
||||
# Run the full test suite
|
||||
pytest
|
||||
|
||||
# Run without coverage (faster)
|
||||
pytest --no-cov
|
||||
|
||||
# Run a specific file
|
||||
pytest tests/test_enums.py -v
|
||||
|
||||
# Run a specific test class or function
|
||||
pytest tests/test_message_handler.py::TestShouldProcessMessage -v
|
||||
pytest tests/test_enums.py::TestPayloadType::test_lookup_by_value -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Test configuration lives in two files:
|
||||
|
||||
**`pytest.ini`** — controls pytest behaviour:
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| `testpaths` | `tests` |
|
||||
| `asyncio_mode` | `auto` — all async tests run automatically, no `@pytest.mark.asyncio` required on each |
|
||||
| `addopts` | `-v --tb=short --strict-markers --cov=modules --cov-report=term-missing` |
|
||||
| Registered markers | `unit`, `integration`, `slow` |
|
||||
|
||||
**`pyproject.toml`** — coverage settings (`[tool.coverage.*]`):
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| `source` | `modules/` |
|
||||
| `omit` | `tests/`, `.venv/` |
|
||||
| `fail_under` | 70% |
|
||||
|
||||
---
|
||||
|
||||
## Running Subsets of Tests
|
||||
|
||||
```bash
|
||||
# Unit tests only (fast, no real DB)
|
||||
pytest -m unit
|
||||
|
||||
# Integration tests only (real SQLite database via tmp_path)
|
||||
pytest -m integration
|
||||
|
||||
# Skip slow tests
|
||||
pytest -m "not slow"
|
||||
|
||||
# Tests in a subdirectory
|
||||
pytest tests/unit/
|
||||
pytest tests/commands/
|
||||
pytest tests/integration/
|
||||
pytest tests/regression/
|
||||
|
||||
# Run and stop on first failure
|
||||
pytest -x
|
||||
|
||||
# Run with full traceback
|
||||
pytest --tb=long
|
||||
|
||||
# Show which tests were collected without running them
|
||||
pytest --collect-only
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coverage
|
||||
|
||||
```bash
|
||||
# Terminal report (default, configured in pytest.ini)
|
||||
pytest
|
||||
|
||||
# HTML report — open htmlcov/index.html in a browser
|
||||
pytest --cov=modules --cov-report=html
|
||||
|
||||
# Check that coverage meets the 70% threshold
|
||||
pytest --cov=modules --cov-fail-under=70
|
||||
|
||||
# Coverage for a single module
|
||||
pytest tests/test_message_handler.py --cov=modules.message_handler --cov-report=term-missing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Linting (configured alongside tests)
|
||||
|
||||
```bash
|
||||
pip install ruff mypy # one-time, or use pip install -e ".[test]" + pip install ruff mypy
|
||||
|
||||
# Check for style/lint issues
|
||||
ruff check .
|
||||
|
||||
# Auto-fix safe issues
|
||||
ruff check . --fix
|
||||
|
||||
# Type checking
|
||||
mypy modules/ --ignore-missing-imports
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shared Infrastructure
|
||||
|
||||
### `tests/conftest.py` — Fixtures available to all tests
|
||||
|
||||
| Fixture | Scope | Description |
|
||||
|---------|-------|-------------|
|
||||
| `mock_logger` | function | Mock with `.info`, `.debug`, `.warning`, `.error` methods |
|
||||
| `minimal_config` | function | `ConfigParser` with Connection / Bot / Channels / Keywords sections |
|
||||
| `command_mock_bot` | function | Lightweight mock bot (no DB, no mesh graph); includes translator mock that echoes keys |
|
||||
| `command_mock_bot_with_db` | function | Same as above but with a mock `db_manager` |
|
||||
| `test_config` | function | Full `[Path_Command]` + `[Bot]` config (graph settings, Seattle lat/lon) |
|
||||
| `test_db` | function | File-based `DBManager` at `tmp_path` with `mesh_connections` + `complete_contact_tracking` tables |
|
||||
| `mock_bot` | function | Mock bot with logger, config, `test_db`, `bot_root`, `prefix_hex_chars=2`, `key_prefix` lambda |
|
||||
| `mesh_graph` | function | `MeshGraph` instance (immediate write, no background thread) |
|
||||
| `populated_mesh_graph` | function | `MeshGraph` pre-populated with 7 test edges of varying observations and ages |
|
||||
|
||||
### `tests/helpers.py` — Data factories
|
||||
|
||||
| Function | Returns |
|
||||
|----------|---------|
|
||||
| `create_test_repeater(prefix, name, ...)` | Dict matching `complete_contact_tracking` schema |
|
||||
| `create_test_edge(from_prefix, to_prefix, ...)` | Dict matching `MeshGraph` edge structure |
|
||||
| `create_test_path(node_ids, prefix_hex_chars)` | Normalized list of node IDs |
|
||||
| `populate_test_graph(mesh_graph, edges)` | Populates a `MeshGraph` with edge dicts and sets observation counts/timestamps |
|
||||
|
||||
### `mock_message()` (helper in conftest)
|
||||
|
||||
```python
|
||||
from tests.conftest import mock_message
|
||||
|
||||
msg = mock_message(content="ping", channel="general")
|
||||
msg = mock_message(content="hello", is_dm=True, sender_id="Alice")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test File Reference
|
||||
|
||||
### Root-level tests (`tests/`)
|
||||
|
||||
#### `test_rate_limiter.py`
|
||||
Tests `modules.rate_limiter` — `RateLimiter` and `PerUserRateLimiter`.
|
||||
|
||||
| Class | What it covers |
|
||||
|-------|---------------|
|
||||
| `TestRateLimiter` | Allow/block by interval, time-until-next, timestamp recording |
|
||||
| `TestPerUserRateLimiter` | Per-key tracking, LRU eviction at `max_entries`, empty-key bypass |
|
||||
|
||||
---
|
||||
|
||||
#### `test_command_manager.py`
|
||||
Tests `modules.command_manager` — `CommandManager` and `InternetStatusCache`.
|
||||
|
||||
| Class | What it covers |
|
||||
|-------|---------------|
|
||||
| `TestLoadKeywords` | Config parsing, quote stripping, escape decoding |
|
||||
| `TestLoadBannedUsers` | Banned list parsing and whitespace handling |
|
||||
| `TestIsUserBanned` | Exact match, prefix (starts-with) match, `None` sender |
|
||||
| `TestChannelTriggerAllowed` | DM bypass, whitelist allow/block logic |
|
||||
| `TestLoadMonitorChannels` | Channel list parsing and quote handling |
|
||||
| `TestLoadChannelKeywords` | Per-channel keyword loading |
|
||||
| `TestCheckKeywords` | Keyword matching, prefix-gating, channel scope, DM routing |
|
||||
| `TestGetHelpForCommand` | Help text lookup for known/unknown commands |
|
||||
| `TestInternetStatusCache` | Freshness check, stale detection, lock lazy-creation |
|
||||
| `TestSendChannelMessageListeners` | Listener registration, invocation on success, skip on failure |
|
||||
| `TestSendChannelMessagesChunked` | Empty chunks, single/multi-chunk timing, failure propagation |
|
||||
|
||||
---
|
||||
|
||||
#### `test_db_manager.py`
|
||||
Tests `modules.db_manager` — `DBManager`.
|
||||
|
||||
| Class | What it covers |
|
||||
|-------|---------------|
|
||||
| `TestGeocoding` | Cache/retrieve geocoding, overwrite existing, invalid hours logged |
|
||||
| `TestGenericCache` | JSON round-trip, miss returns default, independent keys |
|
||||
| `TestCacheCleanup` | Expired rows deleted, valid rows preserved |
|
||||
| `TestTableManagement` | Allowed/disallowed table names, SQL injection prevention |
|
||||
| `TestExecuteQuery` | Returns list of dicts, update returns row count |
|
||||
| `TestMetadata` | `set_metadata`/`get_metadata`, miss, bot start time |
|
||||
| `TestCacheHoursValidation` | Boundary values 1–87600 valid; 0 and 87601 invalid |
|
||||
|
||||
---
|
||||
|
||||
#### `test_command_prefix.py`
|
||||
Tests prefix-gating across `BaseCommand.matches_keyword`, `HelloCommand`, `PingCommand`, and `CommandManager`. Verifies that commands with a configured prefix require it, with 14 test cases covering `.`, `!`, multi-char, whitespace, case sensitivity, and empty-prefix edge cases.
|
||||
|
||||
---
|
||||
|
||||
#### `test_plugin_loader.py`
|
||||
Tests `modules.plugin_loader` — `PluginLoader`.
|
||||
|
||||
| Class | What it covers |
|
||||
|-------|---------------|
|
||||
| `TestDiscover` | Finds command files, excludes `base_command.py` and `__init__.py` |
|
||||
| `TestValidatePlugin` | Rejects missing `execute`, rejects sync `execute`, accepts valid class |
|
||||
| `TestLoadPlugin` | Loads `ping_command`, returns `None` for nonexistent |
|
||||
| `TestKeywordLookup` | By keyword, by name, miss |
|
||||
| `TestCategoryAndFailed` | Category filter, failed-plugins copy |
|
||||
| `TestLocalPlugins` | Discovery (empty / missing dir / found), load from path, collision skip |
|
||||
|
||||
---
|
||||
|
||||
#### `test_checkin_service.py`
|
||||
Tests `local.service_plugins.checkin_service.CheckInService` (optional; auto-skipped if plugin not installed). Covers channel filtering, phrase matching, `any_message_counts`, and day-of-week filtering.
|
||||
|
||||
---
|
||||
|
||||
#### `test_scheduler_logic.py`
|
||||
Tests `modules.scheduler` — `MessageScheduler` pure logic (no threading/asyncio).
|
||||
|
||||
| Class | What it covers |
|
||||
|-------|---------------|
|
||||
| `TestIsValidTimeFormat` | Valid HHMM times, invalid hours/minutes/length/non-numeric |
|
||||
| `TestGetCurrentTime` | Valid timezone, invalid timezone fallback, empty timezone |
|
||||
| `TestHasMeshInfoPlaceholders` | Detects `{total_contacts}`, `{repeaters}`, returns false for plain text |
|
||||
|
||||
---
|
||||
|
||||
#### `test_channel_manager_logic.py`
|
||||
Tests `modules.channel_manager` — `ChannelManager` pure logic.
|
||||
|
||||
| Class | What it covers |
|
||||
|-------|---------------|
|
||||
| `TestGenerateHashtagKey` | Deterministic 16-byte key, `#` prepended if missing, known SHA-256 value |
|
||||
| `TestChannelNameLookup` | Cache hit, fallback to `"channel N"` on miss |
|
||||
| `TestChannelNumberLookup` | Found by name (case-insensitive), miss |
|
||||
| `TestCacheManagement` | `invalidate_cache()` sets `_cache_valid = False` |
|
||||
|
||||
---
|
||||
|
||||
#### `test_feed_manager_formatting.py`
|
||||
Tests `modules.feed_manager` — `FeedManager` pure formatting (networking disabled).
|
||||
|
||||
| Class | What it covers |
|
||||
|-------|---------------|
|
||||
| `TestApplyShortening` | `truncate:N`, `word_wrap:N`, `first_words:N`, `regex:`, `if_regex:`, empty input |
|
||||
| `TestGetNestedValue` | Simple field, dotted path, missing field default |
|
||||
| `TestShouldSendItem` | No filter, `equals`, `in`, `and` logic |
|
||||
| `TestFormatTimestamp` | Recent timestamp string, `None` returns empty |
|
||||
|
||||
---
|
||||
|
||||
#### `test_profanity_filter.py`
|
||||
Tests `modules.profanity_filter` — `censor()` and `contains_profanity()`.
|
||||
|
||||
| Class | What it covers |
|
||||
|-------|---------------|
|
||||
| `TestProfanityFilterEdgeCases` | `None`, empty, whitespace, non-string input; hate symbol detection |
|
||||
| `TestProfanityFilterWithLibrary` | (skipped if `better_profanity` absent) — full censoring, `unidecode` homoglyph detection |
|
||||
| `TestProfanityFilterFallbackWhenLibraryUnavailable` | Graceful degradation, hate symbols still filtered, one-time warning log |
|
||||
|
||||
---
|
||||
|
||||
#### `test_config_validation.py`
|
||||
Tests `modules.config_validation` — `validate_config` and helpers.
|
||||
|
||||
| Class | What it covers |
|
||||
|-------|---------------|
|
||||
| `TestStripOptionalQuotes` | Single/double quote stripping, mismatch handling |
|
||||
| `TestValidateConfig` | Missing sections, minimal valid, optional absent, typo detection |
|
||||
| `TestPathValidation` | Non-existent parent warns, relative paths resolved, non-writable directory warns |
|
||||
| `TestResolvePath` | Absolute/relative path resolution |
|
||||
| `TestCheckPathWritable` | Empty path, non-existent parent, writable dir |
|
||||
| `TestSuggestSimilarCommand` | Fuzzy match hit/miss |
|
||||
| `TestGetCommandPrefixToSection` | Returns expected dict |
|
||||
|
||||
---
|
||||
|
||||
#### `test_utils.py`
|
||||
Tests `modules.utils` — utility functions.
|
||||
|
||||
| Class | What it covers |
|
||||
|-------|---------------|
|
||||
| `TestAbbreviateLocation` | US/CA abbreviations, truncation with ellipsis |
|
||||
| `TestTruncateString` | Under/over max, custom ellipsis |
|
||||
| `TestDecodeEscapeSequences` | `\n`, `\t`, `\r`, literal backslash-n, mixed |
|
||||
| `TestParseLocationString` | No comma, zip-only, city/state, city/country |
|
||||
| `TestCalculateDistance` | Same point = 0, known distances (Seattle–Portland) |
|
||||
| `TestFormatElapsedDisplay` | `None`/`unknown`/invalid input, recent timestamp, future timestamp, translator integration |
|
||||
| `TestDecodePathLenByte` | 1/2/3 bytes-per-hop encoding, size code, path length fallback |
|
||||
| `TestParsePathString` | Comma/space/continuous hex, hop-count suffix, 4-char nodes, legacy fallback |
|
||||
| `TestCalculatePacketHashPathLength` | Single/multi-byte path hashes, different sizes produce different hashes |
|
||||
| `TestMultiBytePathDisplayContract` | Format contract for 1-byte and 2-byte node paths |
|
||||
|
||||
---
|
||||
|
||||
#### `test_bridge_bot_responses.py`
|
||||
Tests `modules.service_plugins.discord_bridge_service` and `telegram_bridge_service` — `channel_sent_listeners` lifecycle.
|
||||
|
||||
Both `TestDiscordBridgeBotResponses` and `TestTelegramBridgeBotResponses` verify:
|
||||
- `start()` registers a listener when `bridge_bot_responses = true`
|
||||
- `stop()` unregisters the listener
|
||||
- `start()` does NOT register a listener when `bridge_bot_responses = false`
|
||||
|
||||
---
|
||||
|
||||
#### `test_config_merge.py`
|
||||
Tests `modules.core.MeshCoreBot` — local config merging. Verifies that `local/config.ini` is merged on `load_config()` and `reload_config()`, and that absent local configs are handled gracefully.
|
||||
|
||||
---
|
||||
|
||||
#### `test_randomline.py`
|
||||
Tests `modules.command_manager.CommandManager.match_randomline` — `[RandomLine]` trigger matching. Covers case/whitespace normalisation, extra-word rejection, channel filtering, and channel-override allowing non-monitored channels.
|
||||
|
||||
---
|
||||
|
||||
#### `test_security_utils.py`
|
||||
Tests `modules.security_utils`.
|
||||
|
||||
| Class | What it covers |
|
||||
|-------|---------------|
|
||||
| `TestValidatePubkeyFormat` | Valid 64-char hex, wrong length, invalid chars, non-string |
|
||||
| `TestValidateSafePath` | Relative resolution, path traversal rejection, absolute path policy |
|
||||
| `TestValidateExternalUrl` | `file://` rejected, `http(s)` allowed, localhost policy, missing netloc |
|
||||
| `TestSanitizeInput` | Max-length truncation, control character stripping, newline/tab preserved |
|
||||
| `TestValidateApiKeyFormat` | Valid key, too-short key, placeholder strings rejected |
|
||||
| `TestValidatePortNumber` | Valid port, privileged port policy, out-of-range |
|
||||
|
||||
---
|
||||
|
||||
#### `test_service_plugin_loader.py`
|
||||
Tests `modules.service_plugin_loader` — `ServicePluginLoader`. Covers local service discovery (empty/missing dir, finds `.py` files), loading (enabled/disabled/invalid/missing-enabled-key), and name-collision skipping.
|
||||
|
||||
---
|
||||
|
||||
#### `test_enums.py`
|
||||
Tests `modules.enums` — all enum and flag types.
|
||||
|
||||
| Class | What it covers |
|
||||
|-------|---------------|
|
||||
| `TestAdvertFlags` | Type flag values, feature flag values, legacy aliases, `\|` combination, membership test |
|
||||
| `TestPayloadType` | All 16 values, lookup by value, uniqueness |
|
||||
| `TestPayloadVersion` | Four version values, lookup |
|
||||
| `TestRouteType` | Four route type values, lookup |
|
||||
| `TestDeviceRole` | String values, lookup, member count |
|
||||
|
||||
---
|
||||
|
||||
#### `test_models.py`
|
||||
Tests `modules.models` — `MeshMessage` dataclass.
|
||||
|
||||
| Class | What it covers |
|
||||
|-------|---------------|
|
||||
| `TestMeshMessageDefaults` | Required `content`, all optional fields default to `None`, `is_dm=False` |
|
||||
| `TestMeshMessageConstruction` | Channel msg, DM, routing_info dict, numeric fields, path, elapsed |
|
||||
| `TestMeshMessageEquality` | Equal messages, different content/channel |
|
||||
|
||||
---
|
||||
|
||||
#### `test_transmission_tracker.py`
|
||||
Tests `modules.transmission_tracker` — `TransmissionRecord` and `TransmissionTracker`.
|
||||
|
||||
| Class | What it covers |
|
||||
|-------|---------------|
|
||||
| `TestTransmissionRecord` | Default fields, custom fields |
|
||||
| `TestRecordTransmission` | Returns record, stores in pending dict, multiple per second, command_id |
|
||||
| `TestMatchPacketHash` | Null/zero hash → None, matches pending, confirmed cached, outside window → None |
|
||||
| `TestRecordRepeat` | Null hash, increment count, no-prefix `_unknown` key, multiple repeats same repeater, unmatched → False |
|
||||
| `TestGetRepeatInfo` | Unknown hash, by packet_hash, by command_id |
|
||||
| `TestExtractRepeaterPrefixes` | Path string last hop, path_nodes last node, own-prefix filter, empty path, `via` annotation, single node |
|
||||
| `TestCleanupOldRecords` | Removes old pending, keeps recent, removes confirmed without repeats, keeps confirmed with repeats |
|
||||
|
||||
---
|
||||
|
||||
#### `test_message_handler.py`
|
||||
Tests `modules.message_handler` — `MessageHandler` pure logic.
|
||||
|
||||
| Class | What it covers |
|
||||
|-------|---------------|
|
||||
| `TestIsOldCachedMessage` | No connection time, `None`/`"unknown"`/0/negative/far-future timestamps, old vs. recent, invalid string |
|
||||
| `TestPathBytesToNodes` | 1/2-byte-per-hop, remainder fallback, empty bytes, zero prefix_hex_chars |
|
||||
| `TestPathHexToNodes` | 2/4-char chunks, empty/short string, remainder fallback |
|
||||
| `TestFormatPathString` | Empty → `"Direct"`, legacy 1-byte, bytes_per_hop 1/2, remainder fallback, `None` input, invalid hex |
|
||||
| `TestGetRouteTypeName` | All 4 known types, unknown type |
|
||||
| `TestGetPayloadTypeName` | Known types, unknown type |
|
||||
| `TestShouldProcessMessage` | Bot disabled, banned user, monitored channel, unmonitored, DM on/off, command-override channel |
|
||||
| `TestCleanupStaleCacheEntries` | Removes old timestamp/pubkey/recent_rf entries, skips full cleanup within interval |
|
||||
|
||||
---
|
||||
|
||||
#### `test_repeater_manager.py`
|
||||
Tests `modules.repeater_manager` — `RepeaterManager` pure logic. Uses a real test DB for construction.
|
||||
|
||||
| Class | What it covers |
|
||||
|-------|---------------|
|
||||
| `TestDetermineContactRole` | `mode` field priority, device type fallback, name-based detection (rpt, roomserver, sensor, bot, gateway), default companion |
|
||||
| `TestDetermineDeviceType` | `advert_data.mode` priority, numeric type codes, name-based detection |
|
||||
| `TestIsRepeaterDevice` | Type 2/3, role fields, name patterns (repeater, gateway), companion → False |
|
||||
| `TestIsCompanionDevice` | Type 1 → True, type 2 → False, empty data → True |
|
||||
| `TestIsInAcl` | No section, key present, key absent, empty list, exact-match-only, auto_purge_enabled config |
|
||||
|
||||
---
|
||||
|
||||
#### `test_core.py`
|
||||
Tests `modules.core.MeshCoreBot` — config, radio settings, reload, and key helpers. Instantiates a real `MeshCoreBot` from temp config files.
|
||||
|
||||
| Class | What it covers |
|
||||
|-------|---------------|
|
||||
| `TestBotRoot` | `bot_root` property returns config file directory |
|
||||
| `TestGetRadioSettings` | Returns dict with all keys, reads connection_type/serial_port/timeout, defaults for missing fields |
|
||||
| `TestReloadConfig` | Success on same settings, failure on changed serial_port, missing file → False |
|
||||
| `TestKeyPrefixHelpers` | `key_prefix()` truncates to `prefix_hex_chars`, `is_valid_prefix()` length check, config-driven `prefix_bytes` |
|
||||
|
||||
---
|
||||
|
||||
### Command tests (`tests/commands/`)
|
||||
|
||||
| File | Command tested | Key scenarios |
|
||||
|------|---------------|---------------|
|
||||
| `test_base_command.py` | `BaseCommand` (via concrete commands) | `derive_config_section_name()`, `is_channel_allowed()`, `get_config_value()` legacy migration for 7 command types |
|
||||
| `test_help_command.py` | `HelpCommand` | Enabled/disabled, async execute |
|
||||
| `test_cmd_command.py` | `CmdCommand` | Command list building, truncation with `(N more)` suffix |
|
||||
| `test_ping_command.py` | `PingCommand` | Keyword response, enabled/disabled |
|
||||
| `test_dice_command.py` | `DiceCommand` | `d20`, `2d6`, decade (`d10`), mixed notation, value ranges, formatting, default d6, invalid input |
|
||||
| `test_hello_command.py` | `HelloCommand` | Emoji-only detection, time-seeded greeting, emoji response, async execute |
|
||||
| `test_magic8_command.py` | `Magic8Command` | Valid 🎱 response, sender mention in channel |
|
||||
| `test_roll_command.py` | `RollCommand` | Parse notation, keyword matching, default 1–100, specific max |
|
||||
|
||||
---
|
||||
|
||||
### Unit tests (`tests/unit/`)
|
||||
|
||||
#### `test_mesh_graph.py` and `test_mesh_graph_*.py`
|
||||
These six files provide comprehensive unit coverage of `modules.mesh_graph`:
|
||||
|
||||
| File | Focus |
|
||||
|------|-------|
|
||||
| `test_mesh_graph.py` | Edge management, multi-resolution prefix, path validation, candidate scoring, multi-hop, persistence |
|
||||
| `test_mesh_graph_scoring.py` | `get_candidate_score()` — prev/next edge, bidirectional bonus, hop-position match and tolerance, disable flags |
|
||||
| `test_mesh_graph_edges.py` | Add/update/get/has, public key merging, 1→2→3 byte edge promotion |
|
||||
| `test_mesh_graph_multihop.py` | `find_intermediate_nodes()` — 2/3-hop paths, no path, min observations, bidirectional bonus, multiple candidates, self-loop prevention |
|
||||
| `test_mesh_graph_validation.py` | `validate_path_segment()` and `validate_path()` — confidence, recency, bidirectional, empty/single paths |
|
||||
| `test_mesh_graph_optimizations.py` | Adjacency indexes, public-key interning, edge expiration/pruning, notification throttle, capture_enabled flag |
|
||||
|
||||
#### `test_path_command_graph.py` and `test_path_command_graph_selection.py`
|
||||
Both test `PathCommand._select_repeater_by_graph()`: no-graph fallback, direct edge selection, stored-key bonus, star bias, multi-hop, hop-position weighting, confidence conversion, missing key handling.
|
||||
|
||||
#### `test_path_command_multibyte.py`
|
||||
Tests `PathCommand._decode_path()` and `_extract_path_from_recent_messages()` for multi-byte prefix support: 2-byte comma-separated, 1-byte, continuous hex, hop-count suffix stripping, routing_info.path_nodes priority.
|
||||
|
||||
---
|
||||
|
||||
### Integration tests (`tests/integration/`)
|
||||
|
||||
Both files test `PathCommand` + `MeshGraph` end-to-end with a real SQLite database (`test_db` fixture). Each scenario uses `mock_bot`, `mesh_graph`, and helper factories.
|
||||
|
||||
| File | Scenarios |
|
||||
|------|-----------|
|
||||
| `test_path_graph_integration.py` | Graph data resolution, prefix collision + disambiguation, starred preference, stored-key priority, 2-hop inference, edge persistence across restart, 5-node real-world scenario |
|
||||
| `test_path_resolution.py` | Same scenarios plus sync graph validation, geographic vs. graph selection, direct SQLite inserts for setup |
|
||||
|
||||
All test methods use `@pytest.mark.integration` and `@pytest.mark.asyncio` where async.
|
||||
|
||||
---
|
||||
|
||||
### Regression tests (`tests/regression/`)
|
||||
|
||||
#### `test_keyword_escapes.py`
|
||||
Regression guard for `modules.utils.decode_escape_sequences` — verifies that `\n` in config values produces a real newline, `\\n` produces a literal backslash-n, and `\t` produces a real tab. Prevents regressions in escape handling after any utils refactor.
|
||||
|
||||
---
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
### Conventions
|
||||
|
||||
- **Class-based:** Use `class TestFeatureName:` grouping.
|
||||
- **Async:** `asyncio_mode = auto` is set — just write `async def test_...` without the mark.
|
||||
- **Fixtures:** Prefer conftest fixtures (`mock_logger`, `mock_bot`, `test_db`, `minimal_config`). Define local fixtures at the top of the file for module-specific setup.
|
||||
- **Factories:** Use `create_test_repeater()`, `create_test_edge()`, `mock_message()` for consistent test data.
|
||||
- **Database:** Use `tmp_path` (file-based SQLite, not `:memory:`) to avoid cross-connection isolation issues.
|
||||
- **Mocking:** `MagicMock` for sync, `AsyncMock` for async methods.
|
||||
- **Marks:** Tag with `@pytest.mark.unit` or `@pytest.mark.integration` for filtering.
|
||||
|
||||
### Example skeleton
|
||||
|
||||
```python
|
||||
"""Tests for modules/my_module.py — MyClass."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock
|
||||
from modules.my_module import MyClass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def my_obj(mock_logger):
|
||||
bot = Mock()
|
||||
bot.logger = mock_logger
|
||||
return MyClass(bot)
|
||||
|
||||
|
||||
class TestMyFeature:
|
||||
|
||||
def test_pure_logic(self, my_obj):
|
||||
result = my_obj.some_method("input")
|
||||
assert result == "expected"
|
||||
|
||||
async def test_async_method(self, my_obj):
|
||||
my_obj.bot.send = AsyncMock(return_value=True)
|
||||
result = await my_obj.async_method("msg")
|
||||
assert result is True
|
||||
```
|
||||
|
||||
### Adding coverage for a new module
|
||||
|
||||
1. Create `tests/test_<module_name>.py`.
|
||||
2. Add a local fixture that constructs the class under test with mocked dependencies.
|
||||
3. Start with pure-logic methods (no network, no DB) — these are fastest to write and run.
|
||||
4. Add integration tests (with `test_db`) for database-touching methods.
|
||||
5. Run `pytest tests/test_<module_name>.py --cov=modules.<module_name> --cov-report=term-missing` to see gaps.
|
||||
|
||||
---
|
||||
|
||||
## CI Integration
|
||||
|
||||
Tests run automatically on push/PR to `main`, `master`, and `dev` via `.github/workflows/test.yml`:
|
||||
|
||||
1. **Install deps** — `pip install -e ".[test]"` + `pip install ruff mypy`
|
||||
2. **Lint** — `ruff check .`
|
||||
3. **Type check** — `mypy modules/ --ignore-missing-imports`
|
||||
4. **Tests** — `pytest tests/ -v --tb=short`
|
||||
|
||||
To keep `TODO.md` in sync locally, run:
|
||||
|
||||
```bash
|
||||
python scripts/update_todos.py
|
||||
```
|
||||
|
||||
Or wire it up as a pre-commit hook (see `TODO.md` → Auto-Update section).
|
||||
@@ -0,0 +1,123 @@
|
||||
# TODO
|
||||
|
||||
Task list for meshcore-bot development. Auto-updated sections are regenerated
|
||||
by running `python scripts/update_todos.py` (see [Auto-Update](#auto-update)).
|
||||
|
||||
**Last updated:** 2026-03-14
|
||||
|
||||
---
|
||||
|
||||
## In Progress
|
||||
|
||||
- [ ] Expand test coverage to ≥70% across all modules (Phase 5 of dev setup plan)
|
||||
- [x] `tests/test_enums.py` — enum values and flag combinations
|
||||
- [x] `tests/test_models.py` — MeshMessage dataclass
|
||||
- [x] `tests/test_transmission_tracker.py` — full TransmissionTracker
|
||||
- [x] `tests/test_message_handler.py` — path parsing, cache, message routing
|
||||
- [x] `tests/test_repeater_manager.py` — role detection, ACL, device type
|
||||
- [x] `tests/test_core.py` — config loading, radio settings, reload
|
||||
- [ ] `tests/test_feed_manager.py` — feed polling, deduplication, queue logic
|
||||
- [ ] `tests/test_scheduler.py` — scheduled message dispatch, interval advertising
|
||||
- [ ] `tests/test_command_manager.py` — full command dispatch, keyword matching
|
||||
- [ ] `tests/test_channel_manager.py` — channel fetch, cache lifecycle
|
||||
|
||||
---
|
||||
|
||||
## Planned Features
|
||||
|
||||
### Bridges
|
||||
|
||||
- [ ] **Two-way Discord bridge** — receive messages from Discord and relay to MeshCore
|
||||
- [ ] **Two-way Telegram bridge** — relay Telegram messages back into MeshCore channels
|
||||
- [ ] **Telegram `message_thread_id` support** — route bridged messages to forum topics
|
||||
- [ ] **Bridge DM support** — optional, opt-in bridging of DMs (requires consent mechanism)
|
||||
|
||||
### Web Viewer
|
||||
|
||||
- [ ] **Authentication** — add token or basic-auth to protect the web dashboard (see BUG-001)
|
||||
- [ ] **Live packet streaming** — SSE/WebSocket stream of incoming packets on dashboard
|
||||
- [ ] **Real-time message monitoring** — live channel message feed in browser
|
||||
- [ ] **Interactive contact management** — star/purge contacts from the UI
|
||||
- [ ] **Export functionality** — CSV/JSON export of contact and path data
|
||||
- [ ] **Mobile-responsive improvements** — optimize layout for small screens
|
||||
|
||||
### Commands and Features
|
||||
|
||||
- [ ] **Inbound webhook command** — trigger bot responses from external HTTP POST
|
||||
- [ ] **Per-channel rate limiting** — different rate limits for different channels
|
||||
- [ ] **Command aliases** — allow users to define shorthand aliases for commands
|
||||
- [ ] **Scheduled message preview** — `!schedule list` command to show upcoming scheduled messages
|
||||
- [ ] **`!wx` non-US improvement** — promote `wx_international.py` to default with US fallback
|
||||
- [ ] **`!path` geographic scoring toggle** — runtime config flag without restart
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- [ ] **Virtual environment setup automation** — `make install` / `make dev` targets
|
||||
- [ ] **`ruff check` CI gate** — block merges when linting fails (test.yml updated; needs clean pass)
|
||||
- [ ] **`mypy` strict mode** — incrementally add type annotations and raise strictness
|
||||
- [ ] **Coverage threshold enforcement** — currently set to 70%; raise to 80% once baseline is met
|
||||
- [ ] **Database migration versioning** — replace ad-hoc `ALTER TABLE IF NOT EXISTS` with numbered migrations
|
||||
- [ ] **Docker multi-arch build** — publish `linux/arm64` image for Raspberry Pi natively
|
||||
|
||||
---
|
||||
|
||||
## Backlog
|
||||
|
||||
- [ ] Investigate replacing `schedule` library with `APScheduler` for more flexible recurring jobs
|
||||
- [ ] Evaluate moving web viewer to a separate installable package
|
||||
- [ ] Add structured logging (JSON) mode for log aggregation pipelines
|
||||
- [ ] Rate-limiter observability — expose current limiter state via web viewer API
|
||||
- [ ] Consider async-safe SQLite wrapper (e.g. `aiosqlite`) for `db_manager`
|
||||
- [ ] Repeater auto-purge dry-run mode — log what would be purged without acting
|
||||
- [ ] Feed manager: add support for JSON API feeds (not just RSS/Atom)
|
||||
- [ ] Map uploader: configurable upload interval (currently hardcoded)
|
||||
- [ ] Support custom greeting messages per-channel (greeter command)
|
||||
|
||||
---
|
||||
|
||||
## Recently Completed
|
||||
|
||||
- [x] Added `ruff` and `mypy` linting configuration to `pyproject.toml`
|
||||
- [x] Added lint steps to `.github/workflows/test.yml`
|
||||
- [x] Fixed `meshcore>=2.2.14` → `meshcore>=2.2.31` version pin in `pyproject.toml`
|
||||
- [x] Added `profanity`, `geo`, `pytest-mock`, `pytest-cov` optional dependency groups
|
||||
- [x] Added coverage reporting to `pytest.ini`
|
||||
- [x] Created 6 new test modules (enums, models, transmission_tracker, message_handler, repeater_manager, core)
|
||||
- [x] Fixed `RepeaterManager` ignoring `auto_manage_contacts = false` (commit `58deb12`)
|
||||
- [x] Fixed timezone handling in `format_elapsed_display` — issue #75 (commit `5c8ee35`)
|
||||
- [x] Fixed `TraceCommand` reversed path nodes and truncated return paths
|
||||
- [x] Fixed shutdown log spam after streams closed (commit `2178a80`)
|
||||
- [x] Added Discord and Telegram one-way bridges
|
||||
- [x] Added chunked message sending for rate-limit-aware large responses
|
||||
- [x] Multi-byte prefix (2-byte) support throughout codebase
|
||||
|
||||
---
|
||||
|
||||
## Auto-Update
|
||||
|
||||
The **Inline TODOs** section below is auto-generated by scanning source files for
|
||||
`# TODO`, `# FIXME`, and `# HACK` markers. Regenerate it with:
|
||||
|
||||
```bash
|
||||
python scripts/update_todos.py
|
||||
```
|
||||
|
||||
Or run it as part of a pre-commit hook by adding to `.pre-commit-config.yaml`:
|
||||
|
||||
```yaml
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: update-todos
|
||||
name: Update TODO.md inline scan
|
||||
language: python
|
||||
entry: python scripts/update_todos.py
|
||||
pass_filenames: false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Inline TODOs (auto-generated)
|
||||
|
||||
> _Last scanned: 2026-03-14. No `# TODO`, `# FIXME`, or `# HACK` markers
|
||||
> found in `modules/` or `tests/`. Run `python scripts/update_todos.py` to refresh._
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
update_todos.py — Scans source files for TODO/FIXME/HACK markers and rewrites
|
||||
the "Inline TODOs" section of TODO.md.
|
||||
|
||||
Usage:
|
||||
python scripts/update_todos.py # from project root
|
||||
python scripts/update_todos.py --check # exit 1 if TODO.md would change (CI use)
|
||||
|
||||
The script only touches the content between the two sentinel comments:
|
||||
<!-- inline-todos-start -->
|
||||
<!-- inline-todos-end -->
|
||||
Everything else in TODO.md is left exactly as-is.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
TODO_FILE = PROJECT_ROOT / "TODO.md"
|
||||
SCAN_DIRS = ["modules", "tests"]
|
||||
SCAN_EXTENSIONS = {".py"}
|
||||
MARKERS = re.compile(r"#\s*(TODO|FIXME|HACK)\b[:\s]*(.*)", re.IGNORECASE)
|
||||
|
||||
SECTION_START = "## Inline TODOs (auto-generated)"
|
||||
SENTINEL_LINE = "> _Last scanned:"
|
||||
|
||||
|
||||
def scan_todos():
|
||||
"""Walk source directories and collect all TODO/FIXME/HACK comments."""
|
||||
results = []
|
||||
for scan_dir in SCAN_DIRS:
|
||||
root = PROJECT_ROOT / scan_dir
|
||||
if not root.exists():
|
||||
continue
|
||||
for dirpath, _dirs, files in os.walk(root):
|
||||
for fname in sorted(files):
|
||||
if Path(fname).suffix not in SCAN_EXTENSIONS:
|
||||
continue
|
||||
fpath = Path(dirpath) / fname
|
||||
rel = fpath.relative_to(PROJECT_ROOT)
|
||||
try:
|
||||
with open(fpath, encoding="utf-8", errors="replace") as fh:
|
||||
for lineno, line in enumerate(fh, 1):
|
||||
m = MARKERS.search(line)
|
||||
if m:
|
||||
marker = m.group(1).upper()
|
||||
text = m.group(2).strip().rstrip(".")
|
||||
results.append((str(rel), lineno, marker, text))
|
||||
except OSError:
|
||||
pass
|
||||
return results
|
||||
|
||||
|
||||
def build_section(todos, today: str) -> str:
|
||||
"""Render the Inline TODOs section as a markdown string."""
|
||||
lines = [f"{SECTION_START}\n"]
|
||||
if not todos:
|
||||
lines.append(
|
||||
f"> _Last scanned: {today}. No `# TODO`, `# FIXME`, or `# HACK` markers"
|
||||
)
|
||||
lines.append(
|
||||
"> found in `modules/` or `tests/`. Run `python scripts/update_todos.py` to refresh._\n"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
lines.append(
|
||||
f"> _Last scanned: {today}. {len(todos)} item(s) found._\n"
|
||||
)
|
||||
|
||||
# Group by marker type
|
||||
by_marker: dict[str, list] = {}
|
||||
for rel, lineno, marker, text in todos:
|
||||
by_marker.setdefault(marker, []).append((rel, lineno, text))
|
||||
|
||||
emoji = {"TODO": "📋", "FIXME": "🔧", "HACK": "⚠️"}
|
||||
for marker in ("FIXME", "TODO", "HACK"):
|
||||
if marker not in by_marker:
|
||||
continue
|
||||
lines.append(f"### {emoji.get(marker, '')} {marker}\n")
|
||||
for rel, lineno, text in sorted(by_marker[marker]):
|
||||
label = text if text else f"(no description — see file)"
|
||||
lines.append(f"- [ ] **`{rel}:{lineno}`** — {label}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def rewrite_todo_md(new_section: str, check_only: bool = False) -> bool:
|
||||
"""
|
||||
Replace the Inline TODOs section in TODO.md.
|
||||
|
||||
Returns True if the file was (or would be) changed.
|
||||
"""
|
||||
if not TODO_FILE.exists():
|
||||
print(f"ERROR: {TODO_FILE} not found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
content = TODO_FILE.read_text(encoding="utf-8")
|
||||
|
||||
# Find the section heading and replace everything from it to EOF
|
||||
idx = content.find(f"\n{SECTION_START}")
|
||||
if idx == -1:
|
||||
# Section missing — append it
|
||||
new_content = content.rstrip() + "\n\n---\n\n" + new_section + "\n"
|
||||
else:
|
||||
new_content = content[: idx + 1] + new_section + "\n"
|
||||
|
||||
changed = new_content != content
|
||||
|
||||
if check_only:
|
||||
if changed:
|
||||
print("TODO.md is out of date. Run `python scripts/update_todos.py` to refresh.")
|
||||
return changed
|
||||
|
||||
if changed:
|
||||
TODO_FILE.write_text(new_content, encoding="utf-8")
|
||||
print(f"TODO.md updated ({len(new_section.splitlines())} lines in Inline TODOs section).")
|
||||
else:
|
||||
print("TODO.md is already up to date.")
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Update Inline TODOs section in TODO.md")
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Exit with code 1 if TODO.md would change (for CI gates)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
today = datetime.date.today().isoformat()
|
||||
todos = scan_todos()
|
||||
section = build_section(todos, today)
|
||||
changed = rewrite_todo_md(section, check_only=args.check)
|
||||
|
||||
if args.check and changed:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Tests for MeshCoreBot logic (config loading, radio settings, helpers)."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from modules.core import MeshCoreBot
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _write_config(path: Path, db_path: Path, extra: str = "") -> None:
|
||||
path.write_text(
|
||||
f"""[Connection]
|
||||
connection_type = serial
|
||||
serial_port = /dev/ttyUSB0
|
||||
timeout = 30
|
||||
|
||||
[Bot]
|
||||
db_path = {db_path.as_posix()}
|
||||
prefix_bytes = 1
|
||||
|
||||
[Channels]
|
||||
monitor_channels = #general
|
||||
{extra}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bot(tmp_path):
|
||||
"""Minimal MeshCoreBot from a temporary config file."""
|
||||
config_file = tmp_path / "config.ini"
|
||||
db_path = tmp_path / "bot.db"
|
||||
_write_config(config_file, db_path)
|
||||
return MeshCoreBot(config_file=str(config_file))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bot_root property
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBotRoot:
|
||||
def test_returns_config_directory(self, tmp_path):
|
||||
config_file = tmp_path / "config.ini"
|
||||
db_path = tmp_path / "bot.db"
|
||||
_write_config(config_file, db_path)
|
||||
bot = MeshCoreBot(config_file=str(config_file))
|
||||
assert bot.bot_root == tmp_path.resolve()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_radio_settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetRadioSettings:
|
||||
def test_returns_dict_with_expected_keys(self, bot):
|
||||
settings = bot._get_radio_settings()
|
||||
assert "connection_type" in settings
|
||||
assert "serial_port" in settings
|
||||
assert "ble_device_name" in settings
|
||||
assert "hostname" in settings
|
||||
assert "tcp_port" in settings
|
||||
assert "timeout" in settings
|
||||
|
||||
def test_reads_connection_type(self, bot):
|
||||
assert bot._get_radio_settings()["connection_type"] == "serial"
|
||||
|
||||
def test_reads_serial_port(self, bot):
|
||||
assert bot._get_radio_settings()["serial_port"] == "/dev/ttyUSB0"
|
||||
|
||||
def test_reads_timeout(self, bot):
|
||||
assert bot._get_radio_settings()["timeout"] == 30
|
||||
|
||||
def test_defaults_when_missing(self, tmp_path):
|
||||
config_file = tmp_path / "config.ini"
|
||||
db_path = tmp_path / "bot.db"
|
||||
# Write config without optional fields
|
||||
config_file.write_text(
|
||||
f"""[Connection]
|
||||
connection_type = ble
|
||||
|
||||
[Bot]
|
||||
db_path = {db_path.as_posix()}
|
||||
|
||||
[Channels]
|
||||
monitor_channels = #general
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
b = MeshCoreBot(config_file=str(config_file))
|
||||
settings = b._get_radio_settings()
|
||||
assert settings["ble_device_name"] == ""
|
||||
assert settings["hostname"] == ""
|
||||
assert settings["tcp_port"] == 5000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reload_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReloadConfig:
|
||||
def test_reload_succeeds_with_same_radio_settings(self, tmp_path):
|
||||
config_file = tmp_path / "config.ini"
|
||||
db_path = tmp_path / "bot.db"
|
||||
_write_config(config_file, db_path)
|
||||
b = MeshCoreBot(config_file=str(config_file))
|
||||
success, msg = b.reload_config()
|
||||
assert success is True
|
||||
|
||||
def test_reload_fails_when_radio_settings_changed(self, tmp_path):
|
||||
config_file = tmp_path / "config.ini"
|
||||
db_path = tmp_path / "bot.db"
|
||||
_write_config(config_file, db_path)
|
||||
b = MeshCoreBot(config_file=str(config_file))
|
||||
# Change serial port in config file
|
||||
_write_config(config_file, db_path, extra="")
|
||||
config_file.write_text(
|
||||
f"""[Connection]
|
||||
connection_type = serial
|
||||
serial_port = /dev/ttyUSB1
|
||||
timeout = 30
|
||||
|
||||
[Bot]
|
||||
db_path = {db_path.as_posix()}
|
||||
|
||||
[Channels]
|
||||
monitor_channels = #general
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
success, msg = b.reload_config()
|
||||
assert success is False
|
||||
assert "serial_port" in msg.lower() or "restart" in msg.lower()
|
||||
|
||||
def test_reload_config_not_found_returns_false(self, tmp_path):
|
||||
config_file = tmp_path / "config.ini"
|
||||
db_path = tmp_path / "bot.db"
|
||||
_write_config(config_file, db_path)
|
||||
b = MeshCoreBot(config_file=str(config_file))
|
||||
# Delete the config file
|
||||
config_file.unlink()
|
||||
success, msg = b.reload_config()
|
||||
assert success is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# key_prefix / is_valid_prefix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestKeyPrefixHelpers:
|
||||
def test_key_prefix_returns_first_n_chars(self, bot):
|
||||
assert bot.key_prefix("deadbeef1234") == "de"
|
||||
|
||||
def test_key_prefix_uses_prefix_hex_chars(self, bot):
|
||||
bot.prefix_hex_chars = 4
|
||||
assert bot.key_prefix("deadbeef1234") == "dead"
|
||||
|
||||
def test_is_valid_prefix_correct_length(self, bot):
|
||||
assert bot.is_valid_prefix("de") is True
|
||||
|
||||
def test_is_valid_prefix_wrong_length(self, bot):
|
||||
assert bot.is_valid_prefix("d") is False
|
||||
assert bot.is_valid_prefix("dead") is False
|
||||
|
||||
def test_prefix_hex_chars_from_config(self, tmp_path):
|
||||
config_file = tmp_path / "config2.ini"
|
||||
db_path = tmp_path / "bot2.db"
|
||||
config_file.write_text(
|
||||
f"""[Connection]
|
||||
connection_type = ble
|
||||
|
||||
[Bot]
|
||||
db_path = {db_path.as_posix()}
|
||||
prefix_bytes = 2
|
||||
|
||||
[Channels]
|
||||
monitor_channels = #general
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
b = MeshCoreBot(config_file=str(config_file))
|
||||
assert b.prefix_hex_chars == 4
|
||||
assert b.is_valid_prefix("dead") is True
|
||||
assert b.is_valid_prefix("de") is False
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tests for modules/enums.py — enum values and flag combinations."""
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.enums import AdvertFlags, DeviceRole, PayloadType, PayloadVersion, RouteType
|
||||
|
||||
|
||||
class TestAdvertFlags:
|
||||
"""Tests for AdvertFlags Flag enum."""
|
||||
|
||||
def test_type_flags_values(self):
|
||||
assert AdvertFlags.ADV_TYPE_NONE.value == 0x00
|
||||
assert AdvertFlags.ADV_TYPE_CHAT.value == 0x01
|
||||
assert AdvertFlags.ADV_TYPE_REPEATER.value == 0x02
|
||||
assert AdvertFlags.ADV_TYPE_ROOM.value == 0x03
|
||||
assert AdvertFlags.ADV_TYPE_SENSOR.value == 0x04
|
||||
|
||||
def test_feature_flag_values(self):
|
||||
assert AdvertFlags.ADV_LATLON_MASK.value == 0x10
|
||||
assert AdvertFlags.ADV_FEAT1_MASK.value == 0x20
|
||||
assert AdvertFlags.ADV_FEAT2_MASK.value == 0x40
|
||||
assert AdvertFlags.ADV_NAME_MASK.value == 0x80
|
||||
|
||||
def test_legacy_aliases_equal_originals(self):
|
||||
assert AdvertFlags.IsCompanion == AdvertFlags.ADV_TYPE_CHAT
|
||||
assert AdvertFlags.IsRepeater == AdvertFlags.ADV_TYPE_REPEATER
|
||||
assert AdvertFlags.IsRoomServer == AdvertFlags.ADV_TYPE_ROOM
|
||||
assert AdvertFlags.HasLocation == AdvertFlags.ADV_LATLON_MASK
|
||||
assert AdvertFlags.HasName == AdvertFlags.ADV_NAME_MASK
|
||||
assert AdvertFlags.HasNameBit7 == AdvertFlags.ADV_NAME_MASK
|
||||
|
||||
def test_flag_combination(self):
|
||||
"""Flags can be combined with |."""
|
||||
combined = AdvertFlags.ADV_TYPE_REPEATER | AdvertFlags.ADV_LATLON_MASK
|
||||
assert AdvertFlags.ADV_TYPE_REPEATER in combined
|
||||
assert AdvertFlags.ADV_LATLON_MASK in combined
|
||||
|
||||
def test_flag_membership(self):
|
||||
combined = AdvertFlags.ADV_TYPE_CHAT | AdvertFlags.ADV_NAME_MASK
|
||||
assert AdvertFlags.ADV_NAME_MASK in combined
|
||||
assert AdvertFlags.ADV_LATLON_MASK not in combined
|
||||
|
||||
|
||||
class TestPayloadType:
|
||||
"""Tests for PayloadType Enum."""
|
||||
|
||||
def test_known_values(self):
|
||||
assert PayloadType.REQ.value == 0x00
|
||||
assert PayloadType.RESPONSE.value == 0x01
|
||||
assert PayloadType.TXT_MSG.value == 0x02
|
||||
assert PayloadType.ACK.value == 0x03
|
||||
assert PayloadType.ADVERT.value == 0x04
|
||||
assert PayloadType.GRP_TXT.value == 0x05
|
||||
assert PayloadType.GRP_DATA.value == 0x06
|
||||
assert PayloadType.ANON_REQ.value == 0x07
|
||||
assert PayloadType.PATH.value == 0x08
|
||||
assert PayloadType.TRACE.value == 0x09
|
||||
assert PayloadType.MULTIPART.value == 0x0A
|
||||
assert PayloadType.RAW_CUSTOM.value == 0x0F
|
||||
|
||||
def test_lookup_by_value(self):
|
||||
assert PayloadType(0x02) == PayloadType.TXT_MSG
|
||||
assert PayloadType(0x04) == PayloadType.ADVERT
|
||||
|
||||
def test_all_members_unique(self):
|
||||
values = [m.value for m in PayloadType]
|
||||
assert len(values) == len(set(values))
|
||||
|
||||
|
||||
class TestPayloadVersion:
|
||||
"""Tests for PayloadVersion Enum."""
|
||||
|
||||
def test_versions(self):
|
||||
assert PayloadVersion.VER_1.value == 0x00
|
||||
assert PayloadVersion.VER_2.value == 0x01
|
||||
assert PayloadVersion.VER_3.value == 0x02
|
||||
assert PayloadVersion.VER_4.value == 0x03
|
||||
|
||||
def test_lookup(self):
|
||||
assert PayloadVersion(0x00) == PayloadVersion.VER_1
|
||||
|
||||
|
||||
class TestRouteType:
|
||||
"""Tests for RouteType Enum."""
|
||||
|
||||
def test_values(self):
|
||||
assert RouteType.TRANSPORT_FLOOD.value == 0x00
|
||||
assert RouteType.FLOOD.value == 0x01
|
||||
assert RouteType.DIRECT.value == 0x02
|
||||
assert RouteType.TRANSPORT_DIRECT.value == 0x03
|
||||
|
||||
def test_lookup_by_value(self):
|
||||
assert RouteType(0x01) == RouteType.FLOOD
|
||||
|
||||
|
||||
class TestDeviceRole:
|
||||
"""Tests for DeviceRole Enum."""
|
||||
|
||||
def test_values(self):
|
||||
assert DeviceRole.Companion.value == "Companion"
|
||||
assert DeviceRole.Repeater.value == "Repeater"
|
||||
assert DeviceRole.RoomServer.value == "RoomServer"
|
||||
|
||||
def test_lookup_by_value(self):
|
||||
assert DeviceRole("Repeater") == DeviceRole.Repeater
|
||||
|
||||
def test_all_members(self):
|
||||
assert len(list(DeviceRole)) == 3
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Tests for MessageHandler pure logic (no network, no meshcore device)."""
|
||||
|
||||
import time
|
||||
import pytest
|
||||
import configparser
|
||||
from unittest.mock import Mock, MagicMock
|
||||
|
||||
from modules.message_handler import MessageHandler
|
||||
from modules.models import MeshMessage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bot(mock_logger):
|
||||
"""Minimal bot mock for MessageHandler instantiation."""
|
||||
bot = Mock()
|
||||
bot.logger = mock_logger
|
||||
bot.config = configparser.ConfigParser()
|
||||
bot.config.add_section("Bot")
|
||||
bot.config.set("Bot", "enabled", "true")
|
||||
bot.config.set("Bot", "rf_data_timeout", "15.0")
|
||||
bot.config.set("Bot", "message_correlation_timeout", "10.0")
|
||||
bot.config.set("Bot", "enable_enhanced_correlation", "true")
|
||||
bot.config.add_section("Channels")
|
||||
bot.config.set("Channels", "respond_to_dms", "true")
|
||||
bot.connection_time = None
|
||||
bot.prefix_hex_chars = 2
|
||||
bot.command_manager = Mock()
|
||||
bot.command_manager.monitor_channels = ["general", "test"]
|
||||
bot.command_manager.is_user_banned = Mock(return_value=False)
|
||||
bot.command_manager.commands = {}
|
||||
return bot
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def handler(bot):
|
||||
return MessageHandler(bot)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_old_cached_message
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsOldCachedMessage:
|
||||
"""Tests for MessageHandler._is_old_cached_message()."""
|
||||
|
||||
def test_no_connection_time_returns_false(self, handler):
|
||||
handler.bot.connection_time = None
|
||||
assert handler._is_old_cached_message(12345) is False
|
||||
|
||||
def test_none_timestamp_returns_false(self, handler):
|
||||
handler.bot.connection_time = time.time()
|
||||
assert handler._is_old_cached_message(None) is False
|
||||
|
||||
def test_unknown_timestamp_returns_false(self, handler):
|
||||
handler.bot.connection_time = time.time()
|
||||
assert handler._is_old_cached_message("unknown") is False
|
||||
|
||||
def test_zero_timestamp_returns_false(self, handler):
|
||||
handler.bot.connection_time = time.time()
|
||||
assert handler._is_old_cached_message(0) is False
|
||||
|
||||
def test_negative_timestamp_returns_false(self, handler):
|
||||
handler.bot.connection_time = time.time()
|
||||
assert handler._is_old_cached_message(-1) is False
|
||||
|
||||
def test_old_timestamp_returns_true(self, handler):
|
||||
now = time.time()
|
||||
handler.bot.connection_time = now
|
||||
old = now - 100 # 100 seconds before connection
|
||||
assert handler._is_old_cached_message(old) is True
|
||||
|
||||
def test_recent_timestamp_returns_false(self, handler):
|
||||
now = time.time()
|
||||
handler.bot.connection_time = now
|
||||
recent = now + 1 # after connection
|
||||
assert handler._is_old_cached_message(recent) is False
|
||||
|
||||
def test_far_future_timestamp_returns_false(self, handler):
|
||||
handler.bot.connection_time = time.time()
|
||||
future = time.time() + 7200 # 2 hours in future
|
||||
assert handler._is_old_cached_message(future) is False
|
||||
|
||||
def test_invalid_string_returns_false(self, handler):
|
||||
handler.bot.connection_time = time.time()
|
||||
assert handler._is_old_cached_message("not_a_number") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _path_bytes_to_nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPathBytesToNodes:
|
||||
"""Tests for MessageHandler._path_bytes_to_nodes()."""
|
||||
|
||||
def test_single_byte_per_hop(self, handler):
|
||||
# 3 bytes -> 3 nodes of 2 hex chars each
|
||||
path_hex, nodes = handler._path_bytes_to_nodes(bytes.fromhex("017e86"), prefix_hex_chars=2)
|
||||
assert path_hex == "017e86"
|
||||
assert nodes == ["01", "7E", "86"]
|
||||
|
||||
def test_two_bytes_per_hop(self, handler):
|
||||
path_hex, nodes = handler._path_bytes_to_nodes(bytes.fromhex("01027e86"), prefix_hex_chars=4)
|
||||
assert nodes == ["0102", "7E86"]
|
||||
|
||||
def test_remainder_falls_back_to_1byte(self, handler):
|
||||
# 3 bytes with prefix_hex_chars=4 → remainder, fallback to 1 byte
|
||||
path_hex, nodes = handler._path_bytes_to_nodes(bytes.fromhex("017e86"), prefix_hex_chars=4)
|
||||
assert nodes == ["01", "7E", "86"]
|
||||
|
||||
def test_empty_bytes(self, handler):
|
||||
path_hex, nodes = handler._path_bytes_to_nodes(b"", prefix_hex_chars=2)
|
||||
assert path_hex == ""
|
||||
# Empty or fallback nodes — no crash expected
|
||||
assert isinstance(nodes, list)
|
||||
|
||||
def test_zero_prefix_hex_chars_defaults_to_2(self, handler):
|
||||
path_hex, nodes = handler._path_bytes_to_nodes(bytes.fromhex("017e"), prefix_hex_chars=0)
|
||||
assert nodes == ["01", "7E"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _path_hex_to_nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPathHexToNodes:
|
||||
"""Tests for MessageHandler._path_hex_to_nodes()."""
|
||||
|
||||
def test_splits_into_2char_nodes(self, handler):
|
||||
handler.bot.prefix_hex_chars = 2
|
||||
nodes = handler._path_hex_to_nodes("017e86")
|
||||
assert nodes == ["01", "7e", "86"]
|
||||
|
||||
def test_empty_string_returns_empty(self, handler):
|
||||
nodes = handler._path_hex_to_nodes("")
|
||||
assert nodes == []
|
||||
|
||||
def test_short_string_returns_empty(self, handler):
|
||||
nodes = handler._path_hex_to_nodes("0")
|
||||
assert nodes == []
|
||||
|
||||
def test_4char_prefix_hex_chars(self, handler):
|
||||
handler.bot.prefix_hex_chars = 4
|
||||
nodes = handler._path_hex_to_nodes("01027e86")
|
||||
assert nodes == ["0102", "7e86"]
|
||||
|
||||
def test_remainder_falls_back_to_2chars(self, handler):
|
||||
handler.bot.prefix_hex_chars = 4
|
||||
# 6 hex chars (3 bytes) with 4-char chunks → remainder → fallback to 2-char
|
||||
nodes = handler._path_hex_to_nodes("017e86")
|
||||
assert nodes == ["01", "7e", "86"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _format_path_string
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFormatPathString:
|
||||
"""Tests for MessageHandler._format_path_string()."""
|
||||
|
||||
def test_empty_path_returns_direct(self, handler):
|
||||
assert handler._format_path_string("") == "Direct"
|
||||
|
||||
def test_legacy_single_byte_per_hop(self, handler):
|
||||
result = handler._format_path_string("017e86")
|
||||
assert result == "01,7e,86"
|
||||
|
||||
def test_with_bytes_per_hop_1(self, handler):
|
||||
result = handler._format_path_string("017e86", bytes_per_hop=1)
|
||||
assert result == "01,7e,86"
|
||||
|
||||
def test_with_bytes_per_hop_2(self, handler):
|
||||
result = handler._format_path_string("01027e86", bytes_per_hop=2)
|
||||
assert result == "0102,7e86"
|
||||
|
||||
def test_remainder_with_bytes_per_hop_falls_back(self, handler):
|
||||
# 3 bytes (6 hex) with bytes_per_hop=2 → remainder → fallback to 1 byte
|
||||
result = handler._format_path_string("017e86", bytes_per_hop=2)
|
||||
assert result == "01,7e,86"
|
||||
|
||||
def test_none_path_returns_direct(self, handler):
|
||||
assert handler._format_path_string(None) == "Direct"
|
||||
|
||||
def test_invalid_hex_returns_raw(self, handler):
|
||||
result = handler._format_path_string("ZZZZ", bytes_per_hop=None)
|
||||
# Should not crash; returns "Raw: ..." fallback
|
||||
assert "Raw" in result or "ZZ" in result.upper() or result == "Direct"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_route_type_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetRouteTypeName:
|
||||
"""Tests for MessageHandler._get_route_type_name()."""
|
||||
|
||||
def test_known_types(self, handler):
|
||||
assert handler._get_route_type_name(0x00) == "ROUTE_TYPE_TRANSPORT_FLOOD"
|
||||
assert handler._get_route_type_name(0x01) == "ROUTE_TYPE_FLOOD"
|
||||
assert handler._get_route_type_name(0x02) == "ROUTE_TYPE_DIRECT"
|
||||
assert handler._get_route_type_name(0x03) == "ROUTE_TYPE_TRANSPORT_DIRECT"
|
||||
|
||||
def test_unknown_type(self, handler):
|
||||
result = handler._get_route_type_name(0xFF)
|
||||
assert "UNKNOWN" in result
|
||||
assert "ff" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_payload_type_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetPayloadTypeName:
|
||||
"""Tests for MessageHandler.get_payload_type_name()."""
|
||||
|
||||
def test_known_types(self, handler):
|
||||
assert handler.get_payload_type_name(0x00) == "REQ"
|
||||
assert handler.get_payload_type_name(0x02) == "TXT_MSG"
|
||||
assert handler.get_payload_type_name(0x04) == "ADVERT"
|
||||
assert handler.get_payload_type_name(0x05) == "GRP_TXT"
|
||||
assert handler.get_payload_type_name(0x08) == "PATH"
|
||||
assert handler.get_payload_type_name(0x0F) == "RAW_CUSTOM"
|
||||
|
||||
def test_unknown_type(self, handler):
|
||||
result = handler.get_payload_type_name(0xAB)
|
||||
assert "UNKNOWN" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# should_process_message
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestShouldProcessMessage:
|
||||
"""Tests for MessageHandler.should_process_message()."""
|
||||
|
||||
def _make_msg(self, channel=None, is_dm=False, sender_id="Alice"):
|
||||
return MeshMessage(
|
||||
content="hello",
|
||||
channel=channel,
|
||||
is_dm=is_dm,
|
||||
sender_id=sender_id,
|
||||
)
|
||||
|
||||
def test_bot_disabled_returns_false(self, handler):
|
||||
handler.bot.config.set("Bot", "enabled", "false")
|
||||
msg = self._make_msg(channel="general")
|
||||
assert handler.should_process_message(msg) is False
|
||||
|
||||
def test_banned_user_returns_false(self, handler):
|
||||
handler.bot.command_manager.is_user_banned.return_value = True
|
||||
msg = self._make_msg(channel="general")
|
||||
assert handler.should_process_message(msg) is False
|
||||
|
||||
def test_monitored_channel_returns_true(self, handler):
|
||||
msg = self._make_msg(channel="general")
|
||||
assert handler.should_process_message(msg) is True
|
||||
|
||||
def test_unmonitored_channel_returns_false(self, handler):
|
||||
msg = self._make_msg(channel="unmonitored")
|
||||
assert handler.should_process_message(msg) is False
|
||||
|
||||
def test_dm_enabled_returns_true(self, handler):
|
||||
handler.bot.config.set("Channels", "respond_to_dms", "true")
|
||||
msg = self._make_msg(is_dm=True)
|
||||
assert handler.should_process_message(msg) is True
|
||||
|
||||
def test_dm_disabled_returns_false(self, handler):
|
||||
handler.bot.config.set("Channels", "respond_to_dms", "false")
|
||||
msg = self._make_msg(is_dm=True)
|
||||
assert handler.should_process_message(msg) is False
|
||||
|
||||
def test_command_override_allows_unmonitored_channel(self, handler):
|
||||
cmd = Mock()
|
||||
cmd.is_channel_allowed = Mock(return_value=True)
|
||||
handler.bot.command_manager.commands = {"special": cmd}
|
||||
msg = self._make_msg(channel="unmonitored")
|
||||
assert handler.should_process_message(msg) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _cleanup_stale_cache_entries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCleanupStaleCacheEntries:
|
||||
"""Tests for MessageHandler._cleanup_stale_cache_entries()."""
|
||||
|
||||
def test_removes_old_timestamp_cache_entries(self, handler):
|
||||
now = time.time()
|
||||
# Old entry: older than rf_data_timeout
|
||||
handler.rf_data_by_timestamp[now - 100] = {"timestamp": now - 100, "data": "old"}
|
||||
# Recent entry
|
||||
handler.rf_data_by_timestamp[now] = {"timestamp": now, "data": "new"}
|
||||
# Force full cleanup
|
||||
handler._last_cache_cleanup = 0
|
||||
handler._cleanup_stale_cache_entries(current_time=now + handler._cache_cleanup_interval + 1)
|
||||
# Old entry should be gone, recent kept
|
||||
assert (now - 100) not in handler.rf_data_by_timestamp
|
||||
assert now in handler.rf_data_by_timestamp
|
||||
|
||||
def test_removes_stale_pubkey_cache_entries(self, handler):
|
||||
now = time.time()
|
||||
handler.rf_data_by_pubkey["deadbeef"] = [
|
||||
{"timestamp": now - 100, "data": "old"}, # stale
|
||||
{"timestamp": now, "data": "new"}, # fresh
|
||||
]
|
||||
handler._last_cache_cleanup = 0
|
||||
handler._cleanup_stale_cache_entries(current_time=now + handler._cache_cleanup_interval + 1)
|
||||
entries = handler.rf_data_by_pubkey.get("deadbeef", [])
|
||||
assert all(now - e["timestamp"] < handler.rf_data_timeout for e in entries)
|
||||
|
||||
def test_removes_stale_recent_rf_data(self, handler):
|
||||
now = time.time()
|
||||
handler.recent_rf_data = [
|
||||
{"timestamp": now - 100},
|
||||
{"timestamp": now},
|
||||
]
|
||||
handler._last_cache_cleanup = 0
|
||||
handler._cleanup_stale_cache_entries(current_time=now + handler._cache_cleanup_interval + 1)
|
||||
assert all(now - e["timestamp"] < handler.rf_data_timeout for e in handler.recent_rf_data)
|
||||
|
||||
def test_skips_full_cleanup_within_interval(self, handler):
|
||||
now = time.time()
|
||||
handler._last_cache_cleanup = now # just cleaned
|
||||
# Stale entry in timestamp cache
|
||||
stale_ts = now - 100
|
||||
handler.rf_data_by_timestamp[stale_ts] = {"timestamp": stale_ts}
|
||||
# Call with time just slightly after (within cleanup interval)
|
||||
handler._cleanup_stale_cache_entries(current_time=now + 1)
|
||||
# Still cleaned (timeout-only cleanup still runs)
|
||||
assert stale_ts not in handler.rf_data_by_timestamp
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for modules/models.py — MeshMessage dataclass."""
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.models import MeshMessage
|
||||
|
||||
|
||||
class TestMeshMessageDefaults:
|
||||
"""MeshMessage default values."""
|
||||
|
||||
def test_content_is_required(self):
|
||||
msg = MeshMessage(content="hello")
|
||||
assert msg.content == "hello"
|
||||
|
||||
def test_optional_fields_default_to_none(self):
|
||||
msg = MeshMessage(content="test")
|
||||
assert msg.sender_id is None
|
||||
assert msg.sender_pubkey is None
|
||||
assert msg.channel is None
|
||||
assert msg.hops is None
|
||||
assert msg.path is None
|
||||
assert msg.timestamp is None
|
||||
assert msg.snr is None
|
||||
assert msg.rssi is None
|
||||
assert msg.elapsed is None
|
||||
assert msg.routing_info is None
|
||||
|
||||
def test_is_dm_defaults_false(self):
|
||||
msg = MeshMessage(content="test")
|
||||
assert msg.is_dm is False
|
||||
|
||||
|
||||
class TestMeshMessageConstruction:
|
||||
"""MeshMessage construction with various field combinations."""
|
||||
|
||||
def test_channel_message(self):
|
||||
msg = MeshMessage(content="ping", channel="general", sender_id="Alice")
|
||||
assert msg.content == "ping"
|
||||
assert msg.channel == "general"
|
||||
assert msg.sender_id == "Alice"
|
||||
assert msg.is_dm is False
|
||||
|
||||
def test_dm_message(self):
|
||||
msg = MeshMessage(content="hello", is_dm=True, sender_pubkey="deadbeef")
|
||||
assert msg.is_dm is True
|
||||
assert msg.channel is None
|
||||
assert msg.sender_pubkey == "deadbeef"
|
||||
|
||||
def test_routing_info_dict(self):
|
||||
routing = {"path_nodes": ["01", "7e"], "route_type": "FLOOD"}
|
||||
msg = MeshMessage(content="data", routing_info=routing)
|
||||
assert msg.routing_info == routing
|
||||
assert msg.routing_info["route_type"] == "FLOOD"
|
||||
|
||||
def test_numeric_fields(self):
|
||||
msg = MeshMessage(
|
||||
content="test",
|
||||
hops=3,
|
||||
timestamp=1700000000,
|
||||
snr=5.5,
|
||||
rssi=-80,
|
||||
)
|
||||
assert msg.hops == 3
|
||||
assert msg.timestamp == 1700000000
|
||||
assert msg.snr == 5.5
|
||||
assert msg.rssi == -80
|
||||
|
||||
def test_path_field(self):
|
||||
msg = MeshMessage(content="test", path="01,7e,86")
|
||||
assert msg.path == "01,7e,86"
|
||||
|
||||
def test_elapsed_field(self):
|
||||
msg = MeshMessage(content="test", elapsed="1.23s")
|
||||
assert msg.elapsed == "1.23s"
|
||||
|
||||
|
||||
class TestMeshMessageEquality:
|
||||
"""MeshMessage dataclass equality."""
|
||||
|
||||
def test_equal_messages(self):
|
||||
msg1 = MeshMessage(content="ping", channel="general", sender_id="Alice")
|
||||
msg2 = MeshMessage(content="ping", channel="general", sender_id="Alice")
|
||||
assert msg1 == msg2
|
||||
|
||||
def test_different_content_not_equal(self):
|
||||
msg1 = MeshMessage(content="ping")
|
||||
msg2 = MeshMessage(content="pong")
|
||||
assert msg1 != msg2
|
||||
|
||||
def test_different_channel_not_equal(self):
|
||||
msg1 = MeshMessage(content="ping", channel="general")
|
||||
msg2 = MeshMessage(content="ping", channel="test")
|
||||
assert msg1 != msg2
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Tests for RepeaterManager pure logic (no network, no geocoding)."""
|
||||
|
||||
import pytest
|
||||
import configparser
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
|
||||
from modules.repeater_manager import RepeaterManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bot(mock_logger, test_db):
|
||||
"""Minimal bot mock for RepeaterManager — uses a real test DB."""
|
||||
bot = Mock()
|
||||
bot.logger = mock_logger
|
||||
bot.db_manager = test_db
|
||||
bot.config = configparser.ConfigParser()
|
||||
bot.config.add_section("Bot")
|
||||
bot.config.set("Bot", "auto_manage_contacts", "false")
|
||||
bot.config.add_section("Companion_Purge")
|
||||
bot.config.set("Companion_Purge", "companion_purge_enabled", "false")
|
||||
bot.config.set("Companion_Purge", "companion_dm_threshold_days", "30")
|
||||
bot.config.set("Companion_Purge", "companion_advert_threshold_days", "30")
|
||||
bot.config.set("Companion_Purge", "companion_min_inactive_days", "30")
|
||||
bot.meshcore = None
|
||||
return bot
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rm(bot):
|
||||
"""RepeaterManager instance for pure logic tests."""
|
||||
return RepeaterManager(bot)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _determine_contact_role
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDetermineContactRole:
|
||||
"""Tests for RepeaterManager._determine_contact_role()."""
|
||||
|
||||
def test_mode_repeater(self, rm):
|
||||
assert rm._determine_contact_role({"mode": "Repeater"}) == "repeater"
|
||||
|
||||
def test_mode_roomserver(self, rm):
|
||||
assert rm._determine_contact_role({"mode": "RoomServer"}) == "roomserver"
|
||||
|
||||
def test_mode_companion(self, rm):
|
||||
assert rm._determine_contact_role({"mode": "Companion"}) == "companion"
|
||||
|
||||
def test_mode_sensor(self, rm):
|
||||
assert rm._determine_contact_role({"mode": "Sensor"}) == "sensor"
|
||||
|
||||
def test_mode_unknown_lowercased(self, rm):
|
||||
result = rm._determine_contact_role({"mode": "CustomMode"})
|
||||
assert result == "custommode"
|
||||
|
||||
def test_device_type_2_returns_repeater(self, rm):
|
||||
assert rm._determine_contact_role({"type": 2}) == "repeater"
|
||||
|
||||
def test_device_type_3_returns_roomserver(self, rm):
|
||||
assert rm._determine_contact_role({"type": 3}) == "roomserver"
|
||||
|
||||
def test_name_rpt_returns_repeater(self, rm):
|
||||
assert rm._determine_contact_role({"name": "My-RPT-01"}) == "repeater"
|
||||
|
||||
def test_name_roomserver_returns_roomserver(self, rm):
|
||||
assert rm._determine_contact_role({"name": "Room Server"}) == "roomserver"
|
||||
|
||||
def test_name_sensor_returns_sensor(self, rm):
|
||||
assert rm._determine_contact_role({"name": "Weather Sensor"}) == "sensor"
|
||||
|
||||
def test_name_bot_returns_bot(self, rm):
|
||||
assert rm._determine_contact_role({"name": "AutomatedBot"}) == "bot"
|
||||
|
||||
def test_name_gateway_returns_gateway(self, rm):
|
||||
assert rm._determine_contact_role({"name": "GW-01"}) == "gateway"
|
||||
|
||||
def test_unknown_defaults_to_companion(self, rm):
|
||||
assert rm._determine_contact_role({"name": "Alice"}) == "companion"
|
||||
|
||||
def test_empty_contact_defaults_to_companion(self, rm):
|
||||
assert rm._determine_contact_role({}) == "companion"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _determine_device_type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDetermineDeviceType:
|
||||
"""Tests for RepeaterManager._determine_device_type()."""
|
||||
|
||||
def test_advert_data_mode_repeater(self, rm):
|
||||
result = rm._determine_device_type(0, "Test", advert_data={"mode": "Repeater"})
|
||||
assert result == "Repeater"
|
||||
|
||||
def test_advert_data_mode_roomserver(self, rm):
|
||||
result = rm._determine_device_type(0, "Test", advert_data={"mode": "RoomServer"})
|
||||
assert result == "RoomServer"
|
||||
|
||||
def test_device_type_1(self, rm):
|
||||
assert rm._determine_device_type(1, "Alice") == "Companion"
|
||||
|
||||
def test_device_type_2(self, rm):
|
||||
assert rm._determine_device_type(2, "Node") == "Repeater"
|
||||
|
||||
def test_device_type_3(self, rm):
|
||||
assert rm._determine_device_type(3, "Node") == "RoomServer"
|
||||
|
||||
def test_name_roomserver(self, rm):
|
||||
assert rm._determine_device_type(0, "RoomServer Node") == "RoomServer"
|
||||
|
||||
def test_name_repeater(self, rm):
|
||||
assert rm._determine_device_type(0, "RPT-01 repeater") == "Repeater"
|
||||
|
||||
def test_name_sensor(self, rm):
|
||||
assert rm._determine_device_type(0, "Weather sens") == "Sensor"
|
||||
|
||||
def test_name_gateway(self, rm):
|
||||
assert rm._determine_device_type(0, "MQTT-GW bridge") == "Gateway"
|
||||
|
||||
def test_name_bot(self, rm):
|
||||
assert rm._determine_device_type(0, "Automated assistant") == "Bot"
|
||||
|
||||
def test_unknown_defaults_to_companion(self, rm):
|
||||
assert rm._determine_device_type(0, "Alice Johnson") == "Companion"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_repeater_device
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsRepeaterDevice:
|
||||
"""Tests for RepeaterManager._is_repeater_device()."""
|
||||
|
||||
def test_type_2_is_repeater(self, rm):
|
||||
assert rm._is_repeater_device({"type": 2}) is True
|
||||
|
||||
def test_type_3_is_repeater(self, rm):
|
||||
assert rm._is_repeater_device({"type": 3}) is True
|
||||
|
||||
def test_type_1_not_repeater(self, rm):
|
||||
assert rm._is_repeater_device({"type": 1}) is False
|
||||
|
||||
def test_role_repeater_field(self, rm):
|
||||
assert rm._is_repeater_device({"role": "repeater"}) is True
|
||||
|
||||
def test_role_roomserver_field(self, rm):
|
||||
assert rm._is_repeater_device({"device_role": "RoomServer"}) is True
|
||||
|
||||
def test_name_repeater(self, rm):
|
||||
assert rm._is_repeater_device({"adv_name": "My Repeater Node"}) is True
|
||||
|
||||
def test_name_gateway(self, rm):
|
||||
assert rm._is_repeater_device({"name": "MQTT Gateway"}) is True
|
||||
|
||||
def test_companion_not_repeater(self, rm):
|
||||
assert rm._is_repeater_device({"type": 1, "name": "Alice"}) is False
|
||||
|
||||
def test_empty_data_not_repeater(self, rm):
|
||||
assert rm._is_repeater_device({}) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_companion_device
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsCompanionDevice:
|
||||
"""Tests for RepeaterManager._is_companion_device()."""
|
||||
|
||||
def test_companion_type_1(self, rm):
|
||||
assert rm._is_companion_device({"type": 1}) is True
|
||||
|
||||
def test_repeater_type_2_not_companion(self, rm):
|
||||
assert rm._is_companion_device({"type": 2}) is False
|
||||
|
||||
def test_empty_is_companion(self, rm):
|
||||
assert rm._is_companion_device({}) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_in_acl
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsInAcl:
|
||||
"""Tests for RepeaterManager._is_in_acl()."""
|
||||
|
||||
def test_no_acl_section_returns_false(self, rm):
|
||||
assert rm._is_in_acl("deadbeef") is False
|
||||
|
||||
def test_key_in_acl(self, rm):
|
||||
rm.bot.config.add_section("Admin_ACL")
|
||||
rm.bot.config.set("Admin_ACL", "admin_pubkeys", "deadbeef,cafebabe")
|
||||
assert rm._is_in_acl("deadbeef") is True
|
||||
|
||||
def test_key_not_in_acl(self, rm):
|
||||
rm.bot.config.add_section("Admin_ACL")
|
||||
rm.bot.config.set("Admin_ACL", "admin_pubkeys", "deadbeef")
|
||||
assert rm._is_in_acl("cafebabe") is False
|
||||
|
||||
def test_empty_acl_list_returns_false(self, rm):
|
||||
rm.bot.config.add_section("Admin_ACL")
|
||||
rm.bot.config.set("Admin_ACL", "admin_pubkeys", "")
|
||||
assert rm._is_in_acl("deadbeef") is False
|
||||
|
||||
def test_exact_match_required(self, rm):
|
||||
"""Partial key match should not succeed."""
|
||||
rm.bot.config.add_section("Admin_ACL")
|
||||
rm.bot.config.set("Admin_ACL", "admin_pubkeys", "deadbeef00112233")
|
||||
assert rm._is_in_acl("deadbeef") is False
|
||||
|
||||
def test_auto_purge_disabled_by_default(self, rm):
|
||||
assert rm.auto_purge_enabled is False
|
||||
|
||||
def test_auto_purge_enabled_when_set(self, bot):
|
||||
bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
rm2 = RepeaterManager(bot)
|
||||
assert rm2.auto_purge_enabled is True
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Tests for modules/transmission_tracker.py."""
|
||||
|
||||
import time
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock
|
||||
|
||||
from modules.transmission_tracker import TransmissionRecord, TransmissionTracker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bot(mock_logger):
|
||||
"""Minimal bot mock for TransmissionTracker."""
|
||||
bot = Mock()
|
||||
bot.logger = mock_logger
|
||||
bot.meshcore = None # No device connected
|
||||
bot.prefix_hex_chars = 2
|
||||
return bot
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tracker(mock_bot):
|
||||
"""TransmissionTracker instance with a mock bot."""
|
||||
return TransmissionTracker(mock_bot)
|
||||
|
||||
|
||||
class TestTransmissionRecord:
|
||||
"""Tests for TransmissionRecord dataclass."""
|
||||
|
||||
def test_default_fields(self):
|
||||
rec = TransmissionRecord(
|
||||
timestamp=1234.0,
|
||||
content="hello",
|
||||
target="general",
|
||||
message_type="channel",
|
||||
)
|
||||
assert rec.repeat_count == 0
|
||||
assert rec.packet_hash is None
|
||||
assert rec.command_id is None
|
||||
assert rec.repeater_prefixes == set()
|
||||
assert rec.repeater_counts == {}
|
||||
|
||||
def test_custom_fields(self):
|
||||
rec = TransmissionRecord(
|
||||
timestamp=5678.0,
|
||||
content="dm text",
|
||||
target="Alice",
|
||||
message_type="dm",
|
||||
packet_hash="abcd1234",
|
||||
command_id="cmd-001",
|
||||
)
|
||||
assert rec.packet_hash == "abcd1234"
|
||||
assert rec.command_id == "cmd-001"
|
||||
|
||||
|
||||
class TestRecordTransmission:
|
||||
"""Tests for TransmissionTracker.record_transmission()."""
|
||||
|
||||
def test_returns_transmission_record(self, tracker):
|
||||
rec = tracker.record_transmission("hello", "general", "channel")
|
||||
assert isinstance(rec, TransmissionRecord)
|
||||
assert rec.content == "hello"
|
||||
assert rec.target == "general"
|
||||
assert rec.message_type == "channel"
|
||||
|
||||
def test_stores_in_pending(self, tracker):
|
||||
rec = tracker.record_transmission("msg", "ch", "channel")
|
||||
key = int(rec.timestamp)
|
||||
assert key in tracker.pending_transmissions
|
||||
assert rec in tracker.pending_transmissions[key]
|
||||
|
||||
def test_multiple_records_same_second(self, tracker):
|
||||
rec1 = tracker.record_transmission("a", "ch", "channel")
|
||||
rec2 = tracker.record_transmission("b", "ch", "channel")
|
||||
key = int(rec1.timestamp)
|
||||
# Both records should be in the same (or nearby) bucket
|
||||
assert rec1 in tracker.pending_transmissions.get(int(rec1.timestamp), [])
|
||||
assert rec2 in tracker.pending_transmissions.get(int(rec2.timestamp), [])
|
||||
|
||||
def test_with_command_id(self, tracker):
|
||||
rec = tracker.record_transmission("msg", "ch", "channel", command_id="cmd-42")
|
||||
assert rec.command_id == "cmd-42"
|
||||
|
||||
|
||||
class TestMatchPacketHash:
|
||||
"""Tests for TransmissionTracker.match_packet_hash()."""
|
||||
|
||||
def test_null_hash_returns_none(self, tracker):
|
||||
assert tracker.match_packet_hash("", time.time()) is None
|
||||
assert tracker.match_packet_hash("0000000000000000", time.time()) is None
|
||||
|
||||
def test_matches_pending_transmission(self, tracker):
|
||||
rec = tracker.record_transmission("msg", "general", "channel")
|
||||
result = tracker.match_packet_hash("deadbeef", rec.timestamp + 1)
|
||||
assert result is not None
|
||||
assert result.packet_hash == "deadbeef"
|
||||
|
||||
def test_already_confirmed_returned_immediately(self, tracker):
|
||||
rec = tracker.record_transmission("msg", "ch", "channel")
|
||||
# First match confirms it
|
||||
tracker.match_packet_hash("abc123", rec.timestamp)
|
||||
# Second call returns same confirmed record
|
||||
result2 = tracker.match_packet_hash("abc123", time.time())
|
||||
assert result2 is not None
|
||||
assert result2.packet_hash == "abc123"
|
||||
|
||||
def test_no_match_outside_window(self, tracker):
|
||||
rec = tracker.record_transmission("msg", "ch", "channel")
|
||||
# RF timestamp far in the future
|
||||
result = tracker.match_packet_hash("deadbeef", rec.timestamp + 9999)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestRecordRepeat:
|
||||
"""Tests for TransmissionTracker.record_repeat()."""
|
||||
|
||||
def test_null_hash_returns_false(self, tracker):
|
||||
assert tracker.record_repeat("") is False
|
||||
assert tracker.record_repeat("0000000000000000") is False
|
||||
|
||||
def test_repeat_increments_count(self, tracker):
|
||||
rec = tracker.record_transmission("msg", "ch", "channel")
|
||||
# First confirm the hash
|
||||
tracker.match_packet_hash("hash01", rec.timestamp)
|
||||
# Now record a repeat
|
||||
result = tracker.record_repeat("hash01", repeater_prefix="7e")
|
||||
assert result is True
|
||||
assert rec.repeat_count == 1
|
||||
assert "7e" in rec.repeater_prefixes
|
||||
|
||||
def test_repeat_without_prefix(self, tracker):
|
||||
rec = tracker.record_transmission("msg", "ch", "channel")
|
||||
tracker.match_packet_hash("hash02", rec.timestamp)
|
||||
result = tracker.record_repeat("hash02")
|
||||
assert result is True
|
||||
assert rec.repeat_count == 1
|
||||
assert rec.repeater_counts.get("_unknown") == 1
|
||||
|
||||
def test_multiple_repeats_same_repeater(self, tracker):
|
||||
rec = tracker.record_transmission("msg", "ch", "channel")
|
||||
tracker.match_packet_hash("hash03", rec.timestamp)
|
||||
tracker.record_repeat("hash03", repeater_prefix="01")
|
||||
tracker.record_repeat("hash03", repeater_prefix="01")
|
||||
assert rec.repeat_count == 2
|
||||
assert rec.repeater_counts["01"] == 2
|
||||
|
||||
def test_unmatched_hash_returns_false(self, tracker):
|
||||
result = tracker.record_repeat("nonexistent_hash")
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestGetRepeatInfo:
|
||||
"""Tests for TransmissionTracker.get_repeat_info()."""
|
||||
|
||||
def test_unknown_hash_returns_zeros(self, tracker):
|
||||
info = tracker.get_repeat_info(packet_hash="unknown")
|
||||
assert info["repeat_count"] == 0
|
||||
assert info["repeater_prefixes"] == []
|
||||
assert info["repeater_counts"] == {}
|
||||
|
||||
def test_lookup_by_packet_hash(self, tracker):
|
||||
rec = tracker.record_transmission("msg", "ch", "channel")
|
||||
tracker.match_packet_hash("hashXX", rec.timestamp)
|
||||
tracker.record_repeat("hashXX", repeater_prefix="7e")
|
||||
info = tracker.get_repeat_info(packet_hash="hashXX")
|
||||
assert info["repeat_count"] == 1
|
||||
assert "7e" in info["repeater_prefixes"]
|
||||
|
||||
def test_lookup_by_command_id(self, tracker):
|
||||
rec = tracker.record_transmission("msg", "ch", "channel", command_id="cmd-99")
|
||||
tracker.match_packet_hash("hashYY", rec.timestamp)
|
||||
tracker.record_repeat("hashYY", repeater_prefix="ab")
|
||||
info = tracker.get_repeat_info(command_id="cmd-99")
|
||||
assert info["repeat_count"] == 1
|
||||
assert "ab" in info["repeater_prefixes"]
|
||||
|
||||
|
||||
class TestExtractRepeaterPrefixes:
|
||||
"""Tests for TransmissionTracker.extract_repeater_prefixes_from_path()."""
|
||||
|
||||
def test_extracts_last_hop_from_path_string(self, tracker):
|
||||
result = tracker.extract_repeater_prefixes_from_path("01,7e,86")
|
||||
assert result == ["86"]
|
||||
|
||||
def test_extracts_from_path_nodes(self, tracker):
|
||||
result = tracker.extract_repeater_prefixes_from_path(None, path_nodes=["01", "7e", "86"])
|
||||
assert result == ["86"]
|
||||
|
||||
def test_filters_own_prefix(self, tracker):
|
||||
tracker.bot_prefix = "86"
|
||||
result = tracker.extract_repeater_prefixes_from_path("01,7e,86")
|
||||
assert result == []
|
||||
|
||||
def test_empty_path_returns_empty(self, tracker):
|
||||
result = tracker.extract_repeater_prefixes_from_path(None)
|
||||
assert result == []
|
||||
|
||||
def test_path_with_route_type_annotation(self, tracker):
|
||||
result = tracker.extract_repeater_prefixes_from_path("01,7e,55 via ROUTE_TYPE_FLOOD")
|
||||
assert result == ["55"]
|
||||
|
||||
def test_single_node_path(self, tracker):
|
||||
result = tracker.extract_repeater_prefixes_from_path("7e")
|
||||
assert result == ["7e"]
|
||||
|
||||
|
||||
class TestCleanupOldRecords:
|
||||
"""Tests for TransmissionTracker.cleanup_old_records()."""
|
||||
|
||||
def test_removes_old_pending(self, tracker):
|
||||
# Inject a record with an old timestamp
|
||||
old_rec = TransmissionRecord(
|
||||
timestamp=time.time() - 600, # 10 minutes ago (beyond cleanup_after=300)
|
||||
content="old msg",
|
||||
target="ch",
|
||||
message_type="channel",
|
||||
)
|
||||
old_key = int(old_rec.timestamp)
|
||||
tracker.pending_transmissions[old_key] = [old_rec]
|
||||
tracker.cleanup_old_records()
|
||||
assert old_key not in tracker.pending_transmissions
|
||||
|
||||
def test_keeps_recent_pending(self, tracker):
|
||||
rec = tracker.record_transmission("recent", "ch", "channel")
|
||||
key = int(rec.timestamp)
|
||||
tracker.cleanup_old_records()
|
||||
assert key in tracker.pending_transmissions
|
||||
|
||||
def test_removes_old_confirmed_without_repeats(self, tracker):
|
||||
old_rec = TransmissionRecord(
|
||||
timestamp=time.time() - 600,
|
||||
content="old",
|
||||
target="ch",
|
||||
message_type="channel",
|
||||
packet_hash="stale_hash",
|
||||
)
|
||||
tracker.confirmed_transmissions["stale_hash"] = old_rec
|
||||
tracker.cleanup_old_records()
|
||||
assert "stale_hash" not in tracker.confirmed_transmissions
|
||||
|
||||
def test_keeps_old_confirmed_with_repeats(self, tracker):
|
||||
old_rec = TransmissionRecord(
|
||||
timestamp=time.time() - 600,
|
||||
content="old",
|
||||
target="ch",
|
||||
message_type="channel",
|
||||
packet_hash="repeat_hash",
|
||||
repeat_count=3,
|
||||
)
|
||||
tracker.confirmed_transmissions["repeat_hash"] = old_rec
|
||||
tracker.cleanup_old_records()
|
||||
assert "repeat_hash" in tracker.confirmed_transmissions
|
||||
Reference in new issue
Block a user