Spaces:
Running
Running
Commit ·
6ff9f9f
1
Parent(s): d7694d8
feat: wire end-to-end validation flow with error banners and loading states
Browse files- static/app.js +170 -5
- static/style.css +171 -1
- templates/index.html +28 -0
- tests/test_validation_flow.js +303 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
}
|
| 479 |
|
|
|
|
| 480 |
dashboard.classList.remove('hidden');
|
| 481 |
currentTicker = String(data?.meta?.symbol || normalizedTicker).toUpperCase();
|
| 482 |
input.value = currentTicker;
|
| 483 |
-
_validatedTicker = currentTicker;
|
| 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 |
+
});
|