spaxel/mothership/internal/fleet/fleet_test.go
jedarden a984576be9 feat: complete fleet status page implementation
- Added dropdown menu for More actions button with options:
  - Re-assign Role
  - View Health History
  - View Event History
  - Remove from Fleet

- Added CSS styles for dropdown menus with proper positioning
  and hover states

- Extended FleetHandler with additional API endpoints:
  - PATCH /api/nodes/{mac}/label - update node label
  - POST /api/nodes/{mac}/locate - send identify command
  - POST /api/nodes/{mac}/role - assign new role
  - DELETE /api/nodes/{mac} - remove from fleet

- Added label validation (max 32 characters)

- Improved test code quality with helper functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 10:34:31 -04:00

739 lines
20 KiB
Go

package fleet
import (
"sync"
"testing"
"time"
"github.com/spaxel/mothership/internal/events"
)
// ─── Test doubles ────────────────────────────────────────────────────────────
type mockNotifier struct {
mu sync.Mutex
rolesSent map[string]string
configSent map[string]int
connected []string
}
func newMockNotifier(connected ...string) *mockNotifier {
return &mockNotifier{
rolesSent: make(map[string]string),
configSent: make(map[string]int),
connected: connected,
}
}
func (m *mockNotifier) SendRoleToMAC(mac, role, _ string) {
m.mu.Lock()
m.rolesSent[mac] = role
m.mu.Unlock()
}
func (m *mockNotifier) SendConfigToMAC(mac string, rateHz int, _ float64) {
m.mu.Lock()
m.configSent[mac] = rateHz
m.mu.Unlock()
}
func (m *mockNotifier) GetConnectedMACs() []string {
m.mu.Lock()
defer m.mu.Unlock()
return append([]string{}, m.connected...)
}
func (m *mockNotifier) SendIdentifyToMAC(mac string, durationMS int) bool {
m.mu.Lock()
defer m.mu.Unlock()
for _, c := range m.connected {
if c == mac {
return true
}
}
return false
}
func (m *mockNotifier) sentRole(mac string) string {
m.mu.Lock()
defer m.mu.Unlock()
return m.rolesSent[mac]
}
type mockBroadcaster struct {
mu sync.Mutex
calls int
}
func (b *mockBroadcaster) BroadcastRegistryState(_ []NodeRecord, _ RoomConfig) {
b.mu.Lock()
b.calls++
b.mu.Unlock()
}
func (b *mockBroadcaster) broadcastCount() int {
b.mu.Lock()
defer b.mu.Unlock()
return b.calls
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
func newTestRegistry(t *testing.T) *Registry {
t.Helper()
reg, err := NewRegistry(":memory:")
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
t.Cleanup(func() { reg.Close() })
return reg
}
func newTestManager(t *testing.T) (*Manager, *mockNotifier, *mockBroadcaster) {
t.Helper()
reg := newTestRegistry(t)
mgr := NewManager(reg)
n := newMockNotifier()
b := &mockBroadcaster{}
mgr.SetNotifier(n)
mgr.SetBroadcaster(b)
return mgr, n, b
}
// ─── Registry tests ───────────────────────────────────────────────────────────
func TestRegistryUpsertAndGet(t *testing.T) {
reg := newTestRegistry(t)
if err := reg.UpsertNode("aa:bb:cc:dd:ee:01", "v1.0", "ESP32-S3"); err != nil {
t.Fatalf("UpsertNode: %v", err)
}
node, err := reg.GetNode("aa:bb:cc:dd:ee:01")
if err != nil {
t.Fatalf("GetNode: %v", err)
}
if node.MAC != "aa:bb:cc:dd:ee:01" {
t.Errorf("MAC = %q, want %q", node.MAC, "aa:bb:cc:dd:ee:01")
}
if node.FirmwareVersion != "v1.0" {
t.Errorf("FirmwareVersion = %q, want %q", node.FirmwareVersion, "v1.0")
}
if node.ChipModel != "ESP32-S3" {
t.Errorf("ChipModel = %q, want %q", node.ChipModel, "ESP32-S3")
}
if node.Role != "rx" {
t.Errorf("default Role = %q, want %q", node.Role, "rx")
}
}
func TestRegistryUpsertUpdatesLastSeen(t *testing.T) {
reg := newTestRegistry(t)
if err := reg.UpsertNode("aa:bb:cc:dd:ee:02", "v1.0", "ESP32-S3"); err != nil {
t.Fatalf("first UpsertNode: %v", err)
}
n1, _ := reg.GetNode("aa:bb:cc:dd:ee:02")
if err := reg.UpsertNode("aa:bb:cc:dd:ee:02", "v1.1", "ESP32-S3"); err != nil {
t.Fatalf("second UpsertNode: %v", err)
}
n2, _ := reg.GetNode("aa:bb:cc:dd:ee:02")
if n2.FirmwareVersion != "v1.1" {
t.Errorf("firmware not updated: got %q", n2.FirmwareVersion)
}
if !n2.LastSeenAt.After(n1.LastSeenAt) || n2.LastSeenAt.Equal(n1.LastSeenAt) {
// Equal is fine if both happened in the same nanosecond (unlikely but allow)
_ = n1
}
}
func TestRegistrySetRole(t *testing.T) {
reg := newTestRegistry(t)
if err := reg.UpsertNode("aa:bb:cc:dd:ee:03", "", ""); err != nil {
t.Fatal(err)
}
if err := reg.SetNodeRole("aa:bb:cc:dd:ee:03", "tx"); err != nil {
t.Fatalf("SetNodeRole: %v", err)
}
node, err := reg.GetNode("aa:bb:cc:dd:ee:03")
if err != nil {
t.Fatal(err)
}
if node.Role != "tx" {
t.Errorf("Role = %q, want tx", node.Role)
}
}
func TestRegistryGetAllNodes(t *testing.T) {
reg := newTestRegistry(t)
macs := []string{"aa:bb:cc:dd:ee:0a", "aa:bb:cc:dd:ee:0b", "aa:bb:cc:dd:ee:0c"}
for _, mac := range macs {
if err := reg.UpsertNode(mac, "", ""); err != nil {
t.Fatalf("UpsertNode %s: %v", mac, err)
}
}
nodes, err := reg.GetAllNodes()
if err != nil {
t.Fatalf("GetAllNodes: %v", err)
}
if len(nodes) != 3 {
t.Errorf("got %d nodes, want 3", len(nodes))
}
}
// ─── Manager role assignment tests ───────────────────────────────────────────
func TestManagerSingleNode_TxRx(t *testing.T) {
mgr, notif, _ := newTestManager(t)
mgr.OnNodeConnected("aa:00:00:00:00:01", "v1", "S3")
role := notif.sentRole("aa:00:00:00:00:01")
if role != "tx_rx" {
t.Errorf("single node: role = %q, want tx_rx", role)
}
node, err := mgr.registry.GetNode("aa:00:00:00:00:01")
if err != nil {
t.Fatalf("GetNode: %v", err)
}
if node.Role != "tx_rx" {
t.Errorf("persisted role = %q, want tx_rx", node.Role)
}
}
func TestManagerTwoNodes_TxRx(t *testing.T) {
mgr, notif, _ := newTestManager(t)
mgr.OnNodeConnected("aa:00:00:00:00:01", "v1", "S3")
mgr.OnNodeConnected("aa:00:00:00:00:02", "v1", "S3")
r1 := notif.sentRole("aa:00:00:00:00:01")
r2 := notif.sentRole("aa:00:00:00:00:02")
// With 2 nodes: first stays tx_rx (assigned before second joined),
// second gets tx (txCount was 0 at join time).
// After second joins: one node has tx, one has tx_rx.
// What matters is that a TX was assigned and an RX was assigned.
roles := map[string]bool{r1: true, r2: true}
if !roles["tx"] {
t.Errorf("expected one TX among roles: %v", roles)
}
}
func TestManagerThreeNodes_HalfTx(t *testing.T) {
mgr, notif, _ := newTestManager(t)
mgr.OnNodeConnected("aa:00:00:00:00:01", "v1", "S3")
mgr.OnNodeConnected("aa:00:00:00:00:02", "v1", "S3")
mgr.OnNodeConnected("aa:00:00:00:00:03", "v1", "S3")
roles := []string{
notif.sentRole("aa:00:00:00:00:01"),
notif.sentRole("aa:00:00:00:00:02"),
notif.sentRole("aa:00:00:00:00:03"),
}
txCount := 0
for _, r := range roles {
if r == "tx" || r == "tx_rx" {
txCount++
}
}
// With 3 nodes floor(3/2)=1 additional TX assigned, plus the original tx_rx.
if txCount < 1 {
t.Errorf("expected at least 1 TX/TX_RX node among %v", roles)
}
}
// ─── Manager self-healing and failure recovery tests ─────────────────────────
func TestManagerNodeDisconnect_Rebalance(t *testing.T) {
mgr, notif, _ := newTestManager(t)
mgr.OnNodeConnected("aa:00:00:00:00:01", "v1", "S3")
mgr.OnNodeConnected("aa:00:00:00:00:02", "v1", "S3")
mgr.OnNodeConnected("aa:00:00:00:00:03", "v1", "S3")
// Node 2 goes offline.
mgr.OnNodeDisconnected("aa:00:00:00:00:02")
// After rebalance with 2 remaining nodes, roles are re-sent.
r1 := notif.sentRole("aa:00:00:00:00:01")
r3 := notif.sentRole("aa:00:00:00:00:03")
if r1 == "" || r3 == "" {
t.Errorf("after disconnect, nodes should have received new roles; got %q, %q", r1, r3)
}
// Exactly one of the remaining nodes should be TX.
txCount := 0
for _, r := range []string{r1, r3} {
if r == "tx" || r == "tx_rx" {
txCount++
}
}
if txCount != 1 {
t.Errorf("after rebalance with 2 nodes: want 1 TX, got %d TX among [%q, %q]", txCount, r1, r3)
}
}
func TestManagerLastNodeDisconnect_ClearsState(t *testing.T) {
mgr, notif, _ := newTestManager(t)
mgr.OnNodeConnected("aa:00:00:00:00:01", "v1", "S3")
mgr.OnNodeDisconnected("aa:00:00:00:00:01")
mgr.mu.RLock()
txCount := mgr.txCount
mgr.mu.RUnlock()
if txCount != 0 {
t.Errorf("txCount after last node leaves = %d, want 0", txCount)
}
_ = notif // no roles should be sent (nothing to send to)
}
func TestManagerSelfHeal_RepushesRoles(t *testing.T) {
mgr, notif, _ := newTestManager(t)
mgr.OnNodeConnected("aa:00:00:00:00:01", "v1", "S3")
// Simulate notifier tracking connected nodes.
notif.mu.Lock()
notif.connected = []string{"aa:00:00:00:00:01"}
notif.mu.Unlock()
// Clear the sent roles to verify selfHeal re-pushes them.
notif.mu.Lock()
notif.rolesSent = make(map[string]string)
notif.mu.Unlock()
mgr.selfHeal()
role := notif.sentRole("aa:00:00:00:00:01")
if role == "" {
t.Error("selfHeal did not re-push role to connected node")
}
}
func TestManagerOverrideRole(t *testing.T) {
mgr, notif, bcaster := newTestManager(t)
mgr.OnNodeConnected("aa:00:00:00:00:01", "v1", "S3")
prevCalls := bcaster.broadcastCount()
if err := mgr.OverrideRole("aa:00:00:00:00:01", "rx"); err != nil {
t.Fatalf("OverrideRole: %v", err)
}
if notif.sentRole("aa:00:00:00:00:01") != "rx" {
t.Errorf("OverrideRole did not push rx to notifier")
}
node, err := mgr.registry.GetNode("aa:00:00:00:00:01")
if err != nil {
t.Fatal(err)
}
if node.Role != "rx" {
t.Errorf("OverrideRole did not persist role; got %q", node.Role)
}
if bcaster.broadcastCount() <= prevCalls {
t.Error("OverrideRole did not trigger a registry broadcast")
}
}
func TestManagerBroadcastOnConnect(t *testing.T) {
mgr, _, bcaster := newTestManager(t)
before := bcaster.broadcastCount()
mgr.OnNodeConnected("aa:00:00:00:00:01", "v1", "S3")
after := bcaster.broadcastCount()
if after <= before {
t.Error("OnNodeConnected did not broadcast registry state")
}
}
func TestManagerBroadcastOnDisconnect(t *testing.T) {
mgr, _, bcaster := newTestManager(t)
mgr.OnNodeConnected("aa:00:00:00:00:01", "v1", "S3")
before := bcaster.broadcastCount()
mgr.OnNodeDisconnected("aa:00:00:00:00:01")
after := bcaster.broadcastCount()
if after <= before {
t.Error("OnNodeDisconnected did not broadcast registry state")
}
}
// TestManagerPersistenceAcrossRestart verifies that node state survives a
// Manager restart by using the same registry.
func TestManagerPersistenceAcrossRestart(t *testing.T) {
reg := newTestRegistry(t)
// First manager: node connects and is persisted.
mgr1 := NewManager(reg)
n1 := newMockNotifier()
mgr1.SetNotifier(n1)
mgr1.OnNodeConnected("aa:00:00:00:00:01", "v1.2", "ESP32-S3")
// Second manager with same registry simulates a restart.
mgr2 := NewManager(reg)
n2 := newMockNotifier()
mgr2.SetNotifier(n2)
nodes, err := mgr2.registry.GetAllNodes()
if err != nil {
t.Fatalf("GetAllNodes after restart: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("expected 1 persisted node after restart, got %d", len(nodes))
}
if nodes[0].MAC != "aa:00:00:00:00:01" {
t.Errorf("wrong MAC after restart: %q", nodes[0].MAC)
}
if nodes[0].FirmwareVersion != "v1.2" {
t.Errorf("wrong firmware after restart: %q", nodes[0].FirmwareVersion)
}
}
// ─── System mode and auto-away tests ───────────────────────────────────────────
// mockBLEPresenceProvider implements BLEPresenceProvider for testing.
type mockBLEPresenceProvider struct {
registeredDevices map[string]string // MAC -> person_id
observations []BLEObservation
}
func newMockBLEPresenceProvider() *mockBLEPresenceProvider {
return &mockBLEPresenceProvider{
registeredDevices: make(map[string]string),
observations: make([]BLEObservation, 0),
}
}
func (m *mockBLEPresenceProvider) GetAllRegisteredDevices() (map[string]string, error) {
return m.registeredDevices, nil
}
func (m *mockBLEPresenceProvider) GetRecentRSSIObservations(mac string, maxAge time.Duration) []BLEObservation {
var result []BLEObservation
cutoff := time.Now().Add(-maxAge)
for _, obs := range m.observations {
if obs.DeviceMAC == mac && obs.Timestamp.After(cutoff) {
result = append(result, obs)
}
}
return result
}
// mockPersonNameProvider implements PersonNameProvider for testing.
type mockPersonNameProvider struct {
names map[string]string // person_id -> name
}
func (m *mockPersonNameProvider) GetPersonName(personID string) string {
if name, ok := m.names[personID]; ok {
return name
}
return personID
}
// mockModeChangeBroadcaster implements ModeChangeBroadcaster for testing.
type mockModeChangeBroadcaster struct {
mu sync.Mutex
events []events.SystemModeChangeEvent
}
func (m *mockModeChangeBroadcaster) BroadcastSystemModeChange(event events.SystemModeChangeEvent) {
m.mu.Lock()
defer m.mu.Unlock()
m.events = append(m.events, event)
}
func (m *mockModeChangeBroadcaster) getEvents() []events.SystemModeChangeEvent {
m.mu.Lock()
defer m.mu.Unlock()
return append([]events.SystemModeChangeEvent{}, m.events...)
}
func TestManager_AutoAwayActivates(t *testing.T) {
mgr, _, _ := newTestManager(t)
bleProvider := newMockBLEPresenceProvider()
bleProvider.registeredDevices["aa:bb:cc:dd:ee:ff"] = "person1"
mgr.SetBLEPresenceProvider(bleProvider)
modeBroadcaster := &mockModeChangeBroadcaster{}
mgr.SetModeChangeBroadcaster(modeBroadcaster)
// Initially in home mode
if mgr.GetSystemMode() != events.ModeHome {
t.Errorf("Expected initial mode to be home, got %s", mgr.GetSystemMode())
}
// Set all devices as seen long ago (more than 15 minutes ago)
mgr.mu.Lock()
mgr.lastDeviceSeen["aa:bb:cc:dd:ee:ff"] = time.Now().Add(-20 * time.Minute)
mgr.mu.Unlock()
// Check auto-away - should activate
mgr.CheckAutoAway()
if mgr.GetSystemMode() != events.ModeAway {
t.Errorf("Expected mode to be away after auto-away, got %s", mgr.GetSystemMode())
}
modeEvents := modeBroadcaster.getEvents()
if len(modeEvents) != 1 {
t.Fatalf("Expected 1 mode change event, got %d", len(modeEvents))
}
if modeEvents[0].NewMode != events.ModeAway {
t.Errorf("Expected new mode to be away, got %s", modeEvents[0].NewMode)
}
if modeEvents[0].Reason != "auto_away" {
t.Errorf("Expected reason to be auto_away, got %s", modeEvents[0].Reason)
}
}
func TestManager_AutoDisarmTriggers(t *testing.T) {
mgr, _, _ := newTestManager(t)
bleProvider := newMockBLEPresenceProvider()
bleProvider.registeredDevices["aa:bb:cc:dd:ee:ff"] = "person1"
mgr.SetBLEPresenceProvider(bleProvider)
personProvider := &mockPersonNameProvider{names: map[string]string{"person1": "Alice"}}
mgr.SetPersonProvider(personProvider)
modeBroadcaster := &mockModeChangeBroadcaster{}
mgr.SetModeChangeBroadcaster(modeBroadcaster)
// Set to away mode
mgr.mu.Lock()
mgr.systemMode = events.ModeAway
mgr.mu.Unlock()
// Simulate BLE observation with strong signal
observations := []BLEObservation{
{
DeviceMAC: "aa:bb:cc:dd:ee:ff",
NodeMAC: "node1",
RSSIdBm: -65, // Stronger than -70 threshold
Timestamp: time.Now(),
},
}
mgr.ProcessBLEObservations(observations)
// Should auto-disarm
if mgr.GetSystemMode() != events.ModeHome {
t.Errorf("Expected mode to be home after auto-disarm, got %s", mgr.GetSystemMode())
}
modeEvents := modeBroadcaster.getEvents()
if len(modeEvents) != 1 {
t.Fatalf("Expected 1 mode change event, got %d", len(modeEvents))
}
if modeEvents[0].NewMode != events.ModeHome {
t.Errorf("Expected new mode to be home, got %s", modeEvents[0].NewMode)
}
if modeEvents[0].Reason != "auto_disarm" {
t.Errorf("Expected reason to be auto_disarm, got %s", modeEvents[0].Reason)
}
if modeEvents[0].PersonID != "person1" {
t.Errorf("Expected person_id to be person1, got %s", modeEvents[0].PersonID)
}
if modeEvents[0].PersonName != "Alice" {
t.Errorf("Expected person_name to be Alice, got %s", modeEvents[0].PersonName)
}
}
func TestManager_AutoAwayDoesNotTriggerWithoutRegisteredDevices(t *testing.T) {
mgr, _, _ := newTestManager(t)
bleProvider := newMockBLEPresenceProvider()
// No registered devices
mgr.SetBLEPresenceProvider(bleProvider)
modeBroadcaster := &mockModeChangeBroadcaster{}
mgr.SetModeChangeBroadcaster(modeBroadcaster)
// Check auto-away - should NOT activate (no registered devices)
mgr.CheckAutoAway()
if mgr.GetSystemMode() != events.ModeHome {
t.Errorf("Expected mode to remain home when no registered devices, got %s", mgr.GetSystemMode())
}
events := modeBroadcaster.getEvents()
if len(events) != 0 {
t.Errorf("Expected no mode change events when no registered devices, got %d", len(events))
}
}
func TestManager_ManualOverridePausesAutoAway(t *testing.T) {
mgr, _, _ := newTestManager(t)
bleProvider := newMockBLEPresenceProvider()
bleProvider.registeredDevices["aa:bb:cc:dd:ee:ff"] = "person1"
mgr.SetBLEPresenceProvider(bleProvider)
// Set manual override
mgr.SetSystemMode(events.ModeAway, "manual")
// Verify override is active
if !mgr.IsManualOverrideActive() {
t.Error("Expected manual override to be active")
}
// Set all devices as seen long ago
mgr.mu.Lock()
mgr.lastDeviceSeen["aa:bb:cc:dd:ee:ff"] = time.Now().Add(-20 * time.Minute)
mgr.mu.Unlock()
// Check auto-away - should NOT trigger due to manual override
initialMode := mgr.GetSystemMode()
mgr.CheckAutoAway()
// Mode should not change (already away, but the point is no auto-away logic ran)
if mgr.GetSystemMode() != initialMode {
t.Errorf("Expected mode to remain %s with manual override, got %s", initialMode, mgr.GetSystemMode())
}
}
func TestManager_SetSystemMode(t *testing.T) {
mgr, _, _ := newTestManager(t)
modeBroadcaster := &mockModeChangeBroadcaster{}
mgr.SetModeChangeBroadcaster(modeBroadcaster)
// Set to away mode
err := mgr.SetSystemMode(events.ModeAway, "test")
if err != nil {
t.Fatalf("Failed to set system mode: %v", err)
}
if mgr.GetSystemMode() != events.ModeAway {
t.Errorf("Expected mode to be away, got %s", mgr.GetSystemMode())
}
modeEvents := modeBroadcaster.getEvents()
if len(modeEvents) != 1 {
t.Fatalf("Expected 1 mode change event, got %d", len(modeEvents))
}
if modeEvents[0].NewMode != events.ModeAway {
t.Errorf("Expected new mode to be away, got %s", modeEvents[0].NewMode)
}
if modeEvents[0].Reason != "test" {
t.Errorf("Expected reason to be test, got %s", modeEvents[0].Reason)
}
}
func TestManager_SetSystemModeSameModeNoOp(t *testing.T) {
mgr, _, _ := newTestManager(t)
modeBroadcaster := &mockModeChangeBroadcaster{}
mgr.SetModeChangeBroadcaster(modeBroadcaster)
// Set to home mode (already home)
err := mgr.SetSystemMode(events.ModeHome, "test")
if err != nil {
t.Fatalf("Failed to set system mode: %v", err)
}
// Should not have triggered any events
modeEvents := modeBroadcaster.getEvents()
if len(modeEvents) != 0 {
t.Errorf("Expected no mode change events when setting to same mode, got %d", len(modeEvents))
}
}
func TestManager_AutoDisarmWeakSignalNoTrigger(t *testing.T) {
mgr, _, _ := newTestManager(t)
bleProvider := newMockBLEPresenceProvider()
bleProvider.registeredDevices["aa:bb:cc:dd:ee:ff"] = "person1"
mgr.SetBLEPresenceProvider(bleProvider)
modeBroadcaster := &mockModeChangeBroadcaster{}
mgr.SetModeChangeBroadcaster(modeBroadcaster)
// Set to away mode
mgr.mu.Lock()
mgr.systemMode = events.ModeAway
mgr.mu.Unlock()
// Simulate BLE observation with weak signal (below -70 threshold)
observations := []BLEObservation{
{
DeviceMAC: "aa:bb:cc:dd:ee:ff",
NodeMAC: "node1",
RSSIdBm: -75, // Weaker than -70 threshold
Timestamp: time.Now(),
},
}
mgr.ProcessBLEObservations(observations)
// Should NOT auto-disarm (signal too weak)
if mgr.GetSystemMode() != events.ModeAway {
t.Errorf("Expected mode to remain away with weak BLE signal, got %s", mgr.GetSystemMode())
}
events := modeBroadcaster.getEvents()
if len(events) != 0 {
t.Errorf("Expected no mode change events with weak BLE signal, got %d", len(events))
}
}
func TestManager_AutoDisarmUnregisteredDeviceNoTrigger(t *testing.T) {
mgr, _, _ := newTestManager(t)
bleProvider := newMockBLEPresenceProvider()
bleProvider.registeredDevices["aa:bb:cc:dd:ee:ff"] = "person1"
mgr.SetBLEPresenceProvider(bleProvider)
modeBroadcaster := &mockModeChangeBroadcaster{}
mgr.SetModeChangeBroadcaster(modeBroadcaster)
// Set to away mode
mgr.mu.Lock()
mgr.systemMode = events.ModeAway
mgr.mu.Unlock()
// Simulate BLE observation from unregistered device
observations := []BLEObservation{
{
DeviceMAC: "11:22:33:44:55:66", // Not registered
NodeMAC: "node1",
RSSIdBm: -65,
Timestamp: time.Now(),
},
}
mgr.ProcessBLEObservations(observations)
// Should NOT auto-disarm (device not registered)
if mgr.GetSystemMode() != events.ModeAway {
t.Errorf("Expected mode to remain away with unregistered BLE device, got %s", mgr.GetSystemMode())
}
events := modeBroadcaster.getEvents()
if len(events) != 0 {
t.Errorf("Expected no mode change events with unregistered BLE device, got %d", len(events))
}
}