338 lines
13 KiB
Dart
338 lines
13 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/unit_tree_service.dart';
|
|
import 'package:uto_manager/domain/value_objects/duty_mos.dart';
|
|
|
|
void main() {
|
|
const service = UnitTreeService();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
UnitNode makeUnit({
|
|
required String id,
|
|
required Echelon echelon,
|
|
String? parentId,
|
|
String name = 'Test Unit',
|
|
}) {
|
|
return UnitNode(
|
|
unitId: id,
|
|
echelon: echelon,
|
|
branch: DoctrinalBranch.infantry,
|
|
name: name,
|
|
isKeyLeader: false,
|
|
parentUnitId: parentId,
|
|
);
|
|
}
|
|
|
|
PositionSlot makeSlot({
|
|
required String slotId,
|
|
required String unitId,
|
|
String? assignedPersonnelId,
|
|
bool isKeyLeader = false,
|
|
}) {
|
|
return PositionSlot(
|
|
slotId: slotId,
|
|
unitId: unitId,
|
|
positionTitle: 'Test Position',
|
|
dutyMos: DutyMos('11B'),
|
|
authorizedRank: AuthorizedRank.e5,
|
|
isKeyLeader: isKeyLeader,
|
|
assignedPersonnelId: assignedPersonnelId,
|
|
);
|
|
}
|
|
|
|
PersonnelRecord makeSoldier({
|
|
required String id,
|
|
String? assignedSlotId,
|
|
ReadinessState readiness = ReadinessState.fullyMissionCapable,
|
|
}) {
|
|
return PersonnelRecord(
|
|
soldierId: id,
|
|
name: 'SGT Test',
|
|
primaryMos: DutyMos('11B'),
|
|
payGrade: AuthorizedRank.e5,
|
|
readinessState: readiness,
|
|
assignedSlotId: assignedSlotId,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// buildTree
|
|
// ---------------------------------------------------------------------------
|
|
|
|
group('buildTree', () {
|
|
test('returns empty list for empty input', () {
|
|
final roots = service.buildTree([], []);
|
|
expect(roots, isEmpty);
|
|
});
|
|
|
|
test('returns single root node with no children', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.brigade);
|
|
final roots = service.buildTree([unit], []);
|
|
expect(roots.length, 1);
|
|
expect(roots.first.unit.unitId, 'u1');
|
|
expect(roots.first.children, isEmpty);
|
|
});
|
|
|
|
test('builds parent-child relationship correctly', () {
|
|
final brigade = makeUnit(id: 'bde', echelon: Echelon.brigade);
|
|
final battalion = makeUnit(id: 'bn', echelon: Echelon.battalion, parentId: 'bde');
|
|
final roots = service.buildTree([brigade, battalion], []);
|
|
expect(roots.length, 1);
|
|
expect(roots.first.children.length, 1);
|
|
expect(roots.first.children.first.unit.unitId, 'bn');
|
|
});
|
|
|
|
test('attaches slots to correct unit nodes', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.platoon);
|
|
final slot1 = makeSlot(slotId: 's1', unitId: 'u1');
|
|
final slot2 = makeSlot(slotId: 's2', unitId: 'u1');
|
|
final roots = service.buildTree([unit], [slot1, slot2]);
|
|
expect(roots.first.slots.length, 2);
|
|
});
|
|
|
|
test('multiple roots are returned correctly', () {
|
|
final bde1 = makeUnit(id: 'bde1', echelon: Echelon.brigade, name: 'Brigade 1');
|
|
final bde2 = makeUnit(id: 'bde2', echelon: Echelon.brigade, name: 'Brigade 2');
|
|
final roots = service.buildTree([bde1, bde2], []);
|
|
expect(roots.length, 2);
|
|
});
|
|
|
|
test('fill rate is 0 when no slots', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.platoon);
|
|
final roots = service.buildTree([unit], []);
|
|
expect(roots.first.fillRate, 0.0);
|
|
});
|
|
|
|
test('fill rate is 100 when all slots filled', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.platoon);
|
|
final slot = makeSlot(slotId: 's1', unitId: 'u1', assignedPersonnelId: 'p1');
|
|
final roots = service.buildTree([unit], [slot]);
|
|
expect(roots.first.fillRate, 100.0);
|
|
});
|
|
|
|
test('fill rate is 50 when half slots filled', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.platoon);
|
|
final slot1 = makeSlot(slotId: 's1', unitId: 'u1', assignedPersonnelId: 'p1');
|
|
final slot2 = makeSlot(slotId: 's2', unitId: 'u1');
|
|
final roots = service.buildTree([unit], [slot1, slot2]);
|
|
expect(roots.first.fillRate, 50.0);
|
|
});
|
|
|
|
test('totalSlots includes children slots', () {
|
|
final platoon = makeUnit(id: 'plt', echelon: Echelon.platoon);
|
|
final squad = makeUnit(id: 'sq', echelon: Echelon.squad, parentId: 'plt');
|
|
final pltSlot = makeSlot(slotId: 'ps1', unitId: 'plt');
|
|
final sqSlot = makeSlot(slotId: 'ss1', unitId: 'sq');
|
|
final roots = service.buildTree([platoon, squad], [pltSlot, sqSlot]);
|
|
expect(roots.first.totalSlots, 2);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getSubtree
|
|
// ---------------------------------------------------------------------------
|
|
|
|
group('getSubtree', () {
|
|
test('returns only the root when no children', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.brigade);
|
|
final result = service.getSubtree('u1', [unit]);
|
|
expect(result.length, 1);
|
|
expect(result.first.unitId, 'u1');
|
|
});
|
|
|
|
test('returns root and all descendants', () {
|
|
final bde = makeUnit(id: 'bde', echelon: Echelon.brigade);
|
|
final bn = makeUnit(id: 'bn', echelon: Echelon.battalion, parentId: 'bde');
|
|
final co = makeUnit(id: 'co', echelon: Echelon.company, parentId: 'bn');
|
|
final result = service.getSubtree('bde', [bde, bn, co]);
|
|
expect(result.length, 3);
|
|
expect(result.map((u) => u.unitId), containsAll(['bde', 'bn', 'co']));
|
|
});
|
|
|
|
test('throws StateError for unknown unitId', () {
|
|
expect(() => service.getSubtree('unknown', []), throwsStateError);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getAncestors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
group('getAncestors', () {
|
|
test('returns empty list for root unit', () {
|
|
final bde = makeUnit(id: 'bde', echelon: Echelon.brigade);
|
|
final result = service.getAncestors('bde', [bde]);
|
|
expect(result, isEmpty);
|
|
});
|
|
|
|
test('returns correct ancestor chain', () {
|
|
final bde = makeUnit(id: 'bde', echelon: Echelon.brigade);
|
|
final bn = makeUnit(id: 'bn', echelon: Echelon.battalion, parentId: 'bde');
|
|
final co = makeUnit(id: 'co', echelon: Echelon.company, parentId: 'bn');
|
|
final result = service.getAncestors('co', [bde, bn, co]);
|
|
expect(result.length, 2);
|
|
expect(result[0].unitId, 'bn');
|
|
expect(result[1].unitId, 'bde');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// validateReparent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
group('validateReparent', () {
|
|
test('valid reparent returns valid result', () {
|
|
final bde = makeUnit(id: 'bde', echelon: Echelon.brigade);
|
|
final bn1 = makeUnit(id: 'bn1', echelon: Echelon.battalion, parentId: 'bde');
|
|
final bn2 = makeUnit(id: 'bn2', echelon: Echelon.battalion, parentId: 'bde');
|
|
final co = makeUnit(id: 'co', echelon: Echelon.company, parentId: 'bn1');
|
|
final result = service.validateReparent(co, bn2, [bde, bn1, bn2, co]);
|
|
expect(result.isValid, isTrue);
|
|
});
|
|
|
|
test('cannot parent unit to itself', () {
|
|
final unit = makeUnit(id: 'u1', echelon: Echelon.company);
|
|
final result = service.validateReparent(unit, unit, [unit]);
|
|
expect(result.isValid, isFalse);
|
|
expect(result.reason, contains('cannot be its own parent'));
|
|
});
|
|
|
|
test('invalid echelon hierarchy returns invalid result', () {
|
|
final bde = makeUnit(id: 'bde', echelon: Echelon.brigade);
|
|
final squad = makeUnit(id: 'sq', echelon: Echelon.squad);
|
|
final result = service.validateReparent(squad, bde, [bde, squad]);
|
|
expect(result.isValid, isFalse);
|
|
expect(result.reason, contains('cannot be a child of'));
|
|
});
|
|
|
|
test('circular reference is rejected', () {
|
|
final bde = makeUnit(id: 'bde', echelon: Echelon.brigade);
|
|
final bn = makeUnit(id: 'bn', echelon: Echelon.battalion, parentId: 'bde');
|
|
final co = makeUnit(id: 'co', echelon: Echelon.company, parentId: 'bn');
|
|
// Try to make bde (brigade) a child of co (company) — echelon is valid
|
|
// direction-wise only if we swap, but the real issue is circular ancestry.
|
|
// Use a case where echelon is valid but would create a cycle:
|
|
// Move bn to be a child of co (bn is ancestor of co → circular).
|
|
final result = service.validateReparent(bn, co, [bde, bn, co]);
|
|
expect(result.isValid, isFalse);
|
|
expect(result.reason, isNotEmpty);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// validateAssignment
|
|
// ---------------------------------------------------------------------------
|
|
|
|
group('validateAssignment', () {
|
|
test('valid assignment returns valid result', () {
|
|
final slot = makeSlot(slotId: 's1', unitId: 'u1');
|
|
final soldier = makeSoldier(id: 'p1');
|
|
final result = service.validateAssignment(slot, soldier);
|
|
expect(result.isValid, isTrue);
|
|
});
|
|
|
|
test('filled slot returns invalid result', () {
|
|
final slot = makeSlot(slotId: 's1', unitId: 'u1', assignedPersonnelId: 'existing');
|
|
final soldier = makeSoldier(id: 'p1');
|
|
final result = service.validateAssignment(slot, soldier);
|
|
expect(result.isValid, isFalse);
|
|
expect(result.reason, contains('already filled'));
|
|
});
|
|
|
|
test('already assigned soldier returns invalid result', () {
|
|
final slot = makeSlot(slotId: 's1', unitId: 'u1');
|
|
final soldier = makeSoldier(id: 'p1', assignedSlotId: 'other-slot');
|
|
final result = service.validateAssignment(slot, soldier);
|
|
expect(result.isValid, isFalse);
|
|
expect(result.reason, contains('already assigned'));
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// calculateFillRate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
group('calculateFillRate', () {
|
|
test('returns 0.0 for empty slot list', () {
|
|
expect(service.calculateFillRate([]), 0.0);
|
|
});
|
|
|
|
test('returns 1.0 when all slots filled', () {
|
|
final slots = [
|
|
makeSlot(slotId: 's1', unitId: 'u1', assignedPersonnelId: 'p1'),
|
|
makeSlot(slotId: 's2', unitId: 'u1', assignedPersonnelId: 'p2'),
|
|
];
|
|
expect(service.calculateFillRate(slots), 1.0);
|
|
});
|
|
|
|
test('returns 0.5 when half slots filled', () {
|
|
final slots = [
|
|
makeSlot(slotId: 's1', unitId: 'u1', assignedPersonnelId: 'p1'),
|
|
makeSlot(slotId: 's2', unitId: 'u1'),
|
|
];
|
|
expect(service.calculateFillRate(slots), 0.5);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getVacantKeyLeaderSlots
|
|
// ---------------------------------------------------------------------------
|
|
|
|
group('getVacantKeyLeaderSlots', () {
|
|
test('returns only vacant key leader slots', () {
|
|
final slots = [
|
|
makeSlot(slotId: 's1', unitId: 'u1', isKeyLeader: true),
|
|
makeSlot(slotId: 's2', unitId: 'u1', isKeyLeader: true, assignedPersonnelId: 'p1'),
|
|
makeSlot(slotId: 's3', unitId: 'u1'),
|
|
];
|
|
final result = service.getVacantKeyLeaderSlots(slots);
|
|
expect(result.length, 1);
|
|
expect(result.first.slotId, 's1');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getMosMismatches
|
|
// ---------------------------------------------------------------------------
|
|
|
|
group('getMosMismatches', () {
|
|
test('returns slots where assigned soldier MOS does not match', () {
|
|
final slot = PositionSlot(
|
|
slotId: 's1',
|
|
unitId: 'u1',
|
|
positionTitle: 'Medic',
|
|
dutyMos: DutyMos('68W'),
|
|
authorizedRank: AuthorizedRank.e4,
|
|
isKeyLeader: false,
|
|
assignedPersonnelId: 'p1',
|
|
);
|
|
final soldier = PersonnelRecord(
|
|
soldierId: 'p1',
|
|
name: 'SGT Wrong',
|
|
primaryMos: DutyMos('11B'), // Wrong MOS
|
|
payGrade: AuthorizedRank.e4,
|
|
readinessState: ReadinessState.fullyMissionCapable,
|
|
assignedSlotId: 's1',
|
|
);
|
|
final result = service.getMosMismatches([slot], [soldier]);
|
|
expect(result.length, 1);
|
|
expect(result.first.slotId, 's1');
|
|
});
|
|
|
|
test('returns empty list when all MOS match', () {
|
|
final slot = makeSlot(slotId: 's1', unitId: 'u1', assignedPersonnelId: 'p1');
|
|
final soldier = makeSoldier(id: 'p1', assignedSlotId: 's1');
|
|
final result = service.getMosMismatches([slot], [soldier]);
|
|
expect(result, isEmpty);
|
|
});
|
|
});
|
|
}
|