500 lines
15 KiB
Dart
500 lines
15 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/value_objects/duty_mos.dart';
|
|
|
|
void main() {
|
|
const engine = MtoeValidationEngine();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
UnitNode makeUnit({
|
|
required String id,
|
|
required Echelon echelon,
|
|
DoctrinalBranch branch = DoctrinalBranch.infantry,
|
|
String? parentId,
|
|
String name = 'Test Unit',
|
|
}) =>
|
|
UnitNode(
|
|
unitId: id,
|
|
echelon: echelon,
|
|
branch: branch,
|
|
parentUnitId: parentId,
|
|
name: name,
|
|
isKeyLeader: false,
|
|
);
|
|
|
|
PositionSlot makeSlot({
|
|
required String slotId,
|
|
required String unitId,
|
|
String mos = '11B',
|
|
AuthorizedRank rank = AuthorizedRank.e5,
|
|
bool isKeyLeader = false,
|
|
String? assignedPersonnelId,
|
|
}) =>
|
|
PositionSlot(
|
|
slotId: slotId,
|
|
unitId: unitId,
|
|
positionTitle: 'Test Position',
|
|
dutyMos: DutyMos.unvalidated(mos),
|
|
authorizedRank: rank,
|
|
isKeyLeader: isKeyLeader,
|
|
assignedPersonnelId: assignedPersonnelId,
|
|
);
|
|
|
|
PersonnelRecord makeSoldier({
|
|
required String id,
|
|
String mos = '11B',
|
|
AuthorizedRank rank = AuthorizedRank.e5,
|
|
String? assignedSlotId,
|
|
ReadinessState readiness = ReadinessState.fullyMissionCapable,
|
|
}) =>
|
|
PersonnelRecord(
|
|
soldierId: id,
|
|
name: 'SGT Test',
|
|
primaryMos: DutyMos.unvalidated(mos),
|
|
payGrade: rank,
|
|
readinessState: readiness,
|
|
assignedSlotId: assignedSlotId,
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Empty input
|
|
// ---------------------------------------------------------------------------
|
|
|
|
group('empty input', () {
|
|
test('returns no findings for empty tree', () {
|
|
final findings = engine.validate(units: [], slots: [], personnel: []);
|
|
expect(findings, isEmpty);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// V001 — MOS Mismatch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
group('V001 — MOS mismatch', () {
|
|
test('flags slot where assigned soldier MOS does not match', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.squad);
|
|
final slot = makeSlot(
|
|
slotId: 's1',
|
|
unitId: 'u1',
|
|
mos: '68W',
|
|
assignedPersonnelId: 'p1',
|
|
);
|
|
final soldier = makeSoldier(id: 'p1', mos: '11B', assignedSlotId: 's1');
|
|
|
|
final findings = engine.validate(
|
|
units: [unit],
|
|
slots: [slot],
|
|
personnel: [soldier],
|
|
);
|
|
|
|
expect(findings.any((f) => f.findingCode == 'V001'), isTrue);
|
|
expect(
|
|
findings.firstWhere((f) => f.findingCode == 'V001').severity,
|
|
FindingSeverity.warning,
|
|
);
|
|
});
|
|
|
|
test('no V001 when MOS matches', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.squad);
|
|
final slot = makeSlot(
|
|
slotId: 's1',
|
|
unitId: 'u1',
|
|
mos: '11B',
|
|
assignedPersonnelId: 'p1',
|
|
);
|
|
final soldier = makeSoldier(id: 'p1', mos: '11B', assignedSlotId: 's1');
|
|
|
|
final findings = engine.validate(
|
|
units: [unit],
|
|
slots: [slot],
|
|
personnel: [soldier],
|
|
);
|
|
|
|
expect(findings.any((f) => f.findingCode == 'V001'), isFalse);
|
|
});
|
|
|
|
test('no V001 for vacant slots', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.squad);
|
|
final slot = makeSlot(slotId: 's1', unitId: 'u1', mos: '68W');
|
|
|
|
final findings = engine.validate(
|
|
units: [unit],
|
|
slots: [slot],
|
|
personnel: [],
|
|
);
|
|
|
|
expect(findings.any((f) => f.findingCode == 'V001'), isFalse);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// V002 — Rank Mismatch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
group('V002 — rank mismatch', () {
|
|
test('flags slot where soldier rank is below minimum', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.squad);
|
|
final slot = makeSlot(
|
|
slotId: 's1',
|
|
unitId: 'u1',
|
|
rank: AuthorizedRank.e6,
|
|
assignedPersonnelId: 'p1',
|
|
);
|
|
final soldier = makeSoldier(
|
|
id: 'p1',
|
|
rank: AuthorizedRank.e4,
|
|
assignedSlotId: 's1',
|
|
);
|
|
|
|
final findings = engine.validate(
|
|
units: [unit],
|
|
slots: [slot],
|
|
personnel: [soldier],
|
|
);
|
|
|
|
expect(findings.any((f) => f.findingCode == 'V002'), isTrue);
|
|
expect(
|
|
findings.firstWhere((f) => f.findingCode == 'V002').severity,
|
|
FindingSeverity.warning,
|
|
);
|
|
});
|
|
|
|
test('no V002 when rank meets minimum', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.squad);
|
|
final slot = makeSlot(
|
|
slotId: 's1',
|
|
unitId: 'u1',
|
|
rank: AuthorizedRank.e5,
|
|
assignedPersonnelId: 'p1',
|
|
);
|
|
final soldier = makeSoldier(
|
|
id: 'p1',
|
|
rank: AuthorizedRank.e6,
|
|
assignedSlotId: 's1',
|
|
);
|
|
|
|
final findings = engine.validate(
|
|
units: [unit],
|
|
slots: [slot],
|
|
personnel: [soldier],
|
|
);
|
|
|
|
expect(findings.any((f) => f.findingCode == 'V002'), isFalse);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// V003 — Vacant Key Leader
|
|
// ---------------------------------------------------------------------------
|
|
|
|
group('V003 — vacant key leader', () {
|
|
test('flags vacant key leader slot as critical', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.platoon);
|
|
final slot = makeSlot(
|
|
slotId: 's1',
|
|
unitId: 'u1',
|
|
isKeyLeader: true,
|
|
);
|
|
|
|
final findings = engine.validate(
|
|
units: [unit],
|
|
slots: [slot],
|
|
personnel: [],
|
|
);
|
|
|
|
expect(findings.any((f) => f.findingCode == 'V003'), isTrue);
|
|
expect(
|
|
findings.firstWhere((f) => f.findingCode == 'V003').severity,
|
|
FindingSeverity.critical,
|
|
);
|
|
});
|
|
|
|
test('no V003 when key leader slot is filled', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.platoon);
|
|
final slot = makeSlot(
|
|
slotId: 's1',
|
|
unitId: 'u1',
|
|
isKeyLeader: true,
|
|
assignedPersonnelId: 'p1',
|
|
);
|
|
final soldier = makeSoldier(id: 'p1', assignedSlotId: 's1');
|
|
|
|
final findings = engine.validate(
|
|
units: [unit],
|
|
slots: [slot],
|
|
personnel: [soldier],
|
|
);
|
|
|
|
expect(findings.any((f) => f.findingCode == 'V003'), isFalse);
|
|
});
|
|
|
|
test('no V003 for non-key-leader vacant slots', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.platoon);
|
|
final slot = makeSlot(slotId: 's1', unitId: 'u1', isKeyLeader: false);
|
|
|
|
final findings = engine.validate(
|
|
units: [unit],
|
|
slots: [slot],
|
|
personnel: [],
|
|
);
|
|
|
|
expect(findings.any((f) => f.findingCode == 'V003'), isFalse);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// V004 — Missing Combat Medic
|
|
// ---------------------------------------------------------------------------
|
|
|
|
group('V004 — missing combat medic', () {
|
|
test('flags platoon with no 68W slot as advisory', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.platoon);
|
|
final slot = makeSlot(slotId: 's1', unitId: 'u1', mos: '11B');
|
|
|
|
final findings = engine.validate(
|
|
units: [unit],
|
|
slots: [slot],
|
|
personnel: [],
|
|
);
|
|
|
|
expect(findings.any((f) => f.findingCode == 'V004'), isTrue);
|
|
expect(
|
|
findings.firstWhere((f) => f.findingCode == 'V004').severity,
|
|
FindingSeverity.advisory,
|
|
);
|
|
});
|
|
|
|
test('no V004 when platoon has 68W slot', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.platoon);
|
|
final slot = makeSlot(slotId: 's1', unitId: 'u1', mos: '68W');
|
|
|
|
final findings = engine.validate(
|
|
units: [unit],
|
|
slots: [slot],
|
|
personnel: [],
|
|
);
|
|
|
|
expect(findings.any((f) => f.findingCode == 'V004'), isFalse);
|
|
});
|
|
|
|
test('no V004 for non-platoon echelons', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.squad);
|
|
final slot = makeSlot(slotId: 's1', unitId: 'u1', mos: '11B');
|
|
|
|
final findings = engine.validate(
|
|
units: [unit],
|
|
slots: [slot],
|
|
personnel: [],
|
|
);
|
|
|
|
expect(findings.any((f) => f.findingCode == 'V004'), isFalse);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// V005 — Missing Fire Support Specialist
|
|
// ---------------------------------------------------------------------------
|
|
|
|
group('V005 — missing fire support specialist', () {
|
|
test('flags platoon with no 13F slot as advisory', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.platoon);
|
|
final slot68w = makeSlot(slotId: 's1', unitId: 'u1', mos: '68W');
|
|
|
|
final findings = engine.validate(
|
|
units: [unit],
|
|
slots: [slot68w],
|
|
personnel: [],
|
|
);
|
|
|
|
expect(findings.any((f) => f.findingCode == 'V005'), isTrue);
|
|
});
|
|
|
|
test('no V005 when platoon has 13F slot', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.platoon);
|
|
final slot68w = makeSlot(slotId: 's1', unitId: 'u1', mos: '68W');
|
|
final slot13f = makeSlot(slotId: 's2', unitId: 'u1', mos: '13F');
|
|
|
|
final findings = engine.validate(
|
|
units: [unit],
|
|
slots: [slot68w, slot13f],
|
|
personnel: [],
|
|
);
|
|
|
|
expect(findings.any((f) => f.findingCode == 'V005'), isFalse);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// V006 — Structural Hierarchy Violation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
group('V006 — structural hierarchy violation', () {
|
|
test('flags squad directly under brigade as critical', () {
|
|
final brigade = makeUnit(id: 'bde', echelon: Echelon.brigade);
|
|
final squad = makeUnit(
|
|
id: 'sq',
|
|
echelon: Echelon.squad,
|
|
parentId: 'bde',
|
|
);
|
|
|
|
final findings = engine.validate(
|
|
units: [brigade, squad],
|
|
slots: [],
|
|
personnel: [],
|
|
);
|
|
|
|
expect(findings.any((f) => f.findingCode == 'V006'), isTrue);
|
|
expect(
|
|
findings.firstWhere((f) => f.findingCode == 'V006').severity,
|
|
FindingSeverity.critical,
|
|
);
|
|
});
|
|
|
|
test('no V006 for valid battalion under brigade', () {
|
|
final brigade = makeUnit(id: 'bde', echelon: Echelon.brigade);
|
|
final battalion = makeUnit(
|
|
id: 'bn',
|
|
echelon: Echelon.battalion,
|
|
parentId: 'bde',
|
|
);
|
|
|
|
final findings = engine.validate(
|
|
units: [brigade, battalion],
|
|
slots: [],
|
|
personnel: [],
|
|
);
|
|
|
|
expect(findings.any((f) => f.findingCode == 'V006'), isFalse);
|
|
});
|
|
|
|
test('no V006 for root units (no parent)', () {
|
|
final brigade = makeUnit(id: 'bde', echelon: Echelon.brigade);
|
|
|
|
final findings = engine.validate(
|
|
units: [brigade],
|
|
slots: [],
|
|
personnel: [],
|
|
);
|
|
|
|
expect(findings.any((f) => f.findingCode == 'V006'), isFalse);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// V007 — Manning Below 70%
|
|
// ---------------------------------------------------------------------------
|
|
|
|
group('V007 — manning below 70%', () {
|
|
test('flags unit with fill rate below 70% as warning', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.squad);
|
|
// 1 filled out of 5 = 20%
|
|
final slots = [
|
|
makeSlot(slotId: 's1', unitId: 'u1', assignedPersonnelId: 'p1'),
|
|
makeSlot(slotId: 's2', unitId: 'u1'),
|
|
makeSlot(slotId: 's3', unitId: 'u1'),
|
|
makeSlot(slotId: 's4', unitId: 'u1'),
|
|
makeSlot(slotId: 's5', unitId: 'u1'),
|
|
];
|
|
|
|
final findings = engine.validate(
|
|
units: [unit],
|
|
slots: slots,
|
|
personnel: [],
|
|
);
|
|
|
|
expect(findings.any((f) => f.findingCode == 'V007'), isTrue);
|
|
expect(
|
|
findings.firstWhere((f) => f.findingCode == 'V007').severity,
|
|
FindingSeverity.warning,
|
|
);
|
|
});
|
|
|
|
test('no V007 when fill rate is exactly 70%', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.squad);
|
|
// 7 filled out of 10 = 70%
|
|
final slots = List.generate(
|
|
10,
|
|
(i) => makeSlot(
|
|
slotId: 's$i',
|
|
unitId: 'u1',
|
|
assignedPersonnelId: i < 7 ? 'p$i' : null,
|
|
),
|
|
);
|
|
|
|
final findings = engine.validate(
|
|
units: [unit],
|
|
slots: slots,
|
|
personnel: [],
|
|
);
|
|
|
|
expect(findings.any((f) => f.findingCode == 'V007'), isFalse);
|
|
});
|
|
|
|
test('no V007 for units with no slots', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.squad);
|
|
|
|
final findings = engine.validate(
|
|
units: [unit],
|
|
slots: [],
|
|
personnel: [],
|
|
);
|
|
|
|
expect(findings.any((f) => f.findingCode == 'V007'), isFalse);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Multiple findings
|
|
// ---------------------------------------------------------------------------
|
|
|
|
group('multiple findings', () {
|
|
test('returns multiple findings for a poorly configured unit', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.platoon);
|
|
// Vacant key leader + no 68W + no 13F + low manning
|
|
final slot = makeSlot(
|
|
slotId: 's1',
|
|
unitId: 'u1',
|
|
isKeyLeader: true,
|
|
);
|
|
|
|
final findings = engine.validate(
|
|
units: [unit],
|
|
slots: [slot],
|
|
personnel: [],
|
|
);
|
|
|
|
// V003 (vacant key leader), V004 (no medic), V005 (no FSO), V007 (manning)
|
|
final codes = findings.map((f) => f.findingCode).toSet();
|
|
expect(codes, containsAll(['V003', 'V004', 'V005', 'V007']));
|
|
});
|
|
|
|
test('finding unitId is set correctly', () {
|
|
final unit = makeUnit(id: 'my-unit', echelon: Echelon.platoon);
|
|
final slot = makeSlot(
|
|
slotId: 's1',
|
|
unitId: 'my-unit',
|
|
isKeyLeader: true,
|
|
);
|
|
|
|
final findings = engine.validate(
|
|
units: [unit],
|
|
slots: [slot],
|
|
personnel: [],
|
|
);
|
|
|
|
final v003 = findings.firstWhere((f) => f.findingCode == 'V003');
|
|
expect(v003.unitId, 'my-unit');
|
|
});
|
|
});
|
|
}
|