mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 13:07:32 +01:00
The alarm was opt-in: it only armed when the user tapped "Set Alarm & Sync to PC", so it could be defeated by simply not tapping it, or by cancelling it the night before. It is now automatic and there is no lever a half-asleep user can pull that buys them sleep. - AlarmPolicy bakes the schedule in at 08:00 daily. The only edit the UI offers is moving the NEXT alarm earlier; setEarlier() refuses anything at or after the baseline, and refuses to relax an active override. Earlier-only is the whole design: every available change costs the user sleep. - The ratchet resets after each fire, so an early start is one-shot. A one-way ratchet with no reset is a trap, not a defence — the first 05:00 would otherwise be permanent with no way back short of reinstalling. - load() re-validates the stored value and ignores anything at or after the baseline. SharedPreferences is not a trust boundary. - No cancel. The Kotlin handler is deleted, not just the Dart wrapper, so the capability genuinely does not exist rather than being one invokeMethod away. - Arming happens in main()'s init seam and via AlarmWatchdog, a daily self-rescheduling check that re-arms if nothing is scheduled — closing the gap where a force-stop or a failed re-arm goes unnoticed until the morning it does not ring. BootReceiver re-establishes it, since setInexactRepeating does not survive a reboot. - Publishing to the PC is automatic too; it used to need the same tap. PC half, so the two agree with the network down: - FALLBACK_ALARM_HOUR 13 -> 8, matching the phone's baseline. At 13:00 a Firebase outage silently moved the alarm five hours LATER — the one direction the design refuses to allow. - _trigger._clamp_to_baseline() refuses any resolved alarm later than the baseline, whatever its source. Without it the PC would faithfully reproduce a stale or tampered remote value and the forbidden direction stays reachable through sync. Tests: 465 Python at 100% branch coverage, 79 Dart. Both ratchet directions are covered, per the standing rule about monotonic counters. test_sync.py and test_trigger.py were split (payload, token, CLI, clamp) to stay under the 250-line gate; the split lost indirect coverage of extract_hlc/newest_alarm, which now have direct tests instead. `python -m wake_alarm._sync --show` and `_trigger --dry-run` both re-run and still print real output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
141 lines
4.6 KiB
Python
141 lines
4.6 KiB
Python
"""Tests for _sync.py's GitHub token discovery and its sibling fallbacks.
|
|
|
|
Split out of test_sync.py to hold both files under the 250-line gate.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from wake_alarm._sync import (
|
|
_read_token,
|
|
_read_token_fallback,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
def _log(hour: object = 7, minute: object = 30) -> str:
|
|
"""Build a crdt_sync log payload shaped like the phone's."""
|
|
return json.dumps(
|
|
{
|
|
"alarm": {
|
|
"fields": {
|
|
"alarm_hour": [hour, "hlc-1"],
|
|
"alarm_minute": [minute, "hlc-1"],
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def config_file(tmp_path: Path) -> Path:
|
|
"""Provide a temporary sync config path."""
|
|
return tmp_path / "sync.json"
|
|
|
|
|
|
@pytest.fixture
|
|
def cache_file(tmp_path: Path) -> Path:
|
|
"""Provide a temporary alarm cache path."""
|
|
return tmp_path / "cache" / "alarm_cache.json"
|
|
|
|
|
|
@pytest.fixture
|
|
def fallback_files(tmp_path: Path) -> tuple[Path, Path]:
|
|
"""Provide two temporary sibling-app token paths."""
|
|
return tmp_path / "diet_guard_token", tmp_path / "screen_locker_token"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _patch_paths(
|
|
config_file: Path, cache_file: Path, fallback_files: tuple[Path, Path]
|
|
) -> None:
|
|
"""Redirect every on-disk path into tmp_path for every test."""
|
|
with (
|
|
patch("wake_alarm._sync.SYNC_CONFIG_FILE", config_file),
|
|
patch("wake_alarm._sync.ALARM_CACHE_FILE", cache_file),
|
|
patch("wake_alarm._sync.SYNC_TOKEN_FALLBACK_FILES", fallback_files),
|
|
):
|
|
yield
|
|
|
|
|
|
class TestReadToken:
|
|
"""Tests for _read_token."""
|
|
|
|
def test_missing_file(self) -> None:
|
|
"""A missing config file yields no token."""
|
|
assert _read_token() is None
|
|
|
|
def test_malformed_json(self, config_file: Path) -> None:
|
|
"""Unparsable JSON yields no token."""
|
|
config_file.write_text("{not json")
|
|
assert _read_token() is None
|
|
|
|
def test_not_a_dict(self, config_file: Path) -> None:
|
|
"""A JSON array is not a config object."""
|
|
config_file.write_text("[]")
|
|
assert _read_token() is None
|
|
|
|
@pytest.mark.parametrize("value", [None, "", 42])
|
|
def test_unusable_token_values(self, config_file: Path, value: object) -> None:
|
|
"""Absent, empty and non-string tokens are all rejected."""
|
|
config_file.write_text(json.dumps({"token": value}))
|
|
assert _read_token() is None
|
|
|
|
def test_valid_token(self, config_file: Path) -> None:
|
|
"""A non-empty string token is returned."""
|
|
config_file.write_text(json.dumps({"token": "ghp_x"}))
|
|
assert _read_token() == "ghp_x"
|
|
|
|
|
|
class TestTokenFallback:
|
|
"""Tests for reusing a sibling companion app's token file."""
|
|
|
|
def test_no_fallback_files(self) -> None:
|
|
"""With nothing on disk anywhere there is no token."""
|
|
assert _read_token_fallback() is None
|
|
assert _read_token() is None
|
|
|
|
def test_reads_raw_single_line_token(
|
|
self, fallback_files: tuple[Path, Path]
|
|
) -> None:
|
|
"""A sibling token file is a raw PAT, not JSON — and is stripped."""
|
|
fallback_files[0].write_text(" ghp_sibling\n")
|
|
assert _read_token_fallback() == "ghp_sibling"
|
|
|
|
def test_skips_empty_file(self, fallback_files: tuple[Path, Path]) -> None:
|
|
"""An empty first file falls through to the second."""
|
|
fallback_files[0].write_text(" \n")
|
|
fallback_files[1].write_text("ghp_second")
|
|
assert _read_token_fallback() == "ghp_second"
|
|
|
|
def test_skips_unreadable_file(self, fallback_files: tuple[Path, Path]) -> None:
|
|
"""An unreadable file is skipped rather than fatal."""
|
|
fallback_files[1].write_text("ghp_second")
|
|
with patch(
|
|
"pathlib.Path.read_text",
|
|
side_effect=[OSError("denied"), "ghp_second"],
|
|
):
|
|
assert _read_token_fallback() == "ghp_second"
|
|
|
|
def test_own_config_wins(
|
|
self, config_file: Path, fallback_files: tuple[Path, Path]
|
|
) -> None:
|
|
"""wake_alarm's own config takes precedence over a sibling's."""
|
|
config_file.write_text(json.dumps({"token": "ghp_own"}))
|
|
fallback_files[0].write_text("ghp_sibling")
|
|
assert _read_token() == "ghp_own"
|
|
|
|
def test_falls_back_when_config_absent(
|
|
self, fallback_files: tuple[Path, Path]
|
|
) -> None:
|
|
"""With no own config, the sibling token is used."""
|
|
fallback_files[0].write_text("ghp_sibling")
|
|
assert _read_token() == "ghp_sibling"
|