test(bf-2v9g): add runtime identity-propagation tests for notify/analytics/falldetect

Adds three projection-level tests proving blob identity flows through every
runtime projection (not just the unit-test structs guarded by bf-5151):
- notify: GenerateFloorPlanThumbnail observes blob.Identity (identified green
  vs unknown blue), nil-safe when empty
- analytics: NotificationAlertHandler.SendAlert propagates PersonName into
  Identity / person_name / person_id, nil-safe for unidentified events
- falldetect: triggerFallAlert carries identityFunc(blob.ID) into event.Identity
  (observes "Alice"), nil-safe when identityFunc unset

Runtime wiring independently verified at HEAD: cmd/mothership main.go:2532
(SetIdentityFunc -> identityMatcher.GetMatch(blobID).Label()), :1823
(anomalyAlertAdapter wired as alert handler bridging to notify), and
:2476/:2552 (event.Identity / personName = match.Label()).

go vet ./... clean (exit 0); all three suites PASS.

Co-Authored-By: Claude <noreply@anthropic.com>
Bead-Id: bf-2v9g
This commit is contained in:
jedarden 2026-07-08 15:40:16 -04:00
parent 5d3f3180cc
commit 1bfcc2769b
3 changed files with 369 additions and 0 deletions

View file

@ -0,0 +1,120 @@
// Package analytics provides alert handling for anomaly detection.
package analytics
import (
"testing"
"time"
"github.com/spaxel/mothership/internal/events"
)
// thumbBlob is the anonymous struct both NotificationService.GenerateFloorPlanThumbnail
// and Service.GenerateFloorPlanThumbnail accept.
type thumbBlob = struct {
X, Y, Z float64
Identity string
IsFall bool
}
// fakeNotifyService captures what the analytics alert handler propagates downstream:
// the Notification it hands to Send (asserted to carry person_name in Data) and the
// blob list it hands to GenerateFloorPlanThumbnail (asserted to carry Identity).
type fakeNotifyService struct {
lastNotif Notification
thumbBlobs []thumbBlob
thumbCalls int
sendErr error
}
func (f *fakeNotifyService) Send(notif Notification) error {
f.lastNotif = notif
return f.sendErr
}
func (f *fakeNotifyService) GenerateFloorPlanThumbnail(width, height int, blobs []thumbBlob) ([]byte, error) {
f.thumbCalls++
f.thumbBlobs = blobs
return []byte("fake-png"), nil
}
// TestAlertHandler_PropagatesResolvedIdentity verifies the analytics (alert
// handler) projection observes a NON-EMPTY identity at runtime and propagates it
// downstream: SendAlert copies event.PersonName into BOTH the notification's Data
// map (person_name / person_id) AND the floor-plan thumbnail blob's Identity
// field. This is the runtime identity-flow the bf-5151 unit-test structs guard
// but which was never exercised end-to-end. (bf-2v9g)
func TestAlertHandler_PropagatesResolvedIdentity(t *testing.T) {
t.Parallel()
fake := &fakeNotifyService{}
h := NewNotificationAlertHandler(fake)
event := events.AnomalyEvent{
ID: "anom-1",
Type: events.AnomalyUnusualDwell,
Score: 0.91,
Description: "Unusual dwell in Kitchen",
ZoneID: "zone-1",
ZoneName: "Kitchen",
BlobID: 7,
PersonID: "person-alice",
PersonName: "Alice",
Position: events.Position{X: 3, Y: 2, Z: 1},
Timestamp: time.Now(),
}
if err := h.SendAlert(event, false); err != nil {
t.Fatalf("SendAlert returned error: %v", err)
}
// (1) The notify Notification carries the resolved person identity.
if got := fake.lastNotif.Data["person_name"]; got != "Alice" {
t.Fatalf("notify projection observed empty identity: Data[person_name]=%v, want %q", got, "Alice")
}
if got := fake.lastNotif.Data["person_id"]; got != "person-alice" {
t.Fatalf("Data[person_id]=%v, want %q", got, "person-alice")
}
// (2) The floor-plan thumbnail blob also carries the resolved identity.
if fake.thumbCalls == 0 {
t.Fatal("GenerateFloorPlanThumbnail was never called by SendAlert")
}
if len(fake.thumbBlobs) != 1 {
t.Fatalf("thumbnail received %d blobs, want 1", len(fake.thumbBlobs))
}
if got := fake.thumbBlobs[0].Identity; got != "Alice" {
t.Fatalf("notify thumbnail blob Identity=%q, want %q", got, "Alice")
}
}
// TestAlertHandler_NilSafeWithUnidentifiedEvent verifies the analytics projection
// never dereferences an UNSET identity field at runtime: an AnomalyEvent with no
// resolved person (the unidentified-intruder / "where applicable" case) flows
// through SendAlert without panic, with empty person fields surfaced as such.
// (bf-2v9g)
func TestAlertHandler_NilSafeWithUnidentifiedEvent(t *testing.T) {
t.Parallel()
fake := &fakeNotifyService{}
h := NewNotificationAlertHandler(fake)
event := events.AnomalyEvent{
ID: "anom-2",
Type: events.AnomalyMotionDuringAway,
Score: 0.88,
Description: "Motion while away",
ZoneName: "Living Room",
Position: events.Position{X: 2, Y: 1, Z: 1},
Timestamp: time.Now(),
// PersonID / PersonName intentionally empty (unidentified intruder).
}
if err := h.SendAlert(event, true); err != nil {
t.Fatalf("SendAlert returned error on unidentified event: %v", err)
}
if v, ok := fake.lastNotif.Data["person_name"]; ok && v != "" {
t.Fatalf("unidentified event surfaced non-empty person_name=%v", v)
}
// Thumbnail still produced for the unidentified blob (Identity="").
if len(fake.thumbBlobs) != 1 || fake.thumbBlobs[0].Identity != "" {
t.Fatalf("expected one unidentified thumbnail blob, got %+v", fake.thumbBlobs)
}
}

