spaxel/dashboard/js/proactive.test.js
jedarden 4c221418c1 test(proactive): fix localStorage state isolation between tests
Move localStorage.clear() to parent beforeEach to ensure module
initialization always starts with clean state. This fixes test
isolation where localStorage data from previous tests was being
loaded by the module before the nested beforeEach could clear it.

The repeated-setting change detection feature is already fully
implemented in proactive.js with:
- Setting change tracking in localStorage (24h window)
- Help prompt after 3+ changes for qualifying settings
- Guided calibration flow with false positive and missed motion tests
- Value suggestions based on diurnal baseline SNR and link health
- Apply suggested value button

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 00:37:50 -04:00

472 lines
16 KiB
JavaScript

/**
* Tests for Proactive Quality Assistance Module
*/
describe('Proactive Quality Module', () => {
let Proactive;
let mockDocument;
let mockWindow;
beforeEach(() => {
// Clear localStorage FIRST, before any module initialization
localStorage.clear();
// Mock document and window
let createdElements = {};
mockDocument = {
createElement: jest.fn((tag) => {
const element = {
id: '',
className: '',
innerHTML: '',
style: {},
appendChild: jest.fn(),
remove: jest.fn(),
addEventListener: jest.fn(),
querySelector: jest.fn(),
querySelectorAll: jest.fn(() => []),
classList: {
add: jest.fn(),
remove: jest.fn(),
contains: jest.fn()
}
};
return element;
}),
body: {
appendChild: jest.fn((element) => {
// Store created elements so getElementById can find them
if (element.id) {
createdElements[element.id] = element;
}
}),
},
head: {
appendChild: jest.fn(),
},
readyState: 'complete',
getElementById: jest.fn((id) => {
// Return created elements if they exist
if (createdElements[id]) {
return createdElements[id];
}
// Otherwise return null for uncreated elements
return null;
}),
};
// Mock fetch to prevent server-side hint detection during tests
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
// Return settings without repeated_edit_hint flag
delta_rms_threshold: 0.02,
})
});
global.fetch = mockFetch;
mockWindow = {
Viz3D: {
highlightLink: jest.fn(),
},
Replay: {
pauseAndTune: jest.fn(),
},
openSettingsPanel: jest.fn(),
SpaxelRouter: {
navigate: jest.fn(),
},
dispatchEvent: jest.fn(),
addEventListener: jest.fn(),
};
global.document = mockDocument;
global.window = mockWindow;
// Load the module - it attaches to window.Proactive
jest.resetModules();
require('./proactive.js');
Proactive = global.window.Proactive;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Quality Prompt Detection', () => {
test('should track poor quality links over time', () => {
const links = [
{ link_id: 'AA:BB:CC:DD:EE:FF:11:22', composite_score: 0.5 },
{ link_id: '11:22:33:44:55:66:77:88', composite_score: 0.8 },
];
Proactive.monitorLinkQuality(links);
// Should track the poor quality link
// (In real scenario, would check qualityPromptLinkID)
});
test('should not show prompt for transient drops (< 5 minutes)', () => {
const now = Date.now();
const links = [
{ link_id: 'test-link-1', composite_score: 0.55 },
];
// Mock that the link just started being tracked
Proactive.monitorLinkQuality(links);
// Prompt should not be shown yet
const prompt = document.getElementById('quality-prompt-card');
expect(prompt).toBeNull();
});
});
describe('Setting Change Tracking', () => {
test('should track qualifying setting changes', () => {
Proactive.trackSettingChange('delta_rms_threshold', 0.02);
Proactive.trackSettingChange('delta_rms_threshold', 0.025);
Proactive.trackSettingChange('delta_rms_threshold', 0.03);
// Should trigger hint after 3 changes
const hint = document.getElementById('setting-change-hint');
expect(hint).not.toBeUndefined();
});
test('should not track non-qualifying settings', () => {
Proactive.trackSettingChange('theme', 'dark');
Proactive.trackSettingChange('theme', 'light');
Proactive.trackSettingChange('theme', 'dark');
// Should not trigger hint for non-qualifying settings
const hint = document.getElementById('setting-change-hint');
expect(hint).toBeNull();
});
test('should respect hint cooldown period', () => {
// This would require mocking Date.now()
// For now, just verify the function exists
expect(Proactive.dismissSettingHint).toBeDefined();
});
});
describe('Feedback Explanations', () => {
test('should show explanation for false positive feedback', () => {
const eventData = {
type: 'incorrect',
explainability: {
contributing_links: [
{
link_id: 'test-link',
delta_rms: 0.05,
diagnosis: {
detail: 'WiFi congestion detected',
advice: 'Move node closer to router'
}
}
]
}
};
const explanation = Proactive.showFeedbackExplanation(eventData);
expect(explanation).not.toBeNull();
expect(explanation.innerHTML).toContain('deltaRMS');
});
test('should handle missing explainability data gracefully', () => {
const eventData = {
type: 'incorrect'
};
const explanation = Proactive.showFeedbackExplanation(eventData);
expect(explanation).toBeUndefined();
});
});
describe('Link Diagnostics', () => {
test('should format link IDs for display', () => {
// This tests the internal formatLinkID function
// Since it's not exposed, we verify through diagnoseLink
const linkID = 'AA:BB:CC:DD:EE:FF:11:22';
Proactive.diagnoseLink(linkID);
// Should create a diagnostic panel
const panel = document.getElementById('diagnostic-results-panel');
expect(panel).not.toBeNull();
});
test('should fetch diagnostics from API', async () => {
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
diagnosis: {
title: 'Test Diagnosis',
detail: 'Test detail',
advice: 'Test advice',
severity: 'warning'
}
})
});
global.fetch = mockFetch;
await Proactive.fetchDiagnosticForLink('test-link', Date.now());
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('diagnostics/link/test-link')
);
});
});
describe('Helper Functions', () => {
test('should format setting names for display', () => {
// Test the internal formatSettingName function
// Since it's not exposed, we verify it works through setting change tracking
Proactive.trackSettingChange('delta_rms_threshold', 0.02);
const hint = document.getElementById('setting-change-hint');
expect(hint).not.toBeNull();
expect(hint.innerHTML).toContain('Motion Threshold');
});
});
});
// Test helper functions
describe('formatLinkID', () => {
it('should format MAC address pairs', () => {
// This would be tested through the module
// For now, we document the expected format
const linkID = 'AA:BB:CC:DD:EE:FF:11:22';
const expected = 'AA:BB:CC:DD → 11:22';
// The actual function is internal to proactive.js
});
});
describe('Help Overlay Module', () => {
let HelpOverlay;
let mockDocument;
let mockFetch;
beforeEach(() => {
mockDocument = {
createElement: jest.fn((tag) => {
const el = {
id: '',
className: '',
innerHTML: '',
style: {},
appendChild: jest.fn(),
remove: jest.fn(),
addEventListener: jest.fn(),
querySelector: jest.fn(() => null),
querySelectorAll: jest.fn(() => []),
focus: jest.fn(),
};
if (tag === 'div') el.tagName = 'DIV';
if (tag === 'input') el.tagName = 'INPUT';
if (tag === 'button') el.tagName = 'BUTTON';
if (tag === 'style') el.tagName = 'STYLE';
return el;
}),
body: {
appendChild: jest.fn(),
},
head: {
appendChild: jest.fn(),
},
readyState: 'loading',
addEventListener: jest.fn(),
getElementById: jest.fn((id) => {
if (id === 'help-overlay') {
return { style: { display: 'none' } };
}
return null;
}),
};
mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => [
{
id: 'test-article',
title: 'Test Article',
content: 'Test content',
category: 'Test',
}
]
});
global.document = mockDocument;
global.fetch = mockFetch;
jest.resetModules();
HelpOverlay = require('./help.js');
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Initialization', () => {
test('should load articles from JSON file', async () => {
await HelpOverlay.init();
expect(mockFetch).toHaveBeenCalledWith('/help_articles.json');
});
test('should handle JSON load failure gracefully', async () => {
mockFetch = jest.fn().mockRejectedValue(new Error('Load failed'));
global.fetch = mockFetch;
jest.resetModules();
HelpOverlay = require('./help.js');
await HelpOverlay.init();
// Should use fallback articles
expect(HelpOverlay).toBeDefined();
});
});
describe('Search Functionality', () => {
test('should filter articles by search query', async () => {
await HelpOverlay.init();
HelpOverlay.open();
// Set search input
const searchInput = mockDocument.getElementById('help-search-input');
searchInput.value = 'fresnel';
// Trigger search event
const searchEvent = new Event('input');
searchInput.dispatchEvent(searchEvent);
// Articles should be filtered
// (In real scenario, would check articlesList.innerHTML)
});
test('should support fuzzy matching', () => {
// Test the internal fuzzyMatch function
// Since it's not exposed, we verify through search behavior
});
});
describe('Category Filtering', () => {
test('should render category filter buttons', async () => {
await HelpOverlay.init();
HelpOverlay.open();
const categories = document.getElementById('help-categories');
expect(categories).not.toBeNull();
});
test('should filter articles when category clicked', async () => {
await HelpOverlay.init();
HelpOverlay.open();
// Click on a category button
// (In real scenario, would simulate button click)
});
});
describe('UI Interaction', () => {
test('should open overlay on toggle', async () => {
await HelpOverlay.init();
HelpOverlay.toggle();
expect(HelpOverlay.isOpen()).toBe(true);
HelpOverlay.toggle();
expect(HelpOverlay.isOpen()).toBe(false);
});
test('should close overlay on escape key', async () => {
await HelpOverlay.init();
HelpOverlay.open();
const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' });
document.dispatchEvent(escapeEvent);
expect(HelpOverlay.isOpen()).toBe(false);
});
});
});
describe('Help Articles JSON', () => {
let articles;
beforeEach(async () => {
// Mock fetch to return the articles
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => {
const fs = require('fs');
const path = require('path');
const jsonPath = path.join(__dirname, '..', 'help_articles.json');
return JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
}
});
});
test('should have 30+ articles', async () => {
const response = await fetch('/help_articles.json');
articles = await response.json();
expect(articles.length).toBeGreaterThanOrEqual(30);
});
test('should have required article fields', async () => {
const response = await fetch('/help_articles.json');
articles = await response.json();
articles.forEach(article => {
expect(article).toHaveProperty('id');
expect(article).toHaveProperty('title');
expect(article).toHaveProperty('content');
expect(article).toHaveProperty('category');
});
});
test('should cover all major categories', async () => {
const response = await fetch('/help_articles.json');
articles = await response.json();
const categories = new Set(articles.map(a => a.category));
const expectedCategories = [
'Basics', 'Troubleshooting', 'Setup', 'Features',
'Advanced', 'Interface', 'Maintenance', 'Integration'
];
expectedCategories.forEach(cat => {
expect(categories.has(cat)).toBe(true);
});
});
test('should have Fresnel zone article', async () => {
const response = await fetch('/help_articles.json');
articles = await response.json();
const fresnelArticle = articles.find(a => a.id === 'fresnel-zone');
expect(fresnelArticle).toBeDefined();
expect(fresnelArticle.title).toContain('Fresnel');
});
test('should have detection quality article', async () => {
const response = await fetch('/help_articles.json');
articles = await response.json();
const qualityArticle = articles.find(a => a.id === 'detection-quality');
expect(qualityArticle).toBeDefined();
expect(qualityArticle.content).toContain('interference');
});
test('should have help article for predictions', async () => {
const response = await fetch('/help_articles.json');
articles = await response.json();
const predictionsArticle = articles.find(a => a.id === 'predictions');
expect(predictionsArticle).toBeDefined();
expect(predictionsArticle.content).toContain('7 days');
});
});