diff --git a/src/__tests__/discoveryLiveCapture.test.ts b/src/__tests__/discoveryLiveCapture.test.ts new file mode 100644 index 00000000..72c3dca7 --- /dev/null +++ b/src/__tests__/discoveryLiveCapture.test.ts @@ -0,0 +1,181 @@ +/** + * discoveryLiveCapture.test.ts — Canlı discovery servislerinin DiscoveryCaptureService'e + * bağlanması (PR-DISC-2). + * + * Kilitler: + * - extendedPidService: keşif bitmask'inde registry'de OLMAYAN PID → capture; registry'deki + * PID → capture YOK; aralık-bayrağı (00/20/…) → capture YOK; hot-poll/native liste değişmez. + * - didDiscoveryService: POZİTİF DID → capture; NO DATA/7F → capture YOK. + * - manufacturerPidService: profil DID'i "katalog" → setKnownDids ile bilinir → capture YOK. + * - Duplicate filtre, tanı event'i, queue, export. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const M = vi.hoisted(() => ({ + isNative: true, + pushedLists: [] as string[][], + readObdDidMock: vi.fn(), +})); + +vi.mock('@capacitor/core', () => ({ + Capacitor: { isNativePlatform: vi.fn(() => M.isNative) }, +})); +vi.mock('../platform/nativePlugin', () => ({ + CarLauncher: { + setObdExtendedPids: vi.fn(async (opts: { pids: string[] }) => { M.pushedLists.push(opts.pids); }), + setObdDiagnosticBurst: vi.fn(async () => {}), + addListener: vi.fn(async () => ({ remove: vi.fn() })), + readObdDid: (...args: unknown[]) => M.readObdDidMock(...args), + }, +})); +vi.mock('../platform/crashLogger', () => ({ logError: vi.fn() })); +vi.mock('../platform/safety/vinContext', () => ({ getHandshakeVin: () => null })); + +import { discoveryCaptureService } from '../platform/obd/discovery'; +import { watchPid, _internals as extInternals } from '../platform/obd/extendedPidService'; +import { startDiscovery } from '../platform/obd/didDiscoveryService'; +import { + loadProfile, unloadProfile, watchDid, _internals as mfrInternals, +} from '../platform/obd/manufacturerPidService'; +import { clear as clearDiag, getEvents } from '../platform/obdDiagnosticRecorder'; +import type { VehicleDidProfile } from '../platform/obd/vehicleDidProfile'; + +const capturedPids = () => discoveryCaptureService.getCaptured().map((r) => r.pidOrDid); + +beforeEach(() => { + discoveryCaptureService.reset(); + extInternals.reset(); + mfrInternals.reset(); + clearDiag(); + M.isNative = true; + M.pushedLists.length = 0; + M.readObdDidMock.mockReset(); + try { localStorage.clear(); } catch { /* jsdom */ } +}); + +/* ── extendedPidService → PID keşif capture ───────────────────────────────── */ + +describe('extendedPidService — yeni PID capture', () => { + it('bitmask\'te registry\'de OLMAYAN PID yakalanır; registry\'deki + aralık bayrağı yakalanmaz', () => { + watchPid('5C', () => {}); // keşfi başlat (queue=['00']) + extInternals.onExtendedData({ pid: '00', data: 'BE1FB813' }); // klasik SAE bitmask + const caps = capturedPids(); + // '01' ve '03' registry'de yok (enum PID) → yakalanır + expect(caps).toContain('01'); + expect(caps).toContain('03'); + // '04' (motor yükü) ve '0C' (RPM) registry'de var → yakalanmaz + expect(caps).not.toContain('04'); + expect(caps).not.toContain('0C'); + // '20' aralık bayrağı (gerçek sinyal değil) → yakalanmaz + expect(caps).not.toContain('20'); + }); + + it('capture, native poll (hot-poll) listesine PID EKLEMEZ', () => { + watchPid('5C', () => {}); + extInternals.onExtendedData({ pid: '00', data: 'BE1FB813' }); + // Yakalanan keşif PID'leri ('01'/'03') hiçbir native poll listesine sızmamalı. + for (const list of M.pushedLists) { + expect(list).not.toContain('01'); + expect(list).not.toContain('03'); + } + }); + + it('aynı keşif iki kez gelince tek capture (duplicate filtre)', () => { + watchPid('5C', () => {}); + extInternals.onExtendedData({ pid: '00', data: 'BE1FB813' }); + const firstCount = discoveryCaptureService.getCaptured().length; + // Aynı bitmask tekrar (yeni keşif turu) → yeni kayıt olmamalı. + extInternals.onExtendedData({ pid: '00', data: 'BE1FB813' }); + expect(discoveryCaptureService.getCaptured().length).toBe(firstCount); + }); + + it('yeni PID keşfi tanı timeline\'ına \'ecuQuery\' event\'i düşürür', () => { + watchPid('5C', () => {}); + extInternals.onExtendedData({ pid: '00', data: 'BE1FB813' }); + const evt = getEvents().find((e) => e.stage === 'ecuQuery' && e.technicalMessage.includes('PID')); + expect(evt).toBeDefined(); + expect(evt?.status).toBe('info'); + }); +}); + +/* ── didDiscoveryService → DID keşif capture ──────────────────────────────── */ + +describe('didDiscoveryService — DID capture', () => { + it('POZİTİF DID yakalanır; NO DATA / 7F yakalanmaz', async () => { + // 2000 pozitif (supported+data), 2001 desteklenmiyor (NO DATA) + M.readObdDidMock.mockImplementation(async ({ did }: { did: string }) => + did === '2000' ? { supported: true, data: 'AABBCC' } : { supported: false, data: '' }); + + const outcome = await startDiscovery({ tx: '7E0', rx: '7E8', from: '2000', to: '2001' }); + expect(outcome.summary.positive).toBe(1); + + const caps = discoveryCaptureService.getCaptured(); + expect(caps.map((r) => r.pidOrDid)).toEqual(['2000']); // yalnız pozitif + const rec = caps[0]!; + expect(rec.discoverySource).toBe('DID'); + expect(rec.ecuAddress).toBe('7E8'); + expect(rec.mode).toBe('22'); + expect(rec.request).toBe('222000'); + expect(rec.rawResponse).toBe('AABBCC'); + expect(rec.supported).toBe(true); + }); + + it('discovery başarısız (plugin yok) → capture oluşmaz', async () => { + M.isNative = false; // readObdDid yolu kapalı → plugin_unavailable + const outcome = await startDiscovery({ tx: '7E0', rx: '7E8', from: '2000', to: '2000' }); + expect(outcome.summary.stopReason).toBe('plugin_unavailable'); + expect(discoveryCaptureService.getCaptured()).toHaveLength(0); + }); +}); + +/* ── manufacturerPidService → katalog (profil) DID'i bilinir ──────────────── */ + +describe('manufacturerPidService — katalog DID capture edilmez', () => { + const PROFILE: VehicleDidProfile = { + brand: 'Test', source: 'ISO 14229-1 (test fixture)', + ecus: [{ id: 'engine', name: 'Motor ECU', tx: '7E0', rx: '7E8' }], + dids: [ + { did: 'F190', ecu: 'engine', name: 'VIN', unit: '', bytes: 17, min: 0, max: 0, category: 'kimlik', decode: { fn: 'ascii' } }, + ], + }; + const hexOf = (t: string) => [...t].map((c) => c.charCodeAt(0).toString(16).padStart(2, '0')).join(''); + + it('profildeki DID (F190) okununca capture OLUŞMAZ (katalog = bilinir)', async () => { + loadProfile(PROFILE); // setKnownDids(['F190']) + M.readObdDidMock.mockResolvedValue({ supported: true, data: hexOf('VF1AAAAA000000001') }); + const unsub = watchDid('F190', () => {}); + await mfrInternals.tick(); + unsub(); + expect(discoveryCaptureService.getCaptured()).toHaveLength(0); + }); + + it('profil DID\'i didDiscovery\'de de "bilinir" → yakalanmaz (setKnownDids köprüsü)', async () => { + loadProfile(PROFILE); // F190 katalogda + M.readObdDidMock.mockResolvedValue({ supported: true, data: 'AABB' }); + await startDiscovery({ tx: '7E0', rx: '7E8', from: 'F190', to: 'F190' }); + // F190 profil (katalog) DID'i → keşif değil. + expect(discoveryCaptureService.getCaptured().map((r) => r.pidOrDid)).not.toContain('F190'); + }); + + it('unloadProfile sonrası aynı DID artık katalog dışı → yakalanır', async () => { + loadProfile(PROFILE); + unloadProfile(); // katalog boşaldı → F190 bilinmiyor + M.readObdDidMock.mockResolvedValue({ supported: true, data: 'AABB' }); + await startDiscovery({ tx: '7E0', rx: '7E8', from: 'F190', to: 'F190' }); + expect(discoveryCaptureService.getCaptured().map((r) => r.pidOrDid)).toContain('F190'); + }); +}); + +/* ── Queue + Export uçtan uca ─────────────────────────────────────────────── */ + +describe('PR-DISC-2 — queue + export uçtan uca', () => { + it('yakalananlar queue\'ya düşer ve JSON export doğru serileşir', async () => { + M.readObdDidMock.mockResolvedValue({ supported: true, data: 'AA' }); + await startDiscovery({ tx: '7E0', rx: '7E8', from: '3000', to: '3001' }); + + expect(discoveryCaptureService.getCaptured().length).toBe(2); // 3000, 3001 + const parsed = JSON.parse(discoveryCaptureService.exportJson()); + expect(parsed.count).toBe(2); + expect(parsed.records.map((r: { pidOrDid: string }) => r.pidOrDid).sort()).toEqual(['3000', '3001']); + }); +}); diff --git a/src/platform/obd/didDiscoveryService.ts b/src/platform/obd/didDiscoveryService.ts index d732c2fa..a9e530ed 100644 --- a/src/platform/obd/didDiscoveryService.ts +++ b/src/platform/obd/didDiscoveryService.ts @@ -17,6 +17,7 @@ import { Capacitor } from '@capacitor/core'; import { CarLauncher } from '../nativePlugin'; import { logError } from '../crashLogger'; +import { discoveryCaptureService } from './discovery'; /** DID'ler arası bekleme (ms) — ECU'yu ardışık sorgularla boğmamak için. */ export const DISCOVERY_INTER_DID_DELAY_MS = 150; @@ -131,8 +132,22 @@ export async function startDiscovery(opts: StartDiscoveryOptions): Promise(); found.forEach((p) => _supported!.add(p)); + + // PR-DISC-2: keşif bitmask'inde araç DESTEKLİYOR ama registry'de OLMAYAN standart + // PID'leri yakala (aralık-bayrağı PID'leri 00/20/40/… gerçek sinyal değil → atla; + // registry'dekiler zaten bilinir → DiscoveryCaptureService onları da eler). Fail-soft: + // yakalama hatası keşfi ETKİLEMEZ. Hot-poll / native listeye DOKUNMAZ (yalnız kayıt). + for (const foundPid of found) { + if ((DISCOVERY_PIDS as readonly string[]).includes(foundPid)) continue; + if (STANDARD_PID_MAP.has(foundPid)) continue; + try { + discoveryCaptureService.capture({ + discoverySource: 'PID', + mode: '01', + pidOrDid: foundPid, + request: `01${pid}`, // hangi supported-bitmask sorgusundan bulundu + rawResponse: event.data, + supported: true, // bitmask "destekleniyor" diyor + }); + } catch (e) { logError('OBD:DiscoveryCapturePid', e); } + } // Zincir: bir sonraki aralığın bitmask PID'i destekliyse kuyruğa ekle. const idx = DISCOVERY_PIDS.indexOf(pid as (typeof DISCOVERY_PIDS)[number]); const next = idx >= 0 ? DISCOVERY_PIDS[idx + 1] : undefined; diff --git a/src/platform/obd/manufacturerPidService.ts b/src/platform/obd/manufacturerPidService.ts index 38b66de7..28d4ee44 100644 --- a/src/platform/obd/manufacturerPidService.ts +++ b/src/platform/obd/manufacturerPidService.ts @@ -26,6 +26,7 @@ import { decodeCompiledDid, } from './vehicleDidProfile'; import type { CompiledDidDef, VehicleDidValue } from './vehicleDidProfile'; +import { discoveryCaptureService } from './discovery'; /** Round-robin zamanlayıcı aralığı (ms) — üretici verileri yavaş değişir, 2-5s yeter. */ export const MANUFACTURER_POLL_INTERVAL_MS = 3000; @@ -59,6 +60,8 @@ export function loadProfile(rawProfile: unknown): { ok: true } | { ok: false; er const result = validateVehicleDidProfile(rawProfile); if (!result.valid) return { ok: false, errors: result.errors }; _profile = compileVehicleDidProfile(result.profile); + // PR-DISC-2: profildeki DID'ler = "katalog" → keşif yakalayıcı bunları "yeni" saymasın. + discoveryCaptureService.setKnownDids(_profile.keys()); _unsupported.clear(); _values.clear(); _rrIndex = 0; @@ -69,6 +72,7 @@ export function loadProfile(rawProfile: unknown): { ok: true } | { ok: false; er /** Profili kaldırır — izleyici kalmışsa bile zamanlayıcı durur (profilsiz okunacak DID yok). */ export function unloadProfile(): void { _profile = null; + discoveryCaptureService.setKnownDids([]); // katalog boşaldı _unsupported.clear(); _values.clear(); _syncTimer(); @@ -125,6 +129,23 @@ async function _tick(): Promise { // NaN yalnız sayısal daldan gelir (metin dalı boş string yerine NaN döner) — type guard // `typeof value === 'string'` durumunda Number.isNaN çağrısını atlar (TS + doğruluk). if (typeof value === 'number' && Number.isNaN(value)) return; // sınır dışı/bozuk — sessizce atla + + // PR-DISC-2: manufacturer çözümlemesinde KATALOG DIŞI DID varsa keşif hattına düşür. + // Profildeki (katalog) DID'ler setKnownDids ile "bilinir" işaretli → DiscoveryCaptureService + // onları ELER (tekrar kaydetmez); yalnız katalog dışı bir DID okunursa yakalanır. Fail-soft. + try { + discoveryCaptureService.capture({ + discoverySource: 'DID', + mode: '22', + ecuAddress: def.rx, + pidOrDid: def.did, + request: `22${def.did}`, + rawResponse: r.data, + supported: true, + decodedValue: value, + }); + } catch (e) { logError('OBD:DiscoveryCaptureManufacturerDid', e); } + const entry: ManufacturerDidValue = { value, def, updatedAt: Date.now() }; _values.set(did, entry); _watchers.get(did)?.forEach((cb) => {