Files
Behavision/desktop/viewing_test.go
Suriyakumarvijayanayagam ecc8bbba6f The app on a laptop reported an engine that was never meant to be there
Signing in on a second Mac showed "engine not reachable at
http://127.0.0.1:8010" and 0 of 0 cameras, on an account whose shops were
running and recognising people the whole time. Nothing was broken: Live() and
Cameras() read only the engine on loopback, so the app answered as though the
person had never signed in - and camera sync goes through the engine, which is
why the count was zero rather than stale.

Having no engine is a normal state. A shop PC watches cameras; an owner's
laptop, a manager's machine and a second till being set up do not, and all
three are signed in to the same estate. Both methods now fall back to head
office when loopback fails and somebody is signed in. Loopback is still tried
first: a real shop PC must never be shown a minute-old summary when the engine
two milliseconds away has the live one.

Decisions worth keeping:

- Viewing is on the snapshot, not inferred per screen. Three surfaces read it,
  and a screen that computed it separately is how the shops screen once came
  out labelled Working, in green, above "2 of 3 cameras not connecting".
- fraction_below_gate takes the WORST shop, never an average. 0.10 against
  0.73 averages to 0.42 and hides the only shop anyone needs to visit.
- A remote camera is flagged, and Edit, Remove and Check placement are
  withheld. They talk to a camera on a LAN this computer cannot reach, and a
  button that cannot work is worse than one that is absent.
- connected is three states. null is "no shop computer has reported yet" and
  reads as waiting; false is "Not connecting". A bare false sends somebody to
  check cabling on a camera nobody has tried to reach.
- Snapshots are fetched in Go as data: URIs and cached by snapshot_at. A
  webview <img> resolves a relative src against wails:// and cannot send the
  bearer - the problem VisitorImage already solved - and this screen polls
  every 8 seconds at ~90 KB a camera.
- With no engine AND no session, the engine error is still the answer. The
  person is most likely setting this PC up.

The picture is the last snapshot and the banner says so: there is no live
video from here, because the engine's MJPEG stream is on the shop PC's
loopback behind a router with no inbound route. The LiveHub relay head office
uses is the answer to that and is a further step for this client.

Verified against production: five arrivals and two cameras parsed from the
real API. viewing_test.go covers the fallback, the worst-shop rule, the
withheld credentials and that an unchanged snapshot is fetched once across
two polls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 16:33:32 +05:30

167 lines
6.1 KiB
Go

