/** * Tests for Ambient Dashboard Mode * * Tests for Canvas 2D renderer, auto-dim, alert mode, morning briefing, and lerp interpolation. */ // Load the ambient modules require('../js/ambient_renderer.js'); require('../js/ambient_briefing.js'); require('../js/ambient.js'); // ============================================ // Test Helpers // ============================================ function createTestCanvas() { const canvas = document.createElement('canvas'); canvas.width = 800; canvas.height = 600; canvas.style.width = '800px'; canvas.style.height = '600px'; document.body.appendChild(canvas); return canvas; } function cleanupTestCanvas(canvas) { if (canvas && canvas.parentNode) { canvas.parentNode.removeChild(canvas); } } function waitForAnimationFrame() { return new Promise(resolve => { requestAnimationFrame(() => { requestAnimationFrame(resolve); }); }); } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // ============================================ // Canvas 2D Renderer Tests // ============================================ describe('AmbientRenderer - Canvas 2D', function() { let canvas; let renderer; beforeEach(function() { canvas = createTestCanvas(); // Reset the renderer module state if (window.SpaxelAmbientRenderer) { // Store original state window._originalAmbientRendererState = { currentPositions: new Map(window.SpaxelAmbientRenderer._currentPositions || []), targetPositions: new Map(window.SpaxelAmbientRenderer._targetPositions || []) }; } }); afterEach(function() { if (renderer) { renderer.destroy(); } cleanupTestCanvas(canvas); // Restore original state if (window._originalAmbientRendererState) { if (window.SpaxelAmbientRenderer) { window.SpaxelAmbientRenderer._currentPositions = window._originalAmbientRendererState.currentPositions; window.SpaxelAmbientRenderer._targetPositions = window._originalAmbientRendererState.targetPositions; } delete window._originalAmbientRendererState; } }); // Skip test if SpaxelAmbientRenderer not available const testIfRendererAvailable = (testName, testFn) => { const conditionalTest = testFn.bind(this); if (!window.SpaxelAmbientRenderer) { test.skip(testName, () => {}); } else { test(testName, conditionalTest); } }; testIfRendererAvailable('should draw zone rectangle at correct pixel coordinates', function() { renderer = window.SpaxelAmbientRenderer; renderer.init(canvas, { scale: 50, // 50 pixels per meter margin: 40 }); // Update state with a zone at (1,1)-(3,3) meters renderer.updateState({ zones: [{ id: 1, name: 'Test Zone', x: 1, y: 1, w: 2, // 3 - 1 = 2 meters wide d: 2, // 3 - 1 = 2 meters deep count: 0 }], blobs: [], portals: [], nodes: [] }); // Trigger render renderer.render(); // Verify the zone was drawn const ctx = canvas.getContext('2d'); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); // Check that some white pixels were drawn (zone outline) let hasWhitePixel = false; for (let i = 0; i < imageData.data.length; i += 4) { const r = imageData.data[i]; const g = imageData.data[i + 1]; const b = imageData.data[i + 2]; // Check for white (zone outline color) if (r > 250 && g > 250 && b > 250) { hasWhitePixel = true; break; } } expect(hasWhitePixel).toBe(true); }); testIfRendererAvailable('should draw person blob at correct position', function() { renderer = window.SpaxelAmbientRenderer; renderer.init(canvas, { scale: 50, margin: 40 }); // Update state with a person at (2, 2) meters renderer.updateState({ zones: [], blobs: [{ id: 1, x: 2, y: 2, z: 0, confidence: 0.8, person: 'Alice' }], portals: [], nodes: [] }); // Trigger render renderer.render(); // The blob should be drawn at approximately (2 * 50 + margin) pixels // x = 40 + (2 - 0) * 50 = 140px // y = 40 + (2 - 0) * 50 = 140px const ctx = canvas.getContext('2d'); // Sample pixels around expected position const centerX = 140; const centerY = 140; const imageData = ctx.getImageData(centerX - 20, centerY - 20, 40, 40); // Check that some colored pixels were drawn (person blob) let hasColoredPixel = false; for (let i = 0; i < imageData.data.length; i += 4) { const r = imageData.data[i]; const g = imageData.data[i + 1]; const b = imageData.data[i + 2]; const a = imageData.data[i + 3]; // Check for non-transparent, non-background pixel if (a > 0 && (r !== 255 || g !== 255 || b !== 255)) { hasColoredPixel = true; break; } } expect(hasColoredPixel).toBe(true); }); testIfRendererAvailable('should draw node position as small grey circle', function() { renderer = window.SpaxelAmbientRenderer; renderer.init(canvas, { scale: 50, margin: 40 }); // Update state with a node at (1, 1) meters renderer.updateState({ zones: [], blobs: [], portals: [], nodes: [{ mac: 'AA:BB:CC:DD:EE:FF', pos_x: 1, pos_y: 1, pos_z: 2 }] }); // Trigger render renderer.render(); // Node should be drawn as a small grey circle // Position: x = 40 + (1 - 0) * 50 = 90px, y = 40 + (1 - 0) * 50 = 90px const ctx = canvas.getContext('2d'); const imageData = ctx.getImageData(85, 85, 10, 10); // Check for grey pixels (#6b7280 = rgb(107, 114, 128)) let hasGreyPixel = false; for (let i = 0; i < imageData.data.length; i += 4) { const r = imageData.data[i]; const g = imageData.data[i + 1]; const b = imageData.data[i + 2]; const a = imageData.data[i + 3]; // Check for grey with some tolerance if (a > 200 && r > 90 && r < 130 && g > 100 && g < 140 && b > 115 && b < 150) { hasGreyPixel = true; break; } } expect(hasGreyPixel).toBe(true); }); testIfRendererAvailable('should render at 2 Hz (one frame every 500ms)', async function() { renderer = window.SpaxelAmbientRenderer; renderer.init(canvas, { scale: 50, margin: 40 }); // Reset render counter renderer._resetRenderCallCount && renderer._resetRenderCallCount(); // Wait for multiple render cycles await sleep(1200); // Wait ~2.4 render cycles // Get render count from internal counter const renderCount = renderer._getRenderCallCount ? renderer._getRenderCallCount() : 0; // At 2 Hz (500ms per frame), in 1200ms we should have 2-3 renders // Allow some tolerance for timing variations expect(renderCount).toBeGreaterThanOrEqual(1); expect(renderCount).toBeLessThanOrEqual(4); }); }); // ============================================ // Auto-Dim Tests // ============================================ describe('AmbientRenderer - Auto-Dim', function() { let canvas; let renderer; beforeEach(function() { canvas = createTestCanvas(); }); afterEach(function() { if (renderer) { renderer.destroy(); } cleanupTestCanvas(canvas); // Clear localStorage localStorage.removeItem('ambient_briefing_last_shown'); }); const testIfRendererAvailable = (testName, testFn) => { if (!window.SpaxelAmbientRenderer) { test.skip(testName, () => {}); } else { test(testName, testFn); } }; testIfRendererAvailable('should reduce canvas brightness after 60s with no presence', function(done) { renderer = window.SpaxelAmbientRenderer; renderer.init(canvas, { scale: 50, margin: 40, ambientZone: 'test-zone' }); // Mock time to speed up test (use shorter timeout for testing) // We'll manually trigger the dim by calling the internal function const originalTimeout = 60000; // Update state with no blobs in ambient zone renderer.updateState({ zones: [{ id: 'test-zone', name: 'Test Zone', x: 0, y: 0, w: 5, d: 5, count: 0 // No presence }], blobs: [], portals: [], nodes: [] }); // Trigger dim mode manually (in real usage, this happens after timeout) renderer._enterDimMode && renderer._enterDimMode(); // Check canvas filter expect(canvas.style.filter).toContain('brightness(0.4)'); done(); }); testIfRendererAvailable('should restore brightness when presence detected in ambient zone', function(done) { renderer = window.SpaxelAmbientRenderer; renderer.init(canvas, { scale: 50, margin: 40, ambientZone: 'test-zone' }); // First enter dim mode renderer._enterDimMode && renderer._enterDimMode(); expect(canvas.style.filter).toContain('brightness(0.4)'); // Now simulate presence detection renderer.updateState({ zones: [{ id: 'test-zone', name: 'Test Zone', x: 0, y: 0, w: 5, d: 5, count: 1 // Presence detected }], blobs: [{ id: 1, x: 2, y: 2, z: 0 }], portals: [], nodes: [] }); // Trigger presence check renderer._checkAmbientZonePresence && renderer._checkAmbientZonePresence(); // Brightness should be restored expect(canvas.style.filter).not.toContain('brightness(0.4)'); expect(canvas.style.filter).toContain('brightness(1)'); done(); }); }); // ============================================ // Alert Mode Tests // ============================================ describe('AmbientRenderer - Alert Mode', function() { let canvas; let renderer; beforeEach(function() { canvas = createTestCanvas(); }); afterEach(function() { if (renderer) { renderer.destroy(); } cleanupTestCanvas(canvas); }); const testIfRendererAvailable = (testName, testFn) => { if (!window.SpaxelAmbientRenderer) { test.skip(testName, () => {}); } else { test(testName, testFn); } }; testIfRendererAvailable('should enter alert mode on fall detected event', function() { renderer = window.SpaxelAmbientRenderer; renderer.init(canvas, { scale: 50, margin: 40 }); // Simulate fall alert renderer.enterAlertMode({ type: 'fall_alert', id: 'alert-1', person: 'Alice', zone: 'Kitchen' }); renderer.render(); // Check that red background was drawn const ctx = canvas.getContext('2d'); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); // Check for red pixels (#dc2626 = rgb(220, 38, 38)) let hasRedPixel = false; for (let i = 0; i < imageData.data.length; i += 4) { const r = imageData.data[i]; const g = imageData.data[i + 1]; const b = imageData.data[i + 2]; if (r > 180 && g < 100 && b < 100) { hasRedPixel = true; break; } } expect(hasRedPixel).toBe(true); }); testIfRendererAvailable('should show large alert text', function() { renderer = window.SpaxelAmbientRenderer; renderer.init(canvas, { scale: 50, margin: 40 }); renderer.enterAlertMode({ type: 'fall_alert', id: 'alert-1', person: 'Alice', zone: 'Kitchen' }); renderer.render(); // Check that text was rendered (verify by checking canvas state) // The renderer should have the alert in its state const alerts = renderer._getCurrentState ? renderer._getCurrentState().alerts : []; expect(alerts.length).toBeGreaterThan(0); expect(alerts[0].type).toBe('fall_alert'); }); testIfRendererAvailable('should pulse alert background at 1 Hz', async function() { renderer = window.SpaxelAmbientRenderer; renderer.init(canvas, { scale: 50, margin: 40 }); renderer.enterAlertMode({ type: 'fall_alert', id: 'alert-1', person: 'Alice' }); // Track pulse state changes let pulseCount = 0; let previousState = renderer._getAlertPulseState && renderer._getAlertPulseState(); const checkInterval = setInterval(() => { const pulseState = renderer._getAlertPulseState && renderer._getAlertPulseState(); if (pulseState !== previousState) { pulseCount++; previousState = pulseState; } }, 100); // Wait for at least 2 state changes await sleep(2500); clearInterval(checkInterval); // Should have seen state change within 2.5 seconds (1 Hz = 1 change per second) expect(pulseCount).toBeGreaterThan(0); }); testIfRendererAvailable('should exit alert mode on acknowledge', function() { renderer = window.SpaxelAmbientRenderer; renderer.init(canvas, { scale: 50, margin: 40 }); // Enter alert mode renderer.enterAlertMode({ type: 'fall_alert', id: 'alert-1', person: 'Alice' }); const stateBefore = renderer._getCurrentState(); expect(stateBefore.alerts.length).toBeGreaterThan(0); // Exit alert mode renderer.exitAlertMode(); const stateAfter = renderer._getCurrentState(); expect(stateAfter.alerts.length).toBe(0); }); }); // ============================================ // Morning Briefing Tests // ============================================ describe('AmbientBriefing - Morning Briefing', function() { let briefingElement; beforeEach(function() { // Clear localStorage localStorage.removeItem('ambient_briefing_last_shown'); // Create briefing element with full structure (matching ensureBriefingElement) if (!document.getElementById('ambient-briefing')) { briefingElement = document.createElement('div'); briefingElement.id = 'ambient-briefing'; briefingElement.className = 'ambient-briefing hidden'; briefingElement.innerHTML = `