uto_manager/test/domain/services/unit_tree_service_test.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);
});
});
}