243 lines
7.8 KiB
Dart
243 lines
7.8 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:uto_manager/domain/entities/authorized_rank.dart';
|
|
import 'package:uto_manager/domain/entities/doctrinal_branch.dart';
|
|
import 'package:uto_manager/domain/entities/echelon.dart';
|
|
import 'package:uto_manager/domain/entities/personnel_record.dart';
|
|
import 'package:uto_manager/domain/entities/position_slot.dart';
|
|
import 'package:uto_manager/domain/entities/unit_node.dart';
|
|
import 'package:uto_manager/domain/services/mtoe_validation_engine.dart';
|
|
import 'package:uto_manager/domain/services/validation_isolate.dart';
|
|
import 'package:uto_manager/domain/value_objects/duty_mos.dart';
|
|
|
|
void main() {
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
UnitNode _makeUnit(String id, Echelon echelon, {String? parentId}) => UnitNode(
|
|
unitId: id,
|
|
echelon: echelon,
|
|
branch: DoctrinalBranch.infantry,
|
|
parentUnitId: parentId,
|
|
name: 'Unit $id',
|
|
isKeyLeader: false,
|
|
);
|
|
|
|
PositionSlot _makeSlot(
|
|
String id,
|
|
String unitId, {
|
|
bool isKeyLeader = false,
|
|
String? assignedPersonnelId,
|
|
}) =>
|
|
PositionSlot(
|
|
slotId: id,
|
|
unitId: unitId,
|
|
positionTitle: isKeyLeader ? 'Squad Leader' : 'Rifleman',
|
|
dutyMos: DutyMos.unvalidated(isKeyLeader ? '11B' : '11B'),
|
|
authorizedRank: isKeyLeader ? AuthorizedRank.e6 : AuthorizedRank.e3,
|
|
isKeyLeader: isKeyLeader,
|
|
assignedPersonnelId: assignedPersonnelId,
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// IsolateValidationRunner
|
|
// ---------------------------------------------------------------------------
|
|
|
|
group('IsolateValidationRunner', () {
|
|
late IsolateValidationRunner runner;
|
|
|
|
setUp(() {
|
|
runner = IsolateValidationRunner();
|
|
});
|
|
|
|
tearDown(() async {
|
|
await runner.dispose();
|
|
});
|
|
|
|
test('isStarted is false before start()', () {
|
|
expect(runner.isStarted, false);
|
|
});
|
|
|
|
test('isStarted is true after start()', () async {
|
|
await runner.start();
|
|
expect(runner.isStarted, true);
|
|
});
|
|
|
|
test('start() is idempotent — calling twice does not throw', () async {
|
|
await runner.start();
|
|
await runner.start(); // second call should be a no-op
|
|
expect(runner.isStarted, true);
|
|
});
|
|
|
|
test('validate() throws StateError if start() not called', () async {
|
|
expect(
|
|
() => runner.validate(units: const [], slots: const [], personnel: const []),
|
|
throwsStateError,
|
|
);
|
|
});
|
|
|
|
test('validate() returns empty findings for empty tree', () async {
|
|
await runner.start();
|
|
final response = await runner.validate(
|
|
units: const [],
|
|
slots: const [],
|
|
personnel: const [],
|
|
);
|
|
expect(response.hasError, false);
|
|
expect(response.findings, isEmpty);
|
|
});
|
|
|
|
test('validate() detects vacant key leader (V003)', () async {
|
|
await runner.start();
|
|
final unit = _makeUnit('u1', Echelon.squad);
|
|
final slot = _makeSlot('sl1', 'u1', isKeyLeader: true); // vacant
|
|
|
|
final response = await runner.validate(
|
|
units: [unit],
|
|
slots: [slot],
|
|
personnel: const [],
|
|
);
|
|
|
|
expect(response.hasError, false);
|
|
expect(response.findings.any((f) => f.findingCode == 'V003'), true);
|
|
});
|
|
|
|
test('validate() detects missing medic in platoon (V004)', () async {
|
|
await runner.start();
|
|
final unit = _makeUnit('u1', Echelon.platoon);
|
|
final slot = _makeSlot('sl1', 'u1'); // no 68W slot
|
|
|
|
final response = await runner.validate(
|
|
units: [unit],
|
|
slots: [slot],
|
|
personnel: const [],
|
|
);
|
|
|
|
expect(response.hasError, false);
|
|
expect(response.findings.any((f) => f.findingCode == 'V004'), true);
|
|
});
|
|
|
|
test('validate() returns clean result for fully-staffed valid unit', () async {
|
|
await runner.start();
|
|
// A squad with a filled key leader slot and no hierarchy violations.
|
|
final unit = _makeUnit('u1', Echelon.squad);
|
|
final keyLeaderSlot = PositionSlot(
|
|
slotId: 'sl1',
|
|
unitId: 'u1',
|
|
positionTitle: 'Squad Leader',
|
|
dutyMos: DutyMos.unvalidated('11B'),
|
|
authorizedRank: AuthorizedRank.e6,
|
|
isKeyLeader: true,
|
|
assignedPersonnelId: 's1',
|
|
);
|
|
final soldier = PersonnelRecord(
|
|
soldierId: 's1',
|
|
name: 'SSG Smith',
|
|
primaryMos: DutyMos.unvalidated('11B'),
|
|
payGrade: AuthorizedRank.e6,
|
|
qualifications: const [],
|
|
readinessState: ReadinessState.fullyMissionCapable,
|
|
);
|
|
|
|
final response = await runner.validate(
|
|
units: [unit],
|
|
slots: [keyLeaderSlot],
|
|
personnel: [soldier],
|
|
);
|
|
|
|
expect(response.hasError, false);
|
|
// V003 should not fire (key leader is filled).
|
|
expect(response.findings.any((f) => f.findingCode == 'V003'), false);
|
|
});
|
|
|
|
test('validate() detects structural hierarchy violation (V006)', () async {
|
|
await runner.start();
|
|
// Squad directly under Brigade — invalid.
|
|
final brigade = _makeUnit('bde', Echelon.brigade);
|
|
final squad = _makeUnit('sq1', Echelon.squad, parentId: 'bde');
|
|
|
|
final response = await runner.validate(
|
|
units: [brigade, squad],
|
|
slots: const [],
|
|
personnel: const [],
|
|
);
|
|
|
|
expect(response.hasError, false);
|
|
expect(response.findings.any((f) => f.findingCode == 'V006'), true);
|
|
expect(
|
|
response.findings.firstWhere((f) => f.findingCode == 'V006').severity,
|
|
FindingSeverity.critical,
|
|
);
|
|
});
|
|
|
|
test('validate() results match synchronous engine output', () async {
|
|
await runner.start();
|
|
final units = [
|
|
_makeUnit('bde', Echelon.brigade),
|
|
_makeUnit('bn', Echelon.battalion, parentId: 'bde'),
|
|
_makeUnit('co', Echelon.company, parentId: 'bn'),
|
|
_makeUnit('plt', Echelon.platoon, parentId: 'co'),
|
|
];
|
|
final slots = [
|
|
_makeSlot('sl1', 'plt', isKeyLeader: true), // vacant key leader
|
|
];
|
|
|
|
final isolateResponse = await runner.validate(
|
|
units: units,
|
|
slots: slots,
|
|
personnel: const [],
|
|
);
|
|
|
|
// Compare with synchronous engine.
|
|
const syncEngine = MtoeValidationEngine();
|
|
final syncFindings = syncEngine.validate(
|
|
units: units,
|
|
slots: slots,
|
|
personnel: const [],
|
|
);
|
|
|
|
expect(isolateResponse.findings.length, syncFindings.length);
|
|
for (var i = 0; i < syncFindings.length; i++) {
|
|
expect(isolateResponse.findings[i].findingCode, syncFindings[i].findingCode);
|
|
expect(isolateResponse.findings[i].severity, syncFindings[i].severity);
|
|
}
|
|
});
|
|
|
|
test('dispose() sets isStarted to false', () async {
|
|
await runner.start();
|
|
await runner.dispose();
|
|
expect(runner.isStarted, false);
|
|
});
|
|
|
|
test('can restart after dispose()', () async {
|
|
await runner.start();
|
|
await runner.dispose();
|
|
await runner.start();
|
|
expect(runner.isStarted, true);
|
|
|
|
final response = await runner.validate(
|
|
units: const [],
|
|
slots: const [],
|
|
personnel: const [],
|
|
);
|
|
expect(response.hasError, false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ValidationResponse
|
|
// ---------------------------------------------------------------------------
|
|
|
|
group('ValidationResponse', () {
|
|
test('hasError is false when error is null', () {
|
|
const response = ValidationResponse(findings: []);
|
|
expect(response.hasError, false);
|
|
});
|
|
|
|
test('hasError is true when error is set', () {
|
|
const response = ValidationResponse(findings: [], error: 'Something went wrong');
|
|
expect(response.hasError, true);
|
|
});
|
|
});
|
|
}
|