190 lines
7.2 KiB
Dart
190 lines
7.2 KiB
Dart
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,
|
||
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));
|
||
});
|
||
});
|
||
}
|