"""Smoke tests for src/config_loader.py — pure-Python, no cv2/ultralytics needed.""" import json import os import pytest import yaml from src.config_loader import ( check_legacy_batch_mode, load_config, read_zone_polygons, resolve_active_mode, set_active_mode, validate_config, ) @pytest.fixture() def repo_config_path(): p = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config.yaml") assert os.path.exists(p), "repo config.yaml missing" return p def test_repo_config_loads_and_validates(repo_config_path): cfg = load_config(repo_config_path) assert cfg.models.active_mode == "C" assert set(cfg.models.modes) >= {"A", "B", "C", "D"} for mode_id, mode in cfg.models.modes.items(): assert mode.engines, f"mode {mode_id} has no engines" for e in mode.engines: assert e.path in cfg.models.paths, f"mode {mode_id}: unknown engine {e.path}" assert e.classes, f"mode {mode_id}: engine {e.path} declares no classes" for cls_name in ("truck", "sack", "box"): dp = cfg.detection_params_for(cls_name) assert 0.0 < dp.conf <= 1.0 assert 0.0 < dp.iou <= 1.0 assert dp.min_bbox_area >= 0 def test_do_block_and_batch_default_mode(repo_config_path): cfg = load_config(repo_config_path) assert cfg.batch.default_mode == "auto" # timeout_seconds = sack-idle (pause); truck_gone_tolerance_seconds = finalize assert cfg.batch.timeout_seconds == 20.0 assert cfg.batch.truck_gone_tolerance_seconds == 30.0 assert cfg.do.enabled is True assert cfg.do.require_do is True assert cfg.do.require_plate is False assert cfg.do.retention_days == 7 assert cfg.do.photo_dir == "do_photos" assert cfg.do.max_photos_per_batch == 8 assert cfg.do.zone_warn_seconds == 3 assert cfg.do.ocr.engine == "rapid" def test_do_block_missing_uses_defaults(tmp_path): p = tmp_path / "config.yaml" p.write_text("models:\n active_mode: C\n") # minimal — will fail validate without modes; copy repo and strip do import shutil shutil.copy(repo_config_path_holder(), p) raw = yaml.safe_load(p.read_text()) del raw["do"] raw["batch"].pop("default_mode", None) raw["batch"].pop("truck_gone_tolerance_seconds", None) p.write_text(yaml.safe_dump(raw)) cfg = load_config(p) assert cfg.batch.default_mode == "auto" assert cfg.batch.truck_gone_tolerance_seconds == 30.0 assert cfg.do.enabled is True assert cfg.do.ocr.engine == "rapid" assert cfg.do.retention_days == 7 def repo_config_path_holder(): import os return os.path.join(os.path.dirname(os.path.dirname(__file__)), "config.yaml") def test_counting_knobs_match_production_defaults(repo_config_path): """All live counting knobs in predict.py must come from config.yaml.""" cfg = load_config(repo_config_path) c = cfg.counting assert c.confirm_delay_sec == 0.5 assert c.exit_confirm_delay_sec == 6.0 assert c.entry_overlap_threshold == 0.20 assert c.exit_overlap_threshold == 0.05 assert c.camera_noise_deadband == 50 assert c.duplicate_circle_radius == 30 assert c.cross_class_iou == 0.4 assert c.min_valid_area == 15000 assert c.max_reid_transit_distance == 400 assert c.circle_stay_timeout_sec == 10.0 assert c.jarak_toleransi_duplikat == 30 assert c.tolerance_missing_frames == 1200 assert c.max_reid_frames == 200 assert c.debounce_frames == 8 def test_mode_preset_only_filters_and_engines(repo_config_path): """Modes must not carry per-class conf/iou — those live in detection_params.""" with open(repo_config_path) as f: raw = yaml.safe_load(f) for mode_id, mode in raw["models"]["modes"].items(): assert "conf" not in mode, f"mode {mode_id}: conf belongs in detection_params" assert "iou" not in mode, f"mode {mode_id}: iou belongs in detection_params" assert "min_bbox_area" not in mode, f"mode {mode_id}" def test_resolve_active_mode_precedence(repo_config_path, monkeypatch): cfg = load_config(repo_config_path) monkeypatch.delenv("MODEL_MODE", raising=False) assert resolve_active_mode(None, cfg) == "C" assert resolve_active_mode("d", cfg) == "D" with pytest.warns(UserWarning): # unknown -> fallback to active assert resolve_active_mode("Z", cfg) == "C" monkeypatch.setenv("MODEL_MODE", "B") with pytest.warns(DeprecationWarning): # env still honoured, deprecated assert resolve_active_mode(None, cfg) == "B" def test_missing_file_falls_back_with_warning(tmp_path, monkeypatch): monkeypatch.delenv("MODEL_MODE", raising=False) with pytest.warns(UserWarning, match="not found"): cfg = load_config(tmp_path / "nope.yaml") assert cfg.models.active_mode == "C" assert cfg.stream.inference_stride == 2 def test_set_active_mode_roundtrip_and_validation(tmp_path, repo_config_path): import shutil dst = tmp_path / "config.yaml" shutil.copy(repo_config_path, dst) assert set_active_mode(dst, "d") == "D" assert load_config(dst).models.active_mode == "D" text = dst.read_text() assert 'active_mode: "D"' in text assert "# config.yaml" in text # comments preserved (no yaml.dump reformat) with pytest.raises(ValueError, match="Invalid model mode"): set_active_mode(dst, "Z") assert load_config(dst).models.active_mode == "D" # untouched on failure def test_future_mode_extensible_without_code(tmp_path, repo_config_path): """Adding mode E is a YAML-only change: loader accepts it, no code edits.""" import shutil dst = tmp_path / "config.yaml" shutil.copy(repo_config_path, dst) with open(dst) as f: raw = yaml.safe_load(f) raw["models"]["modes"]["E"] = { "description": "hypothetical future preset", "engines": [{"path": "combined", "classes": ["truck", "sack"]}], "class_filters": {"truck": ["truck"], "sack": ["sack"], "box": []}, } with open(dst, "w") as f: yaml.safe_dump(raw, f) cfg = load_config(dst) assert resolve_active_mode("E", cfg) == "E" assert set_active_mode(dst, "E") == "E" def test_legacy_batch_mode_check(tmp_path, repo_config_path): cfg = load_config(repo_config_path) legacy = tmp_path / "batch_mode.json" legacy.write_text(json.dumps({"mode": "auto", "model_mode": "B"})) msg = check_legacy_batch_mode(cfg, legacy) assert msg is not None and "'B'" in msg and "'C'" in msg legacy.write_text(json.dumps({"mode": "auto", "model_mode": "C"})) assert check_legacy_batch_mode(cfg, legacy) is None assert check_legacy_batch_mode(cfg, tmp_path / "missing.json") is None def test_zone_polygons_and_legacy_knob_warning(tmp_path): zf = tmp_path / "zones.json" zf.write_text(json.dumps({ "palet": [[0, 0]], "truck": [[1, 1]], "counting": [[2, 2]], "left_limit": 0.27, "right_limit": 0.72, "duplicate_circle_radius": 30, # legacy knob -> ignored + warned })) with pytest.warns(UserWarning, match="legacy knob"): zones = read_zone_polygons(zf) assert zones["palet"] == [[0, 0]] assert zones["left_limit"] == 0.27 # geometry stays in zones.json