mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 18:07:22 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
129 lines
4.8 KiB
Python
129 lines
4.8 KiB
Python
"""Tests for _decision_trail: collapsing repeats so a storm cannot wipe history.
|
|
|
|
On 2026-08-30 the locker had no X server at 02:00, crashed, was restarted by
|
|
``Restart=on-failure`` every ~6.3s, and wrote ~1693 identical records in three
|
|
hours. At ``DECISION_LOG_MAX_ENTRIES = 3000`` that evicted everything behind
|
|
them: the tool built to make blind spots impossible erased its own history.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from screen_locker._decision_trail import append_record
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
def _rows(target: Path) -> list[dict[str, object]]:
|
|
"""Return the trail as parsed records.
|
|
|
|
Args:
|
|
target: The trail file.
|
|
|
|
Returns:
|
|
One dict per line, oldest first.
|
|
"""
|
|
return [json.loads(line) for line in target.read_text().splitlines() if line]
|
|
|
|
|
|
def _record(index: int, *, reason: str = "enforced") -> dict[str, object]:
|
|
"""Build one record whose only varying field is its timestamp.
|
|
|
|
Args:
|
|
index: Distinguishes the timestamps.
|
|
reason: The decision reason.
|
|
|
|
Returns:
|
|
A record ready for append_record.
|
|
"""
|
|
return {
|
|
"timestamp": f"2026-08-30T02:{index:02d}:00+00:00",
|
|
"locked": True,
|
|
"reason": reason,
|
|
}
|
|
|
|
|
|
class TestCollapsing:
|
|
"""Consecutive identical events become one row, not N."""
|
|
|
|
def test_a_storm_of_identical_records_stays_one_row(self, tmp_path: Path) -> None:
|
|
"""The 1695-restart loop, in miniature."""
|
|
target = tmp_path / "d.jsonl"
|
|
for i in range(50):
|
|
append_record(_record(i), log_file=target)
|
|
rows = _rows(target)
|
|
assert len(rows) == 1
|
|
assert rows[0]["repeat_count"] == 50
|
|
|
|
def test_the_row_keeps_both_ends_of_the_streak(self, tmp_path: Path) -> None:
|
|
"""`timestamp` is when it began; `last_timestamp` is freshness."""
|
|
target = tmp_path / "d.jsonl"
|
|
append_record(_record(0), log_file=target)
|
|
append_record(_record(7), log_file=target)
|
|
row = _rows(target)[0]
|
|
assert row["timestamp"] == "2026-08-30T02:00:00+00:00"
|
|
assert row["last_timestamp"] == "2026-08-30T02:07:00+00:00"
|
|
|
|
def test_a_different_decision_opens_a_new_row(self, tmp_path: Path) -> None:
|
|
"""Collapsing must never hide a change of outcome."""
|
|
target = tmp_path / "d.jsonl"
|
|
append_record(_record(0), log_file=target)
|
|
append_record(_record(1, reason="weekly_minimum_met"), log_file=target)
|
|
assert len(_rows(target)) == 2
|
|
|
|
def test_only_consecutive_records_collapse(self, tmp_path: Path) -> None:
|
|
"""An intervening event ends the streak, as the history shows it must."""
|
|
target = tmp_path / "d.jsonl"
|
|
append_record(_record(0), log_file=target)
|
|
append_record(_record(1, reason="mode_makes_no_decision"), log_file=target)
|
|
append_record(_record(2), log_file=target)
|
|
rows = _rows(target)
|
|
assert len(rows) == 3
|
|
assert all("repeat_count" not in row for row in rows)
|
|
|
|
def test_a_changed_field_is_not_a_repeat(self, tmp_path: Path) -> None:
|
|
"""Everything but the timestamps must match to collapse."""
|
|
target = tmp_path / "d.jsonl"
|
|
append_record(_record(0), log_file=target)
|
|
append_record({**_record(1), "weekly_count": 4}, log_file=target)
|
|
assert len(_rows(target)) == 2
|
|
|
|
|
|
class TestCorruptTrail:
|
|
"""A trail that cannot be parsed is kept, loudly -- never overwritten."""
|
|
|
|
def test_an_unparsable_tail_starts_a_new_row(
|
|
self, tmp_path: Path, caplog: MagicMock
|
|
) -> None:
|
|
"""Overwriting a line we cannot read would destroy real history."""
|
|
target = tmp_path / "d.jsonl"
|
|
target.write_text("{truncated\n", encoding="utf-8")
|
|
append_record(_record(0), log_file=target)
|
|
lines = target.read_text().splitlines()
|
|
assert lines[0] == "{truncated"
|
|
assert len(lines) == 2
|
|
assert "unparsable" in caplog.text
|
|
|
|
def test_a_non_object_tail_starts_a_new_row(self, tmp_path: Path) -> None:
|
|
"""Valid JSON of the wrong shape is not a record to merge into."""
|
|
target = tmp_path / "d.jsonl"
|
|
target.write_text('["not", "a", "record"]\n', encoding="utf-8")
|
|
append_record(_record(0), log_file=target)
|
|
assert len(target.read_text().splitlines()) == 2
|
|
|
|
def test_a_write_failure_is_reported(
|
|
self, tmp_path: Path, caplog: MagicMock
|
|
) -> None:
|
|
"""A gap in the trail must itself be visible."""
|
|
target = tmp_path / "nested" / "d.jsonl"
|
|
with patch(
|
|
"screen_locker._decision_trail.Path.write_text",
|
|
side_effect=OSError("full"),
|
|
):
|
|
append_record(_record(0), log_file=target)
|
|
assert "durable trail now has a gap" in caplog.text
|