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>
472 lines
16 KiB
JavaScript
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');
|
|
});
|
|
});
|