merge(phase-3): template library, validation engine, JSON codec into main

This commit is contained in:
UTO Manager Dev 2026-08-09 09:35:03 -04:00
commit fec3b74f10
14 changed files with 3922 additions and 28 deletions

View File

@ -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

View File

@ -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;
}
}
}

View File

@ -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

View File

@ -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);
}
}

View File

@ -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);
}
}
}

View File

@ -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,13 +50,24 @@ 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;
// 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.
static bool isKnown(String code) {

View File

@ -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;
}

View File

@ -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(

View File

@ -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`)
---

View File

@ -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');
});
});
}

View File

@ -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);
}
});
});
}

View File

@ -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']),
);
});
});
}

View File

@ -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 {