- 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>
397 lines
13 KiB
JavaScript
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;
|