# SPDX-License-Identifier: AGPL-3.0-or-later
"""Config loading, merging, and validation."""
from __future__ import annotations
from pathlib import Path
import pytest
from autosplat.config import (
PACKAGED_DEFAULT_CONFIG,
Config,
_deep_merge,
apply_override,
dump_default_config,
load_config,
)
def test_packaged_default_config_exists() -> None:
assert PACKAGED_DEFAULT_CONFIG.exists(), "Default config must ship with the repo"
def test_load_default_config_parses_cleanly() -> None:
cfg = load_config(include_xdg=False)
assert isinstance(cfg, Config)
assert cfg.preprocess.target_frames == 250
assert cfg.colmap.matcher == "sequential"
assert cfg.brush.max_steps == 30000
# v1.4.4 — default flipped to supersplat-local to avoid the HTTPS->HTTP
# mixed-content blocking the remote SuperSplat editor suffers from.
assert cfg.viewer.target == "supersplat-local"
assert cfg.obsidian.enabled is False
def test_share_config_defaults() -> None:
cfg = load_config(include_xdg=False)
assert cfg.share.base_url == ""
assert cfg.share.embed_src_path.is_absolute()
assert cfg.share.embed_src_path.name == "embed"
def test_paths_expand_tilde() -> None:
cfg = load_config(include_xdg=False)
assert "~" not in str(cfg.paths.captures_dir)
assert "~" not in str(cfg.paths.brush_binary)
def test_user_override_wins(tmp_path: Path) -> None:
user = tmp_path / "u.toml"
user.write_text(
"""
[preprocess]
target_frames = 99
""",
encoding="utf-8",
)
cfg = load_config(user_config_path=user, include_xdg=False)
assert cfg.preprocess.target_frames == 99
# Untouched fields keep defaults
assert cfg.preprocess.blur_threshold == 100.0
def test_invalid_matcher_rejected(tmp_path: Path) -> None:
user = tmp_path / "u.toml"
user.write_text(
"""
[colmap]
matcher = "telepathy"
""",
encoding="utf-8",
)
with pytest.raises(Exception):
load_config(user_config_path=user, include_xdg=False)
def test_deep_merge_nested() -> None:
base = {"a": {"x": 1, "y": 2}, "b": 3}
over = {"a": {"y": 20, "z": 30}}
assert _deep_merge(base, over) == {"a": {"x": 1, "y": 20, "z": 30}, "b": 3}
def test_dump_default_config(tmp_path: Path) -> None:
target = tmp_path / "out.toml"
dump_default_config(target)
assert target.exists()
assert target.read_text(encoding="utf-8").startswith("# auto-splat-pipeline")
# ─── Phase-3 sections + override helper ─────────────────────────────────────
def test_default_includes_phase3_sections() -> None:
cfg = load_config(include_xdg=False)
assert cfg.quality_gate.enabled is True
assert cfg.quality_gate.min_camera_ratio == 0.5
assert cfg.quality_gate.min_points == 5000
assert cfg.retry.enabled is True
assert cfg.retry.max_retries == 3
assert cfg.status.max_history == 50
def test_apply_override_swaps_matcher() -> None:
cfg = load_config(include_xdg=False)
assert cfg.colmap.matcher == "sequential"
new_cfg = apply_override(cfg, {"colmap": {"matcher": "exhaustive"}})
assert new_cfg.colmap.matcher == "exhaustive"
# Unrelated values untouched
assert new_cfg.brush.max_steps == cfg.brush.max_steps
# Original cfg unmutated
assert cfg.colmap.matcher == "sequential"
def test_apply_override_empty_returns_original_cfg() -> None:
cfg = load_config(include_xdg=False)
assert apply_override(cfg, {}) is cfg
def test_apply_override_validates_invalid_matcher() -> None:
import pytest
cfg = load_config(include_xdg=False)
with pytest.raises(Exception):
apply_override(cfg, {"colmap": {"matcher": "telepathy"}})
def test_phase3_config_loads_old_toml_without_sections(tmp_path: Path) -> None:
"""A pre-Phase-3 user config (no [quality_gate]/[retry]/[status]) still loads
— the missing sections fall back to packaged defaults via the merge."""
user = tmp_path / "u.toml"
user.write_text(
"""
[brush]
max_steps = 1000
""",
encoding="utf-8",
)
cfg = load_config(user_config_path=user, include_xdg=False)
assert cfg.brush.max_steps == 1000
assert cfg.quality_gate.enabled is True # came from packaged default
assert cfg.retry.max_retries == 3
# ─── v1.4 — Auto-Bisection-Rescue config ────────────────────────────────────
def test_default_includes_v14_bisect_fields() -> None:
cfg = load_config(include_xdg=False)
assert cfg.retry.bisect_enabled is True
assert cfg.retry.bisect_min_clip_s == 60.0
assert cfg.retry.bisect_max_depth == 3
assert cfg.retry.bisect_probe_target_frames == 120
def test_bisect_probe_target_frames_rejects_too_small(tmp_path: Path) -> None:
user = tmp_path / "u.toml"
user.write_text(
"""
[retry]
bisect_probe_target_frames = 10
""",
encoding="utf-8",
)
with pytest.raises(Exception):
load_config(user_config_path=user, include_xdg=False)
def test_bisect_max_depth_rejects_zero(tmp_path: Path) -> None:
user = tmp_path / "u.toml"
user.write_text(
"""
[retry]
bisect_max_depth = 0
""",
encoding="utf-8",
)
with pytest.raises(Exception):
load_config(user_config_path=user, include_xdg=False)
def test_bisect_min_clip_s_rejects_too_small(tmp_path: Path) -> None:
user = tmp_path / "u.toml"
user.write_text(
"""
[retry]
bisect_min_clip_s = 5.0
""",
encoding="utf-8",
)
with pytest.raises(Exception):
load_config(user_config_path=user, include_xdg=False)
def test_apply_override_can_disable_bisect() -> None:
cfg = load_config(include_xdg=False)
new_cfg = apply_override(cfg, {"retry": {"bisect_enabled": False}})
assert new_cfg.retry.bisect_enabled is False
# Other retry fields untouched
assert new_cfg.retry.max_retries == cfg.retry.max_retries
# ─── v1.5.0 — Train-till-Plateau config ─────────────────────────────────────
def test_default_includes_v150_plateau_fields() -> None:
cfg = load_config(include_xdg=False)
assert cfg.brush.plateau_enabled is False
assert cfg.brush.plateau_eval_split_every == 10
assert cfg.brush.plateau_eval_every == 1000
assert cfg.brush.plateau_min_steps == 5000
assert cfg.brush.plateau_patience == 3
assert cfg.brush.plateau_min_delta_psnr == 0.05
def test_plateau_min_steps_above_max_steps_rejected_only_when_enabled(
tmp_path: Path,
) -> None:
"""plateau_min_steps > max_steps would mean the check never engages — reject,
but only when plateau_enabled=true. Otherwise a user lowering max_steps for
CI shouldn't have to also touch plateau_min_steps."""
enabled = tmp_path / "enabled.toml"
enabled.write_text(
"""
[brush]
max_steps = 1000
plateau_enabled = true
plateau_min_steps = 5000
""",
encoding="utf-8",
)
with pytest.raises(Exception):
load_config(user_config_path=enabled, include_xdg=False)
# Same min_steps > max_steps, but plateau disabled → loads fine.
disabled = tmp_path / "disabled.toml"
disabled.write_text(
"""
[brush]
max_steps = 1000
""",
encoding="utf-8",
)
cfg = load_config(user_config_path=disabled, include_xdg=False)
assert cfg.brush.max_steps == 1000
assert cfg.brush.plateau_enabled is False
def test_plateau_min_delta_psnr_rejects_zero(tmp_path: Path) -> None:
user = tmp_path / "u.toml"
user.write_text(
"""
[brush]
plateau_min_delta_psnr = 0.0
""",
encoding="utf-8",
)
with pytest.raises(Exception):
load_config(user_config_path=user, include_xdg=False)
def test_plateau_patience_rejects_zero(tmp_path: Path) -> None:
user = tmp_path / "u.toml"
user.write_text(
"""
[brush]
plateau_patience = 0
""",
encoding="utf-8",
)
with pytest.raises(Exception):
load_config(user_config_path=user, include_xdg=False)
def test_apply_override_can_enable_plateau() -> None:
cfg = load_config(include_xdg=False)
new_cfg = apply_override(
cfg,
{"brush": {"plateau_enabled": True, "plateau_patience": 5}},
)
assert new_cfg.brush.plateau_enabled is True
assert new_cfg.brush.plateau_patience == 5
# Other brush fields untouched
assert new_cfg.brush.max_steps == cfg.brush.max_steps
def test_lifecycle_defaults_to_30_day_grace() -> None:
cfg = load_config(include_xdg=False)
assert cfg.lifecycle.unreviewed_grace_days == 30
def test_copy_to_outputs_defaults_to_false() -> None:
cfg = load_config(include_xdg=False)
assert cfg.export.copy_to_outputs is False