Files
Behavision/tests/test_config.py
Suriyakumarvijayanayagam c7024b57ca The shipped config could not load on a machine without a .env
config/default.yaml reads its camera's host, username and password from
${ENV}. Unset placeholders parse as YAML null, and CameraConfig had no
_normalize_blanks - the guard ApiSection and EmailSection have had all
along - so loading it raised three pydantic errors and the engine would
not start at all. On a developer's checkout .env is right there, which is
why this survived: the failing machine is every machine the product is
actually installed on, and installer/build.ps1 runs this suite, so the
Windows build would have failed on a fresh clone.

The normalisation is field-by-field, never a blanket None -> "": `webcam`
is an Optional[int] whose None means "this is not a webcam", and `tuning`
is a nested model. Sweeping either trades one validation error for
another - which it did, on the first attempt.

Second bug behind the same line: that camera entry would then have been
SEEDED into a fresh install, giving a shop a camera called cam1 that
nobody added, retrying a connection to "" forever, with the first task on
a new PC being to work out what it was. CameraConfig.addressed() says
what a camera entry needs to be one, and seed() drops the rest.

Found by cloning the repository into a temp directory and running the
tests there. Nothing in a working tree can find this class of bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 12:15:53 +05:30

187 lines
7.5 KiB
Python

"""The shipped YAML carries thresholds measured on the deployment site; the
pydantic defaults must not drift away from them (a trimmed config would then
silently re-admit the false positives those values were tuned to reject)."""
from pathlib import Path
import pytest
from behavision.config import (ApiSection, Config, DetectionSection,
RecognitionSection, TrackingSection,
ensure_api_credentials, load_config)
def test_yaml_matches_code_defaults():
cfg = load_config()
for section, model in (("detection", DetectionSection),
("recognition", RecognitionSection),
("tracking", TrackingSection)):
loaded = getattr(cfg, section)
defaults = model()
for name in model.model_fields:
assert getattr(loaded, name) == getattr(defaults, name), (
f"{section}.{name} drifted between config/default.yaml "
f"and config.py")
def test_tuned_thresholds_are_the_defaults():
cfg = Config()
assert cfg.detection.score_threshold == 0.82
assert cfg.recognition.min_enroll_quality == 0.65
def test_loopback_stays_open(tmp_path):
cfg = Config()
cfg.app.data_dir = tmp_path
cfg.api.host = "127.0.0.1"
auth_on, generated = ensure_api_credentials(cfg)
assert (auth_on, generated) == (False, False)
assert not (tmp_path / "api_credentials.txt").exists()
def test_routable_host_never_served_unauthenticated(tmp_path):
cfg = Config()
cfg.app.data_dir = tmp_path
cfg.api.host = "0.0.0.0"
auth_on, generated = ensure_api_credentials(cfg)
assert auth_on and generated
assert cfg.api.username and len(cfg.api.password) >= 16
assert (tmp_path / "api_credentials.txt").exists()
def test_generated_credentials_are_stable_across_restarts(tmp_path):
first = Config()
first.app.data_dir = tmp_path
first.api.host = "0.0.0.0"
ensure_api_credentials(first)
second = Config()
second.app.data_dir = tmp_path
second.api.host = "0.0.0.0"
auth_on, generated = ensure_api_credentials(second)
assert auth_on and not generated # reused, not re-minted
assert second.api.password == first.api.password
def test_configured_credentials_win(tmp_path):
cfg = Config()
cfg.app.data_dir = tmp_path
cfg.api.host = "0.0.0.0"
cfg.api.username, cfg.api.password = "alice", "s3cret"
auth_on, generated = ensure_api_credentials(cfg)
assert (auth_on, generated) == (True, False)
assert not (tmp_path / "api_credentials.txt").exists()
assert cfg.api.password == "s3cret"
def test_blank_env_placeholders_do_not_enable_auth():
"""Unset ${ENV} parses as YAML null; auth must not half-enable."""
cfg = ApiSection(host="0.0.0.0", port=None, username=None, password=None)
assert cfg.username == "" and cfg.password == ""
assert cfg.port == 8010
assert not cfg.auth_enabled
def test_color_order_is_declared_per_model_family():
"""ArcFace/InsightFace exports want RGB, AdaFace wants BGR. Same scaling,
opposite channel order, and feeding the wrong one raises nothing — it just
yields off-distribution embeddings. Pin the mapping down."""
from behavision.recognition import color_order_for
for name in ("w600k_mbf", "w600k_r50", "arcface", "arcface_int8"):
assert color_order_for(name) == "RGB", name
for name in ("adaface_ir18", "adaface_ir50", "adaface_ir101", "AdaFace_IR50"):
assert color_order_for(name) == "BGR", name
def test_better_models_are_preferred_over_the_fallback():
from behavision.recognition import MODEL_CANDIDATES
order = {n: i for i, n in enumerate(MODEL_CANDIDATES)}
# first that loads wins, so accuracy must come before the 13 MB fallback
assert order["w600k_r50.onnx"] < order["w600k_mbf.onnx"]
assert order["adaface_ir50.onnx"] < order["w600k_r50.onnx"]
def test_unloadable_model_falls_through_to_the_next_candidate(tmp_path):
"""The deploy box may not have RAM for the big model. A corrupt/unusable
file must be skipped, not fatal — otherwise a memory-starved machine can
no longer start at all."""
import shutil
from pathlib import Path
from behavision.recognition import ArcFaceEncoder
real = Path(__file__).resolve().parent.parent / "models" / "w600k_mbf.onnx"
if not real.exists():
import pytest
pytest.skip("no recognition model installed")
# a plausible-looking but unloadable "big model" ahead of a good one
(tmp_path / "w600k_r50.onnx").write_bytes(b"not an onnx file")
shutil.copy(real, tmp_path / "w600k_mbf.onnx")
enc = ArcFaceEncoder(tmp_path)
assert enc.model_name == "w600k_mbf" # fell through, did not crash
def test_no_usable_model_fails_loudly(tmp_path):
from behavision.recognition import ArcFaceEncoder
(tmp_path / "w600k_mbf.onnx").write_bytes(b"garbage")
with pytest.raises(FileNotFoundError, match="no usable recognition model"):
ArcFaceEncoder(tmp_path)
def _no_env(monkeypatch, tmp_path):
"""Make the process look like an installed PC rather than a checkout.
`load_config` calls `load_dotenv` first, so simply unsetting the variables
is not enough in a working tree - the .env sitting next to the code puts
them straight back. Removing the .env from the picture is the condition
these tests are actually about.
"""
import behavision.paths as paths
monkeypatch.setattr(paths, "env_file", lambda: None)
for var in ("BEHAVISION_CAM1_HOST", "BEHAVISION_CAM1_USERNAME",
"BEHAVISION_CAM1_PASSWORD", "BEHAVISION_API_USER",
"BEHAVISION_API_PASSWORD", "BEHAVISION_WEBHOOK_URL"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("BEHAVISION_DATA_DIR", str(tmp_path))
def test_the_bundled_config_loads_with_no_environment_at_all(tmp_path, monkeypatch):
"""What a freshly installed PC looks like: config/default.yaml, and nothing
else.
Every `${VAR}` in that file then resolves to YAML null. The engine's own
sections already guarded against that; the camera section did not, so the
shipped config raised three pydantic errors on host, username and password
and the engine would not start at all on any machine without a developer's
.env - which is every machine the product is installed on. It was found by
cloning the repository and running this suite, never by working in a
checkout where .env is right there.
"""
_no_env(monkeypatch, tmp_path)
root = Path(__file__).resolve().parent.parent
cfg = load_config(root / "config" / "default.yaml")
assert cfg.api.username == ""
assert cfg.api.port == 8010
for cam in cfg.cameras:
assert cam.host == "" and cam.username == "" and cam.password == ""
assert cam.port == 554
def test_a_camera_with_no_address_is_not_seeded(tmp_path, monkeypatch):
"""The bundled config declares a camera whose address comes from the
environment. On an installed PC there is none, so seeding it would put a
camera the shop never added into a brand new install, permanently failing
to connect to "" - and their first task would be working out what it was."""
from behavision.cameras import CameraStore
_no_env(monkeypatch, tmp_path)
root = Path(__file__).resolve().parent.parent
cfg = load_config(root / "config" / "default.yaml")
store = CameraStore(tmp_path / "cameras.json")
assert store.seed(cfg.cameras) is False
assert store.list() == []
assert not (tmp_path / "cameras.json").exists()