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
187 lines
7.5 KiB
Python
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()
|