Files
Behavision/tests/test_paths.py
Suriyakumarvijayanayagam dad04e8cda Behavision: face recognition for retail, edge to head office
Five components that ship as one product:

- behavision/  the recognition engine. RTSP ingest, YuNet detection, IoU
               tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
               FastAPI dashboard. Identity is decided once per TRACK from an
               average of at least three embeddings, never per frame.
- agent/       the Go edge agent: supervises the engine, holds a durable
               spool, and drains it to MQTT. Nothing is acked before the
               broker confirms.
- desktop/     the shop PC application (Wails + React + tray).
- server/      the cloud API, MQTT consumer, reports and assistant.
- web/         platform.loyaly.ai, the head-office app, embedded in the
               server binary.

The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.

CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.

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

172 lines
6.4 KiB
Python

"""Install layout: where the code lives versus where it may write.
In a checkout these are one directory, which is exactly why the difference
went unnoticed — every path resolved against the repo root. Installed, the code
sits under Program Files and nothing may write there, so these tests simulate
the frozen layout rather than trusting that it works.
"""
import os
import sys
from pathlib import Path
import pytest
from behavision import paths
@pytest.fixture
def frozen(monkeypatch):
"""Pretend to be a PyInstaller one-folder build on Windows."""
def _apply(exe_dir, programdata):
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "executable", str(Path(exe_dir) / "behavision.exe"))
# Not `os.name`: pathlib dispatches on it and patching it makes
# every Path() in the process raise.
monkeypatch.setattr(paths, "_os_family", lambda: "windows")
monkeypatch.setenv("PROGRAMDATA", str(programdata))
monkeypatch.delenv("BEHAVISION_DATA_DIR", raising=False)
monkeypatch.delenv("BEHAVISION_CONFIG", raising=False)
return _apply
def test_checkout_keeps_everything_in_one_place():
"""Development convenience must not regress."""
monkey = getattr(sys, "frozen", False)
assert not monkey
assert paths.state_root() == paths.install_root()
def test_frozen_writes_to_programdata_not_program_files(tmp_path, frozen):
exe = tmp_path / "Program Files" / "Behavision"
data = tmp_path / "ProgramData"
exe.mkdir(parents=True)
frozen(exe, data)
assert paths.install_root() == exe
assert paths.state_root() == data / "Behavision"
# The one that actually matters: nothing writable lands under the code.
assert exe not in paths.state_root().parents
assert paths.state_root() != paths.install_root()
def test_install_root_is_the_exe_folder_not_meipass(tmp_path, frozen, monkeypatch):
"""_MEIPASS is a temp dir that vanishes between runs; resolving the install
root to it would make every relative path unstable."""
exe = tmp_path / "app"
exe.mkdir()
frozen(exe, tmp_path / "pd")
monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path / "temp_meipass"),
raising=False)
assert paths.install_root() == exe
def test_data_dir_env_overrides_everything(tmp_path, frozen, monkeypatch):
frozen(tmp_path / "app", tmp_path / "pd")
monkeypatch.setenv("BEHAVISION_DATA_DIR", str(tmp_path / "elsewhere"))
assert paths.state_root() == (tmp_path / "elsewhere").resolve()
def test_config_env_overrides_everything(tmp_path, monkeypatch):
target = tmp_path / "custom.yaml"
monkeypatch.setenv("BEHAVISION_CONFIG", str(target))
assert paths.config_path() == target.resolve()
def test_editable_config_is_seeded_once_and_never_overwritten(tmp_path, frozen):
"""An upgrade must not silently revert an operator's thresholds."""
exe = tmp_path / "app"
(exe / "config").mkdir(parents=True)
(exe / "config" / "default.yaml").write_text("app:\n log_level: INFO\n")
frozen(exe, tmp_path / "pd")
first = paths.ensure_config()
assert first == tmp_path / "pd" / "Behavision" / "config" / "default.yaml"
assert first.is_file()
first.write_text("app:\n log_level: DEBUG\n") # operator edits it
assert paths.ensure_config() == first
assert "DEBUG" in first.read_text()
def test_local_config_wins_over_the_bundled_one(tmp_path, frozen):
exe = tmp_path / "app"
(exe / "config").mkdir(parents=True)
(exe / "config" / "default.yaml").write_text("app: {}\n")
pd = tmp_path / "pd"
local = pd / "Behavision" / "config" / "default.yaml"
local.parent.mkdir(parents=True)
local.write_text("app: {}\n")
frozen(exe, pd)
assert paths.config_path() == local
def test_env_file_prefers_the_writable_copy(tmp_path, frozen):
exe = tmp_path / "app"
exe.mkdir()
pd = tmp_path / "pd" / "Behavision"
pd.mkdir(parents=True)
frozen(exe, tmp_path / "pd")
assert paths.env_file() is None # optional, absent is fine
(exe / ".env").write_text("A=1\n")
assert paths.env_file() == exe / ".env"
(pd / ".env").write_text("A=2\n")
assert paths.env_file() == pd / ".env"
def test_models_go_somewhere_writable(tmp_path, frozen):
"""Models are ~200 MB and downloaded on first run, so they cannot live
next to the code under Program Files."""
exe = tmp_path / "app"
exe.mkdir()
frozen(exe, tmp_path / "pd")
from behavision.config import Config
cfg = Config()
resolved = paths.state_root() / cfg.app.models_dir
assert exe not in resolved.parents
def test_describe_answers_where_is_my_database(tmp_path, frozen):
frozen(tmp_path / "app", tmp_path / "pd")
d = paths.describe()
assert d["frozen"] is True
assert set(d) == {"frozen", "install_root", "state_root", "config"}
def test_a_checkout_does_not_copy_its_config_onto_itself(tmp_path, monkeypatch):
"""install_root == state_root in a checkout, so the seeding copy would be
a file onto itself — which truncates it before the read on some platforms.
"""
root = tmp_path / "repo"
(root / "config").mkdir(parents=True)
original = "app:\n log_level: WARNING\n"
(root / "config" / "default.yaml").write_text(original)
monkeypatch.setattr(paths, "install_root", lambda: root)
monkeypatch.setattr(paths, "state_root", lambda: root)
monkeypatch.delenv("BEHAVISION_CONFIG", raising=False)
assert paths.ensure_config() == root / "config" / "default.yaml"
assert (root / "config" / "default.yaml").read_text() == original
def test_missing_bundled_config_names_where_it_should_have_been(tmp_path, frozen):
exe = tmp_path / "app"
exe.mkdir()
frozen(exe, tmp_path / "pd")
assert paths.ensure_config() == exe / "config" / "default.yaml"
def test_spec_ships_the_files_the_app_reads_at_runtime():
"""The dashboard and the default config are read from disk, so they must
travel with the code. A rename here fails in the bundle only — which is
the one place nothing is tested."""
import re
spec = (Path(__file__).parent.parent / "behavision.spec").read_text()
datas = re.search(r"^datas = \[(.*?)^\]", spec, re.S | re.M).group(1)
sources = re.findall(r'\("([^"]+)",\s*"[^"]+"\)', datas)
assert sources, "no data files declared"
root = Path(__file__).parent.parent
missing = [src for src in sources if not (root / src).exists()]
assert not missing, f"spec ships files that do not exist: {missing}"