Compare commits
No commits in common. "6ee71775fa1239378723b0bc626399dab67ae00b" and "94f8317096b5060c308694b0961bf055aa7472e5" have entirely different histories.
6ee71775fa
...
94f8317096
|
|
@ -1,12 +1,11 @@
|
||||||
# UTO Manager — Development Status
|
# UTO Manager — Development Status
|
||||||
|
|
||||||
Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main`
|
_Last updated: 2026-08-09_
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current Branch
|
## Current Branch
|
||||||
|
`main` (feature/phase-4-reporting-analytics merged)
|
||||||
`main` (fix/phase-4-analyzer-infos merged)
|
|
||||||
|
|
||||||
## Overall Progress
|
## Overall Progress
|
||||||
|
|
||||||
|
|
@ -23,7 +22,6 @@ Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main
|
||||||
## Phase 1 — Domain Foundation ✅
|
## Phase 1 — Domain Foundation ✅
|
||||||
|
|
||||||
**Completed items:**
|
**Completed items:**
|
||||||
|
|
||||||
- `Echelon` entity with doctrinal hierarchy validation (`isValidParent`, `isValidChild`)
|
- `Echelon` entity with doctrinal hierarchy validation (`isValidParent`, `isValidChild`)
|
||||||
- `DoctrinalBranch` entity with display names
|
- `DoctrinalBranch` entity with display names
|
||||||
- `AuthorizedRank` entity with pay grade, category flags, `meetsMinimum`, `gradeLevel`
|
- `AuthorizedRank` entity with pay grade, category flags, `meetsMinimum`, `gradeLevel`
|
||||||
|
|
@ -44,14 +42,12 @@ Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main
|
||||||
|
|
||||||
**Completed items:**
|
**Completed items:**
|
||||||
|
|
||||||
### Domain Services (Phase 2)
|
### Domain Services
|
||||||
|
|
||||||
- `UnitTreeService` — `buildTree`, `getSubtree`, `getAncestors`, `validateReparent`,
|
- `UnitTreeService` — `buildTree`, `getSubtree`, `getAncestors`, `validateReparent`,
|
||||||
`validateAssignment`, `calculateFillRate`, `getVacantKeyLeaderSlots`, `getMosMismatches`
|
`validateAssignment`, `calculateFillRate`, `getVacantKeyLeaderSlots`, `getMosMismatches`
|
||||||
- `AssignmentService` — `assign`, `unassign`, `aggregateReadiness` with `ReadinessColor`
|
- `AssignmentService` — `assign`, `unassign`, `aggregateReadiness` with `ReadinessColor`
|
||||||
|
|
||||||
### Infrastructure — Database
|
### Infrastructure — Database
|
||||||
|
|
||||||
- Drift schema: `Units`, `PositionSlots`, `Personnel`, `Templates` tables
|
- Drift schema: `Units`, `PositionSlots`, `Personnel`, `Templates` tables
|
||||||
- `AppDatabase` with `forTesting(NativeDatabase.memory())` constructor
|
- `AppDatabase` with `forTesting(NativeDatabase.memory())` constructor
|
||||||
- `UnitDao` — full CRUD + watch streams
|
- `UnitDao` — full CRUD + watch streams
|
||||||
|
|
@ -61,19 +57,16 @@ Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main
|
||||||
- build_runner code generation (`.g.dart` files)
|
- build_runner code generation (`.g.dart` files)
|
||||||
|
|
||||||
### Infrastructure — Repositories
|
### Infrastructure — Repositories
|
||||||
|
|
||||||
- `DriftUnitRepository` — implements `UnitRepository` with domain↔Drift mapping
|
- `DriftUnitRepository` — implements `UnitRepository` with domain↔Drift mapping
|
||||||
- `DriftPersonnelRepository` — implements `PersonnelRepository` with domain↔Drift mapping
|
- `DriftPersonnelRepository` — implements `PersonnelRepository` with domain↔Drift mapping
|
||||||
- `DriftPositionSlotRepository` — concrete slot repository
|
- `DriftPositionSlotRepository` — concrete slot repository
|
||||||
|
|
||||||
### Application — BLoC (Phase 2)
|
### Application — BLoC
|
||||||
|
|
||||||
- `UnitTreeCubit` / `UnitTreeState` (sealed) — tree loading, selection, reparent, subtree delete
|
- `UnitTreeCubit` / `UnitTreeState` (sealed) — tree loading, selection, reparent, subtree delete
|
||||||
- `PersonnelCubit` / `PersonnelState` (sealed) — roster loading, filter, assign/unassign
|
- `PersonnelCubit` / `PersonnelState` (sealed) — roster loading, filter, assign/unassign
|
||||||
- `TemplateCubit` / `TemplateState` (sealed) — template save/load/delete
|
- `TemplateCubit` / `TemplateState` (sealed) — template save/load/delete
|
||||||
|
|
||||||
### Presentation (Phase 2)
|
### Presentation
|
||||||
|
|
||||||
- `HomeScreen` — three-pane adaptive layout (personnel | canvas | inspector)
|
- `HomeScreen` — three-pane adaptive layout (personnel | canvas | inspector)
|
||||||
- `UtoCanvasWidget` — `InteractiveViewer` pan/zoom org chart with drag-and-drop reparent
|
- `UtoCanvasWidget` — `InteractiveViewer` pan/zoom org chart with drag-and-drop reparent
|
||||||
- `UnitNodeCard` — echelon-colored card with fill rate progress bar
|
- `UnitNodeCard` — echelon-colored card with fill rate progress bar
|
||||||
|
|
@ -81,8 +74,7 @@ Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main
|
||||||
- `PersonnelListTile` — `Draggable` soldier tile for assignment
|
- `PersonnelListTile` — `Draggable` soldier tile for assignment
|
||||||
- Status bar with live unit/slot fill counts
|
- Status bar with live unit/slot fill counts
|
||||||
|
|
||||||
### App Wiring (Phase 2)
|
### App Wiring
|
||||||
|
|
||||||
- `main.dart` — `MultiBlocProvider` wiring all cubits with production database
|
- `main.dart` — `MultiBlocProvider` wiring all cubits with production database
|
||||||
|
|
||||||
**Test count (Phase 2):** 77 new tests (137 total)
|
**Test count (Phase 2):** 77 new tests (137 total)
|
||||||
|
|
@ -93,8 +85,7 @@ Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main
|
||||||
|
|
||||||
**Completed items:**
|
**Completed items:**
|
||||||
|
|
||||||
### Domain Services (Phase 3)
|
### Domain Services
|
||||||
|
|
||||||
- `DoctrinalTemplates` — Pure-Dart library of all five 2026 BCT templates:
|
- `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)
|
- `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)
|
- `sbct` — Stryker Brigade Combat Team (3× Stryker Infantry Battalions, Cav Recon Squadron, FA Battalion, Engineer Battalion, Support Battalion)
|
||||||
|
|
@ -111,16 +102,14 @@ Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main
|
||||||
- **V006** Structural hierarchy violation (critical)
|
- **V006** Structural hierarchy violation (critical)
|
||||||
- **V007** Manning below 70% (warning)
|
- **V007** Manning below 70% (warning)
|
||||||
|
|
||||||
### Infrastructure — Codecs (Phase 3)
|
### Infrastructure — Codecs
|
||||||
|
|
||||||
- `UnitTreeCodec` — JSON encode/decode for full unit tree snapshots (units + slots + personnel)
|
- `UnitTreeCodec` — JSON encode/decode for full unit tree snapshots (units + slots + personnel)
|
||||||
- Schema version 1 with `exportedAt` timestamp
|
- Schema version 1 with `exportedAt` timestamp
|
||||||
- `encode` / `decode` (JSON string)
|
- `encode` / `decode` (JSON string)
|
||||||
- `encodeToMap` / `decodeFromMap` (Map)
|
- `encodeToMap` / `decodeFromMap` (Map)
|
||||||
- Handles all optional fields (null parentUnitId, null assignedPersonnelId, qualifications list)
|
- Handles all optional fields (null parentUnitId, null assignedPersonnelId, qualifications list)
|
||||||
|
|
||||||
### Application — BLoC (Phase 3)
|
### Application — BLoC
|
||||||
|
|
||||||
- `TemplateCubit` — Extended with:
|
- `TemplateCubit` — Extended with:
|
||||||
- `instantiateDoctrinalTemplate(key)` — instantiates any of the five BCT templates into the live database
|
- `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
|
- `saveSnapshot(name, bctType, units, slots)` — saves current tree as a named template
|
||||||
|
|
@ -131,12 +120,10 @@ Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main
|
||||||
- `clear()` — resets to `ValidationInitial`
|
- `clear()` — resets to `ValidationInitial`
|
||||||
- `ValidationComplete` exposes `.critical`, `.warnings`, `.advisories`, `.findingsForUnit(id)`, `.isClean`
|
- `ValidationComplete` exposes `.critical`, `.warnings`, `.advisories`, `.findingsForUnit(id)`, `.isClean`
|
||||||
|
|
||||||
### Domain Value Objects (Phase 3)
|
### 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
|
- `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 (Phase 3)
|
### App Wiring
|
||||||
|
|
||||||
- `main.dart` — Added `ValidationCubit` to `MultiBlocProvider`
|
- `main.dart` — Added `ValidationCubit` to `MultiBlocProvider`
|
||||||
- `TemplateCubit` — Now requires `unitRepository` and `slotRepository` parameters
|
- `TemplateCubit` — Now requires `unitRepository` and `slotRepository` parameters
|
||||||
|
|
||||||
|
|
@ -148,8 +135,7 @@ Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main
|
||||||
|
|
||||||
**Completed items:**
|
**Completed items:**
|
||||||
|
|
||||||
### Domain Services (Phase 4)
|
### Domain Services
|
||||||
|
|
||||||
- `CommandEventStore` — In-memory append-only event log for all tree mutations:
|
- `CommandEventStore` — In-memory append-only event log for all tree mutations:
|
||||||
- `CommandEvent` with `eventId`, `type`, `payload`, `occurredAt`, `isSynced`
|
- `CommandEvent` with `eventId`, `type`, `payload`, `occurredAt`, `isSynced`
|
||||||
- `CommandEventType` enum: `attachNode`, `detachNode`, `assignPersonnel`, `unassignPersonnel`, `modifySlot`
|
- `CommandEventType` enum: `attachNode`, `detachNode`, `assignPersonnel`, `unassignPersonnel`, `modifySlot`
|
||||||
|
|
@ -165,8 +151,7 @@ Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main
|
||||||
- `ValidationResponse` with `findings`, `hasError`, optional `error` string
|
- `ValidationResponse` with `findings`, `hasError`, optional `error` string
|
||||||
- Results guaranteed to match synchronous engine output
|
- Results guaranteed to match synchronous engine output
|
||||||
|
|
||||||
### Infrastructure — Codecs (Phase 4)
|
### Infrastructure — Codecs
|
||||||
|
|
||||||
- `ProtobufCodec` — Binary encode/decode for unit tree snapshots:
|
- `ProtobufCodec` — Binary encode/decode for unit tree snapshots:
|
||||||
- 12-byte header: magic `UTOB` (0x55544F42) + version 1 (big-endian uint32)
|
- 12-byte header: magic `UTOB` (0x55544F42) + version 1 (big-endian uint32)
|
||||||
- Payload: zlib-compressed JSON (reuses `UnitTreeCodec` for serialization)
|
- Payload: zlib-compressed JSON (reuses `UnitTreeCodec` for serialization)
|
||||||
|
|
@ -183,16 +168,14 @@ Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main
|
||||||
- Supports `PdfPageSize.letter` (612×792), `a4` (595×842), `tabloid` (792×1224)
|
- Supports `PdfPageSize.letter` (612×792), `a4` (595×842), `tabloid` (792×1224)
|
||||||
- Unit names truncated to 18 chars; all echelon levels render without error
|
- Unit names truncated to 18 chars; all echelon levels render without error
|
||||||
|
|
||||||
### Application — BLoC (Phase 4)
|
### Application — BLoC
|
||||||
|
|
||||||
- `ValidationCubit` — Extended with isolate-backed validation:
|
- `ValidationCubit` — Extended with isolate-backed validation:
|
||||||
- `startIsolate()` / `disposeIsolate()` lifecycle hooks
|
- `startIsolate()` / `disposeIsolate()` lifecycle hooks
|
||||||
- `validateAsync(units, slots, personnel)` — delegates to `IsolateValidationRunner`
|
- `validateAsync(units, slots, personnel)` — delegates to `IsolateValidationRunner`
|
||||||
- Falls back to synchronous engine if isolate not started
|
- Falls back to synchronous engine if isolate not started
|
||||||
- `ValidationState` extended with `isRunning` flag
|
- `ValidationState` extended with `isRunning` flag
|
||||||
|
|
||||||
### Presentation (Phase 4)
|
### Presentation
|
||||||
|
|
||||||
- `HomeScreen` — Updated with Phase 4 UI:
|
- `HomeScreen` — Updated with Phase 4 UI:
|
||||||
- Validation node indicators on canvas: red border for critical findings, amber for warnings
|
- Validation node indicators on canvas: red border for critical findings, amber for warnings
|
||||||
- "Export PDF" action in app bar → calls `PdfChartExporter`, triggers file save dialog
|
- "Export PDF" action in app bar → calls `PdfChartExporter`, triggers file save dialog
|
||||||
|
|
@ -241,7 +224,7 @@ Last updated: 2026-08-09 — branch `fix/phase-4-analyzer-infos` merged to `main
|
||||||
|
|
||||||
## Architecture Notes
|
## Architecture Notes
|
||||||
|
|
||||||
```text
|
```
|
||||||
lib/
|
lib/
|
||||||
├── domain/
|
├── domain/
|
||||||
│ ├── entities/ # Pure Dart domain models (no Flutter deps)
|
│ ├── entities/ # Pure Dart domain models (no Flutter deps)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ 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/position_slot.dart' as domain;
|
||||||
import 'package:uto_manager/domain/entities/unit_node.dart';
|
import 'package:uto_manager/domain/entities/unit_node.dart';
|
||||||
import 'package:uto_manager/domain/repositories/unit_repository.dart';
|
import 'package:uto_manager/domain/repositories/unit_repository.dart';
|
||||||
|
|
@ -11,7 +12,6 @@ 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';
|
import 'package:uto_manager/infrastructure/repositories/drift_position_slot_repository.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// State
|
// State
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ class UnitTreeCubit extends Cubit<UnitTreeState> {
|
||||||
final currentSelection =
|
final currentSelection =
|
||||||
state is UnitTreeLoaded ? (state as UnitTreeLoaded).selectedUnitId : null;
|
state is UnitTreeLoaded ? (state as UnitTreeLoaded).selectedUnitId : null;
|
||||||
emit(UnitTreeLoaded(
|
emit(UnitTreeLoaded(
|
||||||
units: _units, slots: _slots, roots: roots, selectedUnitId: currentSelection,),);
|
units: _units, slots: _slots, roots: roots, selectedUnitId: currentSelection));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -34,17 +34,6 @@ class CommandEvent {
|
||||||
this.isSynced = false,
|
this.isSynced = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Deserializes a [CommandEvent] from a JSON-compatible map.
|
|
||||||
factory CommandEvent.fromJson(Map<String, dynamic> map) {
|
|
||||||
return CommandEvent(
|
|
||||||
eventId: map['eventId'] as String,
|
|
||||||
type: CommandEventType.values.firstWhere((t) => t.name == map['type']),
|
|
||||||
payload: map['payload'] as Map<String, dynamic>,
|
|
||||||
occurredAt: DateTime.parse(map['occurredAt'] as String),
|
|
||||||
isSynced: map['isSynced'] as bool? ?? false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unique identifier for this event (UUID v4).
|
/// Unique identifier for this event (UUID v4).
|
||||||
final String eventId;
|
final String eventId;
|
||||||
|
|
||||||
|
|
@ -69,6 +58,17 @@ class CommandEvent {
|
||||||
'isSynced': isSynced,
|
'isSynced': isSynced,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Deserializes a [CommandEvent] from a JSON-compatible map.
|
||||||
|
factory CommandEvent.fromJson(Map<String, dynamic> map) {
|
||||||
|
return CommandEvent(
|
||||||
|
eventId: map['eventId'] as String,
|
||||||
|
type: CommandEventType.values.firstWhere((t) => t.name == map['type']),
|
||||||
|
payload: (map['payload'] as Map<String, dynamic>),
|
||||||
|
occurredAt: DateTime.parse(map['occurredAt'] as String),
|
||||||
|
isSynced: map['isSynced'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'CommandEvent(id: $eventId, type: ${type.name}, synced: $isSynced)';
|
String toString() => 'CommandEvent(id: $eventId, type: ${type.name}, synced: $isSynced)';
|
||||||
}
|
}
|
||||||
|
|
@ -232,8 +232,15 @@ class DeltaSynchronizer {
|
||||||
final CommandEventStore store;
|
final CommandEventStore store;
|
||||||
|
|
||||||
/// Whether the synchronizer believes it has network connectivity.
|
/// Whether the synchronizer believes it has network connectivity.
|
||||||
/// Set via [isOnline] setter; read via [isOnline] getter.
|
bool _isOnline = true;
|
||||||
bool isOnline = true;
|
|
||||||
|
/// Sets the connectivity state. When set to true, [syncIfOnline] will
|
||||||
|
/// attempt to transmit pending events.
|
||||||
|
// ignore: avoid_setters_without_getters
|
||||||
|
set isOnline(bool value) => _isOnline = value;
|
||||||
|
|
||||||
|
/// Returns true if the synchronizer believes it has network connectivity.
|
||||||
|
bool get isOnline => _isOnline;
|
||||||
|
|
||||||
/// Transmits all pending events if online.
|
/// Transmits all pending events if online.
|
||||||
///
|
///
|
||||||
|
|
@ -243,7 +250,7 @@ class DeltaSynchronizer {
|
||||||
/// In this implementation the transmission is simulated (no real network
|
/// In this implementation the transmission is simulated (no real network
|
||||||
/// call). Replace [_transmit] with a real HTTP/gRPC call in production.
|
/// call). Replace [_transmit] with a real HTTP/gRPC call in production.
|
||||||
Future<int> syncIfOnline() async {
|
Future<int> syncIfOnline() async {
|
||||||
if (!isOnline) return 0;
|
if (!_isOnline) return 0;
|
||||||
final pending = store.pendingEvents;
|
final pending = store.pendingEvents;
|
||||||
if (pending.isEmpty) return 0;
|
if (pending.isEmpty) return 0;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import 'package:uto_manager/domain/entities/position_slot.dart';
|
|
||||||
import 'package:uto_manager/domain/entities/unit_node.dart';
|
|
||||||
import 'package:uto_manager/domain/services/template_instantiator.dart';
|
import 'package:uto_manager/domain/services/template_instantiator.dart';
|
||||||
|
|
||||||
/// Library of 2026 U.S. Army doctrinal force structure templates.
|
/// Library of 2026 U.S. Army doctrinal force structure templates.
|
||||||
|
|
@ -171,11 +169,11 @@ abstract final class DoctrinalTemplates {
|
||||||
_riflePlatoon('1st Platoon'),
|
_riflePlatoon('1st Platoon'),
|
||||||
_riflePlatoon('2nd Platoon'),
|
_riflePlatoon('2nd Platoon'),
|
||||||
_riflePlatoon('3rd Platoon'),
|
_riflePlatoon('3rd Platoon'),
|
||||||
const UnitDefinition(
|
UnitDefinition(
|
||||||
name: 'Weapons Platoon',
|
name: 'Weapons Platoon',
|
||||||
echelon: 'platoon',
|
echelon: 'platoon',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Weapons Platoon Leader',
|
positionTitle: 'Weapons Platoon Leader',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -299,11 +297,11 @@ abstract final class DoctrinalTemplates {
|
||||||
_infantryBattalion('2-1 Mobile Infantry Battalion'),
|
_infantryBattalion('2-1 Mobile Infantry Battalion'),
|
||||||
_infantryBattalion('3-1 Mobile Infantry Battalion'),
|
_infantryBattalion('3-1 Mobile Infantry Battalion'),
|
||||||
// Cavalry Squadron
|
// Cavalry Squadron
|
||||||
const UnitDefinition(
|
UnitDefinition(
|
||||||
name: 'Cavalry Squadron',
|
name: 'Cavalry Squadron',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Squadron Commander',
|
positionTitle: 'Squadron Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -322,7 +320,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'HHT',
|
name: 'HHT',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'HHT Commander',
|
positionTitle: 'HHT Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -341,7 +339,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Alpha Troop',
|
name: 'Alpha Troop',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Troop Commander',
|
positionTitle: 'Troop Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -360,7 +358,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Bravo Troop',
|
name: 'Bravo Troop',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Troop Commander',
|
positionTitle: 'Troop Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -379,7 +377,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Anti-Tank Troop',
|
name: 'Anti-Tank Troop',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Troop Commander',
|
positionTitle: 'Troop Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -397,11 +395,11 @@ abstract final class DoctrinalTemplates {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Field Artillery Battalion
|
// Field Artillery Battalion
|
||||||
const UnitDefinition(
|
UnitDefinition(
|
||||||
name: 'Field Artillery Battalion',
|
name: 'Field Artillery Battalion',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'fires',
|
branch: 'fires',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battalion Commander',
|
positionTitle: 'Battalion Commander',
|
||||||
dutyMos: '13A',
|
dutyMos: '13A',
|
||||||
|
|
@ -425,7 +423,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Alpha Battery (105mm)',
|
name: 'Alpha Battery (105mm)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'fires',
|
branch: 'fires',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battery Commander',
|
positionTitle: 'Battery Commander',
|
||||||
dutyMos: '13A',
|
dutyMos: '13A',
|
||||||
|
|
@ -449,7 +447,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Bravo Battery (105mm)',
|
name: 'Bravo Battery (105mm)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'fires',
|
branch: 'fires',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battery Commander',
|
positionTitle: 'Battery Commander',
|
||||||
dutyMos: '13A',
|
dutyMos: '13A',
|
||||||
|
|
@ -468,7 +466,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Charlie Battery (155mm Towed)',
|
name: 'Charlie Battery (155mm Towed)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'fires',
|
branch: 'fires',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battery Commander',
|
positionTitle: 'Battery Commander',
|
||||||
dutyMos: '13A',
|
dutyMos: '13A',
|
||||||
|
|
@ -486,11 +484,11 @@ abstract final class DoctrinalTemplates {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Multi-Domain Effects Platoon
|
// Multi-Domain Effects Platoon
|
||||||
const UnitDefinition(
|
UnitDefinition(
|
||||||
name: 'Multi-Domain Effects Platoon (MDEP)',
|
name: 'Multi-Domain Effects Platoon (MDEP)',
|
||||||
echelon: 'platoon',
|
echelon: 'platoon',
|
||||||
branch: 'cyberEw',
|
branch: 'cyberEw',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'MDEP Officer',
|
positionTitle: 'MDEP Officer',
|
||||||
dutyMos: '17A',
|
dutyMos: '17A',
|
||||||
|
|
@ -515,11 +513,11 @@ abstract final class DoctrinalTemplates {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Hunter-Killer Platoon
|
// Hunter-Killer Platoon
|
||||||
const UnitDefinition(
|
UnitDefinition(
|
||||||
name: 'Hunter-Killer Platoon (HKP)',
|
name: 'Hunter-Killer Platoon (HKP)',
|
||||||
echelon: 'platoon',
|
echelon: 'platoon',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'HKP Leader',
|
positionTitle: 'HKP Leader',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -544,11 +542,11 @@ abstract final class DoctrinalTemplates {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Brigade Engineer Battalion
|
// Brigade Engineer Battalion
|
||||||
const UnitDefinition(
|
UnitDefinition(
|
||||||
name: 'Brigade Engineer Battalion',
|
name: 'Brigade Engineer Battalion',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'engineer',
|
branch: 'engineer',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battalion Commander',
|
positionTitle: 'Battalion Commander',
|
||||||
dutyMos: '12A',
|
dutyMos: '12A',
|
||||||
|
|
@ -567,7 +565,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Alpha Engineer Company',
|
name: 'Alpha Engineer Company',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'engineer',
|
branch: 'engineer',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Company Commander',
|
positionTitle: 'Company Commander',
|
||||||
dutyMos: '12A',
|
dutyMos: '12A',
|
||||||
|
|
@ -590,11 +588,11 @@ abstract final class DoctrinalTemplates {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Brigade Support Battalion
|
// Brigade Support Battalion
|
||||||
const UnitDefinition(
|
UnitDefinition(
|
||||||
name: 'Brigade Support Battalion',
|
name: 'Brigade Support Battalion',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'logistics',
|
branch: 'logistics',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battalion Commander',
|
positionTitle: 'Battalion Commander',
|
||||||
dutyMos: '92A',
|
dutyMos: '92A',
|
||||||
|
|
@ -671,11 +669,11 @@ abstract final class DoctrinalTemplates {
|
||||||
_infantryBattalion('2-2 Stryker Infantry Battalion'),
|
_infantryBattalion('2-2 Stryker Infantry Battalion'),
|
||||||
_infantryBattalion('3-2 Stryker Infantry Battalion'),
|
_infantryBattalion('3-2 Stryker Infantry Battalion'),
|
||||||
// Cavalry Reconnaissance Squadron
|
// Cavalry Reconnaissance Squadron
|
||||||
const UnitDefinition(
|
UnitDefinition(
|
||||||
name: 'Cavalry Reconnaissance Squadron',
|
name: 'Cavalry Reconnaissance Squadron',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Squadron Commander',
|
positionTitle: 'Squadron Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -694,7 +692,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'HHT',
|
name: 'HHT',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'HHT Commander',
|
positionTitle: 'HHT Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -713,7 +711,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Alpha Cav Troop (Stryker)',
|
name: 'Alpha Cav Troop (Stryker)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Troop Commander',
|
positionTitle: 'Troop Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -732,7 +730,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Bravo Cav Troop (Stryker)',
|
name: 'Bravo Cav Troop (Stryker)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Troop Commander',
|
positionTitle: 'Troop Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -751,7 +749,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Charlie Cav Troop (Stryker)',
|
name: 'Charlie Cav Troop (Stryker)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Troop Commander',
|
positionTitle: 'Troop Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -770,7 +768,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Anti-Tank Troop (9× Stryker ATGM)',
|
name: 'Anti-Tank Troop (9× Stryker ATGM)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Troop Commander',
|
positionTitle: 'Troop Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -788,11 +786,11 @@ abstract final class DoctrinalTemplates {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Field Artillery Battalion (M777A2)
|
// Field Artillery Battalion (M777A2)
|
||||||
const UnitDefinition(
|
UnitDefinition(
|
||||||
name: 'Field Artillery Battalion (M777A2)',
|
name: 'Field Artillery Battalion (M777A2)',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'fires',
|
branch: 'fires',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battalion Commander',
|
positionTitle: 'Battalion Commander',
|
||||||
dutyMos: '13A',
|
dutyMos: '13A',
|
||||||
|
|
@ -811,7 +809,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Alpha Battery (M777A2)',
|
name: 'Alpha Battery (M777A2)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'fires',
|
branch: 'fires',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battery Commander',
|
positionTitle: 'Battery Commander',
|
||||||
dutyMos: '13A',
|
dutyMos: '13A',
|
||||||
|
|
@ -830,7 +828,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Bravo Battery (M777A2)',
|
name: 'Bravo Battery (M777A2)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'fires',
|
branch: 'fires',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battery Commander',
|
positionTitle: 'Battery Commander',
|
||||||
dutyMos: '13A',
|
dutyMos: '13A',
|
||||||
|
|
@ -849,7 +847,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Charlie Battery (M777A2)',
|
name: 'Charlie Battery (M777A2)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'fires',
|
branch: 'fires',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battery Commander',
|
positionTitle: 'Battery Commander',
|
||||||
dutyMos: '13A',
|
dutyMos: '13A',
|
||||||
|
|
@ -867,11 +865,11 @@ abstract final class DoctrinalTemplates {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Brigade Engineer Battalion
|
// Brigade Engineer Battalion
|
||||||
const UnitDefinition(
|
UnitDefinition(
|
||||||
name: 'Brigade Engineer Battalion',
|
name: 'Brigade Engineer Battalion',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'engineer',
|
branch: 'engineer',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battalion Commander',
|
positionTitle: 'Battalion Commander',
|
||||||
dutyMos: '12A',
|
dutyMos: '12A',
|
||||||
|
|
@ -895,7 +893,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Alpha Engineer Company',
|
name: 'Alpha Engineer Company',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'engineer',
|
branch: 'engineer',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Company Commander',
|
positionTitle: 'Company Commander',
|
||||||
dutyMos: '12A',
|
dutyMos: '12A',
|
||||||
|
|
@ -918,11 +916,11 @@ abstract final class DoctrinalTemplates {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Brigade Support Battalion
|
// Brigade Support Battalion
|
||||||
const UnitDefinition(
|
UnitDefinition(
|
||||||
name: 'Brigade Support Battalion',
|
name: 'Brigade Support Battalion',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'logistics',
|
branch: 'logistics',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battalion Commander',
|
positionTitle: 'Battalion Commander',
|
||||||
dutyMos: '92A',
|
dutyMos: '92A',
|
||||||
|
|
@ -986,11 +984,11 @@ abstract final class DoctrinalTemplates {
|
||||||
],
|
],
|
||||||
children: [
|
children: [
|
||||||
// Armor-Heavy CAB 1
|
// Armor-Heavy CAB 1
|
||||||
const UnitDefinition(
|
UnitDefinition(
|
||||||
name: '1st Combined Arms Battalion (Armor-Heavy)',
|
name: '1st Combined Arms Battalion (Armor-Heavy)',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battalion Commander',
|
positionTitle: 'Battalion Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -1019,7 +1017,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Alpha Tank Company (14× M1A2 SEPv3)',
|
name: 'Alpha Tank Company (14× M1A2 SEPv3)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Company Commander',
|
positionTitle: 'Company Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -1038,7 +1036,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Bravo Tank Company (14× M1A2 SEPv3)',
|
name: 'Bravo Tank Company (14× M1A2 SEPv3)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Company Commander',
|
positionTitle: 'Company Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -1057,7 +1055,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Charlie Mech Infantry Company (14× M2A3 Bradley)',
|
name: 'Charlie Mech Infantry Company (14× M2A3 Bradley)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Company Commander',
|
positionTitle: 'Company Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -1075,11 +1073,11 @@ abstract final class DoctrinalTemplates {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Armor-Heavy CAB 2
|
// Armor-Heavy CAB 2
|
||||||
const UnitDefinition(
|
UnitDefinition(
|
||||||
name: '2nd Combined Arms Battalion (Armor-Heavy)',
|
name: '2nd Combined Arms Battalion (Armor-Heavy)',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battalion Commander',
|
positionTitle: 'Battalion Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -1103,7 +1101,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Alpha Tank Company (14× M1A2 SEPv3)',
|
name: 'Alpha Tank Company (14× M1A2 SEPv3)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Company Commander',
|
positionTitle: 'Company Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -1122,7 +1120,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Bravo Tank Company (14× M1A2 SEPv3)',
|
name: 'Bravo Tank Company (14× M1A2 SEPv3)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Company Commander',
|
positionTitle: 'Company Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -1141,7 +1139,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Charlie Mech Infantry Company (14× M2A3 Bradley)',
|
name: 'Charlie Mech Infantry Company (14× M2A3 Bradley)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Company Commander',
|
positionTitle: 'Company Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -1161,11 +1159,11 @@ abstract final class DoctrinalTemplates {
|
||||||
// Infantry-Heavy CAB
|
// Infantry-Heavy CAB
|
||||||
_infantryBattalion('3rd Combined Arms Battalion (Infantry-Heavy)'),
|
_infantryBattalion('3rd Combined Arms Battalion (Infantry-Heavy)'),
|
||||||
// Armored Cavalry Squadron
|
// Armored Cavalry Squadron
|
||||||
const UnitDefinition(
|
UnitDefinition(
|
||||||
name: 'Armored Cavalry Squadron',
|
name: 'Armored Cavalry Squadron',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Squadron Commander',
|
positionTitle: 'Squadron Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -1184,7 +1182,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'HHT',
|
name: 'HHT',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'HHT Commander',
|
positionTitle: 'HHT Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -1203,7 +1201,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Alpha Armored Cav Troop',
|
name: 'Alpha Armored Cav Troop',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Troop Commander',
|
positionTitle: 'Troop Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -1222,7 +1220,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Bravo Armored Cav Troop',
|
name: 'Bravo Armored Cav Troop',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Troop Commander',
|
positionTitle: 'Troop Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -1241,7 +1239,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Charlie Armored Cav Troop',
|
name: 'Charlie Armored Cav Troop',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Troop Commander',
|
positionTitle: 'Troop Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -1260,7 +1258,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Tank Troop (14× M1A2)',
|
name: 'Tank Troop (14× M1A2)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Troop Commander',
|
positionTitle: 'Troop Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -1278,11 +1276,11 @@ abstract final class DoctrinalTemplates {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Field Artillery Battalion (M109A7 Paladin)
|
// Field Artillery Battalion (M109A7 Paladin)
|
||||||
const UnitDefinition(
|
UnitDefinition(
|
||||||
name: 'Field Artillery Battalion (M109A7 Paladin)',
|
name: 'Field Artillery Battalion (M109A7 Paladin)',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'fires',
|
branch: 'fires',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battalion Commander',
|
positionTitle: 'Battalion Commander',
|
||||||
dutyMos: '13A',
|
dutyMos: '13A',
|
||||||
|
|
@ -1301,7 +1299,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Alpha Battery (M109A7)',
|
name: 'Alpha Battery (M109A7)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'fires',
|
branch: 'fires',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battery Commander',
|
positionTitle: 'Battery Commander',
|
||||||
dutyMos: '13A',
|
dutyMos: '13A',
|
||||||
|
|
@ -1320,7 +1318,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Bravo Battery (M109A7)',
|
name: 'Bravo Battery (M109A7)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'fires',
|
branch: 'fires',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battery Commander',
|
positionTitle: 'Battery Commander',
|
||||||
dutyMos: '13A',
|
dutyMos: '13A',
|
||||||
|
|
@ -1339,7 +1337,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Charlie Battery (M109A7)',
|
name: 'Charlie Battery (M109A7)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'fires',
|
branch: 'fires',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battery Commander',
|
positionTitle: 'Battery Commander',
|
||||||
dutyMos: '13A',
|
dutyMos: '13A',
|
||||||
|
|
@ -1357,11 +1355,11 @@ abstract final class DoctrinalTemplates {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Brigade Engineer Battalion
|
// Brigade Engineer Battalion
|
||||||
const UnitDefinition(
|
UnitDefinition(
|
||||||
name: 'Brigade Engineer Battalion',
|
name: 'Brigade Engineer Battalion',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'engineer',
|
branch: 'engineer',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battalion Commander',
|
positionTitle: 'Battalion Commander',
|
||||||
dutyMos: '12A',
|
dutyMos: '12A',
|
||||||
|
|
@ -1380,7 +1378,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Sapper Company',
|
name: 'Sapper Company',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'engineer',
|
branch: 'engineer',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Company Commander',
|
positionTitle: 'Company Commander',
|
||||||
dutyMos: '12A',
|
dutyMos: '12A',
|
||||||
|
|
@ -1410,11 +1408,11 @@ abstract final class DoctrinalTemplates {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Combat Aviation Brigade — Heavy (2026 doctrine).
|
/// Combat Aviation Brigade — Heavy (2026 doctrine).
|
||||||
static const UnitDefinition cabHeavy = UnitDefinition(
|
static final UnitDefinition cabHeavy = UnitDefinition(
|
||||||
name: 'Combat Aviation Brigade (Heavy)',
|
name: 'Combat Aviation Brigade (Heavy)',
|
||||||
echelon: 'brigade',
|
echelon: 'brigade',
|
||||||
branch: 'aviation',
|
branch: 'aviation',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Brigade Commander',
|
positionTitle: 'Brigade Commander',
|
||||||
dutyMos: '153A',
|
dutyMos: '153A',
|
||||||
|
|
@ -1440,7 +1438,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Attack Aviation Battalion (24× AH-64E Apache)',
|
name: 'Attack Aviation Battalion (24× AH-64E Apache)',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'aviation',
|
branch: 'aviation',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battalion Commander',
|
positionTitle: 'Battalion Commander',
|
||||||
dutyMos: '153A',
|
dutyMos: '153A',
|
||||||
|
|
@ -1469,7 +1467,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Alpha Attack Company',
|
name: 'Alpha Attack Company',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'aviation',
|
branch: 'aviation',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Company Commander',
|
positionTitle: 'Company Commander',
|
||||||
dutyMos: '153A',
|
dutyMos: '153A',
|
||||||
|
|
@ -1488,7 +1486,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Bravo Attack Company',
|
name: 'Bravo Attack Company',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'aviation',
|
branch: 'aviation',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Company Commander',
|
positionTitle: 'Company Commander',
|
||||||
dutyMos: '153A',
|
dutyMos: '153A',
|
||||||
|
|
@ -1510,7 +1508,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Assault Helicopter Battalion (UH-60M / CH-47F)',
|
name: 'Assault Helicopter Battalion (UH-60M / CH-47F)',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'aviation',
|
branch: 'aviation',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battalion Commander',
|
positionTitle: 'Battalion Commander',
|
||||||
dutyMos: '153A',
|
dutyMos: '153A',
|
||||||
|
|
@ -1544,7 +1542,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Alpha Assault Company (UH-60M)',
|
name: 'Alpha Assault Company (UH-60M)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'aviation',
|
branch: 'aviation',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Company Commander',
|
positionTitle: 'Company Commander',
|
||||||
dutyMos: '153A',
|
dutyMos: '153A',
|
||||||
|
|
@ -1563,7 +1561,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Bravo Heavy Lift Company (CH-47F)',
|
name: 'Bravo Heavy Lift Company (CH-47F)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'aviation',
|
branch: 'aviation',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Company Commander',
|
positionTitle: 'Company Commander',
|
||||||
dutyMos: '153A',
|
dutyMos: '153A',
|
||||||
|
|
@ -1585,7 +1583,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Air Ambulance Company (15× HH-60M)',
|
name: 'Air Ambulance Company (15× HH-60M)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'medical',
|
branch: 'medical',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Company Commander',
|
positionTitle: 'Company Commander',
|
||||||
dutyMos: '153A',
|
dutyMos: '153A',
|
||||||
|
|
@ -1620,7 +1618,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Aviation Support Battalion',
|
name: 'Aviation Support Battalion',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'logistics',
|
branch: 'logistics',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battalion Commander',
|
positionTitle: 'Battalion Commander',
|
||||||
dutyMos: '92A',
|
dutyMos: '92A',
|
||||||
|
|
@ -1658,11 +1656,11 @@ abstract final class DoctrinalTemplates {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Combat Aviation Brigade — Light (2026 doctrine).
|
/// Combat Aviation Brigade — Light (2026 doctrine).
|
||||||
static const UnitDefinition cabLight = UnitDefinition(
|
static final UnitDefinition cabLight = UnitDefinition(
|
||||||
name: 'Combat Aviation Brigade (Light)',
|
name: 'Combat Aviation Brigade (Light)',
|
||||||
echelon: 'brigade',
|
echelon: 'brigade',
|
||||||
branch: 'aviation',
|
branch: 'aviation',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Brigade Commander',
|
positionTitle: 'Brigade Commander',
|
||||||
dutyMos: '153A',
|
dutyMos: '153A',
|
||||||
|
|
@ -1688,7 +1686,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Attack Reconnaissance Battalion (AH-64E + MQ-9A)',
|
name: 'Attack Reconnaissance Battalion (AH-64E + MQ-9A)',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'aviation',
|
branch: 'aviation',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battalion Commander',
|
positionTitle: 'Battalion Commander',
|
||||||
dutyMos: '153A',
|
dutyMos: '153A',
|
||||||
|
|
@ -1717,7 +1715,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Alpha Attack Company',
|
name: 'Alpha Attack Company',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'aviation',
|
branch: 'aviation',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Company Commander',
|
positionTitle: 'Company Commander',
|
||||||
dutyMos: '153A',
|
dutyMos: '153A',
|
||||||
|
|
@ -1736,7 +1734,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Troop F (MQ-9A / sUAS)',
|
name: 'Troop F (MQ-9A / sUAS)',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'aviation',
|
branch: 'aviation',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Troop Commander',
|
positionTitle: 'Troop Commander',
|
||||||
dutyMos: '153A',
|
dutyMos: '153A',
|
||||||
|
|
@ -1763,7 +1761,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Assault Helicopter Battalion (UH-60M / CH-47F)',
|
name: 'Assault Helicopter Battalion (UH-60M / CH-47F)',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'aviation',
|
branch: 'aviation',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battalion Commander',
|
positionTitle: 'Battalion Commander',
|
||||||
dutyMos: '153A',
|
dutyMos: '153A',
|
||||||
|
|
@ -1792,7 +1790,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Alpha Assault Company',
|
name: 'Alpha Assault Company',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'aviation',
|
branch: 'aviation',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Company Commander',
|
positionTitle: 'Company Commander',
|
||||||
dutyMos: '153A',
|
dutyMos: '153A',
|
||||||
|
|
@ -1814,7 +1812,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Assault Helicopter Battalion (Medical)',
|
name: 'Assault Helicopter Battalion (Medical)',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'medical',
|
branch: 'medical',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battalion Commander',
|
positionTitle: 'Battalion Commander',
|
||||||
dutyMos: '153A',
|
dutyMos: '153A',
|
||||||
|
|
@ -1843,7 +1841,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Alpha MEDEVAC Company',
|
name: 'Alpha MEDEVAC Company',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'medical',
|
branch: 'medical',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Company Commander',
|
positionTitle: 'Company Commander',
|
||||||
dutyMos: '153A',
|
dutyMos: '153A',
|
||||||
|
|
@ -1870,7 +1868,7 @@ abstract final class DoctrinalTemplates {
|
||||||
name: 'Aviation Support Battalion',
|
name: 'Aviation Support Battalion',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'logistics',
|
branch: 'logistics',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Battalion Commander',
|
positionTitle: 'Battalion Commander',
|
||||||
dutyMos: '92A',
|
dutyMos: '92A',
|
||||||
|
|
|
||||||
|
|
@ -90,13 +90,12 @@ class MtoeValidationEngine {
|
||||||
for (final unit in units) {
|
for (final unit in units) {
|
||||||
final unitSlots = slotsByUnit[unit.unitId] ?? [];
|
final unitSlots = slotsByUnit[unit.unitId] ?? [];
|
||||||
|
|
||||||
findings
|
findings.addAll(_checkMosMismatches(unit, unitSlots, personnelById));
|
||||||
..addAll(_checkMosMismatches(unit, unitSlots, personnelById))
|
findings.addAll(_checkRankMismatches(unit, unitSlots, personnelById));
|
||||||
..addAll(_checkRankMismatches(unit, unitSlots, personnelById))
|
findings.addAll(_checkVacantKeyLeaders(unit, unitSlots));
|
||||||
..addAll(_checkVacantKeyLeaders(unit, unitSlots))
|
findings.addAll(_checkMissingCombatMedic(unit, unitSlots));
|
||||||
..addAll(_checkMissingCombatMedic(unit, unitSlots))
|
findings.addAll(_checkMissingFireSupport(unit, unitSlots));
|
||||||
..addAll(_checkMissingFireSupport(unit, unitSlots))
|
findings.addAll(_checkManning(unit, unitSlots));
|
||||||
..addAll(_checkManning(unit, unitSlots));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
findings.addAll(_checkStructuralHierarchy(units));
|
findings.addAll(_checkStructuralHierarchy(units));
|
||||||
|
|
@ -119,8 +118,7 @@ class MtoeValidationEngine {
|
||||||
final soldier = personnelById[slot.assignedPersonnelId];
|
final soldier = personnelById[slot.assignedPersonnelId];
|
||||||
if (soldier == null) continue;
|
if (soldier == null) continue;
|
||||||
if (soldier.primaryMos != slot.dutyMos) {
|
if (soldier.primaryMos != slot.dutyMos) {
|
||||||
findings.add(
|
findings.add(ValidationFinding(
|
||||||
ValidationFinding(
|
|
||||||
severity: FindingSeverity.warning,
|
severity: FindingSeverity.warning,
|
||||||
findingCode: 'V001',
|
findingCode: 'V001',
|
||||||
description: '${unit.name} — "${slot.positionTitle}": assigned soldier '
|
description: '${unit.name} — "${slot.positionTitle}": assigned soldier '
|
||||||
|
|
@ -128,8 +126,7 @@ class MtoeValidationEngine {
|
||||||
'requires ${slot.dutyMos.code}.',
|
'requires ${slot.dutyMos.code}.',
|
||||||
unitId: unit.unitId,
|
unitId: unit.unitId,
|
||||||
slotId: slot.slotId,
|
slotId: slot.slotId,
|
||||||
),
|
));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return findings;
|
return findings;
|
||||||
|
|
@ -150,8 +147,7 @@ class MtoeValidationEngine {
|
||||||
final soldier = personnelById[slot.assignedPersonnelId];
|
final soldier = personnelById[slot.assignedPersonnelId];
|
||||||
if (soldier == null) continue;
|
if (soldier == null) continue;
|
||||||
if (!soldier.payGrade.meetsMinimum(slot.authorizedRank)) {
|
if (!soldier.payGrade.meetsMinimum(slot.authorizedRank)) {
|
||||||
findings.add(
|
findings.add(ValidationFinding(
|
||||||
ValidationFinding(
|
|
||||||
severity: FindingSeverity.warning,
|
severity: FindingSeverity.warning,
|
||||||
findingCode: 'V002',
|
findingCode: 'V002',
|
||||||
description: '${unit.name} — "${slot.positionTitle}": assigned soldier '
|
description: '${unit.name} — "${slot.positionTitle}": assigned soldier '
|
||||||
|
|
@ -159,8 +155,7 @@ class MtoeValidationEngine {
|
||||||
'requires minimum ${slot.authorizedRank.payGrade}.',
|
'requires minimum ${slot.authorizedRank.payGrade}.',
|
||||||
unitId: unit.unitId,
|
unitId: unit.unitId,
|
||||||
slotId: slot.slotId,
|
slotId: slot.slotId,
|
||||||
),
|
));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return findings;
|
return findings;
|
||||||
|
|
@ -177,16 +172,14 @@ class MtoeValidationEngine {
|
||||||
final findings = <ValidationFinding>[];
|
final findings = <ValidationFinding>[];
|
||||||
for (final slot in slots) {
|
for (final slot in slots) {
|
||||||
if (slot.isKeyLeader && slot.isVacant) {
|
if (slot.isKeyLeader && slot.isVacant) {
|
||||||
findings.add(
|
findings.add(ValidationFinding(
|
||||||
ValidationFinding(
|
|
||||||
severity: FindingSeverity.critical,
|
severity: FindingSeverity.critical,
|
||||||
findingCode: 'V003',
|
findingCode: 'V003',
|
||||||
description: '${unit.name} — Key leader position "${slot.positionTitle}" '
|
description: '${unit.name} — Key leader position "${slot.positionTitle}" '
|
||||||
'(${slot.dutyMos.code}/${slot.authorizedRank.payGrade}) is vacant.',
|
'(${slot.dutyMos.code}/${slot.authorizedRank.payGrade}) is vacant.',
|
||||||
unitId: unit.unitId,
|
unitId: unit.unitId,
|
||||||
slotId: slot.slotId,
|
slotId: slot.slotId,
|
||||||
),
|
));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return findings;
|
return findings;
|
||||||
|
|
@ -256,8 +249,7 @@ class MtoeValidationEngine {
|
||||||
if (parent == null) continue;
|
if (parent == null) continue;
|
||||||
|
|
||||||
if (!unit.echelon.isValidParent(parent.echelon)) {
|
if (!unit.echelon.isValidParent(parent.echelon)) {
|
||||||
findings.add(
|
findings.add(ValidationFinding(
|
||||||
ValidationFinding(
|
|
||||||
severity: FindingSeverity.critical,
|
severity: FindingSeverity.critical,
|
||||||
findingCode: 'V006',
|
findingCode: 'V006',
|
||||||
description: '"${unit.name}" (${unit.echelon.displayName}) is assigned under '
|
description: '"${unit.name}" (${unit.echelon.displayName}) is assigned under '
|
||||||
|
|
@ -265,8 +257,7 @@ class MtoeValidationEngine {
|
||||||
'A ${unit.echelon.displayName} must be subordinate to a '
|
'A ${unit.echelon.displayName} must be subordinate to a '
|
||||||
'${_expectedParentEchelon(unit.echelon)?.displayName ?? "root"}.',
|
'${_expectedParentEchelon(unit.echelon)?.displayName ?? "root"}.',
|
||||||
unitId: unit.unitId,
|
unitId: unit.unitId,
|
||||||
),
|
));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return findings;
|
return findings;
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,8 @@ import 'package:uto_manager/domain/entities/unit_node.dart';
|
||||||
|
|
||||||
/// Result of a doctrinal validation check.
|
/// Result of a doctrinal validation check.
|
||||||
class ValidationResult {
|
class ValidationResult {
|
||||||
/// Creates a [ValidationResult] with explicit [isValid] and optional [reason].
|
|
||||||
const ValidationResult({required this.isValid, this.reason});
|
const ValidationResult({required this.isValid, this.reason});
|
||||||
|
|
||||||
/// Creates a failing validation result with a [reason].
|
|
||||||
ValidationResult.invalid(this.reason) : isValid = false;
|
|
||||||
|
|
||||||
/// Whether the operation is doctrinally valid.
|
/// Whether the operation is doctrinally valid.
|
||||||
final bool isValid;
|
final bool isValid;
|
||||||
|
|
||||||
|
|
@ -19,6 +15,10 @@ class ValidationResult {
|
||||||
|
|
||||||
/// A passing validation result.
|
/// A passing validation result.
|
||||||
static const valid = ValidationResult(isValid: true);
|
static const valid = ValidationResult(isValid: true);
|
||||||
|
|
||||||
|
/// Creates a failing validation result with a [reason].
|
||||||
|
static ValidationResult invalid(String reason) =>
|
||||||
|
ValidationResult(isValid: false, reason: reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents a node in the unit tree with its children and slots.
|
/// Represents a node in the unit tree with its children and slots.
|
||||||
|
|
@ -92,7 +92,7 @@ class UnitTreeService {
|
||||||
} else {
|
} else {
|
||||||
final parent = nodeMap[unit.parentUnitId];
|
final parent = nodeMap[unit.parentUnitId];
|
||||||
if (parent != null) {
|
if (parent != null) {
|
||||||
parent.children.add(nodeMap[unit.unitId]!);
|
(parent.children as List<UnitTreeNode>).add(nodeMap[unit.unitId]!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -213,7 +213,7 @@ class UnitTreeService {
|
||||||
|
|
||||||
/// Calculates the fill rate for a list of [slots] (0.0–1.0).
|
/// Calculates the fill rate for a list of [slots] (0.0–1.0).
|
||||||
double calculateFillRate(List<PositionSlot> slots) {
|
double calculateFillRate(List<PositionSlot> slots) {
|
||||||
if (slots.isEmpty) return 0;
|
if (slots.isEmpty) return 0.0;
|
||||||
final filled = slots.where((s) => s.isFilled).length;
|
final filled = slots.where((s) => s.isFilled).length;
|
||||||
return filled / slots.length;
|
return filled / slots.length;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,9 @@ class ValidationResponse {
|
||||||
|
|
||||||
/// Entry point for the background validation isolate.
|
/// Entry point for the background validation isolate.
|
||||||
///
|
///
|
||||||
/// Receives `ValidationRequest` messages on a `ReceivePort`, runs the
|
/// Receives [ValidationRequest] messages on [receivePort], runs the
|
||||||
/// [MtoeValidationEngine], and sends a `ValidationResponse` back via the
|
/// [MtoeValidationEngine], and sends a [ValidationResponse] back via the
|
||||||
/// request's replyPort.
|
/// request's [ValidationRequest.replyPort].
|
||||||
void validationIsolateEntryPoint(SendPort mainSendPort) {
|
void validationIsolateEntryPoint(SendPort mainSendPort) {
|
||||||
final receivePort = ReceivePort();
|
final receivePort = ReceivePort();
|
||||||
// Send our receive port to the main isolate so it can send us requests.
|
// Send our receive port to the main isolate so it can send us requests.
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,17 @@ extension _PdfPageSizeExt on PdfPageSize {
|
||||||
return 1224;
|
return 1224;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String get displayName {
|
||||||
|
switch (this) {
|
||||||
|
case PdfPageSize.letter:
|
||||||
|
return 'Letter (8.5×11)';
|
||||||
|
case PdfPageSize.a4:
|
||||||
|
return 'A4 (210×297mm)';
|
||||||
|
case PdfPageSize.tabloid:
|
||||||
|
return 'Tabloid/A3 (11×17)';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -77,11 +88,11 @@ class PdfChartExporter {
|
||||||
const PdfChartExporter();
|
const PdfChartExporter();
|
||||||
|
|
||||||
static const String _classificationBanner = 'UNCLASSIFIED';
|
static const String _classificationBanner = 'UNCLASSIFIED';
|
||||||
static const double _margin = 36; // 0.5 inch in points
|
static const double _margin = 36.0; // 0.5 inch in points
|
||||||
static const double _nodeW = 110;
|
static const double _nodeW = 110.0;
|
||||||
static const double _nodeH = 36;
|
static const double _nodeH = 36.0;
|
||||||
static const double _levelGap = 60; // vertical gap between echelon levels
|
static const double _levelGap = 60.0; // vertical gap between echelon levels
|
||||||
static const double _siblingGap = 8; // horizontal gap between siblings
|
static const double _siblingGap = 8.0; // horizontal gap between siblings
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public API
|
// Public API
|
||||||
|
|
@ -98,25 +109,25 @@ class PdfChartExporter {
|
||||||
DateTime? exportDate,
|
DateTime? exportDate,
|
||||||
}) {
|
}) {
|
||||||
final date = exportDate ?? DateTime.now();
|
final date = exportDate ?? DateTime.now();
|
||||||
const treeService = UnitTreeService();
|
final treeService = const UnitTreeService();
|
||||||
final roots = treeService.buildTree(units, slots);
|
final roots = treeService.buildTree(units, slots);
|
||||||
|
|
||||||
// Compute layout positions for all nodes.
|
// Compute layout positions for all nodes.
|
||||||
final positions = <String, _NodePos>{};
|
final positions = <String, _NodePos>{};
|
||||||
var xCursor = _margin;
|
double xCursor = _margin;
|
||||||
for (final root in roots) {
|
for (final root in roots) {
|
||||||
xCursor = _layoutSubtree(root, xCursor, 0, positions);
|
xCursor = _layoutSubtree(root, xCursor, 0, positions);
|
||||||
xCursor += _siblingGap * 4;
|
xCursor += _siblingGap * 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the PDF document.
|
// Build the PDF document.
|
||||||
final builder = _PdfBuilder(pageSize: pageSize)
|
final builder = _PdfBuilder(pageSize: pageSize);
|
||||||
..addCoverPage(
|
builder.addCoverPage(
|
||||||
unitDesignation: unitDesignation,
|
unitDesignation: unitDesignation,
|
||||||
exportDate: date,
|
exportDate: date,
|
||||||
classification: _classificationBanner,
|
classification: _classificationBanner,
|
||||||
)
|
);
|
||||||
..addChartPage(
|
builder.addChartPage(
|
||||||
positions: positions,
|
positions: positions,
|
||||||
roots: roots,
|
roots: roots,
|
||||||
units: units,
|
units: units,
|
||||||
|
|
@ -148,7 +159,7 @@ class PdfChartExporter {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layout children first to determine total width.
|
// Layout children first to determine total width.
|
||||||
var childX = startX;
|
double childX = startX;
|
||||||
for (final child in node.children) {
|
for (final child in node.children) {
|
||||||
childX = _layoutSubtree(child, childX, depth + 1, positions);
|
childX = _layoutSubtree(child, childX, depth + 1, positions);
|
||||||
}
|
}
|
||||||
|
|
@ -207,29 +218,29 @@ class _PdfBuilder {
|
||||||
final h = pageSize.heightPt;
|
final h = pageSize.heightPt;
|
||||||
final cx = w / 2;
|
final cx = w / 2;
|
||||||
|
|
||||||
final stream = StringBuffer()
|
final stream = StringBuffer();
|
||||||
..writeln('BT')
|
stream.writeln('BT');
|
||||||
// Classification banner — top
|
// Classification banner — top
|
||||||
..writeln('/F1 10 Tf')
|
stream.writeln('/F1 10 Tf');
|
||||||
..writeln('${cx - 40} ${h - 20} Td')
|
stream.writeln('${cx - 40} ${h - 20} Td');
|
||||||
..writeln('($classification) Tj')
|
stream.writeln('($classification) Tj');
|
||||||
// Title
|
// Title
|
||||||
..writeln('/F1 24 Tf')
|
stream.writeln('/F1 24 Tf');
|
||||||
..writeln('${cx - 80} ${h / 2 + 40} Td')
|
stream.writeln('${cx - 80} ${h / 2 + 40} Td');
|
||||||
..writeln('(UTO Manager — Organizational Chart) Tj')
|
stream.writeln('(UTO Manager — Organizational Chart) Tj');
|
||||||
// Unit designation
|
// Unit designation
|
||||||
..writeln('/F1 18 Tf')
|
stream.writeln('/F1 18 Tf');
|
||||||
..writeln('${cx - 60} ${h / 2} Td')
|
stream.writeln('${cx - 60} ${h / 2} Td');
|
||||||
..writeln('($unitDesignation) Tj')
|
stream.writeln('($unitDesignation) Tj');
|
||||||
// Export date
|
// Export date
|
||||||
..writeln('/F1 12 Tf')
|
stream.writeln('/F1 12 Tf');
|
||||||
..writeln('${cx - 40} ${h / 2 - 30} Td')
|
stream.writeln('${cx - 40} ${h / 2 - 30} Td');
|
||||||
..writeln('(Exported: $dateStr) Tj')
|
stream.writeln('(Exported: $dateStr) Tj');
|
||||||
// Classification banner — bottom
|
// Classification banner — bottom
|
||||||
..writeln('/F1 10 Tf')
|
stream.writeln('/F1 10 Tf');
|
||||||
..writeln('${cx - 40} 10 Td')
|
stream.writeln('${cx - 40} 10 Td');
|
||||||
..writeln('($classification) Tj')
|
stream.writeln('($classification) Tj');
|
||||||
..writeln('ET');
|
stream.writeln('ET');
|
||||||
|
|
||||||
_addPage(stream.toString());
|
_addPage(stream.toString());
|
||||||
}
|
}
|
||||||
|
|
@ -248,10 +259,13 @@ class _PdfBuilder {
|
||||||
final h = pageSize.heightPt;
|
final h = pageSize.heightPt;
|
||||||
final cx = w / 2;
|
final cx = w / 2;
|
||||||
|
|
||||||
final stream = StringBuffer()
|
final stream = StringBuffer();
|
||||||
|
|
||||||
// Classification banners
|
// Classification banners
|
||||||
..writeln('BT /F1 10 Tf ${cx - 40} ${h - 20} Td ($classification) Tj ET')
|
stream.writeln('BT /F1 10 Tf ${cx - 40} ${h - 20} Td ($classification) Tj ET');
|
||||||
..writeln('BT /F1 10 Tf ${cx - 40} 10 Td ($classification) Tj ET');
|
stream.writeln('BT /F1 10 Tf ${cx - 40} 10 Td ($classification) Tj ET');
|
||||||
|
|
||||||
|
final unitById = {for (final u in units) u.unitId: u};
|
||||||
|
|
||||||
// Draw connector lines first (behind boxes).
|
// Draw connector lines first (behind boxes).
|
||||||
for (final unit in units) {
|
for (final unit in units) {
|
||||||
|
|
@ -265,10 +279,9 @@ class _PdfBuilder {
|
||||||
final parentCx = parentPos.x + PdfChartExporter._nodeW / 2;
|
final parentCx = parentPos.x + PdfChartExporter._nodeW / 2;
|
||||||
final parentBottom = h - parentPos.y - PdfChartExporter._nodeH;
|
final parentBottom = h - parentPos.y - PdfChartExporter._nodeH;
|
||||||
|
|
||||||
stream
|
stream.writeln('0.4 0.4 0.4 RG'); // gray stroke
|
||||||
..writeln('0.4 0.4 0.4 RG') // gray stroke
|
stream.writeln('0.5 w'); // line width
|
||||||
..writeln('0.5 w') // line width
|
stream.writeln('$parentCx $parentBottom m $childCx $childTop l S');
|
||||||
..writeln('$parentCx $parentBottom m $childCx $childTop l S');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw node boxes and labels.
|
// Draw node boxes and labels.
|
||||||
|
|
@ -281,21 +294,21 @@ class _PdfBuilder {
|
||||||
|
|
||||||
// Box fill color based on echelon.
|
// Box fill color based on echelon.
|
||||||
final rgb = _echelonColor(unit.echelon);
|
final rgb = _echelonColor(unit.echelon);
|
||||||
|
stream.writeln('${rgb[0]} ${rgb[1]} ${rgb[2]} rg'); // fill
|
||||||
|
stream.writeln('0.2 0.2 0.4 RG'); // stroke
|
||||||
|
stream.writeln('0.8 w');
|
||||||
|
stream.writeln(
|
||||||
|
'$x $y ${PdfChartExporter._nodeW} ${PdfChartExporter._nodeH} re B',
|
||||||
|
);
|
||||||
|
|
||||||
// Unit name label (truncated to fit box).
|
// Unit name label (truncated to fit box).
|
||||||
final label = _truncate(unit.name, 18);
|
final label = _truncate(unit.name, 18);
|
||||||
final echelonLabel = unit.echelon.displayName;
|
final echelonLabel = unit.echelon.displayName;
|
||||||
stream
|
stream.writeln('1 1 1 rg'); // white text
|
||||||
..writeln('${rgb[0]} ${rgb[1]} ${rgb[2]} rg') // fill
|
stream.writeln(
|
||||||
..writeln('0.2 0.2 0.4 RG') // stroke
|
|
||||||
..writeln('0.8 w')
|
|
||||||
..writeln(
|
|
||||||
'$x $y ${PdfChartExporter._nodeW} ${PdfChartExporter._nodeH} re B',
|
|
||||||
)
|
|
||||||
..writeln('1 1 1 rg') // white text
|
|
||||||
..writeln(
|
|
||||||
'BT /F1 7 Tf ${x + 4} ${y + PdfChartExporter._nodeH - 12} Td ($label) Tj ET',
|
'BT /F1 7 Tf ${x + 4} ${y + PdfChartExporter._nodeH - 12} Td ($label) Tj ET',
|
||||||
)
|
);
|
||||||
..writeln(
|
stream.writeln(
|
||||||
'BT /F1 6 Tf ${x + 4} ${y + 4} Td ($echelonLabel) Tj ET',
|
'BT /F1 6 Tf ${x + 4} ${y + 4} Td ($echelonLabel) Tj ET',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -385,9 +398,9 @@ class _PdfBuilder {
|
||||||
// Ensure font object exists.
|
// Ensure font object exists.
|
||||||
_fontObjNum();
|
_fontObjNum();
|
||||||
|
|
||||||
final buf = StringBuffer()
|
final buf = StringBuffer();
|
||||||
..writeln('%PDF-1.4')
|
buf.writeln('%PDF-1.4');
|
||||||
..writeln('%âãÏÓ'); // binary comment to mark as binary file
|
buf.writeln('%âãÏÓ'); // binary comment to mark as binary file
|
||||||
|
|
||||||
// Write all objects and record their byte offsets for the xref table.
|
// Write all objects and record their byte offsets for the xref table.
|
||||||
final offsets = <int>[];
|
final offsets = <int>[];
|
||||||
|
|
@ -421,21 +434,19 @@ class _PdfBuilder {
|
||||||
// Cross-reference table.
|
// Cross-reference table.
|
||||||
final xrefOffset = byteOffset;
|
final xrefOffset = byteOffset;
|
||||||
final totalObjects = offsets.length;
|
final totalObjects = offsets.length;
|
||||||
buf
|
buf.writeln('xref');
|
||||||
..writeln('xref')
|
buf.writeln('0 ${totalObjects + 1}');
|
||||||
..writeln('0 ${totalObjects + 1}')
|
buf.writeln('0000000000 65535 f ');
|
||||||
..writeln('0000000000 65535 f ');
|
|
||||||
for (final offset in offsets) {
|
for (final offset in offsets) {
|
||||||
buf.writeln('${offset.toString().padLeft(10, '0')} 00000 n ');
|
buf.writeln(offset.toString().padLeft(10, '0') + ' 00000 n ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trailer.
|
// Trailer.
|
||||||
buf
|
buf.writeln('trailer');
|
||||||
..writeln('trailer')
|
buf.writeln('<< /Size ${totalObjects + 1} /Root $catalogObjNum 0 R >>');
|
||||||
..writeln('<< /Size ${totalObjects + 1} /Root $catalogObjNum 0 R >>')
|
buf.writeln('startxref');
|
||||||
..writeln('startxref')
|
buf.writeln(xrefOffset);
|
||||||
..writeln(xrefOffset)
|
buf.writeln('%%EOF');
|
||||||
..writeln('%%EOF');
|
|
||||||
|
|
||||||
return Uint8List.fromList(buf.toString().codeUnits);
|
return Uint8List.fromList(buf.toString().codeUnits);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import 'package:uto_manager/infrastructure/codecs/unit_tree_codec.dart';
|
||||||
/// significantly smaller than the equivalent JSON export. The encoding
|
/// significantly smaller than the equivalent JSON export. The encoding
|
||||||
/// uses a length-prefixed binary format:
|
/// uses a length-prefixed binary format:
|
||||||
///
|
///
|
||||||
/// ```text
|
/// ```
|
||||||
/// [4 bytes: magic] [4 bytes: version] [4 bytes: payload length]
|
/// [4 bytes: magic] [4 bytes: version] [4 bytes: payload length]
|
||||||
/// [N bytes: gzip-compressed JSON payload]
|
/// [N bytes: gzip-compressed JSON payload]
|
||||||
/// ```
|
/// ```
|
||||||
|
|
@ -36,7 +36,7 @@ class ProtobufCodec {
|
||||||
static const int _magic = 0x55544F42; // "UTOB"
|
static const int _magic = 0x55544F42; // "UTOB"
|
||||||
static const int _version = 1;
|
static const int _version = 1;
|
||||||
|
|
||||||
UnitTreeCodec get _jsonCodec => const UnitTreeCodec();
|
final _jsonCodec = const UnitTreeCodec();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Encode
|
// Encode
|
||||||
|
|
@ -69,10 +69,11 @@ class ProtobufCodec {
|
||||||
final payloadLength = compressed.length;
|
final payloadLength = compressed.length;
|
||||||
final totalLength = 12 + payloadLength;
|
final totalLength = 12 + payloadLength;
|
||||||
final result = Uint8List(totalLength);
|
final result = Uint8List(totalLength);
|
||||||
ByteData.sublistView(result, 0, 12)
|
final header = ByteData.sublistView(result, 0, 12);
|
||||||
..setUint32(0, _magic)
|
|
||||||
..setUint32(4, _version)
|
header.setUint32(0, _magic, Endian.big);
|
||||||
..setUint32(8, payloadLength);
|
header.setUint32(4, _version, Endian.big);
|
||||||
|
header.setUint32(8, payloadLength, Endian.big);
|
||||||
result.setRange(12, totalLength, compressed);
|
result.setRange(12, totalLength, compressed);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -94,9 +95,9 @@ class ProtobufCodec {
|
||||||
}
|
}
|
||||||
|
|
||||||
final header = ByteData.sublistView(bytes, 0, 12);
|
final header = ByteData.sublistView(bytes, 0, 12);
|
||||||
final magic = header.getUint32(0);
|
final magic = header.getUint32(0, Endian.big);
|
||||||
final version = header.getUint32(4);
|
final version = header.getUint32(4, Endian.big);
|
||||||
final payloadLength = header.getUint32(8);
|
final payloadLength = header.getUint32(8, Endian.big);
|
||||||
|
|
||||||
if (magic != _magic) {
|
if (magic != _magic) {
|
||||||
throw FormatException(
|
throw FormatException(
|
||||||
|
|
@ -136,7 +137,7 @@ class ProtobufCodec {
|
||||||
/// A ratio < 1.0 means the binary encoding is smaller than the JSON.
|
/// A ratio < 1.0 means the binary encoding is smaller than the JSON.
|
||||||
/// Typical BCT trees achieve 0.25–0.40 (60–75% size reduction).
|
/// Typical BCT trees achieve 0.25–0.40 (60–75% size reduction).
|
||||||
static double compressionRatio(int binaryBytes, int jsonBytes) {
|
static double compressionRatio(int binaryBytes, int jsonBytes) {
|
||||||
if (jsonBytes == 0) return 1;
|
if (jsonBytes == 0) return 1.0;
|
||||||
return binaryBytes / jsonBytes;
|
return binaryBytes / jsonBytes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
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/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/application/blocs/validation_cubit.dart';
|
||||||
import 'package:uto_manager/domain/entities/personnel_record.dart';
|
import 'package:uto_manager/domain/entities/personnel_record.dart';
|
||||||
|
|
@ -428,11 +429,7 @@ class HomeScreen extends StatelessWidget {
|
||||||
final a = valState.advisories.length;
|
final a = valState.advisories.length;
|
||||||
if (valState.isClean) {
|
if (valState.isClean) {
|
||||||
return _statusChip(
|
return _statusChip(
|
||||||
theme,
|
theme, Icons.check_circle_outline, 'MTOE Valid', Colors.green.shade400);
|
||||||
Icons.check_circle_outline,
|
|
||||||
'MTOE Valid',
|
|
||||||
Colors.green.shade400,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -441,12 +438,8 @@ class HomeScreen extends StatelessWidget {
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
if (w > 0) ...[
|
if (w > 0) ...[
|
||||||
_statusChip(
|
_statusChip(theme, Icons.warning_amber_outlined, '$w warnings',
|
||||||
theme,
|
Colors.orange.shade400),
|
||||||
Icons.warning_amber_outlined,
|
|
||||||
'$w warnings',
|
|
||||||
Colors.orange.shade400,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
if (a > 0)
|
if (a > 0)
|
||||||
|
|
@ -580,20 +573,14 @@ class _ExportDialogState extends State<_ExportDialog> {
|
||||||
children: [
|
children: [
|
||||||
const Text('Format:', style: TextStyle(fontWeight: FontWeight.bold)),
|
const Text('Format:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
RadioGroup<_ExportFormat>(
|
..._ExportFormat.values.map(
|
||||||
groupValue: _format,
|
|
||||||
onChanged: (v) => setState(() => _format = v!),
|
|
||||||
child: Column(
|
|
||||||
children: _ExportFormat.values
|
|
||||||
.map(
|
|
||||||
(f) => RadioListTile<_ExportFormat>(
|
(f) => RadioListTile<_ExportFormat>(
|
||||||
title: Text(f.label),
|
title: Text(f.label),
|
||||||
subtitle: Text(f.description, style: const TextStyle(fontSize: 11)),
|
subtitle: Text(f.description, style: const TextStyle(fontSize: 11)),
|
||||||
value: f,
|
value: f,
|
||||||
|
groupValue: _format,
|
||||||
dense: true,
|
dense: true,
|
||||||
),
|
onChanged: (v) => setState(() => _format = v!),
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_format == _ExportFormat.pdf) ...[
|
if (_format == _ExportFormat.pdf) ...[
|
||||||
|
|
@ -667,37 +654,31 @@ class _ExportDialogState extends State<_ExportDialog> {
|
||||||
|
|
||||||
switch (_format) {
|
switch (_format) {
|
||||||
case _ExportFormat.json:
|
case _ExportFormat.json:
|
||||||
const codec = UnitTreeCodec();
|
final codec = const UnitTreeCodec();
|
||||||
final json = codec.encode(units: units, slots: slots, personnel: const []);
|
final json = codec.encode(units: units, slots: slots, personnel: const []);
|
||||||
setState(
|
setState(() => _result = 'JSON export ready — ${json.length} characters '
|
||||||
() => _result = 'JSON export ready — ${json.length} characters '
|
'(${(json.length / 1024).toStringAsFixed(1)} KB)');
|
||||||
'(${(json.length / 1024).toStringAsFixed(1)} KB)',
|
|
||||||
);
|
|
||||||
|
|
||||||
case _ExportFormat.binary:
|
case _ExportFormat.binary:
|
||||||
const codec = ProtobufCodec();
|
final codec = const ProtobufCodec();
|
||||||
final bytes = codec.encode(units: units, slots: slots, personnel: const []);
|
final bytes = codec.encode(units: units, slots: slots, personnel: const []);
|
||||||
final jsonLen =
|
final jsonLen =
|
||||||
const UnitTreeCodec().encode(units: units, slots: slots, personnel: const []).length;
|
const UnitTreeCodec().encode(units: units, slots: slots, personnel: const []).length;
|
||||||
final ratio = ProtobufCodec.compressionRatio(bytes.length, jsonLen);
|
final ratio = ProtobufCodec.compressionRatio(bytes.length, jsonLen);
|
||||||
setState(
|
setState(() => _result = 'Binary export ready — ${bytes.length} bytes '
|
||||||
() => _result = 'Binary export ready — ${bytes.length} bytes '
|
'(${(ratio * 100).round()}% of JSON size)');
|
||||||
'(${(ratio * 100).round()}% of JSON size)',
|
|
||||||
);
|
|
||||||
|
|
||||||
case _ExportFormat.pdf:
|
case _ExportFormat.pdf:
|
||||||
const exporter = PdfChartExporter();
|
final exporter = const PdfChartExporter();
|
||||||
final bytes = exporter.export(
|
final bytes = exporter.export(
|
||||||
units: units,
|
units: units,
|
||||||
slots: slots,
|
slots: slots,
|
||||||
unitDesignation: units.isNotEmpty ? units.first.name : 'Unit Tree',
|
unitDesignation: units.isNotEmpty ? units.first.name : 'Unit Tree',
|
||||||
pageSize: _pageSize,
|
pageSize: _pageSize,
|
||||||
);
|
);
|
||||||
setState(
|
setState(() => _result = 'PDF export ready — ${bytes.length} bytes '
|
||||||
() => _result = 'PDF export ready — ${bytes.length} bytes '
|
|
||||||
'(${(bytes.length / 1024).toStringAsFixed(1)} KB), '
|
'(${(bytes.length / 1024).toStringAsFixed(1)} KB), '
|
||||||
'${_pageSize.displayName}',
|
'${_pageSize.displayName}');
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _error = 'Export failed: $e');
|
setState(() => _error = 'Export failed: $e');
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:uto_manager/domain/entities/personnel_record.dart';
|
import 'package:uto_manager/domain/entities/personnel_record.dart';
|
||||||
import 'package:uto_manager/presentation/widgets/position_slot_card.dart' show PositionSlotCard;
|
|
||||||
|
|
||||||
/// A draggable list tile representing a soldier in the personnel panel.
|
/// A draggable list tile representing a soldier in the personnel panel.
|
||||||
///
|
///
|
||||||
/// Can be dragged onto a [PositionSlotCard] to assign the soldier.
|
/// Can be dragged onto a [PositionSlotCard] to assign the soldier.
|
||||||
class PersonnelListTile extends StatelessWidget {
|
class PersonnelListTile extends StatelessWidget {
|
||||||
const PersonnelListTile({
|
const PersonnelListTile({
|
||||||
required this.record, super.key,
|
super.key,
|
||||||
|
required this.record,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import 'package:uto_manager/domain/entities/position_slot.dart';
|
||||||
/// Supports drag-and-drop target for soldier assignment.
|
/// Supports drag-and-drop target for soldier assignment.
|
||||||
class PositionSlotCard extends StatelessWidget {
|
class PositionSlotCard extends StatelessWidget {
|
||||||
const PositionSlotCard({
|
const PositionSlotCard({
|
||||||
required this.slot,
|
|
||||||
super.key,
|
super.key,
|
||||||
|
required this.slot,
|
||||||
this.assignedSoldier,
|
this.assignedSoldier,
|
||||||
this.onUnassign,
|
this.onUnassign,
|
||||||
this.onAcceptDrop,
|
this.onAcceptDrop,
|
||||||
|
|
@ -121,9 +121,7 @@ class PositionSlotCard extends StatelessWidget {
|
||||||
return const Color(0xFFFF9800);
|
return const Color(0xFFFF9800);
|
||||||
case ReadinessState.notMissionCapable:
|
case ReadinessState.notMissionCapable:
|
||||||
return const Color(0xFFF44336);
|
return const Color(0xFFF44336);
|
||||||
case ReadinessState.unknown:
|
default:
|
||||||
return Colors.grey;
|
|
||||||
case null:
|
|
||||||
return Colors.grey;
|
return Colors.grey;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@ import 'package:uto_manager/domain/services/unit_tree_service.dart';
|
||||||
/// Shows the unit's echelon, branch, name, and fill rate indicator.
|
/// Shows the unit's echelon, branch, name, and fill rate indicator.
|
||||||
class UnitNodeCard extends StatelessWidget {
|
class UnitNodeCard extends StatelessWidget {
|
||||||
const UnitNodeCard({
|
const UnitNodeCard({
|
||||||
required this.treeNode, required this.isSelected, required this.onTap, super.key,
|
super.key,
|
||||||
|
required this.treeNode,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
final UnitTreeNode treeNode;
|
final UnitTreeNode treeNode;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,11 @@ import 'package:uto_manager/presentation/widgets/unit_node_card.dart';
|
||||||
/// [UnitNodeCard] widgets arranged in a hierarchical tree layout.
|
/// [UnitNodeCard] widgets arranged in a hierarchical tree layout.
|
||||||
class UtoCanvasWidget extends StatefulWidget {
|
class UtoCanvasWidget extends StatefulWidget {
|
||||||
const UtoCanvasWidget({
|
const UtoCanvasWidget({
|
||||||
required this.roots, required this.selectedUnitId, required this.onUnitSelected, required this.onReparent, super.key,
|
super.key,
|
||||||
|
required this.roots,
|
||||||
|
required this.selectedUnitId,
|
||||||
|
required this.onUnitSelected,
|
||||||
|
required this.onReparent,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Root-level tree nodes to render.
|
/// Root-level tree nodes to render.
|
||||||
|
|
@ -45,11 +49,12 @@ class _UtoCanvasWidgetState extends State<UtoCanvasWidget> {
|
||||||
return InteractiveViewer(
|
return InteractiveViewer(
|
||||||
transformationController: _transformController,
|
transformationController: _transformController,
|
||||||
minScale: 0.2,
|
minScale: 0.2,
|
||||||
maxScale: 3,
|
maxScale: 3.0,
|
||||||
boundaryMargin: const EdgeInsets.all(500),
|
boundaryMargin: const EdgeInsets.all(500),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(40),
|
padding: const EdgeInsets.all(40),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: widget.roots.map((root) => _buildSubtree(root, 0)).toList(),
|
children: widget.roots.map((root) => _buildSubtree(root, 0)).toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -321,7 +321,6 @@ This document breaks the master development plan into five structured phases spa
|
||||||
> Branch: `feature/phase-3-templates-validation-export` → merged 2026-08-09.
|
> Branch: `feature/phase-3-templates-validation-export` → merged 2026-08-09.
|
||||||
>
|
>
|
||||||
> **Completed in this phase:**
|
> **Completed in this phase:**
|
||||||
>
|
|
||||||
> - `DoctrinalTemplates` — all five 2026 BCT templates (MBCT, SBCT, ABCT, CAB-Heavy, CAB-Light)
|
> - `DoctrinalTemplates` — all five 2026 BCT templates (MBCT, SBCT, ABCT, CAB-Heavy, CAB-Light)
|
||||||
> - `TemplateInstantiator` — converts `UnitDefinition` trees to live domain objects
|
> - `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%
|
> - `MtoeValidationEngine` — 7 validation rules (V001–V007) covering MOS/rank mismatch, vacant key leaders, missing medic/FSO, hierarchy violations, manning below 70%
|
||||||
|
|
@ -518,7 +517,6 @@ This document breaks the master development plan into five structured phases spa
|
||||||
> Branch: `feature/phase-4-reporting-analytics` → merged 2026-08-09.
|
> Branch: `feature/phase-4-reporting-analytics` → merged 2026-08-09.
|
||||||
>
|
>
|
||||||
> **Completed in this phase:**
|
> **Completed in this phase:**
|
||||||
>
|
|
||||||
> - `CommandEventStore` — append-only event log with `CommandEvent`, `CommandEventType`, `CommandPayloads`
|
> - `CommandEventStore` — append-only event log with `CommandEvent`, `CommandEventType`, `CommandPayloads`
|
||||||
> - `DeltaSynchronizer` — offline-first sync helper with `syncIfOnline()` and `isOnline` control
|
> - `DeltaSynchronizer` — offline-first sync helper with `syncIfOnline()` and `isOnline` control
|
||||||
> - `IsolateValidationRunner` — background-isolate wrapper for `MtoeValidationEngine` with `ValidationResponse`
|
> - `IsolateValidationRunner` — background-isolate wrapper for `MtoeValidationEngine` with `ValidationResponse`
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ void main() {
|
||||||
|
|
||||||
test('MOS mismatch is flagged but does not block assignment', () {
|
test('MOS mismatch is flagged but does not block assignment', () {
|
||||||
final slot = makeSlot(mosCode: '68W');
|
final slot = makeSlot(mosCode: '68W');
|
||||||
final soldier = makeSoldier();
|
final soldier = makeSoldier(mosCode: '11B');
|
||||||
final outcome = service.assign(slot, soldier);
|
final outcome = service.assign(slot, soldier);
|
||||||
expect(outcome.isSuccess, isTrue);
|
expect(outcome.isSuccess, isTrue);
|
||||||
expect(outcome.hasMosMismatch, isTrue);
|
expect(outcome.hasMosMismatch, isTrue);
|
||||||
|
|
@ -96,7 +96,7 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('no rank mismatch when soldier meets minimum rank', () {
|
test('no rank mismatch when soldier meets minimum rank', () {
|
||||||
final slot = makeSlot();
|
final slot = makeSlot(rank: AuthorizedRank.e5);
|
||||||
final soldier = makeSoldier(rank: AuthorizedRank.e6);
|
final soldier = makeSoldier(rank: AuthorizedRank.e6);
|
||||||
final outcome = service.assign(slot, soldier);
|
final outcome = service.assign(slot, soldier);
|
||||||
expect(outcome.isSuccess, isTrue);
|
expect(outcome.isSuccess, isTrue);
|
||||||
|
|
@ -151,15 +151,15 @@ void main() {
|
||||||
|
|
||||||
test('all FMC returns green', () {
|
test('all FMC returns green', () {
|
||||||
final soldiers = [
|
final soldiers = [
|
||||||
makeSoldier(),
|
makeSoldier(id: 'p1', readiness: ReadinessState.fullyMissionCapable),
|
||||||
makeSoldier(id: 'p2'),
|
makeSoldier(id: 'p2', readiness: ReadinessState.fullyMissionCapable),
|
||||||
];
|
];
|
||||||
expect(service.aggregateReadiness(soldiers), ReadinessColor.green);
|
expect(service.aggregateReadiness(soldiers), ReadinessColor.green);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('any NMC returns red', () {
|
test('any NMC returns red', () {
|
||||||
final soldiers = [
|
final soldiers = [
|
||||||
makeSoldier(),
|
makeSoldier(id: 'p1', readiness: ReadinessState.fullyMissionCapable),
|
||||||
makeSoldier(id: 'p2', readiness: ReadinessState.notMissionCapable),
|
makeSoldier(id: 'p2', readiness: ReadinessState.notMissionCapable),
|
||||||
];
|
];
|
||||||
expect(service.aggregateReadiness(soldiers), ReadinessColor.red);
|
expect(service.aggregateReadiness(soldiers), ReadinessColor.red);
|
||||||
|
|
@ -167,14 +167,14 @@ void main() {
|
||||||
|
|
||||||
test('mixed FMC and PMC returns amber', () {
|
test('mixed FMC and PMC returns amber', () {
|
||||||
final soldiers = [
|
final soldiers = [
|
||||||
makeSoldier(),
|
makeSoldier(id: 'p1', readiness: ReadinessState.fullyMissionCapable),
|
||||||
makeSoldier(id: 'p2', readiness: ReadinessState.partiallyMissionCapable),
|
makeSoldier(id: 'p2', readiness: ReadinessState.partiallyMissionCapable),
|
||||||
];
|
];
|
||||||
expect(service.aggregateReadiness(soldiers), ReadinessColor.amber);
|
expect(service.aggregateReadiness(soldiers), ReadinessColor.amber);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('single PMC soldier returns amber', () {
|
test('single PMC soldier returns amber', () {
|
||||||
final soldiers = [makeSoldier(readiness: ReadinessState.partiallyMissionCapable)];
|
final soldiers = [makeSoldier(id: 'p1', readiness: ReadinessState.partiallyMissionCapable)];
|
||||||
expect(service.aggregateReadiness(soldiers), ReadinessColor.amber);
|
expect(service.aggregateReadiness(soldiers), ReadinessColor.amber);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -185,14 +185,14 @@ void main() {
|
||||||
|
|
||||||
group('UnitTreeService integration via AssignmentService', () {
|
group('UnitTreeService integration via AssignmentService', () {
|
||||||
test('validateReparent is accessible through treeService', () {
|
test('validateReparent is accessible through treeService', () {
|
||||||
const bde = UnitNode(
|
final bde = UnitNode(
|
||||||
unitId: 'bde',
|
unitId: 'bde',
|
||||||
echelon: Echelon.brigade,
|
echelon: Echelon.brigade,
|
||||||
branch: DoctrinalBranch.infantry,
|
branch: DoctrinalBranch.infantry,
|
||||||
name: 'Brigade',
|
name: 'Brigade',
|
||||||
isKeyLeader: false,
|
isKeyLeader: false,
|
||||||
);
|
);
|
||||||
const bn = UnitNode(
|
final bn = UnitNode(
|
||||||
unitId: 'bn',
|
unitId: 'bn',
|
||||||
echelon: Echelon.battalion,
|
echelon: Echelon.battalion,
|
||||||
branch: DoctrinalBranch.infantry,
|
branch: DoctrinalBranch.infantry,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ void main() {
|
||||||
newParentId: 'unit-2',
|
newParentId: 'unit-2',
|
||||||
previousParentId: 'unit-0',
|
previousParentId: 'unit-0',
|
||||||
),
|
),
|
||||||
occurredAt: DateTime.utc(2026, 8, 9, 10),
|
occurredAt: DateTime.utc(2026, 8, 9, 10, 0, 0),
|
||||||
|
isSynced: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
final json = event.toJson();
|
final json = event.toJson();
|
||||||
|
|
@ -143,19 +144,16 @@ void main() {
|
||||||
|
|
||||||
test('allEvents is unmodifiable', () {
|
test('allEvents is unmodifiable', () {
|
||||||
store.append(_makeEvent('e1', CommandEventType.attachNode));
|
store.append(_makeEvent('e1', CommandEventType.attachNode));
|
||||||
expect(
|
expect(() => store.allEvents.add(_makeEvent('e2', CommandEventType.detachNode)),
|
||||||
() => store.allEvents.add(_makeEvent('e2', CommandEventType.detachNode)),
|
throwsUnsupportedError);
|
||||||
throwsUnsupportedError,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pendingEvents excludes synced events', () {
|
test('pendingEvents excludes synced events', () {
|
||||||
final e1 = _makeEvent('e1', CommandEventType.attachNode);
|
final e1 = _makeEvent('e1', CommandEventType.attachNode);
|
||||||
final e2 = _makeEvent('e2', CommandEventType.detachNode);
|
final e2 = _makeEvent('e2', CommandEventType.detachNode);
|
||||||
store
|
store.append(e1);
|
||||||
..append(e1)
|
store.append(e2);
|
||||||
..append(e2)
|
store.markSynced('e1');
|
||||||
..markSynced('e1');
|
|
||||||
|
|
||||||
expect(store.pendingCount, 1);
|
expect(store.pendingCount, 1);
|
||||||
expect(store.pendingEvents.first.eventId, 'e2');
|
expect(store.pendingEvents.first.eventId, 'e2');
|
||||||
|
|
@ -172,19 +170,17 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('markAllSynced marks every event', () {
|
test('markAllSynced marks every event', () {
|
||||||
store
|
store.append(_makeEvent('e1', CommandEventType.attachNode));
|
||||||
..append(_makeEvent('e1', CommandEventType.attachNode))
|
store.append(_makeEvent('e2', CommandEventType.assignPersonnel));
|
||||||
..append(_makeEvent('e2', CommandEventType.assignPersonnel))
|
store.append(_makeEvent('e3', CommandEventType.modifySlot));
|
||||||
..append(_makeEvent('e3', CommandEventType.modifySlot))
|
store.markAllSynced();
|
||||||
..markAllSynced();
|
|
||||||
expect(store.pendingCount, 0);
|
expect(store.pendingCount, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('exportPendingToJson produces valid JSON array', () {
|
test('exportPendingToJson produces valid JSON array', () {
|
||||||
store
|
store.append(_makeEvent('e1', CommandEventType.attachNode));
|
||||||
..append(_makeEvent('e1', CommandEventType.attachNode))
|
store.append(_makeEvent('e2', CommandEventType.detachNode));
|
||||||
..append(_makeEvent('e2', CommandEventType.detachNode))
|
store.markSynced('e1'); // e1 is synced, only e2 should appear
|
||||||
..markSynced('e1'); // e1 is synced, only e2 should appear
|
|
||||||
|
|
||||||
final json = store.exportPendingToJson();
|
final json = store.exportPendingToJson();
|
||||||
final decoded = jsonDecode(json) as List<dynamic>;
|
final decoded = jsonDecode(json) as List<dynamic>;
|
||||||
|
|
@ -193,10 +189,9 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clear removes all events', () {
|
test('clear removes all events', () {
|
||||||
store
|
store.append(_makeEvent('e1', CommandEventType.attachNode));
|
||||||
..append(_makeEvent('e1', CommandEventType.attachNode))
|
store.append(_makeEvent('e2', CommandEventType.detachNode));
|
||||||
..append(_makeEvent('e2', CommandEventType.detachNode))
|
store.clear();
|
||||||
..clear();
|
|
||||||
expect(store.eventCount, 0);
|
expect(store.eventCount, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -235,9 +230,8 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('syncIfOnline transmits pending events when online', () async {
|
test('syncIfOnline transmits pending events when online', () async {
|
||||||
store
|
store.append(_makeEvent('e1', CommandEventType.attachNode));
|
||||||
..append(_makeEvent('e1', CommandEventType.attachNode))
|
store.append(_makeEvent('e2', CommandEventType.assignPersonnel));
|
||||||
..append(_makeEvent('e2', CommandEventType.assignPersonnel));
|
|
||||||
sync.isOnline = true;
|
sync.isOnline = true;
|
||||||
|
|
||||||
final count = await sync.syncIfOnline();
|
final count = await sync.syncIfOnline();
|
||||||
|
|
@ -255,10 +249,9 @@ void main() {
|
||||||
test('already-synced events are not re-transmitted', () async {
|
test('already-synced events are not re-transmitted', () async {
|
||||||
final e1 = _makeEvent('e1', CommandEventType.attachNode);
|
final e1 = _makeEvent('e1', CommandEventType.attachNode);
|
||||||
final e2 = _makeEvent('e2', CommandEventType.detachNode);
|
final e2 = _makeEvent('e2', CommandEventType.detachNode);
|
||||||
store
|
store.append(e1);
|
||||||
..append(e1)
|
store.append(e2);
|
||||||
..append(e2)
|
store.markSynced('e1');
|
||||||
..markSynced('e1');
|
|
||||||
|
|
||||||
final count = await sync.syncIfOnline();
|
final count = await sync.syncIfOnline();
|
||||||
expect(count, 1); // only e2 was pending
|
expect(count, 1); // only e2 was pending
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ void main() {
|
||||||
mos: '68W',
|
mos: '68W',
|
||||||
assignedPersonnelId: 'p1',
|
assignedPersonnelId: 'p1',
|
||||||
);
|
);
|
||||||
final soldier = makeSoldier(id: 'p1', assignedSlotId: 's1');
|
final soldier = makeSoldier(id: 'p1', mos: '11B', assignedSlotId: 's1');
|
||||||
|
|
||||||
final findings = engine.validate(
|
final findings = engine.validate(
|
||||||
units: [unit],
|
units: [unit],
|
||||||
|
|
@ -109,9 +109,10 @@ void main() {
|
||||||
final slot = makeSlot(
|
final slot = makeSlot(
|
||||||
slotId: 's1',
|
slotId: 's1',
|
||||||
unitId: 'u1',
|
unitId: 'u1',
|
||||||
|
mos: '11B',
|
||||||
assignedPersonnelId: 'p1',
|
assignedPersonnelId: 'p1',
|
||||||
);
|
);
|
||||||
final soldier = makeSoldier(id: 'p1', assignedSlotId: 's1');
|
final soldier = makeSoldier(id: 'p1', mos: '11B', assignedSlotId: 's1');
|
||||||
|
|
||||||
final findings = engine.validate(
|
final findings = engine.validate(
|
||||||
units: [unit],
|
units: [unit],
|
||||||
|
|
@ -173,6 +174,7 @@ void main() {
|
||||||
final slot = makeSlot(
|
final slot = makeSlot(
|
||||||
slotId: 's1',
|
slotId: 's1',
|
||||||
unitId: 'u1',
|
unitId: 'u1',
|
||||||
|
rank: AuthorizedRank.e5,
|
||||||
assignedPersonnelId: 'p1',
|
assignedPersonnelId: 'p1',
|
||||||
);
|
);
|
||||||
final soldier = makeSoldier(
|
final soldier = makeSoldier(
|
||||||
|
|
@ -238,7 +240,7 @@ void main() {
|
||||||
|
|
||||||
test('no V003 for non-key-leader vacant slots', () {
|
test('no V003 for non-key-leader vacant slots', () {
|
||||||
final unit = makeUnit(id: 'u1', echelon: Echelon.platoon);
|
final unit = makeUnit(id: 'u1', echelon: Echelon.platoon);
|
||||||
final slot = makeSlot(slotId: 's1', unitId: 'u1');
|
final slot = makeSlot(slotId: 's1', unitId: 'u1', isKeyLeader: false);
|
||||||
|
|
||||||
final findings = engine.validate(
|
final findings = engine.validate(
|
||||||
units: [unit],
|
units: [unit],
|
||||||
|
|
@ -257,7 +259,7 @@ void main() {
|
||||||
group('V004 — missing combat medic', () {
|
group('V004 — missing combat medic', () {
|
||||||
test('flags platoon with no 68W slot as advisory', () {
|
test('flags platoon with no 68W slot as advisory', () {
|
||||||
final unit = makeUnit(id: 'u1', echelon: Echelon.platoon);
|
final unit = makeUnit(id: 'u1', echelon: Echelon.platoon);
|
||||||
final slot = makeSlot(slotId: 's1', unitId: 'u1');
|
final slot = makeSlot(slotId: 's1', unitId: 'u1', mos: '11B');
|
||||||
|
|
||||||
final findings = engine.validate(
|
final findings = engine.validate(
|
||||||
units: [unit],
|
units: [unit],
|
||||||
|
|
@ -287,7 +289,7 @@ void main() {
|
||||||
|
|
||||||
test('no V004 for non-platoon echelons', () {
|
test('no V004 for non-platoon echelons', () {
|
||||||
final unit = makeUnit(id: 'u1', echelon: Echelon.squad);
|
final unit = makeUnit(id: 'u1', echelon: Echelon.squad);
|
||||||
final slot = makeSlot(slotId: 's1', unitId: 'u1');
|
final slot = makeSlot(slotId: 's1', unitId: 'u1', mos: '11B');
|
||||||
|
|
||||||
final findings = engine.validate(
|
final findings = engine.validate(
|
||||||
units: [unit],
|
units: [unit],
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,13 @@ import 'package:uto_manager/domain/services/template_instantiator.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
// Use a deterministic counter-based ID generator for tests.
|
// Use a deterministic counter-based ID generator for tests.
|
||||||
var counter0 = 0;
|
int _counter = 0;
|
||||||
String nextId() => 'id-${counter0++}';
|
String nextId() => 'id-${_counter++}';
|
||||||
|
|
||||||
late TemplateInstantiator instantiator;
|
late TemplateInstantiator instantiator;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
counter0 = 0;
|
_counter = 0;
|
||||||
instantiator = TemplateInstantiator(idGenerator: nextId);
|
instantiator = TemplateInstantiator(idGenerator: nextId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -109,12 +109,12 @@ void main() {
|
||||||
|
|
||||||
group('parent-child relationships', () {
|
group('parent-child relationships', () {
|
||||||
test('child unit has correct parentUnitId', () {
|
test('child unit has correct parentUnitId', () {
|
||||||
const def = UnitDefinition(
|
final def = UnitDefinition(
|
||||||
name: 'Battalion',
|
name: 'Battalion',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
children: [
|
children: [
|
||||||
UnitDefinition(
|
const UnitDefinition(
|
||||||
name: 'Alpha Company',
|
name: 'Alpha Company',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
|
|
@ -131,7 +131,7 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deeply nested tree has correct parent chain', () {
|
test('deeply nested tree has correct parent chain', () {
|
||||||
const def = UnitDefinition(
|
final def = UnitDefinition(
|
||||||
name: 'Brigade',
|
name: 'Brigade',
|
||||||
echelon: 'brigade',
|
echelon: 'brigade',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
|
|
@ -141,7 +141,7 @@ void main() {
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
children: [
|
children: [
|
||||||
UnitDefinition(
|
const UnitDefinition(
|
||||||
name: 'Company',
|
name: 'Company',
|
||||||
echelon: 'company',
|
echelon: 'company',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
|
|
@ -164,14 +164,14 @@ void main() {
|
||||||
|
|
||||||
test('all generated IDs are unique', () {
|
test('all generated IDs are unique', () {
|
||||||
// Use UUID-style generator to verify uniqueness.
|
// Use UUID-style generator to verify uniqueness.
|
||||||
var counter = 0;
|
int counter = 0;
|
||||||
final inst = TemplateInstantiator(idGenerator: () => 'uid-${counter++}');
|
final inst = TemplateInstantiator(idGenerator: () => 'uid-${counter++}');
|
||||||
|
|
||||||
const def = UnitDefinition(
|
final def = UnitDefinition(
|
||||||
name: 'Brigade',
|
name: 'Brigade',
|
||||||
echelon: 'brigade',
|
echelon: 'brigade',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
slots: [
|
slots: const [
|
||||||
SlotDefinition(
|
SlotDefinition(
|
||||||
positionTitle: 'Commander',
|
positionTitle: 'Commander',
|
||||||
dutyMos: '11A',
|
dutyMos: '11A',
|
||||||
|
|
@ -179,7 +179,7 @@ void main() {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
children: [
|
children: [
|
||||||
UnitDefinition(
|
const UnitDefinition(
|
||||||
name: 'Battalion',
|
name: 'Battalion',
|
||||||
echelon: 'battalion',
|
echelon: 'battalion',
|
||||||
branch: 'infantry',
|
branch: 'infantry',
|
||||||
|
|
@ -293,7 +293,7 @@ void main() {
|
||||||
'abct',
|
'abct',
|
||||||
'cab_heavy',
|
'cab_heavy',
|
||||||
'cab_light',
|
'cab_light',
|
||||||
]),);
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('DoctrinalTemplates.displayName returns non-empty strings', () {
|
test('DoctrinalTemplates.displayName returns non-empty strings', () {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ void main() {
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
UnitNode makeUnit(String id, Echelon echelon, {String? parentId}) => UnitNode(
|
UnitNode _makeUnit(String id, Echelon echelon, {String? parentId}) => UnitNode(
|
||||||
unitId: id,
|
unitId: id,
|
||||||
echelon: echelon,
|
echelon: echelon,
|
||||||
branch: DoctrinalBranch.infantry,
|
branch: DoctrinalBranch.infantry,
|
||||||
|
|
@ -23,7 +23,7 @@ void main() {
|
||||||
isKeyLeader: false,
|
isKeyLeader: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
PositionSlot makeSlot(
|
PositionSlot _makeSlot(
|
||||||
String id,
|
String id,
|
||||||
String unitId, {
|
String unitId, {
|
||||||
bool isKeyLeader = false,
|
bool isKeyLeader = false,
|
||||||
|
|
@ -89,8 +89,8 @@ void main() {
|
||||||
|
|
||||||
test('validate() detects vacant key leader (V003)', () async {
|
test('validate() detects vacant key leader (V003)', () async {
|
||||||
await runner.start();
|
await runner.start();
|
||||||
final unit = makeUnit('u1', Echelon.squad);
|
final unit = _makeUnit('u1', Echelon.squad);
|
||||||
final slot = makeSlot('sl1', 'u1', isKeyLeader: true); // vacant
|
final slot = _makeSlot('sl1', 'u1', isKeyLeader: true); // vacant
|
||||||
|
|
||||||
final response = await runner.validate(
|
final response = await runner.validate(
|
||||||
units: [unit],
|
units: [unit],
|
||||||
|
|
@ -104,8 +104,8 @@ void main() {
|
||||||
|
|
||||||
test('validate() detects missing medic in platoon (V004)', () async {
|
test('validate() detects missing medic in platoon (V004)', () async {
|
||||||
await runner.start();
|
await runner.start();
|
||||||
final unit = makeUnit('u1', Echelon.platoon);
|
final unit = _makeUnit('u1', Echelon.platoon);
|
||||||
final slot = makeSlot('sl1', 'u1'); // no 68W slot
|
final slot = _makeSlot('sl1', 'u1'); // no 68W slot
|
||||||
|
|
||||||
final response = await runner.validate(
|
final response = await runner.validate(
|
||||||
units: [unit],
|
units: [unit],
|
||||||
|
|
@ -120,7 +120,7 @@ void main() {
|
||||||
test('validate() returns clean result for fully-staffed valid unit', () async {
|
test('validate() returns clean result for fully-staffed valid unit', () async {
|
||||||
await runner.start();
|
await runner.start();
|
||||||
// A squad with a filled key leader slot and no hierarchy violations.
|
// A squad with a filled key leader slot and no hierarchy violations.
|
||||||
final unit = makeUnit('u1', Echelon.squad);
|
final unit = _makeUnit('u1', Echelon.squad);
|
||||||
final keyLeaderSlot = PositionSlot(
|
final keyLeaderSlot = PositionSlot(
|
||||||
slotId: 'sl1',
|
slotId: 'sl1',
|
||||||
unitId: 'u1',
|
unitId: 'u1',
|
||||||
|
|
@ -135,6 +135,7 @@ void main() {
|
||||||
name: 'SSG Smith',
|
name: 'SSG Smith',
|
||||||
primaryMos: DutyMos.unvalidated('11B'),
|
primaryMos: DutyMos.unvalidated('11B'),
|
||||||
payGrade: AuthorizedRank.e6,
|
payGrade: AuthorizedRank.e6,
|
||||||
|
qualifications: const [],
|
||||||
readinessState: ReadinessState.fullyMissionCapable,
|
readinessState: ReadinessState.fullyMissionCapable,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -152,8 +153,8 @@ void main() {
|
||||||
test('validate() detects structural hierarchy violation (V006)', () async {
|
test('validate() detects structural hierarchy violation (V006)', () async {
|
||||||
await runner.start();
|
await runner.start();
|
||||||
// Squad directly under Brigade — invalid.
|
// Squad directly under Brigade — invalid.
|
||||||
final brigade = makeUnit('bde', Echelon.brigade);
|
final brigade = _makeUnit('bde', Echelon.brigade);
|
||||||
final squad = makeUnit('sq1', Echelon.squad, parentId: 'bde');
|
final squad = _makeUnit('sq1', Echelon.squad, parentId: 'bde');
|
||||||
|
|
||||||
final response = await runner.validate(
|
final response = await runner.validate(
|
||||||
units: [brigade, squad],
|
units: [brigade, squad],
|
||||||
|
|
@ -172,13 +173,13 @@ void main() {
|
||||||
test('validate() results match synchronous engine output', () async {
|
test('validate() results match synchronous engine output', () async {
|
||||||
await runner.start();
|
await runner.start();
|
||||||
final units = [
|
final units = [
|
||||||
makeUnit('bde', Echelon.brigade),
|
_makeUnit('bde', Echelon.brigade),
|
||||||
makeUnit('bn', Echelon.battalion, parentId: 'bde'),
|
_makeUnit('bn', Echelon.battalion, parentId: 'bde'),
|
||||||
makeUnit('co', Echelon.company, parentId: 'bn'),
|
_makeUnit('co', Echelon.company, parentId: 'bn'),
|
||||||
makeUnit('plt', Echelon.platoon, parentId: 'co'),
|
_makeUnit('plt', Echelon.platoon, parentId: 'co'),
|
||||||
];
|
];
|
||||||
final slots = [
|
final slots = [
|
||||||
makeSlot('sl1', 'plt', isKeyLeader: true), // vacant key leader
|
_makeSlot('sl1', 'plt', isKeyLeader: true), // vacant key leader
|
||||||
];
|
];
|
||||||
|
|
||||||
final isolateResponse = await runner.validate(
|
final isolateResponse = await runner.validate(
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:uto_manager/domain/entities/authorized_rank.dart';
|
|
||||||
import 'package:uto_manager/domain/entities/doctrinal_branch.dart';
|
import 'package:uto_manager/domain/entities/doctrinal_branch.dart';
|
||||||
import 'package:uto_manager/domain/entities/echelon.dart';
|
import 'package:uto_manager/domain/entities/echelon.dart';
|
||||||
import 'package:uto_manager/domain/entities/position_slot.dart';
|
import 'package:uto_manager/domain/entities/position_slot.dart';
|
||||||
import 'package:uto_manager/domain/entities/unit_node.dart';
|
import 'package:uto_manager/domain/entities/unit_node.dart';
|
||||||
import 'package:uto_manager/domain/value_objects/duty_mos.dart';
|
import 'package:uto_manager/domain/value_objects/duty_mos.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/authorized_rank.dart';
|
||||||
import 'package:uto_manager/infrastructure/codecs/pdf_chart_exporter.dart';
|
import 'package:uto_manager/infrastructure/codecs/pdf_chart_exporter.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|
@ -14,7 +14,7 @@ void main() {
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
UnitNode makeUnit(
|
UnitNode _makeUnit(
|
||||||
String id,
|
String id,
|
||||||
Echelon echelon, {
|
Echelon echelon, {
|
||||||
String? parentId,
|
String? parentId,
|
||||||
|
|
@ -29,7 +29,7 @@ void main() {
|
||||||
isKeyLeader: false,
|
isKeyLeader: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
PositionSlot makeSlot(String id, String unitId) => PositionSlot(
|
PositionSlot _makeSlot(String id, String unitId) => PositionSlot(
|
||||||
slotId: id,
|
slotId: id,
|
||||||
unitId: unitId,
|
unitId: unitId,
|
||||||
positionTitle: 'Rifleman',
|
positionTitle: 'Rifleman',
|
||||||
|
|
@ -111,7 +111,7 @@ void main() {
|
||||||
|
|
||||||
group('PdfChartExporter — tree rendering', () {
|
group('PdfChartExporter — tree rendering', () {
|
||||||
test('single root unit renders without error', () {
|
test('single root unit renders without error', () {
|
||||||
final units = [makeUnit('u1', Echelon.platoon, name: '1st Platoon')];
|
final units = [_makeUnit('u1', Echelon.platoon, name: '1st Platoon')];
|
||||||
final bytes = exporter.export(
|
final bytes = exporter.export(
|
||||||
units: units,
|
units: units,
|
||||||
slots: const [],
|
slots: const [],
|
||||||
|
|
@ -123,9 +123,9 @@ void main() {
|
||||||
|
|
||||||
test('parent-child tree renders connector lines', () {
|
test('parent-child tree renders connector lines', () {
|
||||||
final units = [
|
final units = [
|
||||||
makeUnit('u1', Echelon.platoon, name: '1st Platoon'),
|
_makeUnit('u1', Echelon.platoon, name: '1st Platoon'),
|
||||||
makeUnit('u2', Echelon.squad, parentId: 'u1', name: '1st Squad'),
|
_makeUnit('u2', Echelon.squad, parentId: 'u1', name: '1st Squad'),
|
||||||
makeUnit('u3', Echelon.squad, parentId: 'u1', name: '2nd Squad'),
|
_makeUnit('u3', Echelon.squad, parentId: 'u1', name: '2nd Squad'),
|
||||||
];
|
];
|
||||||
final bytes = exporter.export(
|
final bytes = exporter.export(
|
||||||
units: units,
|
units: units,
|
||||||
|
|
@ -139,11 +139,11 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('all echelon levels render without error', () {
|
test('all echelon levels render without error', () {
|
||||||
const echelons = Echelon.values;
|
final echelons = Echelon.values;
|
||||||
final units = echelons
|
final units = echelons
|
||||||
.asMap()
|
.asMap()
|
||||||
.entries
|
.entries
|
||||||
.map((e) => makeUnit('u${e.key}', e.value, name: e.value.displayName))
|
.map((e) => _makeUnit('u${e.key}', e.value, name: e.value.displayName))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -158,8 +158,8 @@ void main() {
|
||||||
|
|
||||||
test('unit names appear in PDF content stream', () {
|
test('unit names appear in PDF content stream', () {
|
||||||
final units = [
|
final units = [
|
||||||
makeUnit('u1', Echelon.company, name: 'Alpha Company'),
|
_makeUnit('u1', Echelon.company, name: 'Alpha Company'),
|
||||||
makeUnit('u2', Echelon.platoon, parentId: 'u1', name: '1st Platoon'),
|
_makeUnit('u2', Echelon.platoon, parentId: 'u1', name: '1st Platoon'),
|
||||||
];
|
];
|
||||||
final bytes = exporter.export(
|
final bytes = exporter.export(
|
||||||
units: units,
|
units: units,
|
||||||
|
|
@ -173,8 +173,8 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('slots are accepted without error', () {
|
test('slots are accepted without error', () {
|
||||||
final units = [makeUnit('u1', Echelon.squad)];
|
final units = [_makeUnit('u1', Echelon.squad)];
|
||||||
final slots = List.generate(9, (i) => makeSlot('sl$i', 'u1'));
|
final slots = List.generate(9, (i) => _makeSlot('sl$i', 'u1'));
|
||||||
expect(
|
expect(
|
||||||
() => exporter.export(
|
() => exporter.export(
|
||||||
units: units,
|
units: units,
|
||||||
|
|
@ -222,6 +222,7 @@ void main() {
|
||||||
units: const [],
|
units: const [],
|
||||||
slots: const [],
|
slots: const [],
|
||||||
unitDesignation: 'Test',
|
unitDesignation: 'Test',
|
||||||
|
pageSize: PdfPageSize.tabloid,
|
||||||
);
|
);
|
||||||
final content = String.fromCharCodes(bytes);
|
final content = String.fromCharCodes(bytes);
|
||||||
expect(content, contains('792.0'));
|
expect(content, contains('792.0'));
|
||||||
|
|
@ -264,27 +265,24 @@ void main() {
|
||||||
|
|
||||||
group('PdfChartExporter — large tree', () {
|
group('PdfChartExporter — large tree', () {
|
||||||
test('50-node tree exports without error', () {
|
test('50-node tree exports without error', () {
|
||||||
final units = <UnitNode>[makeUnit('root', Echelon.company, name: 'Alpha Co')];
|
final units = <UnitNode>[];
|
||||||
|
units.add(_makeUnit('root', Echelon.company, name: 'Alpha Co'));
|
||||||
for (var i = 0; i < 3; i++) {
|
for (var i = 0; i < 3; i++) {
|
||||||
units.add(makeUnit('plt$i', Echelon.platoon, parentId: 'root', name: '${i + 1}st Plt'));
|
units.add(_makeUnit('plt$i', Echelon.platoon, parentId: 'root', name: '${i + 1}st Plt'));
|
||||||
for (var j = 0; j < 3; j++) {
|
for (var j = 0; j < 3; j++) {
|
||||||
units.add(
|
units.add(_makeUnit(
|
||||||
makeUnit(
|
|
||||||
'sq${i}_$j',
|
'sq${i}_$j',
|
||||||
Echelon.squad,
|
Echelon.squad,
|
||||||
parentId: 'plt$i',
|
parentId: 'plt$i',
|
||||||
name: '${j + 1}st Sq',
|
name: '${j + 1}st Sq',
|
||||||
),
|
));
|
||||||
);
|
|
||||||
for (var k = 0; k < 2; k++) {
|
for (var k = 0; k < 2; k++) {
|
||||||
units.add(
|
units.add(_makeUnit(
|
||||||
makeUnit(
|
|
||||||
'tm${i}_${j}_$k',
|
'tm${i}_${j}_$k',
|
||||||
Echelon.team,
|
Echelon.team,
|
||||||
parentId: 'sq${i}_$j',
|
parentId: 'sq${i}_$j',
|
||||||
name: '${k + 1} Team',
|
name: '${k + 1} Team',
|
||||||
),
|
));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ void main() {
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
UnitNode makeUnit(String id, {String? parentId}) => UnitNode(
|
UnitNode _makeUnit(String id, {String? parentId}) => UnitNode(
|
||||||
unitId: id,
|
unitId: id,
|
||||||
echelon: Echelon.platoon,
|
echelon: Echelon.platoon,
|
||||||
branch: DoctrinalBranch.infantry,
|
branch: DoctrinalBranch.infantry,
|
||||||
|
|
@ -27,7 +27,7 @@ void main() {
|
||||||
isKeyLeader: false,
|
isKeyLeader: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
PositionSlot makeSlot(String id, String unitId) => PositionSlot(
|
PositionSlot _makeSlot(String id, String unitId) => PositionSlot(
|
||||||
slotId: id,
|
slotId: id,
|
||||||
unitId: unitId,
|
unitId: unitId,
|
||||||
positionTitle: 'Rifleman',
|
positionTitle: 'Rifleman',
|
||||||
|
|
@ -36,11 +36,12 @@ void main() {
|
||||||
isKeyLeader: false,
|
isKeyLeader: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
PersonnelRecord makeSoldier(String id) => PersonnelRecord(
|
PersonnelRecord _makeSoldier(String id) => PersonnelRecord(
|
||||||
soldierId: id,
|
soldierId: id,
|
||||||
name: 'SGT Test',
|
name: 'SGT Test',
|
||||||
primaryMos: DutyMos.unvalidated('11B'),
|
primaryMos: DutyMos.unvalidated('11B'),
|
||||||
payGrade: AuthorizedRank.e5,
|
payGrade: AuthorizedRank.e5,
|
||||||
|
qualifications: const [],
|
||||||
readinessState: ReadinessState.fullyMissionCapable,
|
readinessState: ReadinessState.fullyMissionCapable,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -50,14 +51,14 @@ void main() {
|
||||||
|
|
||||||
group('ProtobufCodec — encode/decode', () {
|
group('ProtobufCodec — encode/decode', () {
|
||||||
test('encodes to non-empty Uint8List', () {
|
test('encodes to non-empty Uint8List', () {
|
||||||
final units = [makeUnit('u1')];
|
final units = [_makeUnit('u1')];
|
||||||
final bytes = codec.encode(units: units, slots: const [], personnel: const []);
|
final bytes = codec.encode(units: units, slots: const [], personnel: const []);
|
||||||
expect(bytes, isA<Uint8List>());
|
expect(bytes, isA<Uint8List>());
|
||||||
expect(bytes.length, greaterThan(12));
|
expect(bytes.length, greaterThan(12));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('header contains correct magic bytes "UTOB"', () {
|
test('header contains correct magic bytes "UTOB"', () {
|
||||||
final bytes = codec.encode(units: [makeUnit('u1')], slots: const [], personnel: const []);
|
final bytes = codec.encode(units: [_makeUnit('u1')], slots: const [], personnel: const []);
|
||||||
// Magic: 0x55544F42
|
// Magic: 0x55544F42
|
||||||
expect(bytes[0], 0x55);
|
expect(bytes[0], 0x55);
|
||||||
expect(bytes[1], 0x54);
|
expect(bytes[1], 0x54);
|
||||||
|
|
@ -66,7 +67,7 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('header contains version 1', () {
|
test('header contains version 1', () {
|
||||||
final bytes = codec.encode(units: [makeUnit('u1')], slots: const [], personnel: const []);
|
final bytes = codec.encode(units: [_makeUnit('u1')], slots: const [], personnel: const []);
|
||||||
// Version at bytes 4–7
|
// Version at bytes 4–7
|
||||||
expect(bytes[4], 0x00);
|
expect(bytes[4], 0x00);
|
||||||
expect(bytes[5], 0x00);
|
expect(bytes[5], 0x00);
|
||||||
|
|
@ -75,7 +76,7 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('round-trip preserves units', () {
|
test('round-trip preserves units', () {
|
||||||
final units = [makeUnit('u1'), makeUnit('u2', parentId: 'u1')];
|
final units = [_makeUnit('u1'), _makeUnit('u2', parentId: 'u1')];
|
||||||
final bytes = codec.encode(units: units, slots: const [], personnel: const []);
|
final bytes = codec.encode(units: units, slots: const [], personnel: const []);
|
||||||
final snapshot = codec.decode(bytes);
|
final snapshot = codec.decode(bytes);
|
||||||
|
|
||||||
|
|
@ -85,8 +86,8 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('round-trip preserves slots', () {
|
test('round-trip preserves slots', () {
|
||||||
final units = [makeUnit('u1')];
|
final units = [_makeUnit('u1')];
|
||||||
final slots = [makeSlot('sl1', 'u1'), makeSlot('sl2', 'u1')];
|
final slots = [_makeSlot('sl1', 'u1'), _makeSlot('sl2', 'u1')];
|
||||||
final bytes = codec.encode(units: units, slots: slots, personnel: const []);
|
final bytes = codec.encode(units: units, slots: slots, personnel: const []);
|
||||||
final snapshot = codec.decode(bytes);
|
final snapshot = codec.decode(bytes);
|
||||||
|
|
||||||
|
|
@ -95,8 +96,8 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('round-trip preserves personnel', () {
|
test('round-trip preserves personnel', () {
|
||||||
final units = [makeUnit('u1')];
|
final units = [_makeUnit('u1')];
|
||||||
final personnel = [makeSoldier('s1'), makeSoldier('s2')];
|
final personnel = [_makeSoldier('s1'), _makeSoldier('s2')];
|
||||||
final bytes = codec.encode(units: units, slots: const [], personnel: personnel);
|
final bytes = codec.encode(units: units, slots: const [], personnel: personnel);
|
||||||
final snapshot = codec.decode(bytes);
|
final snapshot = codec.decode(bytes);
|
||||||
|
|
||||||
|
|
@ -115,9 +116,9 @@ void main() {
|
||||||
test('binary output is smaller than JSON for non-trivial tree', () {
|
test('binary output is smaller than JSON for non-trivial tree', () {
|
||||||
final units = List.generate(
|
final units = List.generate(
|
||||||
20,
|
20,
|
||||||
(i) => makeUnit('u$i', parentId: i > 0 ? 'u${i - 1}' : null),
|
(i) => _makeUnit('u$i', parentId: i > 0 ? 'u${i - 1}' : null),
|
||||||
);
|
);
|
||||||
final slots = List.generate(40, (i) => makeSlot('sl$i', 'u${i % 20}'));
|
final slots = List.generate(40, (i) => _makeSlot('sl$i', 'u${i % 20}'));
|
||||||
|
|
||||||
final binaryBytes = codec.encode(units: units, slots: slots, personnel: const []);
|
final binaryBytes = codec.encode(units: units, slots: slots, personnel: const []);
|
||||||
final jsonLen =
|
final jsonLen =
|
||||||
|
|
@ -150,14 +151,14 @@ void main() {
|
||||||
|
|
||||||
test('decode throws FormatException for wrong version', () {
|
test('decode throws FormatException for wrong version', () {
|
||||||
// Build a valid header but with version = 99.
|
// Build a valid header but with version = 99.
|
||||||
final bytes = codec.encode(units: [makeUnit('u1')], slots: const [], personnel: const []);
|
final bytes = codec.encode(units: [_makeUnit('u1')], slots: const [], personnel: const []);
|
||||||
final tampered = Uint8List.fromList(bytes);
|
final tampered = Uint8List.fromList(bytes);
|
||||||
tampered[7] = 99; // version byte (big-endian, LSB)
|
tampered[7] = 99; // version byte (big-endian, LSB)
|
||||||
expect(() => codec.decode(tampered), throwsA(isA<FormatException>()));
|
expect(() => codec.decode(tampered), throwsA(isA<FormatException>()));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('decode throws FormatException for truncated payload', () {
|
test('decode throws FormatException for truncated payload', () {
|
||||||
final bytes = codec.encode(units: [makeUnit('u1')], slots: const [], personnel: const []);
|
final bytes = codec.encode(units: [_makeUnit('u1')], slots: const [], personnel: const []);
|
||||||
// Truncate to just the header.
|
// Truncate to just the header.
|
||||||
final truncated = bytes.sublist(0, 12);
|
final truncated = bytes.sublist(0, 12);
|
||||||
expect(() => codec.decode(truncated), throwsA(isA<FormatException>()));
|
expect(() => codec.decode(truncated), throwsA(isA<FormatException>()));
|
||||||
|
|
@ -178,7 +179,7 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ratio < 1.0 for compressed data', () {
|
test('ratio < 1.0 for compressed data', () {
|
||||||
final units = List.generate(10, (i) => makeUnit('u$i'));
|
final units = List.generate(10, (i) => _makeUnit('u$i'));
|
||||||
final bytes = codec.encode(units: units, slots: const [], personnel: const []);
|
final bytes = codec.encode(units: units, slots: const [], personnel: const []);
|
||||||
final jsonLen =
|
final jsonLen =
|
||||||
const UnitTreeCodec().encode(units: units, slots: const [], personnel: const []).length;
|
const UnitTreeCodec().encode(units: units, slots: const [], personnel: const []).length;
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ void main() {
|
||||||
// Fixtures
|
// Fixtures
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const brigade = UnitNode(
|
final brigade = UnitNode(
|
||||||
unitId: 'bde-1',
|
unitId: 'bde-1',
|
||||||
echelon: Echelon.brigade,
|
echelon: Echelon.brigade,
|
||||||
branch: DoctrinalBranch.infantry,
|
branch: DoctrinalBranch.infantry,
|
||||||
|
|
@ -23,7 +23,7 @@ void main() {
|
||||||
isKeyLeader: false,
|
isKeyLeader: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
const battalion = UnitNode(
|
final battalion = UnitNode(
|
||||||
unitId: 'bn-1',
|
unitId: 'bn-1',
|
||||||
echelon: Echelon.battalion,
|
echelon: Echelon.battalion,
|
||||||
branch: DoctrinalBranch.infantry,
|
branch: DoctrinalBranch.infantry,
|
||||||
|
|
@ -170,7 +170,8 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('throws FormatException for missing version', () {
|
test('throws FormatException for missing version', () {
|
||||||
final map = codec.encodeToMap(units: [], slots: [], personnel: [])..remove('version');
|
final map = codec.encodeToMap(units: [], slots: [], personnel: []);
|
||||||
|
map.remove('version');
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
() => codec.decodeFromMap(map),
|
() => codec.decodeFromMap(map),
|
||||||
|
|
@ -223,7 +224,7 @@ void main() {
|
||||||
primaryMos: DutyMos.unvalidated('11B'),
|
primaryMos: DutyMos.unvalidated('11B'),
|
||||||
payGrade: AuthorizedRank.e5,
|
payGrade: AuthorizedRank.e5,
|
||||||
readinessState: ReadinessState.fullyMissionCapable,
|
readinessState: ReadinessState.fullyMissionCapable,
|
||||||
qualifications: const ['Airborne', 'Ranger', 'SERE'],
|
qualifications: ['Airborne', 'Ranger', 'SERE'],
|
||||||
);
|
);
|
||||||
final json = codec.encode(
|
final json = codec.encode(
|
||||||
units: [],
|
units: [],
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
@ -28,7 +29,7 @@ void main() {
|
||||||
final unitRepo = DriftUnitRepository(dao: db.unitDao, db: db);
|
final unitRepo = DriftUnitRepository(dao: db.unitDao, db: db);
|
||||||
final personnelRepo = DriftPersonnelRepository(dao: db.personnelDao);
|
final personnelRepo = DriftPersonnelRepository(dao: db.personnelDao);
|
||||||
final slotRepo = DriftPositionSlotRepository(dao: db.positionSlotDao);
|
final slotRepo = DriftPositionSlotRepository(dao: db.positionSlotDao);
|
||||||
const assignmentService = AssignmentService(treeService: treeService);
|
final assignmentService = AssignmentService(treeService: treeService);
|
||||||
|
|
||||||
// Create cubits but do NOT call startWatching() — avoids live streams.
|
// Create cubits but do NOT call startWatching() — avoids live streams.
|
||||||
unitTreeCubit = UnitTreeCubit(
|
unitTreeCubit = UnitTreeCubit(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue