diff --git a/docs/DEVICE_VALIDATION_LEDGER.md b/docs/DEVICE_VALIDATION_LEDGER.md index e2977b25..4987739f 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 | |---|---------|--------|------------------------------------------|---------| +| 42 | **🔴 Device Validation Pending — Orientation Sensor Demand Gating (PR 3/3)** — İki always-on tüketiciyi (gpsService compass + smartDrivingEngine accelerometer) talep-güdümlü hale getirir. **ACCEL (gerçek foreground demand-gate):** `_accelMagnitude` YALNIZ `detectDrivingMode` Kademe 4'te (OBD+GPS+decayed hepsi yok/stale) okunuyor → yardımcı son-çare. `_evaluateAccelDemand()` yalnız TAZE güvenilir hız kaynağı YOKKEN gate'ten `subscribeMotion` ile abone olur; taze kaynak (OBD **veya** GPS — `recordSpeed` ile beslenen `_lastSpeedEstimate`, yaş>DECAY_MAX_SEC=stale) varken release. `attachAccelerometer` fail-safe (kaynak yoksa acquire), `recordSpeed` her çağrıda re-eval, `detachAccelerometer` release. İvmeölçer TEK BAŞINA safety-critical/kesin hareket üretmez (yalnız Kademe 4 ipucu). Magic number YOK (DECAY_MAX_SEC mevcut sabit). **COMPASS (gpsService):** `_startCompassListener` ham `window.addEventListener` → gate `subscribeOrientationAbsolute/subscribeOrientation`; mevcut JS throttle (COMPASS_THROTTLE_MS, mevcut) + heading/smoothing/blend davranışı KORUNUR. Kazanım: **background auto-pause** (gate visibility hidden→fiziksel listener söker) + dedup. **YALIN:** yalnız 2 dosya değişti; yeni timer/rAF/polling YOK, Generic Sensor API YOK, yeni JS throttle YOK, yeni bağımlılık yok, import yan etkisiz; gate'in sahibi değil. ⚠️ **DÜRÜST SINIRLAMALAR:** (1) **Compass Settings ekranında KAPATILMADI** — foreground Settings-gating "harita ekranda mı" tüketici sinyali gerektirir (global görünüm store'u yok, MainLayout lokal `drawer` state'i; tüketiciye dokunmak KAPSAM DIŞI). Compass foreground davranışı bilinçli KORUNDU (map-heading-while-not-navigating bozulmasın). (2) **Accel mid-drive kaynak kaybı** (fresh sonra stale) re-acquire'ı sonraki `recordSpeed`/attach-döngüsünde olur (timer yok) — accel non-critical fallback + decay(20s)+Kademe5 kapsar. ⚠️ **Bu PR native samplingPeriod düşüşü İDDİA ETMEZ.** | `platform/gpsService.ts` (compass→gate), `platform/smartDrivingEngine.ts` (accel→gate+demand), `__tests__/orientationDemandGating.test.ts` (yeni, 26 test), `__tests__/orientationGateConsumerMigration.test.ts` (PR2 #21/#22 scope-kilidi yeni davranışa güncellendi) | Gerçek Xiaomi cihazda (önce/sonra): (1) sürüş sırasında (GPS/OBD taze) `dumpsys sensorservice` altında smartDriving accel `devicemotion` kaydı KALMAMASI; (2) GPS fix yok + OBD yok senaryosunda accel fallback'in ÇALIŞMASI (mod tespiti sürüyor); (3) background'da (app hidden) compass+accel fiziksel listener'ın SÖKÜLMESİ; (4) foreground dönüşünde geri bağlanması; (5) heading/pusula/navigation bearing DEĞİŞMEMESİ; (6) duplicate listener/cleanup leak OLMAMASI; (7) idle CPU (SensorsHandlerThread+CrRendererMain) sürüş+background senaryolarında düşüşü; (8) BASIC_JS/Mali-400'de regresyon yok. ⚠️ **Compass Settings-gating bu PR'da YOK (tüketici demand sinyali gerektirir — ayrı iş). Mali-400 cihaz doğrulaması bekliyor.** Test edilmeden 🟢 yapma. | 2026-07-11 | | 41 | **🔴 Device Validation Pending — Orientation Sensor Gate On-Demand Consumer Migration (PR 2/3)** — Yaşam-döngüsüyle açılıp kapanan DÖRT sensör tüketicisini ham `window.addEventListener` yerine Orientation Sensor Gate (#40) üzerinden çalıştırır. **Taşınanlar:** `arAlignmentService` (deviceorientationabsolute+deviceorientation+devicemotion → `subscribeOrientationAbsolute/subscribeOrientation/subscribeMotion`, 3 release ref, capture-fazı kaldırıldı=pencere-hedefli device event teslimatı faz-bağımsız), `dashcamService` (devicemotion → `subscribeMotion`, `_motionRelease`), `blackBoxService._startAccelerometer` (gate release aynen döner→`_accelUnsub` sözleşmesi değişmez), `deviceApi.subscribeToAccelerometer` (gate release döner→sentryEngine `_unsubAccel` yolu değişmez). **Model:** her tüketici gate release'i kendi lifecycle alanında tutar; start/stop İDEMPOTENT (mevcut guard'lar korundu); duplicate acquire yok; stop/dispose'ta release; gate'in SAHİBİ DEĞİL, gate'i dispose ETMEZ. **Davranış korundu:** handler'lar/callback sırası/işaretleri/smoothing/filtering/permission (feature-detect `'DeviceMotionEvent' in window` korundu, iOS requestPermission EKLENMEDİ) aynı; event payload aynı callback'e ulaşır. **Visibility:** tüketiciler kendi visibilitychange listener'ını EKLEMEZ (gate yönetir; çift listener yok). **Kazanım:** aynı ham event için birden fazla fiziksel JS listener yerine tek gate listener + background'da otomatik pause + temiz ownership/cleanup. **YALIN:** event başına yeni allocation yok, yeni timer/rAF/polling yok, callback gövdesi büyümedi, yeni bağımlılık yok, import yan etkisiz. ⚠️ **gpsService ve smartDrivingEngine bu PR'da DEĞİŞMEDİ** (hâlâ always-on) → **Settings ekranında native sensör kaydı ve native 60 Hz sampling DEVAM EDEBİLİR; bu PR native samplingPeriod'u düşürdüğünü İDDİA ETMEZ ve Settings CPU sorununu ÇÖZMEZ** — bunlar PR 3 kapsamı (always-on talep-güdümlü acquire). | `platform/arAlignmentService.ts`, `platform/dashcamService.ts`, `platform/security/blackBoxService.ts`, `platform/deviceApi.ts`, `__tests__/orientationGateConsumerMigration.test.ts` (yeni, 30 test) | Gerçek cihazda (AR navigasyon/dashcam/black-box/sentry aktifken): (1) AR/Dashcam/BlackBox/Sentry aktifken sensör olayları çalışıyor (heading/G-force/crash-detect aynı); (2) özellik durduğunda ilgili consumer release oluyor; (3) birden fazla consumer tek fiziksel listener paylaşıyor; (4) background'da (visibility hidden) fiziksel listener sökülüyor; (5) foreground dönüşünde yalnız aktif consumer geri bağlanıyor; (6) duplicate listener/cleanup leak yok; (7) BASIC_JS/Mali-400'de regresyon yok. ⚠️ **gpsService ve smartDrivingEngine bu PR KAPSAMI DIŞI; Settings ve native 60 Hz sorunu PR 3'te çözülür. Mali-400 cihaz doğrulaması bekliyor.** Test edilmeden 🟢 yapma. | 2026-07-11 | | 40 | **🔴 Device Validation Pending — Orientation Sensor Subscription Gate Foundation (PR 1/3)** — Cihaz yön/hareket Web event'leri (`deviceorientationabsolute`/`deviceorientation`/`devicemotion`) için MERKEZİ, ref-count'lu abonelik kapısı (multiplexer). Bugün bu event'lere ≥6 servis (gpsService compass, smartDrivingEngine, arAlignmentService, dashcamService, blackBoxService, deviceApi) AYRI `addEventListener` ile abone; ikisi (compass + accelerometer) kök layout'ta app-ömrü bağlı → Ayarlar dahil her ekranda sensör aktif (QA: `dumpsys sensorservice` samplingPeriod 16.67ms=60Hz, SensorsHandlerThread ~%24 + CrRendererMain ~%21). Kapı: **event türü başına TEK fiziksel window listener** (dedup); callback Set ile ref-count (ilk consumer→bağla, son→sök); `visibilityState!=='visible'`→fiziksel listener sökülür, görünür+aktif consumer→yeniden bağlanır; deterministik fan-out (insertion order, kararlı dizi snapshot'ı); **event başına YENİ array/object allocation YOK** (index'li döngü); callback exception İZOLE (fail-soft, diğerleri sürer); public API throw ETMEZ; bounded (kanal başı 64, aşım fail-soft no-op+droppedCount); dispose zero-leak + sonrası güvenli no-op; reset yeniden kullanılabilir. API: subscribeOrientationAbsolute/subscribeOrientation/subscribeMotion (idempotent release), getSubscriberCounts, getStatus, reset, dispose. **YALIN:** HİÇ import YOK (tam bağımsız→import yan etkisiz); modül seviyesinde listener kurulmaz (ilk abonelikte `visibilitychange` bağlanır); timer/rAF/polling YOK; Generic Sensor API YOK; JS throttle YOK; permission tüketicide kalır. ⚠️ **KAPSAM: foundation — HİÇBİR tüketici bu kapıya bağlanmadı (wiring YOK).** ⚠️ **Bu PR tek başına CPU veya samplingPeriod DÜŞÜRMEZ.** Gerçek kazanım PR 2 (on-demand tüketiciler) ve özellikle PR 3 (always-on compass + smartDriving talep-güdümlü acquire + background pause) wiring'inden SONRA ölçülebilir. **Legacy event API aktifken native sampling 60 Hz OLABİLİR** (frekans parametresi yok — kanıt: gpsService zaten JS'te 10Hz throttle'lı ama dumpsys 60Hz). | `platform/sensors/orientationSensorGate.ts` (yeni), `platform/sensors/index.ts` (yeni), `__tests__/orientationSensorGate.test.ts` (yeni, 30 test), `__tests__/regression.guards.test.ts` (gate zero-leak + bağımsızlık kilidi) | Gerçek cihazda (tüketici wiring PR 2/3 bağlandıktan SONRA): (1) Ayarlar ekranında `dumpsys sensorservice` altında CockpitOS için orientation/motion 60 Hz kaydı kalmaması; (2) background'da (visibility hidden) sensör kaydı kalmaması; (3) harita/navigation açılınca sensörün yeniden çalışması; (4) heading/pusula davranışının korunması; (5) duplicate listener ve cleanup sızıntısı olmaması; (6) idle CPU'nun (SensorsHandlerThread + CrRendererMain) Ayarlar ve background senaryolarında belirgin düşmesi; (7) BASIC_JS/Mali-400'de regresyon olmaması. ⚠️ **PR 1 tek başına ölçülebilir kazanç VERMEZ — foundation. Mali-400 cihaz doğrulaması bekliyor.** Test edilmeden 🟢 yapma. | 2026-07-11 | | 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 | diff --git a/src/__tests__/orientationDemandGating.test.ts b/src/__tests__/orientationDemandGating.test.ts new file mode 100644 index 00000000..b1b661d1 --- /dev/null +++ b/src/__tests__/orientationDemandGating.test.ts @@ -0,0 +1,313 @@ +/** + * Orientation Sensor Demand Gating testleri (PR 3/3). + * + * İki always-on tüketici talep-güdümlü hale getirildi: + * • gpsService COMPASS → Orientation Sensor Gate (background auto-pause + dedup). + * Foreground davranışı KORUNUR (GPS tracking'te acquire). ⚠️ Bu PR compass'ı + * Settings ekranında KAPATMAZ — foreground Settings-gating "harita ekranda mı" + * tüketici sinyali gerektirir (global görünüm store'u yok, MainLayout lokal + * drawer state'i; tüketiciye dokunmak KAPSAM DIŞI). Bkz. PR notu. + * • smartDrivingEngine ACCELEROMETER → gate + TALEP-GÜDÜMLÜ: yalnız TAZE + * güvenilir hız kaynağı (OBD/GPS, recordSpeed) YOKKEN acquire; taze kaynak + * varken release. Fail-safe: hiç kaynak yoksa açık. + * + * ⚠️ Bu PR native samplingPeriod'u düşürdüğünü İDDİA ETMEZ (gerçek cihaz QA + * gerekir). Testler yalnız gate abonelik/talep davranışını doğrular. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/* ── Mocks (gpsService bağımlılıkları) ─────────────────────────── */ + +vi.mock('@capacitor/geolocation', () => ({ + Geolocation: { + checkPermissions: vi.fn(), + requestPermissions: vi.fn(), + watchPosition: vi.fn(), + clearWatch: vi.fn().mockResolvedValue(undefined), + }, +})); +vi.mock('../platform/crashLogger', () => ({ logError: vi.fn() })); + +import { getSubscriberCounts, getStatus, reset as gateReset } from '../platform/sensors'; +import { startGPSTracking, stopGPSTracking, getGPSState } from '../platform/gpsService'; +import { + attachAccelerometer, detachAccelerometer, recordSpeed, detectDrivingMode, +} from '../platform/smartDrivingEngine'; +import { DECAY_MAX_SEC } from '../platform/smartConstants'; + +// Kaynak-metin kilitleri. +import gpsSrc from '../platform/gpsService.ts?raw'; +import smartDrivingSrc from '../platform/smartDrivingEngine.ts?raw'; +import arSrc from '../platform/arAlignmentService.ts?raw'; +import dashSrc from '../platform/dashcamService.ts?raw'; +import blackboxSrc from '../platform/security/blackBoxService.ts?raw'; +import deviceApiSrc from '../platform/deviceApi.ts?raw'; + +/* ── Ortam yardımcıları ────────────────────────────────────────── */ + +function setNative(v: boolean): void { + (globalThis as unknown as Record).Capacitor = { isNativePlatform: () => v }; +} +function mockNavigatorGeolocation(): void { + Object.defineProperty(globalThis, 'navigator', { + value: { geolocation: { watchPosition: vi.fn().mockReturnValue(42), clearWatch: vi.fn() } }, + writable: true, configurable: true, + }); +} +let _vis: DocumentVisibilityState = 'visible'; +Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => _vis }); +function setVisibility(v: DocumentVisibilityState): void { + _vis = v; + document.dispatchEvent(new Event('visibilitychange')); +} + +beforeEach(() => { + const w = window as unknown as Record; + if (!('DeviceMotionEvent' in window)) w.DeviceMotionEvent = class extends Event {}; + if (!('DeviceOrientationEvent' in window)) w.DeviceOrientationEvent = class extends Event {}; +}); + +/* ════════════════════════════════════════════════════════════════ + COMPASS (gpsService) — gate migration: background-pause + dedup + ════════════════════════════════════════════════════════════════ */ +describe('PR 3 — gpsService compass gate demand/visibility', () => { + beforeEach(async () => { + _vis = 'visible'; + setNative(false); + mockNavigatorGeolocation(); + await stopGPSTracking(); + gateReset(); + }); + afterEach(async () => { + await stopGPSTracking(); + gateReset(); + vi.clearAllMocks(); + }); + + /* 1 — gps start compass acquire eder */ + it('1: GPS tracking başlayınca compass gate\'ten acquire edilir', async () => { + await startGPSTracking(); + const c = getSubscriberCounts(); + expect(c.orientationAbsolute).toBe(1); + expect(c.orientation).toBe(1); + }); + + /* 2 — gps stop compass release eder */ + it('2: GPS tracking durunca compass release edilir', async () => { + await startGPSTracking(); + await stopGPSTracking(); + expect(getSubscriberCounts().orientationAbsolute).toBe(0); + expect(getSubscriberCounts().orientation).toBe(0); + }); + + /* 3 — duplicate start ekstra abonelik yok */ + it('3: duplicate GPS start ekstra compass aboneliği oluşturmaz', async () => { + await startGPSTracking(); + await startGPSTracking(); + expect(getSubscriberCounts().orientationAbsolute).toBe(1); + }); + + /* 4 — heading davranışı korunuyor (state yapısı) */ + it('4: heading davranışı korunuyor (getGPSState.heading alanı mevcut)', async () => { + await startGPSTracking(); + expect(getGPSState()).toHaveProperty('heading'); // null olabilir — yapı korunur + }); + + /* 5 — background'da fiziksel compass listener sökülür */ + it('5: background (hidden) → compass fiziksel listener sökülür, kayıt korunur', async () => { + await startGPSTracking(); + expect(getStatus().channels.orientationAbsolute.listenerAttached).toBe(true); + setVisibility('hidden'); + expect(getStatus().channels.orientationAbsolute.listenerAttached).toBe(false); + expect(getSubscriberCounts().orientationAbsolute).toBe(1); // consumer kaydı korunur + }); + + /* 6 — foreground dönüşünde geri bağlanır */ + it('6: foreground dönüşünde compass yeniden bağlanır', async () => { + await startGPSTracking(); + setVisibility('hidden'); + setVisibility('visible'); + expect(getStatus().channels.orientationAbsolute.listenerAttached).toBe(true); + }); + + /* 7 — DÜRÜST SINIR: compass Settings'te KAPATILMADI (foreground korunur) */ + it('7: compass foreground davranışı korunur (bu PR Settings-gating YAPMAZ)', async () => { + // Compass, GPS tracking aktifken ekrandan bağımsız acquire edilir (bugünkü + // davranış korunur). Settings-foreground-gating tüketici demand sinyali + // gerektirir → bu PR KAPSAMI DIŞI. Kaynakta ekran/nav koşulu YOK. + await startGPSTracking(); + expect(getSubscriberCounts().orientationAbsolute).toBe(1); + expect(gpsSrc).not.toMatch(/isNavigating|drawer|activeScreen|currentView/); + }); +}); + +/* ════════════════════════════════════════════════════════════════ + ACCELEROMETER (smartDrivingEngine) — talep-güdümlü fallback + ════════════════════════════════════════════════════════════════ */ +describe('PR 3 — smartDrivingEngine accelerometer demand gating', () => { + let _now = 1_000_000; + beforeEach(() => { + _vis = 'visible'; + _now += 10_000_000; // her test için taze taban (prior stale) + vi.spyOn(performance, 'now').mockImplementation(() => _now); + detachAccelerometer(); // özellik kapalı + release + gateReset(); + }); + afterEach(() => { + detachAccelerometer(); + gateReset(); + vi.restoreAllMocks(); + }); + const advance = (sec: number): void => { _now += sec * 1000; }; + + /* 8 — kaynak yokken fail-safe acquire (fallback açılıyor) */ + it('8: taze hız kaynağı yokken accel fail-safe acquire edilir', () => { + attachAccelerometer(); // recordSpeed yok / prior stale + expect(getSubscriberCounts().motion).toBe(1); + }); + + /* 9 — taze GPS/HAL hızı varken accel AÇILMAZ */ + it('9: taze hız kaynağı (recordSpeed) varken accel açılmaz', () => { + recordSpeed(50); // taze kaynak + attachAccelerometer(); + expect(getSubscriberCounts().motion).toBe(0); + }); + + /* 10 — kaynak gelince release (fresh → bırak) */ + it('10: accel açıkken taze kaynak gelince release edilir', () => { + attachAccelerometer(); // kaynak yok → acquire + expect(getSubscriberCounts().motion).toBe(1); + recordSpeed(50); // taze kaynak → release + expect(getSubscriberCounts().motion).toBe(0); + }); + + /* 11 — kaynak stale olunca (yeni değerlendirmede) fallback açılıyor */ + it('11: kaynak stale olduğunda accel yeniden fallback olur (attach re-eval)', () => { + recordSpeed(50); // taze + attachAccelerometer(); + expect(getSubscriberCounts().motion).toBe(0); // taze → kapalı + advance(DECAY_MAX_SEC + 5); // kaynak stale + detachAccelerometer(); // özellik döngüsü (re-eval tetikleyici) + attachAccelerometer(); + expect(getSubscriberCounts().motion).toBe(1); // stale → fallback açık + }); + + /* 12 — detach release eder */ + it('12: detachAccelerometer accel\'i release eder', () => { + attachAccelerometer(); + detachAccelerometer(); + expect(getSubscriberCounts().motion).toBe(0); + }); + + /* 13 — duplicate attach ekstra abonelik yok */ + it('13: duplicate attach ekstra abonelik oluşturmaz', () => { + attachAccelerometer(); + attachAccelerometer(); + expect(getSubscriberCounts().motion).toBe(1); + }); + + /* 14 — background release (gate) */ + it('14: background (hidden) → accel fiziksel listener sökülür', () => { + attachAccelerometer(); + expect(getStatus().channels.motion.listenerAttached).toBe(true); + setVisibility('hidden'); + expect(getStatus().channels.motion.listenerAttached).toBe(false); + }); + + /* 15 — foreground re-attach */ + it('15: foreground dönüşünde accel yeniden bağlanır (talep sürerken)', () => { + attachAccelerometer(); + setVisibility('hidden'); + setVisibility('visible'); + expect(getStatus().channels.motion.listenerAttached).toBe(true); + }); + + /* 16 — detectDrivingMode davranışı DEĞİŞMEDİ (accel yalnız Kademe 4) */ + it('16: detectDrivingMode davranışı korunuyor', () => { + expect(detectDrivingMode({ btConnected: false, charging: false }, undefined, 30)).toBe('driving'); + expect(detectDrivingMode({ btConnected: false, charging: false }, 0, undefined)).toBe('idle'); + expect(detectDrivingMode({ btConnected: false, charging: false }, undefined, 10)).toBe('normal'); + }); + + /* 17 — leak yok: attach/detach döngüsü sonrası sıfır */ + it('17: attach/detach döngüsü sonrası gate\'te sızıntı yok', () => { + attachAccelerometer(); detachAccelerometer(); + attachAccelerometer(); detachAccelerometer(); + expect(getSubscriberCounts().total).toBe(0); + expect(getStatus().channels.motion.listenerAttached).toBe(false); + }); +}); + +/* ════════════════════════════════════════════════════════════════ + KAYNAK KİLİTLERİ — kapsam, güvenlik, dürüstlük + ════════════════════════════════════════════════════════════════ */ +describe('PR 3 — kaynak kilitleri', () => { + /* 18 — compass gate'e taşındı */ + it('18: gpsService compass gate kullanır (ham deviceorientation yok)', () => { + expect(gpsSrc).toMatch(/from '\.\/sensors'/); + expect(gpsSrc).toMatch(/subscribeOrientationAbsolute\(_onDeviceOrientation\)/); + expect(gpsSrc).not.toMatch(/addEventListener\('deviceorientation/); + }); + + /* 19 — accel gate + demand-gate */ + it('19: smartDrivingEngine accel gate + talep-güdümlü (subscribeMotion + _evaluateAccelDemand)', () => { + expect(smartDrivingSrc).toMatch(/from '\.\/sensors'/); + expect(smartDrivingSrc).toMatch(/subscribeMotion\(_handleDeviceMotion\)/); + expect(smartDrivingSrc).toMatch(/_evaluateAccelDemand/); + expect(smartDrivingSrc).toMatch(/_accelDemandNeeded/); + expect(smartDrivingSrc).not.toMatch(/addEventListener\('devicemotion'/); + }); + + /* 20 — permission davranışı korunur (feature-detect korundu, orientation için + yeni izin akışı EKLENMEDİ). NOT: gpsService'in mevcut Geolocation izni + (checkPermissions/requestPermissions) compass'la İLGİSİZ ve bu PR'da + dokunulmadı — bu yüzden orientation permission'ı için ayrı bir kanıt yok. */ + it('20: permission davranışı korunur (feature-detect, yeni orientation izni yok)', () => { + expect(smartDrivingSrc).toMatch(/window\.DeviceMotionEvent/); // feature-detect korundu + expect(smartDrivingSrc).not.toMatch(/requestPermission/); // accel için izin akışı yok + // compass migration'ı izin akışına dokunmadı — subscribe doğrudan (iOS + // DeviceOrientation requestPermission tüketicinin mevcut davranışında kalır). + expect(gpsSrc).not.toMatch(/DeviceOrientationEvent\s*\.\s*requestPermission/); + }); + + /* 21 — magic number yok: staleness DECAY_MAX_SEC ile (mevcut sabit) */ + it('21: accel staleness DECAY_MAX_SEC sabitiyle (uydurma eşik yok)', () => { + expect(smartDrivingSrc).toMatch(/ageSec > DECAY_MAX_SEC/); + }); + + /* 22 — yeni timer/rAF/polling YOK (değişen dosyalarda sensör için) */ + it('22: smartDrivingEngine yeni timer/rAF/polling eklemez', () => { + expect(smartDrivingSrc).not.toMatch(/setInterval|setTimeout|requestAnimationFrame/); + }); + + /* 23 — Generic Sensor API / JS throttle EKLENMEDİ */ + it('23: Generic Sensor API veya yeni JS throttle eklenmedi', () => { + for (const src of [gpsSrc, smartDrivingSrc]) { + expect(src).not.toMatch(/new\s+(Gyroscope|Accelerometer|AbsoluteOrientationSensor|RelativeOrientationSensor)/); + } + // smartDrivingEngine'e yeni throttle sabiti eklenmedi (accel event'i throttle YOK). + expect(smartDrivingSrc).not.toMatch(/THROTTLE|throttle/); + }); + + /* 24 — native samplingPeriod düşüşü İDDİA edilmiyor */ + it('24: değişen dosyalar native samplingPeriod düşüşü iddia etmez', () => { + expect(gpsSrc).not.toMatch(/samplingPeriod/i); + expect(smartDrivingSrc).not.toMatch(/samplingPeriod/i); + }); + + /* 25 — diğer tüketiciler bu PR'da DEĞİŞMEDİ (PR 2 gate kullanımı korunur) */ + it('25: arAlignment/dashcam/blackBox/deviceApi bu PR\'da değişmedi (hâlâ gate)', () => { + expect(arSrc).toMatch(/subscribeMotion\(_onMotion\)/); + expect(dashSrc).toMatch(/subscribeMotion\(_onMotion\)/); + expect(blackboxSrc).toMatch(/return subscribeMotion\(handler\)/); + expect(deviceApiSrc).toMatch(/return subscribeMotion\(handler\)/); + }); + + /* 26 — SystemBoot/Platform Kernel/OBD-CAN wiring'e bağlanmadı */ + it('26: değişen dosyalar SystemBoot/Kernel wiring eklemez', () => { + expect(smartDrivingSrc).not.toMatch(/SystemBoot|from '[^']*kernel/i); + // gpsService zaten SystemBoot import etmiyor; compass değişikliği eklemedi. + expect(gpsSrc).not.toMatch(/serviceLifecycle|platformKernel/i); + }); +}); diff --git a/src/__tests__/orientationGateConsumerMigration.test.ts b/src/__tests__/orientationGateConsumerMigration.test.ts index 0bd7cebd..04d17910 100644 --- a/src/__tests__/orientationGateConsumerMigration.test.ts +++ b/src/__tests__/orientationGateConsumerMigration.test.ts @@ -263,18 +263,21 @@ describe('Orientation Gate — On-Demand Consumer Migration (PR 2)', () => { expect(Object.keys(e).length).toBe(keysBefore); }); - /* 21 — gpsService DEĞİŞMEDİ */ - it('21: gpsService bu PR\'da değişmedi (hâlâ ham compass listener, gate import YOK)', () => { - expect(gpsSrc).toMatch(/addEventListener\('deviceorientationabsolute'/); - expect(gpsSrc).not.toMatch(/from '\.\/sensors'/); - expect(gpsSrc).not.toMatch(/subscribeOrientation|subscribeMotion/); + /* 21 — gpsService KİLİDİ PR 3'te GÜNCELLENDİ: compass artık gate'e taşındı */ + // NOT: PR 2'de bu kilit "gpsService değişmedi" idi. PR 3 compass'ı BİLİNÇLİ + // olarak Orientation Sensor Gate'e taşıdı (background-pause) → kilit yeni doğru + // davranışa güncellendi (kaldırılmadı). + it('21: gpsService compass gate\'e taşındı (ham deviceorientation aboneliği yok)', () => { + expect(gpsSrc).toMatch(/from '\.\/sensors'/); + expect(gpsSrc).toMatch(/subscribeOrientationAbsolute\(_onDeviceOrientation\)/); + expect(gpsSrc).not.toMatch(/addEventListener\('deviceorientationabsolute'/); }); - /* 22 — smartDrivingEngine DEĞİŞMEDİ */ - it('22: smartDrivingEngine bu PR\'da değişmedi (hâlâ ham devicemotion, gate import YOK)', () => { - expect(smartDrivingSrc).toMatch(/addEventListener\('devicemotion'/); - expect(smartDrivingSrc).not.toMatch(/from '\.\/sensors'/); - expect(smartDrivingSrc).not.toMatch(/subscribeMotion/); + /* 22 — smartDrivingEngine KİLİDİ PR 3'te GÜNCELLENDİ: accel gate'e taşındı */ + it('22: smartDrivingEngine accel gate\'e taşındı (ham devicemotion aboneliği yok)', () => { + expect(smartDrivingSrc).toMatch(/from '\.\/sensors'/); + expect(smartDrivingSrc).toMatch(/subscribeMotion\(_handleDeviceMotion\)/); + expect(smartDrivingSrc).not.toMatch(/addEventListener\('devicemotion'/); }); /* 23 — native sampling rate iddiası yok */ diff --git a/src/platform/gpsService.ts b/src/platform/gpsService.ts index bc595c93..f59c2d90 100644 --- a/src/platform/gpsService.ts +++ b/src/platform/gpsService.ts @@ -17,6 +17,7 @@ import { GPS_FIRST_FIX_MS, } from './gps/gpsUtils'; import { getThermalLevel } from './thermalWatchdog'; +import { subscribeOrientationAbsolute, subscribeOrientation } from './sensors'; // Capacitor global tip tanımı — (window as any) yerine declare global { @@ -180,6 +181,9 @@ function _startFirstFixFallback(): void { let _compassHeading: number | null = null; let _smoothedHeading: number | null = null; let _compassListenerOn = false; +// Orientation Sensor Gate release fonksiyonları (ham window aboneliği yerine). +let _compassAbsRelease: (() => void) | null = null; +let _compassRelRelease: (() => void) | null = null; // Compass throttle: 60Hz → 10Hz (100ms) — pil tasarrufu let _compassLastMs = 0; @@ -207,17 +211,21 @@ function _onDeviceOrientation(e: DeviceOrientationEvent): void { function _startCompassListener(): void { if (_compassListenerOn) return; _compassListenerOn = true; - // deviceorientationabsolute: Android Chrome 65+ — manyetik kuzey referanslı - window.addEventListener('deviceorientationabsolute', _onDeviceOrientation as EventListener); - // Fallback: deviceorientation (iOS absolute=false için webkitCompassHeading kolundan okunur) - window.addEventListener('deviceorientation', _onDeviceOrientation as EventListener); + // Ham window aboneliği yerine merkezi Orientation Sensor Gate: gate aynı ham + // event için tek fiziksel listener paylaştırır ve arka planda (visibility + // hidden) fiziksel listener'ı kendisi söker → gereksiz background sensör yükü + // kalkar. Mevcut JS throttle (_onDeviceOrientation içinde) ve heading davranışı + // KORUNUR. NOT: bu PR compass'ı Settings ekranında kapatmaz (foreground + // demand-gating tüketici sinyali gerektirir — bkz. PR notu). + _compassAbsRelease = subscribeOrientationAbsolute(_onDeviceOrientation); + _compassRelRelease = subscribeOrientation(_onDeviceOrientation); } function _stopCompassListener(): void { if (!_compassListenerOn) return; _compassListenerOn = false; - window.removeEventListener('deviceorientationabsolute', _onDeviceOrientation as EventListener); - window.removeEventListener('deviceorientation', _onDeviceOrientation as EventListener); + if (_compassAbsRelease) { _compassAbsRelease(); _compassAbsRelease = null; } + if (_compassRelRelease) { _compassRelRelease(); _compassRelRelease = null; } _compassHeading = null; _smoothedHeading = null; } diff --git a/src/platform/smartDrivingEngine.ts b/src/platform/smartDrivingEngine.ts index dd7d8196..49f22b83 100644 --- a/src/platform/smartDrivingEngine.ts +++ b/src/platform/smartDrivingEngine.ts @@ -1,18 +1,26 @@ import type { DeviceStatus } from './deviceApi'; import type { DrivingMode, _SpeedEstimate } from './smartTypes'; import { DECAY_RATE_PER_S, DECAY_MAX_SEC, ACCEL_MOTION_MS2 } from './smartConstants'; +import { subscribeMotion } from './sensors'; -/* ── Accelerometer motion detection ───────────────────────── +/* ── Accelerometer motion detection (TALEP-GÜDÜMLÜ) ────────── * * DeviceMotionEvent'in yatay bileşeni (yerçekimi hariç) araç hareketini * GPS/OBD olmadan tespit eder: fren, ivme, viraj → DrivingMode='normal'. * - * Güvenlik: sadece pasif dinleyici (passive:true), UI thread'i bloklamaz. - * Hassasiyet: 2.5 m/s² ≈ 0.25g — rahat sürüş değişimlerini yakalar. + * PR 3 talep-güdümlü kapı: İvmeölçer YALNIZ `detectDrivingMode` Kademe 4'te, + * yani OBD+GPS+decayed hız kaynaklarının HEPSİ yok/stale iken okunur → yardımcı + * SON-ÇARE fallback. Bu yüzden ham/sürekli abonelik yerine yalnız TAZE güvenilir + * hız kaynağı YOKKEN Orientation Sensor Gate üzerinden abone olunur; taze kaynak + * (OBD veya GPS — `recordSpeed` ile beslenen `_lastSpeedEstimate`) varken abonelik + * BIRAKILIR. Hiç kaynak yokken (ör. GPS fix yok + OBD yok) fail-safe açık kalır. + * Gate arka planda (visibility hidden) fiziksel listener'ı kendisi söker. + * İvmeölçer TEK BAŞINA safety-critical karar veya kesin araç hareketi ÜRETMEZ. */ -let _accelMagnitude = 0; // yerçekimsiz yatay ivme büyüklüğü (m/s²) -let _accelAttached = false; +let _accelMagnitude = 0; // yerçekimsiz yatay ivme büyüklüğü (m/s²) +let _accelFeatureEnabled = false; // hook mount → özellik açık +let _accelRelease: (() => void) | null = null; // gate aboneliği aktifse release function _handleDeviceMotion(e: DeviceMotionEvent): void { const a = e.accelerationIncludingGravity; @@ -22,18 +30,41 @@ function _handleDeviceMotion(e: DeviceMotionEvent): void { _accelMagnitude = Math.abs(raw - 9.81); } -/** Accelerometer'ı bir kez global olarak bağla. Hook mount edildiğinde çağrılır. */ +/** + * İvmeölçer talebi: yalnız TAZE güvenilir hız kaynağı YOKKEN gerekli. + * `_lastSpeedEstimate` hem OBD hem GPS ile beslenir (recordSpeed); yaş + * DECAY_MAX_SEC'i aşarsa kaynak stale sayılır → yardımcı fallback gerekir. + * (Magic number yok — DECAY_MAX_SEC mevcut decay penceresi sabiti.) + */ +function _accelDemandNeeded(): boolean { + if (!_lastSpeedEstimate) return true; // hiç hız yok → fallback + const ageSec = (performance.now() - _lastSpeedEstimate.tsMs) / 1000; + return ageSec > DECAY_MAX_SEC; // stale → fallback +} + +/** Talep durumuna göre gate aboneliğini aç/kapat (idempotent). */ +function _evaluateAccelDemand(): void { + if (typeof window === 'undefined' || !window.DeviceMotionEvent) return; // destek yok — permission davranışı korunur + const need = _accelFeatureEnabled && _accelDemandNeeded(); + if (need && !_accelRelease) { + _accelRelease = subscribeMotion(_handleDeviceMotion); // ham window yerine gate + } else if (!need && _accelRelease) { + _accelRelease(); _accelRelease = null; // taze kaynak → bırak + _accelMagnitude = 0; // stale okuma kalmasın + } +} + +/** İvmeölçer özelliğini aç (hook mount). İdempotent. Talep varsa gate'ten abone olur. */ export function attachAccelerometer(): void { - if (_accelAttached || typeof window === 'undefined' || !window.DeviceMotionEvent) return; - _accelAttached = true; - window.addEventListener('devicemotion', _handleDeviceMotion, { passive: true }); + if (_accelFeatureEnabled) return; + _accelFeatureEnabled = true; + _evaluateAccelDemand(); // fail-safe: kaynak yoksa acquire } -/** Accelerometer'ı ayır — HMR cleanup ve app teardown için. */ +/** İvmeölçer özelliğini kapat — HMR cleanup ve app teardown. Gate aboneliğini bırakır. */ export function detachAccelerometer(): void { - if (!_accelAttached || typeof window === 'undefined') return; - window.removeEventListener('devicemotion', _handleDeviceMotion); - _accelAttached = false; + _accelFeatureEnabled = false; + if (_accelRelease) { _accelRelease(); _accelRelease = null; } _accelMagnitude = 0; } @@ -56,6 +87,10 @@ let _lastSpeedEstimate: _SpeedEstimate | null = null; export function recordSpeed(kmh: number): void { // B30: performance.now() monotonic — DST/NTP clock jump'ta hız spike yok _lastSpeedEstimate = { kmh, tsMs: performance.now() }; + // Taze güvenilir hız kaynağı geldi → yardımcı ivmeölçer fallback'i bırak + // (talep-güdümlü kapı). Kaynak yok/stale olduğunda attachAccelerometer veya + // sonraki değerlendirme yeniden acquire eder. + _evaluateAccelDemand(); } function _decayedSpeed(): number | undefined {