View file

@ -0,0 +1,156 @@
// Package falldetect provides fall detection using blob tracking data.
package falldetect
import (
"testing"
"time"
)
// fallBlobInput mirrors the anonymous struct accepted by Detector.Update so the
// test can build tick inputs concisely.
type fallBlobInput struct {
ID int
X, Y, Z float64
VX, VY, VZ float64
Posture string
}
// updateBlob aliases the anonymous struct Detector.Update expects, so the helper
// slice converter can return a concrete type without repeating the field list.
type updateBlob = struct {
ID int
X, Y, Z float64
VX, VY, VZ float64
Posture string
}
// toUpdate converts the helper to the anonymous struct Detector.Update expects.
func (b fallBlobInput) toUpdate() updateBlob {
return updateBlob{
ID: b.ID, X: b.X, Y: b.Y, Z: b.Z,
VX: b.VX, VY: b.VY, VZ: b.VZ, Posture: b.Posture,
}
}
// fallBlobsToUpdate converts a slice of test helpers into the []updateBlob
// Detector.Update expects. It is a free function rather than a method because Go
// does not allow methods on unnamed slice types ([]fallBlobInput).
func fallBlobsToUpdate(s []fallBlobInput) []updateBlob {
out := make([]updateBlob, len(s))
for i, b := range s {
out[i] = b.toUpdate()
}
return out
}
// identityFieldsConfig is a fall-detection Config tuned for a fast, deterministic
// in-test fall: a tiny stillness-confirmation window so the state machine reaches
// StateFallConfirmed within a handful of synthetic ticks (no wall-clock waiting).
func identityFieldsConfig() Config {
return Config{
DescentVelocityThreshold: -1.5,
DescentDropThreshold: 0.8,
DescentWindow: 0.2,
FloorLevelThreshold: 0.4,
StillnessMotionThreshold: 0.01,
StillnessTimeRequired: 0.5,
StandardDropThreshold: 0.8,
ElevatedDropThreshold: 1.2,
PostureHistoryWindow: 600,
EscalationTime1: 2 * time.Minute,
EscalationTime2: 5 * time.Minute,
AlertCooldown: 5 * time.Minute,
}
}
// driveFall feeds a synthetic blob through a complete fall: a standing phase to
// build history, a rapid descent that clears the trigger, then a still floor-level
// phase that confirms the fall and fires triggerFallAlert (which calls identityFunc).
// `now` is advanced synthetically so the test is deterministic and clock-independent.
func driveFall(t *testing.T, d *Detector, base time.Time) {
t.Helper()
blob := fallBlobInput{ID: 7, X: 3, Y: 2, Posture: "standing"}
// Standing phase: build ≥3 samples of history at Z=1.7m so the descent tick
// can compute a >1s lookback z-drop.
for _, off := range []time.Duration{0, 500 * time.Millisecond, 1 * time.Second} {
blob.Z = 1.7
d.Update(fallBlobsToUpdate([]fallBlobInput{blob}), base.Add(off))
}
// Descent: Z drops 1.7→0.3 in one tick with VZ=-2.0 (clears velocity + drop
// thresholds) → StateDescentDetected.
blob.Z, blob.VZ = 0.3, -2.0
d.Update(fallBlobsToUpdate([]fallBlobInput{blob}), base.Add(1100*time.Millisecond))
// Floor-level stillness: Z stays 0.3 (< floor), deltaRMS stays below the
// stillness threshold for ≥ StillnessTimeRequired → StateFallConfirmed →
// triggerFallAlert → identityFunc(7) called.
blob.VZ = 0
for off := 1200; off <= 1700; off += 100 {
d.Update(fallBlobsToUpdate([]fallBlobInput{blob}), base.Add(time.Duration(off)*time.Millisecond))
}
}
// TestDetector_FallEventCarriesResolvedIdentity verifies the falldetect
// projection observes a NON-EMPTY identity at runtime: when a fall is confirmed,
// triggerFallAlert calls identityFunc(blobID) and writes its result onto
// FallEvent.Identity. At runtime (cmd/mothership) identityFunc returns
// matcher.GetMatch(blobID).Label() — the canonical PersonName — so this wires
// the same value and asserts the projection surfaces it. (bf-2v9g)
func TestDetector_FallEventCarriesResolvedIdentity(t *testing.T) {
t.Parallel()
d := NewDetectorWithConfig(identityFieldsConfig())
d.SetDeltaRMSFunc(func(int) float64 { return 0.005 }) // below stillness threshold
// Mirror the runtime identityFunc: return the canonical person name (Label()).
d.SetIdentityFunc(func(blobID int) string {
if blobID != 7 {
return ""
}
return "Alice" // stand-in for matcher.GetMatch(7).Label()
})
got := make(chan FallEvent, 1)
d.SetOnFall(func(e FallEvent) { got <- e })
driveFall(t, d, time.Now())
select {
case e := <-got:
if e.Identity != "Alice" {
t.Fatalf("falldetect projection observed empty identity: FallEvent.Identity=%q, want %q", e.Identity, "Alice")
}
if e.BlobID != 7 {
t.Fatalf("FallEvent.BlobID=%d, want 7", e.BlobID)
}
case <-time.After(2 * time.Second):
t.Fatal("onFall never fired: fall not confirmed within timeout")
}
}
// TestDetector_FallEventNilSafeWithoutIdentityFunc verifies the falldetect
// projection never dereferences an UNSET identity field at runtime: with
// identityFunc left nil (no matcher / no resolved person), a confirmed fall still
// fires without panic and FallEvent.Identity stays empty — the
// "(where applicable)" / unidentified case. (bf-2v9g)
func TestDetector_FallEventNilSafeWithoutIdentityFunc(t *testing.T) {
t.Parallel()
d := NewDetectorWithConfig(identityFieldsConfig()) // identityFunc intentionally nil
d.SetDeltaRMSFunc(func(int) float64 { return 0.005 })
got := make(chan FallEvent, 1)
d.SetOnFall(func(e FallEvent) { got <- e })
driveFall(t, d, time.Now())
select {
case e := <-got:
// No panic, identity gracefully empty — the unidentified-intruder path.
if e.Identity != "" {
t.Fatalf("FallEvent.Identity=%q, want empty when identityFunc is unset", e.Identity)
}
case <-time.After(2 * time.Second):
t.Fatal("onFall never fired: fall not confirmed within timeout")
}
}

