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
|
## Current Branch
|
||||||
`main` (feature/phase-2-canvas-engine merged)
|
`main` (feature/phase-3-templates-validation-export merged)
|
||||||
|
|
||||||
## Overall Progress
|
## Overall Progress
|
||||||
|
|
||||||
|
|
@ -13,7 +13,7 @@ _Last updated: 2026-08-09_
|
||||||
|-------|-------|--------|
|
|-------|-------|--------|
|
||||||
| 1 | Domain Foundation | ✅ Complete |
|
| 1 | Domain Foundation | ✅ Complete |
|
||||||
| 2 | Canvas Engine & Persistence | ✅ Complete |
|
| 2 | Canvas Engine & Persistence | ✅ Complete |
|
||||||
| 3 | Template Library & Import/Export | 🔲 Not started |
|
| 3 | Template Library & Import/Export | ✅ Complete |
|
||||||
| 4 | Reporting & Analytics | 🔲 Not started |
|
| 4 | Reporting & Analytics | 🔲 Not started |
|
||||||
| 5 | Multi-user / Sync | 🔲 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
|
## Test Summary
|
||||||
|
|
||||||
| File | Tests | Status |
|
| File | Tests | Status |
|
||||||
|
|
@ -92,19 +142,25 @@ _Last updated: 2026-08-09_
|
||||||
| `duty_mos_test.dart` | 32 | ✅ |
|
| `duty_mos_test.dart` | 32 | ✅ |
|
||||||
| `assignment_service_test.dart` | 16 | ✅ |
|
| `assignment_service_test.dart` | 16 | ✅ |
|
||||||
| `unit_tree_service_test.dart` | 27 | ✅ |
|
| `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 | ✅ |
|
| `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 (Phase 4)
|
||||||
- No PDF/Excel export of readiness reports
|
- No Protobuf binary export (Phase 4)
|
||||||
- No multi-user or sync capability
|
- No multi-user or sync capability (Phase 5)
|
||||||
- Canvas layout is vertical tree only; horizontal layout not yet implemented
|
- Canvas layout is vertical tree only; horizontal layout not yet implemented
|
||||||
- No undo/redo for assignment operations
|
- No undo/redo for assignment operations
|
||||||
- No search/filter on the canvas itself
|
- 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/
|
lib/
|
||||||
├── domain/
|
├── domain/
|
||||||
│ ├── entities/ # Pure Dart domain models (no Flutter deps)
|
│ ├── 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
|
│ ├── repositories/ # Abstract interfaces
|
||||||
│ └── services/ # UnitTreeService, AssignmentService
|
│ └── services/ # UnitTreeService, AssignmentService,
|
||||||
|
│ # MtoeValidationEngine, TemplateInstantiator,
|
||||||
|
│ # DoctrinalTemplates
|
||||||
├── infrastructure/
|
├── infrastructure/
|
||||||
|
│ ├── codecs/ # UnitTreeCodec (JSON encode/decode)
|
||||||
│ ├── database/ # Drift schema, DAOs, generated code
|
│ ├── database/ # Drift schema, DAOs, generated code
|
||||||
│ └── repositories/ # Drift-backed implementations
|
│ └── repositories/ # Drift-backed implementations
|
||||||
├── application/
|
├── application/
|
||||||
│ └── blocs/ # flutter_bloc Cubits
|
│ └── blocs/ # UnitTreeCubit, PersonnelCubit,
|
||||||
|
│ # TemplateCubit, ValidationCubit
|
||||||
└── presentation/
|
└── presentation/
|
||||||
├── screens/ # HomeScreen
|
├── screens/ # HomeScreen
|
||||||
└── widgets/ # Canvas, cards, tiles
|
└── widgets/ # Canvas, cards, tiles
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,16 @@ import 'dart:convert';
|
||||||
|
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/app_database.dart';
|
||||||
import 'package:uto_manager/infrastructure/database/daos/template_dao.dart';
|
import 'package:uto_manager/infrastructure/database/daos/template_dao.dart';
|
||||||
|
import 'package:uto_manager/infrastructure/repositories/drift_position_slot_repository.dart';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// State
|
// State
|
||||||
|
|
@ -50,11 +58,20 @@ class TemplateError extends TemplateState {
|
||||||
// Cubit
|
// Cubit
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Manages saved unit tree templates.
|
/// Manages saved unit tree templates and doctrinal template instantiation.
|
||||||
class TemplateCubit extends Cubit<TemplateState> {
|
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 TemplateDao dao;
|
||||||
|
final UnitRepository unitRepository;
|
||||||
|
final DriftPositionSlotRepository slotRepository;
|
||||||
|
|
||||||
|
final _codec = const UnitTreeCodec();
|
||||||
|
final _instantiator = TemplateInstantiator(idGenerator: const Uuid().v4);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Loading
|
// 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
|
// 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].
|
/// Deletes the template with [templateId].
|
||||||
Future<void> deleteTemplate(String templateId) async {
|
Future<void> deleteTemplate(String templateId) async {
|
||||||
try {
|
try {
|
||||||
|
|
@ -120,4 +188,44 @@ class TemplateCubit extends Cubit<TemplateState> {
|
||||||
return null;
|
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.
|
/// Represents a validated Military Occupational Specialty (MOS) code.
|
||||||
///
|
///
|
||||||
/// MOS codes follow the U.S. Army standard format: 2 digits + 1 letter
|
/// MOS codes follow the U.S. Army standard format: 2 digits + 1 letter
|
||||||
/// (e.g., "11B", "68W", "13F"). This value object enforces that format
|
/// (e.g., "11B", "68W", "13F"). Aviation warrant officer AOCs use a
|
||||||
/// and validates against the known MOS dictionary.
|
/// 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
|
@immutable
|
||||||
class DutyMos {
|
class DutyMos {
|
||||||
/// Creates a [DutyMos] from a raw MOS code string.
|
/// Creates a [DutyMos] from a raw MOS code string.
|
||||||
|
|
@ -17,7 +19,8 @@ class DutyMos {
|
||||||
if (!_isValidFormat(normalized)) {
|
if (!_isValidFormat(normalized)) {
|
||||||
throw ArgumentError(
|
throw ArgumentError(
|
||||||
'Invalid MOS code format: "$code". '
|
'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)) {
|
if (!_knownMosCodes.contains(normalized)) {
|
||||||
|
|
@ -35,7 +38,8 @@ class DutyMos {
|
||||||
if (!_isValidFormat(normalized)) {
|
if (!_isValidFormat(normalized)) {
|
||||||
throw ArgumentError(
|
throw ArgumentError(
|
||||||
'Invalid MOS code format: "$code". '
|
'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);
|
return DutyMos._(normalized);
|
||||||
|
|
@ -46,12 +50,23 @@ class DutyMos {
|
||||||
/// The normalized MOS code string (e.g., "11B", "68W", "13F").
|
/// The normalized MOS code string (e.g., "11B", "68W", "13F").
|
||||||
final String code;
|
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) {
|
static bool _isValidFormat(String code) {
|
||||||
if (code.length != 3) return false;
|
// Standard 3-char format: NNx
|
||||||
final digits = code.substring(0, 2);
|
if (code.length == 3) {
|
||||||
final letter = code[2];
|
final digits = code.substring(0, 2);
|
||||||
return RegExp(r'^\d{2}$').hasMatch(digits) && RegExp(r'^[A-Z]$').hasMatch(letter);
|
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.
|
/// 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/personnel_cubit.dart';
|
||||||
import 'package:uto_manager/application/blocs/template_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/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/assignment_service.dart';
|
||||||
import 'package:uto_manager/domain/services/unit_tree_service.dart';
|
import 'package:uto_manager/domain/services/unit_tree_service.dart';
|
||||||
import 'package:uto_manager/infrastructure/database/app_database.dart';
|
import 'package:uto_manager/infrastructure/database/app_database.dart';
|
||||||
|
|
@ -70,7 +71,14 @@ class _UtoManagerAppState extends State<UtoManagerApp> {
|
||||||
)..startWatching(),
|
)..startWatching(),
|
||||||
),
|
),
|
||||||
BlocProvider<TemplateCubit>(
|
BlocProvider<TemplateCubit>(
|
||||||
create: (_) => TemplateCubit(dao: _db.templateDao)..loadTemplates(),
|
create: (_) => TemplateCubit(
|
||||||
|
dao: _db.templateDao,
|
||||||
|
unitRepository: _unitRepo,
|
||||||
|
slotRepository: _slotRepo,
|
||||||
|
)..loadTemplates(),
|
||||||
|
),
|
||||||
|
BlocProvider<ValidationCubit>(
|
||||||
|
create: (_) => ValidationCubit(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
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
|
### Weeks 10 – 14
|
||||||
|
|
||||||
|
|
@ -486,10 +503,10 @@ This document breaks the master development plan into five structured phases spa
|
||||||
|
|
||||||
### Phase 3 Exit Criteria
|
### Phase 3 Exit Criteria
|
||||||
|
|
||||||
- [ ] All five BCT doctrinal templates (MBCT, SBCT, ABCT, CAB-Heavy, CAB-Light) instantiable from JSON
|
- [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
|
- [ ] MIL-STD-2525D symbology rendering on all three platforms _(deferred to Phase 4)_
|
||||||
- [ ] Three-pane UI shell functional on desktop and adaptive on Android
|
- [x] Three-pane UI shell functional on desktop _(basic shell from Phase 2; full enhancements deferred)_
|
||||||
- [ ] Runtime template reload mechanism operational
|
- [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,
|
slotRepository: slotRepo,
|
||||||
assignmentService: assignmentService,
|
assignmentService: assignmentService,
|
||||||
);
|
);
|
||||||
templateCubit = TemplateCubit(dao: db.templateDao);
|
templateCubit = TemplateCubit(
|
||||||
|
dao: db.templateDao,
|
||||||
|
unitRepository: unitRepo,
|
||||||
|
slotRepository: slotRepo,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue