2025-10-28 02:50:31 +01:00
|
|
|
import json
|
|
|
|
|
import shutil
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from akkudoktoreos.config import configmigrate
|
|
|
|
|
from akkudoktoreos.config.config import ConfigEOS, SettingsEOSDefaults
|
|
|
|
|
from akkudoktoreos.core.version import __version__
|
|
|
|
|
|
|
|
|
|
# Test data directory and known migration pairs
|
|
|
|
|
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
|
|
|
|
|
|
|
|
|
|
MIGRATION_PAIRS = [
|
|
|
|
|
(
|
|
|
|
|
DIR_TESTDATA / "eos_config_minimal_0_1_0.json",
|
|
|
|
|
DIR_TESTDATA / "eos_config_minimal_now.json",
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
DIR_TESTDATA / "eos_config_andreas_0_1_0.json",
|
|
|
|
|
DIR_TESTDATA / "eos_config_andreas_now.json",
|
|
|
|
|
),
|
2026-03-07 14:46:30 +01:00
|
|
|
(
|
|
|
|
|
DIR_TESTDATA / "eos_config_unstripped.json",
|
|
|
|
|
DIR_TESTDATA / "eos_config_stripped.json",
|
|
|
|
|
),
|
2025-10-28 02:50:31 +01:00
|
|
|
# Add more pairs here:
|
|
|
|
|
# (DIR_TESTDATA / "old_config_X.json", DIR_TESTDATA / "expected_config_X.json"),
|
|
|
|
|
]
|
|
|
|
|
|
2026-02-22 14:12:42 +01:00
|
|
|
# Any sentinel in expected data
|
|
|
|
|
_ANY_SENTINEL = "__ANY__"
|
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
|
|
|
|
|
def _dict_contains(superset: Any, subset: Any, path="") -> list[str]:
|
|
|
|
|
"""Recursively verify that all key-value pairs from a subset dictionary or list exist in a superset.
|
|
|
|
|
|
|
|
|
|
Supports nested dictionaries and lists. Extra keys in superset are allowed.
|
|
|
|
|
Numeric values (int/float) are compared with tolerance.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
superset (Any): The dictionary or list that should contain all items from `subset`.
|
|
|
|
|
subset (Any): The expected dictionary or list.
|
|
|
|
|
path (str, optional): Current nested path used for error reporting. Defaults to "".
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
list[str]: A list of strings describing mismatches or missing keys. Empty list if all subset items are present.
|
|
|
|
|
"""
|
|
|
|
|
errors = []
|
|
|
|
|
|
|
|
|
|
if isinstance(subset, dict) and isinstance(superset, dict):
|
|
|
|
|
for key, sub_value in subset.items():
|
|
|
|
|
full_path = f"{path}/{key}" if path else key
|
|
|
|
|
if key not in superset:
|
|
|
|
|
errors.append(f"Missing key: {full_path}")
|
|
|
|
|
continue
|
|
|
|
|
errors.extend(_dict_contains(superset[key], sub_value, full_path))
|
|
|
|
|
|
|
|
|
|
elif isinstance(subset, list) and isinstance(superset, list):
|
|
|
|
|
for i, elem in enumerate(subset):
|
|
|
|
|
if i >= len(superset):
|
|
|
|
|
full_path = f"{path}[{i}]" if path else f"[{i}]"
|
|
|
|
|
errors.append(f"List too short at {full_path}: expected element {elem}")
|
|
|
|
|
continue
|
|
|
|
|
errors.extend(_dict_contains(superset[i], elem, f"{path}[{i}]" if path else f"[{i}]"))
|
|
|
|
|
|
|
|
|
|
else:
|
2026-02-22 14:12:42 +01:00
|
|
|
# "__ANY__" in expected means "accept whatever value the migration produces"
|
|
|
|
|
if subset == _ANY_SENTINEL:
|
|
|
|
|
return errors
|
2025-10-28 02:50:31 +01:00
|
|
|
# Compare values (with numeric tolerance)
|
|
|
|
|
if isinstance(subset, (int, float)) and isinstance(superset, (int, float)):
|
|
|
|
|
if abs(float(subset) - float(superset)) > 1e-6:
|
|
|
|
|
errors.append(f"Value mismatch at {path}: expected {subset}, got {superset}")
|
|
|
|
|
elif subset != superset:
|
|
|
|
|
errors.append(f"Value mismatch at {path}: expected {subset}, got {superset}")
|
|
|
|
|
|
|
|
|
|
return errors
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestConfigMigration:
|
|
|
|
|
"""Tests for migrate_config_file()"""
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def tmp_config_file(self, config_default_dirs) -> Path:
|
|
|
|
|
"""Create a temporary valid config file with an invalid version."""
|
|
|
|
|
config_default_dir_user, _, _, _ = config_default_dirs
|
|
|
|
|
config_file_user = config_default_dir_user.joinpath(ConfigEOS.CONFIG_FILE_NAME)
|
|
|
|
|
|
|
|
|
|
# Create a default config object (simulates the latest schema)
|
|
|
|
|
default_config = SettingsEOSDefaults()
|
|
|
|
|
|
|
|
|
|
# Dump to JSON
|
|
|
|
|
config_json = json.loads(default_config.model_dump_json())
|
|
|
|
|
|
|
|
|
|
# Corrupt the version (simulate outdated config)
|
|
|
|
|
config_json["general"]["version"] = "0.0.0-old"
|
|
|
|
|
|
|
|
|
|
# Write file
|
|
|
|
|
with config_file_user.open("w", encoding="utf-8") as f:
|
|
|
|
|
json.dump(config_json, f, indent=4)
|
|
|
|
|
|
|
|
|
|
return config_file_user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_migrate_config_file_from_invalid_version(self, tmp_config_file: Path):
|
|
|
|
|
"""Test that migration updates an outdated config version successfully."""
|
|
|
|
|
backup_file = tmp_config_file.with_suffix(".bak")
|
|
|
|
|
|
|
|
|
|
# Run migration
|
|
|
|
|
result = configmigrate.migrate_config_file(tmp_config_file, backup_file)
|
|
|
|
|
|
|
|
|
|
# Verify success
|
|
|
|
|
assert result is True, "Migration should succeed even from invalid version."
|
|
|
|
|
|
|
|
|
|
# Verify backup exists
|
|
|
|
|
assert backup_file.exists(), "Backup file should be created before migration."
|
|
|
|
|
|
|
|
|
|
# Verify version updated
|
|
|
|
|
with tmp_config_file.open("r", encoding="utf-8") as f:
|
|
|
|
|
migrated_data = json.load(f)
|
|
|
|
|
assert migrated_data["general"]["version"] == __version__, \
|
|
|
|
|
"Migrated config should have updated version."
|
|
|
|
|
|
|
|
|
|
# Verify it still matches the structure of SettingsEOSDefaults
|
|
|
|
|
new_model = SettingsEOSDefaults(**migrated_data)
|
|
|
|
|
assert isinstance(new_model, SettingsEOSDefaults)
|
|
|
|
|
|
2026-03-07 14:46:30 +01:00
|
|
|
def test_migrate_config_file_already_current(self, tmp_config_file: Path):
|
2025-10-28 02:50:31 +01:00
|
|
|
"""Test that a current config file returns True immediately."""
|
2026-03-07 14:46:30 +01:00
|
|
|
backup_file = tmp_config_file.with_suffix(".bak")
|
2025-10-28 02:50:31 +01:00
|
|
|
|
2026-03-07 14:46:30 +01:00
|
|
|
# Run migration
|
|
|
|
|
result = configmigrate.migrate_config_file(tmp_config_file, backup_file)
|
|
|
|
|
assert result is True, "Migration should succeed even from invalid version."
|
2025-10-28 02:50:31 +01:00
|
|
|
|
2026-03-07 14:46:30 +01:00
|
|
|
backup_file.unlink()
|
|
|
|
|
assert not backup_file.exists()
|
|
|
|
|
|
|
|
|
|
result = configmigrate.migrate_config_file(tmp_config_file, backup_file)
|
2025-10-28 02:50:31 +01:00
|
|
|
assert result is True
|
|
|
|
|
assert not backup_file.exists(), "No backup should be made if config is already current."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("old_file, expected_file", MIGRATION_PAIRS)
|
|
|
|
|
def test_migrate_old_version_config(self, tmp_path: Path, old_file: Path, expected_file: Path):
|
|
|
|
|
"""Ensure migration from old → new schema produces the expected output."""
|
|
|
|
|
# --- Prepare temporary working file based on expected file name ---
|
|
|
|
|
working_file = expected_file.with_suffix(".new")
|
|
|
|
|
shutil.copy(old_file, working_file)
|
|
|
|
|
|
|
|
|
|
# Backup file path (inside tmp_path to avoid touching repo files)
|
|
|
|
|
backup_file = tmp_path / f"{old_file.stem}.bak"
|
|
|
|
|
|
|
|
|
|
failed = False
|
|
|
|
|
try:
|
|
|
|
|
assert working_file.exists(), f"Working config file is missing: {working_file}"
|
|
|
|
|
|
|
|
|
|
# --- Perform migration ---
|
|
|
|
|
result = configmigrate.migrate_config_file(working_file, backup_file)
|
|
|
|
|
|
|
|
|
|
# --- Assertions ---
|
|
|
|
|
assert result is True, f"Migration failed for {old_file.name}"
|
|
|
|
|
|
|
|
|
|
assert configmigrate.mapped_count >= 1, f"No mapped migrations for {old_file.name}"
|
|
|
|
|
assert configmigrate.auto_count >= 1, f"No automatic migrations for {old_file.name}"
|
|
|
|
|
|
2025-11-01 00:49:11 +01:00
|
|
|
assert len(configmigrate.skipped_paths) <= 3, (
|
2025-10-28 02:50:31 +01:00
|
|
|
f"Too many skipped paths in {old_file.name}: {configmigrate.skipped_paths}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert backup_file.exists(), f"Backup file not created for {old_file.name}"
|
|
|
|
|
|
|
|
|
|
# --- Compare migrated result with expected output ---
|
2026-02-22 14:12:42 +01:00
|
|
|
old_data = json.loads(old_file.read_text(encoding="utf-8"))
|
2025-10-28 02:50:31 +01:00
|
|
|
new_data = json.loads(working_file.read_text(encoding="utf-8"))
|
|
|
|
|
expected_data = json.loads(expected_file.read_text(encoding="utf-8"))
|
|
|
|
|
|
|
|
|
|
# Check version
|
|
|
|
|
assert new_data["general"]["version"] == __version__, (
|
|
|
|
|
f"Expected version {__version__}, got {new_data['general']['version']}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Recursive subset comparison
|
|
|
|
|
errors = _dict_contains(new_data, expected_data)
|
|
|
|
|
assert not errors, (
|
|
|
|
|
f"Migrated config for {old_file.name} is missing or mismatched fields:\n" +
|
2025-11-01 00:49:11 +01:00
|
|
|
"\n".join(errors) + f"\n{new_data}"
|
2025-10-28 02:50:31 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# --- Compare migrated result with migration map ---
|
|
|
|
|
# Ensure all expected mapped fields are actually migrated and correct
|
|
|
|
|
missing_migrations = []
|
|
|
|
|
mismatched_values = []
|
|
|
|
|
|
|
|
|
|
for old_path, mapping in configmigrate.MIGRATION_MAP.items():
|
|
|
|
|
if mapping is None:
|
|
|
|
|
continue # skip intentionally dropped fields
|
|
|
|
|
|
|
|
|
|
# Determine new path (string or tuple)
|
|
|
|
|
new_path = mapping[0] if isinstance(mapping, tuple) else mapping
|
|
|
|
|
|
|
|
|
|
# Get value from expected data (if present)
|
|
|
|
|
expected_value = configmigrate._get_json_nested_value(expected_data, new_path)
|
|
|
|
|
if expected_value is None:
|
|
|
|
|
continue # new field not present in expected config
|
|
|
|
|
|
|
|
|
|
# Check that migration recorded this old path
|
|
|
|
|
if old_path.strip("/") not in configmigrate.migrated_source_paths:
|
|
|
|
|
missing_migrations.append(f"{old_path} → {new_path}")
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Verify the migrated value matches the expected one
|
|
|
|
|
new_value = configmigrate._get_json_nested_value(new_data, new_path)
|
|
|
|
|
if new_value != expected_value:
|
2026-02-22 14:12:42 +01:00
|
|
|
# Check if this mapping uses _KEEP_DEFAULT and the old value was None/missing
|
|
|
|
|
old_value = configmigrate._get_json_nested_value(old_data, old_path)
|
|
|
|
|
keep_default = (
|
|
|
|
|
isinstance(mapping, tuple)
|
|
|
|
|
and configmigrate._KEEP_DEFAULT in mapping
|
|
|
|
|
)
|
|
|
|
|
if keep_default and old_value is None:
|
|
|
|
|
continue # acceptable: old was None, new model keeps its default
|
2025-10-28 02:50:31 +01:00
|
|
|
mismatched_values.append(
|
|
|
|
|
f"{old_path} → {new_path}: expected {expected_value!r}, got {new_value!r}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert not missing_migrations, (
|
|
|
|
|
"Some expected migration map entries were not migrated:\n"
|
|
|
|
|
+ "\n".join(missing_migrations)
|
|
|
|
|
)
|
|
|
|
|
assert not mismatched_values, (
|
|
|
|
|
"Migrated values differ from expected results:\n"
|
|
|
|
|
+ "\n".join(mismatched_values)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Validate migrated config with schema
|
|
|
|
|
new_model = SettingsEOSDefaults(**new_data)
|
|
|
|
|
assert isinstance(new_model, SettingsEOSDefaults)
|
|
|
|
|
|
|
|
|
|
except Exception:
|
|
|
|
|
# mark failure and re-raise so pytest records the error and the working_file is kept
|
|
|
|
|
failed = True
|
|
|
|
|
raise
|
|
|
|
|
finally:
|
|
|
|
|
# Remove the .new working file only if the test passed (failed == False)
|
|
|
|
|
if not failed and working_file.exists():
|
|
|
|
|
working_file.unlink(missing_ok=True)
|