diff --git a/docs/DEVICE_VALIDATION_LEDGER.md b/docs/DEVICE_VALIDATION_LEDGER.md index 61171ee3..fbe2aafe 100644 --- a/docs/DEVICE_VALIDATION_LEDGER.md +++ b/docs/DEVICE_VALIDATION_LEDGER.md @@ -17,6 +17,7 @@ | # | Özellik | Nerede | Kabul ölçütü (cihazda ne gözlemlenmeli) | Eklendi | |---|---------|--------|------------------------------------------|---------| +| 39 | **🔴 Device Validation Pending — Platform Core Event Bridge Foundation** — Vehicle HAL ve Capability Registry'deki GERÇEK değişiklikleri tip-güvenli, bounded, SALT-OKUNUR biçimde Platform Event Bus'a aktaran İKİ köprü. **Vehicle:** `createVehicleHalEventBridge({hal,bus})` — HAL `subscribe` TAM snapshot verir (per-signal delta yok); köprü snapshot'ı kendi son-değer haritasıyla DIFF'leyip yalnız değişen supported sinyalleri yayınlar. `vehicle.signal.changed` **TRANSIENT** (history dışı, küçük payload: signalId/value özeti/quality/confidence/source/stale/supported/timestamp; **full HAL snapshot/raw CAN TAŞINMAZ**; O(1) dedup, hot-path allocation yok). `vehicle.ignition.changed` (retained) yalnız ignition supported+boolean değişince — **foundation'da ignition kaynağı YOK→supported=false→ASLA yayınlanmaz**. `vehicle.connection.changed` (retained) "en az bir supported sinyal" geçişinde (HAL'e dayalı gerçek olgu; veri yoksa false→SESSİZ). `vehicle.identity.changed` (retained) yalnız identity.supported (fingerprint) değişince (ham fingerprint yalnız metadata, payloadta DEĞİL). `vehicle.health.changed` YAYINLANMAZ (HAL'de health kaynağı yok→uydurulmaz). **Capability:** `createCapabilityEventBridge({registry,bus})` — Registry change event yalnız `{type,id,revision,at}` verir; köprü record'u `getCapability(id)` ile çeker. `capability.record.registered/changed/removed` (changed imza-dedup: aynıysa event yok); `capability.snapshot.changed` **RETAINED, küçük payload** {revision, counts, changedIds(bounded)} — **full registry TAŞINMAZ**; sayımlar start'ta BİR KEZ `createSnapshot()` ile tohumlanır, sonra INCREMENTAL O(1) (**değişiklik başına snapshot allocation YOK**); record payload whitelist (id/domain/status/available/quality/confidence/source/stale/reason/limitations bounded, Registry'ce zaten sanitize). **Ortak:** start/stop/dispose İDEMPOTENT; tek abonelik; **kaynakların SAHİBİ DEĞİL→dispose HAL/Registry/Bus'ı dispose ETMEZ**; dispose sonrası callback no-op; publishedCount/droppedCount (Bus reddi/publish hatası→droppedCount, kaynak servis etkilenmez); fail-soft (HAL/Registry subscribe hatası, tek bozuk signal/record izole, public API throw etmez); privacy (VIN/MAC/koordinat/ham CAN/anahtar event'e girmez); immutable payload; katalog event isimleri; HAL/Registry/Bus'ı DOĞRUDAN import ETMEZ (yalnız TYPE→import yan etkisiz); **timer/polling YOK, yeni bağımlılık YOK, global singleton YOK**. Güvenlik KARARI üretmez (yalnız taşır); Event Bus recursion/priority/bounded kuralları DEĞİŞMEZ. **SystemBoot wiring · provider adapter auto-start · Platform Kernel wiring YOK — AYRI PR.** | `platform/eventBus/bridges/vehicleHalEventBridge.ts` (yeni), `platform/eventBus/bridges/capabilityEventBridge.ts` (yeni), `platform/eventBus/bridges/index.ts` (yeni), `platform/eventBus/index.ts` (export), `__tests__/vehicleHalEventBridge.test.ts` (yeni, ~24 test), `__tests__/capabilityEventBridge.test.ts` (yeni, ~24 test) | Gerçek cihazda (SystemBoot/Platform Kernel wiring PR'ı köprüleri gerçek HAL/Registry/Bus'a bağladıktan SONRA): (1) gerçek araç sinyal değişiklikleri Event Bus'a **duplicate olmadan** ulaşıyor; (2) 3 Hz sinyaller (vehicle.signal.changed transient) history/RAM ŞİŞİRMİYOR; (3) capability değişiklikleri doğru event'lerle (registered/changed/removed/snapshot) yayınlanıyor; (4) kaynağı olmayan ignition/connection event'i UYDURULMUYOR (foundation'da sessiz); (5) listener/publish hatası HAL veya Registry'yi ETKİLEMİYOR (droppedCount artar); (6) stop/dispose sonrası abonelik sızıntısı yok; (7) BASIC_JS/Mali-400'de CPU/FPS regresyonu yok (O(1) dedup, timer yok, full snapshot allocation yok, pasifken sıfır yük). ⚠️ **SystemBoot/Platform Kernel wiring AYRI PR.** Test edilmeden 🟢 yapma. | 2026-07-11 | | 38 | **🔴 Device Validation Pending — Platform Core Real Providers Foundation** — Vehicle HAL Provider Adapter ve Capability Provider Adapter için İLK gerçek, SALT-OKUNUR provider implementasyonları. **Vehicle:** `createUnifiedVehicleStoreProvider({store})` mevcut `useUnifiedVehicleStore`'u (fused worker/CAN/OBD/GPS) adapter'ın `VehicleStoreSource`'ına (getSnapshot/subscribe) uyarlar — store SALT-OKUNUR, her snapshot YENİ dondurulmuş alt-küme (speed/rpm/fuel/odometer/reverse + CAN extras), `rpm undefined→null`, **TPMS yalnız gerçek 4-elemanlı numerik veri varsa**, **ignition EXPOSE EDİLMEZ** (kaynak yok), metadata UYDURULMAZ; store DOĞRUDAN import EDİLMEZ (yapısal DI→import yan etkisiz); provider oluşturulmadan abone OLMAZ; subscribe idempotent unsub + dispose zero-leak. **Capability:** `createRuntimeCapabilityProviders({env,probes})` yalnız KANITLANABİLİR provider üretir. **Kanıt sınıfı:** browser-API presence (geolocation/mediaDevices/bluetooth) → `degraded`/düşük conf (donanım KESİNLEŞMEZ), API yok→unknown; `navigator.connection.type` kesin wifi/cellular→available, aksi unknown; mikrofon izin granted olmadıkça available DEĞİL (denied→restricted); native secure storage authoritative→available/yüksek conf; modül runtime (deep_scan/vehicle_learning/assistant_context/ota/safety_kernel/offline_commands/offline_conversation/push/cloud_commands): **modül var ama runtime bağlı değil→degraded/experimental**, runtime hazır→available, yok→unknown; AI BYOK (gemini/groq/claude/cloud): configured+usable→available, configured→degraded, değil→unavailable; offline map/routing gerçek paket/graf yoksa→unavailable. **KAYNAK YOK:** `ai.grok`(xAI entegrasyonu yok→provider HİÇ ÜRETİLMEZ), `ai.local_model`(gerçek yerel LLM yok→yalnız high-tier+probe, low/mid'de OLUŞTURULMAZ). **Runtime readiness ≠ module existence** (dosya/export var tek başına available DEĞİL). Fabrikalar DI; **global singleton YOK**; probe/navigator yalnız `read()`'te (fabrika yan etkisiz); ağır modül IMPORT YOK (yalnız TYPE); timer/polling YOK; her read try/catch (throw etmez); sonuçlar dondurulmuş; privacy (VIN/MAC/anahtar probe payload'ından sızmaz). **SystemBoot wiring · Event Bus publish · Registry auto-start · UI · Assistant Context değişikliği YOK — hepsi AYRI PR.** | `platform/vehicleHal/providers/unifiedVehicleStoreProvider.ts` (yeni), `platform/vehicleHal/providers/index.ts` (yeni), `platform/vehicleHal/index.ts` (export), `platform/capability/providers/runtimeCapabilityProviders.ts` (yeni), `platform/capability/providers/index.ts` (yeni), `platform/capability/index.ts` (export), `__tests__/unifiedVehicleStoreProvider.test.ts` (yeni, ~16 test), `__tests__/runtimeCapabilityProviders.test.ts` (yeni, ~38 test) | Gerçek cihazda (SystemBoot/Registry/HAL wiring PR'ı bağlandıktan SONRA): (1) gerçek araç state'i (hız/RPM/soğutma/yakıt/voltaj) Vehicle HAL Provider Adapter'a doğru aktarılıyor; (2) GPS/Bluetooth/Wi-Fi/cellular capability sonuçları gerçek cihaz API'leriyle uyumlu; (3) izni olmayan özellik (mikrofon) available GÖRÜNMÜYOR; (4) secure storage yalnız gerçek native kanıtla available; (5) Grok/local model/offline map olmayan cihazda available GÖRÜNMÜYOR (grok provider bile yok); (6) provider stop/dispose sonrası subscription sızıntısı yok; (7) BASIC_JS/Mali-400'de CPU/RAM regresyonu yok (fabrika yan etkisiz, timer yok, pasifken sıfır yük). ⚠️ **SystemBoot/Event Bus wiring AYRI PR.** Test edilmeden 🟢 yapma. | 2026-07-11 | | 37 | **🔴 Device Validation Pending — Capability Provider Adapter Foundation** — Gerçek runtime/cihaz/platform kanıtlarını SALT-OKUNUR biçimde Capability Registry kayıtlarına aktaran provider adapter köprüsü (gerçek kanıt → adapter → Registry). ⚠️ **KOD GERÇEĞİ (salt-okunur analiz):** doğrulanabilir kaynaklar sınırlı — `deviceCapabilities`(WebGL/CSS/SAB/cores browser probe→device/platform) · `vehicleHal.getCapability/hasSignal`(araç sinyali, foundation'da hepsi false) · browser API(gps/mic/bluetooth/storage → yalnız "API var", donanım erişimini KESİNLEŞTİRMEZ) · config/lisans(oem.*/ai-provider BYOK anahtarı). **Runtime kaynağı HİÇ YOK:** `vehicle.ignition`(kontak kaynağı yok)·`ai.grok`(xAI yok)·`navigation.offline_map`(tile paketi yoksa) → unknown/unavailable KALIR. UI/dosya/modül varlığı kanıt DEĞİL. **Adapter:** donanımı DOĞRUDAN taramaz — yalnız DI `providers`'ı okur (foundation'da YERLEŞİK gerçek provider YOK; gerçek prob'lar ayrı wiring PR'ı). **Provider sözleşmesi:** `{id, domain, source, authoritative?, refreshPolicy?, read()}` (read sync/async, kaynak yoksa null). Provider sonucu→Registry evidence'ına normalize→`resolveCapability(id, evidence, domain)` (status'ü REGISTRY çözer — karar kuralları DEĞİŞMEZ). **YALNIZ değişeni günceller** (imza-tabanlı dedup→duplicate Registry update yok, O(1)). **API:** start/refresh/refreshProvider/stop/getStatus/getLastRefreshAt/getProviderResults/reset/dispose. **Refresh:** start'ta ilk refresh + açık çağrı; **steady-state timer/polling YOK**; provider'lar PARALEL (`Promise.allSettled` → biri diğerini bloke etmez); opt-in DI timeout (settle'da temizlenir→leak yok), timeout/hata/null→güvenli **unknown**. **DeviceTier:** varlık uydurmaz — Registry tier engelini uygular (low'da ai.local_model restricted); high tier kanıtsız açmaz. **Fail-soft:** provider throw/Registry update hatası izole, public API throw etmez, dispose sonrası no-op. **Gizlilik:** provider/details/reason sanitize (VIN/MAC/koordinat/secret/API-key sızmaz). **Immutable:** getProviderResults dondurulmuş kopya (nested dahil), girdi mutate edilmez. **YALIN:** Registry'yi DOĞRUDAN import ETMEZ (yapısal DI + type-only import→import yan etkisiz); Event Bus'a yayınlamaz; SystemBoot'a bağlanmaz; persistence/cloud yok; native tarama/OBD-CAN sorgusu yok; yeni bağımlılık yok; provider bounded 128. Registry'nin SAHİBİ DEĞİL→dispose Registry'yi dispose etmez. | `platform/capability/capabilityProviderAdapter.ts` (yeni), `platform/capability/index.ts` (export), `__tests__/capabilityProviderAdapter.test.ts` (yeni, 41 test) | Gerçek cihazda (gerçek provider wiring PR'ı bağlandıktan SONRA): (1) GPS/Bluetooth/Wi-Fi/kamera capability'leri doğru doğrulanıyor (native kanıtla available); (2) donanımı olmayan özellik available GÖRÜNMÜYOR; (3) gerçek kontak kaynağı olmayan cihazda `vehicle.ignition` unknown kalıyor; (4) TPMS kaynağı olmayan araçta available görünmüyor; (5) provider hatası Registry'yi/diğer provider'ları çökertmiyor; (6) aynı kanıt tekrarında duplicate capability update yok; (7) BASIC_JS/Mali-400'de CPU/RAM regresyonu yok (O(1) dedup, timer yok, paralel bounded read). ⚠️ **Gerçek provider implementasyonları · Event Bus · SystemBoot wiring · persistence AYRI PR'lar.** Test edilmeden 🟢 yapma. | 2026-07-11 | | 36 | **🔴 Device Validation Pending — Vehicle HAL Provider Adapter Foundation** — Mevcut normalize araç durumunu (UnifiedVehicleStore fused worker/CAN/OBD/GPS) Vehicle HAL sinyal modeline aktaran ilk resmi SALT-OKUNUR provider adapter köprüsü. ⚠️ **KOD GERÇEĞİ (salt-okunur analiz):** UnifiedVehicleStore şu alanları taşır — `speed`(km/h,null=yok)·`rpm`(fused/`canRpm`)·`fuel`(%)·`odometer`·`reverse` + CAN extras `canCoolantTemp`/`canOilTemp`/`canThrottle`/`canBatteryVolt`/`canGearPos`/`canAmbientTemp`/`canTpmsKpa`/`canDoorOpen`/`canParkingBrake`; **per-signal source/quality/confidence metadata YOK** (fused nativeSource bile store'da yok); **gerçek ignition kaynağı YOK.** Store zaten dirty-check ile dedupe eder. **Aktarılan sinyaller:** speed/rpm/fuel_level/odometer(>0)/coolant_temp/oil_temp/throttle/battery_voltage/gear/ambient_temp/tpms + reverse/door_state/parking_brake (yalnız aktif araç varken=en az bir numerik sinyal mevcut). **supported=false KALAN:** `vehicle.ignition` (kaynak yok, ASLA ingest edilmez), TPMS (canTpmsKpa null iken). **Metadata yok → dürüst eşleme:** CAN-adlı store alanları `source=can`(quality high, confidence 0.9, VAL CAN base ~0.92 gerekçeli), füzyon alanları `source=inferred`(quality medium, 0.6); **magic confidence uydurulmadı** (VAL SIGNAL_SOURCE_DEFAULTS taban gerekçesi). Nullable numerik null→ingest YOK; boolean sinyaller default false'u kaynak sanmamak için aktif-araç kapısına tabi. **Değişiklik modeli:** adapter kendi son-değer haritasıyla YALNIZ değişeni ingest eder (duplicate yok, O(1), tam snapshot clone YOK, hot-path allocation yok). **Lifecycle:** start/stop/refresh/dispose İDEMPOTENT; duplicate subscription yok; store aboneliğini kullanır (yeni timer/polling YOK); dispose sonrası callback no-op; **HAL'in SAHİBİ DEĞİL → dispose HAL'i dispose ETMEZ.** **Fail-soft:** store okunamazsa/HAL ingest hatasında/tek sinyal bozukken diğerleri sürer, public API throw etmez. **Gizlilik:** yalnız numerik/boolean/array değer aktarılır (VIN/MAC/koordinat/ham/kimlik YOK). **YALIN:** store'u DOĞRUDAN import ETMEZ (yapısal `source` DI → import yan etkisiz); Event Bus'a yayınlamaz; Capability Registry'yi güncellemez; SystemBoot'a bağlanmaz; native/OBD/CAN sorgusu başlatmaz; poll frekansı değiştirmez; yeni bağımlılık yok. | `platform/vehicleHal/vehicleHalProviderAdapter.ts` (yeni), `platform/vehicleHal/index.ts` (export), `__tests__/vehicleHalProviderAdapter.test.ts` (yeni, 32 test) | Gerçek araçta (wiring PR'ı `useUnifiedVehicleStore.getState/subscribe`'ı bağladıktan SONRA): (1) hız/RPM/soğutma/yakıt/voltaj HAL'de kaynakla uyumlu (`getSpeed()` vb. gerçek değer); (2) aynı sinyal tekrarında duplicate update yok; (3) kaynağı olmayan ignition ve (verisiz) TPMS destekleniyor GÖRÜNMEZ; (4) reconnect sonrası abonelik duplicate olmuyor (start idempotent); (5) adapter stop/dispose sonrası callback kalmıyor; (6) BASIC_JS/Mali-400'de CPU/FPS regresyonu yok (O(1) değişiklik kontrolü, timer yok, mevcut subscribe akışı). ⚠️ **Event Bus · Capability Registry · SystemBoot wiring AYRI PR'lar.** Test edilmeden 🟢 yapma. | 2026-07-11 | diff --git a/src/__tests__/capabilityEventBridge.test.ts b/src/__tests__/capabilityEventBridge.test.ts new file mode 100644 index 00000000..9f98dcca --- /dev/null +++ b/src/__tests__/capabilityEventBridge.test.ts @@ -0,0 +1,280 @@ +/** + * capabilityEventBridge.test.ts — Capability Registry → Platform Event Bus köprüsü testleri. + * + * Kapsam: registered/changed/removed · duplicate yok · snapshot.changed · snapshot full + * registry taşımıyor · changedIds bounded · retained · record payload sanitize/whitelist · + * Registry/Bus fail-soft · dispose zero-leak · kaynak dispose edilmiyor · privacy · input + * mutate yok · counters · createSnapshot tek-sefer (allocation yok) · katalog · import. + */ + +import { describe, it, expect } from 'vitest'; +import { + createCapabilityEventBridge, + type CapabilityRecordLike, type CapabilityChangeEventLike, +} from '../platform/eventBus/bridges/capabilityEventBridge'; +import { DEFAULT_EVENT_CATALOG } from '../platform/eventBus'; +import bridgeSource from '../platform/eventBus/bridges/capabilityEventBridge.ts?raw'; + +const CATALOG_NAMES = new Set(DEFAULT_EVENT_CATALOG.map((e) => e.name)); + +function rec(id: string, o: Partial = {}): CapabilityRecordLike { + return { + id, domain: o.domain ?? 'device', status: o.status ?? 'unknown', available: o.available ?? false, + quality: o.quality ?? 'unknown', confidence: o.confidence ?? 0, source: o.source ?? 'none', + stale: o.stale ?? false, reason: o.reason ?? null, limitations: o.limitations, + }; +} +function bucket(s: string): 'available' | 'unavailable' | 'unknown' | 'degraded' { + if (s === 'available') return 'available'; + if (s === 'unavailable' || s === 'unsupported') return 'unavailable'; + if (s === 'degraded') return 'degraded'; + return 'unknown'; +} + +function fakeRegistry(initial: CapabilityRecordLike[] = []) { + let listener: ((ev: CapabilityChangeEventLike) => void) | null = null; + const records = new Map(); + for (const r of initial) records.set(r.id, r); + let revision = initial.length; + let subCount = 0; + let snapCount = 0; + const fire = (ev: CapabilityChangeEventLike) => { if (listener) listener(ev); }; + return { + subscribe: (l: (ev: CapabilityChangeEventLike) => void) => { subCount++; listener = l; return () => { listener = null; }; }, + getCapability: (id: string) => records.get(id) ?? null, + createSnapshot: () => { + snapCount++; + const caps = [...records.values()]; + let a = 0, u = 0, unk = 0, d = 0; + for (const c of caps) { const b = bucket(c.status); if (b === 'available') a++; else if (b === 'unavailable') u++; else if (b === 'degraded') d++; else unk++; } + return { revision, availableCount: a, unavailableCount: u, unknownCount: unk, degradedCount: d, capabilities: caps }; + }, + register: (r: CapabilityRecordLike) => { records.set(r.id, r); fire({ type: 'registered', id: r.id, revision: ++revision, at: 1 }); }, + update: (r: CapabilityRecordLike) => { records.set(r.id, r); fire({ type: 'updated', id: r.id, revision: ++revision, at: 1 }); }, + remove: (id: string) => { records.delete(id); fire({ type: 'removed', id, revision: ++revision, at: 1 }); }, + resetAll: () => { records.clear(); fire({ type: 'reset', id: null, revision: ++revision, at: 1 }); }, + hasListener: () => listener !== null, + subCount: () => subCount, + snapCount: () => snapCount, + }; +} + +interface Captured { name: string; payload: unknown; retained?: boolean } +function fakeBus(opts: { throwOnPublish?: boolean; rejectAll?: boolean } = {}) { + const events: Captured[] = []; + return { + events, + publish: (input: Captured) => { + if (opts.throwOnPublish) throw new Error('publish boom'); + if (opts.rejectAll) return null; + events.push(input); + return { id: `evt-${events.length}`, name: input.name, sequence: events.length } as never; + }, + named: (name: string) => events.filter((e) => e.name === name), + }; +} + +describe('CapabilityEventBridge', () => { + it('16) registered event', () => { + const reg = fakeRegistry(); const bus = fakeBus(); + createCapabilityEventBridge({ registry: reg, bus }).start(); + reg.register(rec('device.gps', { status: 'degraded' })); + const r = bus.named('capability.record.registered'); + expect(r.length).toBe(1); + expect((r[0].payload as { id: string }).id).toBe('device.gps'); + }); + + it('17) changed event', () => { + const reg = fakeRegistry([rec('device.gps', { status: 'unknown' })]); const bus = fakeBus(); + createCapabilityEventBridge({ registry: reg, bus }).start(); + reg.update(rec('device.gps', { status: 'available', available: true })); + expect(bus.named('capability.record.changed').length).toBe(1); + }); + + it('18) removed event', () => { + const reg = fakeRegistry([rec('device.gps')]); const bus = fakeBus(); + createCapabilityEventBridge({ registry: reg, bus }).start(); + reg.remove('device.gps'); + const r = bus.named('capability.record.removed'); + expect(r.length).toBe(1); + expect((r[0].payload as { id: string }).id).toBe('device.gps'); + }); + + it('19) duplicate record change → event YOK', () => { + const reg = fakeRegistry([rec('device.gps', { status: 'unknown' })]); const bus = fakeBus(); + createCapabilityEventBridge({ registry: reg, bus }).start(); + reg.update(rec('device.gps', { status: 'unknown' })); // aynı → dedup + expect(bus.named('capability.record.changed').length).toBe(0); + expect(bus.named('capability.snapshot.changed').length).toBe(0); + reg.update(rec('device.gps', { status: 'available', available: true })); // farklı → event + expect(bus.named('capability.record.changed').length).toBe(1); + }); + + it('20) snapshot.changed event', () => { + const reg = fakeRegistry(); const bus = fakeBus(); + createCapabilityEventBridge({ registry: reg, bus }).start(); + reg.register(rec('device.wifi', { status: 'available', available: true })); + expect(bus.named('capability.snapshot.changed').length).toBe(1); + }); + + it('21) snapshot payload full registry TAŞIMIYOR', () => { + const reg = fakeRegistry(); const bus = fakeBus(); + createCapabilityEventBridge({ registry: reg, bus }).start(); + reg.register(rec('device.wifi', { status: 'available', available: true })); + const p = bus.named('capability.snapshot.changed')[0].payload as Record; + expect('capabilities' in p).toBe(false); + expect(Object.keys(p).sort()).toEqual( + ['availableCount', 'changedCapabilityIds', 'degradedCount', 'revision', 'unavailableCount', 'unknownCount'].sort(), + ); + expect((p.availableCount as number)).toBe(1); + }); + + it('22) changedCapabilityIds bounded', () => { + const reg = fakeRegistry(); const bus = fakeBus(); + createCapabilityEventBridge({ registry: reg, bus }).start(); + reg.register(rec('device.gps', { status: 'degraded' })); + const p = bus.named('capability.snapshot.changed')[0].payload as { changedCapabilityIds: string[] }; + expect(Array.isArray(p.changedCapabilityIds)).toBe(true); + expect(p.changedCapabilityIds.length).toBeLessThanOrEqual(8); + expect(p.changedCapabilityIds).toEqual(['device.gps']); + }); + + it('23) snapshot.changed retained', () => { + const reg = fakeRegistry(); const bus = fakeBus(); + createCapabilityEventBridge({ registry: reg, bus }).start(); + reg.register(rec('device.gps', { status: 'degraded' })); + expect(bus.named('capability.snapshot.changed')[0].retained).toBe(true); + }); + + it('24) record payload whitelist/şekil', () => { + const reg = fakeRegistry(); const bus = fakeBus(); + createCapabilityEventBridge({ registry: reg, bus }).start(); + reg.register(rec('ai.gemini', { domain: 'ai', status: 'available', available: true, quality: 'medium', confidence: 0.75, source: 'config', reason: 'configured_usable', limitations: ['byok'] })); + const p = bus.named('capability.record.registered')[0].payload as Record; + expect(Object.keys(p).sort()).toEqual( + ['available', 'confidence', 'domain', 'id', 'limitations', 'quality', 'reason', 'source', 'stale', 'status'].sort(), + ); + expect(p.reason).toBe('configured_usable'); + }); + + it('25) Registry subscribe hatası fail-soft', () => { + const badReg = { + subscribe: () => { throw new Error('sub boom'); }, + getCapability: () => null, + createSnapshot: () => ({ revision: 0, availableCount: 0, unavailableCount: 0, unknownCount: 0, degradedCount: 0, capabilities: [] }), + }; + const b = createCapabilityEventBridge({ registry: badReg, bus: fakeBus() }); + expect(() => b.start()).not.toThrow(); + expect(b.getStatus().started).toBe(true); + }); + + it('26) Bus publish hatası fail-soft → droppedCount', () => { + const reg = fakeRegistry(); const bus = fakeBus({ throwOnPublish: true }); + const b = createCapabilityEventBridge({ registry: reg, bus }); + b.start(); + expect(() => reg.register(rec('device.gps', { status: 'degraded' }))).not.toThrow(); + expect(b.getDroppedCount()).toBeGreaterThan(0); + }); + + it('27) dispose zero-leak', () => { + const reg = fakeRegistry(); const bus = fakeBus(); + const b = createCapabilityEventBridge({ registry: reg, bus }); + b.start(); b.dispose(); + expect(reg.hasListener()).toBe(false); + expect(b.isDisposed).toBe(true); + }); + + it('28) kaynaklar dispose EDİLMİYOR', () => { + const reg = fakeRegistry(); const bus = fakeBus(); + const b = createCapabilityEventBridge({ registry: reg, bus }); + b.start(); b.dispose(); + expect(() => reg.createSnapshot()).not.toThrow(); + expect(() => bus.publish({ name: 'capability.record.changed' } as never)).not.toThrow(); + }); + + it('29) privacy — record payload rogue alanları whitelist dışı bırakır', () => { + const reg = fakeRegistry(); const bus = fakeBus(); + createCapabilityEventBridge({ registry: reg, bus }).start(); + const rogue = { ...rec('device.gps', { status: 'degraded' }), vin: '1HGCM82633A004352', apiKey: 'sk-secret-token-123456' } as unknown as CapabilityRecordLike; + reg.register(rogue); + const json = JSON.stringify(bus.named('capability.record.registered')[0].payload); + expect(json).not.toContain('1HGCM82633A004352'); + expect(json).not.toContain('sk-secret-token'); + }); + + it('30) input mutate edilmiyor', () => { + const reg = fakeRegistry(); const bus = fakeBus(); + createCapabilityEventBridge({ registry: reg, bus }).start(); + const r = rec('device.gps', { status: 'degraded', limitations: ['x'] }); + const clone = JSON.parse(JSON.stringify(r)); + reg.register(r); + expect(JSON.parse(JSON.stringify(r))).toEqual(clone); + }); + + /* ── Ortak (31–40) ── */ + + it('31) start idempotent — tek abonelik (duplicate subscription yok)', () => { + const reg = fakeRegistry(); const bus = fakeBus(); + const b = createCapabilityEventBridge({ registry: reg, bus }); + b.start(); b.start(); + expect(reg.subCount()).toBe(1); + }); + + it('32) dispose sonrası callback no-op', () => { + const reg = fakeRegistry(); const bus = fakeBus(); + const b = createCapabilityEventBridge({ registry: reg, bus }); + b.start(); b.dispose(); + const before = bus.events.length; + reg.register(rec('device.gps', { status: 'degraded' })); // listener bırakıldı → ulaşmaz + expect(bus.events.length).toBe(before); + }); + + it('33) counters doğru', () => { + const reg = fakeRegistry(); const bus = fakeBus(); + const b = createCapabilityEventBridge({ registry: reg, bus }); + b.start(); + reg.register(rec('device.gps', { status: 'degraded' })); + expect(b.getPublishedCount()).toBe(bus.events.length); + expect(b.getDroppedCount()).toBe(0); + }); + + it('34) import yan etkisiz — fabrika abone olmaz', () => { + const reg = fakeRegistry(); const bus = fakeBus(); + createCapabilityEventBridge({ registry: reg, bus }); + expect(reg.subCount()).toBe(0); + expect(reg.snapCount()).toBe(0); + expect(bus.events.length).toBe(0); + }); + + it('38) büyük snapshot allocation yok — createSnapshot yalnız start\'ta BİR KEZ', () => { + const reg = fakeRegistry([rec('a'), rec('b')]); const bus = fakeBus(); + const b = createCapabilityEventBridge({ registry: reg, bus }); + b.start(); + reg.register(rec('device.gps', { status: 'degraded' })); + reg.update(rec('device.gps', { status: 'available', available: true })); + reg.remove('device.gps'); + expect(reg.snapCount()).toBe(1); // değişiklik başına createSnapshot YOK + }); + + it('35/36/37) SystemBoot/provider-auto-start/timer wiring YOK', () => { + expect(bridgeSource).not.toMatch(/from ['"].*SystemBoot/); + expect(bridgeSource).not.toMatch(/createRuntimeCapabilityProviders|createUnifiedVehicleStoreProvider/); + expect(bridgeSource).not.toMatch(/setInterval|setTimeout|requestAnimationFrame/); + }); + + it('39) yayınlanan tüm event isimleri katalogda', () => { + const reg = fakeRegistry(); const bus = fakeBus(); + createCapabilityEventBridge({ registry: reg, bus }).start(); + reg.register(rec('device.gps', { status: 'degraded' })); + reg.update(rec('device.gps', { status: 'available', available: true })); + reg.remove('device.gps'); + reg.resetAll(); + for (const e of bus.events) expect(CATALOG_NAMES.has(e.name)).toBe(true); + }); + + it('40) mevcut modülleri VALUE import etmiyor (yalnız type)', () => { + expect(bridgeSource).not.toMatch(/import\s+\{[^}]*\}\s+from\s+['"].*platformEventBus/); + expect(bridgeSource).not.toMatch(/from ['"].*\/capabilityRegistry/); + expect(bridgeSource).toMatch(/import type/); + }); +}); diff --git a/src/__tests__/vehicleHalEventBridge.test.ts b/src/__tests__/vehicleHalEventBridge.test.ts new file mode 100644 index 00000000..0b7254be --- /dev/null +++ b/src/__tests__/vehicleHalEventBridge.test.ts @@ -0,0 +1,288 @@ +/** + * vehicleHalEventBridge.test.ts — Vehicle HAL → Platform Event Bus köprüsü testleri. + * + * Kapsam: start/stop idempotent · signal change · duplicate yok · değişen değer event · + * transient/history-dışı · küçük payload · unsupported/ignition kaynak yok → event yok · + * connection geçişi · identity · HAL/Bus fail-soft · dispose zero-leak · kaynak dispose + * edilmiyor · privacy · input mutate yok · counters · katalog isimleri · import yan etkisiz. + */ + +import { describe, it, expect } from 'vitest'; +import { + createVehicleHalEventBridge, + type HalSignalLike, type HalSnapshotLike, type HalIdentityLike, +} from '../platform/eventBus/bridges/vehicleHalEventBridge'; +import { DEFAULT_EVENT_CATALOG } from '../platform/eventBus'; +import bridgeSource from '../platform/eventBus/bridges/vehicleHalEventBridge.ts?raw'; + +const CATALOG_NAMES = new Set(DEFAULT_EVENT_CATALOG.map((e) => e.name)); +const NO_IDENTITY: HalIdentityLike = { fingerprintHash: null, protocol: null, supported: false }; + +function signal(id: string, value: unknown, o: Partial = {}): HalSignalLike { + return { + id, value, + quality: o.quality ?? 'high', confidence: o.confidence ?? 0.9, source: o.source ?? 'can', + timestamp: o.timestamp ?? 1000, stale: o.stale ?? false, supported: o.supported ?? true, + }; +} +function snap(signals: HalSignalLike[], rev = 1): HalSnapshotLike { + return { revision: rev, updatedAt: 1000, signals }; +} + +function fakeHal(initial: HalSnapshotLike, identity: HalIdentityLike = NO_IDENTITY) { + let listener: ((s: HalSnapshotLike) => void) | null = null; + let current = initial; + let id = identity; + let subCount = 0; + return { + subscribe: (l: (s: HalSnapshotLike) => void) => { subCount++; listener = l; return () => { listener = null; }; }, + getSnapshot: () => current, + getVehicleIdentity: () => id, + emit: (next: HalSnapshotLike) => { current = next; if (listener) listener(next); }, + setIdentity: (i: HalIdentityLike) => { id = i; }, + hasListener: () => listener !== null, + subCount: () => subCount, + }; +} + +interface Captured { name: string; payload: unknown; domain?: string; source?: string; transient?: boolean; retained?: boolean; vehicleFingerprintHash?: string } +function fakeBus(opts: { throwOnPublish?: boolean; rejectAll?: boolean } = {}) { + const events: Captured[] = []; + return { + events, + publish: (input: Captured) => { + if (opts.throwOnPublish) throw new Error('publish boom'); + if (opts.rejectAll) return null; + events.push(input); + return { id: `evt-${events.length}`, name: input.name, sequence: events.length } as never; + }, + named: (name: string) => events.filter((e) => e.name === name), + }; +} + +const EMPTY = snap([]); + +describe('VehicleHalEventBridge', () => { + it('1) start idempotent — tek abonelik', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus(); + const b = createVehicleHalEventBridge({ hal, bus }); + b.start(); b.start(); + expect(hal.subCount()).toBe(1); + expect(b.getStatus().started).toBe(true); + }); + + it('2) stop idempotent', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus(); + const b = createVehicleHalEventBridge({ hal, bus }); + b.start(); + expect(() => { b.stop(); b.stop(); }).not.toThrow(); + expect(hal.hasListener()).toBe(false); + }); + + it('3) signal change → vehicle.signal.changed yayınlanır', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus(); + createVehicleHalEventBridge({ hal, bus }).start(); + hal.emit(snap([signal('vehicle.speed', 60)])); + const sc = bus.named('vehicle.signal.changed'); + expect(sc.length).toBe(1); + expect((sc[0].payload as { signalId: string }).signalId).toBe('vehicle.speed'); + expect((sc[0].payload as { value: number }).value).toBe(60); + }); + + it('4) aynı değer → duplicate event YOK', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus(); + createVehicleHalEventBridge({ hal, bus }).start(); + hal.emit(snap([signal('vehicle.speed', 60)])); + hal.emit(snap([signal('vehicle.speed', 60)], 2)); + expect(bus.named('vehicle.signal.changed').length).toBe(1); + }); + + it('5) değişen değer → yeni event', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus(); + createVehicleHalEventBridge({ hal, bus }).start(); + hal.emit(snap([signal('vehicle.speed', 60)])); + hal.emit(snap([signal('vehicle.speed', 61)], 2)); + expect(bus.named('vehicle.signal.changed').length).toBe(2); + }); + + it('6) signal.changed transient (history dışı) metadata', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus(); + createVehicleHalEventBridge({ hal, bus }).start(); + hal.emit(snap([signal('vehicle.rpm', 2500)])); + expect(bus.named('vehicle.signal.changed')[0].transient).toBe(true); + }); + + it('7) küçük payload — full snapshot/raw taşımıyor', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus(); + createVehicleHalEventBridge({ hal, bus }).start(); + hal.emit(snap([signal('vehicle.speed', 60)])); + const p = bus.named('vehicle.signal.changed')[0].payload as Record; + expect(Object.keys(p).sort()).toEqual( + ['confidence', 'quality', 'signalId', 'source', 'stale', 'supported', 'timestamp', 'value'].sort(), + ); + expect('signals' in p).toBe(false); // full HAL snapshot taşınmıyor + }); + + it('8) unsupported signal → event yok', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus(); + createVehicleHalEventBridge({ hal, bus }).start(); + hal.emit(snap([signal('vehicle.speed', 0, { supported: false })])); + expect(bus.named('vehicle.signal.changed').length).toBe(0); + }); + + it('9) ignition kaynağı yoksa (supported=false) → ignition event YOK; supported+değişince → var', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus(); + createVehicleHalEventBridge({ hal, bus }).start(); + hal.emit(snap([signal('vehicle.ignition', false, { supported: false })])); + expect(bus.named('vehicle.ignition.changed').length).toBe(0); + // ignition signal.changed olarak da yayınlanmamalı (ayrı ele alınır) + expect(bus.named('vehicle.signal.changed').some((e) => (e.payload as { signalId?: string }).signalId === 'vehicle.ignition')).toBe(false); + + hal.emit(snap([signal('vehicle.ignition', true, { supported: true })], 2)); + const ign = bus.named('vehicle.ignition.changed'); + expect(ign.length).toBe(1); + expect((ign[0].payload as { value: boolean }).value).toBe(true); + expect(ign[0].retained).toBe(true); + }); + + it('9b) connection.changed — supported sinyal gelince false→true (retained), veri yoksa sessiz', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus(); + createVehicleHalEventBridge({ hal, bus }).start(); + expect(bus.named('vehicle.connection.changed').length).toBe(0); // boş → sessiz + hal.emit(snap([signal('vehicle.speed', 30)])); + const cc = bus.named('vehicle.connection.changed'); + expect(cc.length).toBe(1); + expect((cc[0].payload as { connected: boolean }).connected).toBe(true); + expect(cc[0].retained).toBe(true); + }); + + it('9c) identity.changed — yalnız supported + değişince (retained), fingerprint payloadta değil', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus(); + createVehicleHalEventBridge({ hal, bus }).start(); + expect(bus.named('vehicle.identity.changed').length).toBe(0); // unsupported → sessiz + hal.setIdentity({ fingerprintHash: 'deadbeefcafe', protocol: 'ISO15765', supported: true }); + hal.emit(snap([signal('vehicle.speed', 10)], 2)); + const idn = bus.named('vehicle.identity.changed'); + expect(idn.length).toBe(1); + expect(idn[0].vehicleFingerprintHash).toBe('deadbeefcafe'); // metadata alanı (Bus doğrular) + expect('fingerprintHash' in (idn[0].payload as object)).toBe(false); // payloadta ham kimlik YOK + }); + + it('10) HAL subscribe hatası fail-soft', () => { + const badHal = { + subscribe: () => { throw new Error('sub boom'); }, + getSnapshot: () => EMPTY, + getVehicleIdentity: () => NO_IDENTITY, + }; + const b = createVehicleHalEventBridge({ hal: badHal, bus: fakeBus() }); + expect(() => b.start()).not.toThrow(); + expect(b.getStatus().started).toBe(true); + }); + + it('11) Bus publish hatası fail-soft → droppedCount artar', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus({ throwOnPublish: true }); + const b = createVehicleHalEventBridge({ hal, bus }); + b.start(); + expect(() => hal.emit(snap([signal('vehicle.speed', 60)]))).not.toThrow(); + expect(b.getDroppedCount()).toBeGreaterThan(0); + expect(b.getPublishedCount()).toBe(0); + }); + + it('11b) Bus reddederse (null) droppedCount artar', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus({ rejectAll: true }); + const b = createVehicleHalEventBridge({ hal, bus }); + b.start(); + hal.emit(snap([signal('vehicle.speed', 60)])); + expect(b.getDroppedCount()).toBeGreaterThan(0); + }); + + it('12) dispose zero-leak — abonelik bırakılır', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus(); + const b = createVehicleHalEventBridge({ hal, bus }); + b.start(); + b.dispose(); + expect(hal.hasListener()).toBe(false); + expect(b.isDisposed).toBe(true); + }); + + it('13) kaynaklar dispose EDİLMİYOR — HAL/Bus kullanılabilir kalır', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus(); + const b = createVehicleHalEventBridge({ hal, bus }); + b.start(); b.dispose(); + // fakeHal/bus'ta dispose metodu YOK; köprü çağırsaydı patlardı → çağırmıyor. + expect(() => hal.getSnapshot()).not.toThrow(); + expect(() => bus.publish({ name: 'vehicle.signal.changed' } as never)).not.toThrow(); + }); + + it('14) privacy — yayınlanan payload ham kimlik/koordinat içermez', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus(); + createVehicleHalEventBridge({ hal, bus }).start(); + hal.setIdentity({ fingerprintHash: 'deadbeef', protocol: 'CAN', supported: true }); + hal.emit(snap([signal('vehicle.speed', 55), signal('vehicle.tpms', [230, 231, 229, 232])], 2)); + const all = JSON.stringify(bus.events.map((e) => e.payload)); + expect(all).not.toMatch(/[A-HJ-NPR-Z0-9]{17}/); // VIN kalıbı yok + expect(all).not.toMatch(/\d{1,3}\.\d{4,},/); // koordinat yok + }); + + it('15) input mutate edilmiyor — snapshot değişmez', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus(); + createVehicleHalEventBridge({ hal, bus }).start(); + const s = snap([signal('vehicle.speed', 60)]); + const clone = JSON.parse(JSON.stringify(s)); + hal.emit(s); + expect(JSON.parse(JSON.stringify(s))).toEqual(clone); + }); + + /* ── Ortak (31–40) ── */ + + it('32) dispose sonrası callback no-op', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus(); + const b = createVehicleHalEventBridge({ hal, bus }); + b.start(); + // dispose HAL aboneliğini bıraktığı için emit listener'a ulaşmaz; yine de doğrudan çağrı no-op olmalı. + b.dispose(); + const before = bus.events.length; + hal.emit(snap([signal('vehicle.speed', 99)], 5)); + expect(bus.events.length).toBe(before); + }); + + it('33) counters doğru', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus(); + const b = createVehicleHalEventBridge({ hal, bus }); + b.start(); + hal.emit(snap([signal('vehicle.speed', 60)])); + hal.emit(snap([signal('vehicle.speed', 61)], 2)); + expect(b.getPublishedCount()).toBe(bus.events.length); + expect(b.getDroppedCount()).toBe(0); + }); + + it('34) import yan etkisiz — fabrika abone olmaz', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus(); + createVehicleHalEventBridge({ hal, bus }); + expect(hal.subCount()).toBe(0); + expect(bus.events.length).toBe(0); + }); + + it('35/36) SystemBoot/provider auto-start wiring YOK', () => { + expect(bridgeSource).not.toMatch(/from ['"].*SystemBoot/); + expect(bridgeSource).not.toMatch(/createUnifiedVehicleStoreProvider|createRuntimeCapabilityProviders/); + }); + + it('37) timer/polling YOK (BASIC_JS düşük maliyet)', () => { + expect(bridgeSource).not.toMatch(/setInterval|setTimeout|requestAnimationFrame/); + }); + + it('39) yayınlanan tüm event isimleri Event Bus kataloğunda', () => { + const hal = fakeHal(EMPTY); const bus = fakeBus(); + createVehicleHalEventBridge({ hal, bus }).start(); + hal.setIdentity({ fingerprintHash: 'deadbeef', protocol: 'CAN', supported: true }); + hal.emit(snap([signal('vehicle.speed', 60), signal('vehicle.ignition', true, { supported: true })], 2)); + for (const e of bus.events) expect(CATALOG_NAMES.has(e.name)).toBe(true); + }); + + it('40) mevcut modülleri VALUE import etmiyor (yalnız type)', () => { + expect(bridgeSource).not.toMatch(/import\s+\{[^}]*\}\s+from\s+['"].*platformEventBus/); // value import yok + expect(bridgeSource).not.toMatch(/from ['"].*\/vehicleHal['"]/); + expect(bridgeSource).toMatch(/import type/); + }); +}); diff --git a/src/platform/eventBus/bridges/capabilityEventBridge.ts b/src/platform/eventBus/bridges/capabilityEventBridge.ts new file mode 100644 index 00000000..d878c5cc --- /dev/null +++ b/src/platform/eventBus/bridges/capabilityEventBridge.ts @@ -0,0 +1,288 @@ +/** + * capabilityEventBridge — Capability Registry değişikliklerini Platform Event Bus'a + * aktaran SALT-OKUNUR köprü — FOUNDATION. + * + * KÖPRÜ: (Capability Registry değişimi) → PlatformEvent (`capability.*`). Yalnız bu. + * + * ⚠️ KOD GERÇEĞİ (salt-okunur analiz): `CapabilityRegistry.subscribe(listener)` listener'a + * yalnız `{type, id, revision, at}` (registered/updated/removed/reset) verir — record'un + * KENDİSİNİ vermez. Köprü record'u `getCapability(id)` ile ÇEKER. Full registry snapshot + * Event Bus'a TAŞINMAZ; `capability.snapshot.changed` yalnız {revision, sayımlar, changedIds} + * taşır. Sayımlar başlangıçta BİR KEZ `createSnapshot()` ile tohumlanır, sonra INCREMENTAL + * (O(1), değişiklik başına full snapshot allocation YOK). + * + * YAYINLANAN EVENT'LER: + * - `capability.record.registered` → yeni record (getCapability payload). + * - `capability.record.changed` → record değişince (imza-dedup: aynıysa event YOK). + * - `capability.record.removed` → {id}. + * - `capability.snapshot.changed` → RETAINED, küçük payload {revision, counts, changedIds(bounded)}. + * + * NE YAPMAZ (bilinçli): Registry KARAR KURALLARINI değiştirmez (yalnız taşır) · SystemBoot'a + * BAĞLANMAZ · provider adapter'ları BAŞLATMAZ · native/SQL/UI YOK · timer/polling YOK. + * Registry'yi DOĞRUDAN import ETMEZ (yapısal DI) → import YAN ETKİSİZ. Kaynakların SAHİBİ + * DEĞİL: `dispose()` Registry'yi/Bus'ı dispose ETMEZ, yalnız kendi aboneliğini bırakır. + * + * ZERO-LEAK: start/stop/dispose İDEMPOTENT; tek abonelik; dispose sonrası callback no-op. + * FAIL-SOFT: Registry subscribe hatası / Bus publish hatası / tek bozuk record köprüyü + * çökertmez; public API throw ETMEZ; publish reddi → `droppedCount`. PRIVACY: record.reason/ + * limitations Registry'ce zaten sanitize; payload'a VIN/MAC/koordinat/ham/anahtar girmez. + */ + +import type { PlatformEvent } from '../platformEventBus'; + +/* ══════════════════════════════════════════════════════════════════════════ + * DI hedefleri (yapısal — CapabilityRegistry / PlatformEventBus uyar) + * ════════════════════════════════════════════════════════════════════════ */ + +export interface CapabilityChangeEventLike { + readonly type: 'registered' | 'updated' | 'removed' | 'reset'; + readonly id: string | null; + readonly revision: number; + readonly at: number; +} + +/** Registry record (CapabilityRecord alt kümesi — yapısal, salt-okunur). */ +export interface CapabilityRecordLike { + readonly id: string; + readonly domain: string; + readonly status: string; + readonly available: boolean; + readonly quality: string; + readonly confidence: number; + readonly source: string; + readonly stale: boolean; + readonly reason: string | null; + readonly limitations?: readonly string[]; +} + +export interface CapabilitySnapshotLike { + readonly revision: number; + readonly availableCount: number; + readonly unavailableCount: number; + readonly unknownCount: number; + readonly degradedCount: number; + readonly capabilities: readonly CapabilityRecordLike[]; +} + +/** Registry kaynağı (DI) — gerçek wiring PR'ı `capabilityRegistry`'yi geçirir. */ +export interface CapabilityRegistrySource { + subscribe: (listener: (event: CapabilityChangeEventLike) => void) => (() => void); + getCapability: (id: string) => CapabilityRecordLike | null; + createSnapshot: () => CapabilitySnapshotLike; +} + +export interface EventBusPublishTarget { + publish: (input: { + name: string; + payload?: unknown; + domain?: string; + source?: string; + transient?: boolean; + retained?: boolean; + }) => PlatformEvent | null; +} + +export interface CapabilityEventBridgeDeps { + readonly registry: CapabilityRegistrySource; + readonly bus: EventBusPublishTarget; + readonly now?: () => number; +} + +export interface CapabilityEventBridgeStatus { + readonly started: boolean; + readonly disposed: boolean; + readonly publishedCount: number; + readonly droppedCount: number; + readonly lastPublishAt: number | null; +} + +const MAX_LIMITATIONS = 4; + +/* ══════════════════════════════════════════════════════════════════════════ + * Saf yardımcılar + * ════════════════════════════════════════════════════════════════════════ */ + +type Bucket = 'available' | 'unavailable' | 'unknown' | 'degraded'; + +/** createSnapshot ile AYNI kovalama (available/unavailable+unsupported/degraded/diğer). */ +function _bucket(status: string): Bucket { + if (status === 'available') return 'available'; + if (status === 'unavailable' || status === 'unsupported') return 'unavailable'; + if (status === 'degraded') return 'degraded'; + return 'unknown'; // unknown + restricted +} + +function _recordSig(r: CapabilityRecordLike): string { + return `${r.status}|${r.available}|${r.quality}|${r.confidence}|${r.source}|${r.stale}|${r.reason ?? ''}`; +} + +/** Record → küçük, güvenli payload (reason/limitations Registry'ce zaten sanitize). */ +function _recordPayload(r: CapabilityRecordLike): Record { + return { + id: r.id, + domain: r.domain, + status: r.status, + available: r.available, + quality: r.quality, + confidence: typeof r.confidence === 'number' ? r.confidence : 0, + source: r.source, + stale: !!r.stale, + reason: r.reason ?? null, + limitations: Array.isArray(r.limitations) ? r.limitations.slice(0, MAX_LIMITATIONS) : [], + }; +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Köprü + * ════════════════════════════════════════════════════════════════════════ */ + +export class CapabilityEventBridge { + private readonly _registry: CapabilityRegistrySource; + private readonly _bus: EventBusPublishTarget; + private readonly _now: () => number; + + private _unsub: (() => void) | null = null; + private _started = false; + private _disposed = false; + private _publishedCount = 0; + private _droppedCount = 0; + private _lastPublishAt: number | null = null; + + private readonly _recordState = new Map(); + private readonly _counts: Record = { available: 0, unavailable: 0, unknown: 0, degraded: 0 }; + private _revision = 0; + + constructor(deps: CapabilityEventBridgeDeps) { + this._registry = deps.registry; + this._bus = deps.bus; + this._now = typeof deps.now === 'function' ? deps.now : () => Date.now(); + } + + private _nowSafe(): number { + try { const n = this._now(); return Number.isFinite(n) ? n : 0; } catch { return 0; } + } + + /** Registry'ye abone olur + sayımları BİR KEZ tohumlar (SESSİZ — event yok). İDEMPOTENT. */ + start(): void { + if (this._disposed || this._started) return; + this._started = true; + // Başlangıç sayımları — tek seferlik cold-path (createSnapshot); event YAYINLANMAZ. + try { + const snap = this._registry.createSnapshot(); + if (snap && Array.isArray(snap.capabilities)) { + for (const r of snap.capabilities) { + if (!r || typeof r.id !== 'string') continue; + const bucket = _bucket(r.status); + this._recordState.set(r.id, { sig: _recordSig(r), bucket }); + this._counts[bucket]++; + } + this._revision = typeof snap.revision === 'number' ? snap.revision : 0; + } + } catch { /* fail-soft: boş baseline */ } + try { + this._unsub = this._registry.subscribe((ev) => this._onChange(ev)); + } catch { + this._unsub = null; // abonelik kurulamadı → fail-soft + } + } + + private _onChange(ev: CapabilityChangeEventLike): void { + if (this._disposed || !this._started) return; // dispose/stop sonrası no-op + if (!ev || typeof ev !== 'object') return; + if (typeof ev.revision === 'number') this._revision = ev.revision; + + if (ev.type === 'reset') { + for (const k of Object.keys(this._counts) as Bucket[]) this._counts[k] = 0; + this._recordState.clear(); + this._publishSnapshot([]); + return; + } + + if (typeof ev.id !== 'string') return; + + if (ev.type === 'removed') { + const prev = this._recordState.get(ev.id); + if (prev) { this._counts[prev.bucket]--; this._recordState.delete(ev.id); } + this._publish('capability.record.removed', { id: ev.id }); + this._publishSnapshot([ev.id]); + return; + } + + // registered | updated + let rec: CapabilityRecordLike | null = null; + try { rec = this._registry.getCapability(ev.id); } catch { rec = null; } + if (!rec || typeof rec !== 'object') return; // record yok → fail-soft + + const sig = _recordSig(rec); + const prev = this._recordState.get(ev.id); + if (prev && prev.sig === sig) return; // değişmedi → event YOK (record + snapshot) + + const bucket = _bucket(rec.status); + if (prev) this._counts[prev.bucket]--; + this._counts[bucket]++; + this._recordState.set(ev.id, { sig, bucket }); + + const name = ev.type === 'registered' && !prev ? 'capability.record.registered' : 'capability.record.changed'; + this._publish(name, _recordPayload(rec)); + this._publishSnapshot([ev.id]); + } + + /** capability.snapshot.changed (retained, küçük payload — full registry TAŞINMAZ). */ + private _publishSnapshot(changedCapabilityIds: readonly string[]): void { + this._publish('capability.snapshot.changed', { + revision: this._revision, + availableCount: this._counts.available, + unavailableCount: this._counts.unavailable, + unknownCount: this._counts.unknown, + degradedCount: this._counts.degraded, + changedCapabilityIds: changedCapabilityIds.slice(0, 8), + }, { retained: true }); + } + + private _publish(name: string, payload: unknown, opts: { retained?: boolean } = {}): void { + try { + const ev = this._bus.publish({ name, payload, domain: 'capability', source: 'capability_registry', retained: opts.retained }); + if (ev) { this._publishedCount++; this._lastPublishAt = this._nowSafe(); } + else { this._droppedCount++; } + } catch { + this._droppedCount++; + } + } + + stop(): void { + if (!this._started) return; + this._started = false; + if (this._unsub) { try { this._unsub(); } catch { /* */ } this._unsub = null; } + } + + getStatus(): CapabilityEventBridgeStatus { + return Object.freeze({ + started: this._started, + disposed: this._disposed, + publishedCount: this._publishedCount, + droppedCount: this._droppedCount, + lastPublishAt: this._lastPublishAt, + }); + } + + getPublishedCount(): number { return this._publishedCount; } + getDroppedCount(): number { return this._droppedCount; } + + /** Zero-leak: aboneliği bırakır + kilitler. Registry/Bus çağıranındır → dispose EDİLMEZ. */ + dispose(): void { + if (this._disposed) return; + this.stop(); + this._recordState.clear(); + this._disposed = true; + } + + get isDisposed(): boolean { return this._disposed; } +} + +/** + * Fabrika — DI ile örnek üretir. YAN ETKİSİZ: abonelik/Registry okuma yalnız `start()`'ta → + * import edilmesi davranış değiştirmez. GLOBAL SINGLETON YOK. + */ +export function createCapabilityEventBridge(deps: CapabilityEventBridgeDeps): CapabilityEventBridge { + return new CapabilityEventBridge(deps); +} diff --git a/src/platform/eventBus/bridges/index.ts b/src/platform/eventBus/bridges/index.ts new file mode 100644 index 00000000..4cd95c5a --- /dev/null +++ b/src/platform/eventBus/bridges/index.ts @@ -0,0 +1,29 @@ +/** + * eventBus/bridges — Vehicle HAL / Capability Registry → Platform Event Bus köprüleri (barrel). + * + * Bu modülü import etmek YAN ETKİ ÜRETMEZ: köprü yalnız fabrika ile oluşturulup `start()` + * çağrılınca abone olur. SystemBoot wiring · provider auto-start KAPSAM DIŞIDIR (ayrı PR). + */ + +export { + createVehicleHalEventBridge, + VehicleHalEventBridge, + type VehicleHalSource, + type EventBusPublishTarget as VehicleEventBusPublishTarget, + type HalSignalLike, + type HalSnapshotLike, + type HalIdentityLike, + type VehicleHalEventBridgeDeps, + type VehicleHalEventBridgeStatus, +} from './vehicleHalEventBridge'; + +export { + createCapabilityEventBridge, + CapabilityEventBridge, + type CapabilityRegistrySource, + type CapabilityChangeEventLike, + type CapabilityRecordLike, + type CapabilitySnapshotLike, + type CapabilityEventBridgeDeps, + type CapabilityEventBridgeStatus, +} from './capabilityEventBridge'; diff --git a/src/platform/eventBus/bridges/vehicleHalEventBridge.ts b/src/platform/eventBus/bridges/vehicleHalEventBridge.ts new file mode 100644 index 00000000..0d7e5d79 --- /dev/null +++ b/src/platform/eventBus/bridges/vehicleHalEventBridge.ts @@ -0,0 +1,289 @@ +/** + * vehicleHalEventBridge — Vehicle HAL değişikliklerini Platform Event Bus'a aktaran + * SALT-OKUNUR köprü — FOUNDATION. + * + * KÖPRÜ: (Vehicle HAL snapshot/identity değişimi) → PlatformEvent (`vehicle.*`). Yalnız bu. + * + * ⚠️ KOD GERÇEĞİ (salt-okunur analiz): `VehicleHal.subscribe(listener)` listener'a **TAM + * snapshot** (`revision`/`updatedAt`/`signals[]`) verir — per-signal delta YOK; identity + * AYRI (`getVehicleIdentity()`), her identity değişiminde de `_emit()` snapshot yayar. + * HAL zaten value/source/quality/supported/stale değişiminde emit eder. Köprü bu snapshot'ı + * kendi son-değer haritasıyla DIFF'leyip yalnız GERÇEKTEN değişen sinyalleri yayınlar. + * + * YAYINLANAN EVENT'LER (yalnız gerçek HAL verisi varsa): + * - `vehicle.signal.changed` → TRANSIENT (history dışı), yalnız supported sinyal, küçük payload. + * - `vehicle.connection.changed` → RETAINED, "en az bir supported sinyal var mı" geçişinde + * (HAL'e dayalı gerçek olgu; veri yoksa false kalır → foundation'da SESSİZ). + * - `vehicle.ignition.changed` → RETAINED, yalnız ignition supported + boolean değeri değişince. + * Foundation'da ignition kaynağı YOK → supported=false → ASLA yayınlanmaz. + * - `vehicle.identity.changed` → RETAINED, yalnız identity.supported (fingerprint) + değişince. + * `vehicle.health.changed` YAYINLANMAZ: HAL'de health kaynağı YOK (event UYDURULMAZ). + * + * NE YAPMAZ (bilinçli): Güvenlik KARARI üretmez (yalnız taşır) · SystemBoot'a BAĞLANMAZ · + * provider adapter'ları BAŞLATMAZ · native/OBD/CAN/poll DEĞİŞTİRMEZ · UI/SQL YOK · timer/ + * polling YOK. HAL'i DOĞRUDAN import ETMEZ (yapısal DI) → import YAN ETKİSİZ. Kaynakların + * SAHİBİ DEĞİL: `dispose()` HAL'i/Bus'ı dispose ETMEZ, yalnız kendi aboneliğini bırakır. + * + * ZERO-LEAK: start/stop/dispose İDEMPOTENT; tek abonelik; dispose sonrası callback no-op. + * FAIL-SOFT: HAL subscribe hatası / Bus publish hatası / tek bozuk sinyal köprüyü çökertmez; + * public API throw ETMEZ; publish reddedilirse `droppedCount` artar (kaynak servis etkilenmez). + * PRIVACY: payload yalnız normalize değer + metadata (VIN/MAC/koordinat/ham CAN/anahtar YOK). + */ + +import type { PlatformEvent } from '../platformEventBus'; + +/* ══════════════════════════════════════════════════════════════════════════ + * DI hedefleri — VehicleHal ve PlatformEventBus yapısal olarak uyar (doğrudan import YOK) + * ════════════════════════════════════════════════════════════════════════ */ + +type SignalQuality = 'low' | 'medium' | 'high' | 'unknown'; +type SignalSource = string; + +/** Okunacak HAL sinyali (VehicleSignal alt kümesi — yapısal). */ +export interface HalSignalLike { + readonly id: string; + readonly value: unknown; + readonly quality: SignalQuality; + readonly confidence: number; + readonly source: SignalSource; + readonly timestamp: number; + readonly stale: boolean; + readonly supported: boolean; +} + +export interface HalSnapshotLike { + readonly revision: number; + readonly updatedAt: number; + readonly signals: readonly HalSignalLike[]; +} + +export interface HalIdentityLike { + readonly fingerprintHash: string | null; + readonly protocol: string | null; + readonly supported: boolean; +} + +/** HAL kaynağı (DI) — gerçek wiring PR'ı `vehicleHal`'i geçirir. */ +export interface VehicleHalSource { + subscribe: (listener: (snapshot: HalSnapshotLike) => void) => (() => void); + getSnapshot: () => HalSnapshotLike; + getVehicleIdentity: () => HalIdentityLike; +} + +/** Bus hedefi (DI) — yalnız `publish` gerekir (PlatformEventBus yapısal uyar). */ +export interface EventBusPublishTarget { + publish: (input: { + name: string; + payload?: unknown; + domain?: string; + source?: string; + transient?: boolean; + retained?: boolean; + vehicleFingerprintHash?: string; + }) => PlatformEvent | null; +} + +export interface VehicleHalEventBridgeDeps { + readonly hal: VehicleHalSource; + readonly bus: EventBusPublishTarget; + readonly now?: () => number; +} + +export interface VehicleHalEventBridgeStatus { + readonly started: boolean; + readonly disposed: boolean; + readonly publishedCount: number; + readonly droppedCount: number; + readonly lastPublishAt: number | null; +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Sabitler + * ════════════════════════════════════════════════════════════════════════ */ + +const IGNITION_ID = 'vehicle.ignition'; +const MAX_TPMS = 8; // TPMS dizi taşması koruması + +/* ══════════════════════════════════════════════════════════════════════════ + * Saf yardımcılar + * ════════════════════════════════════════════════════════════════════════ */ + +/** Payload için güvenli/küçük değer özeti (raw taşımaz). */ +function _valueSummary(value: unknown): number | boolean | number[] | null { + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value === 'boolean') return value; + if (Array.isArray(value)) { + const nums = value.filter((x) => typeof x === 'number' && Number.isFinite(x)).slice(0, MAX_TPMS) as number[]; + return nums.length > 0 ? nums : null; + } + return null; +} + +/** Değişiklik imzası (HAL'in kendi değişim tanımıyla hizalı — duplicate önleme). */ +function _signalSig(s: HalSignalLike): string { + const v = Array.isArray(s.value) ? s.value.join(',') : String(s.value); + return `${v}|${s.quality}|${s.source}|${s.stale}|${s.supported}`; +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Köprü + * ════════════════════════════════════════════════════════════════════════ */ + +export class VehicleHalEventBridge { + private readonly _hal: VehicleHalSource; + private readonly _bus: EventBusPublishTarget; + private readonly _now: () => number; + + private _unsub: (() => void) | null = null; + private _started = false; + private _disposed = false; + private _publishedCount = 0; + private _droppedCount = 0; + private _lastPublishAt: number | null = null; + + private readonly _lastSignalSig = new Map(); // signalId → son yayın imzası + private _lastConnected = false; // "en az bir supported sinyal" + private _lastIgnition: boolean | null = null; // son yayınlanan ignition değeri + private _lastIdentitySig: string | null = null; // fingerprint|protocol + + constructor(deps: VehicleHalEventBridgeDeps) { + this._hal = deps.hal; + this._bus = deps.bus; + this._now = typeof deps.now === 'function' ? deps.now : () => Date.now(); + } + + private _nowSafe(): number { + try { const n = this._now(); return Number.isFinite(n) ? n : 0; } catch { return 0; } + } + + /** HAL'e abone olur + ilk snapshot'ı işler. İDEMPOTENT. */ + start(): void { + if (this._disposed || this._started) return; + this._started = true; + try { + this._unsub = this._hal.subscribe((snap) => this._onSnapshot(snap)); + } catch { + this._unsub = null; // abonelik kurulamadı → fail-soft + } + // İlk durum (retained event'ler doğru başlangıç değerini alsın). + try { this._onSnapshot(this._hal.getSnapshot()); } catch { /* fail-soft */ } + } + + private _onSnapshot(snap: HalSnapshotLike): void { + if (this._disposed || !this._started) return; // dispose/stop sonrası no-op + if (!snap || typeof snap !== 'object' || !Array.isArray(snap.signals)) return; + + // 1) Per-signal değişiklik (ignition hariç — o ayrı retained event). + let anySupported = false; + for (const s of snap.signals) { + if (!s || typeof s !== 'object' || typeof s.id !== 'string') continue; + if (s.supported) anySupported = true; + if (s.id === IGNITION_ID) continue; // ignition ayrı ele alınır + if (!s.supported) { this._lastSignalSig.delete(s.id); continue; } // unsupported → event yok + const sig = _signalSig(s); + if (this._lastSignalSig.get(s.id) === sig) continue; // duplicate → yok + this._lastSignalSig.set(s.id, sig); + this._publish('vehicle.signal.changed', { + signalId: s.id, + value: _valueSummary(s.value), + quality: s.quality, + confidence: typeof s.confidence === 'number' ? s.confidence : 0, + source: s.source, + stale: !!s.stale, + supported: true, + timestamp: typeof s.timestamp === 'number' ? s.timestamp : this._nowSafe(), + }, { transient: true }); + } + + // 2) Ignition (retained) — yalnız supported + boolean değeri değişince. + const ign = snap.signals.find((s) => s && s.id === IGNITION_ID); + if (ign && ign.supported && typeof ign.value === 'boolean') { + if (this._lastIgnition !== ign.value) { + this._lastIgnition = ign.value; + this._publish('vehicle.ignition.changed', { + value: ign.value, quality: ign.quality, source: ign.source, stale: !!ign.stale, + }, { retained: true }); + } + } + // ignition supported değilse SESSİZ (kaynak yok → event uydurulmaz). + + // 3) Connection (retained) — "en az bir supported sinyal" geçişi (HAL'e dayalı gerçek olgu). + if (anySupported !== this._lastConnected) { + this._lastConnected = anySupported; + this._publish('vehicle.connection.changed', { + connected: anySupported, + supportedSignalCount: snap.signals.reduce((n, s) => n + (s && s.supported ? 1 : 0), 0), + }, { retained: true }); + } + + // 4) Identity (retained) — yalnız supported (fingerprint) + değişince. + let identity: HalIdentityLike | null = null; + try { identity = this._hal.getVehicleIdentity(); } catch { identity = null; } + if (identity && identity.supported) { + const sig = `${identity.fingerprintHash ?? ''}|${identity.protocol ?? ''}`; + if (this._lastIdentitySig !== sig) { + this._lastIdentitySig = sig; + this._publish('vehicle.identity.changed', { + supported: true, protocol: identity.protocol ?? null, + }, { retained: true, vehicleFingerprintHash: identity.fingerprintHash ?? undefined }); + } + } + } + + /** Tek publish sarmalayıcı — fail-soft + sayaç. */ + private _publish( + name: string, + payload: unknown, + opts: { transient?: boolean; retained?: boolean; vehicleFingerprintHash?: string } = {}, + ): void { + try { + const ev = this._bus.publish({ + name, payload, domain: 'vehicle', source: 'vehicle_hal', + transient: opts.transient, retained: opts.retained, + vehicleFingerprintHash: opts.vehicleFingerprintHash, + }); + if (ev) { this._publishedCount++; this._lastPublishAt = this._nowSafe(); } + else { this._droppedCount++; } // Bus reddetti → kaynak servis etkilenmez + } catch { + this._droppedCount++; // publish hatası izole + } + } + + /** Aboneliği bırakır (İDEMPOTENT). HAL'i dispose ETMEZ. */ + stop(): void { + if (!this._started) return; + this._started = false; + if (this._unsub) { try { this._unsub(); } catch { /* */ } this._unsub = null; } + } + + getStatus(): VehicleHalEventBridgeStatus { + return Object.freeze({ + started: this._started, + disposed: this._disposed, + publishedCount: this._publishedCount, + droppedCount: this._droppedCount, + lastPublishAt: this._lastPublishAt, + }); + } + + getPublishedCount(): number { return this._publishedCount; } + getDroppedCount(): number { return this._droppedCount; } + + /** Zero-leak: aboneliği bırakır + kilitler. HAL/Bus çağıranındır → dispose EDİLMEZ. */ + dispose(): void { + if (this._disposed) return; + this.stop(); + this._lastSignalSig.clear(); + this._disposed = true; + } + + get isDisposed(): boolean { return this._disposed; } +} + +/** + * Fabrika — DI ile örnek üretir. YAN ETKİSİZ: abonelik/HAL okuma yalnız `start()`'ta → + * import edilmesi davranış değiştirmez. GLOBAL SINGLETON YOK. + */ +export function createVehicleHalEventBridge(deps: VehicleHalEventBridgeDeps): VehicleHalEventBridge { + return new VehicleHalEventBridge(deps); +} diff --git a/src/platform/eventBus/index.ts b/src/platform/eventBus/index.ts index bfb96f15..82dc0172 100644 --- a/src/platform/eventBus/index.ts +++ b/src/platform/eventBus/index.ts @@ -26,3 +26,21 @@ export { type PlatformEventBusDeps, type RecentEventFilter, } from './platformEventBus'; +export { + createVehicleHalEventBridge, + VehicleHalEventBridge, + createCapabilityEventBridge, + CapabilityEventBridge, + type VehicleHalSource, + type HalSignalLike, + type HalSnapshotLike, + type HalIdentityLike, + type VehicleHalEventBridgeDeps, + type VehicleHalEventBridgeStatus, + type CapabilityRegistrySource, + type CapabilityChangeEventLike, + type CapabilityRecordLike, + type CapabilitySnapshotLike, + type CapabilityEventBridgeDeps, + type CapabilityEventBridgeStatus, +} from './bridges';