Files
Behavision/agent/pkg/cameras/checks_test.go
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

248 lines
8.8 KiB
Go

package cameras
import (
"context"
"errors"
"strings"
"testing"
)
type fakeProber struct {
test TestResult
testErr error
placement map[string]any
placeErr error
placedFor int
// testedWith records the camera the probe was actually handed, which is
// where the credential either arrives or does not.
testedWith Local
}
func (f *fakeProber) Test(_ context.Context, cam Local) (TestResult, error) {
f.testedWith = cam
return f.test, f.testErr
}
func (f *fakeProber) Placement(_ context.Context, _ string, seconds int) (map[string]any, error) {
f.placedFor = seconds
return f.placement, f.placeErr
}
type fakeChecks struct {
jobs []Job
submitted []Result
pendErr error
}
func (f *fakeChecks) Pending(context.Context) ([]Job, error) { return f.jobs, f.pendErr }
func (f *fakeChecks) Submit(_ context.Context, r Result) error {
f.submitted = append(f.submitted, r)
return nil
}
func checker(e *fakeEngine, p *fakeProber, c *fakeChecks) *Syncer {
return &Syncer{Engine: e, Cloud: &fakeCloud{}, Prober: p, Checks: c}
}
func TestAReachableCameraReportsItsResolutionAndAPicture(t *testing.T) {
e := newEngine(Local{ID: "entrance", Host: "192.168.0.138", Connected: true})
p := &fakeProber{test: TestResult{OK: true, Width: 1920, Height: 1080,
Snapshot: "/9j/4AAQSkZJRg=="}}
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
checker(e, p, c).runChecks(context.Background(), nil)
if len(c.submitted) != 1 {
t.Fatalf("submitted %d results", len(c.submitted))
}
got := c.submitted[0]
if !got.OK {
t.Fatalf("a working camera reported as failing: %+v", got)
}
if !strings.Contains(got.Headline, "1920") {
t.Errorf("headline does not say what was found: %q", got.Headline)
}
if got.ImageKey == "" {
t.Error("no picture uploaded, so the operator cannot see what it is pointing at")
}
}
// The engine already tells three different failures apart, and each needs a
// different thing done about it. Carrying its sentence and adding the action is
// the whole design; re-wording it here would be a fourth description of the
// same fault.
func TestEachConnectionFailureGetsItsOwnAdvice(t *testing.T) {
cases := map[string]string{
"connection refused": "port",
"host unreachable": "powered on",
"could not open stream - check the host, port, path and credentials": "stream path",
"connected but no frame arrived within 12s": "one viewer at a time",
}
for engineErr, want := range cases {
e := newEngine(Local{ID: "entrance", Connected: true})
p := &fakeProber{test: TestResult{OK: false, Error: engineErr}}
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
checker(e, p, c).runChecks(context.Background(), nil)
got := c.submitted[0]
if got.OK {
t.Errorf("%q reported as a pass", engineErr)
}
// The engine's own sentence must survive intact.
if got.Headline != engineErr {
t.Errorf("headline %q, want the engine's own words %q", got.Headline, engineErr)
}
joined := strings.ToLower(strings.Join(got.Advice, " "))
if !strings.Contains(joined, want) {
t.Errorf("%q -> advice %q, expected it to mention %q", engineErr, joined, want)
}
}
}
// Only `good` is a pass. `marginal` means half the visitors are silently
// discarded, and signing that off as working is exactly how a site runs for
// weeks recognising almost nobody.
func TestOnlyAGoodPlacementCounts(t *testing.T) {
for verdict, wantOK := range map[string]bool{
"good": true, "marginal": false, "poor": false,
"no_faces": false, "artifact": false, "inconclusive": false,
"no_completed_passes": false,
} {
e := newEngine(Local{ID: "entrance", Connected: true})
p := &fakeProber{placement: map[string]any{
"verdict": verdict, "headline": "h",
"advice": []any{"do the thing"},
}}
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "placement", Seconds: 25}}}
checker(e, p, c).runChecks(context.Background(), nil)
if got := c.submitted[0].OK; got != wantOK {
t.Errorf("verdict %q -> ok=%v, want %v", verdict, got, wantOK)
}
}
}
// The engine's advice is written for the person standing next to the camera.
// It must reach them.
func TestThePlacementAdviceIsCarriedThroughVerbatim(t *testing.T) {
e := newEngine(Local{ID: "entrance", Connected: true})
p := &fakeProber{placement: map[string]any{
"verdict": "poor",
"headline": "most visitors here cannot be recognised",
"advice": []any{
"Face the camera the way people walk in, at about head height.",
"Re-run this check after moving it.",
},
"faces": float64(11),
}}
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "placement", Seconds: 25}}}
checker(e, p, c).runChecks(context.Background(), nil)
got := c.submitted[0]
if got.Headline != "most visitors here cannot be recognised" {
t.Errorf("headline changed: %q", got.Headline)
}
if len(got.Advice) != 2 || !strings.Contains(got.Advice[0], "head height") {
t.Errorf("advice did not survive: %+v", got.Advice)
}
// Everything else the engine said travels too, so a new field reaches the
// UI without a schema change on the way.
if got.Detail["faces"] != float64(11) {
t.Errorf("detail was dropped: %+v", got.Detail)
}
if p.placedFor != 25 {
t.Errorf("watched for %ds, want 25", p.placedFor)
}
}
// A camera added at head office 30 seconds ago has not reached the PC yet.
// Telling the operator to check the cabling would send them to the wrong place.
func TestACameraTheShopPCHasNotAppliedYetSaysSo(t *testing.T) {
e := newEngine() // engine knows nothing about it
p := &fakeProber{}
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
checker(e, p, c).runChecks(context.Background(), nil)
got := c.submitted[0]
if got.OK {
t.Fatal("reported a pass for a camera that does not exist here")
}
if !strings.Contains(got.Headline, "not set up that camera yet") {
t.Errorf("headline blames the wrong thing: %q", got.Headline)
}
if !strings.Contains(strings.Join(got.Advice, " "), "Try again shortly") {
t.Errorf("advice does not tell them to wait: %+v", got.Advice)
}
}
// "The engine is not running" and "the camera is broken" need opposite actions.
func TestAStoppedEngineIsNotReportedAsABrokenCamera(t *testing.T) {
e := newEngine()
e.listErr = errors.New("connection refused")
p := &fakeProber{}
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
checker(e, p, c).runChecks(context.Background(), nil)
got := c.submitted[0]
if !strings.Contains(got.Headline, "not responding") {
t.Fatalf("headline blames the camera: %q", got.Headline)
}
if !strings.Contains(strings.Join(got.Advice, " "), "make sure it is started") {
t.Errorf("advice: %+v", got.Advice)
}
}
// An unclaimed PC has neither, and must not treat that as a fault.
func TestASyncerWithNoCheckSupportSkipsQuietly(t *testing.T) {
s := &Syncer{Engine: newEngine(), Cloud: &fakeCloud{}}
s.runChecks(context.Background(), nil) // must not panic
}
func TestNoPendingChecksSubmitsNothing(t *testing.T) {
c := &fakeChecks{}
checker(newEngine(), &fakeProber{}, c).runChecks(context.Background(), nil)
if len(c.submitted) != 0 {
t.Fatalf("submitted %d results with no jobs", len(c.submitted))
}
}
// The engine deliberately never returns a camera password - it reports
// has_password and nothing else - so probing with what the engine hands back
// dials the camera with an empty credential. That reported "could not open
// stream - check the host, port, path and credentials" about a camera the very
// same PC had been streaming for an hour, and sent the installer to check the
// one thing that had never been sent.
func TestTheProbeIsGivenThePasswordHeadOfficeHolds(t *testing.T) {
e := newEngine(Local{ID: "entrance", Host: "192.168.0.138", Username: "admin",
Connected: true}) // no Password: the engine does not return one
p := &fakeProber{test: TestResult{OK: true, Width: 1920, Height: 1080}}
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
desired := []Desired{{CameraID: "entrance", Username: "admin", Password: "hunter2"}}
checker(e, p, c).runChecks(context.Background(), desired)
if p.testedWith.Password != "hunter2" {
t.Fatalf("the probe was handed password %q - a working camera would be "+
"reported unreachable", p.testedWith.Password)
}
}
// A password the engine DOES have is not overwritten by head office's copy:
// the local one is what the camera is actually being streamed with.
func TestALocalPasswordWins(t *testing.T) {
e := newEngine(Local{ID: "entrance", Password: "local", Connected: true})
p := &fakeProber{test: TestResult{OK: true}}
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
checker(e, p, c).runChecks(context.Background(),
[]Desired{{CameraID: "entrance", Password: "remote"}})
if p.testedWith.Password != "local" {
t.Fatalf("probe used %q, want the engine's own", p.testedWith.Password)
}
}