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:
parent
5d3f3180cc
commit
1bfcc2769b
3 changed files with 369 additions and 0 deletions
120
mothership/internal/analytics/identity_fields_test.go
Normal file
120
mothership/internal/analytics/identity_fields_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
156
mothership/internal/falldetect/identity_fields_test.go
Normal file
156
mothership/internal/falldetect/identity_fields_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
93
mothership/internal/notify/identity_fields_test.go
Normal file
93
mothership/internal/notify/identity_fields_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue