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()); 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()), ); }); 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())); }); 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())); }); 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())); }); }); // --------------------------------------------------------------------------- // 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)); }); }); }