test+diag: SWE.4 unit tests for Rust paths, Dart service tests, diagnostics audio.android section
Verification + diagnostics: - apps/chanora_flutter/test/services/back_intent_policy_test.dart: 8-case truth table for the BackIntentPolicy pure function (SWE4-UV-042). - apps/chanora_flutter/test/services/back_intent_service_test.dart: 5 channel-routing tests (SWE4-UV-042, SWE5-IV-019 unit slice). - apps/chanora_flutter/test/services/android_permissions_service_test.dart: 12 tests covering inbound channel events, outbound requests, state-machine transitions, and non-Android short-circuit (SWE4-UV-041). - SDD/SRS trace headers added to alpha_e2e_test.dart, beta_e2e_test.dart, widget_test.dart so the existing test ↔ ID mapping is discoverable by grep. - chanora_diagnostics: extends DiagnosticExport with android_audio: Option<String> for the SDD-116 evidence schema (requested / achieved performance mode + sharing mode + input preset + sample rate + frames per burst, per-effect engagement, latency tier). The bridge's export_diagnostics() now embeds the Android section when current_android_audio_diagnostics() returns Some. All 93 workspace Rust tests and 26 Dart test/services tests pass. Trace: SDD-090, SDD-112, SDD-113, SDD-116, SWE4-UV-041, SWE4-UV-042, SWE4-UV-045, SWE4-UV-047, SWE4-UV-048, SWE4-UV-049, SWE4-UV-051, SWE4-UV-052, SWE5-IV-019.
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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<MethodCall> 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<Object?> Function(MethodCall call)? outgoingResponder;
|
||||
|
||||
/// Helper: simulate Kotlin invoking `permissionStateChanged` on the
|
||||
/// Dart-installed handler. Mirrors what `AndroidPermissionRequester.kt`
|
||||
/// does at runtime.
|
||||
Future<void> sendPermissionStateChanged({
|
||||
required String permission,
|
||||
required String state,
|
||||
}) async {
|
||||
const codec = StandardMethodCodec();
|
||||
final encoded = codec.encodeMethodCall(
|
||||
MethodCall(
|
||||
methodPermissionStateChanged,
|
||||
<String, dynamic>{
|
||||
'permission': permission,
|
||||
'state': state,
|
||||
},
|
||||
),
|
||||
);
|
||||
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.handlePlatformMessage(channel.name, encoded, (_) {});
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
channel = const MethodChannel(androidPermissionsChannelName);
|
||||
outgoingCalls = <MethodCall>[];
|
||||
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<void>.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();
|
||||
});
|
||||
}
|
||||
@@ -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<IgnoreDueToPtt>());
|
||||
});
|
||||
|
||||
test('SWE4-UV-042: ptt=T, modal=F, atRoot=T → IgnoreDueToPtt', () {
|
||||
final d = decideBackIntent(
|
||||
pttActive: true,
|
||||
modalOpen: false,
|
||||
atRoot: true,
|
||||
);
|
||||
expect(d, isA<IgnoreDueToPtt>());
|
||||
});
|
||||
|
||||
test('SWE4-UV-042: ptt=T, modal=T, atRoot=F → IgnoreDueToPtt', () {
|
||||
final d = decideBackIntent(
|
||||
pttActive: true,
|
||||
modalOpen: true,
|
||||
atRoot: false,
|
||||
);
|
||||
expect(d, isA<IgnoreDueToPtt>());
|
||||
});
|
||||
|
||||
test('SWE4-UV-042: ptt=T, modal=T, atRoot=T → IgnoreDueToPtt', () {
|
||||
final d = decideBackIntent(
|
||||
pttActive: true,
|
||||
modalOpen: true,
|
||||
atRoot: true,
|
||||
);
|
||||
expect(d, isA<IgnoreDueToPtt>());
|
||||
});
|
||||
|
||||
// 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<CloseModal>());
|
||||
});
|
||||
|
||||
test('SWE4-UV-042: ptt=F, modal=T, atRoot=T → CloseModal', () {
|
||||
final d = decideBackIntent(
|
||||
pttActive: false,
|
||||
modalOpen: true,
|
||||
atRoot: true,
|
||||
);
|
||||
expect(d, isA<CloseModal>());
|
||||
});
|
||||
|
||||
// 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<PopRoute>());
|
||||
});
|
||||
|
||||
// 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<ExitApp>());
|
||||
});
|
||||
|
||||
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<BackIntentDecision>());
|
||||
expect(close, isA<BackIntentDecision>());
|
||||
expect(pop, isA<BackIntentDecision>());
|
||||
expect(exit, isA<BackIntentDecision>());
|
||||
|
||||
expect(ignore, isNot(isA<CloseModal>()));
|
||||
expect(close, isNot(isA<PopRoute>()));
|
||||
expect(pop, isNot(isA<ExitApp>()));
|
||||
expect(exit, isNot(isA<IgnoreDueToPtt>()));
|
||||
|
||||
expect(ignore.runtimeType, isNot(equals(close.runtimeType)));
|
||||
expect(pop.runtimeType, isNot(equals(exit.runtimeType)));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<MethodCall> 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 = <MethodCall>[];
|
||||
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<void> sendSystemBack() async {
|
||||
const codec = StandardMethodCodec();
|
||||
final encoded = codec.encodeMethodCall(
|
||||
const MethodCall(
|
||||
backIntentMethodFromPlatform,
|
||||
<String, dynamic>{
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<String>) -> 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user