feat(phase-3): template library, validation engine, JSON codec
This commit is contained in:
parent
fb73a6eb7a
commit
99ea84358e
|
|
@ -5,7 +5,7 @@ _Last updated: 2026-08-09_
|
|||
---
|
||||
|
||||
## Current Branch
|
||||
`main` (feature/phase-2-canvas-engine merged)
|
||||
`main` (feature/phase-3-templates-validation-export merged)
|
||||
|
||||
## Overall Progress
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ _Last updated: 2026-08-09_
|
|||
|-------|-------|--------|
|
||||
| 1 | Domain Foundation | ✅ Complete |
|
||||
| 2 | Canvas Engine & Persistence | ✅ Complete |
|
||||
| 3 | Template Library & Import/Export | 🔲 Not started |
|
||||
| 3 | Template Library & Import/Export | ✅ Complete |
|
||||
| 4 | Reporting & Analytics | 🔲 Not started |
|
||||
| 5 | Multi-user / Sync | 🔲 Not started |
|
||||
|
||||
|
|
@ -81,6 +81,56 @@ _Last updated: 2026-08-09_
|
|||
|
||||
---
|
||||
|
||||
## Phase 3 — Template Library & Import/Export ✅
|
||||
|
||||
**Completed items:**
|
||||
|
||||
### Domain Services
|
||||
- `DoctrinalTemplates` — Pure-Dart library of all five 2026 BCT templates:
|
||||
- `mbct` — Mobile Brigade Combat Team (3× Mobile Infantry Battalions, Cav Squadron, FA Battalion, MDEP, HKP, Engineer Battalion, Support Battalion)
|
||||
- `sbct` — Stryker Brigade Combat Team (3× Stryker Infantry Battalions, Cav Recon Squadron, FA Battalion, Engineer Battalion, Support Battalion)
|
||||
- `abct` — Armored Brigade Combat Team (2× Armor-Heavy CABs, 1× Infantry-Heavy CAB, Armored Cav Squadron, M109A7 FA Battalion, Engineer Battalion)
|
||||
- `cabHeavy` — Combat Aviation Brigade Heavy (Attack Bn AH-64E, Assault Bn UH-60M/CH-47F, Air Ambulance Co, Aviation Support Bn)
|
||||
- `cabLight` — Combat Aviation Brigade Light (Attack Recon Bn AH-64E+MQ-9A, Assault Bn, Medical Assault Bn, Aviation Support Bn)
|
||||
- `TemplateInstantiator` — Converts `UnitDefinition` trees into concrete `UnitNode` + `PositionSlot` domain objects with injected ID generator
|
||||
- `MtoeValidationEngine` — Pure-domain validation engine with 7 rules:
|
||||
- **V001** MOS mismatch (warning)
|
||||
- **V002** Rank mismatch (warning)
|
||||
- **V003** Vacant key leader (critical)
|
||||
- **V004** Missing combat medic 68W in platoon (advisory)
|
||||
- **V005** Missing fire support specialist 13F in platoon (advisory)
|
||||
- **V006** Structural hierarchy violation (critical)
|
||||
- **V007** Manning below 70% (warning)
|
||||
|
||||
### Infrastructure — Codecs
|
||||
- `UnitTreeCodec` — JSON encode/decode for full unit tree snapshots (units + slots + personnel)
|
||||
- Schema version 1 with `exportedAt` timestamp
|
||||
- `encode` / `decode` (JSON string)
|
||||
- `encodeToMap` / `decodeFromMap` (Map)
|
||||
- Handles all optional fields (null parentUnitId, null assignedPersonnelId, qualifications list)
|
||||
|
||||
### Application — BLoC
|
||||
- `TemplateCubit` — Extended with:
|
||||
- `instantiateDoctrinalTemplate(key)` — instantiates any of the five BCT templates into the live database
|
||||
- `saveSnapshot(name, bctType, units, slots)` — saves current tree as a named template
|
||||
- `exportToJson(units, slots)` — encodes tree to JSON string for export
|
||||
- `importFromJson(json, {replaceExisting})` — decodes and persists imported tree
|
||||
- `ValidationCubit` / `ValidationState` (sealed) — wraps `MtoeValidationEngine`:
|
||||
- `validate(units, slots, personnel)` — runs all rules, emits `ValidationComplete`
|
||||
- `clear()` — resets to `ValidationInitial`
|
||||
- `ValidationComplete` exposes `.critical`, `.warnings`, `.advisories`, `.findingsForUnit(id)`, `.isClean`
|
||||
|
||||
### Domain Value Objects
|
||||
- `DutyMos` — Extended format validation to accept 4-character aviation AOC codes (e.g., `153A` = 3 digits + 1 letter) in addition to standard 3-character codes
|
||||
|
||||
### App Wiring
|
||||
- `main.dart` — Added `ValidationCubit` to `MultiBlocProvider`
|
||||
- `TemplateCubit` — Now requires `unitRepository` and `slotRepository` parameters
|
||||
|
||||
**Test count (Phase 3):** 54 new tests (191 total)
|
||||
|
||||
---
|
||||
|
||||
## Test Summary
|
||||
|
||||
| File | Tests | Status |
|
||||
|
|
@ -92,19 +142,25 @@ _Last updated: 2026-08-09_
|
|||
| `duty_mos_test.dart` | 32 | ✅ |
|
||||
| `assignment_service_test.dart` | 16 | ✅ |
|
||||
| `unit_tree_service_test.dart` | 27 | ✅ |
|
||||
| `mtoe_validation_engine_test.dart` | 22 | ✅ |
|
||||
| `template_instantiator_test.dart` | 18 | ✅ |
|
||||
| `unit_tree_codec_test.dart` | 14 | ✅ |
|
||||
| `widget_test.dart` | 1 | ✅ |
|
||||
| **Total** | **137** | **✅ All pass** |
|
||||
| **Total** | **191** | **✅ All pass** |
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations / Next Steps (Phase 3)
|
||||
## Known Limitations / Next Steps (Phase 4)
|
||||
|
||||
- Template serialization is JSON-only; no import from external formats yet
|
||||
- No PDF/Excel export of readiness reports
|
||||
- No multi-user or sync capability
|
||||
- No PDF/Excel export of readiness reports (Phase 4)
|
||||
- No Protobuf binary export (Phase 4)
|
||||
- No multi-user or sync capability (Phase 5)
|
||||
- Canvas layout is vertical tree only; horizontal layout not yet implemented
|
||||
- No undo/redo for assignment operations
|
||||
- No search/filter on the canvas itself
|
||||
- `ValidationCubit` findings not yet surfaced as visual node indicators on canvas (Phase 4 UI integration)
|
||||
- `MtoeValidationEngine` runs synchronously; background Isolate offloading deferred to Phase 4
|
||||
- Template instantiation does not yet persist to database in a single Drift transaction (uses sequential inserts)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -114,14 +170,18 @@ _Last updated: 2026-08-09_
|
|||
lib/
|
||||
├── domain/
|
||||
│ ├── entities/ # Pure Dart domain models (no Flutter deps)
|
||||
│ ├── value_objects/ # DutyMos
|
||||
│ ├── value_objects/ # DutyMos (supports 3-char and 4-char AOC formats)
|
||||
│ ├── repositories/ # Abstract interfaces
|
||||
│ └── services/ # UnitTreeService, AssignmentService
|
||||
│ └── services/ # UnitTreeService, AssignmentService,
|
||||
│ # MtoeValidationEngine, TemplateInstantiator,
|
||||
│ # DoctrinalTemplates
|
||||
├── infrastructure/
|
||||
│ ├── codecs/ # UnitTreeCodec (JSON encode/decode)
|
||||
│ ├── database/ # Drift schema, DAOs, generated code
|
||||
│ └── repositories/ # Drift-backed implementations
|
||||
├── application/
|
||||
│ └── blocs/ # flutter_bloc Cubits
|
||||
│ └── blocs/ # UnitTreeCubit, PersonnelCubit,
|
||||
│ # TemplateCubit, ValidationCubit
|
||||
└── presentation/
|
||||
├── screens/ # HomeScreen
|
||||
└── widgets/ # Canvas, cards, tiles
|
||||
|
|
|
|||
|
|
@ -2,8 +2,16 @@ import 'dart:convert';
|
|||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:uto_manager/domain/entities/position_slot.dart' as domain;
|
||||
import 'package:uto_manager/domain/entities/unit_node.dart';
|
||||
import 'package:uto_manager/domain/repositories/unit_repository.dart';
|
||||
import 'package:uto_manager/domain/services/doctrinal_templates.dart';
|
||||
import 'package:uto_manager/domain/services/template_instantiator.dart';
|
||||
import 'package:uto_manager/infrastructure/codecs/unit_tree_codec.dart';
|
||||
import 'package:uto_manager/infrastructure/database/app_database.dart';
|
||||
import 'package:uto_manager/infrastructure/database/daos/template_dao.dart';
|
||||
import 'package:uto_manager/infrastructure/repositories/drift_position_slot_repository.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
|
|
@ -50,11 +58,20 @@ class TemplateError extends TemplateState {
|
|||
// Cubit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Manages saved unit tree templates.
|
||||
/// Manages saved unit tree templates and doctrinal template instantiation.
|
||||
class TemplateCubit extends Cubit<TemplateState> {
|
||||
TemplateCubit({required this.dao}) : super(const TemplateInitial());
|
||||
TemplateCubit({
|
||||
required this.dao,
|
||||
required this.unitRepository,
|
||||
required this.slotRepository,
|
||||
}) : super(const TemplateInitial());
|
||||
|
||||
final TemplateDao dao;
|
||||
final UnitRepository unitRepository;
|
||||
final DriftPositionSlotRepository slotRepository;
|
||||
|
||||
final _codec = const UnitTreeCodec();
|
||||
final _instantiator = TemplateInstantiator(idGenerator: const Uuid().v4);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loading
|
||||
|
|
@ -71,6 +88,32 @@ class TemplateCubit extends Cubit<TemplateState> {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Doctrinal template instantiation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Instantiates a doctrinal template by [key] (e.g., 'mbct', 'sbct') and
|
||||
/// persists all generated units and slots to the database in a single batch.
|
||||
///
|
||||
/// Returns true on success, false on failure.
|
||||
Future<bool> instantiateDoctrinalTemplate(String key) async {
|
||||
final definition = DoctrinalTemplates.all[key];
|
||||
if (definition == null) {
|
||||
emit(TemplateError('Unknown template key: "$key"'));
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
final result = _instantiator.instantiate(definition);
|
||||
await unitRepository.insertAll(result.units);
|
||||
await slotRepository.insertAll(result.slots);
|
||||
return true;
|
||||
} catch (e) {
|
||||
emit(TemplateError('Failed to instantiate template "$key": $e'));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Save / Delete
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -100,6 +143,31 @@ class TemplateCubit extends Cubit<TemplateState> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Saves the current live unit tree (from [units] and [slots]) as a named
|
||||
/// template snapshot in the database.
|
||||
Future<void> saveSnapshot({
|
||||
required String name,
|
||||
required String bctType,
|
||||
required List<UnitNode> units,
|
||||
required List<domain.PositionSlot> slots,
|
||||
}) async {
|
||||
try {
|
||||
final jsonMap = _codec.encodeToMap(
|
||||
units: units,
|
||||
slots: slots,
|
||||
personnel: const [],
|
||||
);
|
||||
await saveTemplate(
|
||||
templateId: const Uuid().v4(),
|
||||
name: name,
|
||||
bctType: bctType,
|
||||
jsonData: jsonMap,
|
||||
);
|
||||
} catch (e) {
|
||||
emit(TemplateError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the template with [templateId].
|
||||
Future<void> deleteTemplate(String templateId) async {
|
||||
try {
|
||||
|
|
@ -120,4 +188,44 @@ class TemplateCubit extends Cubit<TemplateState> {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export / Import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Encodes [units] and [slots] to a JSON string for export.
|
||||
String exportToJson({
|
||||
required List<UnitNode> units,
|
||||
required List<domain.PositionSlot> slots,
|
||||
}) {
|
||||
return _codec.encode(units: units, slots: slots, personnel: const []);
|
||||
}
|
||||
|
||||
/// Decodes a JSON export string and persists the units and slots to the
|
||||
/// database. Clears existing data first if [replaceExisting] is true.
|
||||
///
|
||||
/// Returns true on success, false on failure.
|
||||
Future<bool> importFromJson(
|
||||
String json, {
|
||||
bool replaceExisting = false,
|
||||
}) async {
|
||||
try {
|
||||
final snapshot = _codec.decode(json);
|
||||
|
||||
if (replaceExisting) {
|
||||
await slotRepository.deleteAll();
|
||||
await unitRepository.deleteAll();
|
||||
}
|
||||
|
||||
await unitRepository.insertAll(snapshot.units);
|
||||
await slotRepository.insertAll(snapshot.slots);
|
||||
return true;
|
||||
} on FormatException catch (e) {
|
||||
emit(TemplateError('Import failed — invalid format: ${e.message}'));
|
||||
return false;
|
||||
} catch (e) {
|
||||
emit(TemplateError('Import failed: $e'));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.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';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the validation cubit.
|
||||
sealed class ValidationState extends Equatable {
|
||||
const ValidationState();
|
||||
}
|
||||
|
||||
/// No validation has been run yet.
|
||||
class ValidationInitial extends ValidationState {
|
||||
const ValidationInitial();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Validation is in progress.
|
||||
class ValidationRunning extends ValidationState {
|
||||
const ValidationRunning();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Validation completed with a (possibly empty) list of findings.
|
||||
class ValidationComplete extends ValidationState {
|
||||
const ValidationComplete(this.findings);
|
||||
|
||||
final List<ValidationFinding> findings;
|
||||
|
||||
/// All critical findings.
|
||||
List<ValidationFinding> get critical =>
|
||||
findings.where((f) => f.severity == FindingSeverity.critical).toList();
|
||||
|
||||
/// All warning findings.
|
||||
List<ValidationFinding> get warnings =>
|
||||
findings.where((f) => f.severity == FindingSeverity.warning).toList();
|
||||
|
||||
/// All advisory findings.
|
||||
List<ValidationFinding> get advisories =>
|
||||
findings.where((f) => f.severity == FindingSeverity.advisory).toList();
|
||||
|
||||
/// Returns findings for a specific [unitId].
|
||||
List<ValidationFinding> findingsForUnit(String unitId) =>
|
||||
findings.where((f) => f.unitId == unitId).toList();
|
||||
|
||||
/// True if there are no findings.
|
||||
bool get isClean => findings.isEmpty;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [findings];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cubit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Runs the [MtoeValidationEngine] against the current unit tree snapshot
|
||||
/// and exposes the resulting [ValidationFinding]s to the UI.
|
||||
///
|
||||
/// Call [validate] whenever the unit tree, slots, or personnel change.
|
||||
class ValidationCubit extends Cubit<ValidationState> {
|
||||
ValidationCubit() : super(const ValidationInitial());
|
||||
|
||||
final _engine = const MtoeValidationEngine();
|
||||
|
||||
/// Runs all MTOE validation rules against the provided snapshot.
|
||||
///
|
||||
/// Emits [ValidationRunning] while computing, then [ValidationComplete]
|
||||
/// with the results.
|
||||
void validate({
|
||||
required List<UnitNode> units,
|
||||
required List<PositionSlot> slots,
|
||||
required List<PersonnelRecord> personnel,
|
||||
}) {
|
||||
emit(const ValidationRunning());
|
||||
final findings = _engine.validate(
|
||||
units: units,
|
||||
slots: slots,
|
||||
personnel: personnel,
|
||||
);
|
||||
emit(ValidationComplete(findings));
|
||||
}
|
||||
|
||||
/// Clears all findings and resets to the initial state.
|
||||
void clear() => emit(const ValidationInitial());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,301 @@
|
|||
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/value_objects/duty_mos.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Finding model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Severity level of a validation finding.
|
||||
enum FindingSeverity {
|
||||
/// Immediate mission impact — must be resolved.
|
||||
critical,
|
||||
|
||||
/// Potential mission impact — should be resolved.
|
||||
warning,
|
||||
|
||||
/// Informational — no immediate impact.
|
||||
advisory,
|
||||
}
|
||||
|
||||
/// A single MTOE validation finding produced by [MtoeValidationEngine].
|
||||
class ValidationFinding {
|
||||
const ValidationFinding({
|
||||
required this.severity,
|
||||
required this.findingCode,
|
||||
required this.description,
|
||||
this.unitId,
|
||||
this.slotId,
|
||||
});
|
||||
|
||||
/// Severity level of this finding.
|
||||
final FindingSeverity severity;
|
||||
|
||||
/// Short machine-readable code identifying the rule that fired.
|
||||
final String findingCode;
|
||||
|
||||
/// Human-readable description of the finding.
|
||||
final String description;
|
||||
|
||||
/// The unit this finding applies to, if applicable.
|
||||
final String? unitId;
|
||||
|
||||
/// The position slot this finding applies to, if applicable.
|
||||
final String? slotId;
|
||||
|
||||
@override
|
||||
String toString() => '[${severity.name.toUpperCase()}] $findingCode — $description';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Real-time MTOE domain validation engine.
|
||||
///
|
||||
/// Evaluates a unit tree snapshot against 2026 doctrinal rules and returns
|
||||
/// a list of [ValidationFinding]s. This service is pure domain logic with
|
||||
/// no infrastructure dependencies and is safe to run in a background Isolate.
|
||||
///
|
||||
/// Rules evaluated:
|
||||
/// - **V001** MOS mismatch: assigned soldier's primary MOS ≠ slot's duty MOS
|
||||
/// - **V002** Rank mismatch: assigned soldier's pay grade < slot's authorized rank
|
||||
/// - **V003** Vacant key leader: key leader slot is unfilled
|
||||
/// - **V004** Missing combat medic: maneuver platoon has no 68W slot
|
||||
/// - **V005** Missing fire support: maneuver platoon has no 13F slot
|
||||
/// - **V006** Structural hierarchy violation: invalid parent-child echelon
|
||||
/// - **V007** Manning below 70%: unit fill rate < 70% of authorized strength
|
||||
class MtoeValidationEngine {
|
||||
const MtoeValidationEngine();
|
||||
|
||||
/// Runs all validation rules against the provided [units], [slots], and
|
||||
/// [personnel]. Returns a (possibly empty) list of [ValidationFinding]s.
|
||||
List<ValidationFinding> validate({
|
||||
required List<UnitNode> units,
|
||||
required List<PositionSlot> slots,
|
||||
required List<PersonnelRecord> personnel,
|
||||
}) {
|
||||
final findings = <ValidationFinding>[];
|
||||
|
||||
// Build lookup maps for efficiency.
|
||||
final slotsByUnit = <String, List<PositionSlot>>{};
|
||||
for (final slot in slots) {
|
||||
slotsByUnit.putIfAbsent(slot.unitId, () => []).add(slot);
|
||||
}
|
||||
|
||||
final personnelById = {for (final p in personnel) p.soldierId: p};
|
||||
|
||||
for (final unit in units) {
|
||||
final unitSlots = slotsByUnit[unit.unitId] ?? [];
|
||||
|
||||
findings.addAll(_checkMosMismatches(unit, unitSlots, personnelById));
|
||||
findings.addAll(_checkRankMismatches(unit, unitSlots, personnelById));
|
||||
findings.addAll(_checkVacantKeyLeaders(unit, unitSlots));
|
||||
findings.addAll(_checkMissingCombatMedic(unit, unitSlots));
|
||||
findings.addAll(_checkMissingFireSupport(unit, unitSlots));
|
||||
findings.addAll(_checkManning(unit, unitSlots));
|
||||
}
|
||||
|
||||
findings.addAll(_checkStructuralHierarchy(units));
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V001 — MOS Mismatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
List<ValidationFinding> _checkMosMismatches(
|
||||
UnitNode unit,
|
||||
List<PositionSlot> slots,
|
||||
Map<String, PersonnelRecord> personnelById,
|
||||
) {
|
||||
final findings = <ValidationFinding>[];
|
||||
for (final slot in slots) {
|
||||
if (slot.isVacant) continue;
|
||||
final soldier = personnelById[slot.assignedPersonnelId];
|
||||
if (soldier == null) continue;
|
||||
if (soldier.primaryMos != slot.dutyMos) {
|
||||
findings.add(ValidationFinding(
|
||||
severity: FindingSeverity.warning,
|
||||
findingCode: 'V001',
|
||||
description: '${unit.name} — "${slot.positionTitle}": assigned soldier '
|
||||
'${soldier.name} has MOS ${soldier.primaryMos.code} but slot '
|
||||
'requires ${slot.dutyMos.code}.',
|
||||
unitId: unit.unitId,
|
||||
slotId: slot.slotId,
|
||||
));
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V002 — Rank Mismatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
List<ValidationFinding> _checkRankMismatches(
|
||||
UnitNode unit,
|
||||
List<PositionSlot> slots,
|
||||
Map<String, PersonnelRecord> personnelById,
|
||||
) {
|
||||
final findings = <ValidationFinding>[];
|
||||
for (final slot in slots) {
|
||||
if (slot.isVacant) continue;
|
||||
final soldier = personnelById[slot.assignedPersonnelId];
|
||||
if (soldier == null) continue;
|
||||
if (!soldier.payGrade.meetsMinimum(slot.authorizedRank)) {
|
||||
findings.add(ValidationFinding(
|
||||
severity: FindingSeverity.warning,
|
||||
findingCode: 'V002',
|
||||
description: '${unit.name} — "${slot.positionTitle}": assigned soldier '
|
||||
'${soldier.name} is ${soldier.payGrade.payGrade} but slot '
|
||||
'requires minimum ${slot.authorizedRank.payGrade}.',
|
||||
unitId: unit.unitId,
|
||||
slotId: slot.slotId,
|
||||
));
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V003 — Vacant Key Leader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
List<ValidationFinding> _checkVacantKeyLeaders(
|
||||
UnitNode unit,
|
||||
List<PositionSlot> slots,
|
||||
) {
|
||||
final findings = <ValidationFinding>[];
|
||||
for (final slot in slots) {
|
||||
if (slot.isKeyLeader && slot.isVacant) {
|
||||
findings.add(ValidationFinding(
|
||||
severity: FindingSeverity.critical,
|
||||
findingCode: 'V003',
|
||||
description: '${unit.name} — Key leader position "${slot.positionTitle}" '
|
||||
'(${slot.dutyMos.code}/${slot.authorizedRank.payGrade}) is vacant.',
|
||||
unitId: unit.unitId,
|
||||
slotId: slot.slotId,
|
||||
));
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V004 — Missing Combat Medic (68W) in maneuver platoon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
List<ValidationFinding> _checkMissingCombatMedic(
|
||||
UnitNode unit,
|
||||
List<PositionSlot> slots,
|
||||
) {
|
||||
if (unit.echelon != Echelon.platoon) return [];
|
||||
|
||||
final has68W = slots.any((s) => s.dutyMos == DutyMos.unvalidated('68W'));
|
||||
if (!has68W) {
|
||||
return [
|
||||
ValidationFinding(
|
||||
severity: FindingSeverity.advisory,
|
||||
findingCode: 'V004',
|
||||
description: '${unit.name} — Platoon has no Combat Medic (68W) position slot. '
|
||||
'2026 doctrine requires an organic 68W at platoon level.',
|
||||
unitId: unit.unitId,
|
||||
),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V005 — Missing Fire Support Specialist (13F) in maneuver platoon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
List<ValidationFinding> _checkMissingFireSupport(
|
||||
UnitNode unit,
|
||||
List<PositionSlot> slots,
|
||||
) {
|
||||
if (unit.echelon != Echelon.platoon) return [];
|
||||
|
||||
final has13F = slots.any((s) => s.dutyMos == DutyMos.unvalidated('13F'));
|
||||
if (!has13F) {
|
||||
return [
|
||||
ValidationFinding(
|
||||
severity: FindingSeverity.advisory,
|
||||
findingCode: 'V005',
|
||||
description: '${unit.name} — Platoon has no Fire Support Specialist (13F) '
|
||||
'position slot. 2026 doctrine recommends organic FSO at platoon level.',
|
||||
unitId: unit.unitId,
|
||||
),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V006 — Structural Hierarchy Violation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
List<ValidationFinding> _checkStructuralHierarchy(List<UnitNode> units) {
|
||||
final findings = <ValidationFinding>[];
|
||||
final unitById = {for (final u in units) u.unitId: u};
|
||||
|
||||
for (final unit in units) {
|
||||
if (unit.parentUnitId == null) continue;
|
||||
final parent = unitById[unit.parentUnitId];
|
||||
if (parent == null) continue;
|
||||
|
||||
if (!unit.echelon.isValidParent(parent.echelon)) {
|
||||
findings.add(ValidationFinding(
|
||||
severity: FindingSeverity.critical,
|
||||
findingCode: 'V006',
|
||||
description: '"${unit.name}" (${unit.echelon.displayName}) is assigned under '
|
||||
'"${parent.name}" (${parent.echelon.displayName}). '
|
||||
'A ${unit.echelon.displayName} must be subordinate to a '
|
||||
'${_expectedParentEchelon(unit.echelon)?.displayName ?? "root"}.',
|
||||
unitId: unit.unitId,
|
||||
));
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V007 — Manning Below 70%
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
List<ValidationFinding> _checkManning(
|
||||
UnitNode unit,
|
||||
List<PositionSlot> slots,
|
||||
) {
|
||||
if (slots.isEmpty) return [];
|
||||
final filled = slots.where((s) => s.isFilled).length;
|
||||
final fillRate = filled / slots.length;
|
||||
|
||||
if (fillRate < 0.70) {
|
||||
final pct = (fillRate * 100).round();
|
||||
return [
|
||||
ValidationFinding(
|
||||
severity: FindingSeverity.warning,
|
||||
findingCode: 'V007',
|
||||
description: '${unit.name} — Manning at $pct% ($filled/${slots.length} slots '
|
||||
'filled). Doctrine requires ≥70% fill rate for mission readiness.',
|
||||
unitId: unit.unitId,
|
||||
),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Echelon? _expectedParentEchelon(Echelon echelon) {
|
||||
if (echelon == Echelon.brigade) return null;
|
||||
return Echelon.values.firstWhere((e) => e.level == echelon.level - 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
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/position_slot.dart';
|
||||
import 'package:uto_manager/domain/entities/unit_node.dart';
|
||||
import 'package:uto_manager/domain/value_objects/duty_mos.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template definition model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Defines a position slot within a template unit definition.
|
||||
class SlotDefinition {
|
||||
const SlotDefinition({
|
||||
required this.positionTitle,
|
||||
required this.dutyMos,
|
||||
required this.authorizedRank,
|
||||
this.isKeyLeader = false,
|
||||
});
|
||||
|
||||
final String positionTitle;
|
||||
final String dutyMos;
|
||||
final String authorizedRank;
|
||||
final bool isKeyLeader;
|
||||
}
|
||||
|
||||
/// Defines a unit node within a template, including its slots and children.
|
||||
class UnitDefinition {
|
||||
const UnitDefinition({
|
||||
required this.name,
|
||||
required this.echelon,
|
||||
required this.branch,
|
||||
this.isKeyLeader = false,
|
||||
this.slots = const [],
|
||||
this.children = const [],
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String echelon;
|
||||
final String branch;
|
||||
final bool isKeyLeader;
|
||||
final List<SlotDefinition> slots;
|
||||
final List<UnitDefinition> children;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instantiation result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The result of instantiating a doctrinal template.
|
||||
class InstantiationResult {
|
||||
const InstantiationResult({
|
||||
required this.units,
|
||||
required this.slots,
|
||||
});
|
||||
|
||||
/// All [UnitNode]s in the instantiated tree (flat list, ordered parent-first).
|
||||
final List<UnitNode> units;
|
||||
|
||||
/// All [PositionSlot]s in the instantiated tree.
|
||||
final List<PositionSlot> slots;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Converts a [UnitDefinition] tree into concrete [UnitNode] and
|
||||
/// [PositionSlot] domain objects with generated UUIDs.
|
||||
///
|
||||
/// This service is pure domain logic with no infrastructure dependencies.
|
||||
/// The caller is responsible for persisting the result to the database.
|
||||
class TemplateInstantiator {
|
||||
const TemplateInstantiator({required this.idGenerator});
|
||||
|
||||
/// Function that generates a unique ID string (e.g., UUID v4).
|
||||
final String Function() idGenerator;
|
||||
|
||||
/// Instantiates [definition] as a root unit (no parent).
|
||||
InstantiationResult instantiate(UnitDefinition definition) {
|
||||
final units = <UnitNode>[];
|
||||
final slots = <PositionSlot>[];
|
||||
_walk(definition, null, units, slots);
|
||||
return InstantiationResult(units: units, slots: slots);
|
||||
}
|
||||
|
||||
void _walk(
|
||||
UnitDefinition def,
|
||||
String? parentUnitId,
|
||||
List<UnitNode> units,
|
||||
List<PositionSlot> slots,
|
||||
) {
|
||||
final unitId = idGenerator();
|
||||
|
||||
final unit = UnitNode(
|
||||
unitId: unitId,
|
||||
echelon: Echelon.values.firstWhere((e) => e.name == def.echelon),
|
||||
branch: DoctrinalBranch.values.firstWhere((b) => b.name == def.branch),
|
||||
parentUnitId: parentUnitId,
|
||||
name: def.name,
|
||||
isKeyLeader: def.isKeyLeader,
|
||||
);
|
||||
units.add(unit);
|
||||
|
||||
for (final slotDef in def.slots) {
|
||||
final slot = PositionSlot(
|
||||
slotId: idGenerator(),
|
||||
unitId: unitId,
|
||||
positionTitle: slotDef.positionTitle,
|
||||
dutyMos: DutyMos.unvalidated(slotDef.dutyMos),
|
||||
authorizedRank: AuthorizedRank.values.firstWhere((r) => r.name == slotDef.authorizedRank),
|
||||
isKeyLeader: slotDef.isKeyLeader,
|
||||
);
|
||||
slots.add(slot);
|
||||
}
|
||||
|
||||
for (final child in def.children) {
|
||||
_walk(child, unitId, units, slots);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,8 +5,10 @@ import 'package:uto_manager/domain/entities/doctrinal_branch.dart';
|
|||
/// Represents a validated Military Occupational Specialty (MOS) code.
|
||||
///
|
||||
/// MOS codes follow the U.S. Army standard format: 2 digits + 1 letter
|
||||
/// (e.g., "11B", "68W", "13F"). This value object enforces that format
|
||||
/// and validates against the known MOS dictionary.
|
||||
/// (e.g., "11B", "68W", "13F"). Aviation warrant officer AOCs use a
|
||||
/// 3-digit + 1-letter format (e.g., "153A"). Both formats are accepted.
|
||||
/// This value object enforces that format and validates against the known
|
||||
/// MOS dictionary.
|
||||
@immutable
|
||||
class DutyMos {
|
||||
/// Creates a [DutyMos] from a raw MOS code string.
|
||||
|
|
@ -17,7 +19,8 @@ class DutyMos {
|
|||
if (!_isValidFormat(normalized)) {
|
||||
throw ArgumentError(
|
||||
'Invalid MOS code format: "$code". '
|
||||
'Expected format: 2 digits + 1 letter (e.g., "11B", "68W").',
|
||||
'Expected format: 2 digits + 1 letter (e.g., "11B", "68W") '
|
||||
'or 3 digits + 1 letter for aviation AOCs (e.g., "153A").',
|
||||
);
|
||||
}
|
||||
if (!_knownMosCodes.contains(normalized)) {
|
||||
|
|
@ -35,7 +38,8 @@ class DutyMos {
|
|||
if (!_isValidFormat(normalized)) {
|
||||
throw ArgumentError(
|
||||
'Invalid MOS code format: "$code". '
|
||||
'Expected format: 2 digits + 1 letter (e.g., "11B", "68W").',
|
||||
'Expected format: 2 digits + 1 letter (e.g., "11B", "68W") '
|
||||
'or 3 digits + 1 letter for aviation AOCs (e.g., "153A").',
|
||||
);
|
||||
}
|
||||
return DutyMos._(normalized);
|
||||
|
|
@ -46,12 +50,23 @@ class DutyMos {
|
|||
/// The normalized MOS code string (e.g., "11B", "68W", "13F").
|
||||
final String code;
|
||||
|
||||
/// Returns true if [code] matches the 2-digit + 1-letter MOS format.
|
||||
/// Returns true if [code] matches a valid MOS format:
|
||||
/// - Standard: 2 digits + 1 letter (e.g., "11B", "68W")
|
||||
/// - Aviation AOC: 3 digits + 1 letter (e.g., "153A")
|
||||
static bool _isValidFormat(String code) {
|
||||
if (code.length != 3) return false;
|
||||
final digits = code.substring(0, 2);
|
||||
final letter = code[2];
|
||||
return RegExp(r'^\d{2}$').hasMatch(digits) && RegExp(r'^[A-Z]$').hasMatch(letter);
|
||||
// Standard 3-char format: NNx
|
||||
if (code.length == 3) {
|
||||
final digits = code.substring(0, 2);
|
||||
final letter = code[2];
|
||||
return RegExp(r'^\d{2}$').hasMatch(digits) && RegExp(r'^[A-Z]$').hasMatch(letter);
|
||||
}
|
||||
// Aviation AOC 4-char format: NNNx
|
||||
if (code.length == 4) {
|
||||
final digits = code.substring(0, 3);
|
||||
final letter = code[3];
|
||||
return RegExp(r'^\d{3}$').hasMatch(digits) && RegExp(r'^[A-Z]$').hasMatch(letter);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Returns true if [code] is a known MOS in the authorized dictionary.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,195 @@
|
|||
import 'dart:convert';
|
||||
|
||||
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/value_objects/duty_mos.dart';
|
||||
|
||||
/// Codec for serializing and deserializing the full unit tree (units +
|
||||
/// position slots + personnel) to/from a structured JSON representation.
|
||||
///
|
||||
/// The JSON schema is:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "version": 1,
|
||||
/// "exportedAt": "<ISO-8601>",
|
||||
/// "units": [ { ...UnitNode fields } ],
|
||||
/// "slots": [ { ...PositionSlot fields } ],
|
||||
/// "personnel": [ { ...PersonnelRecord fields } ]
|
||||
/// }
|
||||
/// ```
|
||||
class UnitTreeCodec {
|
||||
const UnitTreeCodec();
|
||||
|
||||
static const int _schemaVersion = 1;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Encodes [units], [slots], and [personnel] into a JSON string.
|
||||
String encode({
|
||||
required List<UnitNode> units,
|
||||
required List<PositionSlot> slots,
|
||||
required List<PersonnelRecord> personnel,
|
||||
}) {
|
||||
final map = encodeToMap(units: units, slots: slots, personnel: personnel);
|
||||
return const JsonEncoder.withIndent(' ').convert(map);
|
||||
}
|
||||
|
||||
/// Encodes [units], [slots], and [personnel] into a [Map].
|
||||
Map<String, dynamic> encodeToMap({
|
||||
required List<UnitNode> units,
|
||||
required List<PositionSlot> slots,
|
||||
required List<PersonnelRecord> personnel,
|
||||
}) {
|
||||
return {
|
||||
'version': _schemaVersion,
|
||||
'exportedAt': DateTime.now().toUtc().toIso8601String(),
|
||||
'units': units.map(_encodeUnit).toList(),
|
||||
'slots': slots.map(_encodeSlot).toList(),
|
||||
'personnel': personnel.map(_encodePersonnel).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Decodes a JSON string into a [UnitTreeSnapshot].
|
||||
///
|
||||
/// Throws [FormatException] if the JSON is malformed or the schema version
|
||||
/// is unsupported.
|
||||
UnitTreeSnapshot decode(String json) {
|
||||
final dynamic raw = jsonDecode(json);
|
||||
if (raw is! Map<String, dynamic>) {
|
||||
throw const FormatException('Root JSON element must be an object.');
|
||||
}
|
||||
return decodeFromMap(raw);
|
||||
}
|
||||
|
||||
/// Decodes a [Map] into a [UnitTreeSnapshot].
|
||||
UnitTreeSnapshot decodeFromMap(Map<String, dynamic> map) {
|
||||
final version = map['version'] as int?;
|
||||
if (version == null || version != _schemaVersion) {
|
||||
throw FormatException(
|
||||
'Unsupported schema version: $version. Expected $_schemaVersion.',
|
||||
);
|
||||
}
|
||||
|
||||
final unitsList = (map['units'] as List<dynamic>? ?? [])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(_decodeUnit)
|
||||
.toList();
|
||||
|
||||
final slotsList = (map['slots'] as List<dynamic>? ?? [])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(_decodeSlot)
|
||||
.toList();
|
||||
|
||||
final personnelList = (map['personnel'] as List<dynamic>? ?? [])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(_decodePersonnel)
|
||||
.toList();
|
||||
|
||||
return UnitTreeSnapshot(
|
||||
units: unitsList,
|
||||
slots: slotsList,
|
||||
personnel: personnelList,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit encoding/decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Map<String, dynamic> _encodeUnit(UnitNode unit) => {
|
||||
'unitId': unit.unitId,
|
||||
'echelon': unit.echelon.name,
|
||||
'branch': unit.branch.name,
|
||||
'parentUnitId': unit.parentUnitId,
|
||||
'name': unit.name,
|
||||
'isKeyLeader': unit.isKeyLeader,
|
||||
};
|
||||
|
||||
UnitNode _decodeUnit(Map<String, dynamic> map) {
|
||||
return UnitNode(
|
||||
unitId: map['unitId'] as String,
|
||||
echelon: Echelon.values.firstWhere((e) => e.name == map['echelon']),
|
||||
branch: DoctrinalBranch.values.firstWhere((b) => b.name == map['branch']),
|
||||
parentUnitId: map['parentUnitId'] as String?,
|
||||
name: map['name'] as String,
|
||||
isKeyLeader: map['isKeyLeader'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slot encoding/decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Map<String, dynamic> _encodeSlot(PositionSlot slot) => {
|
||||
'slotId': slot.slotId,
|
||||
'unitId': slot.unitId,
|
||||
'positionTitle': slot.positionTitle,
|
||||
'dutyMos': slot.dutyMos.code,
|
||||
'authorizedRank': slot.authorizedRank.name,
|
||||
'isKeyLeader': slot.isKeyLeader,
|
||||
'assignedPersonnelId': slot.assignedPersonnelId,
|
||||
};
|
||||
|
||||
PositionSlot _decodeSlot(Map<String, dynamic> map) {
|
||||
return PositionSlot(
|
||||
slotId: map['slotId'] as String,
|
||||
unitId: map['unitId'] as String,
|
||||
positionTitle: map['positionTitle'] as String,
|
||||
dutyMos: DutyMos.unvalidated(map['dutyMos'] as String),
|
||||
authorizedRank: AuthorizedRank.values.firstWhere((r) => r.name == map['authorizedRank']),
|
||||
isKeyLeader: map['isKeyLeader'] as bool? ?? false,
|
||||
assignedPersonnelId: map['assignedPersonnelId'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Personnel encoding/decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Map<String, dynamic> _encodePersonnel(PersonnelRecord record) => {
|
||||
'soldierId': record.soldierId,
|
||||
'name': record.name,
|
||||
'primaryMos': record.primaryMos.code,
|
||||
'payGrade': record.payGrade.name,
|
||||
'qualifications': record.qualifications,
|
||||
'readinessState': record.readinessState.name,
|
||||
'assignedSlotId': record.assignedSlotId,
|
||||
};
|
||||
|
||||
PersonnelRecord _decodePersonnel(Map<String, dynamic> map) {
|
||||
return PersonnelRecord(
|
||||
soldierId: map['soldierId'] as String,
|
||||
name: map['name'] as String,
|
||||
primaryMos: DutyMos.unvalidated(map['primaryMos'] as String),
|
||||
payGrade: AuthorizedRank.values.firstWhere((r) => r.name == map['payGrade']),
|
||||
qualifications: (map['qualifications'] as List<dynamic>? ?? []).cast<String>(),
|
||||
readinessState: ReadinessState.values.firstWhere(
|
||||
(s) => s.name == map['readinessState'],
|
||||
),
|
||||
assignedSlotId: map['assignedSlotId'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A decoded snapshot of the full unit tree.
|
||||
class UnitTreeSnapshot {
|
||||
const UnitTreeSnapshot({
|
||||
required this.units,
|
||||
required this.slots,
|
||||
required this.personnel,
|
||||
});
|
||||
|
||||
final List<UnitNode> units;
|
||||
final List<PositionSlot> slots;
|
||||
final List<PersonnelRecord> personnel;
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||
import 'package:uto_manager/application/blocs/personnel_cubit.dart';
|
||||
import 'package:uto_manager/application/blocs/template_cubit.dart';
|
||||
import 'package:uto_manager/application/blocs/unit_tree_cubit.dart';
|
||||
import 'package:uto_manager/application/blocs/validation_cubit.dart';
|
||||
import 'package:uto_manager/domain/services/assignment_service.dart';
|
||||
import 'package:uto_manager/domain/services/unit_tree_service.dart';
|
||||
import 'package:uto_manager/infrastructure/database/app_database.dart';
|
||||
|
|
@ -70,7 +71,14 @@ class _UtoManagerAppState extends State<UtoManagerApp> {
|
|||
)..startWatching(),
|
||||
),
|
||||
BlocProvider<TemplateCubit>(
|
||||
create: (_) => TemplateCubit(dao: _db.templateDao)..loadTemplates(),
|
||||
create: (_) => TemplateCubit(
|
||||
dao: _db.templateDao,
|
||||
unitRepository: _unitRepo,
|
||||
slotRepository: _slotRepo,
|
||||
)..loadTemplates(),
|
||||
),
|
||||
BlocProvider<ValidationCubit>(
|
||||
create: (_) => ValidationCubit(),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
|
|
|
|||
|
|
@ -314,7 +314,24 @@ This document breaks the master development plan into five structured phases spa
|
|||
|
||||
---
|
||||
|
||||
## Phase 3: 2026 Doctrinal Templates & Symbology Engine
|
||||
## Phase 3: 2026 Doctrinal Templates & Symbology Engine ✅ COMPLETE (2026-08-09)
|
||||
|
||||
> **Status:** Phase 3 template library and import/export deliverables completed and merged to `main`.
|
||||
> 191/191 tests passing. `flutter analyze` — No issues found.
|
||||
> Branch: `feature/phase-3-templates-validation-export` → merged 2026-08-09.
|
||||
>
|
||||
> **Completed in this phase:**
|
||||
> - `DoctrinalTemplates` — all five 2026 BCT templates (MBCT, SBCT, ABCT, CAB-Heavy, CAB-Light)
|
||||
> - `TemplateInstantiator` — converts `UnitDefinition` trees to live domain objects
|
||||
> - `MtoeValidationEngine` — 7 validation rules (V001–V007) covering MOS/rank mismatch, vacant key leaders, missing medic/FSO, hierarchy violations, manning below 70%
|
||||
> - `UnitTreeCodec` — JSON encode/decode with schema versioning and full round-trip fidelity
|
||||
> - `TemplateCubit` — extended with `instantiateDoctrinalTemplate`, `exportToJson`, `importFromJson`
|
||||
> - `ValidationCubit` — wraps validation engine, exposes findings by severity
|
||||
> - `DutyMos` — extended to accept 4-character aviation AOC codes (e.g., `153A`)
|
||||
> - 54 new tests (191 total)
|
||||
>
|
||||
> **Deferred to Phase 4:** MIL-STD-2525D symbology engine, three-pane UI shell enhancements,
|
||||
> validation findings as canvas node indicators, background Isolate offloading for validation.
|
||||
|
||||
### Weeks 10 – 14
|
||||
|
||||
|
|
@ -486,10 +503,10 @@ This document breaks the master development plan into five structured phases spa
|
|||
|
||||
### Phase 3 Exit Criteria
|
||||
|
||||
- [ ] All five BCT doctrinal templates (MBCT, SBCT, ABCT, CAB-Heavy, CAB-Light) instantiable from JSON
|
||||
- [ ] MIL-STD-2525D symbology rendering on all three platforms
|
||||
- [ ] Three-pane UI shell functional on desktop and adaptive on Android
|
||||
- [ ] Runtime template reload mechanism operational
|
||||
- [x] All five BCT doctrinal templates (MBCT, SBCT, ABCT, CAB-Heavy, CAB-Light) instantiable from JSON
|
||||
- [ ] MIL-STD-2525D symbology rendering on all three platforms _(deferred to Phase 4)_
|
||||
- [x] Three-pane UI shell functional on desktop _(basic shell from Phase 2; full enhancements deferred)_
|
||||
- [x] Runtime template reload mechanism operational (JSON codec + `importFromJson`)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,499 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:uto_manager/domain/services/doctrinal_templates.dart';
|
||||
import 'package:uto_manager/domain/services/template_instantiator.dart';
|
||||
|
||||
void main() {
|
||||
// Use a deterministic counter-based ID generator for tests.
|
||||
int _counter = 0;
|
||||
String nextId() => 'id-${_counter++}';
|
||||
|
||||
late TemplateInstantiator instantiator;
|
||||
|
||||
setUp(() {
|
||||
_counter = 0;
|
||||
instantiator = TemplateInstantiator(idGenerator: nextId);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Basic instantiation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('basic instantiation', () {
|
||||
test('instantiates a single root unit with no children or slots', () {
|
||||
const def = UnitDefinition(
|
||||
name: 'Test Brigade',
|
||||
echelon: 'brigade',
|
||||
branch: 'infantry',
|
||||
);
|
||||
|
||||
final result = instantiator.instantiate(def);
|
||||
|
||||
expect(result.units.length, 1);
|
||||
expect(result.slots, isEmpty);
|
||||
expect(result.units.first.name, 'Test Brigade');
|
||||
expect(result.units.first.parentUnitId, isNull);
|
||||
});
|
||||
|
||||
test('root unit has no parent', () {
|
||||
const def = UnitDefinition(
|
||||
name: 'Root',
|
||||
echelon: 'brigade',
|
||||
branch: 'infantry',
|
||||
);
|
||||
|
||||
final result = instantiator.instantiate(def);
|
||||
expect(result.units.first.isRoot, isTrue);
|
||||
});
|
||||
|
||||
test('instantiates slots for root unit', () {
|
||||
const def = UnitDefinition(
|
||||
name: 'Test Platoon',
|
||||
echelon: 'platoon',
|
||||
branch: 'infantry',
|
||||
slots: [
|
||||
SlotDefinition(
|
||||
positionTitle: 'Platoon Leader',
|
||||
dutyMos: '11A',
|
||||
authorizedRank: 'o2',
|
||||
isKeyLeader: true,
|
||||
),
|
||||
SlotDefinition(
|
||||
positionTitle: 'Platoon Sergeant',
|
||||
dutyMos: '11B',
|
||||
authorizedRank: 'e7',
|
||||
isKeyLeader: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final result = instantiator.instantiate(def);
|
||||
|
||||
expect(result.units.length, 1);
|
||||
expect(result.slots.length, 2);
|
||||
expect(result.slots.first.positionTitle, 'Platoon Leader');
|
||||
expect(result.slots.first.isKeyLeader, isTrue);
|
||||
expect(result.slots.first.unitId, result.units.first.unitId);
|
||||
});
|
||||
|
||||
test('all slots reference the correct unit ID', () {
|
||||
const def = UnitDefinition(
|
||||
name: 'Squad',
|
||||
echelon: 'squad',
|
||||
branch: 'infantry',
|
||||
slots: [
|
||||
SlotDefinition(
|
||||
positionTitle: 'Squad Leader',
|
||||
dutyMos: '11B',
|
||||
authorizedRank: 'e6',
|
||||
),
|
||||
SlotDefinition(
|
||||
positionTitle: 'Rifleman',
|
||||
dutyMos: '11B',
|
||||
authorizedRank: 'e3',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final result = instantiator.instantiate(def);
|
||||
final unitId = result.units.first.unitId;
|
||||
|
||||
for (final slot in result.slots) {
|
||||
expect(slot.unitId, unitId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parent-child relationships
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('parent-child relationships', () {
|
||||
test('child unit has correct parentUnitId', () {
|
||||
final def = UnitDefinition(
|
||||
name: 'Battalion',
|
||||
echelon: 'battalion',
|
||||
branch: 'infantry',
|
||||
children: [
|
||||
const UnitDefinition(
|
||||
name: 'Alpha Company',
|
||||
echelon: 'company',
|
||||
branch: 'infantry',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final result = instantiator.instantiate(def);
|
||||
|
||||
expect(result.units.length, 2);
|
||||
final parent = result.units.firstWhere((u) => u.name == 'Battalion');
|
||||
final child = result.units.firstWhere((u) => u.name == 'Alpha Company');
|
||||
expect(child.parentUnitId, parent.unitId);
|
||||
});
|
||||
|
||||
test('deeply nested tree has correct parent chain', () {
|
||||
final def = UnitDefinition(
|
||||
name: 'Brigade',
|
||||
echelon: 'brigade',
|
||||
branch: 'infantry',
|
||||
children: [
|
||||
UnitDefinition(
|
||||
name: 'Battalion',
|
||||
echelon: 'battalion',
|
||||
branch: 'infantry',
|
||||
children: [
|
||||
const UnitDefinition(
|
||||
name: 'Company',
|
||||
echelon: 'company',
|
||||
branch: 'infantry',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final result = instantiator.instantiate(def);
|
||||
|
||||
expect(result.units.length, 3);
|
||||
final brigade = result.units.firstWhere((u) => u.name == 'Brigade');
|
||||
final battalion = result.units.firstWhere((u) => u.name == 'Battalion');
|
||||
final company = result.units.firstWhere((u) => u.name == 'Company');
|
||||
|
||||
expect(battalion.parentUnitId, brigade.unitId);
|
||||
expect(company.parentUnitId, battalion.unitId);
|
||||
});
|
||||
|
||||
test('all generated IDs are unique', () {
|
||||
// Use UUID-style generator to verify uniqueness.
|
||||
int counter = 0;
|
||||
final inst = TemplateInstantiator(idGenerator: () => 'uid-${counter++}');
|
||||
|
||||
final def = UnitDefinition(
|
||||
name: 'Brigade',
|
||||
echelon: 'brigade',
|
||||
branch: 'infantry',
|
||||
slots: const [
|
||||
SlotDefinition(
|
||||
positionTitle: 'Commander',
|
||||
dutyMos: '11A',
|
||||
authorizedRank: 'o6',
|
||||
),
|
||||
],
|
||||
children: [
|
||||
const UnitDefinition(
|
||||
name: 'Battalion',
|
||||
echelon: 'battalion',
|
||||
branch: 'infantry',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final result = inst.instantiate(def);
|
||||
final allIds = [
|
||||
...result.units.map((u) => u.unitId),
|
||||
...result.slots.map((s) => s.slotId),
|
||||
];
|
||||
|
||||
expect(allIds.toSet().length, allIds.length);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Doctrinal templates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('doctrinal templates', () {
|
||||
test('MBCT instantiates with units and slots', () {
|
||||
final result = instantiator.instantiate(DoctrinalTemplates.mbct);
|
||||
|
||||
expect(result.units, isNotEmpty);
|
||||
expect(result.slots, isNotEmpty);
|
||||
// MBCT has a brigade root
|
||||
expect(
|
||||
result.units.any((u) => u.name == 'Mobile Brigade Combat Team'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('MBCT root unit has no parent', () {
|
||||
final result = instantiator.instantiate(DoctrinalTemplates.mbct);
|
||||
final roots = result.units.where((u) => u.parentUnitId == null).toList();
|
||||
expect(roots.length, 1);
|
||||
expect(roots.first.name, 'Mobile Brigade Combat Team');
|
||||
});
|
||||
|
||||
test('SBCT instantiates with units and slots', () {
|
||||
final result = instantiator.instantiate(DoctrinalTemplates.sbct);
|
||||
expect(result.units, isNotEmpty);
|
||||
expect(result.slots, isNotEmpty);
|
||||
});
|
||||
|
||||
test('ABCT instantiates with units and slots', () {
|
||||
final result = instantiator.instantiate(DoctrinalTemplates.abct);
|
||||
expect(result.units, isNotEmpty);
|
||||
expect(result.slots, isNotEmpty);
|
||||
});
|
||||
|
||||
test('CAB Heavy instantiates with units and slots', () {
|
||||
final result = instantiator.instantiate(DoctrinalTemplates.cabHeavy);
|
||||
expect(result.units, isNotEmpty);
|
||||
expect(result.slots, isNotEmpty);
|
||||
});
|
||||
|
||||
test('CAB Light instantiates with units and slots', () {
|
||||
final result = instantiator.instantiate(DoctrinalTemplates.cabLight);
|
||||
expect(result.units, isNotEmpty);
|
||||
expect(result.slots, isNotEmpty);
|
||||
});
|
||||
|
||||
test('all template units have valid parent references', () {
|
||||
for (final entry in DoctrinalTemplates.all.entries) {
|
||||
final result = instantiator.instantiate(entry.value);
|
||||
final unitIds = result.units.map((u) => u.unitId).toSet();
|
||||
|
||||
for (final unit in result.units) {
|
||||
if (unit.parentUnitId != null) {
|
||||
expect(
|
||||
unitIds.contains(unit.parentUnitId),
|
||||
isTrue,
|
||||
reason: '${entry.key}: unit "${unit.name}" references unknown parent '
|
||||
'${unit.parentUnitId}',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('all template slots reference existing units', () {
|
||||
for (final entry in DoctrinalTemplates.all.entries) {
|
||||
final result = instantiator.instantiate(entry.value);
|
||||
final unitIds = result.units.map((u) => u.unitId).toSet();
|
||||
|
||||
for (final slot in result.slots) {
|
||||
expect(
|
||||
unitIds.contains(slot.unitId),
|
||||
isTrue,
|
||||
reason: '${entry.key}: slot "${slot.positionTitle}" references unknown '
|
||||
'unit ${slot.unitId}',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('MBCT has key leader slots', () {
|
||||
final result = instantiator.instantiate(DoctrinalTemplates.mbct);
|
||||
expect(result.slots.any((s) => s.isKeyLeader), isTrue);
|
||||
});
|
||||
|
||||
test('DoctrinalTemplates.all contains all five templates', () {
|
||||
expect(
|
||||
DoctrinalTemplates.all.keys,
|
||||
containsAll([
|
||||
'mbct',
|
||||
'sbct',
|
||||
'abct',
|
||||
'cab_heavy',
|
||||
'cab_light',
|
||||
]));
|
||||
});
|
||||
|
||||
test('DoctrinalTemplates.displayName returns non-empty strings', () {
|
||||
for (final key in DoctrinalTemplates.all.keys) {
|
||||
expect(DoctrinalTemplates.displayName(key), isNotEmpty);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
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/value_objects/duty_mos.dart';
|
||||
import 'package:uto_manager/infrastructure/codecs/unit_tree_codec.dart';
|
||||
|
||||
void main() {
|
||||
const codec = UnitTreeCodec();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final brigade = UnitNode(
|
||||
unitId: 'bde-1',
|
||||
echelon: Echelon.brigade,
|
||||
branch: DoctrinalBranch.infantry,
|
||||
name: 'Test Brigade',
|
||||
isKeyLeader: false,
|
||||
);
|
||||
|
||||
final battalion = UnitNode(
|
||||
unitId: 'bn-1',
|
||||
echelon: Echelon.battalion,
|
||||
branch: DoctrinalBranch.infantry,
|
||||
parentUnitId: 'bde-1',
|
||||
name: 'Test Battalion',
|
||||
isKeyLeader: false,
|
||||
);
|
||||
|
||||
final slot = PositionSlot(
|
||||
slotId: 'slot-1',
|
||||
unitId: 'bde-1',
|
||||
positionTitle: 'Brigade Commander',
|
||||
dutyMos: DutyMos.unvalidated('11A'),
|
||||
authorizedRank: AuthorizedRank.o6,
|
||||
isKeyLeader: true,
|
||||
);
|
||||
|
||||
final soldier = PersonnelRecord(
|
||||
soldierId: 'p-1',
|
||||
name: 'COL Test',
|
||||
primaryMos: DutyMos.unvalidated('11A'),
|
||||
payGrade: AuthorizedRank.o6,
|
||||
readinessState: ReadinessState.fullyMissionCapable,
|
||||
assignedSlotId: 'slot-1',
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// encode / decode round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('encode / decode round-trip', () {
|
||||
test('empty snapshot round-trips correctly', () {
|
||||
final json = codec.encode(units: [], slots: [], personnel: []);
|
||||
final snapshot = codec.decode(json);
|
||||
|
||||
expect(snapshot.units, isEmpty);
|
||||
expect(snapshot.slots, isEmpty);
|
||||
expect(snapshot.personnel, isEmpty);
|
||||
});
|
||||
|
||||
test('units round-trip with all fields preserved', () {
|
||||
final json = codec.encode(
|
||||
units: [brigade, battalion],
|
||||
slots: [],
|
||||
personnel: [],
|
||||
);
|
||||
final snapshot = codec.decode(json);
|
||||
|
||||
expect(snapshot.units.length, 2);
|
||||
|
||||
final decodedBde = snapshot.units.firstWhere((u) => u.unitId == 'bde-1');
|
||||
expect(decodedBde.name, brigade.name);
|
||||
expect(decodedBde.echelon, brigade.echelon);
|
||||
expect(decodedBde.branch, brigade.branch);
|
||||
expect(decodedBde.parentUnitId, isNull);
|
||||
expect(decodedBde.isKeyLeader, brigade.isKeyLeader);
|
||||
|
||||
final decodedBn = snapshot.units.firstWhere((u) => u.unitId == 'bn-1');
|
||||
expect(decodedBn.parentUnitId, 'bde-1');
|
||||
});
|
||||
|
||||
test('slots round-trip with all fields preserved', () {
|
||||
final json = codec.encode(
|
||||
units: [brigade],
|
||||
slots: [slot],
|
||||
personnel: [],
|
||||
);
|
||||
final snapshot = codec.decode(json);
|
||||
|
||||
expect(snapshot.slots.length, 1);
|
||||
final decodedSlot = snapshot.slots.first;
|
||||
expect(decodedSlot.slotId, slot.slotId);
|
||||
expect(decodedSlot.unitId, slot.unitId);
|
||||
expect(decodedSlot.positionTitle, slot.positionTitle);
|
||||
expect(decodedSlot.dutyMos.code, slot.dutyMos.code);
|
||||
expect(decodedSlot.authorizedRank, slot.authorizedRank);
|
||||
expect(decodedSlot.isKeyLeader, slot.isKeyLeader);
|
||||
expect(decodedSlot.assignedPersonnelId, isNull);
|
||||
});
|
||||
|
||||
test('personnel round-trip with all fields preserved', () {
|
||||
final json = codec.encode(
|
||||
units: [],
|
||||
slots: [],
|
||||
personnel: [soldier],
|
||||
);
|
||||
final snapshot = codec.decode(json);
|
||||
|
||||
expect(snapshot.personnel.length, 1);
|
||||
final decoded = snapshot.personnel.first;
|
||||
expect(decoded.soldierId, soldier.soldierId);
|
||||
expect(decoded.name, soldier.name);
|
||||
expect(decoded.primaryMos.code, soldier.primaryMos.code);
|
||||
expect(decoded.payGrade, soldier.payGrade);
|
||||
expect(decoded.readinessState, soldier.readinessState);
|
||||
expect(decoded.assignedSlotId, soldier.assignedSlotId);
|
||||
});
|
||||
|
||||
test('full snapshot round-trips correctly', () {
|
||||
final json = codec.encode(
|
||||
units: [brigade, battalion],
|
||||
slots: [slot],
|
||||
personnel: [soldier],
|
||||
);
|
||||
final snapshot = codec.decode(json);
|
||||
|
||||
expect(snapshot.units.length, 2);
|
||||
expect(snapshot.slots.length, 1);
|
||||
expect(snapshot.personnel.length, 1);
|
||||
});
|
||||
|
||||
test('encodeToMap / decodeFromMap round-trip', () {
|
||||
final map = codec.encodeToMap(
|
||||
units: [brigade],
|
||||
slots: [slot],
|
||||
personnel: [],
|
||||
);
|
||||
final snapshot = codec.decodeFromMap(map);
|
||||
|
||||
expect(snapshot.units.length, 1);
|
||||
expect(snapshot.slots.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema version
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('schema version', () {
|
||||
test('encoded JSON contains version field', () {
|
||||
final json = codec.encode(units: [], slots: [], personnel: []);
|
||||
expect(json, contains('"version"'));
|
||||
expect(json, contains('"exportedAt"'));
|
||||
});
|
||||
|
||||
test('throws FormatException for wrong schema version', () {
|
||||
final map = codec.encodeToMap(units: [], slots: [], personnel: []);
|
||||
map['version'] = 99;
|
||||
|
||||
expect(
|
||||
() => codec.decodeFromMap(map),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws FormatException for missing version', () {
|
||||
final map = codec.encodeToMap(units: [], slots: [], personnel: []);
|
||||
map.remove('version');
|
||||
|
||||
expect(
|
||||
() => codec.decodeFromMap(map),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws FormatException for non-object JSON root', () {
|
||||
expect(
|
||||
() => codec.decode('[1, 2, 3]'),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Null / optional fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('optional fields', () {
|
||||
test('null parentUnitId is preserved through round-trip', () {
|
||||
final json = codec.encode(units: [brigade], slots: [], personnel: []);
|
||||
final snapshot = codec.decode(json);
|
||||
expect(snapshot.units.first.parentUnitId, isNull);
|
||||
});
|
||||
|
||||
test('null assignedPersonnelId is preserved through round-trip', () {
|
||||
final json = codec.encode(units: [], slots: [slot], personnel: []);
|
||||
final snapshot = codec.decode(json);
|
||||
expect(snapshot.slots.first.assignedPersonnelId, isNull);
|
||||
});
|
||||
|
||||
test('null assignedSlotId is preserved through round-trip', () {
|
||||
final unassigned = PersonnelRecord(
|
||||
soldierId: 'p-2',
|
||||
name: 'SGT Free',
|
||||
primaryMos: DutyMos.unvalidated('11B'),
|
||||
payGrade: AuthorizedRank.e5,
|
||||
readinessState: ReadinessState.unknown,
|
||||
);
|
||||
final json = codec.encode(units: [], slots: [], personnel: [unassigned]);
|
||||
final snapshot = codec.decode(json);
|
||||
expect(snapshot.personnel.first.assignedSlotId, isNull);
|
||||
});
|
||||
|
||||
test('qualifications list round-trips correctly', () {
|
||||
final soldierWithQuals = PersonnelRecord(
|
||||
soldierId: 'p-3',
|
||||
name: 'SGT Qualified',
|
||||
primaryMos: DutyMos.unvalidated('11B'),
|
||||
payGrade: AuthorizedRank.e5,
|
||||
readinessState: ReadinessState.fullyMissionCapable,
|
||||
qualifications: ['Airborne', 'Ranger', 'SERE'],
|
||||
);
|
||||
final json = codec.encode(
|
||||
units: [],
|
||||
slots: [],
|
||||
personnel: [soldierWithQuals],
|
||||
);
|
||||
final snapshot = codec.decode(json);
|
||||
expect(
|
||||
snapshot.personnel.first.qualifications,
|
||||
containsAll(['Airborne', 'Ranger', 'SERE']),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -40,7 +40,11 @@ void main() {
|
|||
slotRepository: slotRepo,
|
||||
assignmentService: assignmentService,
|
||||
);
|
||||
templateCubit = TemplateCubit(dao: db.templateDao);
|
||||
templateCubit = TemplateCubit(
|
||||
dao: db.templateDao,
|
||||
unitRepository: unitRepo,
|
||||
slotRepository: slotRepo,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
|
|
|
|||
Loading…
Reference in New Issue