613 lines
23 KiB
JavaScript
613 lines
23 KiB
JavaScript
/**
|
|
* Frontend Scanning Interface Tests
|
|
* Tests the scanning UI functionality including camera access, product lookup, and inventory updates
|
|
*/
|
|
|
|
// Mock DOM environment for testing
|
|
const { JSDOM } = require('jsdom');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
describe('Scanning Interface', () => {
|
|
let dom;
|
|
let document;
|
|
let window;
|
|
let InventoryApp;
|
|
|
|
beforeEach(() => {
|
|
// Load the HTML file
|
|
const htmlPath = path.join(__dirname, '../public/index.html');
|
|
const htmlContent = fs.readFileSync(htmlPath, 'utf8');
|
|
|
|
// Create JSDOM instance
|
|
dom = new JSDOM(htmlContent, {
|
|
url: 'http://localhost:3000',
|
|
pretendToBeVisual: true,
|
|
resources: 'usable'
|
|
});
|
|
|
|
document = dom.window.document;
|
|
window = dom.window;
|
|
|
|
// Mock global objects
|
|
global.document = document;
|
|
global.window = window;
|
|
global.FormData = window.FormData;
|
|
global.fetch = jest.fn();
|
|
global.navigator = {
|
|
mediaDevices: {
|
|
getUserMedia: jest.fn()
|
|
}
|
|
};
|
|
|
|
// Mock file API
|
|
global.File = class File {
|
|
constructor(parts, filename, options = {}) {
|
|
this.name = filename;
|
|
this.size = parts.reduce((size, part) => size + part.length, 0);
|
|
this.type = options.type || '';
|
|
}
|
|
};
|
|
|
|
// Load the JavaScript application
|
|
const jsPath = path.join(__dirname, '../public/js/app.js');
|
|
let jsContent = fs.readFileSync(jsPath, 'utf8');
|
|
|
|
// Modify the JS content to expose InventoryApp class
|
|
jsContent = jsContent.replace(
|
|
'class InventoryApp {',
|
|
'window.InventoryApp = class InventoryApp {'
|
|
);
|
|
|
|
// Execute the JavaScript in the JSDOM context
|
|
const script = new window.Function(jsContent);
|
|
script.call(window);
|
|
|
|
// Get the InventoryApp class from the window context
|
|
InventoryApp = window.InventoryApp;
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks();
|
|
dom.window.close();
|
|
});
|
|
|
|
describe('Initialization', () => {
|
|
test('should initialize scanning state correctly', () => {
|
|
const app = new InventoryApp();
|
|
|
|
expect(app.cameraStream).toBeNull();
|
|
expect(app.isScanning).toBe(false);
|
|
expect(app.currentProduct).toBeNull();
|
|
expect(app.recentUpdates).toEqual([]);
|
|
expect(app.scanInterval).toBeNull();
|
|
});
|
|
|
|
test('should set up scanning event listeners', () => {
|
|
const app = new InventoryApp();
|
|
|
|
// Check that key elements exist
|
|
expect(document.getElementById('start-camera')).toBeTruthy();
|
|
expect(document.getElementById('stop-camera')).toBeTruthy();
|
|
expect(document.getElementById('manual-code-input')).toBeTruthy();
|
|
expect(document.getElementById('lookup-code')).toBeTruthy();
|
|
});
|
|
|
|
test('should initialize with stop camera button disabled', () => {
|
|
new InventoryApp();
|
|
|
|
const stopButton = document.getElementById('stop-camera');
|
|
expect(stopButton.disabled).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Camera Controls', () => {
|
|
test('should start camera successfully', async () => {
|
|
const app = new InventoryApp();
|
|
|
|
// Mock successful camera access
|
|
const mockStream = {
|
|
getTracks: () => [{ stop: jest.fn() }]
|
|
};
|
|
global.navigator.mediaDevices.getUserMedia.mockResolvedValueOnce(mockStream);
|
|
|
|
await app.startCamera();
|
|
|
|
expect(app.cameraStream).toBe(mockStream);
|
|
expect(app.isScanning).toBe(true);
|
|
expect(document.getElementById('camera-container').style.display).toBe('block');
|
|
});
|
|
|
|
test('should handle camera access failure', async () => {
|
|
const app = new InventoryApp();
|
|
app.showError = jest.fn();
|
|
|
|
global.navigator.mediaDevices.getUserMedia.mockRejectedValueOnce(new Error('Camera not available'));
|
|
|
|
await app.startCamera();
|
|
|
|
expect(app.showError).toHaveBeenCalledWith('Failed to access camera: Camera not available');
|
|
expect(app.cameraStream).toBeNull();
|
|
expect(app.isScanning).toBe(false);
|
|
});
|
|
|
|
test('should stop camera correctly', () => {
|
|
const app = new InventoryApp();
|
|
|
|
// Mock active camera stream
|
|
const mockTrack = { stop: jest.fn() };
|
|
app.cameraStream = {
|
|
getTracks: () => [mockTrack]
|
|
};
|
|
app.isScanning = true;
|
|
app.scanInterval = setInterval(() => {}, 1000);
|
|
|
|
app.stopCamera();
|
|
|
|
expect(mockTrack.stop).toHaveBeenCalled();
|
|
expect(app.cameraStream).toBeNull();
|
|
expect(app.isScanning).toBe(false);
|
|
expect(app.scanInterval).toBeNull();
|
|
expect(document.getElementById('camera-container').style.display).toBe('none');
|
|
});
|
|
|
|
test('should handle camera not supported', async () => {
|
|
const app = new InventoryApp();
|
|
app.showError = jest.fn();
|
|
|
|
// Mock no camera support
|
|
global.navigator.mediaDevices = undefined;
|
|
|
|
await app.startCamera();
|
|
|
|
expect(app.showError).toHaveBeenCalledWith('Failed to access camera: Camera not supported in this browser');
|
|
});
|
|
});
|
|
|
|
describe('Scan Status Updates', () => {
|
|
test('should update scan status correctly', () => {
|
|
const app = new InventoryApp();
|
|
|
|
// Add status elements to DOM
|
|
const statusContainer = document.getElementById('scan-status');
|
|
statusContainer.innerHTML = `
|
|
<div class="status-indicator"></div>
|
|
<span class="status-text">Ready</span>
|
|
`;
|
|
statusContainer.style.display = 'flex';
|
|
|
|
app.updateScanStatus('Scanning...', 'scanning');
|
|
|
|
const statusText = document.querySelector('.status-text');
|
|
const statusIndicator = document.querySelector('.status-indicator');
|
|
|
|
expect(statusText.textContent).toBe('Scanning...');
|
|
expect(statusIndicator.classList.contains('scanning')).toBe(true);
|
|
});
|
|
|
|
test('should handle scanned code', () => {
|
|
const app = new InventoryApp();
|
|
app.updateScanStatus = jest.fn();
|
|
app.lookupProduct = jest.fn();
|
|
|
|
app.handleScannedCode('ABC123');
|
|
|
|
expect(app.updateScanStatus).toHaveBeenCalledWith('Code detected!', 'ready');
|
|
expect(document.getElementById('manual-code-input').value).toBe('ABC123');
|
|
expect(app.lookupProduct).toHaveBeenCalledWith('ABC123');
|
|
});
|
|
});
|
|
|
|
describe('Product Lookup', () => {
|
|
test('should lookup product successfully from server', async () => {
|
|
const app = new InventoryApp();
|
|
app.displayProductInfo = jest.fn();
|
|
|
|
const mockProduct = {
|
|
id: 1,
|
|
product_code: 'ABC123',
|
|
description: 'Widget A',
|
|
quantity: 50
|
|
};
|
|
|
|
global.fetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => mockProduct
|
|
});
|
|
|
|
await app.lookupProduct('ABC123');
|
|
|
|
expect(app.displayProductInfo).toHaveBeenCalledWith(mockProduct);
|
|
});
|
|
|
|
test('should handle product not found', async () => {
|
|
const app = new InventoryApp();
|
|
app.showError = jest.fn();
|
|
|
|
global.fetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404
|
|
});
|
|
|
|
await app.lookupProduct('INVALID');
|
|
|
|
expect(app.showError).toHaveBeenCalledWith('Product with code "INVALID" not found');
|
|
});
|
|
|
|
test('should use mock data when server unavailable', async () => {
|
|
const app = new InventoryApp();
|
|
app.mockProductLookup = jest.fn();
|
|
|
|
global.fetch.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
await app.lookupProduct('ABC123');
|
|
|
|
expect(app.mockProductLookup).toHaveBeenCalledWith('ABC123');
|
|
});
|
|
|
|
test('should find mock product correctly', () => {
|
|
const app = new InventoryApp();
|
|
app.displayProductInfo = jest.fn();
|
|
|
|
app.mockProductLookup('ABC123');
|
|
|
|
expect(app.displayProductInfo).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
product_code: 'ABC123',
|
|
description: 'Widget A'
|
|
})
|
|
);
|
|
});
|
|
|
|
test('should handle mock product not found', () => {
|
|
const app = new InventoryApp();
|
|
app.showError = jest.fn();
|
|
|
|
app.mockProductLookup('INVALID');
|
|
|
|
expect(app.showError).toHaveBeenCalledWith('Product with code "INVALID" not found');
|
|
});
|
|
|
|
test('should handle manual code entry with Enter key', () => {
|
|
const app = new InventoryApp();
|
|
app.lookupProduct = jest.fn();
|
|
|
|
const input = document.getElementById('manual-code-input');
|
|
input.value = 'ABC123';
|
|
|
|
const event = new window.KeyboardEvent('keypress', { key: 'Enter' });
|
|
input.dispatchEvent(event);
|
|
|
|
expect(app.lookupProduct).toHaveBeenCalledWith('ABC123');
|
|
});
|
|
});
|
|
|
|
describe('Product Information Display', () => {
|
|
test('should display product information correctly', () => {
|
|
const app = new InventoryApp();
|
|
app.setupInventoryUpdate = jest.fn();
|
|
|
|
const mockProduct = {
|
|
id: 1,
|
|
product_code: 'ABC123',
|
|
description: 'Widget A',
|
|
category: 'Widgets',
|
|
quantity: 50,
|
|
unit_of_measure: 'pcs'
|
|
};
|
|
|
|
app.displayProductInfo(mockProduct);
|
|
|
|
expect(app.currentProduct).toBe(mockProduct);
|
|
expect(document.getElementById('product-info-section').style.display).toBe('block');
|
|
expect(app.setupInventoryUpdate).toHaveBeenCalledWith(mockProduct);
|
|
|
|
const productInfoCard = document.getElementById('product-info-card');
|
|
expect(productInfoCard.innerHTML).toContain('ABC123');
|
|
expect(productInfoCard.innerHTML).toContain('Widget A');
|
|
expect(productInfoCard.innerHTML).toContain('50 pcs');
|
|
});
|
|
});
|
|
|
|
describe('Inventory Update', () => {
|
|
test('should setup inventory update correctly', () => {
|
|
const app = new InventoryApp();
|
|
app.updateNewLevelPreview = jest.fn();
|
|
|
|
const mockProduct = {
|
|
id: 1,
|
|
product_code: 'ABC123',
|
|
quantity: 50
|
|
};
|
|
|
|
app.setupInventoryUpdate(mockProduct);
|
|
|
|
expect(document.getElementById('current-level').textContent).toBe('50');
|
|
expect(document.getElementById('inventory-update-section').style.display).toBe('block');
|
|
expect(document.getElementById('confirm-update').disabled).toBe(false);
|
|
expect(app.updateNewLevelPreview).toHaveBeenCalled();
|
|
});
|
|
|
|
test('should update new level preview correctly for set operation', () => {
|
|
const app = new InventoryApp();
|
|
app.currentProduct = { quantity: 50 };
|
|
|
|
// Set form values
|
|
document.querySelector('input[name="update-type"][value="set"]').checked = true;
|
|
document.getElementById('quantity-input').value = '75';
|
|
|
|
app.updateNewLevelPreview();
|
|
|
|
expect(document.getElementById('new-level-preview').textContent).toBe('75');
|
|
});
|
|
|
|
test('should update new level preview correctly for add operation', () => {
|
|
const app = new InventoryApp();
|
|
app.currentProduct = { quantity: 50 };
|
|
|
|
// Set form values
|
|
document.querySelector('input[name="update-type"][value="add"]').checked = true;
|
|
document.getElementById('quantity-input').value = '25';
|
|
|
|
app.updateNewLevelPreview();
|
|
|
|
expect(document.getElementById('new-level-preview').textContent).toBe('75');
|
|
});
|
|
|
|
test('should update new level preview correctly for subtract operation', () => {
|
|
const app = new InventoryApp();
|
|
app.currentProduct = { quantity: 50 };
|
|
|
|
// Set form values
|
|
document.querySelector('input[name="update-type"][value="subtract"]').checked = true;
|
|
document.getElementById('quantity-input').value = '20';
|
|
|
|
app.updateNewLevelPreview();
|
|
|
|
expect(document.getElementById('new-level-preview').textContent).toBe('30');
|
|
});
|
|
|
|
test('should prevent negative inventory levels', () => {
|
|
const app = new InventoryApp();
|
|
app.currentProduct = { quantity: 10 };
|
|
|
|
// Set form values
|
|
document.querySelector('input[name="update-type"][value="subtract"]').checked = true;
|
|
document.getElementById('quantity-input').value = '20';
|
|
|
|
app.updateNewLevelPreview();
|
|
|
|
expect(document.getElementById('new-level-preview').textContent).toBe('0');
|
|
});
|
|
|
|
test('should show custom reason input when other is selected', () => {
|
|
const app = new InventoryApp();
|
|
|
|
const reasonSelect = document.getElementById('update-reason');
|
|
const customReasonInput = document.getElementById('custom-reason');
|
|
|
|
reasonSelect.value = 'other';
|
|
reasonSelect.dispatchEvent(new window.Event('change'));
|
|
|
|
expect(customReasonInput.style.display).toBe('block');
|
|
expect(customReasonInput.required).toBe(true);
|
|
});
|
|
|
|
test('should hide custom reason input when other option is not selected', () => {
|
|
const app = new InventoryApp();
|
|
|
|
const reasonSelect = document.getElementById('update-reason');
|
|
const customReasonInput = document.getElementById('custom-reason');
|
|
|
|
reasonSelect.value = 'stock_count';
|
|
reasonSelect.dispatchEvent(new window.Event('change'));
|
|
|
|
expect(customReasonInput.style.display).toBe('none');
|
|
expect(customReasonInput.required).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Inventory Update Confirmation', () => {
|
|
test('should confirm inventory update successfully', async () => {
|
|
const app = new InventoryApp();
|
|
app.showSuccess = jest.fn();
|
|
app.addRecentUpdate = jest.fn();
|
|
app.cancelInventoryUpdate = jest.fn();
|
|
|
|
app.currentProduct = {
|
|
id: 1,
|
|
product_code: 'ABC123',
|
|
description: 'Widget A',
|
|
quantity: 50
|
|
};
|
|
|
|
// Set form values
|
|
document.querySelector('input[name="update-type"][value="set"]').checked = true;
|
|
document.getElementById('quantity-input').value = '75';
|
|
document.getElementById('update-reason').value = 'stock_count';
|
|
|
|
global.fetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ success: true })
|
|
});
|
|
|
|
await app.confirmInventoryUpdate();
|
|
|
|
expect(app.showSuccess).toHaveBeenCalledWith('Inventory updated successfully! New level: 75');
|
|
expect(app.currentProduct.quantity).toBe(75);
|
|
expect(app.addRecentUpdate).toHaveBeenCalled();
|
|
expect(app.cancelInventoryUpdate).toHaveBeenCalled();
|
|
});
|
|
|
|
test('should handle inventory update failure gracefully', async () => {
|
|
const app = new InventoryApp();
|
|
app.showSuccess = jest.fn();
|
|
app.addRecentUpdate = jest.fn();
|
|
app.cancelInventoryUpdate = jest.fn();
|
|
|
|
app.currentProduct = {
|
|
id: 1,
|
|
product_code: 'ABC123',
|
|
description: 'Widget A',
|
|
quantity: 50
|
|
};
|
|
|
|
// Set form values
|
|
document.querySelector('input[name="update-type"][value="set"]').checked = true;
|
|
document.getElementById('quantity-input').value = '75';
|
|
|
|
global.fetch.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
await app.confirmInventoryUpdate();
|
|
|
|
expect(app.showSuccess).toHaveBeenCalledWith('Inventory updated successfully! New level: 75');
|
|
expect(app.addRecentUpdate).toHaveBeenCalled();
|
|
expect(app.cancelInventoryUpdate).toHaveBeenCalled();
|
|
});
|
|
|
|
test('should cancel inventory update correctly', () => {
|
|
const app = new InventoryApp();
|
|
|
|
app.currentProduct = { id: 1 };
|
|
document.getElementById('manual-code-input').value = 'ABC123';
|
|
|
|
app.cancelInventoryUpdate();
|
|
|
|
expect(app.currentProduct).toBeNull();
|
|
expect(document.getElementById('product-info-section').style.display).toBe('none');
|
|
expect(document.getElementById('inventory-update-section').style.display).toBe('none');
|
|
expect(document.getElementById('manual-code-input').value).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('Recent Updates', () => {
|
|
test('should add recent update correctly', () => {
|
|
const app = new InventoryApp();
|
|
app.displayRecentUpdates = jest.fn();
|
|
|
|
const update = {
|
|
product_code: 'ABC123',
|
|
description: 'Widget A',
|
|
old_level: 50,
|
|
new_level: 75,
|
|
change: 25,
|
|
reason: 'stock_count',
|
|
timestamp: new Date()
|
|
};
|
|
|
|
app.addRecentUpdate(update);
|
|
|
|
expect(app.recentUpdates).toContain(update);
|
|
expect(app.displayRecentUpdates).toHaveBeenCalled();
|
|
});
|
|
|
|
test('should limit recent updates to 10 items', () => {
|
|
const app = new InventoryApp();
|
|
app.displayRecentUpdates = jest.fn();
|
|
|
|
// Add 12 updates
|
|
for (let i = 0; i < 12; i++) {
|
|
app.addRecentUpdate({
|
|
product_code: `CODE${i}`,
|
|
description: `Product ${i}`,
|
|
old_level: i,
|
|
new_level: i + 1,
|
|
change: 1,
|
|
timestamp: new Date()
|
|
});
|
|
}
|
|
|
|
expect(app.recentUpdates.length).toBe(10);
|
|
expect(app.recentUpdates[0].product_code).toBe('CODE11'); // Most recent first
|
|
});
|
|
|
|
test('should display recent updates correctly', () => {
|
|
const app = new InventoryApp();
|
|
|
|
const update = {
|
|
product_code: 'ABC123',
|
|
description: 'Widget A',
|
|
old_level: 50,
|
|
new_level: 75,
|
|
change: 25,
|
|
reason: 'stock_count',
|
|
timestamp: new Date()
|
|
};
|
|
|
|
app.recentUpdates = [update];
|
|
app.displayRecentUpdates();
|
|
|
|
const recentUpdatesContainer = document.getElementById('recent-updates');
|
|
expect(recentUpdatesContainer.innerHTML).toContain('ABC123');
|
|
expect(recentUpdatesContainer.innerHTML).toContain('Widget A');
|
|
expect(recentUpdatesContainer.innerHTML).toContain('+25');
|
|
expect(recentUpdatesContainer.innerHTML).toContain('50 → 75');
|
|
});
|
|
|
|
test('should show no updates message when list is empty', () => {
|
|
const app = new InventoryApp();
|
|
|
|
app.recentUpdates = [];
|
|
app.displayRecentUpdates();
|
|
|
|
const recentUpdatesContainer = document.getElementById('recent-updates');
|
|
expect(recentUpdatesContainer.innerHTML).toContain('No recent updates');
|
|
});
|
|
|
|
test('should format time correctly', () => {
|
|
const app = new InventoryApp();
|
|
|
|
const testDate = new Date('2023-01-01T14:30:00');
|
|
const formatted = app.formatTime(testDate);
|
|
|
|
expect(formatted).toMatch(/\d{1,2}:\d{2}/); // Should match time format
|
|
});
|
|
});
|
|
|
|
describe('UI Interactions', () => {
|
|
test('should switch to scan tab correctly', () => {
|
|
const app = new InventoryApp();
|
|
|
|
app.switchTab('scan');
|
|
|
|
expect(app.currentTab).toBe('scan');
|
|
|
|
const activeTab = document.querySelector('.nav-tab.active');
|
|
expect(activeTab.dataset.tab).toBe('scan');
|
|
|
|
const activeContent = document.querySelector('.tab-content.active');
|
|
expect(activeContent.id).toBe('scan-tab');
|
|
});
|
|
|
|
test('should handle scan tab click', () => {
|
|
const app = new InventoryApp();
|
|
const scanTab = document.querySelector('[data-tab="scan"]');
|
|
|
|
scanTab.click();
|
|
|
|
expect(app.currentTab).toBe('scan');
|
|
});
|
|
|
|
test('should update new level preview when quantity input changes', () => {
|
|
const app = new InventoryApp();
|
|
app.updateNewLevelPreview = jest.fn();
|
|
|
|
const quantityInput = document.getElementById('quantity-input');
|
|
quantityInput.value = '100';
|
|
quantityInput.dispatchEvent(new window.Event('input'));
|
|
|
|
expect(app.updateNewLevelPreview).toHaveBeenCalled();
|
|
});
|
|
|
|
test('should update new level preview when update type changes', () => {
|
|
const app = new InventoryApp();
|
|
app.updateNewLevelPreview = jest.fn();
|
|
|
|
const addRadio = document.querySelector('input[name="update-type"][value="add"]');
|
|
addRadio.checked = true;
|
|
addRadio.dispatchEvent(new window.Event('change'));
|
|
|
|
expect(app.updateNewLevelPreview).toHaveBeenCalled();
|
|
});
|
|
});
|
|
}); |