spaxel/mothership/internal/analytics/flow_test.go
jedarden 77a2fbc9c0 test: implement acceptance scenario integration tests (AS-1 through AS-6)
- Added comprehensive integration tests in test/acceptance/ covering all 6 acceptance scenarios from plan.md
- AS-1: First-time setup in under 5 minutes - verifies PIN setup and node auto-discovery
- AS-2: Person detected while walking - verifies blob detection during walker simulation
- AS-3: Fall alert fires correctly - verifies fall detection with webhook integration
- AS-4: BLE identity resolves to person name - verifies BLE device registration and identity matching
- AS-5: OTA update succeeds / rollback on bad firmware - verifies OTA workflow and rollback
- AS-6: Replay shows recorded history - verifies replay session creation, seeking, and playback

Tests use spaxel-sim CLI as the test harness and verify:
- API endpoint responses (/api/auth/setup, /api/nodes, /api/blobs, /api/events, /api/ble/devices, /api/replay/*)
- Detection accuracy thresholds (>60% blob presence during walking)
- Alert generation and webhook delivery
- Firmware version updates and rollback behavior
- Replay session lifecycle management

All tests skip by default unless ACCEPTANCE_TEST=1 or SPAXEL_INTEGRATION_TEST=1 is set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 05:45:15 -04:00

481 lines
13 KiB
Go

// Package analytics provides crowd flow visualization and analysis.
package analytics
import (
"database/sql"
"os"
"path/filepath"
"testing"
"time"
_ "modernc.org/sqlite"
)
const (
testGridCellSize = 0.25 // meters - matches defaultGridCellM
)
func TestFlowAccumulator_TrajectorySampling(t *testing.T) {
// Create temp database
tmpDir, err := os.MkdirTemp("", "flow_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir) //nolint:errcheck
dbPath := filepath.Join(tmpDir, "test.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close() //nolint:errcheck
fa := NewFlowAccumulator(db, testGridCellSize)
if err := fa.InitSchema(); err != nil {
t.Fatalf("Failed to init schema: %v", err)
}
defer fa.Close() //nolint:errcheck
// Test: track moves 0.25m -> segment recorded
// First update establishes the waypoint
fa.AddTrackUpdate("track-1", 0, 0, 0, 0.25, 0, 0, "person1")
// Second update 0.25m away should create a segment
fa.AddTrackUpdate("track-1", 0.25, 0, 0, 0.25, 0, 0, "person1")
// Flush buffers
fa.Flush() //nolint:errcheck
// Verify segment was recorded by checking the database directly
var segmentCount int
err = db.QueryRow(`SELECT COUNT(*) FROM trajectory_segments`).Scan(&segmentCount)
if err != nil {
t.Fatalf("Failed to query segments: %v", err)
}
if segmentCount == 0 {
t.Error("Expected at least one segment after 0.25m movement")
}
// Test: track moves 0.05m -> no segment
fa.AddTrackUpdate("track-2", 0, 0, 0, 0.05, 0, 0, "person2")
fa.AddTrackUpdate("track-2", 0.05, 0, 0, 0.05, 0, 0, "person2")
// Flush buffers
fa.Flush() //nolint:errcheck
// This small movement should not create a new segment (0.05 < 0.2 threshold)
var track2Count int
err = db.QueryRow(`SELECT COUNT(*) FROM trajectory_segments WHERE person_id = ?`, "person2").Scan(&track2Count)
if err != nil {
t.Fatalf("Failed to query track 2 segments: %v", err)
}
// The track-2 person_id may not have any segments since the movement was too small
// We need to check if we still only have 1 segment from track-1
var totalCount int
err = db.QueryRow(`SELECT COUNT(*) FROM trajectory_segments`).Scan(&totalCount)
if err != nil {
t.Fatalf("Failed to query total segments: %v", err)
}
if totalCount != 1 {
t.Errorf("Expected 1 segment (only from track-1), got %d", totalCount)
}
}
func TestFlowAccumulator_FlowVectorAveraging(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "flow_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir) //nolint:errcheck
dbPath := filepath.Join(tmpDir, "test.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close() //nolint:errcheck
fa := NewFlowAccumulator(db, testGridCellSize)
if err := fa.InitSchema(); err != nil {
t.Fatalf("Failed to init schema: %v", err)
}
defer fa.Close() //nolint:errcheck
// Create 5 segments all pointing East (positive X direction)
for i := 0; i < 5; i++ {
trackID := string(rune('a' + i))
fa.AddTrackUpdate(trackID, float64(i)*0.5, 0, 0, 0.3, 0, 0, "")
fa.AddTrackUpdate(trackID, float64(i)*0.5+0.3, 0, 0, 0.3, 0, 0, "")
}
// Flush buffers
fa.Flush() //nolint:errcheck
// The flow vectors should average to approximately (1, 0) direction
// Since all segments point in the same direction
// Get flow map to verify
since := time.Now().Add(-time.Hour)
until := time.Now()
flowMap, err := fa.ComputeFlowMap(nil, &since, &until)
if err != nil {
t.Fatalf("Failed to compute flow map: %v", err)
}
if len(flowMap.Cells) == 0 {
t.Error("Expected at least one flow cell from segments")
}
// Check that the flow vectors are generally pointing East (positive X)
for _, cell := range flowMap.Cells {
if cell.VX < 0 {
t.Errorf("Expected positive VX (East direction), got %f", cell.VX)
}
}
}
func TestFlowAccumulator_DwellAccumulation(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "flow_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir) //nolint:errcheck
dbPath := filepath.Join(tmpDir, "test.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close() //nolint:errcheck
fa := NewFlowAccumulator(db, testGridCellSize)
if err := fa.InitSchema(); err != nil {
t.Fatalf("Failed to init schema: %v", err)
}
defer fa.Close() //nolint:errcheck
// Create 100 stationary updates at the same location
gridX := 5
gridY := 7
x := (float64(gridX) + 0.5) * testGridCellSize
y := (float64(gridY) + 0.5) * testGridCellSize
// First update to establish waypoint
fa.AddTrackUpdate("track-1", x, y, 0, 0, 0, 0, "person1")
// 99 more stationary updates (speed = 0)
for i := 0; i < 99; i++ {
fa.AddTrackUpdate("track-1", x, y, 0, 0, 0, 0, "person1")
}
// Flush buffers
fa.Flush() //nolint:errcheck
// Get dwell heatmap
heatmap, err := fa.ComputeDwellHeatmap(nil)
if err != nil {
t.Fatalf("Failed to get dwell heatmap: %v", err)
}
// Find the cell at gridX, gridY
var foundCell *DwellCell
for _, cell := range heatmap.Cells {
if cell.GridX == gridX && cell.GridY == gridY {
foundCell = &cell
break
}
}
if foundCell == nil {
t.Errorf("Expected to find dwell cell at (%d, %d)", gridX, gridY)
} else if foundCell.Count < 99 {
t.Errorf("Expected dwell count >= 99, got %d", foundCell.Count)
}
}
func TestFlowAccumulator_CorridorDetection(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "flow_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir) //nolint:errcheck
dbPath := filepath.Join(tmpDir, "test.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close() //nolint:errcheck
fa := NewFlowAccumulator(db, testGridCellSize)
if err := fa.InitSchema(); err != nil {
t.Fatalf("Failed to init schema: %v", err)
}
defer fa.Close() //nolint:errcheck
// Create 20 aligned segments in adjacent cells (simulating a corridor)
// All moving in +X direction
for i := 0; i < 20; i++ {
trackID := string(rune('a' + i))
x := float64(i) * 0.25
fa.AddTrackUpdate(trackID, x, 0, 1.0, 0.25, 0, 0, "")
fa.AddTrackUpdate(trackID, x+0.25, 0, 1.0, 0.25, 0, 0, "")
}
// Flush buffers
fa.Flush() //nolint:errcheck
// Run corridor detection
_, err = fa.DetectCorridors()
if err != nil {
t.Fatalf("Failed to compute corridors: %v", err)
}
// Get corridors
corridors, err := fa.GetCorridors()
if err != nil {
t.Fatalf("Failed to get corridors: %v", err)
}
// With aligned segments, we should detect at least one corridor
if len(corridors) == 0 {
t.Log("Warning: No corridors detected from aligned segments (may need more data)")
}
}
func TestFlowAccumulator_TimeRangeFiltering(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "flow_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir) //nolint:errcheck
dbPath := filepath.Join(tmpDir, "test.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close() //nolint:errcheck
fa := NewFlowAccumulator(db, testGridCellSize)
if err := fa.InitSchema(); err != nil {
t.Fatalf("Failed to init schema: %v", err)
}
defer fa.Close() //nolint:errcheck
// Create multiple tracks that all move through the same cells to accumulate
// enough segments per cell
for trackID := 1; trackID <= 6; trackID++ {
trackStr := string(rune('a' + trackID))
// Establish waypoint
fa.AddTrackUpdate(trackStr, 0, 0, 0, 0.3, 0, 0, "")
// Move to create segment
fa.AddTrackUpdate(trackStr, 0.5, 0, 0, 0.3, 0, 0, "")
}
// Flush buffers
fa.Flush() //nolint:errcheck
// Query with time range: since 8 days ago (should include recent data)
since := time.Now().AddDate(0, 0, -8)
until := time.Now()
flowMap, err := fa.ComputeFlowMap(nil, &since, &until)
if err != nil {
t.Fatalf("Failed to get flow map: %v", err)
}
// Should include the segments we just created
if len(flowMap.Cells) == 0 {
t.Error("Expected flow cells from recent segments")
}
}
func TestFlowAccumulator_PruneOldSegments(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "flow_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir) //nolint:errcheck
dbPath := filepath.Join(tmpDir, "test.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close() //nolint:errcheck
fa := NewFlowAccumulator(db, testGridCellSize)
if err := fa.InitSchema(); err != nil {
t.Fatalf("Failed to init schema: %v", err)
}
defer fa.Close() //nolint:errcheck
// Create a segment
fa.AddTrackUpdate("track-1", 0, 0, 0, 1, 0, 0, "")
fa.AddTrackUpdate("track-1", 1, 0, 0, 1, 0, 0, "")
// Flush buffers
fa.Flush() //nolint:errcheck
// Check segment was recorded
var countBefore int
err = db.QueryRow(`SELECT COUNT(*) FROM trajectory_segments`).Scan(&countBefore)
if err != nil {
t.Fatalf("Failed to query segments: %v", err)
}
if countBefore == 0 {
t.Fatal("Expected at least one segment before pruning")
}
// Prune with default retention (should not delete recent data)
err = fa.PruneOldData()
if err != nil {
t.Fatalf("Failed to prune segments: %v", err)
}
// Data should still exist (recent data not pruned)
var countAfter int
err = db.QueryRow(`SELECT COUNT(*) FROM trajectory_segments`).Scan(&countAfter)
if err != nil {
t.Fatalf("Failed to query segments after prune: %v", err)
}
if countAfter != countBefore {
t.Errorf("Expected %d segments after pruning recent data, got %d", countBefore, countAfter)
}
}
func TestBresenhamLine(t *testing.T) {
tests := []struct {
name string
x0, y0, x1, y1 int
expectedCount int
}{
{"horizontal line", 0, 0, 5, 0, 6},
{"vertical line", 0, 0, 0, 5, 6},
{"diagonal line", 0, 0, 3, 3, 4},
{"single point", 2, 2, 2, 2, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cells := bresenhamLine(tt.x0, tt.y0, tt.x1, tt.y1)
if len(cells) != tt.expectedCount {
t.Errorf("Expected %d cells, got %d", tt.expectedCount, len(cells))
}
})
}
}
func TestCellKeyAndParse(t *testing.T) {
// Test cell key generation and parsing
x, y := 5, 10
key := cellKey(x, y)
px, py := parseCellKey(key)
if px != x || py != y {
t.Errorf("Expected (%d, %d), got (%d, %d)", x, y, px, py)
}
}
func TestFlowAccumulator_RemoveTrack(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "flow_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir) //nolint:errcheck
dbPath := filepath.Join(tmpDir, "test.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close() //nolint:errcheck
fa := NewFlowAccumulator(db, testGridCellSize)
if err := fa.InitSchema(); err != nil {
t.Fatalf("Failed to init schema: %v", err)
}
defer fa.Close() //nolint:errcheck
// Add a track at origin (establishes waypoint)
fa.AddTrackUpdate("track-1", 0, 0, 0, 0.25, 0, 0, "person1")
// Remove the track (clears the waypoint)
fa.RemoveTrack("track-1")
// Re-add the track at a new position (establishes new waypoint)
fa.AddTrackUpdate("track-1", 0.25, 0, 0, 0.25, 0, 0, "person1")
// Add another update to create a segment
fa.AddTrackUpdate("track-1", 0.5, 0, 0, 0.25, 0, 0, "person1")
fa.Flush() //nolint:errcheck
// Should have a segment since we have two updates after removal
var count int
err = db.QueryRow(`SELECT COUNT(*) FROM trajectory_segments`).Scan(&count)
if err != nil {
t.Fatalf("Failed to query segments: %v", err)
}
if count == 0 {
t.Error("Expected a segment after track removal and re-addition")
}
}
func TestFlowAccumulator_PersonFiltering(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "flow_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir) //nolint:errcheck
dbPath := filepath.Join(tmpDir, "test.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close() //nolint:errcheck
fa := NewFlowAccumulator(db, testGridCellSize)
if err := fa.InitSchema(); err != nil {
t.Fatalf("Failed to init schema: %v", err)
}
defer fa.Close() //nolint:errcheck
// Create segments for person1
fa.AddTrackUpdate("track-1", 0, 0, 0, 0.3, 0, 0, "person1")
fa.AddTrackUpdate("track-1", 0.3, 0, 0, 0.3, 0, 0, "person1")
// Create segments for person2
fa.AddTrackUpdate("track-2", 1, 0, 0, 0.3, 0, 0, "person2")
fa.AddTrackUpdate("track-2", 1.3, 0, 0, 0.3, 0, 0, "person2")
// Create segments for unknown person
fa.AddTrackUpdate("track-3", 2, 0, 0, 0.3, 0, 0, "")
fa.AddTrackUpdate("track-3", 2.3, 0, 0, 0.3, 0, 0, "")
fa.Flush() //nolint:errcheck
// Query all flow
allFlow, err := fa.ComputeFlowMap(nil, nil, nil)
if err != nil {
t.Fatalf("Failed to get all flow: %v", err)
}
// Query only person1
person1 := "person1"
person1Flow, err := fa.ComputeFlowMap(&person1, nil, nil)
if err != nil {
t.Fatalf("Failed to get person1 flow: %v", err)
}
// Query only person2
person2 := "person2"
person2Flow, err := fa.ComputeFlowMap(&person2, nil, nil)
if err != nil {
t.Fatalf("Failed to get person2 flow: %v", err)
}
// All flow should have more segments than individual person flows
if len(person1Flow.Cells) == 0 && len(person2Flow.Cells) == 0 && len(allFlow.Cells) == 0 {
t.Error("Expected some flow data")
}
}