feat(ground-suite): add tactical interaction state
This commit is contained in:
1 parent
cc9e9919db
commit
00a3a9f908
2 files changed
+925
No files matched your search
@@ -0,0 +1,309 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ground_suite/features/command/presentation/tactical_command_page.dart';
|
||||
import 'package:ground_suite/features/command/presentation/tactical_controller.dart';
|
||||
import 'package:ground_suite/features/fleet/domain/fleet_unit.dart';
|
||||
import 'package:ground_suite/features/vehicle_workspace/domain/vehicle_catalog.dart';
|
||||
|
||||
void main() {
|
||||
ProviderContainer container() {
|
||||
final value = ProviderContainer();
|
||||
addTearDown(value.dispose);
|
||||
return value;
|
||||
}
|
||||
|
||||
test('selection and cursor modes have one deterministic owner', () {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
|
||||
controller.selectUnit('DEMO-1022');
|
||||
expect(scope.read(tacticalControllerProvider).selectedUnitIds, {
|
||||
'DEMO-1022',
|
||||
});
|
||||
|
||||
controller.selectUnit('DEMO-1023', toggle: true);
|
||||
expect(scope.read(tacticalControllerProvider).selectedUnitIds, {
|
||||
'DEMO-1022',
|
||||
'DEMO-1023',
|
||||
});
|
||||
|
||||
controller.beginPencil();
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).mode,
|
||||
isA<TacticalPencilMode>(),
|
||||
);
|
||||
expect(scope.read(tacticalControllerProvider).selectedUnitIds, isEmpty);
|
||||
|
||||
controller.showUnitHangar();
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).panel,
|
||||
TacticalPanel.unitHangar,
|
||||
);
|
||||
expect(scope.read(tacticalControllerProvider).unitDraft, isNull);
|
||||
|
||||
final definition = supportedVehicleDefinitions.first;
|
||||
controller.chooseUnitDefinition(definition, displayName: 'QUADCOPTER-01');
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).unitDraft?.definition,
|
||||
definition,
|
||||
);
|
||||
controller.beginUnitSetup();
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).panel,
|
||||
TacticalPanel.unitSetup,
|
||||
);
|
||||
controller.completeUnitPlacement('local-unit-1');
|
||||
expect(scope.read(tacticalControllerProvider).selectedUnitIds, {
|
||||
'local-unit-1',
|
||||
});
|
||||
expect(scope.read(tacticalControllerProvider).unitDraft, isNull);
|
||||
controller.showUnitSetupForSelectedLocalUnit('local-unit-1');
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).panel,
|
||||
TacticalPanel.unitSetup,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'hangar drafts preserve the selected type and do not mutate map state',
|
||||
() {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
final before = scope.read(tacticalControllerProvider);
|
||||
|
||||
controller.showUnitHangar();
|
||||
controller.chooseUnitDefinition(
|
||||
supportedVehicleDefinitions[1],
|
||||
displayName: 'Fixed wing scout',
|
||||
);
|
||||
controller.beginUnitSetup();
|
||||
controller.updateUnitDraftName('Fixed wing north');
|
||||
|
||||
final editing = scope.read(tacticalControllerProvider);
|
||||
expect(
|
||||
identical(editing.selectedUnitIds, before.selectedUnitIds),
|
||||
isTrue,
|
||||
);
|
||||
expect(identical(editing.drawings, before.drawings), isTrue);
|
||||
expect(identical(editing.markers, before.markers), isTrue);
|
||||
expect(editing.panel, TacticalPanel.unitSetup);
|
||||
|
||||
controller.returnToUnitHangar();
|
||||
final returned = scope.read(tacticalControllerProvider);
|
||||
expect(returned.panel, TacticalPanel.unitHangar);
|
||||
expect(returned.unitDraft?.definition, supportedVehicleDefinitions[1]);
|
||||
expect(returned.unitDraft?.displayName, 'Fixed wing north');
|
||||
|
||||
controller.showUnitSetupForSelectedLocalUnit('local-unit-1');
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).panel,
|
||||
TacticalPanel.unitHangar,
|
||||
reason:
|
||||
'A catalog draft cannot produce an unrelated blank setup panel.',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'editing a hangar draft does not notify the tactical render projection',
|
||||
() async {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
scope.read(tacticalSceneRenderStateProvider);
|
||||
var renderNotifications = 0;
|
||||
final subscription = scope.listen<TacticalSceneRenderState>(
|
||||
tacticalSceneRenderStateProvider,
|
||||
(previous, next) => renderNotifications += 1,
|
||||
fireImmediately: true,
|
||||
);
|
||||
addTearDown(subscription.close);
|
||||
renderNotifications = 0;
|
||||
|
||||
controller.showUnitHangar();
|
||||
await scope.pump();
|
||||
controller.chooseUnitDefinition(
|
||||
supportedVehicleDefinitions.first,
|
||||
displayName: 'Quad local',
|
||||
);
|
||||
await scope.pump();
|
||||
controller.updateUnitDraftName('Quad north');
|
||||
await scope.pump();
|
||||
expect(
|
||||
renderNotifications,
|
||||
1,
|
||||
reason: 'Opening the hangar changes its panel visibility.',
|
||||
);
|
||||
|
||||
controller.updateUnitDraftName('Quad north one');
|
||||
await scope.pump();
|
||||
expect(
|
||||
renderNotifications,
|
||||
1,
|
||||
reason: 'Typing only updates the isolated local setup draft.',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'direct pencil strokes are session state and require complete geometry',
|
||||
() {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
|
||||
controller.beginPencil();
|
||||
expect(
|
||||
controller.commitPencilStroke(const [LocalScenePoint(x: .1, y: .2)]),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
controller.commitPencilStroke(const [
|
||||
LocalScenePoint(x: .1, y: .2),
|
||||
LocalScenePoint(x: .3, y: .4),
|
||||
]),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
final state = scope.read(tacticalControllerProvider);
|
||||
expect(state.mode, isA<TacticalPencilMode>());
|
||||
expect(state.drawings, hasLength(1));
|
||||
expect(state.drawings.single.thickness, 3);
|
||||
|
||||
controller.clearSceneObjects();
|
||||
expect(scope.read(tacticalControllerProvider).drawings, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('view toggles remain separate from selection and pencil tools', () {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
|
||||
controller.beginPencil();
|
||||
controller.toggleOverlay(TacticalMapOverlay.drones, false);
|
||||
|
||||
final state = scope.read(tacticalControllerProvider);
|
||||
expect(state.mode, isA<TacticalPencilMode>());
|
||||
expect(state.overlays, isNot(contains(TacticalMapOverlay.drones)));
|
||||
});
|
||||
|
||||
test('fleet panel is separate from a local map mode', () {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
|
||||
controller.showFleetRoster();
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).panel,
|
||||
TacticalPanel.fleetRoster,
|
||||
);
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).mode,
|
||||
isA<TacticalBrowseMode>(),
|
||||
);
|
||||
|
||||
controller.beginPencil();
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).mode,
|
||||
isA<TacticalPencilMode>(),
|
||||
);
|
||||
controller.closePanel();
|
||||
expect(scope.read(tacticalControllerProvider).panel, isNull);
|
||||
});
|
||||
|
||||
test('drag selection is local only', () {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
controller.selectUnits(['DEMO-1021', 'DEMO-1023']);
|
||||
final state = scope.read(tacticalControllerProvider);
|
||||
expect(state.selectedUnitIds, {'DEMO-1021', 'DEMO-1023'});
|
||||
expect(state.mode, isA<TacticalBrowseMode>());
|
||||
});
|
||||
|
||||
test('direct strokes are bounded before local scene commit', () {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
controller.beginPencil();
|
||||
|
||||
final stroke = [
|
||||
for (var index = 0; index < 256; index++)
|
||||
LocalScenePoint(x: index / 255, y: index.isEven ? .4 : .6),
|
||||
];
|
||||
expect(controller.commitPencilStroke(stroke), isTrue);
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).drawings.single.points.length,
|
||||
lessThanOrEqualTo(256),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'pencil style is retained for the current cursor and committed stroke',
|
||||
() {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
controller.beginPencil();
|
||||
controller.configurePencil(
|
||||
hue: 282,
|
||||
saturation: .22,
|
||||
brightness: .38,
|
||||
thickness: 8,
|
||||
);
|
||||
expect(
|
||||
controller.commitPencilStroke(const [
|
||||
LocalScenePoint(x: .2, y: .2),
|
||||
LocalScenePoint(x: .4, y: .4),
|
||||
]),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
final state = scope.read(tacticalControllerProvider);
|
||||
expect(state.mode, isA<TacticalPencilMode>());
|
||||
final mode = state.mode as TacticalPencilMode;
|
||||
expect(mode.hue, 282);
|
||||
expect(mode.saturation, .22);
|
||||
expect(mode.brightness, .38);
|
||||
expect(mode.thickness, 8);
|
||||
expect(state.drawings.single.hue, 282);
|
||||
expect(state.drawings.single.saturation, .22);
|
||||
expect(state.drawings.single.brightness, .38);
|
||||
expect(state.drawings.single.thickness, 8);
|
||||
},
|
||||
);
|
||||
|
||||
test('marker names and colors are independent from pencil strokes', () {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
|
||||
controller.beginPencil();
|
||||
controller.configurePencil(hue: 282, thickness: 8);
|
||||
controller.beginMarker();
|
||||
controller.configureMarker(hue: 26, saturation: .41, brightness: .56);
|
||||
controller.chooseMarkerPoint(const LocalScenePoint(x: .4, y: .6));
|
||||
|
||||
expect(controller.commitMarkerName('Landing review'), isTrue);
|
||||
final state = scope.read(tacticalControllerProvider);
|
||||
expect(state.markers, hasLength(1));
|
||||
expect(state.markers.single.name, 'Landing review');
|
||||
expect(state.markers.single.hue, 26);
|
||||
expect(state.markers.single.saturation, .41);
|
||||
expect(state.markers.single.brightness, .56);
|
||||
expect((state.mode as TacticalMarkerMode).hue, 26);
|
||||
expect(state.drawings, isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'blank marker labels and pending marker cancellation create no marker',
|
||||
() {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
controller.beginMarker();
|
||||
controller.chooseMarkerPoint(const LocalScenePoint(x: .2, y: .2));
|
||||
|
||||
expect(controller.commitMarkerName(' '), isFalse);
|
||||
expect(controller.cancelActiveTool(), isFalse);
|
||||
expect(scope.read(tacticalControllerProvider).markers, isEmpty);
|
||||
expect(controller.cancelActiveTool(), isTrue);
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).mode,
|
||||
isA<TacticalBrowseMode>(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user