197 lines
8.3 KiB
Python
197 lines
8.3 KiB
Python
"""Модельные проверки полного контракта JSON backup без реальной SD-карты."""
|
|
|
|
import json
|
|
import unittest
|
|
import zlib
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import PurePosixPath
|
|
|
|
|
|
MONTHS = (
|
|
"January", "February", "March", "April", "May", "June",
|
|
"July", "August", "September", "October", "November", "December",
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Snapshot:
|
|
"""Подтверждённый снимок модели соответствует metadata C-ядра."""
|
|
|
|
revision: int
|
|
payload: bytes
|
|
rtc: datetime | None
|
|
|
|
@property
|
|
def crc(self) -> int:
|
|
"""CRC моделирует стабильный content hash без календаря."""
|
|
return zlib.crc32(self.payload) & 0xFFFFFFFF
|
|
|
|
|
|
class FakeCard:
|
|
"""In-memory FAT-модель фиксирует атомарность, коллизии и отказы."""
|
|
|
|
def __init__(self) -> None:
|
|
self.files: dict[str, bytes] = {}
|
|
self.ready = True
|
|
self.fail_step: str | None = None
|
|
|
|
def resolve(self, snapshot: Snapshot) -> tuple[str, str]:
|
|
"""Выбирает календарное либо честно помеченное fallback-имя."""
|
|
if snapshot.rtc:
|
|
month = f"{snapshot.rtc:%Y-%m}_{MONTHS[snapshot.rtc.month - 1]}"
|
|
stem = f"settings_{snapshot.rtc:%Y-%m-%d_%H-%M-%S}"
|
|
else:
|
|
month = "invalid_rtc"
|
|
stem = f"settings_invalid_rtc_rev_{snapshot.revision}"
|
|
root = PurePosixPath("niceOne/settings") / month
|
|
suffix = 0
|
|
while True:
|
|
marker = "" if suffix == 0 else f"_{suffix}"
|
|
final = str(root / f"{stem}{marker}.json")
|
|
temporary = str(root / f"{stem}{marker}.tmp")
|
|
if final not in self.files and temporary not in self.files:
|
|
return final, temporary
|
|
suffix += 1
|
|
|
|
def atomic_write(self, temporary: str, final: str, content: bytes) -> bool:
|
|
"""Сбой любого этапа никогда не изменяет старый final JSON."""
|
|
if not self.ready or self.fail_step == "open":
|
|
return False
|
|
self.files[temporary] = b""
|
|
if self.fail_step == "write":
|
|
return False
|
|
self.files[temporary] = content
|
|
if self.fail_step in {"sync", "close", "rename"}:
|
|
return False
|
|
self.files[final] = self.files.pop(temporary)
|
|
return True
|
|
|
|
|
|
def document(snapshot: Snapshot) -> bytes:
|
|
"""Минимальная schema-модель проверяет UTF-8 и обязательные разделы."""
|
|
rooms = [
|
|
{
|
|
"room": room,
|
|
"setpoint_x10": 200,
|
|
"hysteresis_x10": 10,
|
|
"calibration": {"start_position_pct": 0, "full_open_time_100ms": 300},
|
|
"valve": {"target_position_pct": 50, "confirmed_position_pct": 50},
|
|
}
|
|
for room in range(1, 33)
|
|
]
|
|
sensors = [{"rom": "280102030405069E", "room": 1}]
|
|
return (json.dumps(
|
|
{
|
|
"schema": "niceOne.settings",
|
|
"schema_version": 1,
|
|
"snapshot_revision": snapshot.revision,
|
|
"snapshot_crc32": f"{snapshot.crc:08X}",
|
|
"rtc": {"valid": snapshot.rtc is not None,
|
|
"datetime": snapshot.rtc.isoformat() if snapshot.rtc else None},
|
|
"storage": {"requested_backend": "internal_flash",
|
|
"active_backend": "internal_flash"},
|
|
"units": {"temperature": "0.1_degC", "position": "percent"},
|
|
"rooms": rooms,
|
|
"sensors": sensors,
|
|
"restore_policy": "manual_validation_only",
|
|
}, ensure_ascii=False, indent=2,
|
|
) + "\n").encode("utf-8")
|
|
|
|
|
|
class SettingsBackupModelTests(unittest.TestCase):
|
|
"""Каждый тест соответствует одному или нескольким пунктам task.MD."""
|
|
|
|
def test_calendar_tree_and_fat_safe_english_month(self) -> None:
|
|
card = FakeCard()
|
|
final, temporary = card.resolve(Snapshot(7, b"a", datetime(2026, 7, 17, 14, 30, 5)))
|
|
self.assertEqual(final, "niceOne/settings/2026-07_July/settings_2026-07-17_14-30-05.json")
|
|
self.assertTrue(temporary.endswith(".tmp"))
|
|
self.assertNotIn(":", final)
|
|
|
|
def test_json_schema_contains_all_32_rooms_full_rom_and_units(self) -> None:
|
|
snapshot = Snapshot(9, b"confirmed", datetime(2026, 12, 31, 23, 59, 59))
|
|
parsed = json.loads(document(snapshot).decode("utf-8"))
|
|
self.assertEqual(parsed["schema_version"], 1)
|
|
self.assertEqual(len(parsed["rooms"]), 32)
|
|
self.assertEqual(len(parsed["sensors"][0]["rom"]), 16)
|
|
self.assertIn("target_position_pct", parsed["rooms"][0]["valve"])
|
|
self.assertIn("confirmed_position_pct", parsed["rooms"][0]["valve"])
|
|
self.assertEqual(parsed["restore_policy"], "manual_validation_only")
|
|
|
|
def test_hash_excludes_rtc_and_prevents_restart_duplicate(self) -> None:
|
|
first = Snapshot(12, b"same settings", datetime(2026, 7, 1))
|
|
restart = Snapshot(13, b"same settings", datetime(2026, 8, 1))
|
|
self.assertEqual(first.crc, restart.crc)
|
|
identities = {first.crc}
|
|
self.assertIn(restart.crc, identities)
|
|
|
|
def test_confirmed_revision_change_creates_copy_not_periodic_tick(self) -> None:
|
|
revisions = [4, 4, 4, 5, 5]
|
|
writes = []
|
|
previous = None
|
|
for revision in revisions:
|
|
if revision != previous:
|
|
writes.append(revision)
|
|
previous = revision
|
|
self.assertEqual(writes, [4, 5])
|
|
|
|
def test_collision_survives_restart_and_checks_temp_too(self) -> None:
|
|
card = FakeCard()
|
|
snapshot = Snapshot(3, b"x", datetime(2026, 1, 2, 3, 4, 5))
|
|
final, temporary = card.resolve(snapshot)
|
|
card.files[final] = b"old"
|
|
card.files[temporary] = b"interrupted"
|
|
next_final, next_temporary = card.resolve(snapshot)
|
|
self.assertTrue(next_final.endswith("_1.json"))
|
|
self.assertTrue(next_temporary.endswith("_1.tmp"))
|
|
|
|
def test_atomic_failures_preserve_last_good(self) -> None:
|
|
for failure in ("open", "write", "sync", "close", "rename"):
|
|
with self.subTest(failure=failure):
|
|
card = FakeCard()
|
|
card.fail_step = failure
|
|
card.files["last.json"] = b"valid"
|
|
final, temporary = card.resolve(Snapshot(2, b"new", None))
|
|
self.assertFalse(card.atomic_write(temporary, final, b"new"))
|
|
self.assertEqual(card.files["last.json"], b"valid")
|
|
self.assertNotIn(final, card.files)
|
|
|
|
def test_absent_removed_or_full_card_is_non_destructive(self) -> None:
|
|
for failure in (False, "open", "write"):
|
|
with self.subTest(failure=failure):
|
|
card = FakeCard()
|
|
card.ready = bool(failure)
|
|
card.fail_step = failure if isinstance(failure, str) else None
|
|
card.files["last.json"] = b"valid"
|
|
final, temporary = card.resolve(Snapshot(2, b"new", None))
|
|
self.assertFalse(card.atomic_write(temporary, final, b"new"))
|
|
self.assertEqual(card.files["last.json"], b"valid")
|
|
|
|
def test_invalid_rtc_uses_revision_and_never_fake_date(self) -> None:
|
|
final, _ = FakeCard().resolve(Snapshot(0x1234, b"x", None))
|
|
self.assertIn("invalid_rtc", final)
|
|
self.assertIn("rev_4660", final)
|
|
self.assertNotRegex(final, r"20\d\d-\d\d-\d\d")
|
|
|
|
def test_retention_prefers_old_duplicate_and_keeps_one_good(self) -> None:
|
|
entries = [
|
|
("2026-01/a.json", 1), ("2026-02/b.json", 1),
|
|
("2026-03/c.json", 2),
|
|
]
|
|
duplicates = [path for path, crc in entries if sum(v == crc for _, v in entries) > 1]
|
|
victim = min(duplicates)
|
|
remaining = [entry for entry in entries if entry[0] != victim]
|
|
self.assertEqual(victim, "2026-01/a.json")
|
|
self.assertGreaterEqual(len(remaining), 1)
|
|
|
|
def test_restore_is_never_automatic_and_secrets_are_absent(self) -> None:
|
|
text = document(Snapshot(1, b"x", None)).decode("utf-8")
|
|
self.assertIn('"restore_policy": "manual_validation_only"', text)
|
|
for forbidden in ("password", "secret", "unused_flash", "pending_gui"):
|
|
self.assertNotIn(forbidden, text)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|