Files
karung-counting-feedmill-se…/tests/test_config_loader.py
T
andrew f621ae86fb
ci / smoke (push) Canceled after 0s
fix(auto-batch): truck presence needs 50% polygon overlap, config-authoritative timers
Big docked truck bbox rests 1-2px past detection_polygon bottom edge, so
100%-containment made truck_in_area flicker False -> batch 15 split (6+276)
on 2026-09-29 while the truck never left. v4-best.engine detected it at
conf 0.92-0.97 in every replayed frame (no model miss, no retraining).

- truck gate: frac_inside >= 0.5 instead of contains(bbox)
- batch.truck_gone_tolerance_seconds (new, 30) authoritative; drop the
  hardcoded batch_mgr._truck_gone_tolerance = 30.0 override;
  batch.timeout_seconds documented as sack-idle pause only
- [TRUCK] truck_in_area transition debug log
2026-09-30 09:40:15 +07:00

189 lines
7.1 KiB
Python

"""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