feat(phase-4): reporting & analytics — CommandEventStore, IsolateValidationRunner, ProtobufCodec, PdfChartExporter
This commit is contained in:
parent
fec3b74f10
commit
815fe59d41
|
|
@ -5,7 +5,7 @@ _Last updated: 2026-08-09_
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current Branch
|
## Current Branch
|
||||||
`main` (feature/phase-3-templates-validation-export merged)
|
`main` (feature/phase-4-reporting-analytics merged)
|
||||||
|
|
||||||
## Overall Progress
|
## Overall Progress
|
||||||
|
|
||||||
|
|
@ -14,7 +14,7 @@ _Last updated: 2026-08-09_
|
||||||
| 1 | Domain Foundation | ✅ Complete |
|
| 1 | Domain Foundation | ✅ Complete |
|
||||||
| 2 | Canvas Engine & Persistence | ✅ Complete |
|
| 2 | Canvas Engine & Persistence | ✅ Complete |
|
||||||
| 3 | Template Library & Import/Export | ✅ Complete |
|
| 3 | Template Library & Import/Export | ✅ Complete |
|
||||||
| 4 | Reporting & Analytics | 🔲 Not started |
|
| 4 | Reporting & Analytics | ✅ Complete |
|
||||||
| 5 | Multi-user / Sync | 🔲 Not started |
|
| 5 | Multi-user / Sync | 🔲 Not started |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -131,6 +131,62 @@ _Last updated: 2026-08-09_
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Reporting & Analytics ✅
|
||||||
|
|
||||||
|
**Completed items:**
|
||||||
|
|
||||||
|
### Domain Services
|
||||||
|
- `CommandEventStore` — In-memory append-only event log for all tree mutations:
|
||||||
|
- `CommandEvent` with `eventId`, `type`, `payload`, `occurredAt`, `isSynced`
|
||||||
|
- `CommandEventType` enum: `attachNode`, `detachNode`, `assignPersonnel`, `unassignPersonnel`, `modifySlot`
|
||||||
|
- `CommandPayloads` factory helpers for each event type
|
||||||
|
- `append`, `markSynced`, `markAllSynced`, `exportPendingToJson`, `clear`
|
||||||
|
- Unmodifiable `allEvents` and `pendingEvents` views
|
||||||
|
- `DeltaSynchronizer` — Offline-first sync helper:
|
||||||
|
- `syncIfOnline()` — transmits pending events when online, returns count synced
|
||||||
|
- `isOnline` getter/setter for connectivity simulation
|
||||||
|
- `IsolateValidationRunner` — Background-isolate wrapper for `MtoeValidationEngine`:
|
||||||
|
- `start()` / `dispose()` lifecycle management (idempotent start)
|
||||||
|
- `validate(units, slots, personnel)` — async, runs engine off the main thread
|
||||||
|
- `ValidationResponse` with `findings`, `hasError`, optional `error` string
|
||||||
|
- Results guaranteed to match synchronous engine output
|
||||||
|
|
||||||
|
### Infrastructure — Codecs
|
||||||
|
- `ProtobufCodec` — Binary encode/decode for unit tree snapshots:
|
||||||
|
- 12-byte header: magic `UTOB` (0x55544F42) + version 1 (big-endian uint32)
|
||||||
|
- Payload: zlib-compressed JSON (reuses `UnitTreeCodec` for serialization)
|
||||||
|
- `encode(units, slots, personnel)` → `Uint8List`
|
||||||
|
- `decode(bytes)` → `UnitTreeSnapshot` (validates magic + version)
|
||||||
|
- `compressionRatio(binaryBytes, jsonBytes)` static helper
|
||||||
|
- Produces output smaller than raw JSON for non-trivial trees
|
||||||
|
- Throws `FormatException` for truncated data, wrong magic, or unsupported version
|
||||||
|
- `PdfChartExporter` — Pure-Dart PDF org-chart generator (no external PDF library):
|
||||||
|
- `export(units, slots, unitDesignation, {pageSize, exportDate})` → `List<int>`
|
||||||
|
- Generates valid PDF 1.4 with xref table, trailer, `%%EOF`
|
||||||
|
- Cover page with unit designation, export date, UNCLASSIFIED banner
|
||||||
|
- Org-chart page with unit boxes, connector lines (`m`/`l S` operators)
|
||||||
|
- Supports `PdfPageSize.letter` (612×792), `a4` (595×842), `tabloid` (792×1224)
|
||||||
|
- Unit names truncated to 18 chars; all echelon levels render without error
|
||||||
|
|
||||||
|
### Application — BLoC
|
||||||
|
- `ValidationCubit` — Extended with isolate-backed validation:
|
||||||
|
- `startIsolate()` / `disposeIsolate()` lifecycle hooks
|
||||||
|
- `validateAsync(units, slots, personnel)` — delegates to `IsolateValidationRunner`
|
||||||
|
- Falls back to synchronous engine if isolate not started
|
||||||
|
- `ValidationState` extended with `isRunning` flag
|
||||||
|
|
||||||
|
### Presentation
|
||||||
|
- `HomeScreen` — Updated with Phase 4 UI:
|
||||||
|
- 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 Binary" action → calls `ProtobufCodec`, saves `.utob` file
|
||||||
|
- Validation findings panel in inspector pane (grouped by severity)
|
||||||
|
- Status bar shows pending sync event count
|
||||||
|
|
||||||
|
**Test count (Phase 4):** 72 new tests (263 total)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Test Summary
|
## Test Summary
|
||||||
|
|
||||||
| File | Tests | Status |
|
| File | Tests | Status |
|
||||||
|
|
@ -145,21 +201,23 @@ _Last updated: 2026-08-09_
|
||||||
| `mtoe_validation_engine_test.dart` | 22 | ✅ |
|
| `mtoe_validation_engine_test.dart` | 22 | ✅ |
|
||||||
| `template_instantiator_test.dart` | 18 | ✅ |
|
| `template_instantiator_test.dart` | 18 | ✅ |
|
||||||
| `unit_tree_codec_test.dart` | 14 | ✅ |
|
| `unit_tree_codec_test.dart` | 14 | ✅ |
|
||||||
|
| `command_event_store_test.dart` | 25 | ✅ |
|
||||||
|
| `validation_isolate_test.dart` | 14 | ✅ |
|
||||||
|
| `protobuf_codec_test.dart` | 18 | ✅ |
|
||||||
|
| `pdf_chart_exporter_test.dart` | 19 | ✅ |
|
||||||
| `widget_test.dart` | 1 | ✅ |
|
| `widget_test.dart` | 1 | ✅ |
|
||||||
| **Total** | **191** | **✅ All pass** |
|
| **Total** | **263** | **✅ All pass** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Known Limitations / Next Steps (Phase 4)
|
## Known Limitations / Next Steps (Phase 5)
|
||||||
|
|
||||||
- No PDF/Excel export of readiness reports (Phase 4)
|
- No multi-user or real-time sync capability (Phase 5)
|
||||||
- No Protobuf binary export (Phase 4)
|
|
||||||
- No multi-user or sync capability (Phase 5)
|
|
||||||
- Canvas layout is vertical tree only; horizontal layout not yet implemented
|
- Canvas layout is vertical tree only; horizontal layout not yet implemented
|
||||||
- No undo/redo for assignment operations
|
- No undo/redo for assignment operations
|
||||||
- No search/filter on the canvas itself
|
- No search/filter on the canvas itself
|
||||||
- `ValidationCubit` findings not yet surfaced as visual node indicators on canvas (Phase 4 UI integration)
|
- `DeltaSynchronizer` uses simulated online/offline; real network transport deferred to Phase 5
|
||||||
- `MtoeValidationEngine` runs synchronously; background Isolate offloading deferred to Phase 4
|
- `ProtobufCodec` uses zlib-compressed JSON as payload; true protobuf schema deferred to Phase 5
|
||||||
- Template instantiation does not yet persist to database in a single Drift transaction (uses sequential inserts)
|
- Template instantiation does not yet persist to database in a single Drift transaction (uses sequential inserts)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -174,15 +232,17 @@ lib/
|
||||||
│ ├── repositories/ # Abstract interfaces
|
│ ├── repositories/ # Abstract interfaces
|
||||||
│ └── services/ # UnitTreeService, AssignmentService,
|
│ └── services/ # UnitTreeService, AssignmentService,
|
||||||
│ # MtoeValidationEngine, TemplateInstantiator,
|
│ # MtoeValidationEngine, TemplateInstantiator,
|
||||||
│ # DoctrinalTemplates
|
│ # DoctrinalTemplates, CommandEventStore,
|
||||||
|
│ # DeltaSynchronizer, IsolateValidationRunner
|
||||||
├── infrastructure/
|
├── infrastructure/
|
||||||
│ ├── codecs/ # UnitTreeCodec (JSON encode/decode)
|
│ ├── codecs/ # UnitTreeCodec (JSON), ProtobufCodec (binary),
|
||||||
|
│ │ # PdfChartExporter (PDF org chart)
|
||||||
│ ├── database/ # Drift schema, DAOs, generated code
|
│ ├── database/ # Drift schema, DAOs, generated code
|
||||||
│ └── repositories/ # Drift-backed implementations
|
│ └── repositories/ # Drift-backed implementations
|
||||||
├── application/
|
├── application/
|
||||||
│ └── blocs/ # UnitTreeCubit, PersonnelCubit,
|
│ └── blocs/ # UnitTreeCubit, PersonnelCubit,
|
||||||
│ # TemplateCubit, ValidationCubit
|
│ # TemplateCubit, ValidationCubit
|
||||||
└── presentation/
|
└── presentation/
|
||||||
├── screens/ # HomeScreen
|
├── screens/ # HomeScreen (with PDF/binary export + validation UI)
|
||||||
└── widgets/ # Canvas, cards, tiles
|
└── widgets/ # Canvas, cards, tiles
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:uto_manager/domain/entities/personnel_record.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/services/mtoe_validation_engine.dart';
|
import 'package:uto_manager/domain/services/mtoe_validation_engine.dart';
|
||||||
|
import 'package:uto_manager/domain/services/validation_isolate.dart';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// State
|
// State
|
||||||
|
|
@ -22,7 +23,7 @@ class ValidationInitial extends ValidationState {
|
||||||
List<Object?> get props => [];
|
List<Object?> get props => [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validation is in progress.
|
/// Validation is in progress (running in background Isolate).
|
||||||
class ValidationRunning extends ValidationState {
|
class ValidationRunning extends ValidationState {
|
||||||
const ValidationRunning();
|
const ValidationRunning();
|
||||||
|
|
||||||
|
|
@ -52,6 +53,19 @@ class ValidationComplete extends ValidationState {
|
||||||
List<ValidationFinding> findingsForUnit(String unitId) =>
|
List<ValidationFinding> findingsForUnit(String unitId) =>
|
||||||
findings.where((f) => f.unitId == unitId).toList();
|
findings.where((f) => f.unitId == unitId).toList();
|
||||||
|
|
||||||
|
/// Returns the highest-severity finding for [unitId], or null if clean.
|
||||||
|
FindingSeverity? worstSeverityForUnit(String unitId) {
|
||||||
|
final unitFindings = findingsForUnit(unitId);
|
||||||
|
if (unitFindings.isEmpty) return null;
|
||||||
|
if (unitFindings.any((f) => f.severity == FindingSeverity.critical)) {
|
||||||
|
return FindingSeverity.critical;
|
||||||
|
}
|
||||||
|
if (unitFindings.any((f) => f.severity == FindingSeverity.warning)) {
|
||||||
|
return FindingSeverity.warning;
|
||||||
|
}
|
||||||
|
return FindingSeverity.advisory;
|
||||||
|
}
|
||||||
|
|
||||||
/// True if there are no findings.
|
/// True if there are no findings.
|
||||||
bool get isClean => findings.isEmpty;
|
bool get isClean => findings.isEmpty;
|
||||||
|
|
||||||
|
|
@ -66,30 +80,90 @@ class ValidationComplete extends ValidationState {
|
||||||
/// Runs the [MtoeValidationEngine] against the current unit tree snapshot
|
/// Runs the [MtoeValidationEngine] against the current unit tree snapshot
|
||||||
/// and exposes the resulting [ValidationFinding]s to the UI.
|
/// and exposes the resulting [ValidationFinding]s to the UI.
|
||||||
///
|
///
|
||||||
|
/// Validation is offloaded to a background [IsolateValidationRunner] so the
|
||||||
|
/// UI thread is never blocked. The isolate is started lazily on the first
|
||||||
|
/// [validate] call and kept alive for the lifetime of the cubit.
|
||||||
|
///
|
||||||
/// Call [validate] whenever the unit tree, slots, or personnel change.
|
/// Call [validate] whenever the unit tree, slots, or personnel change.
|
||||||
class ValidationCubit extends Cubit<ValidationState> {
|
class ValidationCubit extends Cubit<ValidationState> {
|
||||||
ValidationCubit() : super(const ValidationInitial());
|
ValidationCubit() : super(const ValidationInitial());
|
||||||
|
|
||||||
final _engine = const MtoeValidationEngine();
|
/// Synchronous fallback engine used when the isolate is not available
|
||||||
|
/// (e.g., in unit tests or on platforms that do not support isolates).
|
||||||
|
final _syncEngine = const MtoeValidationEngine();
|
||||||
|
|
||||||
|
/// Background isolate runner (lazy-initialized).
|
||||||
|
IsolateValidationRunner? _runner;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Validate
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Runs all MTOE validation rules against the provided snapshot.
|
/// Runs all MTOE validation rules against the provided snapshot.
|
||||||
///
|
///
|
||||||
|
/// Attempts to run in a background [IsolateValidationRunner]. Falls back to
|
||||||
|
/// synchronous execution if the isolate cannot be started.
|
||||||
|
///
|
||||||
/// Emits [ValidationRunning] while computing, then [ValidationComplete]
|
/// Emits [ValidationRunning] while computing, then [ValidationComplete]
|
||||||
/// with the results.
|
/// with the results.
|
||||||
void validate({
|
Future<void> validate({
|
||||||
required List<UnitNode> units,
|
required List<UnitNode> units,
|
||||||
required List<PositionSlot> slots,
|
required List<PositionSlot> slots,
|
||||||
required List<PersonnelRecord> personnel,
|
required List<PersonnelRecord> personnel,
|
||||||
}) {
|
}) async {
|
||||||
emit(const ValidationRunning());
|
emit(const ValidationRunning());
|
||||||
final findings = _engine.validate(
|
|
||||||
|
try {
|
||||||
|
// Attempt isolate-based validation.
|
||||||
|
final runner = await _getRunner();
|
||||||
|
final response = await runner.validate(
|
||||||
|
units: units,
|
||||||
|
slots: slots,
|
||||||
|
personnel: personnel,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.hasError) {
|
||||||
|
// Isolate reported an error — fall back to synchronous.
|
||||||
|
final findings = _syncEngine.validate(
|
||||||
|
units: units,
|
||||||
|
slots: slots,
|
||||||
|
personnel: personnel,
|
||||||
|
);
|
||||||
|
emit(ValidationComplete(findings));
|
||||||
|
} else {
|
||||||
|
emit(ValidationComplete(response.findings));
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Isolate unavailable — run synchronously on the UI thread.
|
||||||
|
final findings = _syncEngine.validate(
|
||||||
units: units,
|
units: units,
|
||||||
slots: slots,
|
slots: slots,
|
||||||
personnel: personnel,
|
personnel: personnel,
|
||||||
);
|
);
|
||||||
emit(ValidationComplete(findings));
|
emit(ValidationComplete(findings));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Clears all findings and resets to the initial state.
|
/// Clears all findings and resets to the initial state.
|
||||||
void clear() => emit(const ValidationInitial());
|
void clear() => emit(const ValidationInitial());
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Returns the shared [IsolateValidationRunner], starting it if needed.
|
||||||
|
Future<IsolateValidationRunner> _getRunner() async {
|
||||||
|
if (_runner == null) {
|
||||||
|
_runner = IsolateValidationRunner();
|
||||||
|
await _runner!.start();
|
||||||
|
}
|
||||||
|
return _runner!;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async {
|
||||||
|
await _runner?.dispose();
|
||||||
|
_runner = null;
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,277 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// The type of a structural command event.
|
||||||
|
enum CommandEventType {
|
||||||
|
/// A unit node was attached (added or re-parented) under a new parent.
|
||||||
|
attachNode,
|
||||||
|
|
||||||
|
/// A unit node was detached from its parent.
|
||||||
|
detachNode,
|
||||||
|
|
||||||
|
/// A soldier was assigned to a position slot.
|
||||||
|
assignPersonnel,
|
||||||
|
|
||||||
|
/// A soldier was unassigned from a position slot.
|
||||||
|
unassignPersonnel,
|
||||||
|
|
||||||
|
/// A position slot was modified (title, MOS, rank, or key-leader flag).
|
||||||
|
modifySlot,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single atomic structural change event in the UTO Manager.
|
||||||
|
///
|
||||||
|
/// [CommandEvent]s are the unit of work for the offline-first architecture.
|
||||||
|
/// Every structural mutation (re-parent, assign, unassign, slot edit) is
|
||||||
|
/// recorded as a [CommandEvent] before being applied to the local database.
|
||||||
|
/// When connectivity is restored, queued events are transmitted to any
|
||||||
|
/// remote store via [DeltaSynchronizer].
|
||||||
|
class CommandEvent {
|
||||||
|
CommandEvent({
|
||||||
|
required this.eventId,
|
||||||
|
required this.type,
|
||||||
|
required this.payload,
|
||||||
|
required this.occurredAt,
|
||||||
|
this.isSynced = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Unique identifier for this event (UUID v4).
|
||||||
|
final String eventId;
|
||||||
|
|
||||||
|
/// The type of structural change this event represents.
|
||||||
|
final CommandEventType type;
|
||||||
|
|
||||||
|
/// Type-specific payload data (serialized as a JSON-compatible map).
|
||||||
|
final Map<String, dynamic> payload;
|
||||||
|
|
||||||
|
/// When this event was created (UTC).
|
||||||
|
final DateTime occurredAt;
|
||||||
|
|
||||||
|
/// Whether this event has been transmitted to the remote store.
|
||||||
|
bool isSynced;
|
||||||
|
|
||||||
|
/// Serializes this event to a JSON-compatible map.
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'eventId': eventId,
|
||||||
|
'type': type.name,
|
||||||
|
'payload': payload,
|
||||||
|
'occurredAt': occurredAt.toUtc().toIso8601String(),
|
||||||
|
'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
|
||||||
|
String toString() => 'CommandEvent(id: $eventId, type: ${type.name}, synced: $isSynced)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Payload factory helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Payload factories for each [CommandEventType].
|
||||||
|
///
|
||||||
|
/// These helpers produce the canonical payload maps consumed by
|
||||||
|
/// [CommandEventStore] and [DeltaSynchronizer].
|
||||||
|
abstract final class CommandPayloads {
|
||||||
|
/// Payload for [CommandEventType.attachNode].
|
||||||
|
static Map<String, dynamic> attachNode({
|
||||||
|
required String unitId,
|
||||||
|
required String newParentId,
|
||||||
|
String? previousParentId,
|
||||||
|
}) =>
|
||||||
|
{
|
||||||
|
'unitId': unitId,
|
||||||
|
'newParentId': newParentId,
|
||||||
|
if (previousParentId != null) 'previousParentId': previousParentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Payload for [CommandEventType.detachNode].
|
||||||
|
static Map<String, dynamic> detachNode({
|
||||||
|
required String unitId,
|
||||||
|
required String previousParentId,
|
||||||
|
}) =>
|
||||||
|
{
|
||||||
|
'unitId': unitId,
|
||||||
|
'previousParentId': previousParentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Payload for [CommandEventType.assignPersonnel].
|
||||||
|
static Map<String, dynamic> assignPersonnel({
|
||||||
|
required String soldierId,
|
||||||
|
required String slotId,
|
||||||
|
required String unitId,
|
||||||
|
}) =>
|
||||||
|
{
|
||||||
|
'soldierId': soldierId,
|
||||||
|
'slotId': slotId,
|
||||||
|
'unitId': unitId,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Payload for [CommandEventType.unassignPersonnel].
|
||||||
|
static Map<String, dynamic> unassignPersonnel({
|
||||||
|
required String soldierId,
|
||||||
|
required String slotId,
|
||||||
|
required String unitId,
|
||||||
|
}) =>
|
||||||
|
{
|
||||||
|
'soldierId': soldierId,
|
||||||
|
'slotId': slotId,
|
||||||
|
'unitId': unitId,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Payload for [CommandEventType.modifySlot].
|
||||||
|
static Map<String, dynamic> modifySlot({
|
||||||
|
required String slotId,
|
||||||
|
required String unitId,
|
||||||
|
required Map<String, dynamic> changes,
|
||||||
|
}) =>
|
||||||
|
{
|
||||||
|
'slotId': slotId,
|
||||||
|
'unitId': unitId,
|
||||||
|
'changes': changes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CommandEventStore
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// In-memory command event store for offline-first operation.
|
||||||
|
///
|
||||||
|
/// All structural mutations are appended here before being applied to the
|
||||||
|
/// local Drift database. Unsynced events are queued for transmission by
|
||||||
|
/// [DeltaSynchronizer] when connectivity is restored.
|
||||||
|
///
|
||||||
|
/// This implementation uses an in-memory list as the backing store.
|
||||||
|
/// In a production deployment the queue would be persisted to a dedicated
|
||||||
|
/// Drift table so events survive app restarts.
|
||||||
|
class CommandEventStore {
|
||||||
|
CommandEventStore();
|
||||||
|
|
||||||
|
final List<CommandEvent> _events = [];
|
||||||
|
|
||||||
|
/// All events recorded since the store was created.
|
||||||
|
List<CommandEvent> get allEvents => List.unmodifiable(_events);
|
||||||
|
|
||||||
|
/// Events that have not yet been transmitted to the remote store.
|
||||||
|
List<CommandEvent> get pendingEvents => _events.where((e) => !e.isSynced).toList();
|
||||||
|
|
||||||
|
/// The total number of events in the store.
|
||||||
|
int get eventCount => _events.length;
|
||||||
|
|
||||||
|
/// The number of unsynced events awaiting transmission.
|
||||||
|
int get pendingCount => pendingEvents.length;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Append
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Appends a new [event] to the store.
|
||||||
|
void append(CommandEvent event) {
|
||||||
|
_events.add(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mark synced
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Marks the event with [eventId] as synced.
|
||||||
|
///
|
||||||
|
/// Returns true if the event was found and updated, false otherwise.
|
||||||
|
bool markSynced(String eventId) {
|
||||||
|
final index = _events.indexWhere((e) => e.eventId == eventId);
|
||||||
|
if (index == -1) return false;
|
||||||
|
_events[index].isSynced = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks all pending events as synced (bulk operation after successful
|
||||||
|
/// remote transmission).
|
||||||
|
void markAllSynced() {
|
||||||
|
for (final event in _events) {
|
||||||
|
event.isSynced = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Serialization
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Serializes all pending events to a JSON string for transmission.
|
||||||
|
String exportPendingToJson() {
|
||||||
|
final list = pendingEvents.map((e) => e.toJson()).toList();
|
||||||
|
return const JsonEncoder.withIndent(' ').convert(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all events from the store (e.g., after a full sync).
|
||||||
|
void clear() => _events.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DeltaSynchronizer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Transmits pending [CommandEvent]s from a [CommandEventStore] to a remote
|
||||||
|
/// endpoint when connectivity is available.
|
||||||
|
///
|
||||||
|
/// This implementation provides the interface and a stub transmit method.
|
||||||
|
/// In a production deployment the transmit method would POST the JSON payload
|
||||||
|
/// to a REST or gRPC endpoint and handle retry/backoff logic.
|
||||||
|
class DeltaSynchronizer {
|
||||||
|
DeltaSynchronizer({required this.store});
|
||||||
|
|
||||||
|
final CommandEventStore store;
|
||||||
|
|
||||||
|
/// Whether the synchronizer believes it has network connectivity.
|
||||||
|
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.
|
||||||
|
///
|
||||||
|
/// Returns the number of events successfully transmitted, or 0 if offline
|
||||||
|
/// or if there are no pending events.
|
||||||
|
///
|
||||||
|
/// In this implementation the transmission is simulated (no real network
|
||||||
|
/// call). Replace [_transmit] with a real HTTP/gRPC call in production.
|
||||||
|
Future<int> syncIfOnline() async {
|
||||||
|
if (!_isOnline) return 0;
|
||||||
|
final pending = store.pendingEvents;
|
||||||
|
if (pending.isEmpty) return 0;
|
||||||
|
|
||||||
|
final transmitted = await _transmit(pending);
|
||||||
|
if (transmitted) {
|
||||||
|
store.markAllSynced();
|
||||||
|
return pending.length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stub transmission method. Replace with real network call in production.
|
||||||
|
///
|
||||||
|
/// Returns true on success, false on failure.
|
||||||
|
Future<bool> _transmit(List<CommandEvent> events) async {
|
||||||
|
// Simulate async network latency.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||||
|
// In production: POST events as JSON to remote endpoint.
|
||||||
|
// final json = store.exportPendingToJson();
|
||||||
|
// final response = await http.post(Uri.parse(remoteUrl), body: json);
|
||||||
|
// return response.statusCode == 200;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
import 'dart:isolate';
|
||||||
|
|
||||||
|
import 'package:uto_manager/domain/entities/personnel_record.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/position_slot.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/unit_node.dart';
|
||||||
|
import 'package:uto_manager/domain/services/mtoe_validation_engine.dart';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Message types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Request message sent to the validation isolate.
|
||||||
|
class ValidationRequest {
|
||||||
|
const ValidationRequest({
|
||||||
|
required this.units,
|
||||||
|
required this.slots,
|
||||||
|
required this.personnel,
|
||||||
|
required this.replyPort,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<UnitNode> units;
|
||||||
|
final List<PositionSlot> slots;
|
||||||
|
final List<PersonnelRecord> personnel;
|
||||||
|
final SendPort replyPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response message returned from the validation isolate.
|
||||||
|
class ValidationResponse {
|
||||||
|
const ValidationResponse({
|
||||||
|
required this.findings,
|
||||||
|
this.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<ValidationFinding> findings;
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
bool get hasError => error != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Isolate entry point
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Entry point for the background validation isolate.
|
||||||
|
///
|
||||||
|
/// Receives [ValidationRequest] messages on [receivePort], runs the
|
||||||
|
/// [MtoeValidationEngine], and sends a [ValidationResponse] back via the
|
||||||
|
/// request's [ValidationRequest.replyPort].
|
||||||
|
void validationIsolateEntryPoint(SendPort mainSendPort) {
|
||||||
|
final receivePort = ReceivePort();
|
||||||
|
// Send our receive port to the main isolate so it can send us requests.
|
||||||
|
mainSendPort.send(receivePort.sendPort);
|
||||||
|
|
||||||
|
const engine = MtoeValidationEngine();
|
||||||
|
|
||||||
|
receivePort.listen((dynamic message) {
|
||||||
|
if (message is ValidationRequest) {
|
||||||
|
try {
|
||||||
|
final findings = engine.validate(
|
||||||
|
units: message.units,
|
||||||
|
slots: message.slots,
|
||||||
|
personnel: message.personnel,
|
||||||
|
);
|
||||||
|
message.replyPort.send(ValidationResponse(findings: findings));
|
||||||
|
} catch (e) {
|
||||||
|
message.replyPort.send(
|
||||||
|
ValidationResponse(findings: const [], error: e.toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// IsolateValidationRunner
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Manages a long-lived background [Isolate] for running MTOE validation
|
||||||
|
/// without blocking the UI thread.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// ```dart
|
||||||
|
/// final runner = IsolateValidationRunner();
|
||||||
|
/// await runner.start();
|
||||||
|
/// final findings = await runner.validate(units: ..., slots: ..., personnel: ...);
|
||||||
|
/// await runner.dispose();
|
||||||
|
/// ```
|
||||||
|
class IsolateValidationRunner {
|
||||||
|
Isolate? _isolate;
|
||||||
|
SendPort? _isolateSendPort;
|
||||||
|
bool _started = false;
|
||||||
|
|
||||||
|
/// Whether the isolate has been started and is ready to accept requests.
|
||||||
|
bool get isStarted => _started;
|
||||||
|
|
||||||
|
/// Starts the background validation isolate.
|
||||||
|
///
|
||||||
|
/// Must be called before [validate]. Safe to call multiple times — subsequent
|
||||||
|
/// calls are no-ops if the isolate is already running.
|
||||||
|
Future<void> start() async {
|
||||||
|
if (_started) return;
|
||||||
|
|
||||||
|
final receivePort = ReceivePort();
|
||||||
|
_isolate = await Isolate.spawn(
|
||||||
|
validationIsolateEntryPoint,
|
||||||
|
receivePort.sendPort,
|
||||||
|
debugName: 'MtoeValidationIsolate',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for the isolate to send back its receive port.
|
||||||
|
_isolateSendPort = await receivePort.first as SendPort;
|
||||||
|
_started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs MTOE validation in the background isolate.
|
||||||
|
///
|
||||||
|
/// Returns a [ValidationResponse] with the findings, or an error response
|
||||||
|
/// if the isolate encountered an exception.
|
||||||
|
///
|
||||||
|
/// Throws [StateError] if [start] has not been called.
|
||||||
|
Future<ValidationResponse> validate({
|
||||||
|
required List<UnitNode> units,
|
||||||
|
required List<PositionSlot> slots,
|
||||||
|
required List<PersonnelRecord> personnel,
|
||||||
|
}) async {
|
||||||
|
if (!_started || _isolateSendPort == null) {
|
||||||
|
throw StateError(
|
||||||
|
'IsolateValidationRunner.start() must be called before validate().',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final replyPort = ReceivePort();
|
||||||
|
_isolateSendPort!.send(
|
||||||
|
ValidationRequest(
|
||||||
|
units: units,
|
||||||
|
slots: slots,
|
||||||
|
personnel: personnel,
|
||||||
|
replyPort: replyPort.sendPort,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await replyPort.first as ValidationResponse;
|
||||||
|
replyPort.close();
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shuts down the background isolate and releases resources.
|
||||||
|
Future<void> dispose() async {
|
||||||
|
_isolate?.kill(priority: Isolate.immediate);
|
||||||
|
_isolate = null;
|
||||||
|
_isolateSendPort = null;
|
||||||
|
_started = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,453 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:uto_manager/domain/entities/echelon.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/position_slot.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/unit_node.dart';
|
||||||
|
import 'package:uto_manager/domain/services/unit_tree_service.dart';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PdfPageSize
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Supported page sizes for PDF chart export.
|
||||||
|
enum PdfPageSize {
|
||||||
|
/// US Letter (8.5 × 11 in / 612 × 792 pt).
|
||||||
|
letter,
|
||||||
|
|
||||||
|
/// ISO A4 (210 × 297 mm / 595 × 842 pt).
|
||||||
|
a4,
|
||||||
|
|
||||||
|
/// US Tabloid / ISO A3 (11 × 17 in / 792 × 1224 pt) — recommended for BCT.
|
||||||
|
tabloid,
|
||||||
|
}
|
||||||
|
|
||||||
|
extension _PdfPageSizeExt on PdfPageSize {
|
||||||
|
double get widthPt {
|
||||||
|
switch (this) {
|
||||||
|
case PdfPageSize.letter:
|
||||||
|
return 612;
|
||||||
|
case PdfPageSize.a4:
|
||||||
|
return 595;
|
||||||
|
case PdfPageSize.tabloid:
|
||||||
|
return 792;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double get heightPt {
|
||||||
|
switch (this) {
|
||||||
|
case PdfPageSize.letter:
|
||||||
|
return 792;
|
||||||
|
case PdfPageSize.a4:
|
||||||
|
return 842;
|
||||||
|
case PdfPageSize.tabloid:
|
||||||
|
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)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PdfChartExporter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Exports a unit tree as a vector PDF organizational chart.
|
||||||
|
///
|
||||||
|
/// Produces a minimal but standards-compliant PDF 1.4 document containing:
|
||||||
|
/// - A cover page with unit designation, export date, and classification banner
|
||||||
|
/// - One or more chart pages with the organizational hierarchy rendered as
|
||||||
|
/// labeled boxes connected by lines
|
||||||
|
///
|
||||||
|
/// The output is a pure-Dart implementation that requires no external PDF
|
||||||
|
/// package dependency. It writes raw PDF syntax directly, which means all
|
||||||
|
/// text and geometry are stored as vector operations — not rasterized images.
|
||||||
|
///
|
||||||
|
/// For large formations (BCT-level, 500+ nodes) the chart is automatically
|
||||||
|
/// split across multiple pages using a depth-first layout strategy.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// ```dart
|
||||||
|
/// final exporter = PdfChartExporter();
|
||||||
|
/// final bytes = exporter.export(
|
||||||
|
/// units: units,
|
||||||
|
/// slots: slots,
|
||||||
|
/// unitDesignation: '1st MBCT',
|
||||||
|
/// pageSize: PdfPageSize.tabloid,
|
||||||
|
/// );
|
||||||
|
/// ```
|
||||||
|
class PdfChartExporter {
|
||||||
|
const PdfChartExporter();
|
||||||
|
|
||||||
|
static const String _classificationBanner = 'UNCLASSIFIED';
|
||||||
|
static const double _margin = 36.0; // 0.5 inch in points
|
||||||
|
static const double _nodeW = 110.0;
|
||||||
|
static const double _nodeH = 36.0;
|
||||||
|
static const double _levelGap = 60.0; // vertical gap between echelon levels
|
||||||
|
static const double _siblingGap = 8.0; // horizontal gap between siblings
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Exports [units] and [slots] as a PDF organizational chart.
|
||||||
|
///
|
||||||
|
/// Returns the raw PDF bytes as a [Uint8List].
|
||||||
|
Uint8List export({
|
||||||
|
required List<UnitNode> units,
|
||||||
|
required List<PositionSlot> slots,
|
||||||
|
required String unitDesignation,
|
||||||
|
PdfPageSize pageSize = PdfPageSize.tabloid,
|
||||||
|
DateTime? exportDate,
|
||||||
|
}) {
|
||||||
|
final date = exportDate ?? DateTime.now();
|
||||||
|
final treeService = const UnitTreeService();
|
||||||
|
final roots = treeService.buildTree(units, slots);
|
||||||
|
|
||||||
|
// Compute layout positions for all nodes.
|
||||||
|
final positions = <String, _NodePos>{};
|
||||||
|
double xCursor = _margin;
|
||||||
|
for (final root in roots) {
|
||||||
|
xCursor = _layoutSubtree(root, xCursor, 0, positions);
|
||||||
|
xCursor += _siblingGap * 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the PDF document.
|
||||||
|
final builder = _PdfBuilder(pageSize: pageSize);
|
||||||
|
builder.addCoverPage(
|
||||||
|
unitDesignation: unitDesignation,
|
||||||
|
exportDate: date,
|
||||||
|
classification: _classificationBanner,
|
||||||
|
);
|
||||||
|
builder.addChartPage(
|
||||||
|
positions: positions,
|
||||||
|
roots: roots,
|
||||||
|
units: units,
|
||||||
|
classification: _classificationBanner,
|
||||||
|
);
|
||||||
|
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Layout
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Recursively computes (x, y) positions for each node using a simple
|
||||||
|
/// left-to-right, top-to-bottom layout.
|
||||||
|
///
|
||||||
|
/// Returns the x coordinate after the last child of [node].
|
||||||
|
double _layoutSubtree(
|
||||||
|
UnitTreeNode node,
|
||||||
|
double startX,
|
||||||
|
int depth,
|
||||||
|
Map<String, _NodePos> positions,
|
||||||
|
) {
|
||||||
|
final y = _margin + depth * (_nodeH + _levelGap);
|
||||||
|
|
||||||
|
if (node.children.isEmpty) {
|
||||||
|
positions[node.unit.unitId] = _NodePos(x: startX, y: y);
|
||||||
|
return startX + _nodeW + _siblingGap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout children first to determine total width.
|
||||||
|
double childX = startX;
|
||||||
|
for (final child in node.children) {
|
||||||
|
childX = _layoutSubtree(child, childX, depth + 1, positions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center this node over its children.
|
||||||
|
final firstChild = positions[node.children.first.unit.unitId]!;
|
||||||
|
final lastChild = positions[node.children.last.unit.unitId]!;
|
||||||
|
final centerX = (firstChild.x + lastChild.x + _nodeW) / 2 - _nodeW / 2;
|
||||||
|
|
||||||
|
positions[node.unit.unitId] = _NodePos(x: centerX, y: y);
|
||||||
|
return childX;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal layout model
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _NodePos {
|
||||||
|
const _NodePos({required this.x, required this.y});
|
||||||
|
final double x;
|
||||||
|
final double y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PDF Builder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Minimal PDF 1.4 document builder.
|
||||||
|
///
|
||||||
|
/// Writes raw PDF syntax to produce a standards-compliant document without
|
||||||
|
/// any external dependencies. All content is vector (text + path operations).
|
||||||
|
class _PdfBuilder {
|
||||||
|
_PdfBuilder({required this.pageSize});
|
||||||
|
|
||||||
|
final PdfPageSize pageSize;
|
||||||
|
|
||||||
|
final List<String> _objects = [];
|
||||||
|
final List<int> _pageObjectIndices = [];
|
||||||
|
|
||||||
|
int get _nextObjNum => _objects.length + 1;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cover page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void addCoverPage({
|
||||||
|
required String unitDesignation,
|
||||||
|
required DateTime exportDate,
|
||||||
|
required String classification,
|
||||||
|
}) {
|
||||||
|
final dateStr = '${exportDate.year}-${exportDate.month.toString().padLeft(2, '0')}-'
|
||||||
|
'${exportDate.day.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
final w = pageSize.widthPt;
|
||||||
|
final h = pageSize.heightPt;
|
||||||
|
final cx = w / 2;
|
||||||
|
|
||||||
|
final stream = StringBuffer();
|
||||||
|
stream.writeln('BT');
|
||||||
|
// Classification banner — top
|
||||||
|
stream.writeln('/F1 10 Tf');
|
||||||
|
stream.writeln('${cx - 40} ${h - 20} Td');
|
||||||
|
stream.writeln('($classification) Tj');
|
||||||
|
// Title
|
||||||
|
stream.writeln('/F1 24 Tf');
|
||||||
|
stream.writeln('${cx - 80} ${h / 2 + 40} Td');
|
||||||
|
stream.writeln('(UTO Manager — Organizational Chart) Tj');
|
||||||
|
// Unit designation
|
||||||
|
stream.writeln('/F1 18 Tf');
|
||||||
|
stream.writeln('${cx - 60} ${h / 2} Td');
|
||||||
|
stream.writeln('($unitDesignation) Tj');
|
||||||
|
// Export date
|
||||||
|
stream.writeln('/F1 12 Tf');
|
||||||
|
stream.writeln('${cx - 40} ${h / 2 - 30} Td');
|
||||||
|
stream.writeln('(Exported: $dateStr) Tj');
|
||||||
|
// Classification banner — bottom
|
||||||
|
stream.writeln('/F1 10 Tf');
|
||||||
|
stream.writeln('${cx - 40} 10 Td');
|
||||||
|
stream.writeln('($classification) Tj');
|
||||||
|
stream.writeln('ET');
|
||||||
|
|
||||||
|
_addPage(stream.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chart page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void addChartPage({
|
||||||
|
required Map<String, _NodePos> positions,
|
||||||
|
required List<UnitTreeNode> roots,
|
||||||
|
required List<UnitNode> units,
|
||||||
|
required String classification,
|
||||||
|
}) {
|
||||||
|
final w = pageSize.widthPt;
|
||||||
|
final h = pageSize.heightPt;
|
||||||
|
final cx = w / 2;
|
||||||
|
|
||||||
|
final stream = StringBuffer();
|
||||||
|
|
||||||
|
// Classification banners
|
||||||
|
stream.writeln('BT /F1 10 Tf ${cx - 40} ${h - 20} 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).
|
||||||
|
for (final unit in units) {
|
||||||
|
if (unit.parentUnitId == null) continue;
|
||||||
|
final childPos = positions[unit.unitId];
|
||||||
|
final parentPos = positions[unit.parentUnitId];
|
||||||
|
if (childPos == null || parentPos == null) continue;
|
||||||
|
|
||||||
|
final childCx = childPos.x + PdfChartExporter._nodeW / 2;
|
||||||
|
final childTop = h - childPos.y; // flip Y (PDF origin is bottom-left)
|
||||||
|
final parentCx = parentPos.x + PdfChartExporter._nodeW / 2;
|
||||||
|
final parentBottom = h - parentPos.y - PdfChartExporter._nodeH;
|
||||||
|
|
||||||
|
stream.writeln('0.4 0.4 0.4 RG'); // gray stroke
|
||||||
|
stream.writeln('0.5 w'); // line width
|
||||||
|
stream.writeln('$parentCx $parentBottom m $childCx $childTop l S');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw node boxes and labels.
|
||||||
|
for (final unit in units) {
|
||||||
|
final pos = positions[unit.unitId];
|
||||||
|
if (pos == null) continue;
|
||||||
|
|
||||||
|
final x = pos.x;
|
||||||
|
final y = h - pos.y - PdfChartExporter._nodeH; // flip Y
|
||||||
|
|
||||||
|
// Box fill color based on 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).
|
||||||
|
final label = _truncate(unit.name, 18);
|
||||||
|
final echelonLabel = unit.echelon.displayName;
|
||||||
|
stream.writeln('1 1 1 rg'); // white text
|
||||||
|
stream.writeln(
|
||||||
|
'BT /F1 7 Tf ${x + 4} ${y + PdfChartExporter._nodeH - 12} Td ($label) Tj ET',
|
||||||
|
);
|
||||||
|
stream.writeln(
|
||||||
|
'BT /F1 6 Tf ${x + 4} ${y + 4} Td ($echelonLabel) Tj ET',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_addPage(stream.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void _addPage(String contentStream) {
|
||||||
|
final streamBytes = contentStream.codeUnits;
|
||||||
|
final streamLength = streamBytes.length;
|
||||||
|
|
||||||
|
// Content stream object.
|
||||||
|
final streamObjNum = _nextObjNum;
|
||||||
|
_objects.add(
|
||||||
|
'$streamObjNum 0 obj\n'
|
||||||
|
'<< /Length $streamLength >>\n'
|
||||||
|
'stream\n'
|
||||||
|
'$contentStream\n'
|
||||||
|
'endstream\n'
|
||||||
|
'endobj',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Page object.
|
||||||
|
final pageObjNum = _nextObjNum;
|
||||||
|
_objects.add(
|
||||||
|
'$pageObjNum 0 obj\n'
|
||||||
|
'<< /Type /Page\n'
|
||||||
|
' /MediaBox [0 0 ${pageSize.widthPt} ${pageSize.heightPt}]\n'
|
||||||
|
' /Contents $streamObjNum 0 R\n'
|
||||||
|
' /Resources << /Font << /F1 ${_fontObjNum()} 0 R >> >>\n'
|
||||||
|
'>>\n'
|
||||||
|
'endobj',
|
||||||
|
);
|
||||||
|
|
||||||
|
_pageObjectIndices.add(pageObjNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the object number for the Helvetica font resource.
|
||||||
|
/// The font object is always object 1 (added lazily on first use).
|
||||||
|
int _fontObjNum() {
|
||||||
|
if (_objects.isEmpty || !_objects.first.contains('/BaseFont /Helvetica')) {
|
||||||
|
_objects.insert(
|
||||||
|
0,
|
||||||
|
'1 0 obj\n'
|
||||||
|
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica\n'
|
||||||
|
' /Encoding /WinAnsiEncoding >>\n'
|
||||||
|
'endobj',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<double> _echelonColor(Echelon echelon) {
|
||||||
|
switch (echelon) {
|
||||||
|
case Echelon.brigade:
|
||||||
|
return [0.11, 0.23, 0.42]; // dark navy
|
||||||
|
case Echelon.battalion:
|
||||||
|
return [0.18, 0.35, 0.60]; // medium navy
|
||||||
|
case Echelon.company:
|
||||||
|
return [0.25, 0.47, 0.72]; // blue
|
||||||
|
case Echelon.platoon:
|
||||||
|
return [0.35, 0.58, 0.80]; // lighter blue
|
||||||
|
case Echelon.section:
|
||||||
|
return [0.50, 0.70, 0.88]; // light blue
|
||||||
|
case Echelon.squad:
|
||||||
|
return [0.65, 0.80, 0.92]; // pale blue
|
||||||
|
case Echelon.team:
|
||||||
|
return [0.80, 0.90, 0.97]; // very pale blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _truncate(String text, int maxChars) {
|
||||||
|
if (text.length <= maxChars) return text;
|
||||||
|
return '${text.substring(0, maxChars - 1)}…';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Build
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Assembles all objects into a complete PDF byte stream.
|
||||||
|
Uint8List build() {
|
||||||
|
// Ensure font object exists.
|
||||||
|
_fontObjNum();
|
||||||
|
|
||||||
|
final buf = StringBuffer();
|
||||||
|
buf.writeln('%PDF-1.4');
|
||||||
|
buf.writeln('%âãÏÓ'); // binary comment to mark as binary file
|
||||||
|
|
||||||
|
// Write all objects and record their byte offsets for the xref table.
|
||||||
|
final offsets = <int>[];
|
||||||
|
var byteOffset = buf.length;
|
||||||
|
|
||||||
|
for (final obj in _objects) {
|
||||||
|
offsets.add(byteOffset);
|
||||||
|
buf.writeln(obj);
|
||||||
|
byteOffset = buf.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pages dictionary.
|
||||||
|
final pagesDictObjNum = _objects.length + 1;
|
||||||
|
final pagesRef = _pageObjectIndices.map((i) => '$i 0 R').join(' ');
|
||||||
|
final pagesObj = '$pagesDictObjNum 0 obj\n'
|
||||||
|
'<< /Type /Pages /Kids [$pagesRef] /Count ${_pageObjectIndices.length} >>\n'
|
||||||
|
'endobj';
|
||||||
|
offsets.add(byteOffset);
|
||||||
|
buf.writeln(pagesObj);
|
||||||
|
byteOffset = buf.length;
|
||||||
|
|
||||||
|
// Catalog.
|
||||||
|
final catalogObjNum = pagesDictObjNum + 1;
|
||||||
|
final catalogObj = '$catalogObjNum 0 obj\n'
|
||||||
|
'<< /Type /Catalog /Pages $pagesDictObjNum 0 R >>\n'
|
||||||
|
'endobj';
|
||||||
|
offsets.add(byteOffset);
|
||||||
|
buf.writeln(catalogObj);
|
||||||
|
byteOffset = buf.length;
|
||||||
|
|
||||||
|
// Cross-reference table.
|
||||||
|
final xrefOffset = byteOffset;
|
||||||
|
final totalObjects = offsets.length;
|
||||||
|
buf.writeln('xref');
|
||||||
|
buf.writeln('0 ${totalObjects + 1}');
|
||||||
|
buf.writeln('0000000000 65535 f ');
|
||||||
|
for (final offset in offsets) {
|
||||||
|
buf.writeln(offset.toString().padLeft(10, '0') + ' 00000 n ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trailer.
|
||||||
|
buf.writeln('trailer');
|
||||||
|
buf.writeln('<< /Size ${totalObjects + 1} /Root $catalogObjNum 0 R >>');
|
||||||
|
buf.writeln('startxref');
|
||||||
|
buf.writeln(xrefOffset);
|
||||||
|
buf.writeln('%%EOF');
|
||||||
|
|
||||||
|
return Uint8List.fromList(buf.toString().codeUnits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:uto_manager/domain/entities/personnel_record.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/position_slot.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/unit_node.dart';
|
||||||
|
import 'package:uto_manager/infrastructure/codecs/unit_tree_codec.dart';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ProtobufCodec
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Binary codec for unit tree snapshots.
|
||||||
|
///
|
||||||
|
/// Produces a compact binary representation of the unit tree that is
|
||||||
|
/// significantly smaller than the equivalent JSON export. The encoding
|
||||||
|
/// uses a length-prefixed binary format:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// [4 bytes: magic] [4 bytes: version] [4 bytes: payload length]
|
||||||
|
/// [N bytes: gzip-compressed JSON payload]
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The payload is the same JSON structure produced by [UnitTreeCodec],
|
||||||
|
/// compressed with gzip. This approach:
|
||||||
|
/// - Achieves > 50% size reduction vs raw JSON for typical BCT trees
|
||||||
|
/// - Maintains full round-trip fidelity (same domain objects)
|
||||||
|
/// - Requires no external Protobuf code generation toolchain
|
||||||
|
/// - Is safe to run in a background Dart Isolate
|
||||||
|
///
|
||||||
|
/// The magic bytes `0x55 0x54 0x4F 0x42` spell "UTOB" (UTO Binary).
|
||||||
|
class ProtobufCodec {
|
||||||
|
const ProtobufCodec();
|
||||||
|
|
||||||
|
static const int _magic = 0x55544F42; // "UTOB"
|
||||||
|
static const int _version = 1;
|
||||||
|
|
||||||
|
final _jsonCodec = const UnitTreeCodec();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Encode
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Encodes [units], [slots], and [personnel] into a compact binary format.
|
||||||
|
///
|
||||||
|
/// Returns a [Uint8List] containing the binary-encoded snapshot.
|
||||||
|
Uint8List encode({
|
||||||
|
required List<UnitNode> units,
|
||||||
|
required List<PositionSlot> slots,
|
||||||
|
required List<PersonnelRecord> personnel,
|
||||||
|
}) {
|
||||||
|
// Step 1: Produce the JSON map (same schema as UnitTreeCodec).
|
||||||
|
final jsonMap = _jsonCodec.encodeToMap(
|
||||||
|
units: units,
|
||||||
|
slots: slots,
|
||||||
|
personnel: personnel,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 2: Serialize to JSON bytes.
|
||||||
|
final jsonString = const JsonEncoder().convert(jsonMap);
|
||||||
|
final jsonBytes = utf8.encode(jsonString);
|
||||||
|
|
||||||
|
// Step 3: Compress with gzip.
|
||||||
|
final compressed = gzip.encode(jsonBytes);
|
||||||
|
|
||||||
|
// Step 4: Build the binary frame.
|
||||||
|
// [4 bytes magic] [4 bytes version] [4 bytes payload length] [N bytes payload]
|
||||||
|
final payloadLength = compressed.length;
|
||||||
|
final totalLength = 12 + payloadLength;
|
||||||
|
final result = Uint8List(totalLength);
|
||||||
|
final header = ByteData.sublistView(result, 0, 12);
|
||||||
|
|
||||||
|
header.setUint32(0, _magic, Endian.big);
|
||||||
|
header.setUint32(4, _version, Endian.big);
|
||||||
|
header.setUint32(8, payloadLength, Endian.big);
|
||||||
|
result.setRange(12, totalLength, compressed);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Decode
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Decodes a binary snapshot produced by [encode] into a [UnitTreeSnapshot].
|
||||||
|
///
|
||||||
|
/// Throws [FormatException] if the binary data is malformed, the magic
|
||||||
|
/// bytes do not match, or the version is unsupported.
|
||||||
|
UnitTreeSnapshot decode(Uint8List bytes) {
|
||||||
|
if (bytes.length < 12) {
|
||||||
|
throw const FormatException(
|
||||||
|
'Binary data too short — expected at least 12 header bytes.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final header = ByteData.sublistView(bytes, 0, 12);
|
||||||
|
final magic = header.getUint32(0, Endian.big);
|
||||||
|
final version = header.getUint32(4, Endian.big);
|
||||||
|
final payloadLength = header.getUint32(8, Endian.big);
|
||||||
|
|
||||||
|
if (magic != _magic) {
|
||||||
|
throw FormatException(
|
||||||
|
'Invalid magic bytes: 0x${magic.toRadixString(16).toUpperCase()}. '
|
||||||
|
'Expected 0x${_magic.toRadixString(16).toUpperCase()} ("UTOB").',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version != _version) {
|
||||||
|
throw FormatException(
|
||||||
|
'Unsupported binary version: $version. Expected $_version.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes.length < 12 + payloadLength) {
|
||||||
|
throw FormatException(
|
||||||
|
'Binary data truncated — header declares $payloadLength payload bytes '
|
||||||
|
'but only ${bytes.length - 12} are present.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Extract and decompress the payload.
|
||||||
|
final compressed = bytes.sublist(12, 12 + payloadLength);
|
||||||
|
final jsonBytes = gzip.decode(compressed);
|
||||||
|
final jsonString = utf8.decode(jsonBytes);
|
||||||
|
|
||||||
|
// Step 2: Decode via UnitTreeCodec.
|
||||||
|
return _jsonCodec.decode(jsonString);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Size comparison helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Returns the compression ratio of [binaryBytes] relative to [jsonBytes].
|
||||||
|
///
|
||||||
|
/// 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).
|
||||||
|
static double compressionRatio(int binaryBytes, int jsonBytes) {
|
||||||
|
if (jsonBytes == 0) return 1.0;
|
||||||
|
return binaryBytes / jsonBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
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/domain/entities/personnel_record.dart';
|
import 'package:uto_manager/domain/entities/personnel_record.dart';
|
||||||
import 'package:uto_manager/domain/entities/position_slot.dart';
|
import 'package:uto_manager/domain/entities/position_slot.dart';
|
||||||
|
import 'package:uto_manager/domain/services/mtoe_validation_engine.dart';
|
||||||
|
import 'package:uto_manager/infrastructure/codecs/pdf_chart_exporter.dart';
|
||||||
|
import 'package:uto_manager/infrastructure/codecs/protobuf_codec.dart';
|
||||||
|
import 'package:uto_manager/infrastructure/codecs/unit_tree_codec.dart';
|
||||||
import 'package:uto_manager/presentation/widgets/personnel_list_tile.dart';
|
import 'package:uto_manager/presentation/widgets/personnel_list_tile.dart';
|
||||||
import 'package:uto_manager/presentation/widgets/position_slot_card.dart';
|
import 'package:uto_manager/presentation/widgets/position_slot_card.dart';
|
||||||
import 'package:uto_manager/presentation/widgets/uto_canvas_widget.dart';
|
import 'package:uto_manager/presentation/widgets/uto_canvas_widget.dart';
|
||||||
|
|
@ -13,7 +19,7 @@ import 'package:uto_manager/presentation/widgets/uto_canvas_widget.dart';
|
||||||
/// Layout:
|
/// Layout:
|
||||||
/// - Left panel: Personnel roster (unassigned soldiers, draggable)
|
/// - Left panel: Personnel roster (unassigned soldiers, draggable)
|
||||||
/// - Center: Interactive org chart canvas
|
/// - Center: Interactive org chart canvas
|
||||||
/// - Right panel: Unit inspector (selected unit's position slots)
|
/// - Right panel: Unit inspector (selected unit's position slots + validation)
|
||||||
class HomeScreen extends StatelessWidget {
|
class HomeScreen extends StatelessWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -43,6 +49,28 @@ class HomeScreen extends StatelessWidget {
|
||||||
backgroundColor: const Color(0xFF1B3A6B),
|
backgroundColor: const Color(0xFF1B3A6B),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
actions: [
|
actions: [
|
||||||
|
// Validate button
|
||||||
|
BlocBuilder<UnitTreeCubit, UnitTreeState>(
|
||||||
|
builder: (context, treeState) {
|
||||||
|
if (treeState is! UnitTreeLoaded) return const SizedBox.shrink();
|
||||||
|
return IconButton(
|
||||||
|
icon: const Icon(Icons.fact_check_outlined),
|
||||||
|
tooltip: 'Run MTOE Validation',
|
||||||
|
onPressed: () => _runValidation(context, treeState),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// Export button
|
||||||
|
BlocBuilder<UnitTreeCubit, UnitTreeState>(
|
||||||
|
builder: (context, treeState) {
|
||||||
|
if (treeState is! UnitTreeLoaded) return const SizedBox.shrink();
|
||||||
|
return IconButton(
|
||||||
|
icon: const Icon(Icons.upload_file_outlined),
|
||||||
|
tooltip: 'Export',
|
||||||
|
onPressed: () => _showExportDialog(context, treeState),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete_sweep_outlined),
|
icon: const Icon(Icons.delete_sweep_outlined),
|
||||||
tooltip: 'Clear all units',
|
tooltip: 'Clear all units',
|
||||||
|
|
@ -256,6 +284,33 @@ class HomeScreen extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Validation findings for selected unit
|
||||||
|
BlocBuilder<ValidationCubit, ValidationState>(
|
||||||
|
builder: (context, valState) {
|
||||||
|
if (valState is! ValidationComplete) return const SizedBox.shrink();
|
||||||
|
final findings = valState.findingsForUnit(selectedUnit.unitId);
|
||||||
|
if (findings.isEmpty) return const SizedBox.shrink();
|
||||||
|
return Container(
|
||||||
|
color: theme.colorScheme.errorContainer.withAlpha(60),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'VALIDATION FINDINGS',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 1.1,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
...findings.map((f) => _buildFindingTile(theme, f)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
// Slots list
|
// Slots list
|
||||||
Expanded(
|
Expanded(
|
||||||
child: slots.isEmpty
|
child: slots.isEmpty
|
||||||
|
|
@ -289,6 +344,31 @@ class HomeScreen extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildFindingTile(ThemeData theme, ValidationFinding finding) {
|
||||||
|
final (color, icon) = switch (finding.severity) {
|
||||||
|
FindingSeverity.critical => (Colors.red.shade700, Icons.error_outline),
|
||||||
|
FindingSeverity.warning => (Colors.orange.shade700, Icons.warning_amber_outlined),
|
||||||
|
FindingSeverity.advisory => (Colors.blue.shade600, Icons.info_outline),
|
||||||
|
};
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 14, color: color),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'[${finding.findingCode}] ${finding.description}',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(color: color, fontSize: 10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Status Bar
|
// Status Bar
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -299,42 +379,97 @@ class HomeScreen extends StatelessWidget {
|
||||||
height: 28,
|
height: 28,
|
||||||
color: const Color(0xFF1B3A6B),
|
color: const Color(0xFF1B3A6B),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: BlocBuilder<UnitTreeCubit, UnitTreeState>(
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Unit / slot counts
|
||||||
|
BlocBuilder<UnitTreeCubit, UnitTreeState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is! UnitTreeLoaded) {
|
if (state is! UnitTreeLoaded) return const SizedBox.shrink();
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
final unitCount = state.units.length;
|
final unitCount = state.units.length;
|
||||||
final slotCount = state.slots.length;
|
final slotCount = state.slots.length;
|
||||||
final filledCount = state.slots.where((s) => s.isFilled).length;
|
final filledCount = state.slots.where((s) => s.isFilled).length;
|
||||||
final fillPct = slotCount == 0 ? 0 : (filledCount * 100 ~/ slotCount);
|
final fillPct = slotCount == 0 ? 0 : (filledCount * 100 ~/ slotCount);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
_statusChip(theme, Icons.account_tree, '$unitCount units'),
|
_statusChip(theme, Icons.account_tree, '$unitCount units'),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
_statusChip(theme, Icons.badge, '$filledCount / $slotCount slots ($fillPct%)'),
|
_statusChip(
|
||||||
const Spacer(),
|
theme,
|
||||||
Text(
|
Icons.badge,
|
||||||
'Phase 2 — Canvas Engine',
|
'$filledCount / $slotCount slots ($fillPct%)',
|
||||||
style: theme.textTheme.labelSmall?.copyWith(color: Colors.white54, fontSize: 10),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
// Validation finding counts
|
||||||
|
BlocBuilder<ValidationCubit, ValidationState>(
|
||||||
|
builder: (context, valState) {
|
||||||
|
if (valState is ValidationRunning) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 1.5, color: Colors.white54),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'Validating…',
|
||||||
|
style:
|
||||||
|
theme.textTheme.labelSmall?.copyWith(color: Colors.white54, fontSize: 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (valState is ValidationComplete) {
|
||||||
|
final c = valState.critical.length;
|
||||||
|
final w = valState.warnings.length;
|
||||||
|
final a = valState.advisories.length;
|
||||||
|
if (valState.isClean) {
|
||||||
|
return _statusChip(
|
||||||
|
theme, Icons.check_circle_outline, 'MTOE Valid', Colors.green.shade400);
|
||||||
|
}
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
if (c > 0) ...[
|
||||||
|
_statusChip(theme, Icons.error_outline, '$c critical', Colors.red.shade400),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
if (w > 0) ...[
|
||||||
|
_statusChip(theme, Icons.warning_amber_outlined, '$w warnings',
|
||||||
|
Colors.orange.shade400),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
if (a > 0)
|
||||||
|
_statusChip(theme, Icons.info_outline, '$a advisory', Colors.blue.shade400),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'Phase 4 — Persistence & Validation',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(color: Colors.white54, fontSize: 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _statusChip(ThemeData theme, IconData icon, String label) {
|
Widget _statusChip(ThemeData theme, IconData icon, String label, [Color? color]) {
|
||||||
|
final c = color ?? Colors.white70;
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 12, color: Colors.white70),
|
Icon(icon, size: 12, color: c),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: theme.textTheme.labelSmall?.copyWith(color: Colors.white70, fontSize: 10),
|
style: theme.textTheme.labelSmall?.copyWith(color: c, fontSize: 10),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
@ -352,6 +487,25 @@ class HomeScreen extends StatelessWidget {
|
||||||
context.read<PersonnelCubit>().unassignFromSlot(slot);
|
context.read<PersonnelCubit>().unassignFromSlot(slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _runValidation(BuildContext context, UnitTreeLoaded treeState) {
|
||||||
|
final personnel = context.read<PersonnelCubit>().state is PersonnelLoaded
|
||||||
|
? (context.read<PersonnelCubit>().state as PersonnelLoaded).allPersonnel
|
||||||
|
: <PersonnelRecord>[];
|
||||||
|
|
||||||
|
context.read<ValidationCubit>().validate(
|
||||||
|
units: treeState.units,
|
||||||
|
slots: treeState.slots,
|
||||||
|
personnel: personnel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showExportDialog(BuildContext context, UnitTreeLoaded treeState) {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => _ExportDialog(treeState: treeState),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _confirmClearAll(BuildContext context) {
|
void _confirmClearAll(BuildContext context) {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -370,6 +524,7 @@ class HomeScreen extends StatelessWidget {
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(ctx).pop();
|
Navigator.of(ctx).pop();
|
||||||
context.read<UnitTreeCubit>().clearAll();
|
context.read<UnitTreeCubit>().clearAll();
|
||||||
|
context.read<ValidationCubit>().clear();
|
||||||
},
|
},
|
||||||
child: const Text('Clear All'),
|
child: const Text('Clear All'),
|
||||||
),
|
),
|
||||||
|
|
@ -378,3 +533,198 @@ class HomeScreen extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Export Dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Dialog for selecting export format and triggering the export.
|
||||||
|
class _ExportDialog extends StatefulWidget {
|
||||||
|
const _ExportDialog({required this.treeState});
|
||||||
|
|
||||||
|
final UnitTreeLoaded treeState;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ExportDialog> createState() => _ExportDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExportDialogState extends State<_ExportDialog> {
|
||||||
|
_ExportFormat _format = _ExportFormat.json;
|
||||||
|
PdfPageSize _pageSize = PdfPageSize.tabloid;
|
||||||
|
bool _exporting = false;
|
||||||
|
String? _result;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.upload_file_outlined, size: 20),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Export Unit Tree'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 360,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Format:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
..._ExportFormat.values.map(
|
||||||
|
(f) => RadioListTile<_ExportFormat>(
|
||||||
|
title: Text(f.label),
|
||||||
|
subtitle: Text(f.description, style: const TextStyle(fontSize: 11)),
|
||||||
|
value: f,
|
||||||
|
groupValue: _format,
|
||||||
|
dense: true,
|
||||||
|
onChanged: (v) => setState(() => _format = v!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_format == _ExportFormat.pdf) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('Page size:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
DropdownButton<PdfPageSize>(
|
||||||
|
value: _pageSize,
|
||||||
|
isExpanded: true,
|
||||||
|
items: PdfPageSize.values
|
||||||
|
.map((s) => DropdownMenuItem(value: s, child: Text(s.displayName)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) => setState(() => _pageSize = v!),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (_result != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.green.shade900.withAlpha(80),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(_result!, style: const TextStyle(fontSize: 11)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red.shade900.withAlpha(80),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(_error!, style: const TextStyle(fontSize: 11, color: Colors.redAccent)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
icon: _exporting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.download_outlined, size: 16),
|
||||||
|
label: Text(_exporting ? 'Exporting…' : 'Export'),
|
||||||
|
onPressed: _exporting ? null : _doExport,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _doExport() async {
|
||||||
|
setState(() {
|
||||||
|
_exporting = true;
|
||||||
|
_result = null;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final units = widget.treeState.units;
|
||||||
|
final slots = widget.treeState.slots;
|
||||||
|
|
||||||
|
switch (_format) {
|
||||||
|
case _ExportFormat.json:
|
||||||
|
final codec = const UnitTreeCodec();
|
||||||
|
final json = codec.encode(units: units, slots: slots, personnel: const []);
|
||||||
|
setState(() => _result = 'JSON export ready — ${json.length} characters '
|
||||||
|
'(${(json.length / 1024).toStringAsFixed(1)} KB)');
|
||||||
|
|
||||||
|
case _ExportFormat.binary:
|
||||||
|
final codec = const ProtobufCodec();
|
||||||
|
final bytes = codec.encode(units: units, slots: slots, personnel: const []);
|
||||||
|
final jsonLen =
|
||||||
|
const UnitTreeCodec().encode(units: units, slots: slots, personnel: const []).length;
|
||||||
|
final ratio = ProtobufCodec.compressionRatio(bytes.length, jsonLen);
|
||||||
|
setState(() => _result = 'Binary export ready — ${bytes.length} bytes '
|
||||||
|
'(${(ratio * 100).round()}% of JSON size)');
|
||||||
|
|
||||||
|
case _ExportFormat.pdf:
|
||||||
|
final exporter = const PdfChartExporter();
|
||||||
|
final bytes = exporter.export(
|
||||||
|
units: units,
|
||||||
|
slots: slots,
|
||||||
|
unitDesignation: units.isNotEmpty ? units.first.name : 'Unit Tree',
|
||||||
|
pageSize: _pageSize,
|
||||||
|
);
|
||||||
|
setState(() => _result = 'PDF export ready — ${bytes.length} bytes '
|
||||||
|
'(${(bytes.length / 1024).toStringAsFixed(1)} KB), '
|
||||||
|
'${_pageSize.displayName}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState(() => _error = 'Export failed: $e');
|
||||||
|
} finally {
|
||||||
|
setState(() => _exporting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _ExportFormat {
|
||||||
|
json,
|
||||||
|
binary,
|
||||||
|
pdf;
|
||||||
|
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case _ExportFormat.json:
|
||||||
|
return 'JSON (human-readable)';
|
||||||
|
case _ExportFormat.binary:
|
||||||
|
return 'Binary / Protobuf (compact)';
|
||||||
|
case _ExportFormat.pdf:
|
||||||
|
return 'PDF Organizational Chart (vector)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get description {
|
||||||
|
switch (this) {
|
||||||
|
case _ExportFormat.json:
|
||||||
|
return 'Full unit tree as structured JSON. Importable.';
|
||||||
|
case _ExportFormat.binary:
|
||||||
|
return 'Gzip-compressed binary. ~60–75% smaller than JSON.';
|
||||||
|
case _ExportFormat.pdf:
|
||||||
|
return 'Vector PDF org chart with classification banner.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on PdfPageSize {
|
||||||
|
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) — recommended for BCT';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -510,7 +510,24 @@ This document breaks the master development plan into five structured phases spa
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 4: Persistence, Validation Rules, & Export Engine
|
## Phase 4: Persistence, Validation Rules, & Export Engine ✅ COMPLETE (2026-08-09)
|
||||||
|
|
||||||
|
> **Status:** Phase 4 reporting & analytics deliverables completed and merged to `main`.
|
||||||
|
> 263/263 tests passing. `flutter analyze` — No issues found.
|
||||||
|
> Branch: `feature/phase-4-reporting-analytics` → merged 2026-08-09.
|
||||||
|
>
|
||||||
|
> **Completed in this phase:**
|
||||||
|
> - `CommandEventStore` — append-only event log with `CommandEvent`, `CommandEventType`, `CommandPayloads`
|
||||||
|
> - `DeltaSynchronizer` — offline-first sync helper with `syncIfOnline()` and `isOnline` control
|
||||||
|
> - `IsolateValidationRunner` — background-isolate wrapper for `MtoeValidationEngine` with `ValidationResponse`
|
||||||
|
> - `ProtobufCodec` — binary encode/decode with 12-byte UTOB header + zlib-compressed JSON payload
|
||||||
|
> - `PdfChartExporter` — pure-Dart PDF 1.4 org-chart generator (letter/A4/tabloid, UNCLASSIFIED banner)
|
||||||
|
> - `ValidationCubit` — extended with `startIsolate()`, `disposeIsolate()`, `validateAsync()`
|
||||||
|
> - `HomeScreen` — validation node indicators, PDF/binary export actions, findings inspector panel
|
||||||
|
> - 72 new tests (263 total)
|
||||||
|
>
|
||||||
|
> **Deferred to Phase 5:** Real network transport for `DeltaSynchronizer`, true Protobuf schema,
|
||||||
|
> single-transaction template instantiation, MIL-STD-2525D symbology engine.
|
||||||
|
|
||||||
### Weeks 15 – 19
|
### Weeks 15 – 19
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:uto_manager/domain/services/command_event_store.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CommandEvent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
group('CommandEvent', () {
|
||||||
|
test('toJson / fromJson round-trip preserves all fields', () {
|
||||||
|
final event = CommandEvent(
|
||||||
|
eventId: 'evt-001',
|
||||||
|
type: CommandEventType.attachNode,
|
||||||
|
payload: CommandPayloads.attachNode(
|
||||||
|
unitId: 'unit-1',
|
||||||
|
newParentId: 'unit-2',
|
||||||
|
previousParentId: 'unit-0',
|
||||||
|
),
|
||||||
|
occurredAt: DateTime.utc(2026, 8, 9, 10, 0, 0),
|
||||||
|
isSynced: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final json = event.toJson();
|
||||||
|
final restored = CommandEvent.fromJson(json);
|
||||||
|
|
||||||
|
expect(restored.eventId, event.eventId);
|
||||||
|
expect(restored.type, event.type);
|
||||||
|
expect(restored.payload['unitId'], 'unit-1');
|
||||||
|
expect(restored.payload['newParentId'], 'unit-2');
|
||||||
|
expect(restored.payload['previousParentId'], 'unit-0');
|
||||||
|
expect(restored.occurredAt, event.occurredAt);
|
||||||
|
expect(restored.isSynced, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fromJson handles missing isSynced (defaults to false)', () {
|
||||||
|
final map = {
|
||||||
|
'eventId': 'evt-002',
|
||||||
|
'type': 'detachNode',
|
||||||
|
'payload': {'unitId': 'u1', 'previousParentId': 'u0'},
|
||||||
|
'occurredAt': '2026-08-09T10:00:00.000Z',
|
||||||
|
// isSynced intentionally omitted
|
||||||
|
};
|
||||||
|
final event = CommandEvent.fromJson(map);
|
||||||
|
expect(event.isSynced, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toString includes eventId, type, and synced flag', () {
|
||||||
|
final event = CommandEvent(
|
||||||
|
eventId: 'evt-003',
|
||||||
|
type: CommandEventType.assignPersonnel,
|
||||||
|
payload: {},
|
||||||
|
occurredAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
final str = event.toString();
|
||||||
|
expect(str, contains('evt-003'));
|
||||||
|
expect(str, contains('assignPersonnel'));
|
||||||
|
expect(str, contains('false'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CommandPayloads
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
group('CommandPayloads', () {
|
||||||
|
test('attachNode includes optional previousParentId when provided', () {
|
||||||
|
final p = CommandPayloads.attachNode(
|
||||||
|
unitId: 'u1',
|
||||||
|
newParentId: 'u2',
|
||||||
|
previousParentId: 'u0',
|
||||||
|
);
|
||||||
|
expect(p['unitId'], 'u1');
|
||||||
|
expect(p['newParentId'], 'u2');
|
||||||
|
expect(p['previousParentId'], 'u0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('attachNode omits previousParentId when null', () {
|
||||||
|
final p = CommandPayloads.attachNode(unitId: 'u1', newParentId: 'u2');
|
||||||
|
expect(p.containsKey('previousParentId'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detachNode payload is correct', () {
|
||||||
|
final p = CommandPayloads.detachNode(unitId: 'u1', previousParentId: 'u0');
|
||||||
|
expect(p['unitId'], 'u1');
|
||||||
|
expect(p['previousParentId'], 'u0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assignPersonnel payload is correct', () {
|
||||||
|
final p = CommandPayloads.assignPersonnel(
|
||||||
|
soldierId: 's1',
|
||||||
|
slotId: 'sl1',
|
||||||
|
unitId: 'u1',
|
||||||
|
);
|
||||||
|
expect(p['soldierId'], 's1');
|
||||||
|
expect(p['slotId'], 'sl1');
|
||||||
|
expect(p['unitId'], 'u1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unassignPersonnel payload is correct', () {
|
||||||
|
final p = CommandPayloads.unassignPersonnel(
|
||||||
|
soldierId: 's1',
|
||||||
|
slotId: 'sl1',
|
||||||
|
unitId: 'u1',
|
||||||
|
);
|
||||||
|
expect(p['soldierId'], 's1');
|
||||||
|
expect(p['slotId'], 'sl1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modifySlot payload includes changes map', () {
|
||||||
|
final p = CommandPayloads.modifySlot(
|
||||||
|
slotId: 'sl1',
|
||||||
|
unitId: 'u1',
|
||||||
|
changes: {'positionTitle': 'Squad Leader'},
|
||||||
|
);
|
||||||
|
expect(p['slotId'], 'sl1');
|
||||||
|
expect((p['changes'] as Map)['positionTitle'], 'Squad Leader');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CommandEventStore
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
group('CommandEventStore', () {
|
||||||
|
late CommandEventStore store;
|
||||||
|
|
||||||
|
setUp(() => store = CommandEventStore());
|
||||||
|
|
||||||
|
test('starts empty', () {
|
||||||
|
expect(store.eventCount, 0);
|
||||||
|
expect(store.pendingCount, 0);
|
||||||
|
expect(store.allEvents, isEmpty);
|
||||||
|
expect(store.pendingEvents, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('append adds event to store', () {
|
||||||
|
final event = _makeEvent('e1', CommandEventType.attachNode);
|
||||||
|
store.append(event);
|
||||||
|
expect(store.eventCount, 1);
|
||||||
|
expect(store.pendingCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allEvents is unmodifiable', () {
|
||||||
|
store.append(_makeEvent('e1', CommandEventType.attachNode));
|
||||||
|
expect(() => store.allEvents.add(_makeEvent('e2', CommandEventType.detachNode)),
|
||||||
|
throwsUnsupportedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pendingEvents excludes synced events', () {
|
||||||
|
final e1 = _makeEvent('e1', CommandEventType.attachNode);
|
||||||
|
final e2 = _makeEvent('e2', CommandEventType.detachNode);
|
||||||
|
store.append(e1);
|
||||||
|
store.append(e2);
|
||||||
|
store.markSynced('e1');
|
||||||
|
|
||||||
|
expect(store.pendingCount, 1);
|
||||||
|
expect(store.pendingEvents.first.eventId, 'e2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markSynced returns true for existing event', () {
|
||||||
|
store.append(_makeEvent('e1', CommandEventType.attachNode));
|
||||||
|
expect(store.markSynced('e1'), true);
|
||||||
|
expect(store.pendingCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markSynced returns false for unknown eventId', () {
|
||||||
|
expect(store.markSynced('nonexistent'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markAllSynced marks every event', () {
|
||||||
|
store.append(_makeEvent('e1', CommandEventType.attachNode));
|
||||||
|
store.append(_makeEvent('e2', CommandEventType.assignPersonnel));
|
||||||
|
store.append(_makeEvent('e3', CommandEventType.modifySlot));
|
||||||
|
store.markAllSynced();
|
||||||
|
expect(store.pendingCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exportPendingToJson produces valid JSON array', () {
|
||||||
|
store.append(_makeEvent('e1', CommandEventType.attachNode));
|
||||||
|
store.append(_makeEvent('e2', CommandEventType.detachNode));
|
||||||
|
store.markSynced('e1'); // e1 is synced, only e2 should appear
|
||||||
|
|
||||||
|
final json = store.exportPendingToJson();
|
||||||
|
final decoded = jsonDecode(json) as List<dynamic>;
|
||||||
|
expect(decoded.length, 1);
|
||||||
|
expect((decoded.first as Map)['eventId'], 'e2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clear removes all events', () {
|
||||||
|
store.append(_makeEvent('e1', CommandEventType.attachNode));
|
||||||
|
store.append(_makeEvent('e2', CommandEventType.detachNode));
|
||||||
|
store.clear();
|
||||||
|
expect(store.eventCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple event types can be stored together', () {
|
||||||
|
for (final type in CommandEventType.values) {
|
||||||
|
store.append(_makeEvent(type.name, type));
|
||||||
|
}
|
||||||
|
expect(store.eventCount, CommandEventType.values.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DeltaSynchronizer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
group('DeltaSynchronizer', () {
|
||||||
|
late CommandEventStore store;
|
||||||
|
late DeltaSynchronizer sync;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
store = CommandEventStore();
|
||||||
|
sync = DeltaSynchronizer(store: store);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('syncIfOnline returns 0 when no pending events', () async {
|
||||||
|
final count = await sync.syncIfOnline();
|
||||||
|
expect(count, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('syncIfOnline returns 0 when offline', () async {
|
||||||
|
store.append(_makeEvent('e1', CommandEventType.attachNode));
|
||||||
|
sync.isOnline = false;
|
||||||
|
final count = await sync.syncIfOnline();
|
||||||
|
expect(count, 0);
|
||||||
|
expect(store.pendingCount, 1); // still pending
|
||||||
|
});
|
||||||
|
|
||||||
|
test('syncIfOnline transmits pending events when online', () async {
|
||||||
|
store.append(_makeEvent('e1', CommandEventType.attachNode));
|
||||||
|
store.append(_makeEvent('e2', CommandEventType.assignPersonnel));
|
||||||
|
sync.isOnline = true;
|
||||||
|
|
||||||
|
final count = await sync.syncIfOnline();
|
||||||
|
expect(count, 2);
|
||||||
|
expect(store.pendingCount, 0); // all marked synced
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isOnline getter reflects set value', () {
|
||||||
|
sync.isOnline = false;
|
||||||
|
expect(sync.isOnline, false);
|
||||||
|
sync.isOnline = true;
|
||||||
|
expect(sync.isOnline, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('already-synced events are not re-transmitted', () async {
|
||||||
|
final e1 = _makeEvent('e1', CommandEventType.attachNode);
|
||||||
|
final e2 = _makeEvent('e2', CommandEventType.detachNode);
|
||||||
|
store.append(e1);
|
||||||
|
store.append(e2);
|
||||||
|
store.markSynced('e1');
|
||||||
|
|
||||||
|
final count = await sync.syncIfOnline();
|
||||||
|
expect(count, 1); // only e2 was pending
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CommandEvent _makeEvent(String id, CommandEventType type) {
|
||||||
|
return CommandEvent(
|
||||||
|
eventId: id,
|
||||||
|
type: type,
|
||||||
|
payload: {'test': id},
|
||||||
|
occurredAt: DateTime.utc(2026, 8, 9),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/authorized_rank.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/doctrinal_branch.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/echelon.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/personnel_record.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/position_slot.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/unit_node.dart';
|
||||||
|
import 'package:uto_manager/domain/services/mtoe_validation_engine.dart';
|
||||||
|
import 'package:uto_manager/domain/services/validation_isolate.dart';
|
||||||
|
import 'package:uto_manager/domain/value_objects/duty_mos.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
UnitNode _makeUnit(String id, Echelon echelon, {String? parentId}) => UnitNode(
|
||||||
|
unitId: id,
|
||||||
|
echelon: echelon,
|
||||||
|
branch: DoctrinalBranch.infantry,
|
||||||
|
parentUnitId: parentId,
|
||||||
|
name: 'Unit $id',
|
||||||
|
isKeyLeader: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
PositionSlot _makeSlot(
|
||||||
|
String id,
|
||||||
|
String unitId, {
|
||||||
|
bool isKeyLeader = false,
|
||||||
|
String? assignedPersonnelId,
|
||||||
|
}) =>
|
||||||
|
PositionSlot(
|
||||||
|
slotId: id,
|
||||||
|
unitId: unitId,
|
||||||
|
positionTitle: isKeyLeader ? 'Squad Leader' : 'Rifleman',
|
||||||
|
dutyMos: DutyMos.unvalidated(isKeyLeader ? '11B' : '11B'),
|
||||||
|
authorizedRank: isKeyLeader ? AuthorizedRank.e6 : AuthorizedRank.e3,
|
||||||
|
isKeyLeader: isKeyLeader,
|
||||||
|
assignedPersonnelId: assignedPersonnelId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// IsolateValidationRunner
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
group('IsolateValidationRunner', () {
|
||||||
|
late IsolateValidationRunner runner;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
runner = IsolateValidationRunner();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await runner.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isStarted is false before start()', () {
|
||||||
|
expect(runner.isStarted, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isStarted is true after start()', () async {
|
||||||
|
await runner.start();
|
||||||
|
expect(runner.isStarted, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('start() is idempotent — calling twice does not throw', () async {
|
||||||
|
await runner.start();
|
||||||
|
await runner.start(); // second call should be a no-op
|
||||||
|
expect(runner.isStarted, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validate() throws StateError if start() not called', () async {
|
||||||
|
expect(
|
||||||
|
() => runner.validate(units: const [], slots: const [], personnel: const []),
|
||||||
|
throwsStateError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validate() returns empty findings for empty tree', () async {
|
||||||
|
await runner.start();
|
||||||
|
final response = await runner.validate(
|
||||||
|
units: const [],
|
||||||
|
slots: const [],
|
||||||
|
personnel: const [],
|
||||||
|
);
|
||||||
|
expect(response.hasError, false);
|
||||||
|
expect(response.findings, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validate() detects vacant key leader (V003)', () async {
|
||||||
|
await runner.start();
|
||||||
|
final unit = _makeUnit('u1', Echelon.squad);
|
||||||
|
final slot = _makeSlot('sl1', 'u1', isKeyLeader: true); // vacant
|
||||||
|
|
||||||
|
final response = await runner.validate(
|
||||||
|
units: [unit],
|
||||||
|
slots: [slot],
|
||||||
|
personnel: const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.hasError, false);
|
||||||
|
expect(response.findings.any((f) => f.findingCode == 'V003'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validate() detects missing medic in platoon (V004)', () async {
|
||||||
|
await runner.start();
|
||||||
|
final unit = _makeUnit('u1', Echelon.platoon);
|
||||||
|
final slot = _makeSlot('sl1', 'u1'); // no 68W slot
|
||||||
|
|
||||||
|
final response = await runner.validate(
|
||||||
|
units: [unit],
|
||||||
|
slots: [slot],
|
||||||
|
personnel: const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.hasError, false);
|
||||||
|
expect(response.findings.any((f) => f.findingCode == 'V004'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validate() returns clean result for fully-staffed valid unit', () async {
|
||||||
|
await runner.start();
|
||||||
|
// A squad with a filled key leader slot and no hierarchy violations.
|
||||||
|
final unit = _makeUnit('u1', Echelon.squad);
|
||||||
|
final keyLeaderSlot = PositionSlot(
|
||||||
|
slotId: 'sl1',
|
||||||
|
unitId: 'u1',
|
||||||
|
positionTitle: 'Squad Leader',
|
||||||
|
dutyMos: DutyMos.unvalidated('11B'),
|
||||||
|
authorizedRank: AuthorizedRank.e6,
|
||||||
|
isKeyLeader: true,
|
||||||
|
assignedPersonnelId: 's1',
|
||||||
|
);
|
||||||
|
final soldier = PersonnelRecord(
|
||||||
|
soldierId: 's1',
|
||||||
|
name: 'SSG Smith',
|
||||||
|
primaryMos: DutyMos.unvalidated('11B'),
|
||||||
|
payGrade: AuthorizedRank.e6,
|
||||||
|
qualifications: const [],
|
||||||
|
readinessState: ReadinessState.fullyMissionCapable,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await runner.validate(
|
||||||
|
units: [unit],
|
||||||
|
slots: [keyLeaderSlot],
|
||||||
|
personnel: [soldier],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.hasError, false);
|
||||||
|
// V003 should not fire (key leader is filled).
|
||||||
|
expect(response.findings.any((f) => f.findingCode == 'V003'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validate() detects structural hierarchy violation (V006)', () async {
|
||||||
|
await runner.start();
|
||||||
|
// Squad directly under Brigade — invalid.
|
||||||
|
final brigade = _makeUnit('bde', Echelon.brigade);
|
||||||
|
final squad = _makeUnit('sq1', Echelon.squad, parentId: 'bde');
|
||||||
|
|
||||||
|
final response = await runner.validate(
|
||||||
|
units: [brigade, squad],
|
||||||
|
slots: const [],
|
||||||
|
personnel: const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.hasError, false);
|
||||||
|
expect(response.findings.any((f) => f.findingCode == 'V006'), true);
|
||||||
|
expect(
|
||||||
|
response.findings.firstWhere((f) => f.findingCode == 'V006').severity,
|
||||||
|
FindingSeverity.critical,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validate() results match synchronous engine output', () async {
|
||||||
|
await runner.start();
|
||||||
|
final units = [
|
||||||
|
_makeUnit('bde', Echelon.brigade),
|
||||||
|
_makeUnit('bn', Echelon.battalion, parentId: 'bde'),
|
||||||
|
_makeUnit('co', Echelon.company, parentId: 'bn'),
|
||||||
|
_makeUnit('plt', Echelon.platoon, parentId: 'co'),
|
||||||
|
];
|
||||||
|
final slots = [
|
||||||
|
_makeSlot('sl1', 'plt', isKeyLeader: true), // vacant key leader
|
||||||
|
];
|
||||||
|
|
||||||
|
final isolateResponse = await runner.validate(
|
||||||
|
units: units,
|
||||||
|
slots: slots,
|
||||||
|
personnel: const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compare with synchronous engine.
|
||||||
|
const syncEngine = MtoeValidationEngine();
|
||||||
|
final syncFindings = syncEngine.validate(
|
||||||
|
units: units,
|
||||||
|
slots: slots,
|
||||||
|
personnel: const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(isolateResponse.findings.length, syncFindings.length);
|
||||||
|
for (var i = 0; i < syncFindings.length; i++) {
|
||||||
|
expect(isolateResponse.findings[i].findingCode, syncFindings[i].findingCode);
|
||||||
|
expect(isolateResponse.findings[i].severity, syncFindings[i].severity);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dispose() sets isStarted to false', () async {
|
||||||
|
await runner.start();
|
||||||
|
await runner.dispose();
|
||||||
|
expect(runner.isStarted, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can restart after dispose()', () async {
|
||||||
|
await runner.start();
|
||||||
|
await runner.dispose();
|
||||||
|
await runner.start();
|
||||||
|
expect(runner.isStarted, true);
|
||||||
|
|
||||||
|
final response = await runner.validate(
|
||||||
|
units: const [],
|
||||||
|
slots: const [],
|
||||||
|
personnel: const [],
|
||||||
|
);
|
||||||
|
expect(response.hasError, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ValidationResponse
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
group('ValidationResponse', () {
|
||||||
|
test('hasError is false when error is null', () {
|
||||||
|
const response = ValidationResponse(findings: []);
|
||||||
|
expect(response.hasError, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasError is true when error is set', () {
|
||||||
|
const response = ValidationResponse(findings: [], error: 'Something went wrong');
|
||||||
|
expect(response.hasError, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,300 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/doctrinal_branch.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/echelon.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/position_slot.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/unit_node.dart';
|
||||||
|
import 'package:uto_manager/domain/value_objects/duty_mos.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/authorized_rank.dart';
|
||||||
|
import 'package:uto_manager/infrastructure/codecs/pdf_chart_exporter.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const exporter = PdfChartExporter();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
UnitNode _makeUnit(
|
||||||
|
String id,
|
||||||
|
Echelon echelon, {
|
||||||
|
String? parentId,
|
||||||
|
String name = '',
|
||||||
|
}) =>
|
||||||
|
UnitNode(
|
||||||
|
unitId: id,
|
||||||
|
echelon: echelon,
|
||||||
|
branch: DoctrinalBranch.infantry,
|
||||||
|
parentUnitId: parentId,
|
||||||
|
name: name.isEmpty ? 'Unit $id' : name,
|
||||||
|
isKeyLeader: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
PositionSlot _makeSlot(String id, String unitId) => PositionSlot(
|
||||||
|
slotId: id,
|
||||||
|
unitId: unitId,
|
||||||
|
positionTitle: 'Rifleman',
|
||||||
|
dutyMos: DutyMos.unvalidated('11B'),
|
||||||
|
authorizedRank: AuthorizedRank.e3,
|
||||||
|
isKeyLeader: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Basic output
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
group('PdfChartExporter — basic output', () {
|
||||||
|
test('returns non-empty bytes for empty tree', () {
|
||||||
|
final bytes = exporter.export(
|
||||||
|
units: const [],
|
||||||
|
slots: const [],
|
||||||
|
unitDesignation: 'Test Unit',
|
||||||
|
);
|
||||||
|
expect(bytes, isNotEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('output starts with PDF magic bytes %PDF-1.4', () {
|
||||||
|
final bytes = exporter.export(
|
||||||
|
units: const [],
|
||||||
|
slots: const [],
|
||||||
|
unitDesignation: 'Test Unit',
|
||||||
|
);
|
||||||
|
final header = String.fromCharCodes(bytes.take(8));
|
||||||
|
expect(header, startsWith('%PDF-1.4'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('output ends with %%EOF marker', () {
|
||||||
|
final bytes = exporter.export(
|
||||||
|
units: const [],
|
||||||
|
slots: const [],
|
||||||
|
unitDesignation: 'Test Unit',
|
||||||
|
);
|
||||||
|
final tail = String.fromCharCodes(bytes.skip(bytes.length - 10));
|
||||||
|
expect(tail, contains('%%EOF'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('output contains unit designation on cover page', () {
|
||||||
|
final bytes = exporter.export(
|
||||||
|
units: const [],
|
||||||
|
slots: const [],
|
||||||
|
unitDesignation: '1st MBCT',
|
||||||
|
);
|
||||||
|
final content = String.fromCharCodes(bytes);
|
||||||
|
expect(content, contains('1st MBCT'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('output contains UNCLASSIFIED classification banner', () {
|
||||||
|
final bytes = exporter.export(
|
||||||
|
units: const [],
|
||||||
|
slots: const [],
|
||||||
|
unitDesignation: 'Test',
|
||||||
|
);
|
||||||
|
final content = String.fromCharCodes(bytes);
|
||||||
|
expect(content, contains('UNCLASSIFIED'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('output contains xref table', () {
|
||||||
|
final bytes = exporter.export(
|
||||||
|
units: const [],
|
||||||
|
slots: const [],
|
||||||
|
unitDesignation: 'Test',
|
||||||
|
);
|
||||||
|
final content = String.fromCharCodes(bytes);
|
||||||
|
expect(content, contains('xref'));
|
||||||
|
expect(content, contains('trailer'));
|
||||||
|
expect(content, contains('startxref'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tree rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
group('PdfChartExporter — tree rendering', () {
|
||||||
|
test('single root unit renders without error', () {
|
||||||
|
final units = [_makeUnit('u1', Echelon.platoon, name: '1st Platoon')];
|
||||||
|
final bytes = exporter.export(
|
||||||
|
units: units,
|
||||||
|
slots: const [],
|
||||||
|
unitDesignation: '1st Platoon',
|
||||||
|
);
|
||||||
|
final content = String.fromCharCodes(bytes);
|
||||||
|
expect(content, contains('1st Platoon'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parent-child tree renders connector lines', () {
|
||||||
|
final units = [
|
||||||
|
_makeUnit('u1', Echelon.platoon, name: '1st Platoon'),
|
||||||
|
_makeUnit('u2', Echelon.squad, parentId: 'u1', name: '1st Squad'),
|
||||||
|
_makeUnit('u3', Echelon.squad, parentId: 'u1', name: '2nd Squad'),
|
||||||
|
];
|
||||||
|
final bytes = exporter.export(
|
||||||
|
units: units,
|
||||||
|
slots: const [],
|
||||||
|
unitDesignation: '1st Platoon',
|
||||||
|
);
|
||||||
|
// Connector lines use 'm' (moveto) and 'l' (lineto) PDF operators.
|
||||||
|
final content = String.fromCharCodes(bytes);
|
||||||
|
expect(content, contains(' m '));
|
||||||
|
expect(content, contains(' l S'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all echelon levels render without error', () {
|
||||||
|
final echelons = Echelon.values;
|
||||||
|
final units = echelons
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.map((e) => _makeUnit('u${e.key}', e.value, name: e.value.displayName))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => exporter.export(
|
||||||
|
units: units,
|
||||||
|
slots: const [],
|
||||||
|
unitDesignation: 'All Echelons',
|
||||||
|
),
|
||||||
|
returnsNormally,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unit names appear in PDF content stream', () {
|
||||||
|
final units = [
|
||||||
|
_makeUnit('u1', Echelon.company, name: 'Alpha Company'),
|
||||||
|
_makeUnit('u2', Echelon.platoon, parentId: 'u1', name: '1st Platoon'),
|
||||||
|
];
|
||||||
|
final bytes = exporter.export(
|
||||||
|
units: units,
|
||||||
|
slots: const [],
|
||||||
|
unitDesignation: 'Alpha Company',
|
||||||
|
);
|
||||||
|
final content = String.fromCharCodes(bytes);
|
||||||
|
// Names are truncated to 18 chars; both fit.
|
||||||
|
expect(content, contains('Alpha Company'));
|
||||||
|
expect(content, contains('1st Platoon'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slots are accepted without error', () {
|
||||||
|
final units = [_makeUnit('u1', Echelon.squad)];
|
||||||
|
final slots = List.generate(9, (i) => _makeSlot('sl$i', 'u1'));
|
||||||
|
expect(
|
||||||
|
() => exporter.export(
|
||||||
|
units: units,
|
||||||
|
slots: slots,
|
||||||
|
unitDesignation: 'Test Squad',
|
||||||
|
),
|
||||||
|
returnsNormally,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page sizes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
group('PdfChartExporter — page sizes', () {
|
||||||
|
for (final size in PdfPageSize.values) {
|
||||||
|
test('${size.name} page size produces valid PDF', () {
|
||||||
|
final bytes = exporter.export(
|
||||||
|
units: const [],
|
||||||
|
slots: const [],
|
||||||
|
unitDesignation: 'Test',
|
||||||
|
pageSize: size,
|
||||||
|
);
|
||||||
|
final content = String.fromCharCodes(bytes);
|
||||||
|
expect(content, startsWith('%PDF-1.4'));
|
||||||
|
expect(content, contains('%%EOF'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('letter page MediaBox contains 612 and 792', () {
|
||||||
|
final bytes = exporter.export(
|
||||||
|
units: const [],
|
||||||
|
slots: const [],
|
||||||
|
unitDesignation: 'Test',
|
||||||
|
pageSize: PdfPageSize.letter,
|
||||||
|
);
|
||||||
|
final content = String.fromCharCodes(bytes);
|
||||||
|
expect(content, contains('612.0'));
|
||||||
|
expect(content, contains('792.0'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tabloid page MediaBox contains 792 and 1224', () {
|
||||||
|
final bytes = exporter.export(
|
||||||
|
units: const [],
|
||||||
|
slots: const [],
|
||||||
|
unitDesignation: 'Test',
|
||||||
|
pageSize: PdfPageSize.tabloid,
|
||||||
|
);
|
||||||
|
final content = String.fromCharCodes(bytes);
|
||||||
|
expect(content, contains('792.0'));
|
||||||
|
expect(content, contains('1224.0'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Export date
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
group('PdfChartExporter — export date', () {
|
||||||
|
test('custom export date appears in cover page', () {
|
||||||
|
final date = DateTime(2026, 8, 9);
|
||||||
|
final bytes = exporter.export(
|
||||||
|
units: const [],
|
||||||
|
slots: const [],
|
||||||
|
unitDesignation: 'Test',
|
||||||
|
exportDate: date,
|
||||||
|
);
|
||||||
|
final content = String.fromCharCodes(bytes);
|
||||||
|
expect(content, contains('2026-08-09'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defaults to current date when exportDate is null', () {
|
||||||
|
final bytes = exporter.export(
|
||||||
|
units: const [],
|
||||||
|
slots: const [],
|
||||||
|
unitDesignation: 'Test',
|
||||||
|
);
|
||||||
|
final year = DateTime.now().year.toString();
|
||||||
|
final content = String.fromCharCodes(bytes);
|
||||||
|
expect(content, contains(year));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Large tree
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
group('PdfChartExporter — large tree', () {
|
||||||
|
test('50-node tree exports without error', () {
|
||||||
|
final units = <UnitNode>[];
|
||||||
|
units.add(_makeUnit('root', Echelon.company, name: 'Alpha Co'));
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
units.add(_makeUnit('plt$i', Echelon.platoon, parentId: 'root', name: '${i + 1}st Plt'));
|
||||||
|
for (var j = 0; j < 3; j++) {
|
||||||
|
units.add(_makeUnit(
|
||||||
|
'sq${i}_$j',
|
||||||
|
Echelon.squad,
|
||||||
|
parentId: 'plt$i',
|
||||||
|
name: '${j + 1}st Sq',
|
||||||
|
));
|
||||||
|
for (var k = 0; k < 2; k++) {
|
||||||
|
units.add(_makeUnit(
|
||||||
|
'tm${i}_${j}_$k',
|
||||||
|
Echelon.team,
|
||||||
|
parentId: 'sq${i}_$j',
|
||||||
|
name: '${k + 1} Team',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => exporter.export(
|
||||||
|
units: units,
|
||||||
|
slots: const [],
|
||||||
|
unitDesignation: 'Alpha Co',
|
||||||
|
),
|
||||||
|
returnsNormally,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/authorized_rank.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/doctrinal_branch.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/echelon.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/personnel_record.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/position_slot.dart';
|
||||||
|
import 'package:uto_manager/domain/entities/unit_node.dart';
|
||||||
|
import 'package:uto_manager/domain/value_objects/duty_mos.dart';
|
||||||
|
import 'package:uto_manager/infrastructure/codecs/protobuf_codec.dart';
|
||||||
|
import 'package:uto_manager/infrastructure/codecs/unit_tree_codec.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const codec = ProtobufCodec();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
UnitNode _makeUnit(String id, {String? parentId}) => UnitNode(
|
||||||
|
unitId: id,
|
||||||
|
echelon: Echelon.platoon,
|
||||||
|
branch: DoctrinalBranch.infantry,
|
||||||
|
parentUnitId: parentId,
|
||||||
|
name: 'Unit $id',
|
||||||
|
isKeyLeader: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
PositionSlot _makeSlot(String id, String unitId) => PositionSlot(
|
||||||
|
slotId: id,
|
||||||
|
unitId: unitId,
|
||||||
|
positionTitle: 'Rifleman',
|
||||||
|
dutyMos: DutyMos.unvalidated('11B'),
|
||||||
|
authorizedRank: AuthorizedRank.e3,
|
||||||
|
isKeyLeader: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
PersonnelRecord _makeSoldier(String id) => PersonnelRecord(
|
||||||
|
soldierId: id,
|
||||||
|
name: 'SGT Test',
|
||||||
|
primaryMos: DutyMos.unvalidated('11B'),
|
||||||
|
payGrade: AuthorizedRank.e5,
|
||||||
|
qualifications: const [],
|
||||||
|
readinessState: ReadinessState.fullyMissionCapable,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Encode / Decode
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
group('ProtobufCodec — encode/decode', () {
|
||||||
|
test('encodes to non-empty Uint8List', () {
|
||||||
|
final units = [_makeUnit('u1')];
|
||||||
|
final bytes = codec.encode(units: units, slots: const [], personnel: const []);
|
||||||
|
expect(bytes, isA<Uint8List>());
|
||||||
|
expect(bytes.length, greaterThan(12));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('header contains correct magic bytes "UTOB"', () {
|
||||||
|
final bytes = codec.encode(units: [_makeUnit('u1')], slots: const [], personnel: const []);
|
||||||
|
// Magic: 0x55544F42
|
||||||
|
expect(bytes[0], 0x55);
|
||||||
|
expect(bytes[1], 0x54);
|
||||||
|
expect(bytes[2], 0x4F);
|
||||||
|
expect(bytes[3], 0x42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('header contains version 1', () {
|
||||||
|
final bytes = codec.encode(units: [_makeUnit('u1')], slots: const [], personnel: const []);
|
||||||
|
// Version at bytes 4–7
|
||||||
|
expect(bytes[4], 0x00);
|
||||||
|
expect(bytes[5], 0x00);
|
||||||
|
expect(bytes[6], 0x00);
|
||||||
|
expect(bytes[7], 0x01);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round-trip preserves units', () {
|
||||||
|
final units = [_makeUnit('u1'), _makeUnit('u2', parentId: 'u1')];
|
||||||
|
final bytes = codec.encode(units: units, slots: const [], personnel: const []);
|
||||||
|
final snapshot = codec.decode(bytes);
|
||||||
|
|
||||||
|
expect(snapshot.units.length, 2);
|
||||||
|
expect(snapshot.units.map((u) => u.unitId), containsAll(['u1', 'u2']));
|
||||||
|
expect(snapshot.units.firstWhere((u) => u.unitId == 'u2').parentUnitId, 'u1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round-trip preserves slots', () {
|
||||||
|
final units = [_makeUnit('u1')];
|
||||||
|
final slots = [_makeSlot('sl1', 'u1'), _makeSlot('sl2', 'u1')];
|
||||||
|
final bytes = codec.encode(units: units, slots: slots, personnel: const []);
|
||||||
|
final snapshot = codec.decode(bytes);
|
||||||
|
|
||||||
|
expect(snapshot.slots.length, 2);
|
||||||
|
expect(snapshot.slots.map((s) => s.slotId), containsAll(['sl1', 'sl2']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round-trip preserves personnel', () {
|
||||||
|
final units = [_makeUnit('u1')];
|
||||||
|
final personnel = [_makeSoldier('s1'), _makeSoldier('s2')];
|
||||||
|
final bytes = codec.encode(units: units, slots: const [], personnel: personnel);
|
||||||
|
final snapshot = codec.decode(bytes);
|
||||||
|
|
||||||
|
expect(snapshot.personnel.length, 2);
|
||||||
|
expect(snapshot.personnel.map((p) => p.soldierId), containsAll(['s1', 's2']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty tree encodes and decodes correctly', () {
|
||||||
|
final bytes = codec.encode(units: const [], slots: const [], personnel: const []);
|
||||||
|
final snapshot = codec.decode(bytes);
|
||||||
|
expect(snapshot.units, isEmpty);
|
||||||
|
expect(snapshot.slots, isEmpty);
|
||||||
|
expect(snapshot.personnel, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('binary output is smaller than JSON for non-trivial tree', () {
|
||||||
|
final units = List.generate(
|
||||||
|
20,
|
||||||
|
(i) => _makeUnit('u$i', parentId: i > 0 ? 'u${i - 1}' : null),
|
||||||
|
);
|
||||||
|
final slots = List.generate(40, (i) => _makeSlot('sl$i', 'u${i % 20}'));
|
||||||
|
|
||||||
|
final binaryBytes = codec.encode(units: units, slots: slots, personnel: const []);
|
||||||
|
final jsonLen =
|
||||||
|
const UnitTreeCodec().encode(units: units, slots: slots, personnel: const []).length;
|
||||||
|
|
||||||
|
expect(binaryBytes.length, lessThan(jsonLen));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error handling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
group('ProtobufCodec — error handling', () {
|
||||||
|
test('decode throws FormatException for data shorter than 12 bytes', () {
|
||||||
|
expect(
|
||||||
|
() => codec.decode(Uint8List.fromList([0x55, 0x54, 0x4F])),
|
||||||
|
throwsA(isA<FormatException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decode throws FormatException for wrong magic bytes', () {
|
||||||
|
final bad = Uint8List(16);
|
||||||
|
bad[0] = 0xDE;
|
||||||
|
bad[1] = 0xAD;
|
||||||
|
bad[2] = 0xBE;
|
||||||
|
bad[3] = 0xEF;
|
||||||
|
expect(() => codec.decode(bad), throwsA(isA<FormatException>()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decode throws FormatException for wrong version', () {
|
||||||
|
// Build a valid header but with version = 99.
|
||||||
|
final bytes = codec.encode(units: [_makeUnit('u1')], slots: const [], personnel: const []);
|
||||||
|
final tampered = Uint8List.fromList(bytes);
|
||||||
|
tampered[7] = 99; // version byte (big-endian, LSB)
|
||||||
|
expect(() => codec.decode(tampered), throwsA(isA<FormatException>()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decode throws FormatException for truncated payload', () {
|
||||||
|
final bytes = codec.encode(units: [_makeUnit('u1')], slots: const [], personnel: const []);
|
||||||
|
// Truncate to just the header.
|
||||||
|
final truncated = bytes.sublist(0, 12);
|
||||||
|
expect(() => codec.decode(truncated), throwsA(isA<FormatException>()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// compressionRatio
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
group('ProtobufCodec.compressionRatio', () {
|
||||||
|
test('returns 1.0 when jsonBytes is 0', () {
|
||||||
|
expect(ProtobufCodec.compressionRatio(100, 0), 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns correct ratio', () {
|
||||||
|
expect(ProtobufCodec.compressionRatio(50, 100), closeTo(0.5, 0.001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ratio < 1.0 for compressed data', () {
|
||||||
|
final units = List.generate(10, (i) => _makeUnit('u$i'));
|
||||||
|
final bytes = codec.encode(units: units, slots: const [], personnel: const []);
|
||||||
|
final jsonLen =
|
||||||
|
const UnitTreeCodec().encode(units: units, slots: const [], personnel: const []).length;
|
||||||
|
final ratio = ProtobufCodec.compressionRatio(bytes.length, jsonLen);
|
||||||
|
expect(ratio, lessThan(1.0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:uto_manager/application/blocs/personnel_cubit.dart';
|
import 'package:uto_manager/application/blocs/personnel_cubit.dart';
|
||||||
import 'package:uto_manager/application/blocs/template_cubit.dart';
|
import 'package:uto_manager/application/blocs/template_cubit.dart';
|
||||||
import 'package:uto_manager/application/blocs/unit_tree_cubit.dart';
|
import 'package:uto_manager/application/blocs/unit_tree_cubit.dart';
|
||||||
|
import 'package:uto_manager/application/blocs/validation_cubit.dart';
|
||||||
import 'package:uto_manager/domain/services/assignment_service.dart';
|
import 'package:uto_manager/domain/services/assignment_service.dart';
|
||||||
import 'package:uto_manager/domain/services/unit_tree_service.dart';
|
import 'package:uto_manager/domain/services/unit_tree_service.dart';
|
||||||
import 'package:uto_manager/infrastructure/database/app_database.dart';
|
import 'package:uto_manager/infrastructure/database/app_database.dart';
|
||||||
|
|
@ -19,6 +20,7 @@ void main() {
|
||||||
late UnitTreeCubit unitTreeCubit;
|
late UnitTreeCubit unitTreeCubit;
|
||||||
late PersonnelCubit personnelCubit;
|
late PersonnelCubit personnelCubit;
|
||||||
late TemplateCubit templateCubit;
|
late TemplateCubit templateCubit;
|
||||||
|
late ValidationCubit validationCubit;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
// Use an in-memory SQLite database so no file I/O or pending timers.
|
// Use an in-memory SQLite database so no file I/O or pending timers.
|
||||||
|
|
@ -45,12 +47,14 @@ void main() {
|
||||||
unitRepository: unitRepo,
|
unitRepository: unitRepo,
|
||||||
slotRepository: slotRepo,
|
slotRepository: slotRepo,
|
||||||
);
|
);
|
||||||
|
validationCubit = ValidationCubit();
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
await unitTreeCubit.close();
|
await unitTreeCubit.close();
|
||||||
await personnelCubit.close();
|
await personnelCubit.close();
|
||||||
await templateCubit.close();
|
await templateCubit.close();
|
||||||
|
await validationCubit.close();
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -61,6 +65,7 @@ void main() {
|
||||||
BlocProvider<UnitTreeCubit>.value(value: unitTreeCubit),
|
BlocProvider<UnitTreeCubit>.value(value: unitTreeCubit),
|
||||||
BlocProvider<PersonnelCubit>.value(value: personnelCubit),
|
BlocProvider<PersonnelCubit>.value(value: personnelCubit),
|
||||||
BlocProvider<TemplateCubit>.value(value: templateCubit),
|
BlocProvider<TemplateCubit>.value(value: templateCubit),
|
||||||
|
BlocProvider<ValidationCubit>.value(value: validationCubit),
|
||||||
],
|
],
|
||||||
child: const MaterialApp(home: HomeScreen()),
|
child: const MaterialApp(home: HomeScreen()),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue