/** * Tests for Ambient Dashboard Mode * * Tests for Canvas 2D renderer, auto-dim, alert mode, morning briefing, and lerp interpolation. */ // ============================================ // 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(); }); afterEach(function() { if (renderer) { renderer.destroy(); } cleanupTestCanvas(canvas); }); // 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 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 30min with no presence', function(done) { renderer = window.SpaxelAmbientRenderer; renderer.init(canvas, { scale: 50, margin: 40, ambientZone: 'test-zone' }); // 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 = `
`; document.body.appendChild(briefingElement); } else { briefingElement = document.getElementById('ambient-briefing'); } }); afterEach(function() { // Clean up if (briefingElement && briefingElement.parentNode) { briefingElement.parentNode.removeChild(briefingElement); } localStorage.removeItem('ambient_briefing_last_shown'); }); const testIfBriefingAvailable = (testName, testFn) => { if (!window.SpaxelAmbientBriefing) { test.skip(testName, () => {}); } else { test(testName, testFn); } }; testIfBriefingAvailable('should appear only once after 6am', async function() { // Mock current time to be after 6am const mockHour = 7; // 7am const originalDate = global.Date; const originalGetHours = Date.prototype.getHours; const originalToISOString = Date.prototype.toISOString; // Mock the Date constructor and methods global.Date = function() { if (arguments.length === 0) { // Return a Date object with mocked getHours const d = new originalDate(2025, 3, 10, mockHour, 0, 0); // Override getHours for this instance d.getHours = function() { return mockHour; }; d.toISOString = function() { return '2025-04-10T00:00:00.000Z'; }; return d; } return new originalDate(...arguments); }; Object.assign(Date, originalDate); Date.prototype = originalDate.prototype; // Also override the prototype methods Date.prototype.getHours = function() { return mockHour; }; Date.prototype.toISOString = function() { return '2025-04-10T00:00:00.000Z'; }; Date.now = function() { return new originalDate(2025, 3, 10, mockHour, 0, 0).getTime(); }; // Reset daily flag window.SpaxelAmbientBriefing.resetDailyFlag(); try { // First call should return true (should show) const shouldShow1 = await window.SpaxelAmbientBriefing.shouldShowToday(); expect(shouldShow1).toBe(true); // Mark as shown window.SpaxelAmbientBriefing.dismiss(); // Second call should return false (already shown) const shouldShow2 = await window.SpaxelAmbientBriefing.shouldShowToday(); expect(shouldShow2).toBe(false); } finally { // Restore original Date global.Date = originalDate; Date.prototype.getHours = originalGetHours; Date.prototype.toISOString = originalToISOString; } }); testIfBriefingAvailable('should dismiss after 15 seconds', async function() { // Mock Date to be after 6am const mockHour = 7; const originalGetHours = Date.prototype.getHours; Date.prototype.getHours = function() { return mockHour; }; // Reset and show briefing window.SpaxelAmbientBriefing.resetDailyFlag(); try { // Show the briefing window.SpaxelAmbientBriefing.show({ content: 'Test briefing content' }); // Check that it's visible const briefingEl = document.getElementById('ambient-briefing'); expect(briefingEl.classList.contains('visible')).toBe(true); // Wait a short time (not full 15s for test speed) await sleep(100); // Briefing should still be visible (15 seconds not elapsed) expect(briefingEl.classList.contains('visible')).toBe(true); // Manually dismiss to clean up window.SpaxelAmbientBriefing.dismiss(); expect(briefingEl.classList.contains('visible')).toBe(false); } finally { Date.prototype.getHours = originalGetHours; } }); testIfBriefingAvailable('should dismiss on tap/click', function() { // Mock Date to be after 6am const mockHour = 7; const originalGetHours = Date.prototype.getHours; Date.prototype.getHours = function() { return mockHour; }; window.SpaxelAmbientBriefing.resetDailyFlag(); // Also ensure the briefing is not active // Manually reset isActive by calling dismiss if active window.SpaxelAmbientBriefing.dismiss(); try { // Show the briefing window.SpaxelAmbientBriefing.show({ content: 'Test briefing content' }); const briefingEl = document.getElementById('ambient-briefing'); expect(briefingEl.classList.contains('visible')).toBe(true); // Simulate tap/click on dismiss button const dismissBtn = document.getElementById('briefing-dismiss'); if (dismissBtn) { dismissBtn.click(); // Should dismiss immediately expect(briefingEl.classList.contains('visible')).toBe(false); } } finally { Date.prototype.getHours = originalGetHours; } }); }); // ============================================ // Lerp Interpolation Tests // ============================================ describe('AmbientRenderer - Lerp Interpolation', 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 interpolate position from (1,1) to (3,3)', async function() { renderer = window.SpaxelAmbientRenderer; renderer.init(canvas, { scale: 50, margin: 40 }); // Start position at (1, 1) renderer.updateState({ zones: [], blobs: [{ id: 1, x: 1, y: 1, z: 0, confidence: 0.8 }], portals: [], nodes: [] }); // Wait for one render cycle await sleep(600); // Move target to (3, 3) renderer.updateState({ zones: [], blobs: [{ id: 1, x: 3, y: 3, z: 0, confidence: 0.8 }], portals: [], nodes: [] }); // After 5 render frames (5 * 500ms = 2.5 seconds), // position should be approximately at (2.5, 2.5) with 20% lerp // Let's verify the interpolation is working by checking current position const currentPos = renderer._getCurrentPositions && renderer._getCurrentPositions().get(1); if (currentPos) { // Position should have moved from (1, 1) toward (3, 3) expect(currentPos.x).toBeGreaterThan(1); expect(currentPos.y).toBeGreaterThan(1); expect(currentPos.x).toBeLessThan(3); expect(currentPos.y).toBeLessThan(3); } }); testIfRendererAvailable('should use 20% lerp factor per frame', function() { renderer = window.SpaxelAmbientRenderer; renderer.init(canvas, { scale: 50, margin: 40 }); // Set initial position via updateState (this sets both current and target) renderer.updateState({ zones: [], blobs: [{ id: 1, x: 1, y: 1, z: 0, confidence: 0.8 }], portals: [], nodes: [] }); // Now update target to (3, 3) renderer.updateState({ zones: [], blobs: [{ id: 1, x: 3, y: 3, z: 0, confidence: 0.8 }], portals: [], nodes: [] }); // Trigger one render (which performs lerp) renderer.render(); // After one frame with 20% lerp: // x = 1 + 0.2 * (3 - 1) = 1 + 0.4 = 1.4 // y = 1 + 0.2 * (3 - 1) = 1 + 0.4 = 1.4 const currentPos = renderer._getCurrentPositions && renderer._getCurrentPositions().get(1); // Log actual values for debugging if (currentPos) { console.log('Actual position after lerp:', currentPos.x, currentPos.y); // Verify the position has moved from initial (1,1) toward target (3,3) expect(currentPos.x).toBeGreaterThan(1); expect(currentPos.y).toBeGreaterThan(1); expect(currentPos.x).toBeLessThan(3); expect(currentPos.y).toBeLessThan(3); } else { fail('currentPos is undefined'); } }); }); // ============================================ // Time-of-Day Palette Tests // ============================================ describe('AmbientMode - Time-of-Day Palette', function() { let originalBody; beforeEach(function() { originalBody = document.body.cloneNode(true); document.body.classList.add('ambient-mode'); }); afterEach(function() { document.body.className = originalBody.className; }); const testIfModeAvailable = (testName, testFn) => { if (!window.SpaxelAmbientMode) { test.skip(testName, () => {}); } else { test(testName, testFn); } }; testIfModeAvailable('should use morning palette (6-10am)', function() { // Mock hour to 7am const originalGetHours = Date.prototype.getHours; Date.prototype.getHours = function() { return 7; }; try { window.SpaxelAmbientMode.enable(); // Check that time-morning class was added expect(document.body.classList.contains('time-morning')).toBe(true); // Clean up window.SpaxelAmbientMode.disable(); } finally { Date.prototype.getHours = originalGetHours; } }); testIfModeAvailable('should use day palette (10am-6pm)', function() { // Mock hour to 2pm const originalGetHours = Date.prototype.getHours; Date.prototype.getHours = function() { return 14; }; try { window.SpaxelAmbientMode.enable(); expect(document.body.classList.contains('time-day')).toBe(true); } finally { Date.prototype.getHours = originalGetHours; window.SpaxelAmbientMode.disable(); } }); testIfModeAvailable('should use evening palette (6-10pm)', function() { // Mock hour to 7pm const originalGetHours = Date.prototype.getHours; Date.prototype.getHours = function() { return 19; }; try { window.SpaxelAmbientMode.enable(); expect(document.body.classList.contains('time-evening')).toBe(true); } finally { Date.prototype.getHours = originalGetHours; window.SpaxelAmbientMode.disable(); } }); testIfModeAvailable('should use night palette (10pm-6am)', function() { // Mock hour to 11pm const originalGetHours = Date.prototype.getHours; Date.prototype.getHours = function() { return 23; }; try { window.SpaxelAmbientMode.enable(); expect(document.body.classList.contains('time-night')).toBe(true); } finally { Date.prototype.getHours = originalGetHours; window.SpaxelAmbientMode.disable(); } }); }); // Add closeTo matcher for Jest expect.extend({ toBeCloseTo(received, expected, precision = 0.001) { const pass = Math.abs(received - expected) < precision; return { pass: pass, message: () => `Expected ${received} to be close to ${expected} within ${precision}` }; } });