From 54f1a5d5784ef55f4c361be163cb5a61da8862aa Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Tue, 17 Feb 2026 19:16:23 +0400 Subject: [PATCH] test: add coverage for finance/trending/reload and stabilize map harness --- e2e/investments-panel.spec.ts | 119 ++++++++++++++++++++++++ e2e/keyword-spike-flow.spec.ts | 90 ++++++++++++++++++ e2e/map-harness.spec.ts | 82 ++++++++++------- e2e/runtime-fetch.spec.ts | 73 +++++++++++++++ package.json | 2 +- playwright.config.ts | 2 +- src/App.ts | 5 +- src/bootstrap/chunk-reload.ts | 43 +++++++++ src/main.ts | 14 +-- src/services/investments-focus.ts | 17 ++++ tests/deploy-config.test.mjs | 58 ++++++++++++ tests/gulf-fdi-data.test.mjs | 147 ++++++++++++++++++++++++++++++ vite.config.ts | 12 ++- 13 files changed, 616 insertions(+), 48 deletions(-) create mode 100644 e2e/investments-panel.spec.ts create mode 100644 src/bootstrap/chunk-reload.ts create mode 100644 src/services/investments-focus.ts create mode 100644 tests/deploy-config.test.mjs create mode 100644 tests/gulf-fdi-data.test.mjs diff --git a/e2e/investments-panel.spec.ts b/e2e/investments-panel.spec.ts new file mode 100644 index 000000000..102d8075e --- /dev/null +++ b/e2e/investments-panel.spec.ts @@ -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, + 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('.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( + '.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('.fdi-sort[data-sort="investmentUSD"]'); + investmentSort!.click(); // asc + investmentSort!.click(); // desc + + const firstRow = root.querySelector('.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); + }); +}); diff --git a/e2e/keyword-spike-flow.spec.ts b/e2e/keyword-spike-flow.spec.ts index abdb5210b..b23c3c90a 100644 --- a/e2e/keyword-spike-flow.spec.ts +++ b/e2e/keyword-spike-flow.spec.ts @@ -104,4 +104,94 @@ test.describe('keyword spike modal/badge flow', () => { delete (window as unknown as Record).__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); + }); }); diff --git a/e2e/map-harness.spec.ts b/e2e/map-harness.spec.ts index db06d6860..d82cd1477 100644 --- a/e2e/map-harness.spec.ts +++ b/e2e/map-harness.spec.ts @@ -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 }) => { diff --git a/e2e/runtime-fetch.spec.ts b/e2e/runtime-fetch.spec.ts index 41702bc2d..db5d53919 100644 --- a/e2e/runtime-fetch.spec.ts +++ b/e2e/runtime-fetch.spec.ts @@ -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 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(); + 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'); diff --git a/package.json b/package.json index fb648bcc1..20b0aef14 100644 --- a/package.json +++ b/package.json @@ -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\"", diff --git a/playwright.config.ts b/playwright.config.ts index e6120a187..38042fc2a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, diff --git a/src/App.ts b/src/App.ts index 96e475350..3cd8c6e10 100644 --- a/src/App.ts +++ b/src/App.ts @@ -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; } diff --git a/src/bootstrap/chunk-reload.ts b/src/bootstrap/chunk-reload.ts new file mode 100644 index 000000000..3519877d9 --- /dev/null +++ b/src/bootstrap/chunk-reload.ts @@ -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); +} diff --git a/src/main.ts b/src/main.ts index 706f3d7be..f3e0b0180 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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); diff --git a/src/services/investments-focus.ts b/src/services/investments-focus.ts new file mode 100644 index 000000000..f2579ae64 --- /dev/null +++ b/src/services/investments-focus.ts @@ -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); +} diff --git a/tests/deploy-config.test.mjs b/tests/deploy-config.test.mjs new file mode 100644 index 000000000..5d152a016 --- /dev/null +++ b/tests/deploy-config.test.mjs @@ -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\(\/\/,\s*`\/,\s*` { + 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}` + ); + } + } + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 078904655..21e3a4461 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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