spaxel/dashboard/js/ambient.test.setup.js
jedarden d81d1cb82c feat: implement ambient dashboard mode with Canvas 2D renderer
- Added /ambient route serving ambient.html for wall-mounted tablet display
- Canvas 2D renderer at 2Hz with lerp interpolation for smooth person movement
- Time-of-day palette with 30-minute transitions (morning/day/evening/night)
- Auto-dim: reduces brightness to 40% after 60s of no presence
- Alert mode: pulsing red background for fall/security alerts
- Morning briefing overlay: 15-second overlay on first detection after 6am
- Unified alerts API for fall, anomaly, and node_offline events
- Jest test setup mocking Canvas 2D context for jsdom

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 23:16:52 -04:00

397 lines
13 KiB
JavaScript

/**
* Jest setup for ambient tests.
* Mocks Canvas 2D context which is not implemented in jsdom.
*/
// Storage for canvas draw operations (for pixel-based tests)
const canvasDrawData = new Map();
// Helper to reset canvas draw data
global.resetCanvasDrawData = function() {
canvasDrawData.clear();
};
// Helper to get canvas key
function getCanvasKey(canvas) {
return canvas.id || canvas.toString();
}
// Mock Canvas 2D context before modules are loaded
HTMLCanvasElement.prototype.getContext = function(contextType) {
if (contextType === '2d' && !this._mockContext) {
const canvasKey = getCanvasKey(this);
// Initialize draw data for this canvas
if (!canvasDrawData.has(canvasKey)) {
const width = this.width || 800;
const height = this.height || 600;
const data = new Uint8ClampedArray(width * height * 4);
// Fill with white background
for (let i = 0; i < data.length; i += 4) {
data[i] = 255; // R
data[i + 1] = 255; // G
data[i + 2] = 255; // B
data[i + 3] = 255; // A
}
canvasDrawData.set(canvasKey, { data, width, height });
}
// Create a mock 2D context that actually tracks draw operations
const mockContext = {
canvas: this,
fillStyle: '#000000',
strokeStyle: '#000000',
lineWidth: 1,
font: '12px sans-serif',
textAlign: 'left',
textBaseline: 'alphabetic',
// Mock methods that track drawing
clearRect: jest.fn(function(x, y, w, h) {
const drawData = canvasDrawData.get(canvasKey);
if (!drawData) return;
const { data, width } = drawData;
for (let py = y; py < y + h && py < drawData.height; py++) {
for (let px = x; px < x + w && px < width; px++) {
const i = (py * width + px) * 4;
data[i] = 255;
data[i + 1] = 255;
data[i + 2] = 255;
data[i + 3] = 255;
}
}
}),
fillRect: jest.fn(function(x, y, w, h) {
const drawData = canvasDrawData.get(canvasKey);
if (!drawData) return;
const { data, width, height } = drawData;
// Parse fillStyle
const color = parseColor(mockContext.fillStyle);
for (let py = y; py < y + h && py < height; py++) {
for (let px = x; px < x + w && px < width; px++) {
const i = (py * width + px) * 4;
data[i] = color.r;
data[i + 1] = color.g;
data[i + 2] = color.b;
data[i + 3] = 255;
}
}
}),
strokeRect: jest.fn(function(x, y, w, h) {
const drawData = canvasDrawData.get(canvasKey);
if (!drawData) return;
const { data, width, height } = drawData;
const color = parseColor(mockContext.strokeStyle);
// Draw outline (1px thick)
const lineWidth = mockContext.lineWidth || 1;
for (let i = 0; i < lineWidth; i++) {
// Top edge
for (let px = x; px < x + w && px < width; px++) {
setPixel(data, width, px, y + i, color);
}
// Bottom edge
for (let px = x; px < x + w && px < width; px++) {
setPixel(data, width, px, y + h - i - 1, color);
}
// Left edge
for (let py = y; py < y + h && py < height; py++) {
setPixel(data, width, x + i, py, color);
}
// Right edge
for (let py = y; py < y + h && py < height; py++) {
setPixel(data, width, x + w - i - 1, py, color);
}
}
}),
fillText: jest.fn(function(text, x, y) {
const drawData = canvasDrawData.get(canvasKey);
if (!drawData) return;
const { data, width, height } = drawData;
const color = parseColor(mockContext.fillStyle);
// Draw a simple "text" as colored pixels at the position
for (let py = y - 6; py < y + 6 && py < height; py++) {
for (let px = x - 20; px < x + 20 && px < width; px++) {
setPixel(data, width, px, py, color);
}
}
}),
beginPath: jest.fn(),
arc: jest.fn(function(x, y, radius, startAngle, endAngle) {
const drawData = canvasDrawData.get(canvasKey);
if (!drawData) return;
const { data, width, height } = drawData;
const color = parseColor(mockContext.fillStyle);
// Draw a filled circle
for (let py = Math.floor(y - radius); py <= Math.ceil(y + radius) && py < height; py++) {
for (let px = Math.floor(x - radius); px <= Math.ceil(x + radius) && px < width; px++) {
const dx = px - x;
const dy = py - y;
if (dx * dx + dy * dy <= radius * radius) {
setPixel(data, width, px, py, color);
}
}
}
}),
moveTo: jest.fn(),
lineTo: jest.fn(),
closePath: jest.fn(),
fill: jest.fn(),
stroke: jest.fn(),
scale: jest.fn(),
roundRect: jest.fn(function(x, y, w, h, radius) {
// Just draw a filled rectangle for simplicity
mockContext.fillRect(x, y, w, h);
}),
// Mock getImageData to return actual pixel data
getImageData: function(x, y, width, height) {
const drawData = canvasDrawData.get(canvasKey);
if (!drawData) {
// Return empty data
const data = new Uint8ClampedArray(width * height * 4);
return { data, width, height };
}
const { data: srcData } = drawData;
const result = new Uint8ClampedArray(width * height * 4);
// Copy the requested region
for (let py = 0; py < height; py++) {
for (let px = 0; px < width; px++) {
const srcX = x + px;
const srcY = y + py;
if (srcX >= 0 && srcX < drawData.width && srcY >= 0 && srcY < drawData.height) {
const srcIdx = (srcY * drawData.width + srcX) * 4;
const dstIdx = (py * width + px) * 4;
result[dstIdx] = srcData[srcIdx];
result[dstIdx + 1] = srcData[srcIdx + 1];
result[dstIdx + 2] = srcData[srcIdx + 2];
result[dstIdx + 3] = srcData[srcIdx + 3];
}
}
}
return {
data: result,
width: width,
height: height
};
}
};
this._mockContext = mockContext;
}
return this._mockContext;
};
// Helper to parse color strings
function parseColor(colorStr) {
if (typeof colorStr !== 'string') {
return { r: 0, g: 0, b: 0 };
}
// Handle hex colors
if (colorStr.startsWith('#')) {
let hex = colorStr.slice(1);
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return { r, g, b };
}
// Handle rgb/hsl colors - simplified
if (colorStr.startsWith('rgb')) {
const match = colorStr.match(/\d+/g);
if (match && match.length >= 3) {
return { r: parseInt(match[0]), g: parseInt(match[1]), b: parseInt(match[2]) };
}
}
if (colorStr.startsWith('hsl')) {
// Simplified HSL to RGB - just return a default color
return { r: 100, g: 100, b: 100 };
}
// Default colors
const namedColors = {
'white': { r: 255, g: 255, b: 255 },
'black': { r: 0, g: 0, b: 0 },
'red': { r: 255, g: 0, b: 0 },
'green': { r: 0, g: 255, b: 0 },
'blue': { r: 0, g: 0, b: 255 },
'grey': { r: 128, g: 128, b: 128 },
'gray': { r: 128, g: 128, b: 128 }
};
const lower = colorStr.toLowerCase();
if (namedColors[lower]) {
return namedColors[lower];
}
return { r: 0, g: 0, b: 0 };
}
// Helper to set a pixel
function setPixel(data, width, x, y, color) {
if (x < 0 || y < 0 || x >= width) return;
const i = (y * width + x) * 4;
data[i] = color.r;
data[i + 1] = color.g;
data[i + 2] = color.b;
data[i + 3] = 255;
}
// Mock getBoundingClientRect for proper hit testing
HTMLElement.prototype.getBoundingClientRect = function() {
const rect = {
x: 0,
y: 0,
width: this.offsetWidth || 800,
height: this.offsetHeight || 600,
top: 0,
left: 0,
bottom: (this.offsetHeight || 600),
right: (this.offsetWidth || 800),
toJSON: function() {
return {
x: this.x,
y: this.y,
width: this.width,
height: this.height,
top: this.top,
left: this.left,
bottom: this.bottom,
right: this.right
};
}
};
return rect;
};
// Mock devicePixelRatio
Object.defineProperty(window, 'devicePixelRatio', {
value: 1,
writable: true
});
// Mock requestAnimationFrame with increasing timestamps
let rafTimestamp = 0;
let rafCallbacks = new Map();
let rafIdCounter = 0;
let rafLoopRunning = false;
// Start a mock RAF loop that runs at ~60fps (16ms per frame)
function startRafLoop() {
if (rafLoopRunning) return;
rafLoopRunning = true;
function loop() {
// Process all pending callbacks
const callbacksToRun = Array.from(rafCallbacks.entries())
.filter(([id]) => typeof id === 'number')
.map(([id, callback]) => callback);
// Clear processed callbacks
for (const id of rafCallbacks.keys()) {
if (typeof id === 'number') {
rafCallbacks.delete(id);
}
}
// Run all callbacks with current timestamp
rafTimestamp += 16; // Advance time
callbacksToRun.forEach(callback => {
try {
callback(rafTimestamp);
} catch (e) {
// Ignore errors in callbacks
}
});
// Schedule next iteration
if (rafLoopRunning) {
const timerId = setTimeout(loop, 0);
if (global._activeRafTimers) {
global._activeRafTimers.add(timerId);
}
}
}
const timerId = setTimeout(loop, 0);
if (global._activeRafTimers) {
global._activeRafTimers.add(timerId);
}
}
// Start the RAF loop immediately
startRafLoop();
global.requestAnimationFrame = function(callback) {
const id = ++rafIdCounter;
rafCallbacks.set(id, callback);
return id;
};
global.cancelAnimationFrame = function(id) {
rafCallbacks.delete(id);
};
// Helper to stop the RAF loop (for testing)
global.stopRafLoop = function() {
rafLoopRunning = false;
};
// Helper to restart the RAF loop
global.restartRafLoop = function() {
rafLoopRunning = false; // Stop first
rafTimestamp = 0; // Reset timestamp
startRafLoop(); // Restart
};
// Mock localStorage
var storage = {};
Object.defineProperty(global, 'localStorage', {
value: {
getItem: function(key) { return storage[key] || null; },
setItem: function(key, val) { storage[key] = String(val); },
removeItem: function(key) { delete storage[key]; },
clear: function() { storage = {}; },
get length() { return Object.keys(storage).length; },
key: function(index) { return Object.keys(storage)[index] || null; }
},
writable: true,
configurable: true
});
// Make storage accessible for test cleanup
global._localStorage = storage;
// Track active requestAnimationFrame timers for cleanup
global._activeRafTimers = new Set();
global._stopAllRafTimers = function() {
_activeRafTimers.forEach(timerId => clearTimeout(timerId));
_activeRafTimers.clear();
};
// Mock addEventListener and removeEventListener on EventTarget prototype
// to ensure they work properly in tests
const originalAddEventListener = EventTarget.prototype.addEventListener;
const originalRemoveEventListener = EventTarget.prototype.removeEventListener;