From 1bfcc2769b8a8f8ba158d15581799f284dc10dd3 Mon Sep 17 00:00:00 2001 From: jedarden Date: Wed, 8 Jul 2026 15:40:16 -0400 Subject: [PATCH] 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 Bead-Id: bf-2v9g --- .../analytics/identity_fields_test.go | 120 ++++++++++++++ .../falldetect/identity_fields_test.go | 156 ++++++++++++++++++ .../internal/notify/identity_fields_test.go | 93 +++++++++++ 3 files changed, 369 insertions(+) create mode 100644 mothership/internal/analytics/identity_fields_test.go create mode 100644 mothership/internal/falldetect/identity_fields_test.go create mode 100644 mothership/internal/notify/identity_fields_test.go diff --git a/mothership/internal/analytics/identity_fields_test.go b/mothership/internal/analytics/identity_fields_test.go new file mode 100644 index 0000000..205bd89 --- /dev/null +++ b/mothership/internal/analytics/identity_fields_test.go @@ -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) + } +} diff --git a/mothership/internal/falldetect/identity_fields_test.go b/mothership/internal/falldetect/identity_fields_test.go new file mode 100644 index 0000000..7daefa5 --- /dev/null +++ b/mothership/internal/falldetect/identity_fields_test.go @@ -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") + } +} diff --git a/mothership/internal/notify/identity_fields_test.go b/mothership/internal/notify/identity_fields_test.go new file mode 100644 index 0000000..ada0158 --- /dev/null +++ b/mothership/internal/notify/identity_fields_test.go @@ -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") + } + }) + } +}