-
Notifications
You must be signed in to change notification settings - Fork 0
feat(obd): otomatik PID/DID keşif yakalama boru hattı (PR-DISC-1) #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,256 @@ | ||
| /** | ||
| * discoveryCapture.test.ts — Otomatik PID/DID keşif yakalama boru hattı (PR-DISC-1). | ||
| * | ||
| * Kapsananlar (görev tanımı): | ||
| * - Duplicate discovery oluşmuyor (aynı ECU+mode+PID/DID tek kayıt). | ||
| * - Registry'deki PID tekrar kaydedilmiyor (bilinen PID atlanır). | ||
| * - Yeni DID yakalanıyor. | ||
| * - Hash deduplikasyon çalışıyor (FNV-1a kimlik + bounded eviction). | ||
| * - Queue davranışı doğru (enqueue/peekAll/drain/bounded/kalıcılık). | ||
| * - Export JSON doğru (sürümlü zarf). | ||
| * - Yeni keşifte tanı event'i düşüyor (item 6). | ||
| */ | ||
| import { describe, it, expect, beforeEach, vi } from 'vitest'; | ||
| import { | ||
| DiscoveryCache, | ||
| DiscoveryQueue, | ||
| DiscoveryCaptureService, | ||
| exportDiscoveryJson, | ||
| buildDiscoveryEnvelope, | ||
| DISCOVERY_EXPORT_SCHEMA, | ||
| createDiscoveryRecord, | ||
| discoveryHash, | ||
| fnv1a, | ||
| type DiscoveryRecord, | ||
| type DiscoveryCaptureOptions, | ||
| } from '../platform/obd/discovery'; | ||
| import { recordDiag, clear as clearDiag, getEvents } from '../platform/obdDiagnosticRecorder'; | ||
|
Check failure on line 27 in src/__tests__/discoveryCapture.test.ts
|
||
|
|
||
| /** Her testte benzersiz storage anahtarı (kalıcı kuyruk çapraz-kirlenme yok). */ | ||
| let _k = 0; | ||
| const freshQueue = () => new DiscoveryQueue(`test-discovery-${_k++}`, 500); | ||
|
|
||
| /** Enjekte edilebilir servis kur (tanı emitörü spy; registry gerçek). */ | ||
| function makeService(overrides: DiscoveryCaptureOptions = {}) { | ||
| const emit = vi.fn(); | ||
| const svc = new DiscoveryCaptureService({ | ||
| cache: new DiscoveryCache(), | ||
| queue: freshQueue(), | ||
| emitDiagnostic: emit, | ||
| ...overrides, | ||
| }); | ||
| return { svc, emit }; | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| try { localStorage.clear(); } catch { /* jsdom */ } | ||
| }); | ||
|
|
||
| /* ── Model / hash ─────────────────────────────────────────────────────────── */ | ||
|
|
||
| describe('discoveryModel — kimlik & hash', () => { | ||
| it('fnv1a deterministik + 8 hane hex', () => { | ||
| expect(fnv1a('abc')).toBe(fnv1a('abc')); | ||
| expect(fnv1a('abc')).toMatch(/^[0-9a-f]{8}$/); | ||
| expect(fnv1a('abc')).not.toBe(fnv1a('abd')); | ||
| }); | ||
|
|
||
| it('dedup hash zaman/yanıttan BAĞIMSIZ, kimlikten (ECU+mode+PID/DID+kaynak) türer', () => { | ||
| const base = { discoverySource: 'DID' as const, mode: '22', ecuAddress: '7E8', pidOrDid: 'F190' }; | ||
| const a = createDiscoveryRecord({ ...base, timestamp: 1, rawResponse: 'AAA' }); | ||
| const b = createDiscoveryRecord({ ...base, timestamp: 999, rawResponse: 'BBB' }); | ||
| expect(discoveryHash(a)).toBe(discoveryHash(b)); // aynı kimlik → aynı hash | ||
| // farklı ECU → farklı hash | ||
| const c = createDiscoveryRecord({ ...base, ecuAddress: '7E9' }); | ||
| expect(discoveryHash(c)).not.toBe(discoveryHash(a)); | ||
| }); | ||
|
|
||
| it('createDiscoveryRecord tam şekil + hex normalize (küçük harf/boşluk)', () => { | ||
| const r = createDiscoveryRecord({ pidOrDid: 'f1 90', discoverySource: 'DID', ecuAddress: '7e8', mode: '22' }); | ||
| expect(r.pidOrDid).toBe('F190'); | ||
| expect(r.ecuAddress).toBe('7E8'); | ||
| expect(r.supported).toBe(false); | ||
| expect(r.vehicleProfile).toBe(''); | ||
| }); | ||
| }); | ||
|
|
||
| /* ── DiscoveryCache — hash dedup ──────────────────────────────────────────── */ | ||
|
|
||
| describe('DiscoveryCache — hash-tabanlı dedup', () => { | ||
| it('aynı kimlik ikinci eklemede false (dedup), farklı kimlik true', () => { | ||
| const c = new DiscoveryCache(); | ||
| const id = { discoverySource: 'PID' as const, mode: '01', ecuAddress: '7E8', pidOrDid: 'A5' }; | ||
| expect(c.add(id)).toBe(true); | ||
| expect(c.add(id)).toBe(false); | ||
| expect(c.has(id)).toBe(true); | ||
| expect(c.add({ ...id, pidOrDid: 'A6' })).toBe(true); | ||
| expect(c.size).toBe(2); | ||
| }); | ||
|
|
||
| it('bounded: tavan aşılınca en eski hash düşer (zero-leak)', () => { | ||
| const c = new DiscoveryCache(2); | ||
| c.add({ discoverySource: 'PID', mode: '01', ecuAddress: '7E8', pidOrDid: 'A1' }); | ||
| c.add({ discoverySource: 'PID', mode: '01', ecuAddress: '7E8', pidOrDid: 'A2' }); | ||
| c.add({ discoverySource: 'PID', mode: '01', ecuAddress: '7E8', pidOrDid: 'A3' }); // A1 düşer | ||
| expect(c.size).toBe(2); | ||
| // A1 düştüğü için yeniden "yeni" sayılır | ||
| expect(c.add({ discoverySource: 'PID', mode: '01', ecuAddress: '7E8', pidOrDid: 'A1' })).toBe(true); | ||
| }); | ||
|
|
||
| it('clear tümünü sıfırlar', () => { | ||
| const c = new DiscoveryCache(); | ||
| c.add({ discoverySource: 'PID', mode: '01', ecuAddress: '7E8', pidOrDid: 'A5' }); | ||
| c.clear(); | ||
| expect(c.size).toBe(0); | ||
| }); | ||
| }); | ||
|
|
||
| /* ── DiscoveryQueue — offline-first FIFO ──────────────────────────────────── */ | ||
|
|
||
| describe('DiscoveryQueue — offline-first kuyruk', () => { | ||
| const rec = (pid: string): DiscoveryRecord => | ||
| createDiscoveryRecord({ pidOrDid: pid, discoverySource: 'PID', mode: '01', ecuAddress: '7E8' }); | ||
|
|
||
| it('enqueue + peekAll kopya döner (dış mutasyon sızmaz)', () => { | ||
| const q = freshQueue(); | ||
| q.enqueue(rec('A1')); | ||
| q.enqueue(rec('A2')); | ||
| const all = q.peekAll(); | ||
| expect(all.map((r) => r.pidOrDid)).toEqual(['A1', 'A2']); | ||
| all.pop(); // kopya — iç durum etkilenmez | ||
| expect(q.size).toBe(2); | ||
| }); | ||
|
|
||
| it('drain kuyruğu boşaltır ve içeriği döndürür', () => { | ||
| const q = freshQueue(); | ||
| q.enqueue(rec('A1')); | ||
| const drained = q.drain(); | ||
| expect(drained).toHaveLength(1); | ||
| expect(q.size).toBe(0); | ||
| }); | ||
|
|
||
| it('bounded: tavan aşılınca en eski düşer (FIFO, zero-leak)', () => { | ||
| const q = new DiscoveryQueue(`test-bound-${_k++}`, 2); | ||
| q.enqueue(rec('A1')); | ||
| q.enqueue(rec('A2')); | ||
| q.enqueue(rec('A3')); // A1 düşer | ||
| expect(q.peekAll().map((r) => r.pidOrDid)).toEqual(['A2', 'A3']); | ||
| }); | ||
|
|
||
| it('kalıcılık: aynı anahtarla yeni örnek diskten yükler (offline-first)', () => { | ||
| const key = `test-persist-${_k++}`; | ||
| const q1 = new DiscoveryQueue(key, 500); | ||
| q1.enqueue(rec('F1')); | ||
| const q2 = new DiscoveryQueue(key, 500); | ||
| expect(q2.peekAll().map((r) => r.pidOrDid)).toEqual(['F1']); | ||
| }); | ||
| }); | ||
|
|
||
| /* ── Export JSON ──────────────────────────────────────────────────────────── */ | ||
|
|
||
| describe('discoveryExport — yerel JSON', () => { | ||
| it('sürümlü zarf: schema/count/records tutarlı ve geri parse edilebilir', () => { | ||
| const records = [createDiscoveryRecord({ pidOrDid: 'F190', discoverySource: 'DID', mode: '22' })]; | ||
| const env = buildDiscoveryEnvelope(records); | ||
| expect(env.schema).toBe(DISCOVERY_EXPORT_SCHEMA); | ||
| expect(env.count).toBe(1); | ||
|
|
||
| const json = exportDiscoveryJson(records); | ||
| const parsed = JSON.parse(json); | ||
| expect(parsed.schema).toBe(DISCOVERY_EXPORT_SCHEMA); | ||
| expect(parsed.count).toBe(1); | ||
| expect(parsed.records[0].pidOrDid).toBe('F190'); | ||
| expect(typeof json).toBe('string'); | ||
| }); | ||
|
|
||
| it('boş liste → count 0, records []', () => { | ||
| const parsed = JSON.parse(exportDiscoveryJson([])); | ||
| expect(parsed.count).toBe(0); | ||
| expect(parsed.records).toEqual([]); | ||
| }); | ||
| }); | ||
|
|
||
| /* ── DiscoveryCaptureService — orkestrasyon ───────────────────────────────── */ | ||
|
|
||
| describe('DiscoveryCaptureService — yakalama kuralları', () => { | ||
| it('registry\'deki PID (0x0C RPM) YAKALANMAZ (reason: known)', () => { | ||
| const { svc, emit } = makeService(); | ||
| const res = svc.capture({ pidOrDid: '0C', discoverySource: 'PID', mode: '01', ecuAddress: '7E8' }); | ||
| expect(res).toEqual({ captured: false, reason: 'known' }); | ||
| expect(svc.getCaptured()).toHaveLength(0); | ||
| expect(emit).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('registry\'de OLMAYAN PID yakalanır + tanı event\'i düşer', () => { | ||
| const { svc, emit } = makeService(); | ||
| const res = svc.capture({ pidOrDid: 'A5', discoverySource: 'PID', mode: '01', ecuAddress: '7E8', supported: true }); | ||
| expect(res.captured).toBe(true); | ||
| expect(svc.getCaptured().map((r) => r.pidOrDid)).toEqual(['A5']); | ||
| expect(emit).toHaveBeenCalledTimes(1); // yeni keşif → tanı event | ||
| }); | ||
|
|
||
| it('yeni DID (F190) yakalanır (bilinen DID kümesi boşken)', () => { | ||
| const { svc } = makeService(); | ||
| const res = svc.capture({ pidOrDid: 'F190', discoverySource: 'DID', mode: '22', ecuAddress: '7E8', decodedValue: 'VF1...' }); | ||
| expect(res.captured).toBe(true); | ||
| expect(svc.getCaptured()[0]?.discoverySource).toBe('DID'); | ||
| }); | ||
|
|
||
| it('setKnownDids ile profildeki DID artık YAKALANMAZ (reason: known)', () => { | ||
| const { svc, emit } = makeService(); | ||
| svc.setKnownDids(['f190']); // küçük harf → normalize | ||
| const res = svc.capture({ pidOrDid: 'F190', discoverySource: 'DID', mode: '22', ecuAddress: '7E8' }); | ||
| expect(res).toEqual({ captured: false, reason: 'known' }); | ||
| expect(emit).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('DUPLICATE: aynı ECU+mode+PID/DID ikinci gözlemde yakalanmaz (reason: duplicate)', () => { | ||
| const { svc, emit } = makeService(); | ||
| const input = { pidOrDid: 'A7', discoverySource: 'PID' as const, mode: '01', ecuAddress: '7E8' }; | ||
| expect(svc.capture(input).captured).toBe(true); | ||
| const second = svc.capture({ ...input, rawResponse: 'farklı-yanit', timestamp: 12345 }); | ||
| expect(second).toEqual({ captured: false, reason: 'duplicate' }); | ||
| expect(svc.getCaptured()).toHaveLength(1); // tek kayıt | ||
| expect(emit).toHaveBeenCalledTimes(1); // event yalnız ilk keşifte | ||
| }); | ||
|
|
||
| it('farklı ECU aynı PID → AYRI keşif (kimlik ECU\'yu içerir)', () => { | ||
| const { svc } = makeService(); | ||
| svc.capture({ pidOrDid: 'A8', discoverySource: 'PID', mode: '01', ecuAddress: '7E8' }); | ||
| svc.capture({ pidOrDid: 'A8', discoverySource: 'PID', mode: '01', ecuAddress: '7E9' }); | ||
| expect(svc.getCaptured()).toHaveLength(2); | ||
| }); | ||
|
|
||
| it('exportJson yakalananları sürümlü zarfta verir', () => { | ||
| const { svc } = makeService(); | ||
| svc.capture({ pidOrDid: 'A9', discoverySource: 'PID', mode: '01', ecuAddress: '7E8' }); | ||
| const parsed = JSON.parse(svc.exportJson()); | ||
| expect(parsed.count).toBe(1); | ||
| expect(parsed.records[0].pidOrDid).toBe('A9'); | ||
| }); | ||
|
|
||
| it('reset cache + kuyruğu temizler', () => { | ||
| const { svc } = makeService(); | ||
| svc.capture({ pidOrDid: 'AA', discoverySource: 'PID', mode: '01', ecuAddress: '7E8' }); | ||
| svc.reset(); | ||
| expect(svc.getCaptured()).toHaveLength(0); | ||
| expect(svc.capturedCount).toBe(0); | ||
| }); | ||
| }); | ||
|
|
||
| /* ── Tanı log entegrasyonu (varsayılan emitör → recordDiag) ───────────────── */ | ||
|
|
||
| describe('DiscoveryCaptureService — varsayılan tanı event wiring', () => { | ||
| beforeEach(() => clearDiag()); | ||
|
|
||
| it('varsayılan emitör yeni keşfi tanı timeline\'ına \'ecuQuery\' event\'i olarak yazar', () => { | ||
| // emitDiagnostic enjekte EDİLMEZ → gerçek recordDiag yolu kullanılır. | ||
| const svc = new DiscoveryCaptureService({ cache: new DiscoveryCache(), queue: freshQueue() }); | ||
| svc.capture({ pidOrDid: 'B5', discoverySource: 'PID', mode: '01', ecuAddress: '7E8', request: '01B5', rawResponse: '41B500' }); | ||
| const evt = getEvents().find((e) => e.technicalMessage.includes('B5')); | ||
| expect(evt).toBeDefined(); | ||
| expect(evt?.stage).toBe('ecuQuery'); | ||
| expect(evt?.status).toBe('info'); | ||
| expect(evt?.command).toBe('01B5'); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| /** | ||
| * DiscoveryCache — keşif kayıtları için HASH-tabanlı, sınırlı (bounded) deduplikasyon. | ||
| * | ||
| * NEDEN: aynı ECU + mode + PID/DID tekrar tekrar gözlemlenir (poll turları); bunları | ||
| * her seferinde kaydetmek kuyruğu/logu şişirir. Cache, bir keşfin FNV-1a kimlik hash'ini | ||
| * tutar → ikinci gözlemi O(1) eler. | ||
| * | ||
| * ZERO-LEAK (CLAUDE.md — sınırlı bellek): hash kümesi SABİT tavanla (maxEntries) sınırlıdır; | ||
| * tavan aşılınca EN ESKİ hash düşürülür (insertion-order, FIFO). Böylece uzun oturumlarda | ||
| * bile bellek sabit kalır. (Düşen hash ileride yeniden yakalanabilir — sınırlı bellek, | ||
| * mükemmel dedup'tan önceliklidir; tavan geniş tutulur.) | ||
| * | ||
| * SAF: React/Capacitor/native importu yok — kök vitest paketinden test edilebilir. | ||
| */ | ||
|
|
||
| import { discoveryHash, type DiscoveryRecord } from './discoveryModel'; | ||
|
|
||
| /** Kimlik alt kümesi — hash için yeterli (tam kayıt gerekmez). */ | ||
| type Identity = Pick<DiscoveryRecord, 'discoverySource' | 'mode' | 'ecuAddress' | 'pidOrDid'>; | ||
|
|
||
| export class DiscoveryCache { | ||
| /** Ekleme sırası korunan hash kümesi (Map anahtar sırası = FIFO eviction). */ | ||
| private readonly _seen = new Map<string, true>(); | ||
| private readonly maxEntries: number; | ||
|
|
||
| constructor(maxEntries = 1024) { | ||
| if (maxEntries < 1) throw new Error('DiscoveryCache maxEntries ≥ 1 olmalı'); | ||
| this.maxEntries = maxEntries; | ||
| } | ||
|
|
||
| /** Bu kimlik daha önce görüldü mü. */ | ||
| has(id: Identity): boolean { | ||
| return this._seen.has(discoveryHash(id)); | ||
| } | ||
|
|
||
| /** | ||
| * Kimliği kaydeder. @returns true = YENİ (ilk kez), false = zaten görülmüştü. | ||
| * Tavan aşılırsa en eski hash düşürülür (bounded). | ||
| */ | ||
| add(id: Identity): boolean { | ||
| const h = discoveryHash(id); | ||
| if (this._seen.has(h)) return false; | ||
| this._seen.set(h, true); | ||
| if (this._seen.size > this.maxEntries) { | ||
| // En eski anahtar (ilk eklenen) — Map iterasyonu ekleme sırasındadır. | ||
| const oldest = this._seen.keys().next().value; | ||
| if (oldest !== undefined) this._seen.delete(oldest); | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| /** Kayıtlı benzersiz kimlik sayısı. */ | ||
| get size(): number { | ||
| return this._seen.size; | ||
| } | ||
|
|
||
| /** Tümünü temizler (oturum sıfırlama / araç değişimi). */ | ||
| clear(): void { | ||
| this._seen.clear(); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.