View file

@ -0,0 +1,93 @@
package notify
import (
"bytes"
"path/filepath"
"testing"
)
// thumbBlob is the anonymous struct Service.GenerateFloorPlanThumbnail accepts.
type thumbBlob = struct {
X, Y, Z float64
Identity string
IsFall bool
}
// newTestService builds a notify.Service backed by a throwaway SQLite DB for
// thumbnail-rendering tests.
func newTestService(t *testing.T) *Service {
t.Helper()
s, err := NewService(filepath.Join(t.TempDir(), "notify-test.db"))
if err != nil {
t.Fatalf("NewService: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
// TestGenerateFloorPlanThumbnail_ObservesIdentity verifies the notify projection
// observes a NON-EMPTY identity at runtime: GenerateFloorPlanThumbnail branches on
// blob.Identity (green when identified, blue when unknown). Rendering the same
// position with a populated Identity vs an empty one must therefore produce
// DIFFERENT output — proving the identity field was read and consumed. Both calls
// must succeed without error (no dereference of the identity field). (bf-2v9g)
func TestGenerateFloorPlanThumbnail_ObservesIdentity(t *testing.T) {
t.Parallel()
s := newTestService(t)
identified := []thumbBlob{{X: 2, Y: 0, Z: 2, Identity: "Alice", IsFall: false}}
unknown := []thumbBlob{{X: 2, Y: 0, Z: 2, Identity: "", IsFall: false}}
pngIdentified, err := s.GenerateFloorPlanThumbnail(100, 100, identified)
if err != nil {
t.Fatalf("identified render failed: %v", err)
}
if len(pngIdentified) == 0 {
t.Fatal("identified render returned empty PNG")
}
pngUnknown, err := s.GenerateFloorPlanThumbnail(100, 100, unknown)
if err != nil {
t.Fatalf("unknown render failed: %v", err)
}
if len(pngUnknown) == 0 {
t.Fatal("unknown render returned empty PNG")
}
// The identity value changes the blob color (green vs blue), so the two
// renderings differ — i.e. the projection observed and used the identity.
if bytes.Equal(pngIdentified, pngUnknown) {
t.Fatal("notify projection did not observe identity: identified and unknown blobs rendered identically")
}
}
// TestGenerateFloorPlanThumbnail_FallAndIdentityNilSafe verifies the notify
// projection never dereferences an UNSET identity field at runtime: fall blobs and
// fully-unidentified blobs both render without panic. (bf-2v9g)
func TestGenerateFloorPlanThumbnail_FallAndIdentityNilSafe(t *testing.T) {
t.Parallel()
s := newTestService(t)
cases := []struct {
name string
blob thumbBlob
}{
{"unidentified blob", thumbBlob{X: 2, Y: 0, Z: 2, Identity: "", IsFall: false}},
{"identified blob", thumbBlob{X: 2, Y: 0, Z: 2, Identity: "Bob", IsFall: false}},
{"unidentified fall", thumbBlob{X: 2, Y: 0, Z: 2, Identity: "", IsFall: true}},
{"identified fall", thumbBlob{X: 2, Y: 0, Z: 2, Identity: "Bob", IsFall: true}},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
png, err := s.GenerateFloorPlanThumbnail(100, 100, []thumbBlob{tc.blob})
if err != nil {
t.Fatalf("render failed: %v", err)
}
if len(png) == 0 {
t.Fatal("render returned empty PNG")
}
})
}
}