"""Модельные проверки полного контракта 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()