diff --git a/mothership/internal/zones/manager.go b/mothership/internal/zones/manager.go index 38c0f74..5c90b88 100644 --- a/mothership/internal/zones/manager.go +++ b/mothership/internal/zones/manager.go @@ -683,7 +683,7 @@ func (m *Manager) recordCrossing(event CrossingEvent) { _, err := m.db.Exec(` INSERT INTO crossing_events (portal_id, blob_id, direction, from_zone, to_zone, timestamp, identity) VALUES (?, ?, ?, ?, ?, ?, ?) - `, event.PortalID, event.BlobID, event.Direction, event.FromZone, event.ToZone, event.Timestamp.UnixNano(), event.Identity) + `, event.PortalID, event.BlobID, event.Direction, event.FromZone, event.ToZone, event.Timestamp.UnixMilli(), event.Identity) if err != nil { log.Printf("[WARN] Failed to record crossing event: %v", err) } @@ -752,7 +752,7 @@ func (m *Manager) GetRecentCrossings(limit int) []CrossingEvent { if err := rows.Scan(&event.PortalID, &event.BlobID, &event.Direction, &event.FromZone, &event.ToZone, &ts, &event.Identity); err != nil { continue } - event.Timestamp = time.Unix(0, ts) + event.Timestamp = time.UnixMilli(ts) events = append(events, event) } return events @@ -782,7 +782,7 @@ func (m *Manager) GetBlobDwellTime(blobID int, zoneID string) (time.Duration, bo } // Calculate dwell time since entering the zone - dwellTime := time.Since(time.Unix(0, enterTime)) + dwellTime := time.Since(time.UnixMilli(enterTime)) return dwellTime, true } @@ -836,7 +836,7 @@ func (m *Manager) reconcileOccupancy() { m.mu.Lock() defer m.mu.Unlock() - now := time.Now() + now := time.Now().In(m.tz) midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, m.tz) midnightMs := midnight.UnixMilli() @@ -862,7 +862,7 @@ func (m *Manager) reconcileOccupancy() { // Step 2: Compute net portal crossings since midnight crossRows, err := m.db.Query(` - SELECT zone_a_id, zone_b_id, direction, timestamp + SELECT from_zone, to_zone, timestamp FROM crossing_events WHERE timestamp >= ? `, midnightMs) @@ -874,19 +874,14 @@ func (m *Manager) reconcileOccupancy() { netPerZone := make(map[string]int) for crossRows.Next() { - var zoneAID, zoneBID, direction string + var fromZone, toZone string var tsMs int64 - if err := crossRows.Scan(&zoneAID, &zoneBID, &direction, &tsMs); err != nil { + if err := crossRows.Scan(&fromZone, &toZone, &tsMs); err != nil { continue } - switch direction { - case "a_to_b", "1": - netPerZone[zoneBID]++ - netPerZone[zoneAID]-- - case "b_to_a", "-1": - netPerZone[zoneAID]++ - netPerZone[zoneBID]-- - } + // Each crossing: from_zone loses one, to_zone gains one + netPerZone[fromZone]-- + netPerZone[toZone]++ } // Step 3: Apply net crossings to loaded occupancy @@ -960,15 +955,17 @@ func (m *Manager) ReconcileTick() { zoneID, oldCount, blobCount) m.reconDiscrep = 0 } - } else { + } else if diff == 0 { + // Exact match — mark reconciled after 2 consecutive checks m.reconChecks++ m.reconDiscrep = 0 if m.reconChecks >= 2 { occ.Status = OccupancyReconciled - occ.Count = blobCount - occ.BlobIDs = nil - occ.LastUpdated = time.Now() } + } else { + // diff == 1: close but not exact, stay uncertain + m.reconChecks = 0 + m.reconDiscrep = 0 } } diff --git a/mothership/internal/zones/manager_test.go b/mothership/internal/zones/manager_test.go new file mode 100644 index 0000000..8da4cfe --- /dev/null +++ b/mothership/internal/zones/manager_test.go @@ -0,0 +1,811 @@ +package zones + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// testDB creates a temporary database file for testing. +func testDB(t *testing.T) string { + t.Helper() + dir := t.TempDir() + return filepath.Join(dir, "test.db") +} + +// setupManager creates a Manager with a test database and pre-populated zones. +func setupManager(t *testing.T, tz *time.Location) (*Manager, func()) { + t.Helper() + if tz == nil { + tz = time.UTC + } + dbPath := testDB(t) + m, err := NewManager(dbPath, tz) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + cleanup := func() { + m.Close() + os.Remove(dbPath) + } + return m, cleanup +} + +// --- reconcileOccupancy tests --- + +func TestReconcileOccupancy_PersistedOnly(t *testing.T) { + tests := []struct { + name string + persisted map[string]int // zone_id -> last_known_occupancy + wantCount map[string]int // zone_id -> expected reconciled count + wantStatus map[string]OccupancyStatus + }{ + { + name: "no persisted values", + persisted: map[string]int{}, + wantCount: map[string]int{}, + wantStatus: map[string]OccupancyStatus{}, + }, + { + name: "single zone with 2 people", + persisted: map[string]int{ + "kitchen": 2, + }, + wantCount: map[string]int{ + "kitchen": 2, + }, + wantStatus: map[string]OccupancyStatus{ + "kitchen": OccupancyUncertain, + }, + }, + { + name: "multiple zones with various counts", + persisted: map[string]int{ + "kitchen": 1, + "bedroom": 0, + "hallway": 3, + }, + wantCount: map[string]int{ + "kitchen": 1, + "bedroom": 0, + "hallway": 3, + }, + wantStatus: map[string]OccupancyStatus{ + "kitchen": OccupancyUncertain, + "bedroom": OccupancyUncertain, + "hallway": OccupancyUncertain, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m, cleanup := setupManager(t, time.UTC) + defer cleanup() + + // Create zones and set persisted occupancy + for zoneID := range tt.persisted { + zone := &Zone{ + ID: zoneID, + Name: zoneID, + MinX: 0, MinY: 0, MinZ: 0, + MaxX: 1, MaxY: 1, MaxZ: 1, + Enabled: true, + } + if err := m.CreateZone(zone); err != nil { + t.Fatalf("CreateZone: %v", err) + } + // Set persisted occupancy directly in DB + m.db.Exec(`UPDATE zones SET last_known_occupancy = ? WHERE id = ?`, tt.persisted[zoneID], zoneID) + } + + // Run reconciliation + m.reconcileOccupancy() + + // Check results + for zoneID, wantCount := range tt.wantCount { + occ := m.GetZoneOccupancy(zoneID) + if occ == nil { + if wantCount != 0 { + t.Errorf("zone %s: got nil occupancy, want count %d", zoneID, wantCount) + } + continue + } + if occ.Count != wantCount { + t.Errorf("zone %s: got count %d, want %d", zoneID, occ.Count, wantCount) + } + } + + // Verify status for zones + for zoneID, wantStatus := range tt.wantStatus { + occ := m.GetZoneOccupancy(zoneID) + if occ == nil { + t.Errorf("zone %s: nil occupancy, want status %s", zoneID, wantStatus) + continue + } + if occ.Status != wantStatus { + t.Errorf("zone %s: got status %s, want %s", zoneID, occ.Status, wantStatus) + } + } + }) + } +} + +func TestReconcileOccupancy_WithCrossings(t *testing.T) { + tests := []struct { + name string + persisted map[string]int // zone_id -> last_known_occupancy + crossings []struct { + zoneA string + zoneB string + dir int // 1 = a_to_b, -1 = b_to_a + tsMs int64 + } + wantCount map[string]int + wantStatus map[string]OccupancyStatus + }{ + { + name: "one person left kitchen after midnight", + persisted: map[string]int{ + "kitchen": 2, + "hallway": 0, + }, + crossings: []struct { + zoneA string + zoneB string + dir int + tsMs int64 + }{ + {zoneA: "kitchen", zoneB: "hallway", dir: 1, tsMs: nowMsSinceMidnight(1 * time.Hour)}, + }, + wantCount: map[string]int{ + "kitchen": 1, + "hallway": 1, + }, + wantStatus: map[string]OccupancyStatus{ + "kitchen": OccupancyUncertain, + "hallway": OccupancyUncertain, + }, + }, + { + name: "person entered and left (net zero)", + persisted: map[string]int{ + "kitchen": 1, + "hallway": 0, + }, + crossings: []struct { + zoneA string + zoneB string + dir int + tsMs int64 + }{ + {zoneA: "kitchen", zoneB: "hallway", dir: 1, tsMs: nowMsSinceMidnight(1 * time.Hour)}, + {zoneA: "hallway", zoneB: "kitchen", dir: 1, tsMs: nowMsSinceMidnight(2 * time.Hour)}, + }, + wantCount: map[string]int{ + "kitchen": 1, + "hallway": 0, + }, + wantStatus: map[string]OccupancyStatus{ + "kitchen": OccupancyUncertain, + }, + }, + { + name: "net negative clamped to zero", + persisted: map[string]int{ + "kitchen": 0, + "hallway": 0, + }, + crossings: []struct { + zoneA string + zoneB string + dir int + tsMs int64 + }{ + {zoneA: "kitchen", zoneB: "hallway", dir: 1, tsMs: nowMsSinceMidnight(1 * time.Hour)}, + }, + wantCount: map[string]int{ + "kitchen": 0, // clamped from -1 + "hallway": 1, + }, + wantStatus: map[string]OccupancyStatus{ + "hallway": OccupancyUncertain, + }, + }, + { + name: "crossings before midnight ignored", + persisted: map[string]int{ + "kitchen": 2, + "hallway": 0, + }, + crossings: []struct { + zoneA string + zoneB string + dir int + tsMs int64 + }{ + // This crossing is before midnight, should be ignored + {zoneA: "kitchen", zoneB: "hallway", dir: 1, tsMs: nowMsSinceMidnight(-1 * time.Hour)}, + }, + wantCount: map[string]int{ + "kitchen": 2, + "hallway": 0, + }, + wantStatus: map[string]OccupancyStatus{ + "kitchen": OccupancyUncertain, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m, cleanup := setupManager(t, time.UTC) + defer cleanup() + + // Create zones + allZoneIDs := make(map[string]bool) + for zoneID := range tt.persisted { + allZoneIDs[zoneID] = true + } + for _, c := range tt.crossings { + allZoneIDs[c.zoneA] = true + allZoneIDs[c.zoneB] = true + } + for zoneID := range allZoneIDs { + zone := &Zone{ + ID: zoneID, Name: zoneID, + MinX: 0, MinY: 0, MinZ: 0, + MaxX: 1, MaxY: 1, MaxZ: 1, + Enabled: true, + } + if err := m.CreateZone(zone); err != nil { + t.Fatalf("CreateZone: %v", err) + } + } + + // Set persisted occupancy + for zoneID, count := range tt.persisted { + m.db.Exec(`UPDATE zones SET last_known_occupancy = ? WHERE id = ?`, count, zoneID) + } + + // Insert crossing events + for _, c := range tt.crossings { + m.db.Exec(` + INSERT INTO crossing_events (portal_id, blob_id, direction, from_zone, to_zone, timestamp) + VALUES (?, ?, ?, ?, ?, ?) + `, "portal_1", 1, c.dir, c.zoneA, c.zoneB, c.tsMs) + } + + // Run reconciliation + m.reconcileOccupancy() + + // Check results + for zoneID, wantCount := range tt.wantCount { + occ := m.GetZoneOccupancy(zoneID) + if occ == nil { + if wantCount != 0 { + t.Errorf("zone %s: got nil occupancy, want count %d", zoneID, wantCount) + } + continue + } + if occ.Count != wantCount { + t.Errorf("zone %s: got count %d, want %d", zoneID, occ.Count, wantCount) + } + } + for zoneID, wantStatus := range tt.wantStatus { + occ := m.GetZoneOccupancy(zoneID) + if occ == nil { + t.Errorf("zone %s: nil occupancy, want status %s", zoneID, wantStatus) + continue + } + if occ.Status != wantStatus { + t.Errorf("zone %s: got status %s, want %s", zoneID, occ.Status, wantStatus) + } + } + }) + } +} + +// --- ReconcileTick tests --- + +func TestReconcileTick_BlobCountOverride(t *testing.T) { + tests := []struct { + name string + initialCount int + blobCount int + ticks int // number of ReconcileTick calls + wantFinalCount int + wantReconciled bool + }{ + { + name: "no discrepancy", + initialCount: 2, + blobCount: 2, + ticks: 2, + wantFinalCount: 2, + wantReconciled: true, // agrees after 2 checks + }, + { + name: "off by 1 is ok", + initialCount: 2, + blobCount: 1, + ticks: 2, + wantFinalCount: 2, // still uncertain after 2 checks (diff=1 not >1) + wantReconciled: false, + }, + { + name: "off by 2 triggers override after 2 ticks", + initialCount: 3, + blobCount: 1, + ticks: 2, + wantFinalCount: 1, // blob count wins + wantReconciled: false, + }, + { + name: "off by 5 triggers override after 2 ticks", + initialCount: 5, + blobCount: 0, + ticks: 2, + wantFinalCount: 0, + wantReconciled: false, + }, + { + name: "single tick with large discrepancy does not override", + initialCount: 3, + blobCount: 0, + ticks: 1, + wantFinalCount: 3, // needs 2 consecutive discrepancies + wantReconciled: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m, cleanup := setupManager(t, time.UTC) + defer cleanup() + + // Create zone + zone := &Zone{ + ID: "test_zone", Name: "Test", + MinX: 0, MinY: 0, MinZ: 0, + MaxX: 10, MaxY: 10, MaxZ: 3, + Enabled: true, + } + if err := m.CreateZone(zone); err != nil { + t.Fatalf("CreateZone: %v", err) + } + + // Set initial occupancy as uncertain + m.mu.Lock() + m.occupancy["test_zone"] = &ZoneOccupancy{ + ZoneID: "test_zone", + Count: tt.initialCount, + Status: OccupancyUncertain, + } + m.reconciled = false // reset — constructor set true when no zones existed + m.mu.Unlock() + + // Place blobs to simulate live blob count + m.mu.Lock() + for i := 0; i < tt.blobCount; i++ { + m.blobPositions[i+100] = struct { + X, Y, Z float64 + ZoneID string + LastUpdated time.Time + }{X: 1, Y: 1, Z: 1, ZoneID: "test_zone", LastUpdated: time.Now()} + } + m.mu.Unlock() + + // Run ticks + for i := 0; i < tt.ticks; i++ { + m.ReconcileTick() + } + + occ := m.GetZoneOccupancy("test_zone") + if occ == nil { + t.Fatalf("nil occupancy for test_zone") + } + if occ.Count != tt.wantFinalCount { + t.Errorf("got count %d, want %d", occ.Count, tt.wantFinalCount) + } + if m.IsReconciled() != tt.wantReconciled { + t.Errorf("got reconciled %v, want %v", m.IsReconciled(), tt.wantReconciled) + } + }) + } +} + +func TestReconcileTick_ForceReconcileAfter60s(t *testing.T) { + m, cleanup := setupManager(t, time.UTC) + defer cleanup() + + // Create zone + zone := &Zone{ + ID: "test_zone", Name: "Test", + MinX: 0, MinY: 0, MinZ: 0, + MaxX: 10, MaxY: 10, MaxZ: 3, + Enabled: true, + } + if err := m.CreateZone(zone); err != nil { + t.Fatalf("CreateZone: %v", err) + } + + // Set initial occupancy as uncertain with wrong count + m.mu.Lock() + m.occupancy["test_zone"] = &ZoneOccupancy{ + ZoneID: "test_zone", + Count: 5, + Status: OccupancyUncertain, + } + m.reconciled = false + m.startedAt = time.Now().Add(-61 * time.Second) // simulate 61s elapsed + m.mu.Unlock() + + // Run tick — should force-reconcile even though there are no blobs + m.ReconcileTick() + + occ := m.GetZoneOccupancy("test_zone") + if occ == nil { + t.Fatalf("nil occupancy") + } + if occ.Status != OccupancyReconciled { + t.Errorf("got status %s, want reconciled", occ.Status) + } + if occ.Count != 0 { + t.Errorf("got count %d, want 0 (no blobs)", occ.Count) + } + if !m.IsReconciled() { + t.Error("expected IsReconciled=true after 60s force") + } +} + +// --- PersistOccupancy tests --- + +func TestPersistOccupancy(t *testing.T) { + tests := []struct { + name string + occupancy map[string]int + }{ + { + name: "single zone", + occupancy: map[string]int{ + "kitchen": 2, + }, + }, + { + name: "multiple zones", + occupancy: map[string]int{ + "kitchen": 1, + "bedroom": 0, + "hallway": 3, + }, + }, + { + name: "empty", + occupancy: map[string]int{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m, cleanup := setupManager(t, time.UTC) + defer cleanup() + + // Create zones + for zoneID, count := range tt.occupancy { + zone := &Zone{ + ID: zoneID, Name: zoneID, + MinX: 0, MinY: 0, MinZ: 0, + MaxX: 1, MaxY: 1, MaxZ: 1, + Enabled: true, + } + if err := m.CreateZone(zone); err != nil { + t.Fatalf("CreateZone: %v", err) + } + m.occupancy[zoneID] = &ZoneOccupancy{ + ZoneID: zoneID, + Count: count, + Status: OccupancyReconciled, + } + } + + if err := m.PersistOccupancy(); err != nil { + t.Fatalf("PersistOccupancy: %v", err) + } + + // Verify values in DB + for zoneID, wantCount := range tt.occupancy { + var gotCount int + err := m.db.QueryRow(`SELECT last_known_occupancy FROM zones WHERE id = ?`, zoneID).Scan(&gotCount) + if err != nil { + t.Errorf("failed to query zone %s: %v", zoneID, err) + continue + } + if gotCount != wantCount { + t.Errorf("zone %s: got persisted count %d, want %d", zoneID, gotCount, wantCount) + } + } + }) + } +} + +func TestPersistOccupancy_OnBlobUpdate(t *testing.T) { + m, cleanup := setupManager(t, time.UTC) + defer cleanup() + + zone := &Zone{ + ID: "kitchen", Name: "Kitchen", + MinX: 0, MinY: 0, MinZ: 0, + MaxX: 10, MaxY: 10, MaxZ: 3, + Enabled: true, + } + if err := m.CreateZone(zone); err != nil { + t.Fatalf("CreateZone: %v", err) + } + + // Update blob positions — should persist occupancy + m.UpdateBlobPositions([]struct { + ID int + X, Y, Z float64 + }{ + {ID: 1, X: 5, Y: 5, Z: 1}, + }) + + // Verify persisted in DB + var gotCount int + err := m.db.QueryRow(`SELECT last_known_occupancy FROM zones WHERE id = 'kitchen'`).Scan(&gotCount) + if err != nil { + t.Fatalf("query: %v", err) + } + if gotCount != 1 { + t.Errorf("got persisted count %d, want 1", gotCount) + } + + // Add second blob + m.UpdateBlobPositions([]struct { + ID int + X, Y, Z float64 + }{ + {ID: 1, X: 5, Y: 5, Z: 1}, + {ID: 2, X: 6, Y: 6, Z: 1}, + }) + + err = m.db.QueryRow(`SELECT last_known_occupancy FROM zones WHERE id = 'kitchen'`).Scan(&gotCount) + if err != nil { + t.Fatalf("query: %v", err) + } + if gotCount != 2 { + t.Errorf("got persisted count %d, want 2", gotCount) + } +} + +func TestPersistOccupancy_OnBlobRemoval(t *testing.T) { + m, cleanup := setupManager(t, time.UTC) + defer cleanup() + + zone := &Zone{ + ID: "kitchen", Name: "Kitchen", + MinX: 0, MinY: 0, MinZ: 0, + MaxX: 10, MaxY: 10, MaxZ: 3, + Enabled: true, + } + if err := m.CreateZone(zone); err != nil { + t.Fatalf("CreateZone: %v", err) + } + + // Add blobs + m.UpdateBlobPositions([]struct { + ID int + X, Y, Z float64 + }{ + {ID: 1, X: 5, Y: 5, Z: 1}, + {ID: 2, X: 6, Y: 6, Z: 1}, + }) + + // Simulate blob timeout by manipulating LastUpdated directly + m.mu.Lock() + for id := range m.blobPositions { + pos := m.blobPositions[id] + pos.LastUpdated = time.Now().Add(-15 * time.Second) + m.blobPositions[id] = pos + } + m.mu.Unlock() + + // Trigger cleanup by updating with no blobs (empty update still cleans up) + m.UpdateBlobPositions(nil) + + // Verify persisted count is 0 + var gotCount int + err := m.db.QueryRow(`SELECT last_known_occupancy FROM zones WHERE id = 'kitchen'`).Scan(&gotCount) + if err != nil { + t.Fatalf("query: %v", err) + } + if gotCount != 0 { + t.Errorf("got persisted count %d, want 0 after blob timeout", gotCount) + } +} + +// --- End-to-end: reconcile after restart simulation --- + +func TestEndToEnd_RestoreOccupancyAfterRestart(t *testing.T) { + // Simulate: zone has 2 people, shutdown (persist), "restart" (reconcile) + dbPath := testDB(t) + tz := time.UTC + + // Phase 1: Initial run — create zone, set occupancy, persist + m1, err := NewManager(dbPath, tz) + if err != nil { + t.Fatalf("NewManager (phase 1): %v", err) + } + + zone := &Zone{ + ID: "kitchen", Name: "Kitchen", + MinX: 0, MinY: 0, MinZ: 0, + MaxX: 10, MaxY: 10, MaxZ: 3, + Enabled: true, + } + if err := m1.CreateZone(zone); err != nil { + t.Fatalf("CreateZone: %v", err) + } + + // Simulate 2 people in kitchen + m1.UpdateBlobPositions([]struct { + ID int + X, Y, Z float64 + }{ + {ID: 1, X: 5, Y: 5, Z: 1}, + {ID: 2, X: 6, Y: 6, Z: 1}, + }) + + // Persist on "shutdown" + if err := m1.PersistOccupancy(); err != nil { + t.Fatalf("PersistOccupancy: %v", err) + } + m1.Close() + + // Phase 2: "Restart" — open same DB, reconcile + m2, err := NewManager(dbPath, tz) + if err != nil { + t.Fatalf("NewManager (phase 2): %v", err) + } + defer m2.Close() + + occ := m2.GetZoneOccupancy("kitchen") + if occ == nil { + t.Fatal("expected occupancy for kitchen, got nil") + } + if occ.Count != 2 { + t.Errorf("got count %d, want 2", occ.Count) + } + if occ.Status != OccupancyUncertain { + t.Errorf("got status %s, want uncertain", occ.Status) + } +} + +func TestEndToEnd_RestoreWithCrossings(t *testing.T) { + dbPath := testDB(t) + tz := time.UTC + + // Phase 1: Create zone, set occupancy, add crossing, persist + m1, err := NewManager(dbPath, tz) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + zone := &Zone{ + ID: "kitchen", Name: "Kitchen", + MinX: 0, MinY: 0, MinZ: 0, + MaxX: 10, MaxY: 10, MaxZ: 3, + Enabled: true, + } + if err := m1.CreateZone(zone); err != nil { + t.Fatalf("CreateZone: %v", err) + } + + hallway := &Zone{ + ID: "hallway", Name: "Hallway", + MinX: 10, MinY: 0, MinZ: 0, + MaxX: 20, MaxY: 10, MaxZ: 3, + Enabled: true, + } + if err := m1.CreateZone(hallway); err != nil { + t.Fatalf("CreateZone: %v", err) + } + + // Simulate 2 people in kitchen, persist + m1.UpdateBlobPositions([]struct { + ID int + X, Y, Z float64 + }{ + {ID: 1, X: 5, Y: 5, Z: 1}, + {ID: 2, X: 6, Y: 6, Z: 1}, + }) + m1.PersistOccupancy() + + // Simulate a crossing event (one person left kitchen to hallway after midnight) + now := time.Now() + m1.recordCrossing(CrossingEvent{ + PortalID: "portal_1", + BlobID: 1, + Direction: 1, // a_to_b + FromZone: "kitchen", + ToZone: "hallway", + Timestamp: now, + }) + m1.Close() + + // Phase 2: Restart and reconcile + m2, err := NewManager(dbPath, tz) + if err != nil { + t.Fatalf("NewManager (restart): %v", err) + } + defer m2.Close() + + kitchenOcc := m2.GetZoneOccupancy("kitchen") + if kitchenOcc == nil { + t.Fatal("nil kitchen occupancy") + } + if kitchenOcc.Count != 1 { + t.Errorf("kitchen: got count %d, want 1 (was 2, one left via portal)", kitchenOcc.Count) + } + + hallwayOcc := m2.GetZoneOccupancy("hallway") + if hallwayOcc == nil { + t.Fatal("nil hallway occupancy") + } + if hallwayOcc.Count != 1 { + t.Errorf("hallway: got count %d, want 1 (entered via portal)", hallwayOcc.Count) + } +} + +// --- GetOccupancyStatus tests --- + +func TestGetOccupancyStatus(t *testing.T) { + m, cleanup := setupManager(t, time.UTC) + defer cleanup() + + zone := &Zone{ + ID: "kitchen", Name: "Kitchen", + MinX: 0, MinY: 0, MinZ: 0, + MaxX: 10, MaxY: 10, MaxZ: 3, + Enabled: true, + } + if err := m.CreateZone(zone); err != nil { + t.Fatalf("CreateZone: %v", err) + } + + m.mu.Lock() + m.occupancy["kitchen"] = &ZoneOccupancy{ + ZoneID: "kitchen", + Count: 2, + Status: OccupancyUncertain, + } + m.mu.Unlock() + + status := m.GetOccupancyStatus() + if status["kitchen"] != OccupancyUncertain { + t.Errorf("got %s, want uncertain", status["kitchen"]) + } +} + +// --- IsReconciled tests --- + +func TestIsReconciled_NoZones(t *testing.T) { + m, cleanup := setupManager(t, time.UTC) + defer cleanup() + + // No zones, no occupancy — should be reconciled (nothing to reconcile) + if !m.IsReconciled() { + t.Error("expected reconciled with no zones") + } +} + +// --- Helper --- + +// nowMsSinceMidnight returns a Unix ms timestamp the given duration after midnight today. +func nowMsSinceMidnight(d time.Duration) int64 { + now := time.Now().UTC() + midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + return midnight.Add(d).UnixMilli() +}