Brajmovech commited on
Commit
6ff9f9f
·
1 Parent(s): d7694d8

feat: wire end-to-end validation flow with error banners and loading states

Browse files
static/app.js CHANGED
@@ -73,6 +73,114 @@ document.addEventListener('DOMContentLoaded', () => {
73
  let latestPredictedPrice = null;
74
  let latestAnalyzeHistory = [];
75
  let latestAnalyzeTimeframe = '6M';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  // --- Validation UI elements ---
77
  const inputWrapper = document.getElementById('ticker-input-wrapper');
78
  const clearBtn = document.getElementById('ticker-clear');
@@ -464,36 +572,93 @@ document.addEventListener('DOMContentLoaded', () => {
464
  params.set('period', mapped.period);
465
  params.set('interval', mapped.interval);
466
 
 
 
 
 
 
 
467
  setLoading(true);
 
468
  errorMsg.classList.add('hidden');
 
 
469
  if (!keepDashboardVisible) {
470
  dashboard.classList.add('hidden');
471
  }
472
 
473
  try {
474
- const response = await fetch(`/api/analyze?${params.toString()}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
475
  const data = await response.json();
476
  if (!response.ok) {
477
- throw new Error(data.error || 'Failed to fetch data');
 
 
 
 
 
478
  }
479
 
 
480
  dashboard.classList.remove('hidden');
481
  currentTicker = String(data?.meta?.symbol || normalizedTicker).toUpperCase();
482
  input.value = currentTicker;
483
- _validatedTicker = currentTicker; // keep in sync after successful analysis
484
  latestPredictedPrice = Number(data?.market?.predicted_price_next_session);
485
  latestAnalyzeHistory = normalizeHistoryPoints(data?.market?.history);
486
  latestAnalyzeTimeframe = getActiveTimeframe();
487
  updateDashboard(data);
488
  await refreshChartForTimeframe(currentTicker, getActiveTimeframe(), false);
 
489
  } catch (error) {
 
490
  console.error(error);
491
- errorMsg.textContent = error.message || 'Failed to fetch data';
492
- errorMsg.classList.remove('hidden');
493
  if (!keepDashboardVisible) {
494
  dashboard.classList.add('hidden');
495
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
496
  } finally {
 
 
497
  setLoading(false);
498
  }
499
  }
 
73
  let latestPredictedPrice = null;
74
  let latestAnalyzeHistory = [];
75
  let latestAnalyzeTimeframe = '6M';
76
+ // --- Analysis state / flow elements ---
77
+ const errorBanner = document.getElementById('error-banner');
78
+ const errorBannerMsg = document.getElementById('error-banner-msg');
79
+ const errorBannerChips = document.getElementById('error-banner-chips');
80
+ const errorBannerRetry = document.getElementById('error-banner-retry');
81
+ const errorBannerDismiss= document.getElementById('error-banner-dismiss');
82
+ const analysisProgress = document.getElementById('analysis-progress');
83
+ const analysisSkeleton = document.getElementById('analysis-skeleton');
84
+
85
+ let _retryTicker = null;
86
+ let _progressTimers = [];
87
+ let _rateLimitTimer = null;
88
+
89
+ function _showErrorBanner(message, suggestions, showRetry) {
90
+ if (!errorBanner) return;
91
+ if (errorBannerMsg) errorBannerMsg.textContent = message;
92
+ if (errorBannerChips) {
93
+ errorBannerChips.innerHTML = '';
94
+ if (Array.isArray(suggestions) && suggestions.length) {
95
+ suggestions.forEach((s) => {
96
+ const chip = document.createElement('button');
97
+ chip.type = 'button';
98
+ chip.className = 'suggestion-chip';
99
+ chip.textContent = s;
100
+ chip.addEventListener('click', () => {
101
+ _hideErrorBanner();
102
+ input.value = s;
103
+ _triggerValidation(s);
104
+ });
105
+ errorBannerChips.appendChild(chip);
106
+ });
107
+ }
108
+ }
109
+ if (errorBannerRetry) errorBannerRetry.classList.toggle('hidden', !showRetry);
110
+ errorBanner.classList.remove('hidden');
111
+ requestAnimationFrame(() => errorBanner.classList.add('is-visible'));
112
+ }
113
+
114
+ function _hideErrorBanner() {
115
+ if (!errorBanner) return;
116
+ errorBanner.classList.remove('is-visible');
117
+ setTimeout(() => errorBanner.classList.add('hidden'), 300);
118
+ }
119
+
120
+ function _clearProgressTimers() {
121
+ _progressTimers.forEach((t) => clearTimeout(t));
122
+ _progressTimers = [];
123
+ }
124
+
125
+ function _advanceProgress(step) {
126
+ if (!analysisProgress) return;
127
+ analysisProgress.querySelectorAll('.progress-step').forEach((el, i) => {
128
+ const n = i + 1;
129
+ el.className = n < step ? 'progress-step is-done'
130
+ : n === step ? 'progress-step is-active'
131
+ : 'progress-step';
132
+ });
133
+ }
134
+
135
+ function _showProgress() {
136
+ if (!analysisProgress) return;
137
+ _advanceProgress(1);
138
+ analysisProgress.classList.remove('hidden');
139
+ _progressTimers.push(setTimeout(() => _advanceProgress(2), 1000));
140
+ _progressTimers.push(setTimeout(() => _advanceProgress(3), 3000));
141
+ _progressTimers.push(setTimeout(() => _advanceProgress(4), 5000));
142
+ }
143
+
144
+ function _hideProgress() {
145
+ _clearProgressTimers();
146
+ if (!analysisProgress) return;
147
+ analysisProgress.classList.add('hidden');
148
+ analysisProgress.querySelectorAll('.progress-step').forEach((el) => {
149
+ el.className = 'progress-step';
150
+ });
151
+ }
152
+
153
+ function _showSkeleton() { if (analysisSkeleton) analysisSkeleton.classList.remove('hidden'); }
154
+ function _hideSkeleton() { if (analysisSkeleton) analysisSkeleton.classList.add('hidden'); }
155
+
156
+ function _startRateLimitCountdown(seconds) {
157
+ const endTime = Date.now() + seconds * 1000;
158
+ analyzeBtn.disabled = true;
159
+ function tick() {
160
+ const remaining = Math.ceil((endTime - Date.now()) / 1000);
161
+ if (remaining <= 0) {
162
+ if (btnText) btnText.textContent = 'Analyze Risk';
163
+ analyzeBtn.disabled = !_validatedTicker;
164
+ return;
165
+ }
166
+ if (btnText) btnText.textContent = `Wait ${remaining}s…`;
167
+ _rateLimitTimer = setTimeout(tick, 500);
168
+ }
169
+ tick();
170
+ }
171
+
172
+ if (errorBannerDismiss) {
173
+ errorBannerDismiss.addEventListener('click', _hideErrorBanner);
174
+ }
175
+ if (errorBannerRetry) {
176
+ errorBannerRetry.addEventListener('click', () => {
177
+ if (_retryTicker) {
178
+ _hideErrorBanner();
179
+ loadTickerData(_retryTicker, false);
180
+ }
181
+ });
182
+ }
183
+
184
  // --- Validation UI elements ---
185
  const inputWrapper = document.getElementById('ticker-input-wrapper');
186
  const clearBtn = document.getElementById('ticker-clear');
 
572
  params.set('period', mapped.period);
573
  params.set('interval', mapped.interval);
574
 
575
+ _retryTicker = normalizedTicker;
576
+
577
+ // 30-second hard timeout on the entire analysis
578
+ const timeoutCtrl = new AbortController();
579
+ const timeoutId = setTimeout(() => timeoutCtrl.abort(), 30000);
580
+
581
  setLoading(true);
582
+ _hideErrorBanner();
583
  errorMsg.classList.add('hidden');
584
+ _showProgress();
585
+ _showSkeleton();
586
  if (!keepDashboardVisible) {
587
  dashboard.classList.add('hidden');
588
  }
589
 
590
  try {
591
+ const response = await fetch(`/api/analyze?${params.toString()}`, {
592
+ signal: timeoutCtrl.signal,
593
+ });
594
+ clearTimeout(timeoutId);
595
+
596
+ if (response.status === 422) {
597
+ const body = await response.json().catch(() => ({}));
598
+ _showErrorBanner(
599
+ body.error || 'Invalid ticker. Please check the symbol.',
600
+ body.suggestions || [],
601
+ false
602
+ );
603
+ _setInputState('error');
604
+ _validatedTicker = null;
605
+ analyzeBtn.disabled = true;
606
+ return;
607
+ }
608
+
609
+ if (response.status === 429) {
610
+ _showErrorBanner(
611
+ "You're sending requests too quickly. Please wait a moment and try again.",
612
+ [],
613
+ false
614
+ );
615
+ _startRateLimitCountdown(10);
616
+ return;
617
+ }
618
+
619
  const data = await response.json();
620
  if (!response.ok) {
621
+ _showErrorBanner(
622
+ data.error || 'Something went wrong on our end. Please try again in a few seconds.',
623
+ [],
624
+ true
625
+ );
626
+ return;
627
  }
628
 
629
+ // Success
630
  dashboard.classList.remove('hidden');
631
  currentTicker = String(data?.meta?.symbol || normalizedTicker).toUpperCase();
632
  input.value = currentTicker;
633
+ _validatedTicker = currentTicker;
634
  latestPredictedPrice = Number(data?.market?.predicted_price_next_session);
635
  latestAnalyzeHistory = normalizeHistoryPoints(data?.market?.history);
636
  latestAnalyzeTimeframe = getActiveTimeframe();
637
  updateDashboard(data);
638
  await refreshChartForTimeframe(currentTicker, getActiveTimeframe(), false);
639
+
640
  } catch (error) {
641
+ clearTimeout(timeoutId);
642
  console.error(error);
 
 
643
  if (!keepDashboardVisible) {
644
  dashboard.classList.add('hidden');
645
  }
646
+ if (error.name === 'AbortError') {
647
+ _showErrorBanner(
648
+ 'The analysis is taking longer than expected. Please try again.',
649
+ [],
650
+ true
651
+ );
652
+ } else {
653
+ _showErrorBanner(
654
+ 'Something went wrong on our end. Please try again in a few seconds.',
655
+ [],
656
+ true
657
+ );
658
+ }
659
  } finally {
660
+ _hideSkeleton();
661
+ _hideProgress();
662
  setLoading(false);
663
  }
664
  }
static/style.css CHANGED
@@ -1326,4 +1326,174 @@ footer p {
1326
  background: var(--accent-blue);
1327
  color: var(--text-inverse);
1328
  box-shadow: 0 0 0 2px var(--accent-blue-glow);
1329
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1326
  background: var(--accent-blue);
1327
  color: var(--text-inverse);
1328
  box-shadow: 0 0 0 2px var(--accent-blue-glow);
1329
+ }
1330
+
1331
+ /* ============================================================
1332
+ Error banner
1333
+ ============================================================ */
1334
+
1335
+ @keyframes slideDown {
1336
+ from { opacity: 0; transform: translateY(-10px); }
1337
+ to { opacity: 1; transform: translateY(0); }
1338
+ }
1339
+ @keyframes slideUp {
1340
+ from { opacity: 1; transform: translateY(0); }
1341
+ to { opacity: 0; transform: translateY(-10px); }
1342
+ }
1343
+
1344
+ .error-banner {
1345
+ display: flex;
1346
+ align-items: flex-start;
1347
+ justify-content: space-between;
1348
+ gap: 1rem;
1349
+ max-width: 700px;
1350
+ margin: 0 auto 1.25rem;
1351
+ padding: 0.9rem 1.2rem;
1352
+ border-radius: 12px;
1353
+ background: rgba(182, 78, 90, 0.12);
1354
+ border: 1px solid var(--status-red);
1355
+ color: var(--status-red);
1356
+ opacity: 0;
1357
+ transform: translateY(-10px);
1358
+ transition: opacity 0.28s ease, transform 0.28s ease;
1359
+ }
1360
+ .error-banner.is-visible {
1361
+ opacity: 1;
1362
+ transform: translateY(0);
1363
+ }
1364
+ .error-banner-body {
1365
+ flex: 1;
1366
+ min-width: 0;
1367
+ }
1368
+ .error-banner-text {
1369
+ display: block;
1370
+ font-size: 0.93rem;
1371
+ font-weight: 600;
1372
+ margin-bottom: 0.35rem;
1373
+ }
1374
+ .error-banner-actions {
1375
+ display: flex;
1376
+ align-items: center;
1377
+ gap: 0.5rem;
1378
+ flex-shrink: 0;
1379
+ }
1380
+ .error-banner-retry {
1381
+ padding: 0.3rem 0.8rem;
1382
+ border-radius: 8px;
1383
+ border: 1px solid var(--status-red);
1384
+ background: transparent;
1385
+ color: var(--status-red);
1386
+ font-size: 0.82rem;
1387
+ font-weight: 600;
1388
+ cursor: pointer;
1389
+ transition: background 0.2s ease;
1390
+ }
1391
+ .error-banner-retry:hover {
1392
+ background: var(--status-red);
1393
+ color: var(--text-inverse);
1394
+ }
1395
+ .error-banner-dismiss {
1396
+ background: none;
1397
+ border: none;
1398
+ cursor: pointer;
1399
+ color: var(--status-red);
1400
+ font-size: 1.3rem;
1401
+ line-height: 1;
1402
+ padding: 0.1rem 0.2rem;
1403
+ opacity: 0.7;
1404
+ transition: opacity 0.2s ease;
1405
+ }
1406
+ .error-banner-dismiss:hover { opacity: 1; }
1407
+
1408
+ /* ============================================================
1409
+ Analysis progress steps
1410
+ ============================================================ */
1411
+
1412
+ .analysis-progress {
1413
+ display: flex;
1414
+ align-items: center;
1415
+ justify-content: center;
1416
+ flex-wrap: wrap;
1417
+ gap: 0 0.5rem;
1418
+ max-width: 700px;
1419
+ margin: 0 auto 1.25rem;
1420
+ padding: 0.8rem 1.2rem;
1421
+ border-radius: 12px;
1422
+ background: var(--bg-subtle);
1423
+ border: 1px solid var(--panel-border);
1424
+ }
1425
+
1426
+ .progress-step {
1427
+ display: flex;
1428
+ align-items: center;
1429
+ gap: 0.35rem;
1430
+ padding: 0.25rem 0.6rem;
1431
+ border-radius: 999px;
1432
+ font-size: 0.82rem;
1433
+ font-weight: 500;
1434
+ color: var(--text-muted);
1435
+ transition: color 0.3s ease;
1436
+ }
1437
+ .progress-step + .progress-step::before {
1438
+ content: '→';
1439
+ color: var(--text-muted);
1440
+ font-size: 0.75rem;
1441
+ margin-right: 0.5rem;
1442
+ }
1443
+ .progress-step.is-done {
1444
+ color: var(--status-green);
1445
+ }
1446
+ .progress-step.is-active {
1447
+ color: var(--accent-blue);
1448
+ font-weight: 700;
1449
+ }
1450
+ .prog-icon {
1451
+ font-size: 0.8rem;
1452
+ }
1453
+
1454
+ /* ============================================================
1455
+ Skeleton loader
1456
+ ============================================================ */
1457
+
1458
+ @keyframes shimmer {
1459
+ 0% { background-position: -400px 0; }
1460
+ 100% { background-position: 400px 0; }
1461
+ }
1462
+
1463
+ .skeleton-grid {
1464
+ display: grid;
1465
+ grid-template-columns: repeat(3, 1fr);
1466
+ gap: 1.25rem;
1467
+ max-width: 700px;
1468
+ margin: 0 auto 1.5rem;
1469
+ }
1470
+ @media (max-width: 640px) {
1471
+ .skeleton-grid { grid-template-columns: 1fr; }
1472
+ }
1473
+
1474
+ .skeleton-card {
1475
+ border-radius: 12px;
1476
+ border: 1px solid var(--panel-border);
1477
+ background: var(--card-bg);
1478
+ padding: 1.2rem;
1479
+ min-height: 120px;
1480
+ display: flex;
1481
+ flex-direction: column;
1482
+ gap: 0.75rem;
1483
+ }
1484
+ .skeleton-card--tall { min-height: 180px; }
1485
+
1486
+ .skeleton-line,
1487
+ .skeleton-block {
1488
+ border-radius: 6px;
1489
+ background: linear-gradient(
1490
+ 90deg,
1491
+ var(--bg-subtle) 25%,
1492
+ var(--panel-border) 50%,
1493
+ var(--bg-subtle) 75%
1494
+ );
1495
+ background-size: 400px 100%;
1496
+ animation: shimmer 1.4s ease-in-out infinite;
1497
+ }
1498
+ .skeleton-line { height: 14px; width: 55%; }
1499
+ .skeleton-block { height: 48px; }
templates/index.html CHANGED
@@ -71,6 +71,34 @@
71
  <div id="error-message" class="error-msg hidden"></div>
72
  </section>
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  <!-- Dashboard Results (Hidden by default) -->
75
  <section id="results-dashboard" class="dashboard hidden">
76
  <div class="dashboard-header">
 
71
  <div id="error-message" class="error-msg hidden"></div>
72
  </section>
73
 
74
+ <!-- Error Banner (shown on 422 / 429 / 500 / timeout) -->
75
+ <div id="error-banner" class="error-banner hidden" role="alert" aria-live="assertive">
76
+ <div class="error-banner-body">
77
+ <span id="error-banner-msg" class="error-banner-text"></span>
78
+ <div id="error-banner-chips" class="suggestion-chips"></div>
79
+ </div>
80
+ <div class="error-banner-actions">
81
+ <button id="error-banner-retry" class="error-banner-retry hidden" type="button">↺ Retry</button>
82
+ <button id="error-banner-dismiss" class="error-banner-dismiss" type="button" aria-label="Dismiss">×</button>
83
+ </div>
84
+ </div>
85
+
86
+ <!-- Analysis progress + skeleton (shown while loading) -->
87
+ <div id="analysis-progress" class="analysis-progress hidden" aria-live="polite">
88
+ <div class="progress-step" id="prog-step-1"><span class="prog-icon">✓</span><span class="prog-label">Ticker validated</span></div>
89
+ <div class="progress-step" id="prog-step-2"><span class="prog-icon">●</span><span class="prog-label">Fetching market data…</span></div>
90
+ <div class="progress-step" id="prog-step-3"><span class="prog-icon">○</span><span class="prog-label">Running risk analysis</span></div>
91
+ <div class="progress-step" id="prog-step-4"><span class="prog-icon">○</span><span class="prog-label">Generating report</span></div>
92
+ </div>
93
+
94
+ <div id="analysis-skeleton" class="analysis-skeleton hidden" aria-hidden="true">
95
+ <div class="skeleton-grid">
96
+ <div class="skeleton-card"><div class="skeleton-line"></div><div class="skeleton-block"></div></div>
97
+ <div class="skeleton-card skeleton-card--tall"><div class="skeleton-line"></div><div class="skeleton-block"></div><div class="skeleton-block"></div></div>
98
+ <div class="skeleton-card"><div class="skeleton-line"></div><div class="skeleton-block"></div></div>
99
+ </div>
100
+ </div>
101
+
102
  <!-- Dashboard Results (Hidden by default) -->
103
  <section id="results-dashboard" class="dashboard hidden">
104
  <div class="dashboard-header">
tests/test_validation_flow.js ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+ /**
3
+ * Integration tests for the end-to-end validation → analysis flow.
4
+ * Tests the frontend behaviour when the backend returns 200 / 422 / 429 / 5xx.
5
+ *
6
+ * Run with: node --test tests/test_validation_flow.js
7
+ */
8
+
9
+ const { test, describe, beforeEach } = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+ const path = require('node:path');
12
+ const { JSDOM } = require('jsdom');
13
+
14
+ const { validateTickerFormat, validateTickerRemote } = require(
15
+ path.join(__dirname, '..', 'static', 'tickerValidation.js')
16
+ );
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Minimal DOM that mirrors the relevant parts of index.html
20
+ // ---------------------------------------------------------------------------
21
+ function buildDOM() {
22
+ return new JSDOM(`<!DOCTYPE html><html><body>
23
+ <form id="analyze-form">
24
+ <div class="search-box">
25
+ <div id="ticker-input-wrapper">
26
+ <input id="ticker-input" type="text" />
27
+ <button id="ticker-clear" class="hidden" type="button">×</button>
28
+ <span id="ticker-val-indicator" class="hidden"></span>
29
+ </div>
30
+ <button id="analyze-btn" type="submit" disabled>
31
+ <span class="btn-text">Analyze Risk</span>
32
+ </button>
33
+ </div>
34
+ </form>
35
+ <div id="validation-hint" class="hidden">
36
+ <span id="validation-msg"></span>
37
+ <div id="suggestion-chips"></div>
38
+ </div>
39
+ <div id="error-banner" class="hidden"><div id="error-banner-body">
40
+ <span id="error-banner-msg"></span>
41
+ <div id="error-banner-chips"></div>
42
+ </div>
43
+ <button id="error-banner-retry" class="hidden" type="button">Retry</button>
44
+ <button id="error-banner-dismiss" type="button">×</button>
45
+ </div>
46
+ <div id="analysis-progress" class="hidden">
47
+ <div class="progress-step" id="prog-step-1"></div>
48
+ <div class="progress-step" id="prog-step-2"></div>
49
+ <div class="progress-step" id="prog-step-3"></div>
50
+ <div class="progress-step" id="prog-step-4"></div>
51
+ </div>
52
+ <div id="analysis-skeleton" class="hidden"></div>
53
+ <section id="results-dashboard" class="dashboard hidden"></section>
54
+ <div id="error-message" class="hidden"></div>
55
+ </body></html>`, { pretendToBeVisual: true });
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Wire the analysis-flow logic (mirrors app.js without the full module)
60
+ // ---------------------------------------------------------------------------
61
+ function wireFlow(window, mockFetch) {
62
+ const { document } = window;
63
+ window.TickerValidation = { validateTickerFormat, validateTickerRemote };
64
+ window.fetch = mockFetch;
65
+
66
+ const input = document.getElementById('ticker-input');
67
+ const analyzeBtn = document.getElementById('analyze-btn');
68
+ const btnText = analyzeBtn.querySelector('.btn-text');
69
+ const dashboard = document.getElementById('results-dashboard');
70
+ const errorBanner = document.getElementById('error-banner');
71
+ const errorBannerMsg = document.getElementById('error-banner-msg');
72
+ const errorBannerChips = document.getElementById('error-banner-chips');
73
+ const errorBannerRetry = document.getElementById('error-banner-retry');
74
+ const analysisSkeleton = document.getElementById('analysis-skeleton');
75
+ const analysisProgress = document.getElementById('analysis-progress');
76
+
77
+ let _retryTicker = null;
78
+ let _validatedTicker = null;
79
+ let _progressTimers = [];
80
+ let _rateLimitTimer = null;
81
+
82
+ function _showErrorBanner(message, suggestions, showRetry) {
83
+ if (errorBannerMsg) errorBannerMsg.textContent = message;
84
+ if (errorBannerChips) {
85
+ errorBannerChips.innerHTML = '';
86
+ (suggestions || []).forEach((s) => {
87
+ const chip = document.createElement('button');
88
+ chip.type = 'button';
89
+ chip.className = 'suggestion-chip';
90
+ chip.textContent = s;
91
+ chip.addEventListener('click', () => {
92
+ input.value = s;
93
+ // In real app this calls _triggerValidation(s).
94
+ // Here we emit a custom event so tests can observe it.
95
+ input.dispatchEvent(new window.CustomEvent('validation-triggered', { detail: s }));
96
+ });
97
+ errorBannerChips.appendChild(chip);
98
+ });
99
+ }
100
+ if (errorBannerRetry) errorBannerRetry.classList.toggle('hidden', !showRetry);
101
+ errorBanner.classList.remove('hidden');
102
+ errorBanner.classList.add('is-visible');
103
+ }
104
+
105
+ function _hideErrorBanner() {
106
+ errorBanner.classList.remove('is-visible');
107
+ errorBanner.classList.add('hidden');
108
+ }
109
+
110
+ function _showProgress() {
111
+ analysisProgress.classList.remove('hidden');
112
+ // Advance immediately to step 1
113
+ analysisProgress.querySelectorAll('.progress-step').forEach((el, i) => {
114
+ el.className = i === 0 ? 'progress-step is-active' : 'progress-step';
115
+ });
116
+ }
117
+
118
+ function _hideProgress() {
119
+ _progressTimers.forEach((t) => clearTimeout(t));
120
+ _progressTimers = [];
121
+ analysisProgress.classList.add('hidden');
122
+ }
123
+
124
+ function _showSkeleton() { analysisSkeleton.classList.remove('hidden'); }
125
+ function _hideSkeleton() { analysisSkeleton.classList.add('hidden'); }
126
+
127
+ function _startRateLimitCountdown(seconds) {
128
+ analyzeBtn.disabled = true;
129
+ const endTime = Date.now() + seconds * 1000;
130
+ function tick() {
131
+ const remaining = Math.ceil((endTime - Date.now()) / 1000);
132
+ if (remaining <= 0) {
133
+ if (btnText) btnText.textContent = 'Analyze Risk';
134
+ analyzeBtn.disabled = false;
135
+ return;
136
+ }
137
+ if (btnText) btnText.textContent = `Wait ${remaining}s…`;
138
+ _rateLimitTimer = setTimeout(tick, 500);
139
+ }
140
+ tick();
141
+ }
142
+
143
+ async function runAnalysis(ticker) {
144
+ const normalizedTicker = String(ticker || '').trim().toUpperCase();
145
+ _retryTicker = normalizedTicker;
146
+
147
+ const timeoutCtrl = new window.AbortController();
148
+ const timeoutId = setTimeout(() => timeoutCtrl.abort(), 30000);
149
+
150
+ analyzeBtn.disabled = true;
151
+ _hideErrorBanner();
152
+ _showProgress();
153
+ _showSkeleton();
154
+ dashboard.classList.add('hidden');
155
+
156
+ try {
157
+ const response = await window.fetch(`/api/analyze?ticker=${normalizedTicker}`, {
158
+ signal: timeoutCtrl.signal,
159
+ });
160
+ clearTimeout(timeoutId);
161
+
162
+ if (response.status === 422) {
163
+ const body = await response.json().catch(() => ({}));
164
+ _showErrorBanner(body.error || 'Invalid ticker.', body.suggestions || [], false);
165
+ return;
166
+ }
167
+
168
+ if (response.status === 429) {
169
+ _showErrorBanner(
170
+ "You're sending requests too quickly. Please wait a moment and try again.",
171
+ [], false
172
+ );
173
+ _startRateLimitCountdown(10);
174
+ return;
175
+ }
176
+
177
+ const data = await response.json();
178
+ if (!response.ok) {
179
+ _showErrorBanner(data.error || 'Something went wrong.', [], true);
180
+ return;
181
+ }
182
+
183
+ // Success
184
+ _hideSkeleton();
185
+ _hideProgress();
186
+ dashboard.classList.remove('hidden');
187
+ _validatedTicker = normalizedTicker;
188
+ analyzeBtn.disabled = false;
189
+
190
+ } catch (err) {
191
+ clearTimeout(timeoutId);
192
+ dashboard.classList.add('hidden');
193
+ if (err.name === 'AbortError') {
194
+ _showErrorBanner('The analysis is taking longer than expected. Please try again.', [], true);
195
+ } else {
196
+ _showErrorBanner('Something went wrong on our end. Please try again in a few seconds.', [], true);
197
+ }
198
+ } finally {
199
+ _hideSkeleton();
200
+ _hideProgress();
201
+ }
202
+ }
203
+
204
+ return { runAnalysis, input, analyzeBtn, btnText, dashboard, errorBanner,
205
+ errorBannerMsg, errorBannerChips, errorBannerRetry };
206
+ }
207
+
208
+ // Helpers
209
+ function makeFetch(status, body) {
210
+ return async () => ({
211
+ status,
212
+ ok: status >= 200 && status < 300,
213
+ json: async () => body,
214
+ });
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Tests
219
+ // ---------------------------------------------------------------------------
220
+
221
+ describe('End-to-end validation flow', () => {
222
+
223
+ test('test_valid_ticker_shows_analysis_result — 200 renders dashboard', async () => {
224
+ const { window } = buildDOM();
225
+ const { runAnalysis, dashboard, errorBanner } = wireFlow(window, makeFetch(200, {
226
+ meta: { symbol: 'AAPL' },
227
+ market: { predicted_price_next_session: 200, history: [] },
228
+ }));
229
+
230
+ await runAnalysis('AAPL');
231
+
232
+ assert.ok(!dashboard.classList.contains('hidden'),
233
+ 'Dashboard should be visible after 200 response');
234
+ assert.ok(errorBanner.classList.contains('hidden'),
235
+ 'Error banner should remain hidden after success');
236
+ });
237
+
238
+ test('test_invalid_ticker_shows_error_banner — 422 shows banner with message', async () => {
239
+ const { window } = buildDOM();
240
+ const { runAnalysis, errorBanner, errorBannerMsg, errorBannerChips } = wireFlow(
241
+ window,
242
+ makeFetch(422, {
243
+ valid: false,
244
+ error: 'Ticker "XYZQW" was not found. Please check the symbol and try again.',
245
+ suggestions: ['XYZ', 'XYZX'],
246
+ })
247
+ );
248
+
249
+ await runAnalysis('XYZQW');
250
+
251
+ assert.ok(!errorBanner.classList.contains('hidden'),
252
+ 'Error banner should be visible after 422');
253
+ assert.ok(errorBannerMsg.textContent.includes('not found'),
254
+ 'Banner should display the error message');
255
+ const chips = errorBannerChips.querySelectorAll('.suggestion-chip');
256
+ assert.equal(chips.length, 2, 'Should render suggestion chips');
257
+ });
258
+
259
+ test('test_rate_limit_shows_cooldown — 429 disables button with countdown', async () => {
260
+ const { window } = buildDOM();
261
+ const { runAnalysis, analyzeBtn, errorBanner, errorBannerMsg } = wireFlow(
262
+ window,
263
+ makeFetch(429, {})
264
+ );
265
+
266
+ await runAnalysis('AAPL');
267
+
268
+ assert.ok(!errorBanner.classList.contains('hidden'),
269
+ 'Error banner should show on 429');
270
+ assert.ok(errorBannerMsg.textContent.includes('too quickly'),
271
+ 'Message should mention rate limiting');
272
+ assert.ok(analyzeBtn.disabled,
273
+ 'Submit button should be disabled during cooldown');
274
+ });
275
+
276
+ test('test_suggestion_click_in_banner_retriggers_validation — chip click fires event', async () => {
277
+ const { window } = buildDOM();
278
+ const { runAnalysis, input, errorBannerChips } = wireFlow(
279
+ window,
280
+ makeFetch(422, {
281
+ valid: false,
282
+ error: 'Not found.',
283
+ suggestions: ['AAPL', 'APD'],
284
+ })
285
+ );
286
+
287
+ let triggeredTicker = null;
288
+ input.addEventListener('validation-triggered', (e) => {
289
+ triggeredTicker = e.detail;
290
+ });
291
+
292
+ await runAnalysis('AAPX');
293
+
294
+ const chip = errorBannerChips.querySelector('.suggestion-chip');
295
+ assert.ok(chip, 'Suggestion chip should be rendered');
296
+ chip.click();
297
+
298
+ assert.equal(input.value, chip.textContent,
299
+ 'Input should be filled with the clicked suggestion');
300
+ assert.equal(triggeredTicker, chip.textContent,
301
+ 'Clicking suggestion should trigger validation for that ticker');
302
+ });
303
+ });