diff --git a/apps/chanora_flutter/test/alpha_e2e_test.dart b/apps/chanora_flutter/test/alpha_e2e_test.dart index 1c5e7f4..b420f58 100644 --- a/apps/chanora_flutter/test/alpha_e2e_test.dart +++ b/apps/chanora_flutter/test/alpha_e2e_test.dart @@ -8,6 +8,13 @@ // path. // // Tagged with the network-required marker so future CI can opt out. +// +// Requirement trace (SWE.5 / SWE.6 — end-to-end connect path): +// SRS-001..SRS-030 (server connection flow), SRS-031..SRS-060 (channel/client snapshot), +// SAD-032, SAD-033, SAD-040, SAD-041 (protocol adapter + bridge facade), +// SDD-046, SDD-047 (typed DTOs + event mapping). +// Verification-plan rows: SWE5-IV-008, SWE5-IV-009 (swe5-software-integration-verification-plan.md); +// SWE6-SV-001, SWE6-SV-002 (swe6-software-verification-plan.md). // For now it's just a regular flutter_test test. import 'package:flutter_test/flutter_test.dart'; diff --git a/apps/chanora_flutter/test/beta_e2e_test.dart b/apps/chanora_flutter/test/beta_e2e_test.dart index e5636db..d36dc50 100644 --- a/apps/chanora_flutter/test/beta_e2e_test.dart +++ b/apps/chanora_flutter/test/beta_e2e_test.dart @@ -8,6 +8,13 @@ // 4. Encoder produces Opus frames while PTT is held. // 5. Disconnect cleans both protocol and audio. // +// Requirement trace (SWE.5 / SWE.6 — end-to-end voice + PTT path): +// SRS-061..SRS-090 (voice send/receive, PTT, mute/deaf), SRS-156 (PTT control), +// SRS-195..SRS-203 (desktop PTT semantics; FocusedPttBackend collapse on mobile), +// SAD-034 (audio subsystem), SAD-071..SAD-079 (PTT subsystem), +// SDD-048 (voice view-model), SDD-081..SDD-092 (PTT trait + backends + watchdog). +// Verification-plan rows: SWE5-IV-010, SWE5-IV-015 (swe5-software-integration-verification-plan.md); +// SWE6-SV-003, SWE6-SV-004, SWE6-SV-017 (swe6-software-verification-plan.md). // Network-dependent. Quietly tolerates a server that rejects voice // (e.g. because the test account is not yet allowed to talk). diff --git a/apps/chanora_flutter/test/services/android_permissions_service_test.dart b/apps/chanora_flutter/test/services/android_permissions_service_test.dart new file mode 100644 index 0000000..364804c --- /dev/null +++ b/apps/chanora_flutter/test/services/android_permissions_service_test.dart @@ -0,0 +1,435 @@ +// SWE.4 unit tests for AndroidPermissionsService — the Dart-side +// integration layer for the Kotlin `AndroidPermissionRequester` +// (SDD-106) consumed by SRS-209's listen-only fallback / pre-voice_join +// gate. +// +// Requirement trace: +// Verification-plan row: SWE4-UV-041 (swe4-unit-verification-plan.md). +// SDD-106 (Android runtime acquisition of RECORD_AUDIO; bridge event +// surface §5; openAppSettings deep-link §3). +// SRS-209 (runtime microphone permission acquisition, fail-safe to +// listen-only on denial, user-visible path to grant). +// +// Strategy: Following the pattern in `back_intent_service_test.dart`, +// use `TestDefaultBinaryMessengerBinding` to (a) capture outbound +// `requestRecordAudio` / `openAppSettings` invocations and (b) inject +// inbound `permissionStateChanged` calls as if Kotlin had emitted them. +// +// Platform note: these tests run on the host (Linux), where +// `Platform.isAndroid` is false. The production constructor's default +// `_channel` therefore becomes `null` on the host. We bypass that by +// injecting an explicit `MethodChannel` via the constructor's +// `channel:` parameter so that the inbound/outbound contract under +// test is actually exercised. The non-Android short-circuit (channel +// == null) is covered explicitly by the final test. + +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/services/android_permissions_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MethodChannel channel; + late List outgoingCalls; + + // Optional override for the outbound mock handler. When null, the + // mock returns null (no-op) and does not synthesise an inbound + // `permissionStateChanged` callback. + Future Function(MethodCall call)? outgoingResponder; + + /// Helper: simulate Kotlin invoking `permissionStateChanged` on the + /// Dart-installed handler. Mirrors what `AndroidPermissionRequester.kt` + /// does at runtime. + Future sendPermissionStateChanged({ + required String permission, + required String state, + }) async { + const codec = StandardMethodCodec(); + final encoded = codec.encodeMethodCall( + MethodCall( + methodPermissionStateChanged, + { + 'permission': permission, + 'state': state, + }, + ), + ); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage(channel.name, encoded, (_) {}); + } + + setUp(() { + channel = const MethodChannel(androidPermissionsChannelName); + outgoingCalls = []; + outgoingResponder = null; + + // Capture outbound invokeMethod calls (requestRecordAudio, + // openAppSettings) by installing a mock on the channel's outgoing + // side. The default returns null; tests that need a richer + // response set `outgoingResponder`. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + outgoingCalls.add(call); + final r = outgoingResponder; + if (r != null) return r(call); + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test( + 'SWE4-UV-041 / SDD-106: a fresh service exposes a deterministic ' + 'initial recordAudioState (granted on non-Android host, where these ' + 'unit tests run; the Android cold-launch case is unknown)', () { + // Trace: SDD-106 §5 state machine; non-Android short-circuit + // documented in android_permissions_service.dart's library + // doc-comment ("default state is granted" off-Android). + final svc = AndroidPermissionsService(channel: channel); + // On the linux host where flutter test runs, Platform.isAndroid is + // false, so the ValueNotifier seeds to granted regardless of + // whether a channel was injected. This documents that contract. + expect( + svc.recordAudioState.value, + AndroidRecordAudioPermissionState.granted, + ); + svc.dispose(); + }); + + test( + 'SWE4-UV-041 / SDD-106 §5: inbound permissionStateChanged with ' + 'state=Granted transitions recordAudioState to granted and notifies ' + 'listeners', () async { + final svc = AndroidPermissionsService(channel: channel)..start(); + + // Drive away from granted first so the assertion is meaningful. + await sendPermissionStateChanged( + permission: 'android.permission.RECORD_AUDIO', + state: 'Denied', + ); + expect( + svc.recordAudioState.value, + AndroidRecordAudioPermissionState.denied, + ); + + var notified = 0; + void listener() => notified++; + svc.recordAudioState.addListener(listener); + + await sendPermissionStateChanged( + permission: 'android.permission.RECORD_AUDIO', + state: 'Granted', + ); + + expect( + svc.recordAudioState.value, + AndroidRecordAudioPermissionState.granted, + ); + expect(notified, greaterThanOrEqualTo(1)); + + svc.recordAudioState.removeListener(listener); + svc.dispose(); + }); + + test( + 'SWE4-UV-041 / SDD-106 §5: inbound permissionStateChanged with ' + 'state=Denied transitions recordAudioState to denied', () async { + final svc = AndroidPermissionsService(channel: channel)..start(); + + await sendPermissionStateChanged( + permission: 'android.permission.RECORD_AUDIO', + state: 'Denied', + ); + + expect( + svc.recordAudioState.value, + AndroidRecordAudioPermissionState.denied, + ); + svc.dispose(); + }); + + test( + 'SWE4-UV-041 / SDD-106 §3: inbound permissionStateChanged with ' + 'state=PermanentlyDenied transitions recordAudioState to ' + 'permanentlyDenied (drives the "open app settings" UX)', () async { + final svc = AndroidPermissionsService(channel: channel)..start(); + + await sendPermissionStateChanged( + permission: 'android.permission.RECORD_AUDIO', + state: 'PermanentlyDenied', + ); + + expect( + svc.recordAudioState.value, + AndroidRecordAudioPermissionState.permanentlyDenied, + ); + svc.dispose(); + }); + + test( + 'SWE4-UV-041 / SDD-106 §5: a malformed state string parses to ' + 'unknown (the _parseState default branch)', () async { + // Implementation contract: `_parseState` returns `unknown` for any + // string outside {Granted, Denied, PermanentlyDenied}. + final svc = AndroidPermissionsService(channel: channel)..start(); + + // Start from a non-unknown baseline so the transition is observable. + await sendPermissionStateChanged( + permission: 'android.permission.RECORD_AUDIO', + state: 'Granted', + ); + expect( + svc.recordAudioState.value, + AndroidRecordAudioPermissionState.granted, + ); + + await sendPermissionStateChanged( + permission: 'android.permission.RECORD_AUDIO', + state: 'Bogus', + ); + + expect( + svc.recordAudioState.value, + AndroidRecordAudioPermissionState.unknown, + ); + svc.dispose(); + }); + + test( + 'SWE4-UV-041 / SDD-106: inbound permissionStateChanged for a ' + 'permission other than RECORD_AUDIO is ignored (POST_NOTIFICATIONS ' + 'will route through a sibling listenable per the impl note)', + () async { + final svc = AndroidPermissionsService(channel: channel)..start(); + + // Seed a known baseline. + await sendPermissionStateChanged( + permission: 'android.permission.RECORD_AUDIO', + state: 'Denied', + ); + expect( + svc.recordAudioState.value, + AndroidRecordAudioPermissionState.denied, + ); + + // Now send a POST_NOTIFICATIONS message claiming Granted; the + // RECORD_AUDIO state must not move. + await sendPermissionStateChanged( + permission: 'android.permission.POST_NOTIFICATIONS', + state: 'Granted', + ); + + expect( + svc.recordAudioState.value, + AndroidRecordAudioPermissionState.denied, + reason: + 'POST_NOTIFICATIONS must not mutate the RECORD_AUDIO listenable', + ); + svc.dispose(); + }); + + test( + 'SWE4-UV-041 / SDD-106: start() is idempotent — calling it twice ' + 'does not double-register the handler and inbound messages still ' + 'fire exactly once', () async { + final svc = AndroidPermissionsService(channel: channel) + ..start() + ..start(); // Second call must be a no-op. + + var notified = 0; + void listener() => notified++; + svc.recordAudioState.addListener(listener); + + await sendPermissionStateChanged( + permission: 'android.permission.RECORD_AUDIO', + state: 'Denied', + ); + + expect( + svc.recordAudioState.value, + AndroidRecordAudioPermissionState.denied, + ); + // A single inbound call → a single value change → exactly one + // listener notification (would be two if the handler had been + // double-registered). + expect(notified, 1); + + svc.recordAudioState.removeListener(listener); + svc.dispose(); + }); + + test( + 'SWE4-UV-041 / SDD-106: stop() removes the handler — subsequent ' + 'inbound messages have no effect on recordAudioState', () async { + final svc = AndroidPermissionsService(channel: channel)..start(); + + await sendPermissionStateChanged( + permission: 'android.permission.RECORD_AUDIO', + state: 'Denied', + ); + expect( + svc.recordAudioState.value, + AndroidRecordAudioPermissionState.denied, + ); + + svc.stop(); + + // After stop(): inbound message must NOT mutate state. + await sendPermissionStateChanged( + permission: 'android.permission.RECORD_AUDIO', + state: 'Granted', + ); + + expect( + svc.recordAudioState.value, + AndroidRecordAudioPermissionState.denied, + reason: 'state must be frozen after stop()', + ); + }); + + test( + 'SWE4-UV-041 / SDD-106 §1: ensureRecordAudio() emits an outbound ' + 'requestRecordAudio MethodCall on the channel; when the platform ' + 'stub raises (e.g. handler missing in a debug build) the impl ' + 'catches and resolves to the current cached state', () async { + final svc = AndroidPermissionsService(channel: channel)..start(); + + // Seed a baseline so the return value is observable & deterministic. + await sendPermissionStateChanged( + permission: 'android.permission.RECORD_AUDIO', + state: 'Denied', + ); + + // Make the platform stub throw to drive the impl's catch-branch + // (see android_permissions_service.dart: "Channel-side failure + // (e.g. missing handler in a debug build). Fall back to whatever + // state we currently hold"). This is the deterministic, fast path + // to verify the outbound call is emitted without depending on a + // synthesised inbound transition. + outgoingResponder = (call) async { + throw PlatformException(code: 'unavailable'); + }; + + final priorOutgoing = outgoingCalls.length; + final result = await svc.ensureRecordAudio(); + + final requests = outgoingCalls + .skip(priorOutgoing) + .where((c) => c.method == methodRequestRecordAudio) + .toList(); + expect(requests, hasLength(1), + reason: 'ensureRecordAudio() must invoke requestRecordAudio'); + expect( + result, + AndroidRecordAudioPermissionState.denied, + reason: + 'on channel failure ensureRecordAudio must return the cached state', + ); + svc.dispose(); + }); + + test( + 'SWE4-UV-041 / SDD-106 §1: ensureRecordAudio() resolves with the ' + 'new state when an inbound permissionStateChanged is delivered ' + 'while the request is in-flight (the realistic Kotlin flow)', + () async { + final svc = AndroidPermissionsService(channel: channel)..start(); + + // Seed away from the host default (granted on linux) so that the + // subsequent Granted emission is an actual ValueNotifier + // transition, which is what the in-flight listener inside + // ensureRecordAudio relies on. + await sendPermissionStateChanged( + permission: 'android.permission.RECORD_AUDIO', + state: 'Denied', + ); + expect( + svc.recordAudioState.value, + AndroidRecordAudioPermissionState.denied, + ); + + // Kick off the request without awaiting; the listener inside + // ensureRecordAudio is registered synchronously before the + // invokeMethod call yields. + final pending = svc.ensureRecordAudio(); + + // Yield once so the outgoing invokeMethod can dispatch and our + // capture-mock can record it. + await Future.delayed(Duration.zero); + expect( + outgoingCalls.where((c) => c.method == methodRequestRecordAudio), + hasLength(1), + reason: 'outgoing requestRecordAudio must be observable mid-flight', + ); + + // Now simulate Kotlin emitting the resolved state. + await sendPermissionStateChanged( + permission: 'android.permission.RECORD_AUDIO', + state: 'Granted', + ); + + final result = await pending; + + expect(result, AndroidRecordAudioPermissionState.granted); + expect( + svc.recordAudioState.value, + AndroidRecordAudioPermissionState.granted, + ); + svc.dispose(); + }); + + test( + 'SWE4-UV-041 / SDD-106 §3: openAppSettings() emits an outbound ' + 'openAppSettings MethodCall on the channel', () async { + final svc = AndroidPermissionsService(channel: channel)..start(); + + await svc.openAppSettings(); + + final calls = outgoingCalls + .where((c) => c.method == methodOpenAppSettings) + .toList(); + expect(calls, hasLength(1)); + svc.dispose(); + }); + + test( + 'SWE4-UV-041 / SDD-106: non-Android short-circuit — when channel ' + 'is null, recordAudioState seeds to granted and ensureRecordAudio ' + 'resolves synchronously without touching any channel', () async { + final svc = AndroidPermissionsService(channel: null); + + // Per the library doc: "The state listenable stays at granted so + // the voice-join gate becomes a no-op on those platforms." + expect( + svc.recordAudioState.value, + AndroidRecordAudioPermissionState.granted, + ); + + // start()/stop() must be no-ops (not throw) when channel is null. + svc.start(); + svc.stop(); + + final priorOutgoing = outgoingCalls.length; + final result = await svc.ensureRecordAudio(); + + expect(result, AndroidRecordAudioPermissionState.granted); + expect( + outgoingCalls.length, + priorOutgoing, + reason: 'no outbound channel calls when channel is null', + ); + + // openAppSettings must also be a silent no-op. + await svc.openAppSettings(); + expect(outgoingCalls.length, priorOutgoing); + + svc.dispose(); + }); +} diff --git a/apps/chanora_flutter/test/services/back_intent_policy_test.dart b/apps/chanora_flutter/test/services/back_intent_policy_test.dart new file mode 100644 index 0000000..647aa4e --- /dev/null +++ b/apps/chanora_flutter/test/services/back_intent_policy_test.dart @@ -0,0 +1,125 @@ +// SWE.4 unit tests for the BackIntentPolicy pure decision function. +// +// Requirement trace: +// SDD-028 §2 (deterministic route-pop ordering) / SAD-018. +// Verification-plan row: SWE4-UV-042 (swe4-unit-verification-plan.md). +// +// Strategy: enumerate ALL 8 combinations of the three boolean inputs +// (pttActive, modalOpen, atRoot) and assert the exact decision variant +// returned by `decideBackIntent`. The function is pure-Dart with no +// Flutter dependency, so no widget tester or platform channel is needed. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/services/back_intent_policy.dart'; + +void main() { + group('SWE4-UV-042 decideBackIntent truth table', () { + // Branch 1 (highest priority): pttActive == true → IgnoreDueToPtt. + // Four combinations of (modalOpen, atRoot) must all yield the same + // decision because PTT-active short-circuits the whole policy. + + test('SWE4-UV-042: ptt=T, modal=F, atRoot=F → IgnoreDueToPtt', () { + final d = decideBackIntent( + pttActive: true, + modalOpen: false, + atRoot: false, + ); + expect(d, isA()); + }); + + test('SWE4-UV-042: ptt=T, modal=F, atRoot=T → IgnoreDueToPtt', () { + final d = decideBackIntent( + pttActive: true, + modalOpen: false, + atRoot: true, + ); + expect(d, isA()); + }); + + test('SWE4-UV-042: ptt=T, modal=T, atRoot=F → IgnoreDueToPtt', () { + final d = decideBackIntent( + pttActive: true, + modalOpen: true, + atRoot: false, + ); + expect(d, isA()); + }); + + test('SWE4-UV-042: ptt=T, modal=T, atRoot=T → IgnoreDueToPtt', () { + final d = decideBackIntent( + pttActive: true, + modalOpen: true, + atRoot: true, + ); + expect(d, isA()); + }); + + // Branch 2: pttActive == false && modalOpen == true → CloseModal. + // atRoot is irrelevant when a modal is on top. + + test('SWE4-UV-042: ptt=F, modal=T, atRoot=F → CloseModal', () { + final d = decideBackIntent( + pttActive: false, + modalOpen: true, + atRoot: false, + ); + expect(d, isA()); + }); + + test('SWE4-UV-042: ptt=F, modal=T, atRoot=T → CloseModal', () { + final d = decideBackIntent( + pttActive: false, + modalOpen: true, + atRoot: true, + ); + expect(d, isA()); + }); + + // Branch 3: pttActive == false && modalOpen == false && atRoot == false + // → PopRoute. + + test('SWE4-UV-042: ptt=F, modal=F, atRoot=F → PopRoute', () { + final d = decideBackIntent( + pttActive: false, + modalOpen: false, + atRoot: false, + ); + expect(d, isA()); + }); + + // Branch 4 (default): all false except atRoot → ExitApp. + + test('SWE4-UV-042: ptt=F, modal=F, atRoot=T → ExitApp', () { + final d = decideBackIntent( + pttActive: false, + modalOpen: false, + atRoot: true, + ); + expect(d, isA()); + }); + + test('SWE4-UV-042: variants are distinct and correctly typed', () { + // Sanity: the four variants are NOT interchangeable. This guards + // against an accidental refactor that collapses the sealed + // hierarchy. + const ignore = IgnoreDueToPtt(); + const close = CloseModal(); + const pop = PopRoute(); + const exit = ExitApp(); + + expect(ignore, isA()); + expect(close, isA()); + expect(pop, isA()); + expect(exit, isA()); + + expect(ignore, isNot(isA())); + expect(close, isNot(isA())); + expect(pop, isNot(isA())); + expect(exit, isNot(isA())); + + expect(ignore.runtimeType, isNot(equals(close.runtimeType))); + expect(pop.runtimeType, isNot(equals(exit.runtimeType))); + }); + }); +} diff --git a/apps/chanora_flutter/test/services/back_intent_service_test.dart b/apps/chanora_flutter/test/services/back_intent_service_test.dart new file mode 100644 index 0000000..941d0dd --- /dev/null +++ b/apps/chanora_flutter/test/services/back_intent_service_test.dart @@ -0,0 +1,196 @@ +// SWE.4 unit-level slice for BackIntentService channel routing. +// +// Requirement trace: +// SDD-028 (BackIntentService channel adapter) / SAD-018. +// Verification-plan rows: SWE4-UV-042 (policy branches dispatched +// through the channel handler), SWE5-IV-019 (BackIntentService ↔ +// Navigator stack — unit-level slice; full integration is in SWE.5). +// +// Strategy: use Flutter's `TestDefaultBinaryMessengerBinding` to drive +// inbound MethodCalls on `app.chanora/back_intent` and to capture +// outbound `popToSystem` calls. No real Android platform is involved. + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/services/back_intent_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MethodChannel channel; + late List outgoingCalls; + + // Probe state. Mutating these lets each test express the shell + // state the policy should observe at dispatch time. + late bool pttActive; + late bool modalOpen; + late bool atRoot; + + // Effect counters. + late int closeTopModalCount; + late int popRouteCount; + + BackIntentService makeService() { + return BackIntentService( + pttActiveProbe: () => pttActive, + modalOpenProbe: () => modalOpen, + atRootProbe: () => atRoot, + closeTopModal: () => closeTopModalCount++, + popRoute: () => popRouteCount++, + channel: channel, + ); + } + + setUp(() { + channel = const MethodChannel(backIntentChannelName); + outgoingCalls = []; + pttActive = false; + modalOpen = false; + atRoot = false; + closeTopModalCount = 0; + popRouteCount = 0; + + // Capture outbound invokeMethod calls (e.g. popToSystem) by + // installing a mock handler on the OUTGOING side of the channel. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + outgoingCalls.add(call); + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + /// Helper: simulate the platform side sending a `backIntent` call + /// into the Dart-installed handler. Mirrors what `BackIntentBridge.kt` + /// does at runtime. + Future sendSystemBack() async { + const codec = StandardMethodCodec(); + final encoded = codec.encodeMethodCall( + const MethodCall( + backIntentMethodFromPlatform, + { + backIntentPayloadKindKey: backIntentPayloadKindSystemBack, + }, + ), + ); + await TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .handlePlatformMessage(channel.name, encoded, (_) {}); + } + + test( + 'SWE4-UV-042 / SWE5-IV-019: pttActive swallows back; no effects, ' + 'no outbound popToSystem', () async { + final svc = makeService()..start(); + pttActive = true; + modalOpen = true; // Should not matter — ptt wins. + atRoot = true; // Should not matter — ptt wins. + + await sendSystemBack(); + + expect(closeTopModalCount, 0); + expect(popRouteCount, 0); + expect( + outgoingCalls.where((c) => c.method == backIntentMethodPopToSystem), + isEmpty, + ); + + svc.stop(); + }); + + test( + 'SWE4-UV-042 / SWE5-IV-019: modalOpen routes to closeTopModal only', + () async { + final svc = makeService()..start(); + modalOpen = true; + atRoot = true; // Irrelevant when modal is open. + + await sendSystemBack(); + + expect(closeTopModalCount, 1); + expect(popRouteCount, 0); + expect( + outgoingCalls.where((c) => c.method == backIntentMethodPopToSystem), + isEmpty, + ); + + svc.stop(); + }); + + test( + 'SWE4-UV-042 / SWE5-IV-019: !atRoot routes to popRoute only', + () async { + final svc = makeService()..start(); + atRoot = false; + + await sendSystemBack(); + + expect(popRouteCount, 1); + expect(closeTopModalCount, 0); + expect( + outgoingCalls.where((c) => c.method == backIntentMethodPopToSystem), + isEmpty, + ); + + svc.stop(); + }); + + test( + 'SWE4-UV-042 / SWE5-IV-019: atRoot with no modal/ptt invokes ' + 'popToSystem back through the channel', () async { + final svc = makeService()..start(); + atRoot = true; + + await sendSystemBack(); + + expect(closeTopModalCount, 0); + expect(popRouteCount, 0); + final popToSystem = outgoingCalls + .where((c) => c.method == backIntentMethodPopToSystem) + .toList(); + expect(popToSystem, hasLength(1)); + + svc.stop(); + }); + + test( + 'SWE5-IV-019: stop() removes the handler so subsequent inbound ' + 'calls have no effect', () async { + final svc = makeService()..start(); + atRoot = false; + + // First call should route normally. + await sendSystemBack(); + expect(popRouteCount, 1); + + svc.stop(); + + // After stop(): an inbound backIntent must not increment effect + // counters or produce outbound popToSystem. Because stop() removed + // our handler, the test messenger's mock-outgoing handler is the + // only thing left on the channel; sending a "backIntent" to it + // would otherwise be captured in outgoingCalls. To verify the + // handler is gone we instead drive the channel as if the platform + // re-sent the same message and assert no effect counter moved. + final priorOutgoing = outgoingCalls.length; + await sendSystemBack(); + expect(popRouteCount, 1, reason: 'popRoute must not fire after stop()'); + expect(closeTopModalCount, 0); + // The mock outgoing handler may have echoed the inbound call into + // outgoingCalls (because with no inbound handler installed, the + // test binding routes the message to the outbound side). That is + // acceptable — the contract we care about is that the *service* + // performed no work. Assert no NEW popToSystem was issued by the + // service (the captured call, if any, has method 'backIntent', not + // 'popToSystem'). + final newPopToSystem = outgoingCalls + .skip(priorOutgoing) + .where((c) => c.method == backIntentMethodPopToSystem); + expect(newPopToSystem, isEmpty); + }); +} diff --git a/apps/chanora_flutter/test/widget_test.dart b/apps/chanora_flutter/test/widget_test.dart index a768867..02377fd 100644 --- a/apps/chanora_flutter/test/widget_test.dart +++ b/apps/chanora_flutter/test/widget_test.dart @@ -3,6 +3,12 @@ // // Does NOT call into the FRB Rust side; that requires the cdylib at // runtime and is verified by alpha_e2e_test.dart + beta_e2e_test.dart. +// +// Requirement trace (SWE.4 unit verification — banner widget): +// SRS-165..SRS-169 (localization), SRS-170..SRS-176 (Unicode / server content), +// SAD-014 (design-system + localization integration), +// SDD-031, SDD-032, SDD-033, SDD-034 (localization fallback + product-vs-server text). +// Verification-plan rows: SWE4-UV-014, SWE4-UV-019, SWE4-UV-020 (swe4-unit-verification-plan.md). import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/crates/chanora_diagnostics/src/lib.rs b/crates/chanora_diagnostics/src/lib.rs index 256d474..1b0bb01 100644 --- a/crates/chanora_diagnostics/src/lib.rs +++ b/crates/chanora_diagnostics/src/lib.rs @@ -606,6 +606,12 @@ pub struct DiagnosticExport { /// Number of currently registered known-secret values. The /// values themselves are *not* exported. pub known_secret_count: usize, + /// SDD-116 item 3: Android voice-audio diagnostics YAML + /// fragment, or `None` on non-Android targets / before a voice + /// session has opened. The producing crate guarantees this + /// fragment contains only device-side technical scalars per + /// SDD-090 (no PII, no permission state, no server identity). + pub android_audio: Option, } impl DiagnosticExport { @@ -618,9 +624,20 @@ impl DiagnosticExport { metadata, recent_logs: sink.snapshot(), known_secret_count: sink.redactor().secrets().len(), + android_audio: None, }) } + /// Attach an Android voice-audio diagnostics YAML fragment + /// (SDD-112 item 10 / SDD-113 item 7 / SDD-116 item 3). The + /// caller is responsible for ensuring the fragment is already + /// sanitised per SDD-090; the diagnostics bundle does not + /// re-redact this string. + pub fn with_android_audio(mut self, yaml_fragment: Option) -> Self { + self.android_audio = yaml_fragment; + self + } + /// Render as a plaintext blob suitable for `Share` / `Copy`. /// The output is multi-line UTF-8, redacted. pub fn to_text(&self) -> String { @@ -634,6 +651,11 @@ impl DiagnosticExport { for (k, v) in &self.metadata { out.push_str(&format!("{k}: {v}\n")); } + if let Some(yaml) = &self.android_audio { + // SDD-116 item 3 verification-matrix section. + out.push_str("\n[audio.android]\n"); + out.push_str(yaml); + } out.push_str("\n[recent logs]\n"); for line in &self.recent_logs { out.push_str(line); @@ -873,4 +895,77 @@ mod tests { assert!(!text.contains("u128 banned record must drop")); assert!(text.contains("safe record must pass")); } + + /// SWE4-UV-056 — `DiagnosticExport` with `android_audio` renders the + /// `[audio.android]` section AFTER `[metadata]` and BEFORE + /// `[recent logs]`, with the YAML fragment immediately following + /// the header byte-for-byte (the bundle does not re-redact per + /// SDD-090 trust boundary). + #[test] + fn android_audio_renders_between_metadata_and_logs() { + let secrets = KnownSecretRegistry::default(); + let redactor = Redactor::with_secrets(secrets); + let sink = InMemoryLogSink::new(16, redactor); + let yaml = "achieved:\n performance_mode: LowLatency\n sample_rate_hz: 48000\n"; + let exported = DiagnosticExport::from_sink( + &sink, + vec![("build".into(), "test".into())], + ) + .unwrap() + .with_android_audio(Some(yaml.to_string())); + let text = exported.to_text(); + + let metadata_pos = text.find("[metadata]").expect("metadata section"); + let android_pos = text.find("[audio.android]").expect("android section"); + let logs_pos = text.find("[recent logs]").expect("logs section"); + assert!( + metadata_pos < android_pos, + "[metadata] must come before [audio.android]" + ); + assert!( + android_pos < logs_pos, + "[audio.android] must come before [recent logs]" + ); + + // The YAML fragment must follow the header byte-for-byte + // (no re-redaction, no reformatting — SDD-090 trust boundary). + let header = "[audio.android]\n"; + let header_start = text.find(header).expect("audio.android header"); + let after_header = &text[header_start + header.len()..]; + assert!( + after_header.starts_with(yaml), + "YAML fragment must follow [audio.android] header verbatim" + ); + } + + /// SWE4-UV-057 — `DiagnosticExport` with default `android_audio = + /// None` omits the `[audio.android]` header entirely (negative + /// test). Also verifies idempotence of explicit + /// `with_android_audio(None)`. + #[test] + fn android_audio_absent_omits_section() { + let secrets = KnownSecretRegistry::default(); + let redactor = Redactor::with_secrets(secrets); + let sink = InMemoryLogSink::new(16, redactor); + + // Default — never call with_android_audio. + let exported_default = + DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap(); + let text_default = exported_default.to_text(); + assert!( + !text_default.contains("[audio.android]"), + "default export must omit [audio.android] section" + ); + + // Explicit None — idempotent with default. + let exported_explicit = + DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]) + .unwrap() + .with_android_audio(None); + let text_explicit = exported_explicit.to_text(); + assert!( + !text_explicit.contains("[audio.android]"), + "explicit with_android_audio(None) must also omit the section" + ); + } }