test: add coverage for finance/trending/reload and stabilize map harness

This commit is contained in:
Elie Habib
2026-02-17 19:16:23 +04:00
parent e0c6aa32be
commit 54f1a5d578
13 changed files with 616 additions and 48 deletions

View File

@@ -0,0 +1,119 @@
import { expect, test } from '@playwright/test';
test.describe('GCC investments coverage', () => {
test('focusInvestmentOnMap enables layer and recenters map', async ({ page }) => {
await page.goto('/tests/runtime-harness.html');
const result = await page.evaluate(async () => {
const { focusInvestmentOnMap } = await import('/src/services/investments-focus.ts');
const calls: { layers: string[]; center: { lat: number; lon: number; zoom: number } | null } = {
layers: [],
center: null,
};
const map = {
enableLayer: (layer: string) => {
calls.layers.push(layer);
},
setCenter: (lat: number, lon: number, zoom: number) => {
calls.center = { lat, lon, zoom };
},
};
const mapLayers = { gulfInvestments: false };
focusInvestmentOnMap(
map as unknown as {
enableLayer: (layer: 'gulfInvestments') => void;
setCenter: (lat: number, lon: number, zoom: number) => void;
},
mapLayers as unknown as { gulfInvestments: boolean } & Record<string, boolean>,
24.4667,
54.3667
);
return {
layers: calls.layers,
center: calls.center,
gulfInvestmentsEnabled: mapLayers.gulfInvestments,
};
});
expect(result.layers).toEqual(['gulfInvestments']);
expect(result.center).toEqual({ lat: 24.4667, lon: 54.3667, zoom: 6 });
expect(result.gulfInvestmentsEnabled).toBe(true);
});
test('InvestmentsPanel supports search/filter/sort and row click callbacks', async ({ page }) => {
await page.goto('/tests/runtime-harness.html');
const result = await page.evaluate(async () => {
const { InvestmentsPanel } = await import('/src/components/InvestmentsPanel.ts');
const { GULF_INVESTMENTS } = await import('/src/config/gulf-fdi.ts');
const clickedIds: string[] = [];
const panel = new InvestmentsPanel((inv) => {
clickedIds.push(inv.id);
});
document.body.appendChild(panel.getElement());
const root = panel.getElement();
const totalRows = root.querySelectorAll('.fdi-row').length;
const firstInvestment = GULF_INVESTMENTS[0];
const searchToken = firstInvestment?.assetName.split(/\s+/)[0]?.toLowerCase() ?? '';
const searchInput = root.querySelector<HTMLInputElement>('.fdi-search');
searchInput!.value = searchToken;
searchInput!.dispatchEvent(new Event('input', { bubbles: true }));
const searchRows = root.querySelectorAll('.fdi-row').length;
searchInput!.value = '';
searchInput!.dispatchEvent(new Event('input', { bubbles: true }));
const countrySelect = root.querySelector<HTMLSelectElement>(
'.fdi-filter[data-filter="investingCountry"]'
);
countrySelect!.value = 'SA';
countrySelect!.dispatchEvent(new Event('change', { bubbles: true }));
const saRows = root.querySelectorAll('.fdi-row').length;
const expectedSaRows = GULF_INVESTMENTS.filter((inv) => inv.investingCountry === 'SA').length;
const investmentSort = root.querySelector<HTMLElement>('.fdi-sort[data-sort="investmentUSD"]');
investmentSort!.click(); // asc
investmentSort!.click(); // desc
const firstRow = root.querySelector<HTMLElement>('.fdi-row');
const firstRowId = firstRow?.dataset.id ?? null;
const expectedTopSaId = GULF_INVESTMENTS
.filter((inv) => inv.investingCountry === 'SA')
.slice()
.sort((a, b) => (b.investmentUSD ?? -1) - (a.investmentUSD ?? -1))[0]?.id ?? null;
firstRow?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
panel.destroy();
root.remove();
return {
totalRows,
datasetSize: GULF_INVESTMENTS.length,
searchRows,
saRows,
expectedSaRows,
firstRowId,
expectedTopSaId,
clickedId: clickedIds[0] ?? null,
};
});
expect(result.totalRows).toBe(result.datasetSize);
expect(result.searchRows).toBeGreaterThan(0);
expect(result.searchRows).toBeLessThanOrEqual(result.totalRows);
expect(result.saRows).toBe(result.expectedSaRows);
expect(result.firstRowId).toBe(result.expectedTopSaId);
expect(result.clickedId).toBe(result.firstRowId);
});
});

