703 lines
27 KiB
Python
703 lines
27 KiB
Python
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import app.persistence as persistence
|
|
from app.config.store import (
|
|
COLOR_PALETTE_LIMIT,
|
|
CONFIG_SCHEMA_VERSION,
|
|
CONFIG_V1_FIELDS,
|
|
CONFIG_V2_FIELDS,
|
|
CONFIG_V3_FIELDS,
|
|
CONFIG_V4_FIELDS,
|
|
CONFIG_V5_FIELDS,
|
|
CONFIG_V6_FIELDS,
|
|
CONFIG_V7_FIELDS,
|
|
CONFIG_V8_FIELDS,
|
|
CONFIG_V9_FIELDS,
|
|
CONFIG_V10_FIELDS,
|
|
DEFAULT_ANIMATION_PREVIEW_MAX_CONCURRENT,
|
|
DEFAULT_COLOR_PALETTE,
|
|
DEFAULT_DISPLAY,
|
|
DEFAULT_WORKSPACE_ORDER,
|
|
DEFAULT_MATRIX_REFRESH_RATE_LIMIT_HZ,
|
|
DEFAULT_PREVIEW_REFRESH_INTERVAL_MS,
|
|
MATRIX_REFRESH_RATE_LIMITS,
|
|
ConfigError,
|
|
ConfigStore,
|
|
resolve_data_dir,
|
|
resolve_runtime_dir,
|
|
)
|
|
|
|
|
|
def test_config_store_creates_and_updates(tmp_path):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
|
|
assert store.config["orientation"] == 0
|
|
assert store.config["color_palette"] == DEFAULT_COLOR_PALETTE
|
|
assert store.config["voltage_calibration_factor"] == 1.0
|
|
assert store.config["voltage_calibrated_at"] is None
|
|
assert store.config["preview_refresh_interval_ms"] == DEFAULT_PREVIEW_REFRESH_INTERVAL_MS
|
|
assert store.config["low_voltage_protection_enabled"] is False
|
|
assert store.config["matrix_refresh_rate_limit_hz"] == 100
|
|
assert store.config["custom_test_color"] == "#40A0FF"
|
|
assert store.config["default_display"] == DEFAULT_DISPLAY
|
|
assert store.config["workspace_order"] == DEFAULT_WORKSPACE_ORDER
|
|
assert store.config["performance_mode_enabled"] is False
|
|
assert store.config["animation_preview_max_concurrent"] == DEFAULT_ANIMATION_PREVIEW_MAX_CONCURRENT
|
|
assert (tmp_path / "data" / "config.json").exists()
|
|
assert "schema_version" not in store.config
|
|
persisted = json.loads((tmp_path / "data" / "config.json").read_text(encoding="utf-8"))
|
|
assert persisted["schema_version"] == CONFIG_SCHEMA_VERSION
|
|
|
|
updated = store.update({"orientation": 180, "brightness": 35})
|
|
assert updated["orientation"] == 180
|
|
assert updated["brightness"] == 35
|
|
|
|
reloaded = ConfigStore(project_root=tmp_path)
|
|
assert reloaded.config["orientation"] == 180
|
|
assert reloaded.config["brightness"] == 35
|
|
|
|
|
|
def test_v1_config_migrates_preview_interval_without_losing_device_values(tmp_path):
|
|
original_store = ConfigStore(project_root=tmp_path)
|
|
original_store.update({
|
|
"orientation": 270,
|
|
"brightness": 31,
|
|
"color_palette": ["#010203", "#AABBCC"],
|
|
"voltage_calibration_factor": 1.01,
|
|
"voltage_calibrated_at": "2026-07-20T12:34:56.000Z",
|
|
"voltage_calibration_reference": 5.0,
|
|
"voltage_calibration_uncalibrated": 4.950495,
|
|
})
|
|
v1_document = json.loads(original_store.path.read_text(encoding="utf-8"))
|
|
v1_document["schema_version"] = 1
|
|
v1_document.pop("preview_refresh_interval_ms")
|
|
v1_document.pop("low_voltage_protection_enabled")
|
|
v1_document.pop("matrix_refresh_rate_limit_hz")
|
|
v1_document.pop("custom_test_color")
|
|
v1_document.pop("default_display")
|
|
v1_document.pop("workspace_order")
|
|
v1_document.pop("performance_mode_enabled")
|
|
v1_document.pop("animation_preview_max_concurrent")
|
|
original_store.path.write_text(
|
|
json.dumps(v1_document, ensure_ascii=False),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
migrated = ConfigStore(project_root=tmp_path)
|
|
persisted = json.loads(migrated.path.read_text(encoding="utf-8"))
|
|
|
|
assert migrated.config["preview_refresh_interval_ms"] == DEFAULT_PREVIEW_REFRESH_INTERVAL_MS
|
|
assert migrated.config["orientation"] == 270
|
|
assert migrated.config["brightness"] == 31
|
|
assert migrated.config["color_palette"] == ["#010203", "#AABBCC"]
|
|
assert migrated.config["voltage_calibration_factor"] == 1.01
|
|
assert migrated.config["voltage_calibrated_at"] == "2026-07-20T12:34:56.000Z"
|
|
assert persisted["schema_version"] == CONFIG_SCHEMA_VERSION
|
|
assert migrated.config["low_voltage_protection_enabled"] is False
|
|
assert migrated.config["matrix_refresh_rate_limit_hz"] == 100
|
|
|
|
|
|
def test_v2_config_migrates_protection_switch_without_losing_values(tmp_path):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
store.update({
|
|
"orientation": 90,
|
|
"brightness": 27,
|
|
"preview_refresh_interval_ms": 321,
|
|
"color_palette": ["#123456"],
|
|
})
|
|
document = json.loads(store.path.read_text(encoding="utf-8"))
|
|
document["schema_version"] = 2
|
|
document.pop("low_voltage_protection_enabled")
|
|
document.pop("matrix_refresh_rate_limit_hz")
|
|
document.pop("custom_test_color")
|
|
document.pop("default_display")
|
|
document.pop("workspace_order")
|
|
document.pop("performance_mode_enabled")
|
|
document.pop("animation_preview_max_concurrent")
|
|
store.path.write_text(json.dumps(document), encoding="utf-8")
|
|
|
|
migrated = ConfigStore(project_root=tmp_path)
|
|
|
|
assert migrated.config["low_voltage_protection_enabled"] is False
|
|
assert migrated.config["orientation"] == 90
|
|
assert migrated.config["brightness"] == 27
|
|
assert migrated.config["preview_refresh_interval_ms"] == 321
|
|
assert migrated.config["color_palette"] == ["#123456"]
|
|
assert migrated.config["matrix_refresh_rate_limit_hz"] == 100
|
|
assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == CONFIG_SCHEMA_VERSION
|
|
|
|
|
|
def test_v3_config_migrates_matrix_refresh_limit_without_losing_values(tmp_path):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
store.update({
|
|
"orientation": 180,
|
|
"brightness": 29,
|
|
"preview_refresh_interval_ms": 444,
|
|
"low_voltage_protection_enabled": True,
|
|
})
|
|
document = json.loads(store.path.read_text(encoding="utf-8"))
|
|
document["schema_version"] = 3
|
|
document.pop("matrix_refresh_rate_limit_hz")
|
|
document.pop("custom_test_color")
|
|
document.pop("default_display")
|
|
document.pop("workspace_order")
|
|
document.pop("performance_mode_enabled")
|
|
document.pop("animation_preview_max_concurrent")
|
|
store.path.write_text(json.dumps(document), encoding="utf-8")
|
|
|
|
migrated = ConfigStore(project_root=tmp_path)
|
|
|
|
assert migrated.config["matrix_refresh_rate_limit_hz"] == DEFAULT_MATRIX_REFRESH_RATE_LIMIT_HZ
|
|
assert migrated.config["orientation"] == 180
|
|
assert migrated.config["brightness"] == 29
|
|
assert migrated.config["preview_refresh_interval_ms"] == 444
|
|
assert migrated.config["low_voltage_protection_enabled"] is True
|
|
assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == CONFIG_SCHEMA_VERSION
|
|
|
|
|
|
def test_v4_config_migrates_custom_test_color_without_losing_values(tmp_path):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
store.update({"orientation": 270, "brightness": 37})
|
|
document = json.loads(store.path.read_text(encoding="utf-8"))
|
|
document["schema_version"] = 4
|
|
document.pop("custom_test_color")
|
|
document.pop("default_display")
|
|
document.pop("workspace_order")
|
|
document.pop("performance_mode_enabled")
|
|
document.pop("animation_preview_max_concurrent")
|
|
store.path.write_text(json.dumps(document), encoding="utf-8")
|
|
|
|
migrated = ConfigStore(project_root=tmp_path)
|
|
|
|
assert migrated.config["custom_test_color"] == "#40A0FF"
|
|
assert migrated.config["orientation"] == 270
|
|
assert migrated.config["brightness"] == 37
|
|
assert migrated.config["default_display"] == DEFAULT_DISPLAY
|
|
assert migrated.config["workspace_order"] == DEFAULT_WORKSPACE_ORDER
|
|
assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == CONFIG_SCHEMA_VERSION
|
|
|
|
|
|
def test_v5_config_migrates_default_display_without_losing_values(tmp_path):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
store.update({"orientation": 90, "brightness": 33, "custom_test_color": "#123456"})
|
|
document = json.loads(store.path.read_text(encoding="utf-8"))
|
|
document["schema_version"] = 5
|
|
document.pop("default_display")
|
|
document.pop("workspace_order")
|
|
document.pop("performance_mode_enabled")
|
|
document.pop("animation_preview_max_concurrent")
|
|
store.path.write_text(json.dumps(document), encoding="utf-8")
|
|
|
|
migrated = ConfigStore(project_root=tmp_path)
|
|
|
|
assert migrated.config["default_display"] == DEFAULT_DISPLAY
|
|
assert migrated.config["orientation"] == 90
|
|
assert migrated.config["brightness"] == 33
|
|
assert migrated.config["custom_test_color"] == "#123456"
|
|
assert migrated.config["workspace_order"] == DEFAULT_WORKSPACE_ORDER
|
|
assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == CONFIG_SCHEMA_VERSION
|
|
|
|
|
|
def test_v6_config_migrates_workspace_order_without_losing_values(tmp_path):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
store.update({"orientation": 180, "brightness": 26})
|
|
document = json.loads(store.path.read_text(encoding="utf-8"))
|
|
document["schema_version"] = 6
|
|
document.pop("workspace_order")
|
|
document.pop("performance_mode_enabled")
|
|
document.pop("animation_preview_max_concurrent")
|
|
store.path.write_text(json.dumps(document), encoding="utf-8")
|
|
|
|
migrated = ConfigStore(project_root=tmp_path)
|
|
|
|
assert migrated.config["workspace_order"] == DEFAULT_WORKSPACE_ORDER
|
|
assert migrated.config["orientation"] == 180
|
|
assert migrated.config["brightness"] == 26
|
|
assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == CONFIG_SCHEMA_VERSION
|
|
|
|
|
|
def test_v7_config_inserts_media_workspace_without_reordering_existing_items(tmp_path):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
document = json.loads(store.path.read_text(encoding="utf-8"))
|
|
document["schema_version"] = 7
|
|
document["workspace_order"] = ["settings", "text", "templates", "animations", "device", "canvas"]
|
|
document.pop("performance_mode_enabled")
|
|
document.pop("animation_preview_max_concurrent")
|
|
store.path.write_text(json.dumps(document), encoding="utf-8")
|
|
|
|
migrated = ConfigStore(project_root=tmp_path)
|
|
|
|
assert migrated.config["workspace_order"] == [
|
|
"settings", "templates", "media-import", "animations", "device", "canvas",
|
|
]
|
|
assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == CONFIG_SCHEMA_VERSION
|
|
|
|
|
|
def test_v8_config_migrates_performance_mode_disabled(tmp_path):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
document = json.loads(store.path.read_text(encoding="utf-8"))
|
|
document["schema_version"] = 8
|
|
document.pop("performance_mode_enabled")
|
|
document.pop("animation_preview_max_concurrent")
|
|
store.path.write_text(json.dumps(document), encoding="utf-8")
|
|
|
|
migrated = ConfigStore(project_root=tmp_path)
|
|
|
|
assert migrated.config["performance_mode_enabled"] is False
|
|
assert migrated.config["animation_preview_max_concurrent"] == 2
|
|
assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == CONFIG_SCHEMA_VERSION
|
|
|
|
|
|
def test_v9_config_migrates_animation_preview_concurrency_without_losing_values(tmp_path):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
document = json.loads(store.path.read_text(encoding="utf-8"))
|
|
document["schema_version"] = 9
|
|
document["performance_mode_enabled"] = True
|
|
document.pop("animation_preview_max_concurrent")
|
|
store.path.write_text(json.dumps(document), encoding="utf-8")
|
|
|
|
migrated = ConfigStore(project_root=tmp_path)
|
|
|
|
assert migrated.config["performance_mode_enabled"] is True
|
|
assert migrated.config["animation_preview_max_concurrent"] == 2
|
|
assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == 11
|
|
|
|
|
|
def test_config_schema_field_sets_are_explicit_and_incremental():
|
|
assert "preview_refresh_interval_ms" not in CONFIG_V1_FIELDS
|
|
assert "low_voltage_protection_enabled" not in CONFIG_V1_FIELDS
|
|
assert CONFIG_V2_FIELDS == CONFIG_V1_FIELDS | {"preview_refresh_interval_ms"}
|
|
assert CONFIG_V3_FIELDS == CONFIG_V2_FIELDS | {"low_voltage_protection_enabled"}
|
|
assert CONFIG_V4_FIELDS == CONFIG_V3_FIELDS | {"matrix_refresh_rate_limit_hz"}
|
|
assert CONFIG_V5_FIELDS == CONFIG_V4_FIELDS | {"custom_test_color"}
|
|
assert CONFIG_V6_FIELDS == CONFIG_V5_FIELDS | {"default_display"}
|
|
assert CONFIG_V7_FIELDS == CONFIG_V6_FIELDS | {"workspace_order"}
|
|
assert CONFIG_V8_FIELDS == CONFIG_V7_FIELDS
|
|
assert CONFIG_V9_FIELDS == CONFIG_V8_FIELDS | {"performance_mode_enabled"}
|
|
assert CONFIG_V10_FIELDS == CONFIG_V9_FIELDS | {"animation_preview_max_concurrent"}
|
|
|
|
|
|
def test_animation_preview_concurrency_is_strict_and_persistent(tmp_path):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
|
|
for value in (1, 2, 50):
|
|
assert store.update({"animation_preview_max_concurrent": value})[
|
|
"animation_preview_max_concurrent"
|
|
] == value
|
|
assert ConfigStore(project_root=tmp_path).config[
|
|
"animation_preview_max_concurrent"
|
|
] == value
|
|
|
|
for value in (0, 51, True, 2.0, "2", None):
|
|
with pytest.raises(ConfigError):
|
|
store.update({"animation_preview_max_concurrent": value})
|
|
|
|
|
|
def test_workspace_order_is_strict_unique_and_persistent(tmp_path):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
selected = ["templates", "device", "settings", "canvas", "text", "animations"]
|
|
|
|
assert store.update({"workspace_order": selected})["workspace_order"] == selected
|
|
assert ConfigStore(project_root=tmp_path).config["workspace_order"] == selected
|
|
|
|
for value in (
|
|
None,
|
|
[],
|
|
["device", "device"],
|
|
["bad id"],
|
|
["_bad"],
|
|
[1, "device"],
|
|
"device",
|
|
):
|
|
with pytest.raises(ConfigError):
|
|
store.update({"workspace_order": value})
|
|
assert store.config["workspace_order"] == selected
|
|
|
|
|
|
@pytest.mark.parametrize("value", [["device", "device"], ["bad id"], [], [1]])
|
|
def test_invalid_persisted_workspace_order_is_preserved_and_blocks_startup(tmp_path, value):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
document = json.loads(store.path.read_text(encoding="utf-8"))
|
|
document["workspace_order"] = value
|
|
original = json.dumps(document, ensure_ascii=False).encode("utf-8")
|
|
store.path.write_bytes(original)
|
|
|
|
with pytest.raises(ConfigError):
|
|
ConfigStore(project_root=tmp_path)
|
|
|
|
assert store.path.read_bytes() == original
|
|
|
|
|
|
def test_default_display_is_strict_normalized_and_persistent(tmp_path):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
selected = {
|
|
"type": "template",
|
|
"id": "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA",
|
|
}
|
|
|
|
assert store.update({"default_display": selected})["default_display"] == {
|
|
"type": "template",
|
|
"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
}
|
|
assert ConfigStore(project_root=tmp_path).config["default_display"]["id"] == (
|
|
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
|
)
|
|
|
|
for value in (
|
|
None,
|
|
{},
|
|
{"type": "template"},
|
|
{"type": "image", "id": selected["id"]},
|
|
{"type": "template", "id": "not-a-uuid"},
|
|
{"type": "template", "id": selected["id"], "name": "extra"},
|
|
):
|
|
with pytest.raises(ConfigError):
|
|
store.update({"default_display": value})
|
|
|
|
|
|
def test_custom_test_color_is_normalized_strict_and_persistent(tmp_path):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
|
|
assert store.update({"custom_test_color": "#a1b2c3"})["custom_test_color"] == "#A1B2C3"
|
|
assert ConfigStore(project_root=tmp_path).config["custom_test_color"] == "#A1B2C3"
|
|
|
|
for value in (None, True, 123, "red", "#12345", "#1234567"):
|
|
with pytest.raises(ConfigError):
|
|
store.update({"custom_test_color": value})
|
|
assert store.config["custom_test_color"] == "#A1B2C3"
|
|
|
|
|
|
def test_matrix_refresh_rate_limit_requires_a_fixed_integer_preset(tmp_path):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
|
|
for value in sorted(MATRIX_REFRESH_RATE_LIMITS):
|
|
assert store.update({"matrix_refresh_rate_limit_hz": value})[
|
|
"matrix_refresh_rate_limit_hz"
|
|
] == value
|
|
assert ConfigStore(project_root=tmp_path).config[
|
|
"matrix_refresh_rate_limit_hz"
|
|
] == value
|
|
|
|
for value in (0, 14, 16, 60.0, True, "60", None):
|
|
with pytest.raises(ConfigError):
|
|
store.update({"matrix_refresh_rate_limit_hz": value})
|
|
|
|
|
|
@pytest.mark.parametrize("value", [0, 1, None, "true", 1.0])
|
|
def test_low_voltage_protection_switch_requires_a_strict_boolean(tmp_path, value):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
|
|
with pytest.raises(ConfigError, match="must be a boolean"):
|
|
store.update({"low_voltage_protection_enabled": value})
|
|
|
|
assert store.config["low_voltage_protection_enabled"] is False
|
|
|
|
|
|
def test_low_voltage_protection_switch_persists_across_restart(tmp_path):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
|
|
store.update({"low_voltage_protection_enabled": True})
|
|
|
|
assert ConfigStore(project_root=tmp_path).config[
|
|
"low_voltage_protection_enabled"
|
|
] is True
|
|
|
|
|
|
def test_preview_refresh_interval_range_type_and_restart_persistence(tmp_path):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
|
|
for value in (1, 99, 100, 60000):
|
|
assert store.update({"preview_refresh_interval_ms": value})[
|
|
"preview_refresh_interval_ms"
|
|
] == value
|
|
assert ConfigStore(project_root=tmp_path).config[
|
|
"preview_refresh_interval_ms"
|
|
] == value
|
|
|
|
for value in (0, 60001, True, 1.5, "100"):
|
|
with pytest.raises(ConfigError):
|
|
store.update({"preview_refresh_interval_ms": value})
|
|
|
|
|
|
def test_palette_normalizes_persists_and_updates_atomically(tmp_path):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
store.update({"color_palette": []})
|
|
|
|
store.add_palette_color("#aabbcc")
|
|
store.add_palette_color("#AABBCC")
|
|
store.add_palette_color("#010203")
|
|
store.remove_palette_color("#aabbcc")
|
|
store.remove_palette_color("#ffffff")
|
|
|
|
assert store.config["color_palette"] == ["#010203"]
|
|
assert ConfigStore(project_root=tmp_path).config["color_palette"] == ["#010203"]
|
|
|
|
|
|
def test_legacy_palette_repairs_entries_without_losing_other_config(tmp_path):
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
raw_palette = ["#aabbcc", "bad", "#AABBCC"] + [f"#{index:06X}" for index in range(30)]
|
|
(data_dir / "config.json").write_text(json.dumps({
|
|
"orientation": 180,
|
|
"brightness": 35,
|
|
"color_palette": raw_palette,
|
|
}), encoding="utf-8")
|
|
|
|
store = ConfigStore(project_root=tmp_path)
|
|
|
|
assert store.config["orientation"] == 180
|
|
assert store.config["brightness"] == 35
|
|
assert store.config["color_palette"][0] == "#AABBCC"
|
|
assert len(store.config["color_palette"]) == COLOR_PALETTE_LIMIT
|
|
persisted = json.loads((data_dir / "config.json").read_text(encoding="utf-8"))
|
|
assert persisted["schema_version"] == CONFIG_SCHEMA_VERSION
|
|
assert persisted["color_palette"] == store.config["color_palette"]
|
|
|
|
|
|
def test_config_without_palette_is_migrated_to_defaults(tmp_path):
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
path = data_dir / "config.json"
|
|
path.write_text(json.dumps({"orientation": 90, "brightness": 50}), encoding="utf-8")
|
|
|
|
store = ConfigStore(project_root=tmp_path)
|
|
|
|
assert store.config["orientation"] == 90
|
|
assert store.config["color_palette"] == DEFAULT_COLOR_PALETTE
|
|
persisted = json.loads(path.read_text(encoding="utf-8"))
|
|
assert persisted["schema_version"] == CONFIG_SCHEMA_VERSION
|
|
assert persisted["color_palette"] == DEFAULT_COLOR_PALETTE
|
|
|
|
|
|
def test_config_store_rejects_corrupt_file_without_modifying_it(tmp_path):
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
path = data_dir / "config.json"
|
|
original = b"{bad json"
|
|
path.write_bytes(original)
|
|
|
|
with pytest.raises(ConfigError, match="unreadable"):
|
|
ConfigStore(project_root=tmp_path)
|
|
|
|
assert path.read_bytes() == original
|
|
|
|
|
|
def test_voltage_calibration_migrates_and_persists_as_one_group(tmp_path):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
store.update({
|
|
"voltage_calibration_factor": 1.01,
|
|
"voltage_calibrated_at": "2026-07-20T12:34:56.000Z",
|
|
"voltage_calibration_reference": 5.0,
|
|
"voltage_calibration_uncalibrated": 4.950495,
|
|
})
|
|
|
|
reloaded = ConfigStore(project_root=tmp_path)
|
|
|
|
assert reloaded.config["voltage_calibration_factor"] == 1.01
|
|
assert reloaded.config["voltage_calibrated_at"] == "2026-07-20T12:34:56.000Z"
|
|
|
|
|
|
def test_legacy_config_migration_preserves_valid_voltage_calibration(tmp_path):
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
path = data_dir / "config.json"
|
|
calibration = {
|
|
"voltage_calibration_factor": 1.01,
|
|
"voltage_calibrated_at": "2026-07-20T12:34:56.000Z",
|
|
"voltage_calibration_reference": 5.0,
|
|
"voltage_calibration_uncalibrated": 4.950495,
|
|
}
|
|
path.write_text(
|
|
json.dumps({"orientation": 180, "brightness": 35, **calibration}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
store = ConfigStore(project_root=tmp_path)
|
|
|
|
assert {key: store.config[key] for key in calibration} == calibration
|
|
persisted = json.loads(path.read_text(encoding="utf-8"))
|
|
assert {key: persisted[key] for key in calibration} == calibration
|
|
assert persisted["schema_version"] == CONFIG_SCHEMA_VERSION
|
|
|
|
|
|
def test_invalid_or_partial_voltage_calibration_is_preserved_and_blocks_startup(tmp_path):
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
path = data_dir / "config.json"
|
|
path.write_text(json.dumps({
|
|
"orientation": 180,
|
|
"brightness": 35,
|
|
"voltage_calibration_factor": 9,
|
|
"voltage_calibrated_at": "bad",
|
|
"voltage_calibration_reference": 5.0,
|
|
}), encoding="utf-8")
|
|
|
|
original = path.read_bytes()
|
|
|
|
with pytest.raises(ConfigError, match="calibration is incomplete"):
|
|
ConfigStore(project_root=tmp_path)
|
|
|
|
assert path.read_bytes() == original
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"document, message",
|
|
[
|
|
({"schema_version": CONFIG_SCHEMA_VERSION + 1}, "newer than supported"),
|
|
({"orientation": 0, "unexpected": True}, "unknown persisted config fields"),
|
|
],
|
|
)
|
|
def test_unmigratable_config_is_preserved_and_blocks_startup(
|
|
tmp_path,
|
|
document,
|
|
message,
|
|
):
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
path = data_dir / "config.json"
|
|
original = (json.dumps(document, separators=(",", ":")) + "\n").encode("utf-8")
|
|
path.write_bytes(original)
|
|
|
|
with pytest.raises(ConfigError, match=message):
|
|
ConfigStore(project_root=tmp_path)
|
|
|
|
assert path.read_bytes() == original
|
|
|
|
|
|
def test_config_migration_is_idempotent(tmp_path):
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
path = data_dir / "config.json"
|
|
path.write_text(json.dumps({"orientation": 270, "brightness": 42}), encoding="utf-8")
|
|
|
|
first = ConfigStore(project_root=tmp_path)
|
|
migrated = path.read_bytes()
|
|
second = ConfigStore(project_root=tmp_path)
|
|
|
|
assert first.config == second.config
|
|
assert path.read_bytes() == migrated
|
|
assert json.loads(migrated)["schema_version"] == CONFIG_SCHEMA_VERSION
|
|
|
|
|
|
def test_atomic_write_failure_keeps_previous_config_and_cleans_temp_files(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
store.update({"orientation": 90})
|
|
original = store.path.read_bytes()
|
|
|
|
def fail_replace(_source, _target):
|
|
raise OSError("replace failed")
|
|
|
|
monkeypatch.setattr(persistence.os, "replace", fail_replace)
|
|
with pytest.raises(OSError, match="replace failed"):
|
|
store.update({"orientation": 180})
|
|
|
|
assert store.path.read_bytes() == original
|
|
assert store.config["orientation"] == 90
|
|
assert list(store.data_dir.glob("*.tmp")) == []
|
|
|
|
|
|
def test_directory_sync_failure_restores_previous_config_and_cleans_temp_files(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
store.update({"orientation": 90})
|
|
original = store.path.read_bytes()
|
|
sync_calls = 0
|
|
|
|
def fail_second_sync(_directory):
|
|
nonlocal sync_calls
|
|
sync_calls += 1
|
|
if sync_calls == 2:
|
|
raise OSError("directory sync failed")
|
|
|
|
monkeypatch.setattr(persistence, "_fsync_directory", fail_second_sync)
|
|
with pytest.raises(OSError, match="directory sync failed"):
|
|
store.update({"orientation": 180})
|
|
|
|
assert store.path.read_bytes() == original
|
|
assert store.config["orientation"] == 90
|
|
assert list(store.data_dir.glob(".*.tmp")) == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("field", "value"),
|
|
[
|
|
("brightness", True),
|
|
("orientation", 0.0),
|
|
("default_text_size", "12"),
|
|
("low_voltage_protection_enabled", 1),
|
|
("performance_mode_enabled", 1),
|
|
("animation_preview_max_concurrent", True),
|
|
],
|
|
)
|
|
def test_current_config_schema_rejects_noncanonical_json_types(
|
|
tmp_path,
|
|
field,
|
|
value,
|
|
):
|
|
store = ConfigStore(project_root=tmp_path)
|
|
document = json.loads(store.path.read_text(encoding="utf-8"))
|
|
document[field] = value
|
|
original = (json.dumps(document, separators=(",", ":")) + "\n").encode("utf-8")
|
|
store.path.write_bytes(original)
|
|
|
|
with pytest.raises(ConfigError, match="must be"):
|
|
ConfigStore(project_root=tmp_path)
|
|
|
|
assert store.path.read_bytes() == original
|
|
|
|
|
|
def test_explicit_project_root_overrides_storage_environment(tmp_path, monkeypatch):
|
|
environment_data = tmp_path / "environment-data"
|
|
environment_runtime = tmp_path / "environment-runtime"
|
|
monkeypatch.setenv("MATRIX_DATA_DIR", str(environment_data))
|
|
monkeypatch.setenv("STATE_DIRECTORY", str(tmp_path / "state"))
|
|
monkeypatch.setenv("MATRIX_RUNTIME_DIR", str(environment_runtime))
|
|
monkeypatch.setenv("RUNTIME_DIRECTORY", str(tmp_path / "runtime"))
|
|
explicit = tmp_path / "explicit"
|
|
|
|
store = ConfigStore(project_root=explicit)
|
|
|
|
assert store.data_dir == explicit / "data"
|
|
assert store.runtime_dir == explicit / "data" / "runtime"
|
|
assert not environment_data.exists()
|
|
assert not environment_runtime.exists()
|
|
|
|
|
|
def test_environment_storage_path_precedence_and_runtime_fallback(tmp_path, monkeypatch):
|
|
matrix_data = tmp_path / "matrix-data"
|
|
state_data = tmp_path / "state-data"
|
|
matrix_runtime = tmp_path / "matrix-runtime"
|
|
systemd_runtime = tmp_path / "systemd-runtime"
|
|
monkeypatch.setenv("MATRIX_DATA_DIR", str(matrix_data))
|
|
monkeypatch.setenv("STATE_DIRECTORY", str(state_data))
|
|
monkeypatch.setenv("MATRIX_RUNTIME_DIR", str(matrix_runtime))
|
|
monkeypatch.setenv("RUNTIME_DIRECTORY", str(systemd_runtime))
|
|
|
|
assert resolve_data_dir() == matrix_data
|
|
assert resolve_runtime_dir(data_dir=matrix_data) == matrix_runtime
|
|
|
|
monkeypatch.delenv("MATRIX_DATA_DIR")
|
|
monkeypatch.delenv("MATRIX_RUNTIME_DIR")
|
|
assert resolve_data_dir() == state_data
|
|
assert resolve_runtime_dir(data_dir=state_data) == systemd_runtime
|
|
|
|
monkeypatch.delenv("STATE_DIRECTORY")
|
|
monkeypatch.delenv("RUNTIME_DIRECTORY")
|
|
configured_data = tmp_path / "configured-only"
|
|
monkeypatch.setenv("MATRIX_DATA_DIR", str(configured_data))
|
|
assert (
|
|
resolve_runtime_dir(data_dir=resolve_data_dir())
|
|
== Path(__file__).resolve().parents[1] / "data" / "runtime"
|
|
)
|
|
|
|
monkeypatch.delenv("MATRIX_DATA_DIR")
|
|
assert resolve_data_dir() == Path(__file__).resolve().parents[1] / "data"
|