|
|
@@ -1,13 +1,12 @@
|
|
|
-"""Tests for daemon.main — _perform_update() and heartbeat_loop command dispatch."""
|
|
|
+"""Tests for daemon.main — heartbeat_loop command dispatch and scale wake gating."""
|
|
|
|
|
|
import asyncio
|
|
|
-import sys
|
|
|
import time
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
|
import pytest
|
|
|
from daemon.config import Config
|
|
|
-from daemon.main import _perform_update, heartbeat_loop
|
|
|
+from daemon.main import heartbeat_loop, scale_poll_loop
|
|
|
|
|
|
|
|
|
def _make_config(**overrides):
|
|
|
@@ -31,177 +30,9 @@ def _make_api():
|
|
|
return api
|
|
|
|
|
|
|
|
|
-def _mock_process(returncode=0, stdout=b"", stderr=b""):
|
|
|
- proc = AsyncMock()
|
|
|
- proc.communicate = AsyncMock(return_value=(stdout, stderr))
|
|
|
- proc.returncode = returncode
|
|
|
- return proc
|
|
|
-
|
|
|
-
|
|
|
-class TestPerformUpdate:
|
|
|
- @pytest.mark.asyncio
|
|
|
- async def test_successful_update(self):
|
|
|
- config = _make_config()
|
|
|
- api = _make_api()
|
|
|
-
|
|
|
- proc_ok = _mock_process(returncode=0)
|
|
|
-
|
|
|
- with (
|
|
|
- patch("daemon.main.asyncio.create_subprocess_exec", return_value=proc_ok),
|
|
|
- patch("daemon.main.shutil.which", return_value="/usr/bin/git"),
|
|
|
- patch("daemon.main.Path") as mock_path_cls,
|
|
|
- pytest.raises(SystemExit) as exc_info,
|
|
|
- ):
|
|
|
- # Make venv pip not exist so it uses sys.executable path
|
|
|
- mock_path_inst = MagicMock()
|
|
|
- mock_path_cls.return_value.resolve.return_value.parent.parent.parent = mock_path_inst
|
|
|
- mock_path_inst.__truediv__ = MagicMock(
|
|
|
- side_effect=lambda x: MagicMock(
|
|
|
- exists=MagicMock(return_value=False),
|
|
|
- __truediv__=MagicMock(return_value=MagicMock(exists=MagicMock(return_value=False))),
|
|
|
- __str__=MagicMock(return_value="/fake/repo"),
|
|
|
- )
|
|
|
- )
|
|
|
- mock_path_inst.__str__ = MagicMock(return_value="/fake/repo")
|
|
|
-
|
|
|
- await _perform_update(config, api)
|
|
|
-
|
|
|
- assert exc_info.value.code == 0
|
|
|
-
|
|
|
- # Should have reported status multiple times
|
|
|
- assert api.report_update_status.await_count >= 3
|
|
|
- # Last call should be "complete"
|
|
|
- last_call = api.report_update_status.call_args_list[-1]
|
|
|
- assert last_call[0][1] == "complete"
|
|
|
-
|
|
|
- @pytest.mark.asyncio
|
|
|
- async def test_git_fetch_failure(self):
|
|
|
- config = _make_config()
|
|
|
- api = _make_api()
|
|
|
-
|
|
|
- proc_fail = _mock_process(returncode=1, stderr=b"fatal: cannot fetch")
|
|
|
-
|
|
|
- with (
|
|
|
- patch("daemon.main.asyncio.create_subprocess_exec", return_value=proc_fail),
|
|
|
- patch("daemon.main.shutil.which", return_value="/usr/bin/git"),
|
|
|
- patch("daemon.main.Path") as mock_path_cls,
|
|
|
- ):
|
|
|
- mock_path_inst = MagicMock()
|
|
|
- mock_path_cls.return_value.resolve.return_value.parent.parent.parent = mock_path_inst
|
|
|
- mock_path_inst.__str__ = MagicMock(return_value="/fake/repo")
|
|
|
-
|
|
|
- await _perform_update(config, api)
|
|
|
-
|
|
|
- # Should report error status
|
|
|
- error_calls = [c for c in api.report_update_status.call_args_list if c[0][1] == "error"]
|
|
|
- assert len(error_calls) == 1
|
|
|
- assert "git fetch failed" in error_calls[0][0][2]
|
|
|
-
|
|
|
- @pytest.mark.asyncio
|
|
|
- async def test_git_reset_failure(self):
|
|
|
- config = _make_config()
|
|
|
- api = _make_api()
|
|
|
-
|
|
|
- call_count = 0
|
|
|
-
|
|
|
- async def mock_exec(*args, **kwargs):
|
|
|
- nonlocal call_count
|
|
|
- call_count += 1
|
|
|
- if call_count == 1:
|
|
|
- # git fetch succeeds
|
|
|
- return _mock_process(returncode=0)
|
|
|
- else:
|
|
|
- # git reset fails
|
|
|
- return _mock_process(returncode=1, stderr=b"reset error")
|
|
|
-
|
|
|
- with (
|
|
|
- patch("daemon.main.asyncio.create_subprocess_exec", side_effect=mock_exec),
|
|
|
- patch("daemon.main.shutil.which", return_value="/usr/bin/git"),
|
|
|
- patch("daemon.main.Path") as mock_path_cls,
|
|
|
- ):
|
|
|
- mock_path_inst = MagicMock()
|
|
|
- mock_path_cls.return_value.resolve.return_value.parent.parent.parent = mock_path_inst
|
|
|
- mock_path_inst.__str__ = MagicMock(return_value="/fake/repo")
|
|
|
-
|
|
|
- await _perform_update(config, api)
|
|
|
-
|
|
|
- error_calls = [c for c in api.report_update_status.call_args_list if c[0][1] == "error"]
|
|
|
- assert len(error_calls) == 1
|
|
|
- assert "git reset failed" in error_calls[0][0][2]
|
|
|
-
|
|
|
-
|
|
|
class TestHeartbeatLoopCommands:
|
|
|
"""Test command dispatch in heartbeat_loop."""
|
|
|
|
|
|
- @pytest.mark.asyncio
|
|
|
- async def test_update_command_triggers_perform_update(self):
|
|
|
- config = _make_config()
|
|
|
- api = _make_api()
|
|
|
-
|
|
|
- # First heartbeat returns update command, second returns None to break
|
|
|
- call_count = 0
|
|
|
-
|
|
|
- async def mock_heartbeat(*args, **kwargs):
|
|
|
- nonlocal call_count
|
|
|
- call_count += 1
|
|
|
- if call_count == 1:
|
|
|
- return {"pending_command": "update"}
|
|
|
- return None
|
|
|
-
|
|
|
- api.heartbeat = mock_heartbeat
|
|
|
-
|
|
|
- display = MagicMock()
|
|
|
- display.set_brightness = MagicMock()
|
|
|
- display.set_blank_timeout = MagicMock()
|
|
|
- display.tick = MagicMock()
|
|
|
-
|
|
|
- shared = {"nfc": None, "scale": None, "display": display}
|
|
|
-
|
|
|
- with patch("daemon.main._perform_update", new_callable=AsyncMock) as mock_update:
|
|
|
- # Run for 2 iterations then cancel
|
|
|
- task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
|
|
|
- await asyncio.sleep(0.1)
|
|
|
- task.cancel()
|
|
|
- try:
|
|
|
- await task
|
|
|
- except asyncio.CancelledError:
|
|
|
- pass
|
|
|
-
|
|
|
- mock_update.assert_awaited_once_with(config, api)
|
|
|
-
|
|
|
- @pytest.mark.asyncio
|
|
|
- async def test_update_command_reports_error_on_exception(self):
|
|
|
- config = _make_config()
|
|
|
- api = _make_api()
|
|
|
-
|
|
|
- call_count = 0
|
|
|
-
|
|
|
- async def mock_heartbeat(*args, **kwargs):
|
|
|
- nonlocal call_count
|
|
|
- call_count += 1
|
|
|
- if call_count == 1:
|
|
|
- return {"pending_command": "update"}
|
|
|
- return None
|
|
|
-
|
|
|
- api.heartbeat = mock_heartbeat
|
|
|
-
|
|
|
- display = MagicMock()
|
|
|
- display.tick = MagicMock()
|
|
|
- shared = {"nfc": None, "scale": None, "display": display}
|
|
|
-
|
|
|
- with patch("daemon.main._perform_update", new_callable=AsyncMock, side_effect=RuntimeError("boom")):
|
|
|
- task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
|
|
|
- await asyncio.sleep(0.1)
|
|
|
- task.cancel()
|
|
|
- try:
|
|
|
- await task
|
|
|
- except asyncio.CancelledError:
|
|
|
- pass
|
|
|
-
|
|
|
- api.report_update_status.assert_awaited()
|
|
|
- error_call = api.report_update_status.call_args
|
|
|
- assert error_call[0][1] == "error"
|
|
|
-
|
|
|
@pytest.mark.asyncio
|
|
|
async def test_tare_command_executes_scale_tare(self):
|
|
|
config = _make_config()
|
|
|
@@ -379,3 +210,113 @@ class TestHeartbeatLoopCommands:
|
|
|
assert config.tare_offset == 200
|
|
|
assert config.calibration_factor == 1.05
|
|
|
scale.update_calibration.assert_called_with(200, 1.05)
|
|
|
+
|
|
|
+
|
|
|
+class TestScalePollLoopWakeGating:
|
|
|
+ """Regression tests for the wake-from-scale-noise bug.
|
|
|
+
|
|
|
+ A noisy load cell that bounces by ≥50g around its midpoint used to fire
|
|
|
+ display.wake() on every bounce because the threshold check ran against
|
|
|
+ `last_wake_grams` which itself advanced to noisy values. The fix gates
|
|
|
+ wake on the scale's `stable` flag so noise can't trigger wake AND
|
|
|
+ last_wake_grams only advances to settled readings.
|
|
|
+ """
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _make_scale(readings):
|
|
|
+ """Build a scale mock whose .read() yields the given readings then None forever."""
|
|
|
+ scale = MagicMock()
|
|
|
+ scale.ok = True
|
|
|
+ seq = list(readings)
|
|
|
+
|
|
|
+ def _read():
|
|
|
+ if seq:
|
|
|
+ return seq.pop(0)
|
|
|
+ return None
|
|
|
+
|
|
|
+ scale.read = _read
|
|
|
+ return scale
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ async def _run_loop(scale, display, *, iterations: int):
|
|
|
+ config = _make_config(scale_read_interval=0.0, scale_report_interval=0.0)
|
|
|
+ api = AsyncMock()
|
|
|
+ api.scale_reading = AsyncMock(return_value=None)
|
|
|
+ shared = {"scale": scale, "display": display}
|
|
|
+
|
|
|
+ # Bypass the real threadpool — call scale.read inline so each loop
|
|
|
+ # iteration consumes exactly one canned reading without races.
|
|
|
+ async def _inline_to_thread(fn, *args, **kwargs):
|
|
|
+ return fn(*args, **kwargs)
|
|
|
+
|
|
|
+ with patch("daemon.main.asyncio.to_thread", _inline_to_thread):
|
|
|
+ task = asyncio.create_task(scale_poll_loop(config, api, shared))
|
|
|
+ for _ in range(iterations):
|
|
|
+ await asyncio.sleep(0)
|
|
|
+ task.cancel()
|
|
|
+ try:
|
|
|
+ await task
|
|
|
+ except asyncio.CancelledError:
|
|
|
+ pass
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_unstable_noise_above_threshold_does_not_wake(self):
|
|
|
+ """A noisy load cell bouncing ±60g must NOT fire wake repeatedly."""
|
|
|
+ # All readings are unstable (stable=False) — typical of an unsettled
|
|
|
+ # load cell. Each crosses the 50g threshold from the previous value.
|
|
|
+ readings = [
|
|
|
+ (100.0, False, 1000),
|
|
|
+ (160.0, False, 1100), # +60g, unstable
|
|
|
+ (95.0, False, 990), # -65g, unstable
|
|
|
+ (155.0, False, 1080), # +60g, unstable
|
|
|
+ (90.0, False, 970), # -65g, unstable
|
|
|
+ ]
|
|
|
+ scale = self._make_scale(readings)
|
|
|
+ display = MagicMock()
|
|
|
+
|
|
|
+ await self._run_loop(scale, display, iterations=20)
|
|
|
+
|
|
|
+ display.wake.assert_not_called()
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_stable_large_change_wakes(self):
|
|
|
+ """A real spool placement (settled reading >50g from baseline) wakes."""
|
|
|
+ # First a settled baseline at 0g, then a settled new reading at 250g.
|
|
|
+ readings = [
|
|
|
+ (0.0, True, 100), # baseline stable
|
|
|
+ (250.0, True, 5000), # spool placed, settled
|
|
|
+ ]
|
|
|
+ scale = self._make_scale(readings)
|
|
|
+ display = MagicMock()
|
|
|
+
|
|
|
+ await self._run_loop(scale, display, iterations=20)
|
|
|
+
|
|
|
+ # Wake should fire exactly twice: once on first stable reading
|
|
|
+ # (last_wake_grams was None) and once on the >50g stable change.
|
|
|
+ assert display.wake.call_count == 2
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_noise_then_settled_wakes_once(self):
|
|
|
+ """Noise that briefly exceeds threshold must not bump last_wake_grams.
|
|
|
+
|
|
|
+ After noise stops and the scale settles at the original baseline,
|
|
|
+ the next stable reading at a real new value (>50g away) should still
|
|
|
+ wake — proving last_wake_grams wasn't poisoned by the noise.
|
|
|
+ """
|
|
|
+ readings = [
|
|
|
+ (0.0, True, 100), # initial settled — first wake (None → 0)
|
|
|
+ (75.0, False, 1500), # noise spike, unstable, ignored
|
|
|
+ (-50.0, False, -800), # noise dip, unstable, ignored
|
|
|
+ (80.0, False, 1600), # noise spike, unstable, ignored
|
|
|
+ (200.0, True, 4000), # spool placed, settled — should wake (>50g from 0)
|
|
|
+ ]
|
|
|
+ scale = self._make_scale(readings)
|
|
|
+ display = MagicMock()
|
|
|
+
|
|
|
+ await self._run_loop(scale, display, iterations=30)
|
|
|
+
|
|
|
+ # Two stable wake events: initial baseline + real spool placement.
|
|
|
+ # If last_wake_grams had advanced to 80 during noise, the 200g jump
|
|
|
+ # would still wake (delta 120 > 50), so this asserts both gating AND
|
|
|
+ # the absence of poisoning.
|
|
|
+ assert display.wake.call_count == 2
|