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
172 lines
6.4 KiB
Python
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}"
|