from __future__ import annotations import json import pytest from scripts.migrate_state_root import ( STAGING_NAME, StateRootMigrationError, build_manifest, migrate_state_root, ) def seed_legacy_state(root): (root / "templates" / "records").mkdir(parents=True) (root / "templates" / "thumbnails").mkdir(parents=True) (root / "future-user-data").mkdir() (root / "runtime").mkdir() (root / ".portrait.tmp").mkdir() (root / "config.json").write_text( json.dumps({"brightness": 37, "voltage_calibration_factor": 1.01}), encoding="utf-8", ) (root / "templates" / "records" / "template.json").write_text("{}", encoding="utf-8") (root / "templates" / "thumbnails" / "template.png").write_bytes(b"png") (root / "future-user-data" / "keep.bin").write_bytes(b"keep") (root / "last_frame.png").write_bytes(b"runtime") (root / "startup_indicator.json").write_text("{}", encoding="utf-8") (root / "runtime" / "last_frame.png").write_bytes(b"new-runtime") (root / ".portrait.tmp" / "keep.bin").write_bytes(b"keep") (root / "config.json.tmp").write_text("partial", encoding="utf-8") (root / "templates" / "records" / ".record.json.0123456789abcdef0123456789abcdef.tmp").write_text( "partial", encoding="utf-8", ) def test_migration_preserves_persistent_unknown_files_and_keeps_source(tmp_path): source = tmp_path / "opt" / "data" target = tmp_path / "var" / "lib" / "matrix-screen-controller" seed_legacy_state(source) result = migrate_state_root(source, target) assert result.status == "migrated" assert source.is_dir() assert (source / "last_frame.png").exists() assert (target / "config.json").exists() assert (target / "templates" / "records" / "template.json").exists() assert (target / "future-user-data" / "keep.bin").read_bytes() == b"keep" assert not (target / "last_frame.png").exists() assert not (target / "startup_indicator.json").exists() assert not (target / "runtime").exists() assert (target / ".portrait.tmp" / "keep.bin").read_bytes() == b"keep" assert not (target / "config.json.tmp").exists() assert not ( target / "templates" / "records" / ".record.json.0123456789abcdef0123456789abcdef.tmp" ).exists() assert build_manifest(source) == build_manifest(target) def test_migration_is_idempotent_when_target_matches(tmp_path): source = tmp_path / "legacy" target = tmp_path / "state" seed_legacy_state(source) migrate_state_root(source, target) second = migrate_state_root(source, target) assert second.status == "already-prepared" assert not (target.parent / STAGING_NAME).exists() def test_migration_refuses_different_nonempty_target_without_modifying_it(tmp_path): source = tmp_path / "legacy" target = tmp_path / "state" seed_legacy_state(source) target.mkdir() existing = target / "config.json" existing.write_text('{"brightness": 99}', encoding="utf-8") before = existing.read_bytes() with pytest.raises(StateRootMigrationError, match="refusing to merge"): migrate_state_root(source, target) assert existing.read_bytes() == before assert not (target.parent / STAGING_NAME).exists() def test_missing_source_keeps_current_target_untouched(tmp_path): source = tmp_path / "missing" target = tmp_path / "state" target.mkdir() config = target / "config.json" config.write_text('{"schema_version": 1}', encoding="utf-8") result = migrate_state_root(source, target) assert result.status == "target-only" assert config.read_text(encoding="utf-8") == '{"schema_version": 1}' def test_migration_refuses_overlapping_state_roots(tmp_path): source = tmp_path / "legacy" seed_legacy_state(source) with pytest.raises(StateRootMigrationError, match="must not contain"): migrate_state_root(source, source / "new-state") def test_missing_source_and_empty_target_fail_closed(tmp_path): source = tmp_path / "missing" target = tmp_path / "empty" target.mkdir() with pytest.raises(StateRootMigrationError, match="no persistent files"): migrate_state_root(source, target) def test_empty_legacy_source_fails_closed(tmp_path): source = tmp_path / "empty-source" source.mkdir() with pytest.raises(StateRootMigrationError, match="source contains no persistent files"): migrate_state_root(source, tmp_path / "target") def test_state_root_symlink_is_rejected(tmp_path): real_source = tmp_path / "real-source" seed_legacy_state(real_source) source_link = tmp_path / "source-link" try: source_link.symlink_to(real_source, target_is_directory=True) except OSError: pytest.skip("directory symlinks are unavailable on this host") with pytest.raises(StateRootMigrationError, match="must not be symlinks"): migrate_state_root(source_link, tmp_path / "target")