uto_manager/test/infrastructure/codecs/protobuf_codec_test.dart

191 lines
7.2 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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