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
235 lines
7.6 KiB
Go
235 lines
7.6 KiB
Go
package api
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func stepsByName(steps []CheckStep) map[string]CheckStep {
|
|
out := map[string]CheckStep{}
|
|
for _, s := range steps {
|
|
out[s.Name] = s
|
|
}
|
|
return out
|
|
}
|
|
|
|
func healthySite() *SiteHealth {
|
|
now := time.Now().UTC()
|
|
return &SiteHealth{
|
|
SiteID: siteA, Name: "Chennai", Online: true,
|
|
LastHeartbeatAt: now.Add(-20 * time.Second).Format(time.RFC3339),
|
|
LastEventAt: now.Add(-3 * time.Minute).Format(time.RFC3339),
|
|
RecognitionModel: "w600k_r50.onnx",
|
|
CamerasUp: 2, CamerasTotal: 2, FractionBelowGate: 0.11,
|
|
}
|
|
}
|
|
|
|
func connectedCameras(n int) []Camera {
|
|
up := true
|
|
out := make([]Camera, n)
|
|
for i := range out {
|
|
out[i] = Camera{ID: "c", SiteID: siteA, CameraID: "entrance", Connected: &up}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestAHealthySitePassesEveryStep(t *testing.T) {
|
|
steps := BuildSiteSteps(healthySite(), connectedCameras(2), time.Now().UTC())
|
|
for _, s := range steps {
|
|
if s.Status != "pass" {
|
|
t.Errorf("%q: %s — %s", s.Name, s.Status, s.Detail)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The point of the whole check: a site whose PC is off cannot be judged on
|
|
// anything else, and printing guesses next to the real failure buries it.
|
|
func TestAnOfflinePCStopsTheRestBeingJudged(t *testing.T) {
|
|
site := healthySite()
|
|
site.Online = false
|
|
site.LastHeartbeatAt = time.Now().Add(-3 * time.Hour).UTC().Format(time.RFC3339)
|
|
|
|
steps := BuildSiteSteps(site, connectedCameras(2), time.Now().UTC())
|
|
by := stepsByName(steps)
|
|
|
|
if by["The shop's PC is online"].Status != "fail" {
|
|
t.Fatal("an offline PC was not reported as a failure")
|
|
}
|
|
for _, name := range []string{"Cameras are connected", "Cameras can recognise faces",
|
|
"Visits are reaching head office"} {
|
|
if got := by[name].Status; got != "unknown" {
|
|
t.Errorf("%q reported %q on an offline PC - it cannot be known", name, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A PC that has never reported needs installing, not restarting. Different
|
|
// sentence, different action.
|
|
func TestAPCThatHasNeverReportedIsToldToInstall(t *testing.T) {
|
|
site := healthySite()
|
|
site.Online, site.LastHeartbeatAt = false, ""
|
|
|
|
by := stepsByName(BuildSiteSteps(site, nil, time.Now().UTC()))
|
|
advice := by["The shop's PC is online"].Advice
|
|
if !strings.Contains(advice, "enrolment code") {
|
|
t.Fatalf("advice does not say how to claim the PC: %q", advice)
|
|
}
|
|
}
|
|
|
|
// The Office1 case, and the reason this check exists at all: everything is
|
|
// plugged in, everything is online, and almost nobody is being recognised.
|
|
func TestASiteWhereMostVisitorsAreMissedFailsEvenThoughEverythingIsOnline(t *testing.T) {
|
|
site := healthySite()
|
|
site.FractionBelowGate = 0.727 // the number measured on Office1
|
|
|
|
steps := BuildSiteSteps(site, connectedCameras(2), time.Now().UTC())
|
|
by := stepsByName(steps)
|
|
|
|
if by["The shop's PC is online"].Status != "pass" {
|
|
t.Error("the PC is fine and should say so")
|
|
}
|
|
faces := by["Cameras can recognise faces"]
|
|
if faces.Status != "fail" {
|
|
t.Fatalf("73%% of visitors missed was reported as %q", faces.Status)
|
|
}
|
|
if !strings.Contains(faces.Detail, "73%") {
|
|
t.Errorf("the number is not shown: %q", faces.Detail)
|
|
}
|
|
if !strings.Contains(faces.Advice, "moving") {
|
|
t.Errorf("advice does not say what to do: %q", faces.Advice)
|
|
}
|
|
// And the site as a whole must not read as working.
|
|
ok := true
|
|
for _, s := range steps {
|
|
if s.Status != "pass" {
|
|
ok = false
|
|
}
|
|
}
|
|
if ok {
|
|
t.Fatal("a site missing 73% of its visitors was reported as working")
|
|
}
|
|
}
|
|
|
|
// A shop set up before opening has seen nobody. That is not a fault, and
|
|
// calling it one sends an installer looking for a problem that is not there.
|
|
func TestAShopThatHasSeenNobodyYetIsUnknownNotBroken(t *testing.T) {
|
|
site := healthySite()
|
|
site.LastEventAt = ""
|
|
site.FractionBelowGate = 0
|
|
|
|
by := stepsByName(BuildSiteSteps(site, connectedCameras(1), time.Now().UTC()))
|
|
if got := by["Cameras can recognise faces"].Status; got != "unknown" {
|
|
t.Errorf("a new shop reported %q, want unknown", got)
|
|
}
|
|
if got := by["Visits are reaching head office"].Status; got != "unknown" {
|
|
t.Errorf("no visits yet reported %q, want unknown", got)
|
|
}
|
|
// But it must still tell them how to prove it before opening.
|
|
if !strings.Contains(by["Cameras can recognise faces"].Advice, "walk-past") {
|
|
t.Error("no advice on how to prove the camera before the shop opens")
|
|
}
|
|
}
|
|
|
|
// Lost footfall can never be recovered, so it has to be visible rather than
|
|
// inferred from a report that is quietly short.
|
|
func TestDroppedVisitsAreAFailureAndSayTheyCannotBeRecovered(t *testing.T) {
|
|
site := healthySite()
|
|
site.Dropped = 412
|
|
|
|
by := stepsByName(BuildSiteSteps(site, connectedCameras(1), time.Now().UTC()))
|
|
send := by["Visits are reaching head office"]
|
|
if send.Status != "fail" {
|
|
t.Fatalf("lost visits reported as %q", send.Status)
|
|
}
|
|
if !strings.Contains(send.Detail, "412") {
|
|
t.Errorf("the count is not shown: %q", send.Detail)
|
|
}
|
|
if !strings.Contains(send.Advice, "cannot be recovered") {
|
|
t.Errorf("advice implies they might come back: %q", send.Advice)
|
|
}
|
|
}
|
|
|
|
// Recording but not sending is its own state: the shop is working, head office
|
|
// is blind, and the two look identical on a footfall report.
|
|
func TestABackedUpQueueIsAWarningNotAFailure(t *testing.T) {
|
|
site := healthySite()
|
|
site.Queued = 340
|
|
|
|
by := stepsByName(BuildSiteSteps(site, connectedCameras(1), time.Now().UTC()))
|
|
if got := by["Visits are reaching head office"].Status; got != "warn" {
|
|
t.Fatalf("a backed-up queue reported as %q", got)
|
|
}
|
|
}
|
|
|
|
func TestASiteWithNoCamerasSaysToAddOne(t *testing.T) {
|
|
by := stepsByName(BuildSiteSteps(healthySite(), nil, time.Now().UTC()))
|
|
cam := by["Cameras are connected"]
|
|
if cam.Status != "fail" {
|
|
t.Fatalf("no cameras reported as %q", cam.Status)
|
|
}
|
|
if !strings.Contains(cam.Advice, "Add a camera") {
|
|
t.Errorf("advice: %q", cam.Advice)
|
|
}
|
|
}
|
|
|
|
// A camera nobody has tried is not a camera that is down.
|
|
func TestCamerasNotYetTriedAreAWarningNotAFailure(t *testing.T) {
|
|
cams := []Camera{{ID: "c1", SiteID: siteA, CameraID: "entrance"}} // Connected nil
|
|
|
|
by := stepsByName(BuildSiteSteps(healthySite(), cams, time.Now().UTC()))
|
|
cam := by["Cameras are connected"]
|
|
if cam.Status != "warn" {
|
|
t.Fatalf("an untried camera reported as %q, want warn", cam.Status)
|
|
}
|
|
if !strings.Contains(cam.Detail, "none tried yet") {
|
|
t.Errorf("detail: %q", cam.Detail)
|
|
}
|
|
}
|
|
|
|
func TestADownCameraFails(t *testing.T) {
|
|
down := false
|
|
up := true
|
|
cams := []Camera{
|
|
{ID: "c1", SiteID: siteA, CameraID: "entrance", Connected: &up},
|
|
{ID: "c2", SiteID: siteA, CameraID: "till", Connected: &down},
|
|
}
|
|
by := stepsByName(BuildSiteSteps(healthySite(), cams, time.Now().UTC()))
|
|
if got := by["Cameras are connected"].Status; got != "fail" {
|
|
t.Fatalf("a down camera reported as %q", got)
|
|
}
|
|
}
|
|
|
|
// A running process is not a working engine: on a memory-starved box the large
|
|
// model loses the fallback chain and the process stays up regardless.
|
|
func TestAnEngineThatHasNotSaidWhichModelItLoadedIsAWarning(t *testing.T) {
|
|
site := healthySite()
|
|
site.RecognitionModel = ""
|
|
|
|
by := stepsByName(BuildSiteSteps(site, connectedCameras(1), time.Now().UTC()))
|
|
if got := by["Recognition is running"].Status; got != "warn" {
|
|
t.Fatalf("reported %q", got)
|
|
}
|
|
}
|
|
|
|
func TestHumanAgoReadsLikeAPersonWouldSayIt(t *testing.T) {
|
|
now := time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC)
|
|
for _, tc := range []struct {
|
|
ago time.Duration
|
|
want string
|
|
}{
|
|
{10 * time.Second, "just now"},
|
|
{20 * time.Minute, "20 min ago"},
|
|
{5 * time.Hour, "5 h ago"},
|
|
{80 * time.Hour, "3 days ago"},
|
|
} {
|
|
got := humanAgo(now.Add(-tc.ago).Format(time.RFC3339), now)
|
|
if got != tc.want {
|
|
t.Errorf("%s ago -> %q, want %q", tc.ago, got, tc.want)
|
|
}
|
|
}
|
|
if got := humanAgo("not a time", now); got != "at an unknown time" {
|
|
t.Errorf("unparseable -> %q", got)
|
|
}
|
|
}
|