Files
PilotVault/Fly App/test/fly/product_profile_test.dart
T
tajniak81andClaude Opus 5.5 606838a0b1 Teach the Fly App what the connected aircraft can do
The GO 4-parity HUD has to work on every aircraft MSDK V4 drives, so no
screen may assume one model. This adds the capability layer the rest of
the plan builds on (P0).

ProductProfileBridge reads what the SDK can report at runtime (cameras
and their ISO/shutter/aperture/EV ranges, gimbal axes, air link, FC and
RC feature flags, batteries) and emits it as a productProfile event on
connect and component change. Capability ranges stay empty until the
camera handshakes, so it re-probes at 3, 10 and 30 s and emits only
when something changed.

On the Dart side, ProductProfile merges that with a static
product_matrix keyed by the MSDK Model enum name, for facts the SDK
cannot report: vision sensor layout, battery bay count, intelligent
modes. SDK answers win and every fact records its source. HudFeatures
lists each capability-gated element with its predicate, and Requires
hides a widget the profile rules out. The flight page now gates its
gimbal slider, camera column and vision pill, labels the link Wi-Fi or
HD, and takes the gimbal pitch range from the profile.

Settings > About > Capability profile shows the active profile, fact
sources and which features are on. Matrix rows are seeded from published
specs until runtime capture confirms them; nothing has run on a real
aircraft yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:56:08 +02:00