package main
import (
"context"
"encoding/base64"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/loyaly/behavision-desktop/internal/cloud"
"github.com/loyaly/behavision-desktop/internal/local"
)
// Viewer mode: what the app shows on a computer that is signed in and is not
// itself watching any cameras.
//
// This is the friend's-Mac case, and before it existed the app was honest and
// useless: Live() and Cameras() read ONLY the engine on 127.0.0.1, so a laptop
// with no engine got "engine not reachable at http://127.0.0.1:8010" and
// "0 of 0 cameras" - on an account whose shops were running and recognising
// people the whole time. Signing in is what the person did; the app answered
// as if they had not.
//
// The engine here is a port nothing listens on, which is precisely what a PC
// with no engine is.
const noEngine = "http://127.0.0.1:1" // reserved, refuses immediately
func viewerApp(t *testing.T, srv *httptest.Server) *App {
t.Helper()
c := cloud.New(srv.URL)
c.SetSession(cloud.Session{Token: "test-token"})
return &App{
ctx: context.Background(),
cloud: c,
local: local.New(noEngine, "", ""),
}
}
func TestLiveFallsBackToHeadOfficeWhenThereIsNoEngine(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/api/sites":
// Two shops. One is fine, one is the Office1 case.
w.Write([]byte(`[
{"slug":"a","name":"A","cameras_up":2,"cameras_total":2,"fraction_below_gate":0.10},
{"slug":"b","name":"B","cameras_up":1,"cameras_total":3,"fraction_below_gate":0.73}
]`))
case strings.HasPrefix(r.URL.Path, "/api/visits"):
w.Write([]byte(`{"arrivals":[
{"visit_id":"v1","visitor_id":"p1","ref":"V-1","label":"Visitor 1","camera_id":"cam2","is_new_visitor":true},
{"visit_id":"v2","visitor_id":"p1","ref":"V-1","label":"Visitor 1","camera_id":"cam2"},
{"visit_id":"v3","camera_id":"entrance"}
]}`))
default:
t.Errorf("unexpected request %s", r.URL.Path)
}
}))
defer srv.Close()
snap, err := viewerApp(t, srv).Live()
if err != nil {
t.Fatalf("Live: %v", err)
}
if !snap.Viewing {
t.Fatal("the snapshot did not say it was a view of somewhere else")
}
if got := snap.Stats["cameras_up"]; got != 3 {
t.Errorf("cameras_up = %v, want 3 summed across both shops", got)
}
if got := snap.Stats["cameras_total"]; got != 5 {
t.Errorf("cameras_total = %v, want 5", got)
}
// The WORST site, never an average. Averaging 0.10 against 0.73 reports
// 0.42 and hides the only shop anyone needs to go and fix - the same rule
// the heartbeat already follows with worst_site.
if got := snap.Stats["fraction_below_gate"]; got != 0.73 {
t.Errorf("fraction_below_gate = %v, want the worst shop's 0.73", got)
}
// Three arrivals, two of them the same person, one unidentified. A visit
// with no visitor_id is real footfall and an unknown person, so it counts
// as a sighting and not as somebody known.
g := snap.Stats["gallery"].(map[string]any)
if g["identities"] != 1 || g["sightings"] != 3 {
t.Errorf("gallery = %v, want 1 identity over 3 sightings", g)
}
if len(snap.Events) != 3 {
t.Fatalf("got %d events, want 3", len(snap.Events))
}
if snap.Events[0]["type"] != "person.new" || snap.Events[1]["type"] != "person.seen" {
t.Errorf("arrival types wrong: %v", snap.Events)
}
}
// Nobody signed in: the local failure is the honest answer. There is nothing
// else to show, and the person is most likely setting this PC up - telling
// them about head office would be telling them about something they have not
// got to yet.
func TestLiveWithNoEngineAndNoSessionReportsTheEngine(t *testing.T) {
a := &App{ctx: context.Background(), cloud: cloud.New("https://example.invalid"),
local: local.New(noEngine, "", "")}
if _, err := a.Live(); err == nil {
t.Fatal("want the engine error, got nil")
}
}
// A remote camera is flagged, because the screen has to withhold every button
// that would talk to a camera on a network this computer cannot reach. An Edit
// button that cannot work is worse than one that is absent.
func TestRemoteCamerasAreFlaggedAndCarryNoCredentials(t *testing.T) {
jpeg := base64.StdEncoding.EncodeToString([]byte{0xFF, 0xD8, 0xFF, 0xD9})
var shots int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/camera-snapshots/") {
shots++
w.Header().Set("Content-Type", "image/jpeg")
b, _ := base64.StdEncoding.DecodeString(jpeg)
w.Write(b)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[
{"id":"c1","camera_id":"cam2","label":"Open office","site":"Coimbatore",
"connected":true,"snapshot_at":"2026-09-30T10:00:00Z",
"snapshot":{"available":true,"url":"/api/camera-snapshots/c1.jpg","auth":true}}
]`))
}))
defer srv.Close()
a := viewerApp(t, srv)
cams, err := a.Cameras()
if err != nil {
t.Fatalf("Cameras: %v", err)
}
if len(cams) != 1 {
t.Fatalf("got %d cameras, want 1", len(cams))
}
if cams[0]["remote"] != true {
t.Error("the camera was not flagged remote")
}
// The RTSP details are a live path into the camera itself and the server
// does not send them to a tenant at all. Nothing here may invent them.
for _, k := range []string{"host", "port", "path", "username", "password"} {
if _, ok := cams[0][k]; ok {
t.Errorf("a remote camera carried %q", k)
}
}
// The picture has to be fetched here: a webview <img> resolves a relative
// src against wails:// and cannot send the session's bearer.
shot := cams[0]["snapshot"].(cloud.Photo)
if !strings.HasPrefix(shot.URL, "data:image/jpeg;base64,") || shot.Auth {
t.Errorf("snapshot url = %q auth=%v, want an inline data URI", shot.URL, shot.Auth)
}
// And fetched ONCE. This screen polls every 8 seconds and a real snapshot
// is ~90 KB, so re-fetching an unchanged picture is megabytes an hour to
// redraw the same frame.
if _, err := a.Cameras(); err != nil {
t.Fatalf("second poll: %v", err)
}
if shots != 1 {
t.Errorf("fetched the same snapshot %d times across two polls", shots)
}
}