'use strict'; /** * Unit tests for the ticker autocomplete dropdown behaviour. * Run with: node --test tests/test_autocomplete.js * Requires: npm install (installs jsdom) */ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const { JSDOM } = require('jsdom'); // --------------------------------------------------------------------------- // Helpers to build a minimal DOM environment for each test // --------------------------------------------------------------------------- function buildDOM() { const dom = new JSDOM(`
`, { pretendToBeVisual: true }); return dom; } // Minimal standalone versions of the autocomplete helpers so we can unit-test // without loading the full app.js (which depends on many other DOM elements). function buildHelpers(doc) { const input = doc.getElementById('ticker-input'); const dropdown = doc.getElementById('ticker-dropdown'); const liveRegion = doc.getElementById('ticker-ac-live'); let _acActiveIndex = -1; let _acResults = []; function _hideDropdown() { dropdown.classList.add('hidden'); input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant'); _acActiveIndex = -1; _acResults = []; } function _highlightItem(index) { const items = dropdown.querySelectorAll('.ticker-dropdown-item'); _acActiveIndex = Math.max(-1, Math.min(index, items.length - 1)); items.forEach((el, i) => { const active = i === _acActiveIndex; el.setAttribute('aria-selected', active ? 'true' : 'false'); if (active) input.setAttribute('aria-activedescendant', el.id); }); if (_acActiveIndex === -1) input.removeAttribute('aria-activedescendant'); } function _selectItem(ticker) { _hideDropdown(); input.value = ticker; if (liveRegion) liveRegion.textContent = ticker + ' selected'; } function _renderDropdown(results) { dropdown.innerHTML = ''; _acResults = results || []; if (!_acResults.length) { _hideDropdown(); return; } _acResults.forEach((item, i) => { const li = doc.createElement('li'); li.id = 'ac-item-' + i; li.setAttribute('role', 'option'); li.className = 'ticker-dropdown-item'; li.setAttribute('aria-selected', 'false'); const ts = doc.createElement('span'); ts.className = 'ticker-dropdown-ticker'; ts.textContent = item.ticker; const ns = doc.createElement('span'); ns.className = 'ticker-dropdown-name'; ns.textContent = item.name || ''; li.appendChild(ts); li.appendChild(ns); li.addEventListener('mousedown', (e) => { e.preventDefault(); _selectItem(item.ticker); }); dropdown.appendChild(li); }); dropdown.classList.remove('hidden'); input.setAttribute('aria-expanded', 'true'); _acActiveIndex = -1; } return { _hideDropdown, _highlightItem, _selectItem, _renderDropdown, get acActiveIndex() { return _acActiveIndex; }, get acResults() { return _acResults; } }; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('autocomplete dropdown rendering', () => { test('test_render_shows_dropdown_with_results', () => { const { document } = buildDOM().window; const { _renderDropdown } = buildHelpers(document); const dropdown = document.getElementById('ticker-dropdown'); const input = document.getElementById('ticker-input'); _renderDropdown([ { ticker: 'AAPL', name: 'Apple Inc.' }, { ticker: 'AAMT', name: 'AAMT Corp' }, ]); assert.equal(dropdown.classList.contains('hidden'), false, 'dropdown should be visible after rendering results'); assert.equal(input.getAttribute('aria-expanded'), 'true'); assert.equal(dropdown.querySelectorAll('.ticker-dropdown-item').length, 2); }); test('test_render_empty_hides_dropdown', () => { const { document } = buildDOM().window; const { _renderDropdown } = buildHelpers(document); const dropdown = document.getElementById('ticker-dropdown'); _renderDropdown([]); assert.equal(dropdown.classList.contains('hidden'), true, 'dropdown should be hidden when results are empty'); }); test('test_item_has_ticker_and_name_spans', () => { const { document } = buildDOM().window; const { _renderDropdown } = buildHelpers(document); const dropdown = document.getElementById('ticker-dropdown'); _renderDropdown([{ ticker: 'NVDA', name: 'NVIDIA Corp' }]); const item = dropdown.querySelector('.ticker-dropdown-item'); assert.ok(item, 'item should exist'); assert.equal(item.querySelector('.ticker-dropdown-ticker').textContent, 'NVDA'); assert.equal(item.querySelector('.ticker-dropdown-name').textContent, 'NVIDIA Corp'); }); }); describe('autocomplete keyboard navigation', () => { test('test_arrow_down_highlights_first_item', () => { const { document } = buildDOM().window; const { _renderDropdown, _highlightItem, acActiveIndex } = buildHelpers(document); _renderDropdown([ { ticker: 'AAPL', name: 'Apple Inc.' }, { ticker: 'AAP', name: 'Advance Auto' }, ]); const helpers = buildHelpers(document); helpers._renderDropdown([ { ticker: 'AAPL', name: 'Apple Inc.' }, { ticker: 'AAP', name: 'Advance Auto' }, ]); helpers._highlightItem(0); const items = document.querySelectorAll('.ticker-dropdown-item'); // The second call to buildHelpers operated on the same DOM // so the last rendered item set is what matters for aria-selected // Re-query after highlight const firstItem = document.getElementById('ac-item-0'); // helpers._acActiveIndex should be 0 assert.equal(helpers.acActiveIndex, 0); }); test('test_select_item_fills_input_and_hides_dropdown', () => { const { document } = buildDOM().window; const helpers = buildHelpers(document); const dropdown = document.getElementById('ticker-dropdown'); const input = document.getElementById('ticker-input'); helpers._renderDropdown([{ ticker: 'MSFT', name: 'Microsoft Corp' }]); helpers._selectItem('MSFT'); assert.equal(input.value, 'MSFT'); assert.equal(dropdown.classList.contains('hidden'), true, 'dropdown should be hidden after selection'); }); });