View File

@@ -104,4 +104,94 @@ test.describe('keyword spike modal/badge flow', () => {
delete (window as unknown as Record<string, unknown>).__keywordSpikeTest;
});
});
test('does not emit spikes from source-attribution suffixes', async ({ page }) => {
await page.goto('/tests/runtime-harness.html');
const result = await page.evaluate(async () => {
const trending = await import('/src/services/trending-keywords.ts');
const previousConfig = trending.getTrendingConfig();
try {
trending.updateTrendingConfig({
blockedTerms: [],
minSpikeCount: 4,
spikeMultiplier: 3,
autoSummarize: false,
});
const now = new Date();
const headlines = [
{ source: 'Reuters', title: 'Qzxalpha ventures stabilize - WireDesk' },
{ source: 'AP', title: 'Bravotango liquidity trims - WireDesk' },
{ source: 'BBC', title: 'Cindelta refinery expands - WireDesk' },
{ source: 'Bloomberg', title: 'Dorion transit reroutes - WireDesk' },
{ source: 'WSJ', title: 'Epsiluna lending reprices - WireDesk' },
].map((item) => ({ ...item, pubDate: now }));
trending.ingestHeadlines(headlines);
let spikes = trending.drainTrendingSignals();
for (let i = 0; i < 20 && spikes.length === 0; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 40));
spikes = trending.drainTrendingSignals();
}
return {
emittedTitles: spikes.map((signal) => signal.title),
hasWireDeskSpike: spikes.some((signal) => /wiredesk/i.test(signal.title)),
};
} finally {
trending.updateTrendingConfig(previousConfig);
}
});
expect(result.hasWireDeskSpike).toBe(false);
expect(result.emittedTitles.length).toBe(0);
});
test('suppresses month-name token spikes', async ({ page }) => {
await page.goto('/tests/runtime-harness.html');
const result = await page.evaluate(async () => {
const trending = await import('/src/services/trending-keywords.ts');
const previousConfig = trending.getTrendingConfig();
try {
trending.updateTrendingConfig({
blockedTerms: [],
minSpikeCount: 4,
spikeMultiplier: 3,
autoSummarize: false,
});
const now = new Date();
const headlines = [
{ source: 'Reuters', title: 'January qxavon ledger shift' },
{ source: 'AP', title: 'January brivon routing update' },
{ source: 'BBC', title: 'January caldren supply note' },
{ source: 'Bloomberg', title: 'January dernix cargo brief' },
{ source: 'WSJ', title: 'January eptara policy digest' },
].map((item) => ({ ...item, pubDate: now }));
trending.ingestHeadlines(headlines);
let spikes = trending.drainTrendingSignals();
for (let i = 0; i < 20 && spikes.length === 0; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 40));
spikes = trending.drainTrendingSignals();
}
return {
emittedTitles: spikes.map((signal) => signal.title),
hasJanuarySpike: spikes.some((signal) => /january/i.test(signal.title)),
};
} finally {
trending.updateTrendingConfig(previousConfig);
}
});
expect(result.hasJanuarySpike).toBe(false);
expect(result.emittedTitles.length).toBe(0);
});
});

View File

