spaxel/mothership/internal/dashboard/hub_test.go
jedarden aaa622d410 feat(ui): implement command palette (Component 34) with tests
- commandpalette.js: CommandPaletteManager with fuzzy scorer, time parsing,
  command registry (20 commands), recent history, entity search, mode gating
- commandpalette.css: modal overlay, search input, result list styles
- commandpalette.test.js: 64 tests covering fuzzy match, time parsing, commands
  completeness, keyboard nav, recent history, expert-mode gating, positioning
- app.js: spaxelGetState() exposure and Ctrl+K fallback listener
- index.html: includes commandpalette.css and commandpalette.js
- explainability.js + explain.go: detection explainability backend/frontend
- hub.go + server.go: dashboard WebSocket and API updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 19:51:16 -04:00

1096 lines
28 KiB
Go

package dashboard
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/spaxel/mothership/internal/ingestion"
)
func TestHub_RegisterUnregister(t *testing.T) {
hub := NewHub()
go hub.Run()
defer func() {
// Hub has no shutdown method in Phase 1, just let it run
}()
client := &Client{
hub: hub,
send: make(chan []byte, 10),
}
// Register
hub.Register(client)
time.Sleep(10 * time.Millisecond)
if hub.ClientCount() != 1 {
t.Errorf("expected 1 client, got %d", hub.ClientCount())
}
// Unregister
hub.Unregister(client)
time.Sleep(10 * time.Millisecond)
if hub.ClientCount() != 0 {
t.Errorf("expected 0 clients, got %d", hub.ClientCount())
}
}
// drainSnapshot reads and discards the initial snapshot message sent on connect.
func drainSnapshot(t *testing.T, ch chan []byte) {
t.Helper()
select {
case <-ch:
case <-time.After(100 * time.Millisecond):
t.Fatal("expected snapshot message")
}
}
func TestHub_Broadcast(t *testing.T) {
hub := NewHub()
go hub.Run()
client := &Client{
hub: hub,
send: make(chan []byte, 10),
}
hub.Register(client)
time.Sleep(10 * time.Millisecond)
drainSnapshot(t, client.send)
// Broadcast a message
testMsg := []byte(`{"type":"test"}`)
hub.Broadcast(testMsg)
// Client should receive it
select {
case msg := <-client.send:
if string(msg) != string(testMsg) {
t.Errorf("expected %s, got %s", testMsg, msg)
}
case <-time.After(100 * time.Millisecond):
t.Error("expected to receive broadcast message")
}
}
func TestHub_BroadcastCSI(t *testing.T) {
hub := NewHub()
go hub.Run()
client := &Client{
hub: hub,
send: make(chan []byte, 10),
}
hub.Register(client)
time.Sleep(10 * time.Millisecond)
drainSnapshot(t, client.send)
// Broadcast CSI (raw binary data)
testData := []byte{0x01, 0x02, 0x03, 0x04}
hub.BroadcastCSI("AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66", testData)
select {
case msg := <-client.send:
if string(msg) != string(testData) {
t.Errorf("expected %v, got %v", testData, msg)
}
case <-time.After(100 * time.Millisecond):
t.Error("expected to receive CSI broadcast")
}
}
func TestHub_NodeEvents(t *testing.T) {
hub := NewHub()
go hub.Run()
client := &Client{
hub: hub,
send: make(chan []byte, 10),
}
hub.Register(client)
time.Sleep(10 * time.Millisecond)
drainSnapshot(t, client.send)
// Test node connected event
hub.BroadcastNodeConnected("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
msg := <-client.send
expected := `{"chip":"ESP32-S3","firmware_version":"1.0.0","mac":"AA:BB:CC:DD:EE:FF","type":"node_connected"}`
if string(msg) != expected {
t.Errorf("expected %s, got %s", expected, msg)
}
// Test node disconnected event
hub.BroadcastNodeDisconnected("AA:BB:CC:DD:EE:FF")
msg = <-client.send
expected = `{"mac":"AA:BB:CC:DD:EE:FF","type":"node_disconnected"}`
if string(msg) != expected {
t.Errorf("expected %s, got %s", expected, msg)
}
}
func TestHub_LinkEvents(t *testing.T) {
hub := NewHub()
go hub.Run()
client := &Client{
hub: hub,
send: make(chan []byte, 10),
}
hub.Register(client)
time.Sleep(10 * time.Millisecond)
drainSnapshot(t, client.send)
// Test link active event
hub.BroadcastLinkActive("AA:BB:CC:DD:EE:FF:11:22:33:44:55:66", "AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66")
msg := <-client.send
expected := `{"id":"AA:BB:CC:DD:EE:FF:11:22:33:44:55:66","node_mac":"AA:BB:CC:DD:EE:FF","peer_mac":"11:22:33:44:55:66","type":"link_active"}`
if string(msg) != expected {
t.Errorf("expected %s, got %s", expected, msg)
}
}
// MockIngestionState implements IngestionState for testing
type MockIngestionState struct {
nodes []ingestion.NodeInfo
links []ingestion.LinkInfo
}
func (m *MockIngestionState) GetConnectedNodesInfo() []ingestion.NodeInfo {
return m.nodes
}
func (m *MockIngestionState) GetAllLinksInfo() []ingestion.LinkInfo {
return m.links
}
func (m *MockIngestionState) GetAllMotionStates() []ingestion.MotionStateItem {
return nil
}
func TestHub_SnapshotOnConnect(t *testing.T) {
hub := NewHub()
go hub.Run()
// Set mock ingestion state so the snapshot has content
mock := &MockIngestionState{
nodes: []ingestion.NodeInfo{
{MAC: "AA:BB:CC:DD:EE:FF", FirmwareVersion: "1.0.0", Chip: "ESP32-S3"},
},
links: []ingestion.LinkInfo{
{ID: "AA:BB:CC:DD:EE:FF:11:22:33:44:55:66", NodeMAC: "AA:BB:CC:DD:EE:FF", PeerMAC: "11:22:33:44:55:66"},
},
}
hub.SetIngestionState(mock)
client := &Client{
hub: hub,
send: make(chan []byte, 10),
}
hub.Register(client)
time.Sleep(10 * time.Millisecond)
// First message must be the snapshot
select {
case msg := <-client.send:
if len(msg) == 0 || msg[0] != '{' {
t.Fatalf("expected JSON snapshot message, got %v", msg)
}
var parsed map[string]json.RawMessage
if err := json.Unmarshal(msg, &parsed); err != nil {
t.Fatalf("failed to parse snapshot JSON: %v", err)
}
// Must have type="snapshot"
if typ, ok := parsed["type"]; !ok {
t.Fatal("snapshot missing 'type' field")
} else if strings.Trim(string(typ), `"`) != "snapshot" {
t.Fatalf("expected type=snapshot, got %s", string(typ))
}
// Must have timestamp_ms
if _, ok := parsed["timestamp_ms"]; !ok {
t.Fatal("snapshot missing 'timestamp_ms' field")
}
// Must contain nodes from the mock
if _, ok := parsed["nodes"]; !ok {
t.Fatal("snapshot missing 'nodes' field")
}
case <-time.After(100 * time.Millisecond):
t.Fatal("expected snapshot message within 100 ms")
}
}
func TestHub_SnapshotBeforeDelta(t *testing.T) {
hub := NewHub()
go hub.Run()
mock := &MockIngestionState{
nodes: []ingestion.NodeInfo{
{MAC: "AA:BB:CC:DD:EE:FF", FirmwareVersion: "1.0.0"},
},
}
hub.SetIngestionState(mock)
client := &Client{
hub: hub,
send: make(chan []byte, 256),
}
hub.Register(client)
time.Sleep(10 * time.Millisecond)
// Drain all messages; first must be snapshot, rest must be deltas or events.
timeout := time.After(200 * time.Millisecond)
first := true
for {
select {
case msg := <-client.send:
var parsed map[string]json.RawMessage
if err := json.Unmarshal(msg, &parsed); err != nil {
continue // skip non-JSON (binary CSI)
}
if first {
first = false
typ := strings.Trim(string(parsed["type"]), `"`)
if typ != "snapshot" {
t.Fatalf("first message should be snapshot, got type=%s", typ)
}
} else {
// Subsequent messages from tickDelta must NOT have a type field.
// Event-driven messages (BroadcastNodeConnected etc.) do have type.
if _, hasType := parsed["type"]; hasType {
typ := strings.Trim(string(parsed["type"]), `"`)
// These are acceptable event types
switch typ {
case "node_connected", "node_disconnected", "link_active",
"link_inactive", "motion_state", "loc_update",
"registry_state", "fleet_change", "system_health",
"ble_scan", "trigger_state", "event", "alert",
"anomaly_detected", "system_mode_change",
"fleet_health", "fleet_history":
// OK — event-driven broadcast
default:
t.Errorf("unexpected type in non-snapshot message: %s", typ)
}
}
// No type field → delta update (expected from tickDelta)
}
case <-timeout:
return // test passed
}
}
}
func TestHub_BroadcastAlert(t *testing.T) {
tests := []struct {
name string
alertID string
severity string
description string
acknowledged bool
}{
{
name: "critical anomaly alert",
alertID: "anomaly-001",
severity: "critical",
description: "Unusual activity detected in Kitchen at 3am",
acknowledged: false,
},
{
name: "warning security mode armed",
alertID: "security-armed-20260407-030000",
severity: "warning",
description: "Security mode armed (auto-away)",
acknowledged: false,
},
{
name: "acknowledged alert",
alertID: "anomaly-002",
severity: "warning",
description: "Environmental change detected",
acknowledged: true,
},
{
name: "security mode disarmed",
alertID: "security-disarmed-20260407-080000",
severity: "warning",
description: "Security mode disarmed (BLE device detected)",
acknowledged: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
hub := NewHub()
go hub.Run()
client := &Client{
hub: hub,
send: make(chan []byte, 10),
}
hub.Register(client)
time.Sleep(10 * time.Millisecond)
drainSnapshot(t, client.send)
ts := time.Date(2026, 4, 7, 3, 0, 0, 0, time.UTC)
hub.BroadcastAlert(tc.alertID, ts, tc.severity, tc.description, tc.acknowledged)
select {
case msg := <-client.send:
var parsed map[string]interface{}
if err := json.Unmarshal(msg, &parsed); err != nil {
t.Fatalf("failed to parse alert JSON: %v", err)
}
if parsed["type"] != "alert" {
t.Errorf("expected type=alert, got %v", parsed["type"])
}
alert, ok := parsed["alert"].(map[string]interface{})
if !ok {
t.Fatal("missing alert object")
}
if alert["id"] != tc.alertID {
t.Errorf("expected id=%s, got %v", tc.alertID, alert["id"])
}
if alert["severity"] != tc.severity {
t.Errorf("expected severity=%s, got %v", tc.severity, alert["severity"])
}
if alert["description"] != tc.description {
t.Errorf("expected description=%s, got %v", tc.description, alert["description"])
}
if alert["acknowledged"] != tc.acknowledged {
t.Errorf("expected acknowledged=%v, got %v", tc.acknowledged, alert["acknowledged"])
}
// ts should be Unix milliseconds
tsVal, ok := alert["ts"].(float64)
if !ok {
t.Fatalf("expected ts to be numeric, got %T", alert["ts"])
}
expectedTs := float64(ts.UnixMilli())
if tsVal != expectedTs {
t.Errorf("expected ts=%v, got %v", expectedTs, tsVal)
}
case <-time.After(100 * time.Millisecond):
t.Error("expected to receive alert broadcast")
}
})
}
}
func TestHub_BroadcastEvent(t *testing.T) {
tests := []struct {
name string
eventID string
kind string
zone string
blobID int
personName string
}{
{
name: "zone entry with person",
eventID: "zone_entry:1:1711234567890",
kind: "zone_entry",
zone: "Kitchen",
blobID: 2,
personName: "Alice",
},
{
name: "zone exit without person",
eventID: "zone_exit:1:1711234567891",
kind: "zone_exit",
zone: "Kitchen",
blobID: 3,
personName: "",
},
{
name: "portal crossing with person",
eventID: "portal:5:1711234567892",
kind: "portal_crossing",
zone: "Hallway",
blobID: 1,
personName: "Bob",
},
{
name: "presence transition",
eventID: "presence:AA:BB:CC:DD:EE:FF:11:22:33:44:55:66:1711234567893",
kind: "presence_transition",
zone: "AA:BB:CC:DD:EE:FF:11:22:33:44:55:66",
blobID: 0,
personName: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
hub := NewHub()
go hub.Run()
client := &Client{
hub: hub,
send: make(chan []byte, 10),
}
hub.Register(client)
time.Sleep(10 * time.Millisecond)
drainSnapshot(t, client.send)
ts := time.Date(2026, 4, 7, 14, 30, 5, 0, time.UTC)
hub.BroadcastEvent(tc.eventID, ts, tc.kind, tc.zone, tc.blobID, tc.personName)
select {
case msg := <-client.send:
var parsed map[string]interface{}
if err := json.Unmarshal(msg, &parsed); err != nil {
t.Fatalf("failed to parse event JSON: %v", err)
}
if parsed["type"] != "event" {
t.Errorf("expected type=event, got %v", parsed["type"])
}
evt, ok := parsed["event"].(map[string]interface{})
if !ok {
t.Fatal("missing event object")
}
if evt["id"] != tc.eventID {
t.Errorf("expected id=%s, got %v", tc.eventID, evt["id"])
}
if evt["kind"] != tc.kind {
t.Errorf("expected kind=%s, got %v", tc.kind, evt["kind"])
}
if evt["zone"] != tc.zone {
t.Errorf("expected zone=%s, got %v", tc.zone, evt["zone"])
}
if evt["blob_id"] != float64(tc.blobID) {
t.Errorf("expected blob_id=%d, got %v", tc.blobID, evt["blob_id"])
}
if tc.personName != "" && evt["person_name"] != tc.personName {
t.Errorf("expected person_name=%s, got %v", tc.personName, evt["person_name"])
}
tsVal, ok := evt["ts"].(float64)
if !ok {
t.Fatalf("expected ts to be numeric, got %T", evt["ts"])
}
expectedTs := float64(ts.UnixMilli())
if tsVal != expectedTs {
t.Errorf("expected ts=%v, got %v", expectedTs, tsVal)
}
case <-time.After(100 * time.Millisecond):
t.Error("expected to receive event broadcast")
}
})
}
}
func TestHub_BroadcastBLEScan(t *testing.T) {
tests := []struct {
name string
devices []map[string]interface{}
}{
{
name: "single device",
devices: []map[string]interface{}{
{"mac": "AA:BB:CC:DD:EE:FF", "name": "iPhone", "rssi": -62,
"last_seen": int64(1711234567890), "label": "Alice", "blob_id": 1},
},
},
{
name: "multiple devices",
devices: []map[string]interface{}{
{"mac": "AA:BB:CC:DD:EE:FF", "name": "iPhone", "rssi": -62,
"last_seen": int64(1711234567890), "label": "Alice", "blob_id": 1},
{"mac": "11:22:33:44:55:66", "name": "Apple Watch", "rssi": -70,
"last_seen": int64(1711234567891), "label": "", "blob_id": nil},
},
},
{
name: "empty device list",
devices: []map[string]interface{}{},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
hub := NewHub()
go hub.Run()
client := &Client{
hub: hub,
send: make(chan []byte, 10),
}
hub.Register(client)
time.Sleep(10 * time.Millisecond)
drainSnapshot(t, client.send)
hub.BroadcastBLEScan(tc.devices)
select {
case msg := <-client.send:
var parsed map[string]interface{}
if err := json.Unmarshal(msg, &parsed); err != nil {
t.Fatalf("failed to parse ble_scan JSON: %v", err)
}
if parsed["type"] != "ble_scan" {
t.Errorf("expected type=ble_scan, got %v", parsed["type"])
}
devs, ok := parsed["devices"].([]interface{})
if !ok {
t.Fatal("missing devices array")
}
if len(devs) != len(tc.devices) {
t.Errorf("expected %d devices, got %d", len(tc.devices), len(devs))
}
for i, dev := range tc.devices {
d := devs[i].(map[string]interface{})
if d["mac"] != dev["mac"] {
t.Errorf("device %d: expected mac=%v, got %v", i, dev["mac"], d["mac"])
}
if d["name"] != dev["name"] {
t.Errorf("device %d: expected name=%v, got %v", i, dev["name"], d["name"])
}
}
case <-time.After(100 * time.Millisecond):
t.Error("expected to receive ble_scan broadcast")
}
})
}
}
func TestHub_BroadcastEventFromDB(t *testing.T) {
tests := []struct {
name string
id int64
timestamp int64
eventType string
zone string
person string
blobID int
detailJSON string
severity string
}{
{
name: "zone entry with person and detail",
id: 42,
timestamp: 1711234567890,
eventType: "zone_entry",
zone: "Kitchen",
person: "Alice",
blobID: 2,
detailJSON: `{"direction":"north"}`,
severity: "info",
},
{
name: "zone exit without person",
id: 43,
timestamp: 1711234567891,
eventType: "zone_exit",
zone: "Kitchen",
person: "",
blobID: 3,
severity: "info",
},
{
name: "portal crossing",
id: 44,
timestamp: 1711234567892,
eventType: "portal_crossing",
zone: "Hallway",
person: "Bob",
blobID: 1,
severity: "info",
},
{
name: "anomaly alert",
id: 45,
timestamp: 1711234567893,
eventType: "anomaly",
zone: "Kitchen",
person: "",
blobID: 0,
detailJSON: `{"score":0.92}`,
severity: "warning",
},
{
name: "minimal event",
id: 46,
timestamp: 1711234567894,
eventType: "system",
zone: "",
person: "",
blobID: 0,
severity: "info",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
hub := NewHub()
go hub.Run()
client := &Client{
hub: hub,
send: make(chan []byte, 10),
}
hub.Register(client)
time.Sleep(10 * time.Millisecond)
drainSnapshot(t, client.send)
hub.BroadcastEventFromDB(tc.id, tc.timestamp, tc.eventType, tc.zone, tc.person, tc.blobID, tc.detailJSON, tc.severity)
select {
case msg := <-client.send:
var parsed map[string]interface{}
if err := json.Unmarshal(msg, &parsed); err != nil {
t.Fatalf("failed to parse event JSON: %v", err)
}
if parsed["type"] != "event" {
t.Errorf("expected type=event, got %v", parsed["type"])
}
evt, ok := parsed["event"].(map[string]interface{})
if !ok {
t.Fatal("missing event object")
}
// Verify canonical field names (matching BroadcastEvent format)
if evt["ts"] != float64(tc.timestamp) {
t.Errorf("expected ts=%d, got %v", tc.timestamp, evt["ts"])
}
if evt["kind"] != tc.eventType {
t.Errorf("expected kind=%s, got %v", tc.eventType, evt["kind"])
}
if evt["zone"] != tc.zone {
t.Errorf("expected zone=%s, got %v", tc.zone, evt["zone"])
}
if evt["blob_id"] != float64(tc.blobID) {
t.Errorf("expected blob_id=%d, got %v", tc.blobID, evt["blob_id"])
}
if evt["person_name"] != tc.person {
t.Errorf("expected person_name=%s, got %v", tc.person, evt["person_name"])
}
// Verify extra DB fields are present
if evt["severity"] != tc.severity {
t.Errorf("expected severity=%s, got %v", tc.severity, evt["severity"])
}
// detail_json should be present when non-empty
if tc.detailJSON != "" {
if evt["detail_json"] != tc.detailJSON {
t.Errorf("expected detail_json=%s, got %v", tc.detailJSON, evt["detail_json"])
}
}
// Verify legacy field names are NOT used
if _, hasLegacy := evt["timestamp_ms"]; hasLegacy {
t.Error("legacy field timestamp_ms should not be present (use ts)")
}
if _, hasLegacy := evt["type"]; hasLegacy {
t.Error("legacy field type should not be present inside event (use kind)")
}
if _, hasLegacy := evt["person"]; hasLegacy {
t.Error("legacy field person should not be present (use person_name)")
}
case <-time.After(100 * time.Millisecond):
t.Error("expected to receive event broadcast")
}
})
}
}
func TestHub_BroadcastSystemHealth(t *testing.T) {
tests := []struct {
name string
uptimeS int64
nodeCount int
beadCount int
goRoutines int
memMB float64
}{
{
name: "fresh start",
uptimeS: 60,
nodeCount: 0,
beadCount: 0,
goRoutines: 12,
memMB: 45.2,
},
{
name: "running system",
uptimeS: 86400,
nodeCount: 4,
beadCount: 28,
goRoutines: 87,
memMB: 128.5,
},
{
name: "long uptime large fleet",
uptimeS: 2592000,
nodeCount: 16,
beadCount: 120,
goRoutines: 200,
memMB: 512.0,
},
{
name: "zero values",
uptimeS: 0,
nodeCount: 0,
beadCount: 0,
goRoutines: 0,
memMB: 0,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
hub := NewHub()
go hub.Run()
client := &Client{
hub: hub,
send: make(chan []byte, 10),
}
hub.Register(client)
time.Sleep(10 * time.Millisecond)
drainSnapshot(t, client.send)
hub.BroadcastSystemHealth(tc.uptimeS, tc.nodeCount, tc.beadCount, tc.goRoutines, tc.memMB)
select {
case msg := <-client.send:
var parsed map[string]interface{}
if err := json.Unmarshal(msg, &parsed); err != nil {
t.Fatalf("failed to parse system_health JSON: %v", err)
}
if parsed["type"] != "system_health" {
t.Errorf("expected type=system_health, got %v", parsed["type"])
}
health, ok := parsed["health"].(map[string]interface{})
if !ok {
t.Fatal("missing health object")
}
if health["uptime_s"] != float64(tc.uptimeS) {
t.Errorf("expected uptime_s=%d, got %v", tc.uptimeS, health["uptime_s"])
}
if health["node_count"] != float64(tc.nodeCount) {
t.Errorf("expected node_count=%d, got %v", tc.nodeCount, health["node_count"])
}
if health["bead_count"] != float64(tc.beadCount) {
t.Errorf("expected bead_count=%d, got %v", tc.beadCount, health["bead_count"])
}
if health["go_routines"] != float64(tc.goRoutines) {
t.Errorf("expected go_routines=%d, got %v", tc.goRoutines, health["go_routines"])
}
if health["mem_mb"] != tc.memMB {
t.Errorf("expected mem_mb=%f, got %v", tc.memMB, health["mem_mb"])
}
case <-time.After(100 * time.Millisecond):
t.Error("expected to receive system_health broadcast")
}
})
}
}
func TestHub_DeltaOmitsTypeField(t *testing.T) {
hub := NewHub()
go hub.Run()
mock := &MockIngestionState{
nodes: []ingestion.NodeInfo{
{MAC: "AA:BB:CC:DD:EE:FF", FirmwareVersion: "1.0.0"},
},
}
hub.SetIngestionState(mock)
client := &Client{
hub: hub,
send: make(chan []byte, 256),
}
hub.Register(client)
time.Sleep(10 * time.Millisecond)
// Read the first message (snapshot) and discard it
select {
case <-client.send:
case <-time.After(100 * time.Millisecond):
t.Fatal("expected snapshot")
}
// Wait for a delta tick (100ms) and check it has no type field
found := false
for i := 0; i < 5; i++ {
select {
case msg := <-client.send:
var parsed map[string]json.RawMessage
if err := json.Unmarshal(msg, &parsed); err != nil {
continue
}
if _, hasType := parsed["type"]; !hasType {
// This is a delta message — must have timestamp_ms
if _, ok := parsed["timestamp_ms"]; !ok {
t.Error("delta message missing timestamp_ms")
}
found = true
}
case <-time.After(150 * time.Millisecond):
// Try next tick
}
}
if !found {
t.Error("expected at least one delta message (no type field)")
}
}
func TestHub_BroadcastTriggerState(t *testing.T) {
tests := []struct {
name string
triggerID string
triggerName string
lastFired time.Time
enabled bool
}{
{
name: "enabled trigger with last fired",
triggerID: "trigger-1",
triggerName: "Couch Dwell",
lastFired: time.Date(2026, 4, 7, 14, 32, 5, 0, time.UTC),
enabled: true,
},
{
name: "disabled trigger never fired",
triggerID: "trigger-2",
triggerName: "Hallway Motion",
lastFired: time.Time{},
enabled: false,
},
{
name: "enabled trigger never fired",
triggerID: "trigger-3",
triggerName: "Kitchen Entry",
lastFired: time.Time{},
enabled: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
hub := NewHub()
go hub.Run()
client := &Client{
hub: hub,
send: make(chan []byte, 10),
}
hub.Register(client)
time.Sleep(10 * time.Millisecond)
drainSnapshot(t, client.send)
hub.BroadcastTriggerState(tc.triggerID, tc.triggerName, tc.lastFired, tc.enabled)
select {
case msg := <-client.send:
var parsed map[string]interface{}
if err := json.Unmarshal(msg, &parsed); err != nil {
t.Fatalf("failed to parse trigger_state JSON: %v", err)
}
if parsed["type"] != "trigger_state" {
t.Errorf("expected type=trigger_state, got %v", parsed["type"])
}
trigger, ok := parsed["trigger"].(map[string]interface{})
if !ok {
t.Fatal("missing trigger object")
}
if trigger["id"] != tc.triggerID {
t.Errorf("expected id=%s, got %v", tc.triggerID, trigger["id"])
}
if trigger["name"] != tc.triggerName {
t.Errorf("expected name=%s, got %v", tc.triggerName, trigger["name"])
}
if trigger["enabled"] != tc.enabled {
t.Errorf("expected enabled=%v, got %v", tc.enabled, trigger["enabled"])
}
// Verify last_fired
if !tc.lastFired.IsZero() {
tsVal, ok := trigger["last_fired"].(float64)
if !ok {
t.Fatalf("expected last_fired to be numeric, got %T", trigger["last_fired"])
}
expectedTs := float64(tc.lastFired.UnixMilli())
if tsVal != expectedTs {
t.Errorf("expected last_fired=%v, got %v", expectedTs, tsVal)
}
}
case <-time.After(100 * time.Millisecond):
t.Error("expected to receive trigger_state broadcast")
}
})
}
}
// ---- ExplainabilitySnapshot WebSocket flow tests ----
// TestHub_RequestExplain_ConsumeExplainRequests verifies that RequestExplain
// enqueues a blob ID and ConsumeExplainRequests drains the queue.
func TestHub_RequestExplain_ConsumeExplainRequests(t *testing.T) {
hub := NewHub()
// Initially nothing pending.
if ids := hub.ConsumeExplainRequests(); len(ids) != 0 {
t.Fatalf("expected empty queue, got %v", ids)
}
// Enqueue a request.
hub.RequestExplain(42)
ids := hub.ConsumeExplainRequests()
if len(ids) != 1 || ids[0] != 42 {
t.Fatalf("expected [42], got %v", ids)
}
// Queue should be empty after consuming.
if ids2 := hub.ConsumeExplainRequests(); len(ids2) != 0 {
t.Fatalf("expected empty queue after consume, got %v", ids2)
}
}
// TestHub_RequestExplain_Deduplicate verifies that duplicate requests for the same
// blob ID are deduplicated (the ID appears only once per consume cycle).
func TestHub_RequestExplain_Deduplicate(t *testing.T) {
hub := NewHub()
hub.RequestExplain(7)
hub.RequestExplain(7)
hub.RequestExplain(7)
ids := hub.ConsumeExplainRequests()
if len(ids) != 1 {
t.Fatalf("expected deduplication to 1 entry, got %d: %v", len(ids), ids)
}
if ids[0] != 7 {
t.Fatalf("expected id=7, got %d", ids[0])
}
}
// TestHub_BroadcastExplainSnapshot verifies that BroadcastExplainSnapshot sends a
// correctly structured "blob_explain" message to all connected clients.
func TestHub_BroadcastExplainSnapshot(t *testing.T) {
hub := NewHub()
go hub.Run()
client := &Client{
hub: hub,
send: make(chan []byte, 20),
}
hub.Register(client)
time.Sleep(10 * time.Millisecond)
drainSnapshot(t, client.send) // discard the initial snapshot
snapshot := map[string]interface{}{
"blob_id": 1,
"blob_position": [3]float64{3.2, 1.8, 1.0},
"fusion_score": 0.87,
}
hub.BroadcastExplainSnapshot(1, snapshot)
select {
case msg := <-client.send:
var parsed map[string]interface{}
if err := json.Unmarshal(msg, &parsed); err != nil {
t.Fatalf("failed to unmarshal blob_explain message: %v", err)
}
if parsed["type"] != "blob_explain" {
t.Errorf("expected type='blob_explain', got %v", parsed["type"])
}
blobIDRaw, ok := parsed["blob_id"]
if !ok {
t.Fatal("missing blob_id field")
}
switch v := blobIDRaw.(type) {
case float64:
if int(v) != 1 {
t.Errorf("expected blob_id=1, got %v", v)
}
default:
t.Errorf("unexpected blob_id type %T: %v", blobIDRaw, blobIDRaw)
}
if _, ok := parsed["snapshot"]; !ok {
t.Error("missing snapshot field in blob_explain message")
}
case <-time.After(100 * time.Millisecond):
t.Error("expected to receive blob_explain broadcast")
}
}
// TestServer_HandleRequestExplain verifies that a "request_explain" WebSocket
// command enqueues the blob ID in the hub for the next fusion tick.
func TestServer_HandleRequestExplain(t *testing.T) {
hub := NewHub()
server := NewServer(hub)
cmd := []byte(`{"type":"request_explain","blob_id":99}`)
server.handleCommand(cmd, nil)
ids := hub.ConsumeExplainRequests()
if len(ids) != 1 || ids[0] != 99 {
t.Fatalf("expected [99] after handleRequestExplain, got %v", ids)
}
}
// TestServer_HandleRequestExplain_MissingBlobID verifies graceful handling of a
// "request_explain" command with a missing or invalid blob_id field.
func TestServer_HandleRequestExplain_MissingBlobID(t *testing.T) {
hub := NewHub()
server := NewServer(hub)
// Missing blob_id — should be silently ignored.
server.handleCommand([]byte(`{"type":"request_explain"}`), nil)
if ids := hub.ConsumeExplainRequests(); len(ids) != 0 {
t.Fatalf("expected no enqueued IDs for missing blob_id, got %v", ids)
}
// blob_id is a string — should also be silently ignored.
server.handleCommand([]byte(`{"type":"request_explain","blob_id":"not_a_number"}`), nil)
if ids := hub.ConsumeExplainRequests(); len(ids) != 0 {
t.Fatalf("expected no enqueued IDs for string blob_id, got %v", ids)
}
}