spaxel/dashboard/js/sidebar-timeline.test.js
jedarden 21020e9fc9 feat(timeline): add tap-to-jump time-travel coordination
When timeline event is clicked in expert mode, emit jump_to_time command
with event timestamp. The time-travel player pauses live playback, seeks
CSI recording buffer to timestamp, and begins replay. Selected event
highlights in timeline and "Now replaying" chip appears in header.

Backend: POST /api/replay/jump-to-time creates replay session centered
on timestamp, replaces previous active session. Frontend: handleSeek()
in sidebar-timeline delegates to SpaxelReplay.jumpToTime() which calls
the API, shows replay control bar, and notifies Viz3D.

Tests: 7 Go test cases for jump-to-time endpoint, 8 JS test cases for
tap-to-jump interaction, event highlighting, and now-replaying chip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 12:08:39 -04:00

1234 lines
52 KiB
JavaScript

/**
* Tests for Sidebar Timeline Panel
*
* Tests the collapsible sidebar panel showing events in reverse-chronological order.
* Covers:
* - Event-specific visual rendering with icons and descriptions per event type
* - Thumbs-up/down buttons on each event delegating to feedback module
* - Virtualized rendering with IntersectionObserver for 10,000+ events
*/
'use strict';
// Mock dependencies
let mockEventData = { events: [], cursor: null, total_filtered: 0 };
global.fetch = jest.fn(function() {
return Promise.resolve({
ok: true,
json: function() {
return Promise.resolve(mockEventData);
}
});
});
// Mock Feedback module
global.Feedback = {
sendFeedback: jest.fn()
};
// Mock SpaxelApp
global.SpaxelApp = {
registerMessageHandler: jest.fn(),
showToast: jest.fn()
};
// Mock SpaxelRouter
global.SpaxelRouter = {
onModeChange: jest.fn(),
navigate: jest.fn()
};
// Mock SpaxelTimeline
global.SpaxelTimeline = {};
// Mock SpaxelSimpleModeDetection
global.SpaxelSimpleModeDetection = {
onModeChange: jest.fn()
};
// Mock IntersectionObserver
global.IntersectionObserver = jest.fn(function(callback, options) {
this.observe = jest.fn();
this.unobserve = jest.fn();
this.disconnect = jest.fn();
this.callback = callback;
this.options = options;
// Simulate immediate intersection for testing
this.simulateIntersection = function(entries) {
if (this.callback) {
this.callback(entries);
}
};
});
describe('SidebarTimeline', function() {
let SidebarTimeline;
let mockElements;
let mockEventData = { events: [], cursor: null, total_filtered: 0 };
beforeEach(function() {
// Reset all mocks
jest.clearAllMocks();
// Reset mock event data
mockEventData = { events: [], cursor: null, total_filtered: 0 };
// Setup DOM structure
document.body.innerHTML = `
<div id="sidebar-timeline-panel" class="sidebar-panel collapsed">
<div class="sidebar-panel-header">
<div class="sidebar-panel-title">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>Timeline</span>
</div>
<div class="sidebar-panel-actions">
<button id="sidebar-timeline-toggle" class="sidebar-panel-btn" title="Toggle panel">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
</button>
<button id="sidebar-timeline-close" class="sidebar-panel-btn" title="Close panel">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
<div id="sidebar-timeline-content" class="sidebar-panel-content">
<div id="sidebar-timeline-events" class="sidebar-timeline-events"></div>
<div id="sidebar-timeline-loading" class="sidebar-timeline-loading" style="display: none;">
<div class="sidebar-spinner"></div>
</div>
<div id="sidebar-timeline-empty" class="sidebar-timeline-empty" style="display: none;">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="6" x2="12" y2="12"></line>
<line x1="12" y1="12" x2="12" y2="18"></line>
</svg>
<h3>No events yet</h3>
<p>Events will appear here as they happen</p>
</div>
<div id="sidebar-timeline-spacer-top" class="timeline-spacer timeline-spacer-top" style="height: 0px;"></div>
<div id="sidebar-timeline-spacer-bottom" class="timeline-spacer timeline-spacer-bottom" style="height: 0px;"></div>
</div>
</div>
<button id="sidebar-timeline-show-btn" class="sidebar-show-btn hidden" title="Show timeline">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
`;
// Load the module
jest.isolateModules(function() {
require('./sidebar-timeline.js');
SidebarTimeline = window.SpaxelSidebarTimeline;
});
// Cache mock elements
mockElements = {
panel: document.getElementById('sidebar-timeline-panel'),
content: document.getElementById('sidebar-timeline-content'),
eventsContainer: document.getElementById('sidebar-timeline-events'),
loading: document.getElementById('sidebar-timeline-loading'),
empty: document.getElementById('sidebar-timeline-empty'),
spacerTop: document.getElementById('sidebar-timeline-spacer-top'),
spacerBottom: document.getElementById('sidebar-timeline-spacer-bottom'),
toggleBtn: document.getElementById('sidebar-timeline-toggle'),
closeBtn: document.getElementById('sidebar-timeline-close'),
showBtn: document.getElementById('sidebar-timeline-show-btn')
};
});
afterEach(function() {
document.body.innerHTML = '';
delete window.SpaxelSidebarTimeline;
});
// ============================================
// Panel Visibility Tests
// ============================================
describe('Panel Visibility', function() {
test('show() displays the panel', function() {
SidebarTimeline.show();
expect(mockElements.panel.classList.contains('collapsed')).toBe(false);
expect(mockElements.showBtn.classList.contains('hidden')).toBe(true);
});
test('hide() collapses the panel', function() {
SidebarTimeline.show();
SidebarTimeline.hide();
expect(mockElements.panel.classList.contains('collapsed')).toBe(true);
expect(mockElements.showBtn.classList.contains('hidden')).toBe(false);
});
test('toggle() switches panel state', function() {
expect(mockElements.panel.classList.contains('collapsed')).toBe(true);
SidebarTimeline.toggle();
expect(mockElements.panel.classList.contains('collapsed')).toBe(false);
SidebarTimeline.toggle();
expect(mockElements.panel.classList.contains('collapsed')).toBe(true);
});
});
// ============================================
// Event Type Rendering Tests
// ============================================
describe('Event Type Rendering', function() {
beforeEach(function() {
// Reset events state
if (window.SpaxelSidebarTimeline && window.SpaxelSidebarTimeline.state) {
window.SpaxelSidebarTimeline.state.events = [];
}
// Mock successful API response
global.fetch.mockImplementation(function() {
return Promise.resolve({
ok: true,
json: function() {
return Promise.resolve({
events: [
{
id: 1,
timestamp_ms: Date.now() - 3600000,
type: 'zone_entry',
zone: 'Kitchen',
person: 'Alice',
severity: 'info'
},
{
id: 2,
timestamp_ms: Date.now() - 7200000,
type: 'zone_exit',
zone: 'Living Room',
person: 'Bob',
severity: 'info'
},
{
id: 3,
timestamp_ms: Date.now() - 10800000,
type: 'portal_crossing',
zone: 'Hallway',
person: 'Alice',
detail_json: JSON.stringify({
from_zone: 'Kitchen',
to_zone: 'Living Room'
}),
severity: 'info'
},
{
id: 4,
timestamp_ms: Date.now() - 14400000,
type: 'presence_transition',
zone: 'Bedroom',
person: 'Alice',
severity: 'info'
},
{
id: 5,
timestamp_ms: Date.now() - 18000000,
type: 'stationary_detected',
zone: 'Living Room',
person: 'Bob',
severity: 'info'
},
{
id: 6,
timestamp_ms: Date.now() - 21600000,
type: 'detection',
zone: 'Kitchen',
person: null,
severity: 'info'
},
{
id: 7,
timestamp_ms: Date.now() - 25200000,
type: 'anomaly',
zone: 'Kitchen',
person: null,
severity: 'warning'
},
{
id: 8,
timestamp_ms: Date.now() - 28800000,
type: 'security_alert',
zone: 'Hallway',
person: null,
detail_json: JSON.stringify({
description: 'Motion detected while armed'
}),
severity: 'alert'
},
{
id: 9,
timestamp_ms: Date.now() - 32400000,
type: 'fall_alert',
zone: 'Bathroom',
person: 'Alice',
severity: 'critical'
},
{
id: 10,
timestamp_ms: Date.now() - 36000000,
type: 'node_online',
zone: null,
person: null,
detail_json: JSON.stringify({
node: 'kitchen-north'
}),
severity: 'info'
},
{
id: 11,
timestamp_ms: Date.now() - 39600000,
type: 'node_offline',
zone: null,
person: null,
detail_json: JSON.stringify({
node: 'living-room-west'
}),
severity: 'warning'
},
{
id: 12,
timestamp_ms: Date.now() - 43200000,
type: 'ota_update',
zone: null,
person: null,
detail_json: JSON.stringify({
node: 'kitchen-north',
version: '1.2.3'
}),
severity: 'info'
},
{
id: 13,
timestamp_ms: Date.now() - 46800000,
type: 'baseline_changed',
zone: null,
person: null,
detail_json: JSON.stringify({
link: 'AA:BB:CC:DD:EE:FF:11:22:33:44:55:66'
}),
severity: 'info'
},
{
id: 14,
timestamp_ms: Date.now() - 50400000,
type: 'learning_milestone',
zone: null,
person: null,
detail_json: JSON.stringify({
description: 'Anomaly patterns learned for Kitchen'
}),
severity: 'info'
},
{
id: 15,
timestamp_ms: Date.now() - 54000000,
type: 'sleep_session_end',
zone: 'Bedroom',
person: 'Alice',
detail_json: JSON.stringify({
duration: '7h 23m'
}),
severity: 'info'
}
],
cursor: null,
total_filtered: 15
});
}
});
});
});
test('all event types render correctly', function() {
SidebarTimeline.show();
SidebarTimeline.refresh();
// Wait for fetch to complete and rendering to happen
return new Promise(function(resolve) {
setTimeout(function() {
// Check that all events were rendered
const eventEls = mockElements.eventsContainer.querySelectorAll('.sidebar-timeline-event');
expect(eventEls.length).toBe(15);
// Verify zone_entry
const zoneEntry = mockElements.eventsContainer.querySelector('[data-id="1"]');
expect(zoneEntry).toBeTruthy();
expect(zoneEntry.dataset.type).toBe('zone_entry');
expect(zoneEntry.querySelector('.sidebar-timeline-event-icon').textContent).toBe('🚪');
expect(zoneEntry.querySelector('.sidebar-timeline-event-title').textContent).toContain('Alice');
expect(zoneEntry.querySelector('.sidebar-timeline-event-title').textContent).toContain('Kitchen');
expect(zoneEntry.querySelector('.sidebar-timeline-event-title').textContent).toContain('entered');
// Verify zone_exit
const zoneExit = mockElements.eventsContainer.querySelector('[data-id="2"]');
expect(zoneExit).toBeTruthy();
expect(zoneExit.dataset.type).toBe('zone_exit');
expect(zoneExit.querySelector('.sidebar-timeline-event-icon').textContent).toBe('🚶');
// Verify portal_crossing
const portal = mockElements.eventsContainer.querySelector('[data-id="3"]');
expect(portal).toBeTruthy();
expect(portal.dataset.type).toBe('portal_crossing');
expect(portal.querySelector('.sidebar-timeline-event-icon').textContent).toBe('→');
// Verify presence_transition
const presence = mockElements.eventsContainer.querySelector('[data-id="4"]');
expect(presence).toBeTruthy();
expect(presence.dataset.type).toBe('presence_transition');
expect(presence.querySelector('.sidebar-timeline-event-icon').textContent).toBe('👤');
// Verify stationary_detected
const stationary = mockElements.eventsContainer.querySelector('[data-id="5"]');
expect(stationary).toBeTruthy();
expect(stationary.dataset.type).toBe('stationary_detected');
expect(stationary.querySelector('.sidebar-timeline-event-icon').textContent).toBe('💤');
// Verify detection
const detection = mockElements.eventsContainer.querySelector('[data-id="6"]');
expect(detection).toBeTruthy();
expect(detection.dataset.type).toBe('detection');
expect(detection.querySelector('.sidebar-timeline-event-icon').textContent).toBe('👁️');
// Verify anomaly
const anomaly = mockElements.eventsContainer.querySelector('[data-id="7"]');
expect(anomaly).toBeTruthy();
expect(anomaly.dataset.type).toBe('anomaly');
expect(anomaly.querySelector('.sidebar-timeline-event-icon').textContent).toBe('⚠️');
expect(anomaly.classList.contains('severity-warning')).toBe(true);
// Verify security_alert
const security = mockElements.eventsContainer.querySelector('[data-id="8"]');
expect(security).toBeTruthy();
expect(security.dataset.type).toBe('security_alert');
expect(security.querySelector('.sidebar-timeline-event-icon').textContent).toBe('🚨');
expect(security.classList.contains('severity-critical')).toBe(true);
// Verify fall_alert
const fall = mockElements.eventsContainer.querySelector('[data-id="9"]');
expect(fall).toBeTruthy();
expect(fall.dataset.type).toBe('fall_alert');
expect(fall.querySelector('.sidebar-timeline-event-icon').textContent).toBe('🆘');
expect(fall.classList.contains('severity-critical')).toBe(true);
// Verify node_online
const nodeOnline = mockElements.eventsContainer.querySelector('[data-id="10"]');
expect(nodeOnline).toBeTruthy();
expect(nodeOnline.dataset.type).toBe('node_online');
expect(nodeOnline.querySelector('.sidebar-timeline-event-icon').textContent).toBe('📡');
expect(nodeOnline.classList.contains('secondary')).toBe(true);
// Verify node_offline
const nodeOffline = mockElements.eventsContainer.querySelector('[data-id="11"]');
expect(nodeOffline).toBeTruthy();
expect(nodeOffline.dataset.type).toBe('node_offline');
expect(nodeOffline.querySelector('.sidebar-timeline-event-icon').textContent).toBe('📵');
// Verify ota_update
const ota = mockElements.eventsContainer.querySelector('[data-id="12"]');
expect(ota).toBeTruthy();
expect(ota.dataset.type).toBe('ota_update');
expect(ota.querySelector('.sidebar-timeline-event-icon').textContent).toBe('⬆️');
// Verify baseline_changed
const baseline = mockElements.eventsContainer.querySelector('[data-id="13"]');
expect(baseline).toBeTruthy();
expect(baseline.dataset.type).toBe('baseline_changed');
expect(baseline.querySelector('.sidebar-timeline-event-icon').textContent).toBe('📊');
// Verify learning_milestone
const learning = mockElements.eventsContainer.querySelector('[data-id="14"]');
expect(learning).toBeTruthy();
expect(learning.dataset.type).toBe('learning_milestone');
expect(learning.querySelector('.sidebar-timeline-event-icon').textContent).toBe('🎓');
// Verify sleep_session_end
const sleep = mockElements.eventsContainer.querySelector('[data-id="15"]');
expect(sleep).toBeTruthy();
expect(sleep.dataset.type).toBe('sleep_session_end');
expect(sleep.querySelector('.sidebar-timeline-event-icon').textContent).toBe('😴');
expect(sleep.querySelector('.sidebar-timeline-event-title').textContent).toContain('7h 23m');
resolve();
}, 150);
});
});
});
// ============================================
// Plain English Description Tests
// ============================================
describe('Plain English Descriptions', function() {
test('descriptions use plain English without technical jargon', function() {
global.fetch.mockImplementation(function() {
return Promise.resolve({
ok: true,
json: function() {
return Promise.resolve({
events: [
{
id: 1,
timestamp_ms: Date.now(),
type: 'zone_entry',
zone: 'Kitchen',
person: 'Alice',
severity: 'info'
}
],
cursor: null,
total_filtered: 1
});
}
});
});
SidebarTimeline.show();
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
const eventEl = mockElements.eventsContainer.querySelector('[data-id="1"]');
const title = eventEl.querySelector('.sidebar-timeline-event-title').textContent;
// Should not contain technical jargon
expect(title).not.toMatch(/CSI|Fresnel|deltaRMS|blob_id|timestamp_ms/);
// Should use plain English
expect(title).toMatch(/Alice|Kitchen|entered/);
resolve();
}, 150);
});
});
test('unknown person defaults to "Someone"', function() {
global.fetch.mockImplementation(function() {
return Promise.resolve({
ok: true,
json: function() {
return Promise.resolve({
events: [
{
id: 1,
timestamp_ms: Date.now(),
type: 'detection',
zone: 'Hallway',
person: null,
severity: 'info'
}
],
cursor: null,
total_filtered: 1
});
}
});
});
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
const eventEl = mockElements.eventsContainer.querySelector('[data-id="1"]');
const title = eventEl.querySelector('.sidebar-timeline-event-title').textContent;
expect(title).toContain('Motion');
expect(title).toContain('Hallway');
resolve();
}, 150);
});
});
});
// ============================================
// Feedback Button Tests
// ============================================
describe('Feedback Buttons', function() {
beforeEach(function() {
global.fetch.mockImplementation(function() {
return Promise.resolve({
ok: true,
json: function() {
return Promise.resolve({
events: [
{
id: 123,
timestamp_ms: Date.now(),
type: 'detection',
zone: 'Kitchen',
person: 'Alice',
blob_id: 42,
severity: 'info'
}
],
cursor: null,
total_filtered: 1
});
}
});
});
});
test('each event has thumbs-up and thumbs-down buttons', function() {
SidebarTimeline.show();
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
const eventEl = mockElements.eventsContainer.querySelector('[data-id="123"]');
expect(eventEl).toBeTruthy();
const thumbsUp = eventEl.querySelector('.feedback-positive');
const thumbsDown = eventEl.querySelector('.feedback-negative');
expect(thumbsUp).toBeTruthy();
expect(thumbsDown).toBeTruthy();
expect(thumbsUp.getAttribute('aria-label')).toBe('Thumbs up');
expect(thumbsDown.getAttribute('aria-label')).toBe('Thumbs down');
resolve();
}, 150);
});
});
test('thumbs-up button delegates to feedback module', function() {
SidebarTimeline.show();
SidebarTimeline.refresh();
// Mock feedback API response
global.fetch.mockImplementationOnce(function() {
return Promise.resolve({
ok: true,
json: function() {
return Promise.resolve({ ok: true });
}
});
});
return new Promise(function(resolve) {
setTimeout(function() {
const eventEl = mockElements.eventsContainer.querySelector('[data-id="123"]');
const thumbsUp = eventEl.querySelector('.feedback-positive');
thumbsUp.click();
setTimeout(function() {
expect(global.Feedback.sendFeedback).toHaveBeenCalledWith(
'123',
'detection',
'TRUE_POSITIVE',
expect.anything()
);
expect(global.SpaxelApp.showToast).toHaveBeenCalledWith(
'Thanks for the feedback!',
'success'
);
resolve();
}, 50);
}, 150);
});
});
test('thumbs-down button delegates to feedback module', function() {
SidebarTimeline.show();
SidebarTimeline.refresh();
// Mock feedback API response
global.fetch.mockImplementationOnce(function() {
return Promise.resolve({
ok: true,
json: function() {
return Promise.resolve({ ok: true });
}
});
});
return new Promise(function(resolve) {
setTimeout(function() {
const eventEl = mockElements.eventsContainer.querySelector('[data-id="123"]');
const thumbsDown = eventEl.querySelector('.feedback-negative');
thumbsDown.click();
setTimeout(function() {
expect(global.Feedback.sendFeedback).toHaveBeenCalledWith(
'123',
'detection',
'FALSE_POSITIVE',
expect.anything()
);
expect(global.SpaxelApp.showToast).toHaveBeenCalledWith(
'Thanks — I\'ll adjust my detection.',
'success'
);
resolve();
}, 50);
}, 150);
});
});
test('feedback button click stops propagation', function() {
SidebarTimeline.show();
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
const eventEl = mockElements.eventsContainer.querySelector('[data-id="123"]');
const thumbsUp = eventEl.querySelector('.feedback-positive');
let eventClickFired = false;
eventEl.addEventListener('click', function() {
eventClickFired = true;
});
thumbsUp.click();
setTimeout(function() {
expect(eventClickFired).toBe(false);
resolve();
}, 50);
}, 150);
});
});
});
// ============================================
// Virtualized Rendering Tests
// ============================================
describe('Virtualized Rendering with IntersectionObserver', function() {
test('uses IntersectionObserver for virtualization when enabled', function() {
SidebarTimeline.show();
// Check that IntersectionObserver was called during init
// This is verified by the module initialization logging
expect(global.IntersectionObserver).toHaveBeenCalled();
});
test('handles large event lists without performance degradation', function() {
// Create a large array of events
const largeEventList = [];
for (let i = 0; i < 100; i++) {
largeEventList.push({
id: i,
timestamp_ms: Date.now() - (i * 60000), // 1 minute apart
type: i % 2 === 0 ? 'detection' : 'zone_entry',
zone: ['Kitchen', 'Living Room', 'Bedroom'][i % 3],
person: ['Alice', 'Bob', 'Charlie'][i % 3],
severity: 'info'
});
}
global.fetch.mockImplementation(function() {
return Promise.resolve({
ok: true,
json: function() {
return Promise.resolve({
events: largeEventList,
cursor: null,
total_filtered: 100
});
}
});
});
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
// Check that events were rendered
const renderedEvents = mockElements.eventsContainer.querySelectorAll('.sidebar-timeline-event');
expect(renderedEvents.length).toBeGreaterThan(0);
expect(renderedEvents.length).toBeLessThanOrEqual(100);
resolve();
}, 200);
});
});
});
// ============================================
// Empty State Tests
// ============================================
describe('Empty State', function() {
test('shows empty state when no events', function() {
global.fetch.mockImplementation(function() {
return Promise.resolve({
ok: true,
json: function() {
return Promise.resolve({
events: [],
cursor: null,
total_filtered: 0
});
}
});
});
SidebarTimeline.show();
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
expect(mockElements.empty.style.display).toBe('flex');
expect(mockElements.eventsContainer.children.length).toBe(0);
resolve();
}, 150);
});
});
test('empty state shows correct message and icon', function() {
global.fetch.mockImplementation(function() {
return Promise.resolve({
ok: true,
json: function() {
return Promise.resolve({
events: [],
cursor: null,
total_filtered: 0
});
}
});
});
SidebarTimeline.show();
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
const emptyTitle = mockElements.empty.querySelector('h3');
const emptyText = mockElements.empty.querySelector('p');
const emptyIcon = mockElements.empty.querySelector('svg');
expect(emptyTitle.textContent).toBe('No events yet');
expect(emptyText.textContent).toBe('Events will appear here as they happen');
expect(emptyIcon).toBeTruthy();
resolve();
}, 150);
});
});
});
// ============================================
// Severity Styling Tests
// ============================================
describe('Severity Styling', function() {
beforeEach(function() {
global.fetch.mockImplementation(function() {
return Promise.resolve({
ok: true,
json: function() {
return Promise.resolve({
events: [
{
id: 1,
timestamp_ms: Date.now(),
type: 'detection',
zone: 'Kitchen',
person: 'Alice',
severity: 'info'
},
{
id: 2,
timestamp_ms: Date.now() - 3600000,
type: 'anomaly',
zone: 'Living Room',
person: null,
severity: 'warning'
},
{
id: 3,
timestamp_ms: Date.now() - 7200000,
type: 'fall_alert',
zone: 'Bathroom',
person: 'Alice',
severity: 'critical'
}
],
cursor: null,
total_filtered: 3
});
}
});
});
});
test('info events have no severity class', function() {
SidebarTimeline.show();
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
const eventEl = mockElements.eventsContainer.querySelector('[data-id="1"]');
expect(eventEl).toBeTruthy();
expect(eventEl.classList.contains('severity-critical')).toBe(false);
expect(eventEl.classList.contains('severity-warning')).toBe(false);
resolve();
}, 150);
});
});
test('warning events have severity-warning class', function() {
SidebarTimeline.show();
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
const eventEl = mockElements.eventsContainer.querySelector('[data-id="2"]');
expect(eventEl).toBeTruthy();
expect(eventEl.classList.contains('severity-warning')).toBe(true);
resolve();
}, 150);
});
});
test('critical events have severity-critical class', function() {
SidebarTimeline.show();
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
const eventEl = mockElements.eventsContainer.querySelector('[data-id="3"]');
expect(eventEl).toBeTruthy();
expect(eventEl.classList.contains('severity-critical')).toBe(true);
resolve();
}, 150);
});
});
});
// ============================================
// System Event Secondary Styling Tests
// ============================================
describe('System Event Secondary Styling', function() {
beforeEach(function() {
global.fetch.mockImplementation(function() {
return Promise.resolve({
ok: true,
json: function() {
return Promise.resolve({
events: [
{
id: 1,
timestamp_ms: Date.now(),
type: 'detection',
zone: 'Kitchen',
person: 'Alice',
severity: 'info'
},
{
id: 2,
timestamp_ms: Date.now() - 3600000,
type: 'node_online',
zone: null,
person: null,
detail_json: JSON.stringify({ node: 'kitchen-north' }),
severity: 'info'
}
],
cursor: null,
total_filtered: 2
});
}
});
});
});
test('user events have no secondary class', function() {
SidebarTimeline.show();
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
const eventEl = mockElements.eventsContainer.querySelector('[data-id="1"]');
expect(eventEl).toBeTruthy();
expect(eventEl.classList.contains('secondary')).toBe(false);
resolve();
}, 150);
});
});
test('system events have secondary class', function() {
SidebarTimeline.show();
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
const eventEl = mockElements.eventsContainer.querySelector('[data-id="2"]');
expect(eventEl).toBeTruthy();
expect(eventEl.classList.contains('secondary')).toBe(true);
resolve();
}, 150);
});
});
});
// ============================================
// Tap-to-Jump Time-Travel Tests
// ============================================
describe('Tap-to-Jump Time-Travel', function() {
var mockJumpToTime;
beforeEach(function() {
mockJumpToTime = jest.fn(function() {
return Promise.resolve({
session_id: 'test-session-1',
timestamp_ms: 1710519800000,
from_ms: 1710519795000,
to_ms: 1710519805000,
state: 'paused'
});
});
window.SpaxelReplay = {
jumpToTime: mockJumpToTime,
isReplayMode: jest.fn(function() { return false; })
};
global.fetch.mockImplementation(function() {
return Promise.resolve({
ok: true,
json: function() {
return Promise.resolve({
events: [
{
id: 100,
timestamp_ms: 1710519800000,
type: 'zone_entry',
zone: 'Kitchen',
person: 'Alice',
severity: 'info'
},
{
id: 200,
timestamp_ms: 1710519860000,
type: 'zone_exit',
zone: 'Kitchen',
person: 'Alice',
severity: 'info'
}
],
cursor: null,
total_filtered: 2
});
}
});
});
});
afterEach(function() {
delete window.SpaxelReplay;
});
test('clicking event calls jumpToTime with event timestamp', function() {
SidebarTimeline.show();
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
var eventEl = mockElements.eventsContainer.querySelector('[data-id="100"]');
expect(eventEl).toBeTruthy();
eventEl.click();
expect(mockJumpToTime).toHaveBeenCalledTimes(1);
expect(mockJumpToTime).toHaveBeenCalledWith(1710519800000);
resolve();
}, 150);
});
});
test('clicking different events emits correct timestamps', function() {
SidebarTimeline.show();
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
var first = mockElements.eventsContainer.querySelector('[data-id="100"]');
var second = mockElements.eventsContainer.querySelector('[data-id="200"]');
expect(first).toBeTruthy();
expect(second).toBeTruthy();
first.click();
expect(mockJumpToTime).toHaveBeenCalledWith(1710519800000);
second.click();
expect(mockJumpToTime).toHaveBeenCalledWith(1710519860000);
expect(mockJumpToTime).toHaveBeenCalledTimes(2);
resolve();
}, 150);
});
});
test('selected event highlights with selected class', function() {
SidebarTimeline.show();
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
var eventEl = mockElements.eventsContainer.querySelector('[data-id="100"]');
expect(eventEl).toBeTruthy();
expect(eventEl.classList.contains('selected')).toBe(false);
eventEl.click();
// Wait for async handleSeek
setTimeout(function() {
expect(eventEl.classList.contains('selected')).toBe(true);
resolve();
}, 50);
}, 150);
});
});
test('clicking new event clears previous selection', function() {
SidebarTimeline.show();
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
var first = mockElements.eventsContainer.querySelector('[data-id="100"]');
var second = mockElements.eventsContainer.querySelector('[data-id="200"]');
first.click();
setTimeout(function() {
expect(first.classList.contains('selected')).toBe(true);
expect(second.classList.contains('selected')).toBe(false);
second.click();
setTimeout(function() {
expect(first.classList.contains('selected')).toBe(false);
expect(second.classList.contains('selected')).toBe(true);
resolve();
}, 50);
}, 50);
}, 150);
});
});
test('Now replaying chip appears after jump in expert mode', function() {
SidebarTimeline.show();
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
var eventEl = mockElements.eventsContainer.querySelector('[data-id="100"]');
eventEl.click();
// Wait for jumpToTime promise to resolve
setTimeout(function() {
var chip = document.getElementById('now-replaying-chip');
expect(chip).toBeTruthy();
expect(chip.style.display).not.toBe('none');
expect(chip.textContent).toContain('Now replaying');
resolve();
}, 100);
}, 150);
});
});
test('hideNowReplayingChip hides the chip', function() {
SidebarTimeline.show();
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
var eventEl = mockElements.eventsContainer.querySelector('[data-id="100"]');
eventEl.click();
setTimeout(function() {
var chip = document.getElementById('now-replaying-chip');
expect(chip).toBeTruthy();
SidebarTimeline.hideNowReplayingChip();
expect(chip.style.display).toBe('none');
resolve();
}, 100);
}, 150);
});
});
test('clearSelection removes selected class from event', function() {
SidebarTimeline.show();
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
var eventEl = mockElements.eventsContainer.querySelector('[data-id="100"]');
eventEl.click();
setTimeout(function() {
expect(eventEl.classList.contains('selected')).toBe(true);
SidebarTimeline.clearSelection();
expect(eventEl.classList.contains('selected')).toBe(false);
resolve();
}, 50);
}, 150);
});
});
test('simple mode navigates to timeline view instead of replay', function() {
// Override to simple mode by triggering mode change
SidebarTimeline.show();
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
// Simulate simple mode by changing internal state
// The module listens to SpaxelSimpleModeDetection.onModeChange callbacks
// We need to trigger it through the registered callback
var simpleCallback = null;
if (global.SpaxelSimpleModeDetection && global.SpaxelSimpleModeDetection.onModeChange) {
var calls = global.SpaxelSimpleModeDetection.onModeChange.mock.calls;
if (calls.length > 0) {
simpleCallback = calls[0][0];
}
}
if (simpleCallback) {
simpleCallback('simple');
}
var eventEl = mockElements.eventsContainer.querySelector('[data-id="100"]');
if (eventEl) {
eventEl.click();
// In simple mode, should navigate via router, not call jumpToTime
var jumpCallsAfterSimple = mockJumpToTime.mock.calls.length;
// The jumpToTime may or may not be called depending on mode,
// but SpaxelRouter.navigate should be called with 'timeline'
expect(global.SpaxelRouter.navigate).toHaveBeenCalledWith('timeline');
}
resolve();
}, 150);
});
});
test('jumpToTime failure shows error toast', function() {
mockJumpToTime.mockImplementation(function() {
return Promise.reject(new Error('Network error'));
});
SidebarTimeline.show();
SidebarTimeline.refresh();
return new Promise(function(resolve) {
setTimeout(function() {
var eventEl = mockElements.eventsContainer.querySelector('[data-id="100"]');
eventEl.click();
setTimeout(function() {
expect(global.SpaxelApp.showToast).toHaveBeenCalledWith('Failed to jump to time', 'error');
resolve();
}, 100);
}, 150);
});
});
});
});