@@ -123,6 +123,7 @@ const EXPECTED_FINANCE_DECK_LAYERS = [
'financial-centers-layer',
'central-banks-layer',
'commodity-hubs-layer',
'gulf-investments-layer',
];
const waitForHarnessReady = async (
@@ -164,6 +165,8 @@ const prepareVisualScenario = async (
};
test.describe('DeckGL map harness', () => {
test.describe.configure({ retries: 1 });
test('serves requested runtime variant for this test run', async ({ page }) => {
await waitForHarnessReady(page);
@@ -299,6 +302,32 @@ test.describe('DeckGL map harness', () => {
}
});
test('renders GCC investments layer when enabled in finance variant', async ({ page }) => {
await waitForHarnessReady(page);
const variant = await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.variant ?? 'full';
});
test.skip(variant !== 'finance', 'Finance variant only');
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.seedAllDynamicData();
w.__mapHarness?.setLayersForSnapshot(['gulfInvestments']);
w.__mapHarness?.setCamera({ lon: 55.27, lat: 25.2, zoom: 4.2 });
});
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerDataCount('gulf-investments-layer') ?? 0;
});
}, { timeout: 15000 })
.toBeGreaterThan(0);
});
test('sanitizes cyber threat tooltip content', async ({ page }) => {
await waitForHarnessReady(page);
@@ -342,44 +371,16 @@ test.describe('DeckGL map harness', () => {
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.seedAllDynamicData();
w.__mapHarness?.setHotspotActivityScenario('none');
w.__mapHarness?.setPulseProtestsScenario('none');
w.__mapHarness?.setNewsPulseScenario('none');
w.__mapHarness?.forcePulseStartupElapsed();
w.__mapHarness?.setNewsPulseScenario('recent');
});
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
});
}, { timeout: 10000 })
.toBe(true);
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.setNewsPulseScenario('stale');
w.__mapHarness?.setHotspotActivityScenario('none');
w.__mapHarness?.setPulseProtestsScenario('none');
});
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
});
}, { timeout: 10000 })
.toBe(false);
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.setPulseProtestsScenario('recent-gdelt-riot');
});
await page.waitForTimeout(800);
await page.waitForTimeout(600);
const gdeltPulseRunning = await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
@@ -397,8 +398,25 @@ test.describe('DeckGL map harness', () => {
const w = window as HarnessWindow;
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
});
}, { timeout: 10000 })
}, { timeout: 15000 })
.toBe(true);
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.resetPulseStartupTime();
w.__mapHarness?.setNewsPulseScenario('none');
w.__mapHarness?.setHotspotActivityScenario('none');
w.__mapHarness?.setPulseProtestsScenario('none');
});
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
});
}, { timeout: 12000 })
.toBe(false);
});
test('matches golden screenshots per layer and zoom', async ({ page }) => {

View File

@@ -152,6 +152,79 @@ test.describe('desktop runtime routing guardrails', () => {
expect(result.calls.some((url) => url.includes('worldmonitor.app/api/stablecoin-markets'))).toBe(true);
});
test('chunk preload reload guard is one-shot until app boot clears it', async ({ page }) => {
await page.goto('/tests/runtime-harness.html');
const result = await page.evaluate(async () => {
const {
buildChunkReloadStorageKey,
installChunkReloadGuard,
clearChunkReloadGuard,
} = await import('/src/bootstrap/chunk-reload.ts');
const listeners = new Map<string, Array<() => void>>();
const eventTarget = {
addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => {
const list = listeners.get(type) ?? [];
list.push(() => {
if (typeof listener === 'function') {
listener(new Event(type));
} else {
listener.handleEvent(new Event(type));
}
});
listeners.set(type, list);
},
};
const storageMap = new Map<string, string>();
const storage = {
getItem: (key: string) => storageMap.get(key) ?? null,
setItem: (key: string, value: string) => {
storageMap.set(key, value);
},
removeItem: (key: string) => {
storageMap.delete(key);
},
};
const emit = (eventName: string) => {
const handlers = listeners.get(eventName) ?? [];
handlers.forEach((handler) => handler());
};
let reloadCount = 0;
const storageKey = installChunkReloadGuard('9.9.9', {
eventTarget,
storage,
eventName: 'preload-error',
reload: () => {
reloadCount += 1;
},
});
emit('preload-error');
emit('preload-error');
const reloadCountBeforeClear = reloadCount;
clearChunkReloadGuard(storageKey, storage);
emit('preload-error');
return {
storageKey,
expectedKey: buildChunkReloadStorageKey('9.9.9'),
reloadCountBeforeClear,
reloadCountAfterClear: reloadCount,
storedValue: storageMap.get(storageKey) ?? null,
};
});
expect(result.storageKey).toBe(result.expectedKey);
expect(result.reloadCountBeforeClear).toBe(1);
expect(result.reloadCountAfterClear).toBe(2);
expect(result.storedValue).toBe('1');
});
test('update badge picks architecture-correct desktop download url', async ({ page }) => {
await page.goto('/tests/runtime-harness.html');

View File

@@ -20,7 +20,7 @@
"test:e2e:finance": "VITE_VARIANT=finance playwright test",
"test:e2e:runtime": "VITE_VARIANT=full playwright test e2e/runtime-fetch.spec.ts",
"test:e2e": "npm run test:e2e:runtime && npm run test:e2e:full && npm run test:e2e:tech && npm run test:e2e:finance",
"test:data": "node --test tests/countries-geojson.test.mjs",
"test:data": "node --test tests/*.test.mjs",
"test:sidecar": "node --test src-tauri/sidecar/local-api-server.test.mjs api/_cors.test.mjs api/youtube/embed.test.mjs api/cyber-threats.test.mjs",
"test:e2e:visual:full": "VITE_VARIANT=full playwright test -g \"matches golden screenshots per layer and zoom\"",
"test:e2e:visual:tech": "VITE_VARIANT=tech playwright test -g \"matches golden screenshots per layer and zoom\"",

View File

@@ -32,7 +32,7 @@ export default defineConfig({
],
snapshotPathTemplate: '{testDir}/{testFileName}-snapshots/{arg}{ext}',
webServer: {
command: 'npm run dev -- --host 127.0.0.1 --port 4173',
command: 'VITE_E2E=1 npm run dev -- --host 127.0.0.1 --port 4173',
url: 'http://127.0.0.1:4173/tests/map-harness.html',
reuseExistingServer: false,
timeout: 120000,

View File

@@ -25,6 +25,7 @@ import { analyzeFlightsForSurge, surgeAlertToSignal, detectForeignMilitaryPresen
import { fetchCachedTheaterPosture } from '@/services/cached-theater-posture';
import { ingestProtestsForCII, ingestMilitaryForCII, ingestNewsForCII, ingestOutagesForCII, ingestConflictsForCII, ingestUcdpForCII, ingestHapiForCII, ingestDisplacementForCII, ingestClimateForCII, startLearning, isInLearningMode, calculateCII, getCountryData, TIER1_COUNTRIES } from '@/services/country-instability';
import { dataFreshness, type DataSourceId } from '@/services/data-freshness';
import { focusInvestmentOnMap } from '@/services/investments-focus';
import { fetchConflictEvents } from '@/services/conflicts';
import { fetchUcdpClassifications } from '@/services/ucdp';
import { fetchHapiSummary } from '@/services/hapi';
@@ -2149,9 +2150,7 @@ export class App {
// GCC Investments Panel (finance variant)
if (SITE_VARIANT === 'finance') {
const investmentsPanel = new InvestmentsPanel((inv) => {
this.map?.enableLayer('gulfInvestments');
this.mapLayers.gulfInvestments = true;
this.map?.setCenter(inv.lat, inv.lon, 6);
focusInvestmentOnMap(this.map, this.mapLayers, inv.lat, inv.lon);
});
this.panels['gcc-investments'] = investmentsPanel;
}

View File

@@ -0,0 +1,43 @@
interface EventTargetLike {
addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void;
}
interface StorageLike {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
removeItem: (key: string) => void;
}
interface ChunkReloadGuardOptions {
eventTarget?: EventTargetLike;
storage?: StorageLike;
eventName?: string;
reload?: () => void;
}
export function buildChunkReloadStorageKey(version: string): string {
return `wm-chunk-reload:${version}`;
}
export function installChunkReloadGuard(
version: string,
options: ChunkReloadGuardOptions = {}
): string {
const storageKey = buildChunkReloadStorageKey(version);
const eventName = options.eventName ?? 'vite:preloadError';
const eventTarget = options.eventTarget ?? window;
const storage = options.storage ?? sessionStorage;
const reload = options.reload ?? (() => window.location.reload());
eventTarget.addEventListener(eventName, () => {
if (storage.getItem(storageKey)) return;
storage.setItem(storageKey, '1');
reload();
});
return storageKey;
}
export function clearChunkReloadGuard(storageKey: string, storage: StorageLike = sessionStorage): void {
storage.removeItem(storageKey);
}

View File

@@ -7,16 +7,10 @@ import { initMetaTags } from '@/services/meta-tags';
import { installRuntimeFetchPatch } from '@/services/runtime';
import { loadDesktopSecrets } from '@/services/runtime-config';
import { applyStoredTheme } from '@/utils/theme-manager';
import { clearChunkReloadGuard, installChunkReloadGuard } from '@/bootstrap/chunk-reload';
const chunkReloadStorageKey = `wm-chunk-reload:${__APP_VERSION__}`;
// Auto-reload on stale chunk 404s after deployment (Vite fires this for modulepreload failures)
window.addEventListener('vite:preloadError', () => {
if (!sessionStorage.getItem(chunkReloadStorageKey)) {
sessionStorage.setItem(chunkReloadStorageKey, '1');
window.location.reload();
}
});
// Auto-reload on stale chunk 404s after deployment (Vite fires this for modulepreload failures).
const chunkReloadStorageKey = installChunkReloadGuard(__APP_VERSION__);
// Initialize Vercel Analytics
inject();
@@ -41,7 +35,7 @@ app
.init()
.then(() => {
// Clear the one-shot guard after a successful boot so future stale-chunk incidents can recover.
sessionStorage.removeItem(chunkReloadStorageKey);
clearChunkReloadGuard(chunkReloadStorageKey);
})
.catch(console.error);

View File

@@ -0,0 +1,17 @@
import type { MapLayers } from '@/types';
interface InvestmentsMapLike {
enableLayer: (layer: keyof MapLayers) => void;
setCenter: (lat: number, lon: number, zoom: number) => void;
}
export function focusInvestmentOnMap(
map: InvestmentsMapLike | null,
mapLayers: MapLayers,
lat: number,
lon: number
): void {
map?.enableLayer('gulfInvestments');
mapLayers.gulfInvestments = true;
map?.setCenter(lat, lon, 6);
}

View File

@@ -0,0 +1,58 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const vercelConfig = JSON.parse(readFileSync(resolve(__dirname, '../vercel.json'), 'utf-8'));
const viteConfigSource = readFileSync(resolve(__dirname, '../vite.config.ts'), 'utf-8');
const getCacheHeaderValue = (sourcePath) => {
const rule = vercelConfig.headers.find((entry) => entry.source === sourcePath);
const header = rule?.headers?.find((item) => item.key.toLowerCase() === 'cache-control');
return header?.value ?? null;
};
describe('deploy/cache configuration guardrails', () => {
it('disables caching for HTML entry routes on Vercel', () => {
assert.equal(getCacheHeaderValue('/'), 'no-cache, no-store, must-revalidate');
assert.equal(getCacheHeaderValue('/index.html'), 'no-cache, no-store, must-revalidate');
});
it('keeps immutable caching for hashed static assets', () => {
assert.equal(
getCacheHeaderValue('/assets/(.*)'),
'public, max-age=31536000, immutable'
);
});
it('keeps PWA precache glob free of HTML files', () => {
assert.match(
viteConfigSource,
/globPatterns:\s*\['\*\*\/\*\.\{js,css,ico,png,svg,woff2\}'\]/
);
assert.doesNotMatch(viteConfigSource, /globPatterns:\s*\['\*\*\/\*\.\{js,css,html/);
});
it('uses network-first runtime caching for navigation requests', () => {
assert.match(viteConfigSource, /request\.mode === 'navigate'/);
assert.match(viteConfigSource, /handler:\s*'NetworkFirst'/);
assert.match(viteConfigSource, /cacheName:\s*'html-navigation'/);
});
it('contains variant-specific metadata fields used by html replacement and manifest', () => {
assert.match(viteConfigSource, /shortName:\s*'/);
assert.match(viteConfigSource, /subject:\s*'/);
assert.match(viteConfigSource, /classification:\s*'/);
assert.match(viteConfigSource, /categories:\s*\[/);
assert.match(
viteConfigSource,
/\.replace\(\/<meta name="subject" content="\.\*\?" \\\/>\/,\s*`<meta name="subject"/
);
assert.match(
viteConfigSource,
/\.replace\(\/<meta name="classification" content="\.\*\?" \\\/>\/,\s*`<meta name="classification"/
);
});
});

View File

@@ -0,0 +1,147 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as ts from 'typescript';
const __dirname = dirname(fileURLToPath(import.meta.url));
function loadGulfInvestments() {
const sourcePath = resolve(__dirname, '../src/config/gulf-fdi.ts');
const source = readFileSync(sourcePath, 'utf-8');
const transpiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
},
fileName: sourcePath,
});
const module = { exports: {} };
const evaluator = new Function('exports', 'module', transpiled.outputText);
evaluator(module.exports, module);
return module.exports.GULF_INVESTMENTS;
}
const GULF_INVESTMENTS = loadGulfInvestments();
const VALID_COUNTRIES = new Set(['SA', 'UAE']);
const VALID_SECTORS = new Set([
'ports',
'pipelines',
'energy',
'datacenters',
'airports',
'railways',
'telecoms',
'water',
'logistics',
'mining',
'real-estate',
'manufacturing',
]);
const VALID_STATUSES = new Set([
'operational',
'under-construction',
'announced',
'rumoured',
'cancelled',
'divested',
]);
describe('gulf-fdi dataset integrity', () => {
it('contains records', () => {
assert.ok(Array.isArray(GULF_INVESTMENTS));
assert.ok(GULF_INVESTMENTS.length > 0);
});
it('has unique IDs', () => {
const ids = GULF_INVESTMENTS.map((investment) => investment.id);
const uniqueCount = new Set(ids).size;
assert.equal(uniqueCount, ids.length, 'Expected all gulf-fdi IDs to be unique');
});
it('uses valid enum-like values', () => {
for (const investment of GULF_INVESTMENTS) {
assert.ok(
VALID_COUNTRIES.has(investment.investingCountry),
`Invalid investingCountry: ${investment.investingCountry} (${investment.id})`
);
assert.ok(
VALID_SECTORS.has(investment.sector),
`Invalid sector: ${investment.sector} (${investment.id})`
);
assert.ok(
VALID_STATUSES.has(investment.status),
`Invalid status: ${investment.status} (${investment.id})`
);
}
});
it('keeps latitude/longitude in valid ranges', () => {
for (const investment of GULF_INVESTMENTS) {
assert.ok(
Number.isFinite(investment.lat) && investment.lat >= -90 && investment.lat <= 90,
`Invalid lat for ${investment.id}: ${investment.lat}`
);
assert.ok(
Number.isFinite(investment.lon) && investment.lon >= -180 && investment.lon <= 180,
`Invalid lon for ${investment.id}: ${investment.lon}`
);
}
});
it('keeps optional numeric fields in sane bounds', () => {
for (const investment of GULF_INVESTMENTS) {
if (investment.investmentUSD != null) {
assert.ok(
Number.isFinite(investment.investmentUSD) && investment.investmentUSD > 0,
`Invalid investmentUSD for ${investment.id}: ${investment.investmentUSD}`
);
}
if (investment.stakePercent != null) {
assert.ok(
Number.isFinite(investment.stakePercent)
&& investment.stakePercent >= 0
&& investment.stakePercent <= 100,
`Invalid stakePercent for ${investment.id}: ${investment.stakePercent}`
);
}
}
});
it('validates year and URL fields when present', () => {
for (const investment of GULF_INVESTMENTS) {
if (investment.yearAnnounced != null) {
assert.ok(
Number.isInteger(investment.yearAnnounced)
&& investment.yearAnnounced >= 1990
&& investment.yearAnnounced <= 2100,
`Invalid yearAnnounced for ${investment.id}: ${investment.yearAnnounced}`
);
}
if (investment.yearOperational != null) {
assert.ok(
Number.isInteger(investment.yearOperational)
&& investment.yearOperational >= 1990
&& investment.yearOperational <= 2100,
`Invalid yearOperational for ${investment.id}: ${investment.yearOperational}`
);
}
if (investment.yearAnnounced != null && investment.yearOperational != null) {
assert.ok(
investment.yearOperational >= investment.yearAnnounced,
`yearOperational before yearAnnounced for ${investment.id}`
);
}
if (investment.sourceUrl) {
assert.match(
investment.sourceUrl,
/^https?:\/\//,
`sourceUrl must be absolute for ${investment.id}`
);
}
}
});
});

View File

@@ -3,6 +3,8 @@ import { VitePWA } from 'vite-plugin-pwa';
import { resolve } from 'path';
import pkg from './package.json';
const isE2E = process.env.VITE_E2E === '1';
const VARIANT_META: Record<string, {
title: string;
description: string;
@@ -304,7 +306,15 @@ export default defineConfig({
},
server: {
port: 3000,
open: true,
open: !isE2E,
hmr: isE2E ? false : undefined,
watch: {
ignored: [
'**/test-results/**',
'**/playwright-report/**',
'**/.playwright-mcp/**',
],
},
proxy: {
// Yahoo Finance API
'/api/yahoo': {