- 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>
992 lines
31 KiB
JavaScript
992 lines
31 KiB
JavaScript
/**
|
|
* 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 = `
|
|
<div class="ambient-briefing-content">
|
|
<div class="ambient-briefing-greeting" id="briefing-greeting"></div>
|
|
<div id="briefing-content" class="ambient-briefing-sections"></div>
|
|
<button class="ambient-briefing-dismiss" id="briefing-dismiss">Got it</button>
|
|
</div>
|
|
`;
|
|
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
|
|
});
|
|
|
|
// Stop the render loop so we can manually control renders
|
|
// Note: The renderer starts a render loop in init()
|
|
// We need to wait for one render cycle to pass, then test the lerp
|
|
|
|
// 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');
|
|
}
|
|
});
|
|
|
|
testIfRendererAvailable('should smoothly decelerate with exponential approach', function() {
|
|
renderer = window.SpaxelAmbientRenderer;
|
|
renderer.init(canvas, {
|
|
scale: 50,
|
|
margin: 40
|
|
});
|
|
|
|
// Stop the background render loop to avoid interference with manual render calls
|
|
renderer.stopRenderLoop && renderer.stopRenderLoop();
|
|
|
|
// First, set a blob at position (0,0) to initialize it
|
|
renderer.updateState({
|
|
zones: [],
|
|
blobs: [{
|
|
id: 1,
|
|
x: 0,
|
|
y: 0,
|
|
z: 0,
|
|
confidence: 0.8
|
|
}],
|
|
portals: [],
|
|
nodes: []
|
|
});
|
|
|
|
// Do one render to lock in the initial position
|
|
renderer.render();
|
|
|
|
// Now update target to (10, 10) - current position stays at (0,0)
|
|
renderer.updateState({
|
|
zones: [],
|
|
blobs: [{
|
|
id: 1,
|
|
x: 10,
|
|
y: 10,
|
|
z: 0,
|
|
confidence: 0.8
|
|
}],
|
|
portals: [],
|
|
nodes: []
|
|
});
|
|
|
|
const positions = [];
|
|
|
|
// Simulate 10 frames - each render lerps 20% toward target
|
|
for (let i = 0; i < 10; i++) {
|
|
renderer.render();
|
|
const pos = window.SpaxelAmbientRenderer._getCurrentPositions && window.SpaxelAmbientRenderer._getCurrentPositions().get(1);
|
|
if (pos) {
|
|
positions.push({ x: pos.x, y: pos.y });
|
|
}
|
|
}
|
|
|
|
// Check that movement per frame decreases (exponential deceleration)
|
|
let prevDelta = null;
|
|
for (let i = 1; i < positions.length; i++) {
|
|
const delta = Math.sqrt(
|
|
Math.pow(positions[i].x - positions[i-1].x, 2) +
|
|
Math.pow(positions[i].y - positions[i-1].y, 2)
|
|
);
|
|
|
|
if (prevDelta !== null) {
|
|
// Movement should decrease or stay same (never increase)
|
|
// Allow some tolerance for floating point errors
|
|
expect(delta).toBeLessThanOrEqual(prevDelta + 0.001);
|
|
}
|
|
prevDelta = delta;
|
|
}
|
|
|
|
// Final position should be closer to target than initial
|
|
if (positions.length > 0) {
|
|
const finalDist = Math.sqrt(
|
|
Math.pow(10 - positions[positions.length-1].x, 2) +
|
|
Math.pow(10 - positions[positions.length-1].y, 2)
|
|
);
|
|
const initialDist = Math.sqrt(
|
|
Math.pow(10 - positions[0].x, 2) +
|
|
Math.pow(10 - positions[0].y, 2)
|
|
);
|
|
|
|
// The initial position should be (0,0), distance from (10,10) is sqrt(200) ≈ 14.14
|
|
// After lerp, we should be closer to (10,10)
|
|
expect(finalDist).toBeLessThan(initialDist);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ============================================
|
|
// 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}`
|
|
};
|
|
}
|
|
});
|