'use strict'; /** * Unit tests for tickerValidation.js and the TickerInput component behaviour. * Run with: node --test tests/test_ticker_validation.js * Requires: npm install (installs jsdom for DOM tests) */ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); const { JSDOM } = require('jsdom'); // Load the validation module (UMD — sets module.exports in Node.js) const { validateTickerFormat, validateTickerRemote } = require( path.join(__dirname, '..', 'static', 'tickerValidation.js') ); // --------------------------------------------------------------------------- // Pure format-validation tests (no DOM, no network) // --------------------------------------------------------------------------- describe('validateTickerFormat', () => { test('test_empty_input_shows_error — empty string returns error', () => { const result = validateTickerFormat(''); assert.equal(result.valid, false); assert.match(result.error, /Please enter/i); }); test('test_empty_input_shows_error — whitespace-only returns error', () => { const result = validateTickerFormat(' '); assert.equal(result.valid, false); assert.match(result.error, /Please enter/i); }); test('test_numbers_rejected_instantly — digits in input', () => { for (const bad of ['123', '1A', 'A1B', '9XYZ']) { const result = validateTickerFormat(bad); assert.equal(result.valid, false, `Expected "${bad}" to be rejected`); assert.match(result.error, /invalid ticker format/i); } }); test('test_too_long_ticker_rejected — 6+ letters rejected', () => { const result = validateTickerFormat('ABCDEF'); assert.equal(result.valid, false); assert.match(result.error, /invalid ticker format/i); }); test('reserved words rejected', () => { for (const word of ['TEST', 'NULL', 'NONE', 'HELP', 'NA']) { const result = validateTickerFormat(word); assert.equal(result.valid, false, `Expected "${word}" to be rejected as reserved`); assert.match(result.error, /not a stock ticker/i); } }); test('test_valid_format_triggers_remote_check — valid formats pass', () => { for (const good of ['AAPL', 'msft', ' TSLA ', 'A', 'GOOGL']) { const result = validateTickerFormat(good); assert.equal(result.valid, true, `Expected "${good}" to pass format check`); assert.equal(result.cleaned, good.trim().toUpperCase()); } }); test('index symbols pass format check', () => { for (const sym of ['^GSPC', '^DJI', '^IXIC', '^RUT', '^VIX']) { const result = validateTickerFormat(sym); assert.equal(result.valid, true, `Expected "${sym}" to pass format check`); } }); test('futures symbols pass format check', () => { for (const sym of ['CL=F', 'GC=F', 'SI=F', 'HG=F', 'NG=F']) { const result = validateTickerFormat(sym); assert.equal(result.valid, true, `Expected "${sym}" to pass format check`); } }); test('composite symbols pass format check', () => { const result = validateTickerFormat('DX-Y.NYB'); assert.equal(result.valid, true, 'Expected "DX-Y.NYB" to pass format check'); }); test('invalid special-looking inputs still rejected', () => { for (const bad of ['^', '=F', 'CL=X', '^^DJI', 'TOOLONGBASE=F']) { const result = validateTickerFormat(bad); assert.equal(result.valid, false, `Expected "${bad}" to be rejected`); } }); }); // --------------------------------------------------------------------------- // DOM behaviour tests (requires jsdom) // --------------------------------------------------------------------------- /** Build a minimal DOM that mirrors the search section in index.html */ function buildDOM() { const dom = new JSDOM(`
`, { runScripts: 'dangerously', pretendToBeVisual: true }); const { window } = dom; // Expose TickerValidation in the fake window before loading app logic window.TickerValidation = { validateTickerFormat, validateTickerRemote }; return { window, document: window.document }; } /** * Wire up a minimal version of the validation logic from app.js so we can * test component behaviour without importing the full 1 000-line app.js * (which has heavyweight dependencies like LightweightCharts). */ function wireValidation(window) { const { document } = window; const input = document.getElementById('ticker-input'); const analyzeBtn = document.getElementById('analyze-btn'); const clearBtn = document.getElementById('ticker-clear'); const validationHint = document.getElementById('validation-hint'); const validationMsgEl = document.getElementById('validation-msg'); const suggestionChips = document.getElementById('suggestion-chips'); let _validatedTicker = null; let _debounceTimer = null; let _abortController = null; function getValidatedTicker() { return _validatedTicker; } function _setInputState(state) { input.className = state ? `ticker-input--${state}` : ''; } function _showHint(text, type) { validationMsgEl.textContent = text; validationMsgEl.className = `validation-msg validation-msg--${type}`; validationHint.classList.remove('hidden'); } function _clearHint() { validationHint.classList.add('hidden'); validationMsgEl.textContent = ''; suggestionChips.innerHTML = ''; } function _renderSuggestions(suggestions) { suggestionChips.innerHTML = ''; if (!Array.isArray(suggestions)) return; suggestions.forEach((s) => { const chip = document.createElement('button'); chip.type = 'button'; chip.className = 'suggestion-chip'; chip.textContent = s; chip.addEventListener('click', () => { input.value = s; triggerValidation(s); }); suggestionChips.appendChild(chip); }); } async function triggerValidation(rawValue) { const val = String(rawValue || '').trim().toUpperCase(); if (_abortController) _abortController.abort(); _abortController = new window.AbortController(); const fmt = window.TickerValidation.validateTickerFormat(val); if (!fmt.valid) { _validatedTicker = null; analyzeBtn.disabled = true; _setInputState('error'); _showHint(fmt.error, 'error'); _renderSuggestions([]); return; } _validatedTicker = null; analyzeBtn.disabled = true; _setInputState('validating'); _clearHint(); const { signal } = _abortController; const result = await window.TickerValidation.validateTickerRemote(val, signal); if (!result) return; if (result.valid) { _validatedTicker = val; analyzeBtn.disabled = false; _setInputState('valid'); _showHint(`✓ ${result.company_name || val}`, 'success'); _renderSuggestions([]); } else { _setInputState('error'); _showHint(result.error || 'Not found.', 'error'); _renderSuggestions(result.suggestions || []); } } input.addEventListener('input', () => { input.value = input.value.toUpperCase(); clearBtn.classList.toggle('hidden', !input.value); const val = input.value.trim(); clearTimeout(_debounceTimer); const fmt = window.TickerValidation.validateTickerFormat(val); if (!fmt.valid) { if (_abortController) _abortController.abort(); _validatedTicker = null; analyzeBtn.disabled = true; _setInputState(val ? 'error' : ''); if (val) _showHint(fmt.error, 'error'); else _clearHint(); _renderSuggestions([]); return; } _validatedTicker = null; analyzeBtn.disabled = true; _setInputState('validating'); _clearHint(); _debounceTimer = setTimeout(() => triggerValidation(val), 500); }); clearBtn.addEventListener('click', () => { input.value = ''; _validatedTicker = null; analyzeBtn.disabled = true; clearTimeout(_debounceTimer); if (_abortController) _abortController.abort(); _setInputState(''); _clearHint(); clearBtn.classList.add('hidden'); }); return { input, analyzeBtn, clearBtn, validationHint, validationMsgEl, suggestionChips, triggerValidation, getValidatedTicker }; } // Helper: fire input event function fireInput(input, value) { input.value = value; input.dispatchEvent(new input.ownerDocument.defaultView.Event('input', { bubbles: true })); } describe('TickerInput DOM behaviour', () => { test('test_submit_disabled_until_valid — button starts disabled', () => { const { document } = buildDOM(); const btn = document.getElementById('analyze-btn'); assert.equal(btn.disabled, true); }); test('test_suggestion_click_fills_input — clicking chip fills input', async () => { const { window } = buildDOM(); // Mock validateTickerRemote to return suggestions for a bad ticker window.TickerValidation.validateTickerRemote = async () => ({ valid: false, error: 'Not found.', suggestions: ['AAPL', 'APD'], }); const { input, suggestionChips, triggerValidation } = wireValidation(window); await triggerValidation('AAPL1'); // invalid format handled locally // Use a format-valid but server-rejected ticker to get suggestions // Bypass format check by directly calling with a valid-format ticker // Override validateTickerFormat temporarily const original = window.TickerValidation.validateTickerFormat; window.TickerValidation.validateTickerFormat = () => ({ valid: true, cleaned: 'AAPX' }); await triggerValidation('AAPX'); window.TickerValidation.validateTickerFormat = original; const chips = suggestionChips.querySelectorAll('.suggestion-chip'); assert.ok(chips.length > 0, 'Expected suggestion chips to be rendered'); // Click the first chip — it should fill the input chips[0].click(); assert.equal(input.value, chips[0].textContent); }); test('test_submit_disabled_until_valid — button enabled after valid remote result', async () => { const { window } = buildDOM(); window.TickerValidation.validateTickerRemote = async () => ({ valid: true, ticker: 'AAPL', company_name: 'Apple Inc.', }); const { analyzeBtn, triggerValidation } = wireValidation(window); assert.equal(analyzeBtn.disabled, true, 'Should start disabled'); await triggerValidation('AAPL'); assert.equal(analyzeBtn.disabled, false, 'Should be enabled after valid result'); }); });