189 lines
7.6 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pilotvault_fly/fly/hud_features.dart';
import 'package:pilotvault_fly/fly/product_matrix.dart';
import 'package:pilotvault_fly/fly/product_profile.dart';
import 'package:pilotvault_fly/ui/fly/debug/product_profile_page.dart';
import 'profile_fixtures.dart';
/// The feature ids a profile switches on.
Set<String> enabled(ProductProfile p) =>
<String>{for (final HudFeature f in HudFeatures.all) if (f.enabledFor(p)) f.id};
/// A stand-in HUD: one gated marker per feature, the way P2's widgets will be.
Widget hud(ProductProfile p) => MaterialApp(
home: ProductProfileScope(
profile: p,
child: Wrap(children: <Widget>[
for (final HudFeature f in HudFeatures.all) Requires(f, child: Text(f.id)),
]),
),
);
void main() {
group('ProductProfile.fromBridge', () {
test('disconnected map yields the disconnected profile', () {
final ProductProfile p = ProductProfile.fromBridge(<String, dynamic>{'connected': false});
expect(p.connected, isFalse);
expect(enabled(p), isEmpty);
});
test('Spark over Wi-Fi', () {
final ProductProfile p = ProductProfile.fromBridge(sparkWifi);
expect(p.link, LinkType.wifi);
expect(p.sources['link'], FactSource.sdk);
expect(p.vision, VisionSensors.front);
expect(p.sources['vision'], FactSource.matrix);
expect(p.batteryCount, 1);
expect(p.virtualStickPrimary, isTrue);
expect(p.supportsMode(IntelligentMode.waypoint), isFalse);
expect(p.supportsMode(IntelligentMode.quickShots), isTrue);
});
test('SDK link wins over the matrix', () {
// A Mavic Pro is OcuSync in the matrix but can be flown over Wi-Fi.
final ProductProfile p = ProductProfile.fromBridge(<String, dynamic>{
...phantom4,
'model': 'MAVIC_PRO',
'link': 'wifi',
});
expect(p.link, LinkType.wifi);
expect(p.sources['link'], FactSource.sdk);
});
test('matrix fills the link before the air link enumerates', () {
final ProductProfile p = ProductProfile.fromBridge(<String, dynamic>{...phantom4, 'link': null});
expect(p.link, LinkType.lightbridge);
expect(p.sources['link'], FactSource.matrix);
});
test('battery count falls back to the matrix bay count', () {
final ProductProfile p = ProductProfile.fromBridge(<String, dynamic>{...inspire2X5s, 'batteries': <dynamic>[]});
expect(p.batteryCount, 2);
expect(p.sources['batteryCount'], FactSource.matrix);
});
test('no flight assistant caps the vision layout at VPS', () {
final ProductProfile p = ProductProfile.fromBridge(<String, dynamic>{
...phantom4,
'flightController': <String, dynamic>{'flightAssistantSupported': false},
});
expect(p.vision, VisionSensors.downward);
expect(p.hasObstacleSensing, isFalse);
expect(p.hasVps, isTrue);
});
test('unlisted model keeps SDK facts and marks the rest unknown', () {
final ProductProfile p = ProductProfile.fromBridge(<String, dynamic>{
...phantom4,
'model': 'UNKNOWN_AIRCRAFT',
'link': null,
});
expect(p.matrix, isNull);
expect(p.link, LinkType.unknown);
expect(p.vision, VisionSensors.unknown);
expect(p.hasCamera, isTrue);
expect(p.modes, isEmpty);
});
test('gimbal without capabilities yet still pitches', () {
final ProductProfile p = ProductProfile.fromBridge(<String, dynamic>{
...phantom4,
'gimbals': <dynamic>[
<String, dynamic>{'index': 0, 'capabilitiesKnown': false},
],
});
expect(HudFeatures.gimbalPitch.enabledFor(p), isTrue);
expect(HudFeatures.gimbalYaw.enabledFor(p), isFalse);
});
});
group('ProductMatrix', () {
test('every row is keyed by a distinct MSDK model name', () {
final List<String> keys = ProductMatrix.entries.map((ProductMatrixEntry e) => e.model).toList();
expect(keys.toSet().length, keys.length);
});
test('covers the GO 4 families from the plan', () {
for (final String m in <String>[
'PHANTOM_3_PROFESSIONAL', 'PHANTOM_4', 'PHANTOM_4_PRO', 'PHANTOM_4_RTK', //
'MAVIC_PRO', 'MAVIC_AIR', 'MAVIC_2_PRO', 'MAVIC_2_ZOOM', 'MAVIC_2_ENTERPRISE',
'Spark', 'MAVIC_MINI', 'INSPIRE_1', 'INSPIRE_2', 'MATRICE_100', 'MATRICE_200', 'MATRICE_600',
]) {
expect(ProductMatrix.lookup(m), isNotNull, reason: m);
}
});
});
group('HUD features per profile', () {
test('Spark-like Wi-Fi', () {
final Set<String> on = enabled(ProductProfile.fromBridge(sparkWifi));
expect(on, containsAll(<String>['status.wifiSignal', 'battery.single', 'overlay.radar', 'control.virtualSticks']));
expect(on, isNot(contains('status.rcHdSignal')));
expect(on, isNot(contains('camera.aperture')));
expect(on, isNot(contains('battery.dual')));
expect(on, isNot(contains('mode.waypoint')));
});
test('Phantom 4', () {
final Set<String> on = enabled(ProductProfile.fromBridge(phantom4));
expect(on, containsAll(<String>['status.rcHdSignal', 'battery.single', 'gimbal.pitchExtension', 'mode.waypoint', 'mode.tapFly']));
expect(on, isNot(contains('status.wifiSignal')));
expect(on, isNot(contains('camera.ssd')));
expect(on, isNot(contains('gimbal.yaw')));
});
test('Inspire 2 + X5S', () {
final Set<String> on = enabled(ProductProfile.fromBridge(inspire2X5s));
expect(on, containsAll(<String>[
'battery.dual', 'camera.ssd', 'camera.aperture', 'gimbal.yaw', 'status.masterSlave', 'status.rcHdSignal',
]));
expect(on, isNot(contains('battery.single')));
expect(on, isNot(contains('battery.multi')));
expect(on, isNot(contains('camera.zoom')));
});
});
group('Requires', () {
testWidgets('renders only enabled features for each synthetic profile', (WidgetTester tester) async {
for (final Map<String, dynamic> raw in <Map<String, dynamic>>[sparkWifi, phantom4, inspire2X5s]) {
final ProductProfile p = ProductProfile.fromBridge(raw);
await tester.pumpWidget(hud(p));
for (final HudFeature f in HudFeatures.all) {
expect(find.text(f.id), f.enabledFor(p) ? findsOneWidget : findsNothing, reason: '${p.model}: ${f.id}');
}
}
});
testWidgets('gate is open with no product, unless told otherwise', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: Column(children: <Widget>[
Requires(HudFeatures.dualBattery, child: const Text('preview')),
Requires(HudFeatures.wifiSignal, whenDisconnected: false, fallback: const Text('HD'), child: const Text('Wi-Fi')),
]),
));
expect(find.text('preview'), findsOneWidget);
expect(find.text('HD'), findsOneWidget);
expect(find.text('Wi-Fi'), findsNothing);
});
});
group('ProductProfileView', () {
testWidgets('shows the profile, fact sources and feature states', (WidgetTester tester) async {
tester.view.physicalSize = const Size(1200, 4000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
for (final Map<String, dynamic> raw in <Map<String, dynamic>>[sparkWifi, phantom4, inspire2X5s]) {
final ProductProfile p = ProductProfile.fromBridge(raw);
await tester.pumpWidget(MaterialApp(home: Scaffold(body: ProductProfileView(profile: p))));
expect(find.textContaining(p.displayName!), findsWidgets);
expect(find.text('HUD FEATURES'), findsOneWidget);
expect(find.text('sdk'), findsWidgets);
expect(tester.takeException(), isNull);
}
});
});
}