From 93daec7631227436d7e179e31076066dcbec17ef Mon Sep 17 00:00:00 2001 From: syoul Date: Sun, 22 Mar 2026 16:32:23 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20g=C3=A9olocalisation=20Cesium+=20et=20te?= =?UTF-8?q?sts=20d=C3=A9terministes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CesiumAdapter : utilise le champ `title` (analysé ES) au lieu de `title.keyword` qui retournait 0 résultats ; coerce lat/lon en number (certains profils stockent des strings) - DataService : sépare totalVolume (all tx blockchain) de geoCount (tx heatmap) - StatsPanel : barre de couverture géo uniquement en mode live - App : badge source "● live Ğ1v2" ou "○ mock" - DataService.test.ts : mock SubsquidAdapter + CesiumAdapter directement (vi.mock hoistés) pour que les tests soient déterministes quel que soit VITE_USE_LIVE_API dans .env.local - tsconfig.app.json : exclude src/test pour éviter les erreurs de build prod Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 2 +- src/components/StatsPanel.tsx | 24 ++++++- src/schemas/g1.schema.ts | 27 +++++++- src/services/DataService.ts | 91 +++++++++++++++----------- src/services/adapters/CesiumAdapter.ts | 6 +- src/test/DataService.test.ts | 49 ++++++++++---- tsconfig.app.json | 3 +- 7 files changed, 142 insertions(+), 60 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index c7239ed..52c1d77 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,7 +32,7 @@ export default function App() { return (
{/* Side panel */} - + {/* Map area */}
diff --git a/src/components/StatsPanel.tsx b/src/components/StatsPanel.tsx index a75e622..b5d35c7 100644 --- a/src/components/StatsPanel.tsx +++ b/src/components/StatsPanel.tsx @@ -4,6 +4,7 @@ interface StatsPanelProps { stats: PeriodStats | null; loading: boolean; periodDays: number; + source: 'live' | 'mock'; } const MEDALS = ['🥇', '🥈', '🥉']; @@ -18,8 +19,11 @@ function StatCard({ label, value, sub }: { label: string; value: string; sub?: s ); } -export function StatsPanel({ stats, loading, periodDays }: StatsPanelProps) { +export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelProps) { const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`; + const geoPct = stats && stats.transactionCount > 0 + ? Math.round((stats.geoCount / stats.transactionCount) * 100) + : null; return ( diff --git a/src/schemas/g1.schema.ts b/src/schemas/g1.schema.ts index 0d19529..755ba4e 100644 --- a/src/schemas/g1.schema.ts +++ b/src/schemas/g1.schema.ts @@ -22,6 +22,28 @@ export const G1v2KeySchema = z.string() .startsWith('g1') .length(49); +// --------------------------------------------------------------------------- +// Ğ1v1 (Duniter GVA) — conservé pour référence, non utilisé en production v2 +// --------------------------------------------------------------------------- +export const GvaTransactionNodeSchema = z.object({ + currency: z.literal('g1'), + issuers: z.array(z.string().min(43).max(44)), + outputs: z.array(z.string().regex(/^\d+:\d+:SIG\(.+\)$/)).min(1), + blockstampTime: z.number().int().positive(), + comment: z.string().optional(), + hash: z.string().optional(), +}); + +export const GvaResponseSchema = z.object({ + data: z.object({ + txsHistoryBc: z.object({ + both: z.object({ + edges: z.array(z.object({ node: GvaTransactionNodeSchema })), + }), + }), + }), +}); + // --------------------------------------------------------------------------- // Subsquid — réponse brute d'un transfert Ğ1v2 // Endpoint : POST https://squidv2s.syoul.fr/v1/graphql @@ -59,8 +81,9 @@ export const CesiumProfileSchema = z.object({ title: z.string().optional(), city: z.string().optional(), geoPoint: z.object({ - lat: z.number().min(-90).max(90), - lon: z.number().min(-180).max(180), + // Certains profils Cesium+ ont lat/lon en string — coerce pour les deux cas + lat: z.coerce.number().min(-90).max(90), + lon: z.coerce.number().min(-180).max(180), }).optional(), }), }); diff --git a/src/services/DataService.ts b/src/services/DataService.ts index 6184538..aad0a4b 100644 --- a/src/services/DataService.ts +++ b/src/services/DataService.ts @@ -5,8 +5,9 @@ * Mode live (USE_LIVE_API = true) : données réelles Ğ1v2. * - Transactions : Subsquid indexer https://squidv2s.syoul.fr/v1/graphql * - Géolocalisation : Cesium+ https://g1.data.e-is.pro - * → recherche par nom d'identité (identity.name depuis le graphe Subsquid) - * → les transactions sans profil Cesium+ reçoivent des coordonnées approx. + * → recherche batch par nom d'identité (champ "title" analysé ES) + * → couverture ~50-60% : les tx sans profil géo sont EXCLUES du heatmap + * mais comptées dans totalCount / totalVolume * * Pour activer : définir VITE_USE_LIVE_API=true dans .env.local */ @@ -19,27 +20,21 @@ import { type Transaction, } from '../data/mockData'; -// --------------------------------------------------------------------------- -// Configuration -// --------------------------------------------------------------------------- const USE_LIVE_API = import.meta.env.VITE_USE_LIVE_API === 'true'; -// Centroïde France — fallback géo quand Cesium+ n'a pas le profil -const FRANCE_CENTER = { lat: 46.2276, lng: 2.2137 }; - -// --------------------------------------------------------------------------- -// Pipeline données live Ğ1v2 -// --------------------------------------------------------------------------- -async function fetchLiveTransactions(periodDays: number): Promise { - // 1. Récupère les transferts depuis Subsquid +async function fetchLiveTransactions(periodDays: number): Promise<{ + geolocated: Transaction[]; + totalCount: number; + totalVolume: number; +}> { const rawTransfers = await fetchTransfers(periodDays); + if (rawTransfers.length === 0) return { geolocated: [], totalCount: 0, totalVolume: 0 }; - if (rawTransfers.length === 0) return []; + const totalCount = rawTransfers.length; + const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0); - // 2. Collecte les noms d'identité uniques pour la recherche Cesium+ + // Résolution géo batch via Cesium+ const names = rawTransfers.map((t) => t.fromName).filter(Boolean); - - // 3. Résout les coordonnées via Cesium+ (une seule requête batch) let geoMap = new Map(); try { const profiles = await resolveGeoByNames(names); @@ -47,53 +42,69 @@ async function fetchLiveTransactions(periodDays: number): Promise geoMap.set(name, { lat: p.lat, lng: p.lng, city: p.city }); } } catch (err) { - console.warn('Cesium+ indisponible, fallback coordonnées France :', err); + console.warn('Cesium+ indisponible :', err); } - // 4. Assemble les transactions avec coordonnées - return rawTransfers.map((t): Transaction => { - const geo = geoMap.get(t.fromName) ?? FRANCE_CENTER; - return { + // Seules les transactions avec un profil géo entrent dans le heatmap + const geolocated: Transaction[] = []; + for (const t of rawTransfers) { + const geo = geoMap.get(t.fromName); + if (!geo) continue; // pas de profil → exclu du heatmap + + geolocated.push({ id: t.id, timestamp: t.timestamp, lat: geo.lat, lng: geo.lng, amount: t.amount, - city: ('city' in geo ? geo.city : undefined) ?? 'Inconnue', + city: geo.city, fromKey: t.fromId, toKey: t.toId, - }; - }); + }); + } + + return { geolocated, totalCount, totalVolume }; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export interface PeriodStats { - totalVolume: number; - transactionCount: number; + totalVolume: number; + transactionCount: number; // total blockchain (y compris non-géolocalisés) + geoCount: number; // transactions visibles sur la carte topCities: { name: string; volume: number; count: number }[]; } export interface DataResult { - transactions: Transaction[]; - stats: PeriodStats; + transactions: Transaction[]; // uniquement géolocalisées → heatmap + stats: PeriodStats; source: 'live' | 'mock'; } export async function fetchData(periodDays: number): Promise { - let transactions: Transaction[]; - let source: 'live' | 'mock'; - - if (USE_LIVE_API) { - transactions = await fetchLiveTransactions(periodDays); - source = 'live'; - } else { + if (!USE_LIVE_API) { await new Promise((r) => setTimeout(r, 80)); - transactions = getTransactionsForPeriod(periodDays); - source = 'mock'; + const transactions = getTransactionsForPeriod(periodDays); + const base = computeStats(transactions); + return { + transactions, + stats: { ...base, geoCount: transactions.length }, + source: 'mock', + }; } - const stats = computeStats(transactions); - return { transactions, stats, source }; + const { geolocated, totalCount, totalVolume } = await fetchLiveTransactions(periodDays); + const base = computeStats(geolocated); + + return { + transactions: geolocated, + stats: { + totalVolume, // vrai total blockchain + transactionCount: totalCount, + geoCount: geolocated.length, + topCities: base.topCities, + }, + source: 'live', + }; } diff --git a/src/services/adapters/CesiumAdapter.ts b/src/services/adapters/CesiumAdapter.ts index 1b26854..28d6986 100644 --- a/src/services/adapters/CesiumAdapter.ts +++ b/src/services/adapters/CesiumAdapter.ts @@ -53,11 +53,13 @@ export async function resolveGeoByNames( if (unique.length === 0) return new Map(); const query = { - size: unique.length, + // Dépasser la limite par défaut : plusieurs profils peuvent avoir le même prénom + size: unique.length * 3, query: { bool: { must: [ - { terms: { 'title.keyword': unique } }, + // Champ "title" analysé (lowercase tokens) — title.keyword retourne 0 résultats + { terms: { title: unique } }, ], filter: [ { exists: { field: 'geoPoint' } }, diff --git a/src/test/DataService.test.ts b/src/test/DataService.test.ts index 8fd3dab..1e7e66a 100644 --- a/src/test/DataService.test.ts +++ b/src/test/DataService.test.ts @@ -1,44 +1,69 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { fetchData } from '../services/DataService'; -// Mock the mockData module so tests are deterministic +// Mocker les adaptateurs live AVANT l'import de DataService, +// pour que les tests soient déterministes quel que soit VITE_USE_LIVE_API +vi.mock('../services/adapters/SubsquidAdapter', () => ({ + fetchTransfers: vi.fn(async (days: number) => [ + { id: 't1', timestamp: Date.now(), amount: 20, fromId: 'g1' + 'a'.repeat(47), toId: 'g1' + 'b'.repeat(47), fromName: 'Alice' }, + { id: 't2', timestamp: Date.now(), amount: 10, fromId: 'g1' + 'c'.repeat(47), toId: 'g1' + 'd'.repeat(47), fromName: 'Bob' }, + ].slice(0, days >= 7 ? 2 : 1)), +})); + +vi.mock('../services/adapters/CesiumAdapter', () => ({ + resolveGeoByNames: vi.fn(async () => new Map([ + ['Alice', { name: 'Alice', city: 'Paris', lat: 48.8566, lng: 2.3522 }], + ['Bob', { name: 'Bob', city: 'Lyon', lat: 45.764, lng: 4.8357 }], + ])), +})); + vi.mock('../data/mockData', () => ({ getTransactionsForPeriod: vi.fn((days: number) => [ - { id: 't1', timestamp: Date.now(), lat: 48.8, lng: 2.3, amount: 20, city: 'Paris', fromKey: 'a', toKey: 'b' }, - { id: 't2', timestamp: Date.now(), lat: 45.7, lng: 4.8, amount: 10, city: 'Lyon', fromKey: 'c', toKey: 'd' }, + { id: 't1', timestamp: Date.now(), lat: 48.8, lng: 2.3, amount: 20, city: 'Paris', fromKey: 'g1' + 'a'.repeat(47), toKey: 'g1' + 'b'.repeat(47) }, + { id: 't2', timestamp: Date.now(), lat: 45.7, lng: 4.8, amount: 10, city: 'Lyon', fromKey: 'g1' + 'c'.repeat(47), toKey: 'g1' + 'd'.repeat(47) }, ].slice(0, days >= 7 ? 2 : 1)), computeStats: vi.fn((txs) => ({ - totalVolume: txs.reduce((s: number, t: { amount: number }) => s + t.amount, 0), + totalVolume: txs.reduce((s: number, t: { amount: number }) => s + t.amount, 0), transactionCount: txs.length, - topCities: [{ name: 'Paris', volume: 20, count: 1 }], + topCities: [{ name: 'Paris', volume: 20, count: 1 }], })), })); beforeEach(() => vi.clearAllMocks()); +import { fetchData } from '../services/DataService'; + describe('fetchData', () => { - it('returns transactions and stats shaped correctly', async () => { + it('retourne transactions et stats avec la bonne forme', async () => { const result = await fetchData(7); expect(result).toHaveProperty('transactions'); expect(result).toHaveProperty('stats'); + expect(result).toHaveProperty('source'); expect(result.stats).toHaveProperty('totalVolume'); expect(result.stats).toHaveProperty('transactionCount'); + expect(result.stats).toHaveProperty('geoCount'); expect(result.stats).toHaveProperty('topCities'); }); - it('filters by period — 1 day returns fewer transactions than 7 days', async () => { + it('1 jour retourne moins de transactions que 7 jours', async () => { const r1 = await fetchData(1); const r7 = await fetchData(7); expect(r7.transactions.length).toBeGreaterThanOrEqual(r1.transactions.length); }); - it('stats.totalVolume matches sum of returned transactions', async () => { + it('totalVolume >= somme des transactions géolocalisées', async () => { const { transactions, stats } = await fetchData(7); - const sum = transactions.reduce((s, t) => s + t.amount, 0); - expect(stats.totalVolume).toBeCloseTo(sum); + const geoSum = transactions.reduce((s, t) => s + t.amount, 0); + // En mode live : totalVolume = total blockchain >= geolocalisées + // En mode mock : totalVolume = geoSum (même ensemble) + expect(stats.totalVolume).toBeGreaterThanOrEqual(geoSum - 0.01); }); - it('resolves (does not reject) for all valid periods', async () => { + it('geoCount <= transactionCount', async () => { + const { stats } = await fetchData(7); + expect(stats.geoCount).toBeLessThanOrEqual(stats.transactionCount); + }); + + it('ne rejette pas pour les trois périodes valides', async () => { await expect(fetchData(1)).resolves.toBeDefined(); await expect(fetchData(7)).resolves.toBeDefined(); await expect(fetchData(30)).resolves.toBeDefined(); diff --git a/tsconfig.app.json b/tsconfig.app.json index af516fc..bd518c4 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -24,5 +24,6 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/test"] }