From 7040578cc558bf8fcaf287eaa512f82456fcb87c Mon Sep 17 00:00:00 2001 From: Selim Date: Sat, 11 Jul 2026 15:11:25 +0300 Subject: [PATCH] feat(sensors): Orientation Sensor Subscription Gate foundation (PR 1/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 AYRI addEventListener ile abone; ikisi kök layout'ta app-ömrü bağlı → Ayarlar dahil her ekranda sensör aktif (QA: samplingPeriod 60Hz, SensorsHandlerThread ~%24 + CrRendererMain ~%21). Gate: - 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 (background pause temeli) - Deterministik fan-out (insertion order, kararlı dizi); event başına YENİ array/object allocation YOK - Callback exception İZOLE (fail-soft); 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 - HİÇ import YOK (bağımsız, import yan etkisiz); 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/samplingPeriod DÜŞÜRMEZ; gerçek kazanım PR 2 (on-demand tüketiciler) ve PR 3 (always-on talep-güdümlü + background pause) wiring'inden SONRA ölçülür. Legacy event API aktifken native sampling 60 Hz olabilir (frekans parametresi yok). Yeni: platform/sensors/orientationSensorGate.ts, platform/sensors/index.ts Test: 30 birim testi + regression guard (zero-leak + bağımsızlık kilidi) Ledger: #40 🔴 (Mali-400 cihaz doğrulaması bekliyor) tsc -b temiz · ESLint temiz · tam suite 3141 yeşil · prod build OK Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KKAPmfBwMTjsd5ft26GmXf --- docs/DEVICE_VALIDATION_LEDGER.md | 1 + src/__tests__/orientationSensorGate.test.ts | 363 ++++++++++++++++++ src/__tests__/regression.guards.test.ts | 33 ++ src/platform/sensors/index.ts | 26 ++ src/platform/sensors/orientationSensorGate.ts | 330 ++++++++++++++++ 5 files changed, 753 insertions(+) create mode 100644 src/__tests__/orientationSensorGate.test.ts create mode 100644 src/platform/sensors/index.ts create mode 100644 src/platform/sensors/orientationSensorGate.ts diff --git a/docs/DEVICE_VALIDATION_LEDGER.md b/docs/DEVICE_VALIDATION_LEDGER.md index fbe2aafe..cf615636 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 | |---|---------|--------|------------------------------------------|---------| +| 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 | | 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 | diff --git a/src/__tests__/orientationSensorGate.test.ts b/src/__tests__/orientationSensorGate.test.ts new file mode 100644 index 00000000..c79743c5 --- /dev/null +++ b/src/__tests__/orientationSensorGate.test.ts @@ -0,0 +1,363 @@ +/** + * orientationSensorGate — Foundation birim testleri (PR 1). + * + * Kapsam: ref-count multiplexer, tek fiziksel listener/dedup, visibility gate, + * deterministik fan-out, exception izolasyonu, bounded/fail-soft, zero-leak, + * import yan etkisizliği, timer/rAF yokluğu ve bağımsızlık (GPS/MapLibre/Kernel). + * + * Not: Bu kapı NATIVE sampling rate'i değiştirmez ve böyle bir iddia taşımaz; + * testler yalnız JS-tarafı abonelik yönetimini doğrular. + */ + +import rawSrc from '../platform/sensors/orientationSensorGate.ts?raw'; +import * as gate from '../platform/sensors/orientationSensorGate'; +import { + subscribeOrientationAbsolute, + subscribeOrientation, + subscribeMotion, + getSubscriberCounts, + getStatus, + reset, + dispose, +} from '../platform/sensors/orientationSensorGate'; + +/* ── Test yardımcıları ─────────────────────────────────────────── */ + +let _vis: DocumentVisibilityState = 'visible'; +Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => _vis }); +function setVisibility(v: DocumentVisibilityState): void { + _vis = v; + document.dispatchEvent(new Event('visibilitychange')); +} + +const fireMotion = (): void => { window.dispatchEvent(new Event('devicemotion')); }; +const fireAbs = (): void => { window.dispatchEvent(new Event('deviceorientationabsolute')); }; +const fireRel = (): void => { window.dispatchEvent(new Event('deviceorientation')); }; + +/** Belirli bir event adı için window.addEventListener çağrı sayısı. */ +function addCount(spy: ReturnType, name: string): number { + return spy.mock.calls.filter((c) => c[0] === name).length; +} + +describe('orientationSensorGate (PR 1 foundation)', () => { + beforeEach(() => { + _vis = 'visible'; + reset(); + }); + afterEach(() => { + reset(); + vi.restoreAllMocks(); + }); + + /* 1. import yan etkisiz */ + it('1: import yan etkisiz — dinlenmeyen taze durumda hiç listener yok', () => { + // Kaynak tamamen bağımsız (hiç import yok) → modül seviyesinde yan etki olamaz. + expect(rawSrc).not.toMatch(/^\s*import\s/m); + // Taze durumda (abonelik yok) fiziksel/visibility listener bağlı değil. + const s = getStatus(); + expect(s.channels.motion.listenerAttached).toBe(false); + expect(s.channels.orientation.listenerAttached).toBe(false); + expect(s.channels.orientationAbsolute.listenerAttached).toBe(false); + expect(s.visibilityListenerAttached).toBe(false); + expect(getSubscriberCounts().total).toBe(0); + }); + + /* 2. ilk consumer listener bağlar */ + it('2: ilk consumer fiziksel listener bağlar', () => { + const addSpy = vi.spyOn(window, 'addEventListener'); + subscribeMotion(() => {}); + expect(getStatus().channels.motion.listenerAttached).toBe(true); + expect(addCount(addSpy, 'devicemotion')).toBe(1); + }); + + /* 3. ikinci consumer yeni fiziksel listener oluşturmaz */ + it('3: ikinci consumer yeni fiziksel listener oluşturmaz (dedup)', () => { + const addSpy = vi.spyOn(window, 'addEventListener'); + subscribeMotion(() => {}); + subscribeMotion(() => {}); + expect(addCount(addSpy, 'devicemotion')).toBe(1); + expect(getSubscriberCounts().motion).toBe(2); + }); + + /* 4. tüm callback'lere fan-out */ + it('4: event tüm callback\'lere fan-out edilir', () => { + const calls: number[] = []; + subscribeMotion(() => calls.push(1)); + subscribeMotion(() => calls.push(2)); + subscribeMotion(() => calls.push(3)); + fireMotion(); + expect(calls.sort()).toEqual([1, 2, 3]); + }); + + /* 5. callback sırası deterministik */ + it('5: fan-out sırası deterministik (insertion order)', () => { + const order: number[] = []; + subscribeMotion(() => order.push(1)); + subscribeMotion(() => order.push(2)); + subscribeMotion(() => order.push(3)); + fireMotion(); + expect(order).toEqual([1, 2, 3]); + }); + + /* 6. duplicate callback engellenir */ + it('6: aynı callback iki kez → tek kayıt, tek çağrı', () => { + let n = 0; + const cb = (): void => { n++; }; + subscribeMotion(cb); + subscribeMotion(cb); + expect(getSubscriberCounts().motion).toBe(1); + fireMotion(); + expect(n).toBe(1); + }); + + /* 7. release idempotent */ + it('7: release idempotent — çift çağrı güvenli', () => { + const release = subscribeMotion(() => {}); + expect(getSubscriberCounts().motion).toBe(1); + release(); + release(); + release(); + expect(getSubscriberCounts().motion).toBe(0); + }); + + /* 8. son release fiziksel listener söker */ + it('8: son release fiziksel listener\'ı söker', () => { + const removeSpy = vi.spyOn(window, 'removeEventListener'); + const release = subscribeMotion(() => {}); + release(); + expect(getStatus().channels.motion.listenerAttached).toBe(false); + expect(removeSpy.mock.calls.some((c) => c[0] === 'devicemotion')).toBe(true); + }); + + /* 9. hidden fiziksel listener söker */ + it('9: visibility hidden → fiziksel listener sökülür', () => { + const removeSpy = vi.spyOn(window, 'removeEventListener'); + subscribeMotion(() => {}); + expect(getStatus().channels.motion.listenerAttached).toBe(true); + setVisibility('hidden'); + expect(getStatus().channels.motion.listenerAttached).toBe(false); + expect(removeSpy.mock.calls.some((c) => c[0] === 'devicemotion')).toBe(true); + }); + + /* 10. hidden sırasında consumer kaydı korunur */ + it('10: hidden sırasında consumer kaydı korunur', () => { + subscribeMotion(() => {}); + setVisibility('hidden'); + expect(getSubscriberCounts().motion).toBe(1); + expect(getStatus().channels.motion.listenerAttached).toBe(false); + }); + + /* 11. visible dönüşünde aktif consumer varsa geri bağlanır */ + it('11: visible dönüşünde aktif consumer varsa yeniden bağlanır', () => { + subscribeMotion(() => {}); + setVisibility('hidden'); + expect(getStatus().channels.motion.listenerAttached).toBe(false); + setVisibility('visible'); + expect(getStatus().channels.motion.listenerAttached).toBe(true); + }); + + /* 12. consumer yoksa visible dönüşünde bağlanmaz */ + it('12: consumer yokken görünürlük değişimi listener bağlamaz', () => { + const addSpy = vi.spyOn(window, 'addEventListener'); + setVisibility('hidden'); + setVisibility('visible'); + expect(getStatus().channels.motion.listenerAttached).toBe(false); + expect(addCount(addSpy, 'devicemotion')).toBe(0); + }); + + /* 13. callback exception izolasyonu */ + it('13: bir callback fırlatırsa diğerleri etkilenmez', () => { + let reached = false; + subscribeMotion(() => { throw new Error('boom'); }); + subscribeMotion(() => { reached = true; }); + const before = getStatus().callbackErrors; + expect(() => fireMotion()).not.toThrow(); + expect(reached).toBe(true); + expect(getStatus().callbackErrors).toBeGreaterThan(before); + }); + + /* 14. event mutate edilmez */ + it('14: dağıtılan event mutate/sarma edilmez (aynı referans)', () => { + const ev = new Event('devicemotion'); + let received: Event | null = null; + subscribeMotion((e) => { received = e as unknown as Event; }); + const keysBefore = Object.keys(ev).length; + window.dispatchEvent(ev); + expect(received).toBe(ev); // sarmalanmadı + expect(Object.keys(ev).length).toBe(keysBefore); // yeni prop eklenmedi + }); + + /* 15. input callback yapısı mutate edilmez */ + it('15: callback fonksiyonuna gate tarafından prop eklenmez', () => { + const cb = (): void => {}; + (cb as unknown as { tag: string }).tag = 'consumer'; + const keysBefore = Object.keys(cb).sort(); + subscribeMotion(cb); + fireMotion(); + expect(Object.keys(cb).sort()).toEqual(keysBefore); + expect((cb as unknown as { tag: string }).tag).toBe('consumer'); + }); + + /* 16. orientation türleri birbirinden bağımsız */ + it('16: absolute ve relative orientation kanalları bağımsız', () => { + let abs = 0; + let rel = 0; + subscribeOrientationAbsolute(() => abs++); + subscribeOrientation(() => rel++); + fireAbs(); + expect(abs).toBe(1); + expect(rel).toBe(0); + fireRel(); + expect(abs).toBe(1); + expect(rel).toBe(1); + const s = getStatus(); + expect(s.channels.orientationAbsolute.listenerAttached).toBe(true); + expect(s.channels.orientation.listenerAttached).toBe(true); + }); + + /* 17. motion türü bağımsız */ + it('17: motion kanalı orientation event\'lerinden etkilenmez', () => { + let motion = 0; + subscribeMotion(() => motion++); + fireAbs(); + fireRel(); + expect(motion).toBe(0); + fireMotion(); + expect(motion).toBe(1); + }); + + /* 18. reset */ + it('18: reset tüm consumer\'ları temizler ve modül yeniden kullanılabilir', () => { + subscribeMotion(() => {}); + subscribeOrientation(() => {}); + subscribeOrientationAbsolute(() => {}); + reset(); + const s = getStatus(); + expect(getSubscriberCounts().total).toBe(0); + expect(s.disposed).toBe(false); + expect(s.visibilityListenerAttached).toBe(false); + expect(s.channels.motion.listenerAttached).toBe(false); + // Yeniden kullanılabilir: + let n = 0; + subscribeMotion(() => n++); + fireMotion(); + expect(n).toBe(1); + }); + + /* 19. dispose zero-leak */ + it('19: dispose tüm fiziksel + visibility listener\'ları söker (zero-leak)', () => { + const removeWin = vi.spyOn(window, 'removeEventListener'); + const removeDoc = vi.spyOn(document, 'removeEventListener'); + subscribeMotion(() => {}); + subscribeOrientation(() => {}); + dispose(); + const s = getStatus(); + expect(s.disposed).toBe(true); + expect(s.channels.motion.listenerAttached).toBe(false); + expect(s.channels.orientation.listenerAttached).toBe(false); + expect(s.visibilityListenerAttached).toBe(false); + expect(removeWin.mock.calls.some((c) => c[0] === 'devicemotion')).toBe(true); + expect(removeDoc.mock.calls.some((c) => c[0] === 'visibilitychange')).toBe(true); + }); + + /* 20. dispose sonrası API güvenli no-op */ + it('20: dispose sonrası API güvenli no-op (throw yok)', () => { + dispose(); + let called = false; + const release = subscribeMotion(() => { called = true; }); + expect(typeof release).toBe('function'); + expect(() => release()).not.toThrow(); + expect(getSubscriberCounts().total).toBe(0); + fireMotion(); + expect(called).toBe(false); + expect(getStatus().disposed).toBe(true); + }); + + /* 21. yeni timer yok */ + it('21: abonelik/dağıtım yeni timer kurmaz', () => { + const si = vi.spyOn(globalThis, 'setInterval'); + const st = vi.spyOn(globalThis, 'setTimeout'); + subscribeMotion(() => {}); + fireMotion(); + setVisibility('hidden'); + setVisibility('visible'); + expect(si).not.toHaveBeenCalled(); + expect(st).not.toHaveBeenCalled(); + }); + + /* 22. yeni requestAnimationFrame yok */ + it('22: yeni requestAnimationFrame döngüsü kurmaz', () => { + const raf = vi.spyOn(window, 'requestAnimationFrame'); + subscribeMotion(() => {}); + fireMotion(); + expect(raf).not.toHaveBeenCalled(); + }); + + /* 23. bounded consumer sayısı */ + it('23: kanal başına consumer bounded (tavan 64)', () => { + for (let i = 0; i < 70; i++) subscribeMotion(() => {}); + expect(getSubscriberCounts().motion).toBe(64); + expect(getStatus().droppedSubscriptions).toBeGreaterThanOrEqual(6); + }); + + /* 24. consumer limiti fail-soft */ + it('24: tavan aşımı fail-soft — throw yok, mevcut consumer\'lar çalışır', () => { + let hits = 0; + for (let i = 0; i < 64; i++) subscribeMotion(() => hits++); + let overflowRelease: gate.Release = () => {}; + expect(() => { overflowRelease = subscribeMotion(() => hits++); }).not.toThrow(); + expect(typeof overflowRelease).toBe('function'); + fireMotion(); + expect(hits).toBe(64); // 65. abonelik reddedildi + }); + + /* 25. visibility listener yalnız bir tane */ + it('25: birden çok abonelik tek visibilitychange listener kurar', () => { + const docAdd = vi.spyOn(document, 'addEventListener'); + subscribeMotion(() => {}); + subscribeOrientation(() => {}); + subscribeOrientationAbsolute(() => {}); + subscribeMotion(() => {}); + expect(docAdd.mock.calls.filter((c) => c[0] === 'visibilitychange').length).toBe(1); + expect(getStatus().visibilityListenerAttached).toBe(true); + }); + + /* 26. visibility listener dispose'ta sökülüyor */ + it('26: dispose visibilitychange listener\'ını söker', () => { + const docRemove = vi.spyOn(document, 'removeEventListener'); + subscribeMotion(() => {}); + expect(getStatus().visibilityListenerAttached).toBe(true); + dispose(); + expect(docRemove.mock.calls.filter((c) => c[0] === 'visibilitychange').length).toBe(1); + expect(getStatus().visibilityListenerAttached).toBe(false); + }); + + /* 27. permission akışı değişmiyor (izin tüketicide kalır) */ + it('27: kapı hiçbir permission API\'si içermez (izin tüketicide)', () => { + const keys = Object.keys(gate); + expect(keys.some((k) => /permission/i.test(k))).toBe(false); + expect(rawSrc).not.toMatch(/requestPermission/); + expect(rawSrc).not.toMatch(/\.permissions?\b/); + }); + + // Not: aşağıdaki bağımsızlık kilitleri IMPORT-regex kullanır (docstring + // mention'a TAKILMAZ). Kapı tamamen bağımsızdır (hiç import yok) → hiçbir + // tüketici/motoru import edip etkileyemez. + + /* 28. GPS davranışı değişmiyor */ + it('28: gate gpsService\'i import etmez (bağımsız)', () => { + expect(rawSrc).not.toMatch(/^\s*import\s/m); // hiç import yok + expect(rawSrc).not.toMatch(/from\s+['"][^'"]*gps/i); // gps import yolu yok + }); + + /* 29. MapLibre davranışı değişmiyor */ + it('29: gate MapLibre/harita motorunu import etmez', () => { + expect(rawSrc).not.toMatch(/from\s+['"][^'"]*maplibre/i); + expect(rawSrc).not.toMatch(/from\s+['"][^'"]*(mapService|MapCore|\/map)/i); + }); + + /* 30. Platform Kernel PR #55 değişmiyor */ + it('30: gate Platform Kernel\'i import etmez', () => { + expect(rawSrc).not.toMatch(/from\s+['"][^'"]*(kernel|serviceLifecycle)/i); + }); +}); diff --git a/src/__tests__/regression.guards.test.ts b/src/__tests__/regression.guards.test.ts index 7d446810..8d143a5e 100644 --- a/src/__tests__/regression.guards.test.ts +++ b/src/__tests__/regression.guards.test.ts @@ -34,6 +34,7 @@ import vehicleComputeWorkerSrc from '../platform/vehicleDataLayer/VehicleCompute import vehicleEventHubSrc from '../platform/vehicleDataLayer/VehicleEventHub.ts?raw'; import systemOrchestratorSrc from '../platform/system/SystemOrchestrator.ts?raw'; import healthMonitorSrc from '../platform/system/SystemHealthMonitor.ts?raw'; +import orientationGateSrc from '../platform/sensors/orientationSensorGate.ts?raw'; import { AdaptiveRuntimeManager } from '../core/runtime/AdaptiveRuntimeManager'; import { RuntimeMode } from '../core/runtime/runtimeTypes'; import { forceMode } from './sim/runtimeSimulator'; @@ -1347,3 +1348,35 @@ describe('Sağlık rollup — donanımsız cihazda false-critical kilidi', () => expect(healthMonitorSrc).toMatch(/!s\.healthy && s\.criticality === 'critical' && !isExpectedAbsence/); }); }); + +/* ─────────────────────────────────────────────────────────────── + PR 1 — Orientation Sensor Gate Foundation kilitleri. + + Kapı, ham DeviceOrientation/DeviceMotion event'lerinin MERKEZİ ref-count'lu + sahibidir. İki davranış defalarca sessizce bozulabilir: (a) visibility + listener'ının dispose'ta sökülmemesi (zero-leak ihlali), (b) modülün bir + tüketici/motor import ederek bağımsızlığını + import-yan-etkisizliğini + kaybetmesi. Bu kilitler ikisini de dondurur. + ─────────────────────────────────────────────────────────────── */ +describe('Orientation Sensor Gate — foundation kilitleri', () => { + it('YAPISAL: gate tek visibilitychange listener kurar VE dispose\'ta söker (zero-leak)', () => { + expect(orientationGateSrc, 'visibilitychange dinleyicisi eklenmiyor — hidden/visible gate çalışmaz') + .toMatch(/addEventListener\(\s*'visibilitychange'\s*,\s*_onVisibilityChange\s*\)/); + expect(orientationGateSrc, 'visibilitychange dinleyicisi sökülmüyor — zero-leak ihlali') + .toMatch(/removeEventListener\(\s*'visibilitychange'\s*,\s*_onVisibilityChange\s*\)/); + }); + + it('YAPISAL: gate tamamen bağımsız — hiç import yok (import yan etkisiz, GPS/MapLibre/Kernel etkilenmez)', () => { + // Tek bir top-level `import` bile yok → hiçbir tüketici/motoru import edip + // etkileyemez; modül yüklenmesi yan etkisizdir. + expect(orientationGateSrc, 'gate artık bağımsız değil — bir modül import edilmiş') + .not.toMatch(/^\s*import\s/m); + }); + + it('YAPISAL: gate native sampling rate iddiası taşımaz (legacy event API frekans kontrolü yok)', () => { + // Generic Sensor API / frekans ayarı EKLENMEMELİ — bu PR yalnız JS-tarafı + // abonelik yönetir; native samplingPeriod PR 2/3 wiring'i ile değişir. + expect(orientationGateSrc).not.toMatch(/new\s+(Gyroscope|Accelerometer|AbsoluteOrientationSensor|RelativeOrientationSensor)/); + expect(orientationGateSrc).not.toMatch(/frequency\s*:/); + }); +}); diff --git a/src/platform/sensors/index.ts b/src/platform/sensors/index.ts new file mode 100644 index 00000000..118992ed --- /dev/null +++ b/src/platform/sensors/index.ts @@ -0,0 +1,26 @@ +/** + * platform/sensors — Cihaz sensör aboneliği kapıları (gate). + * + * Şu an yalnız Orientation Sensor Gate foundation'ı (PR 1) barındırır. + * Tüketici wiring'i (gpsService compass, smartDrivingEngine, arAlignment, …) + * AYRI PR'larda bu kapıya taşınır. + */ + +export type { + OrientationCallback, + MotionCallback, + Release, + OrientationGateStatus, + OrientationGateChannelStatus, + OrientationGateSubscriberCounts, +} from './orientationSensorGate'; + +export { + subscribeOrientationAbsolute, + subscribeOrientation, + subscribeMotion, + getSubscriberCounts, + getStatus, + reset, + dispose, +} from './orientationSensorGate'; diff --git a/src/platform/sensors/orientationSensorGate.ts b/src/platform/sensors/orientationSensorGate.ts new file mode 100644 index 00000000..66040cdf --- /dev/null +++ b/src/platform/sensors/orientationSensorGate.ts @@ -0,0 +1,330 @@ +/** + * orientationSensorGate — Cihaz yön/hareket Web event'leri için MERKEZİ, + * ref-count'lu abonelik kapısı (multiplexer). + * + * NEDEN (kanıt): Bugün `deviceorientationabsolute` / `deviceorientation` / + * `devicemotion` event'lerine ≥6 servis (gpsService compass, smartDrivingEngine, + * arAlignmentService, dashcamService, blackBoxService, deviceApi) AYRI AYRI + * `window.addEventListener` ile abone oluyor. İkisi (compass + accelerometer) + * kök layout'ta app-ömrü boyunca bağlı → Ayarlar dahil HER ekranda sensör aktif. + * Bu kapı, tüketicilerin ham event'e doğrudan abone olması yerine TEK fiziksel + * listener üzerinden fan-out yapmasını ve görünürlük (visibility) durumuna göre + * fiziksel listener'ın açılıp kapanmasını sağlar. + * + * BU DOSYANIN YAPMADIĞI (foundation — PR 1): + * - Hiçbir tüketiciyi (gpsService/smartDrivingEngine/arAlignment/…) bağlamaz. + * Gerçek wiring AYRI PR'lardır (PR 2 on-demand, PR 3 always-on talep-güdümlü). + * - Native sampling rate'i DEĞİŞTİRMEZ / böyle bir iddia taşımaz. Legacy + * DeviceOrientation/DeviceMotion event API'sinin frekans parametresi yoktur; + * bu kapı yalnız JS-tarafı aboneliği yönetir. Fiziksel listener bağlıyken + * native sampling 60 Hz olabilir — CPU/samplingPeriod kazancı PR 2/3 wiring'i + * (özellikle talep-güdümlü acquire + background pause) ile ölçülür. + * - Permission (izin) akışına dokunmaz — izin sorma/erişim tüketicide kalır. + * - Generic Sensor API eklemez, JS throttle eklemez. + * + * TASARIM İLKELERİ: + * - Event türü başına TEK fiziksel `window` listener (dedup). + * - Callback Set ile ref-count; ilk consumer → bağla, son consumer → sök. + * - `document.visibilityState !== 'visible'` → fiziksel listener sökülür; + * görünür + aktif consumer → yeniden bağlanır. + * - Deterministik dağıtım sırası (Set insertion order, sabit dizi snapshot'ı). + * - Event başına YENİ array/object allocation YOK (index'li döngü, kararlı dizi). + * - Bir callback fırlatırsa diğerleri ETKİLENMEZ (izolasyon, fail-soft). + * - Public API ASLA throw etmez; dispose sonrası güvenli no-op. + * - Import yan etkisiz (modül seviyesinde HİÇBİR listener kurulmaz; ilk abonelik + * çağrısına kadar `visibilitychange` bile bağlanmaz), timer/rAF/polling YOK. + * - Bağımsız modül: HİÇBİR import YOK. + */ + +/* ─────────────────────────────────────────────────────────────── */ +/* PUBLIC TYPES */ +/* ─────────────────────────────────────────────────────────────── */ + +export type OrientationCallback = (event: DeviceOrientationEvent) => void; +export type MotionCallback = (event: DeviceMotionEvent) => void; + +/** Aboneliği kaldıran fonksiyon. İdempotenttir (birden çok çağrı güvenli). */ +export type Release = () => void; + +export interface OrientationGateChannelStatus { + subscriberCount: number; + listenerAttached: boolean; +} + +export interface OrientationGateSubscriberCounts { + orientationAbsolute: number; + orientation: number; + motion: number; + total: number; +} + +export interface OrientationGateStatus { + disposed: boolean; + visible: boolean; + visibilityListenerAttached: boolean; + droppedSubscriptions: number; + callbackErrors: number; + channels: { + orientationAbsolute: OrientationGateChannelStatus; + orientation: OrientationGateChannelStatus; + motion: OrientationGateChannelStatus; + }; +} + +/* ─────────────────────────────────────────────────────────────── */ +/* BOUNDS */ +/* ─────────────────────────────────────────────────────────────── */ + +/** + * Kanal başına en fazla eşzamanlı consumer. Gerçekte 3-4 tüketici beklenir + * (bkz. dosya başı); 64 bol bir güvenlik tavanı. Aşımda fail-soft: yeni + * abonelik sessizce reddedilir (no-op release, droppedSubscriptions++), + * mevcut consumer'lar ETKİLENMEZ, throw YOK. + */ +const MAX_SUBSCRIBERS_PER_CHANNEL = 64; + +/* ─────────────────────────────────────────────────────────────── */ +/* INTERNAL CHANNEL MODEL */ +/* ─────────────────────────────────────────────────────────────── */ + +type RawCallback = (event: Event) => void; +type ChannelEventName = 'deviceorientationabsolute' | 'deviceorientation' | 'devicemotion'; + +interface Channel { + readonly eventName: ChannelEventName; + /** Üyelik + dedup için (kimlik referansına göre). */ + readonly cbs: Set; + /** Hot-path dağıtımı için kararlı snapshot — yalnız abonelik değişince yeniden kurulur. */ + arr: RawCallback[]; + /** Kararlı fiziksel handler referansı (add/removeEventListener aynı referansı kullanır). */ + handler: RawCallback; + attached: boolean; +} + +function _makeChannel(eventName: ChannelEventName): Channel { + const ch: Channel = { + eventName, + cbs: new Set(), + arr: [], + handler: _noop as RawCallback, + attached: false, + }; + // Kararlı referans: her event'te aynı fonksiyon — add/remove eşleşir, alloc yok. + ch.handler = (event: Event): void => _dispatch(ch, event); + return ch; +} + +/* ─────────────────────────────────────────────────────────────── */ +/* MODULE STATE */ +/* ─────────────────────────────────────────────────────────────── */ + +const _noop: Release = () => { /* no-op */ }; + +let _disposed = false; +let _visibilityAttached = false; +let _droppedSubscriptions = 0; +let _callbackErrors = 0; + +const _chAbs = _makeChannel('deviceorientationabsolute'); +const _chRel = _makeChannel('deviceorientation'); +const _chMotion = _makeChannel('devicemotion'); + +// Sabit kanal listesi (görünürlük reconcile'ı için — event başına alloc değil). +const _channels: readonly Channel[] = [_chAbs, _chRel, _chMotion]; + +const _onVisibilityChange = (): void => { _reconcileAll(); }; + +/* ─────────────────────────────────────────────────────────────── */ +/* INTERNAL HELPERS */ +/* ─────────────────────────────────────────────────────────────── */ + +function _hasWindow(): boolean { + return typeof window !== 'undefined'; +} + +/** Belge görünür mü? Belge yoksa (DOM'suz ortam) görünür kabul edilir. */ +function _isVisible(): boolean { + return typeof document === 'undefined' || document.visibilityState === 'visible'; +} + +/** Hot-path dağıtımı — index'li döngü, event başına YENİ array/object YOK. */ +function _dispatch(ch: Channel, event: Event): void { + const arr = ch.arr; + for (let i = 0; i < arr.length; i++) { + try { + arr[i](event); + } catch { + // İzolasyon: bir callback'in hatası diğerlerini engellemez (fail-soft). + _callbackErrors++; + } + } +} + +/** cbs değişince kararlı dağıtım dizisini yeniden kur (insertion order korunur). */ +function _rebuild(ch: Channel): void { + ch.arr = Array.from(ch.cbs); +} + +/** Kanalın fiziksel listener durumunu istenen duruma getir (idempotent). */ +function _reconcile(ch: Channel): void { + if (!_hasWindow()) return; + const shouldAttach = !_disposed && ch.cbs.size > 0 && _isVisible(); + if (shouldAttach && !ch.attached) { + window.addEventListener(ch.eventName, ch.handler); + ch.attached = true; + } else if (!shouldAttach && ch.attached) { + window.removeEventListener(ch.eventName, ch.handler); + ch.attached = false; + } +} + +function _reconcileAll(): void { + _reconcile(_chAbs); + _reconcile(_chRel); + _reconcile(_chMotion); +} + +function _ensureVisibilityListener(): void { + if (_visibilityAttached || _disposed || typeof document === 'undefined') return; + document.addEventListener('visibilitychange', _onVisibilityChange); + _visibilityAttached = true; +} + +function _removeVisibilityListener(): void { + if (_visibilityAttached && typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', _onVisibilityChange); + } + _visibilityAttached = false; +} + +function _detachAllPhysical(): void { + if (!_hasWindow()) return; + for (let i = 0; i < _channels.length; i++) { + const ch = _channels[i]; + if (ch.attached) { + window.removeEventListener(ch.eventName, ch.handler); + ch.attached = false; + } + } +} + +function _clearChannels(): void { + for (let i = 0; i < _channels.length; i++) { + _channels[i].cbs.clear(); + _channels[i].arr = []; + } +} + +/** + * Ortak abonelik yolu. rawCb, çağrının cast'lenmiş callback'idir — cast + * derleme-zamanıdır, runtime'da AYNI fonksiyon referansı korunur (dedup ve + * release kimliği bozulmaz). + */ +function _subscribe(ch: Channel, rawCb: RawCallback): Release { + // Fail-soft: dispose sonrası veya DOM'suz ortamda güvenli no-op. + if (_disposed || !_hasWindow()) return _noop; + + // Duplicate: tek kayıt korunur — no-op release döndür (orijinal release yönetir). + if (ch.cbs.has(rawCb)) return _noop; + + // Bounded: tavan aşılırsa fail-soft reddet (throw yok). + if (ch.cbs.size >= MAX_SUBSCRIBERS_PER_CHANNEL) { + _droppedSubscriptions++; + return _noop; + } + + ch.cbs.add(rawCb); + _rebuild(ch); + _ensureVisibilityListener(); + _reconcile(ch); + + let released = false; + return (): void => { + if (released) return; // idempotent + released = true; + if (ch.cbs.delete(rawCb)) { + _rebuild(ch); + _reconcile(ch); + } + }; +} + +/* ─────────────────────────────────────────────────────────────── */ +/* PUBLIC API */ +/* ─────────────────────────────────────────────────────────────── */ + +/** + * `deviceorientationabsolute` event'ine abone ol (Android absolute pusula). + * İdempotent release döndürür. Aynı callback iki kez verilirse tek kayıt tutulur. + */ +export function subscribeOrientationAbsolute(callback: OrientationCallback): Release { + return _subscribe(_chAbs, callback as unknown as RawCallback); +} + +/** + * `deviceorientation` event'ine abone ol (iOS webkitCompassHeading / fallback). + */ +export function subscribeOrientation(callback: OrientationCallback): Release { + return _subscribe(_chRel, callback as unknown as RawCallback); +} + +/** + * `devicemotion` event'ine abone ol (ivmeölçer + jiroskop rotationRate). + */ +export function subscribeMotion(callback: MotionCallback): Release { + return _subscribe(_chMotion, callback as unknown as RawCallback); +} + +/** Kanal başına ve toplam aktif consumer sayısı (dondurulmuş kopya). */ +export function getSubscriberCounts(): OrientationGateSubscriberCounts { + const a = _chAbs.cbs.size; + const r = _chRel.cbs.size; + const m = _chMotion.cbs.size; + return Object.freeze({ + orientationAbsolute: a, + orientation: r, + motion: m, + total: a + r + m, + }); +} + +/** Kapının tam anlık durumu (dondurulmuş, teşhis/test amaçlı). */ +export function getStatus(): OrientationGateStatus { + return Object.freeze({ + disposed: _disposed, + visible: _isVisible(), + visibilityListenerAttached: _visibilityAttached, + droppedSubscriptions: _droppedSubscriptions, + callbackErrors: _callbackErrors, + channels: Object.freeze({ + orientationAbsolute: Object.freeze({ subscriberCount: _chAbs.cbs.size, listenerAttached: _chAbs.attached }), + orientation: Object.freeze({ subscriberCount: _chRel.cbs.size, listenerAttached: _chRel.attached }), + motion: Object.freeze({ subscriberCount: _chMotion.cbs.size, listenerAttached: _chMotion.attached }), + }), + }) as OrientationGateStatus; +} + +/** + * Tüm consumer'ları ve fiziksel listener'ları temizle, modülü YENİDEN + * KULLANILABİLİR temiz duruma döndür (sayaçlar sıfırlanır, disposed=false). + */ +export function reset(): void { + _detachAllPhysical(); + _clearChannels(); + _removeVisibilityListener(); + _droppedSubscriptions = 0; + _callbackErrors = 0; + _disposed = false; +} + +/** + * Kapıyı kalıcı olarak kapat: tüm fiziksel + görünürlük listener'larını sök, + * consumer'ları temizle. Zero-leak. Sonrasında public API güvenli no-op olur + * (yeniden kullanmak için `reset()` gerekir). + */ +export function dispose(): void { + _detachAllPhysical(); + _clearChannels(); + _removeVisibilityListener(); + _disposed = true; +}