mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-08-11 18:26:27 +10:00
rebuild translations
This commit is contained in:
@@ -6,8 +6,8 @@ import 'package:meshcore_open/services/image_chunk_transport.dart';
|
||||
import 'package:meshcore_open/widgets/image_send_codec_binding.dart';
|
||||
|
||||
Uint8List payloadOf(int length, {int seed = 7}) => Uint8List.fromList(
|
||||
List<int>.generate(length, (i) => (i * 37 + seed * 11) & 0xFF),
|
||||
);
|
||||
List<int>.generate(length, (i) => (i * 37 + seed * 11) & 0xFF),
|
||||
);
|
||||
|
||||
const ImageStreamMetadata stdMeta = ImageStreamMetadata(
|
||||
rate: ImageCodecRatePoint.standard,
|
||||
@@ -447,7 +447,9 @@ void main() {
|
||||
);
|
||||
expect(r.pendingCount, 1);
|
||||
|
||||
final expired = r.evictExpired(now: start.add(const Duration(minutes: 2)));
|
||||
final expired = r.evictExpired(
|
||||
now: start.add(const Duration(minutes: 2)),
|
||||
);
|
||||
expect(expired.length, 1);
|
||||
expect(expired.single.total, 3);
|
||||
expect(expired.single.receivedDataChunks, 2);
|
||||
@@ -690,7 +692,17 @@ void main() {
|
||||
|
||||
test('RESP_CODE_CHANNEL_DATA_RECV parses, snr is signed', () {
|
||||
final frame = Uint8List.fromList(<int>[
|
||||
0x1B, 0xF8, 0, 0, 3, 0xFF, 0x1C, 0xAE, 2, 0x42, 0x43,
|
||||
0x1B,
|
||||
0xF8,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
0xFF,
|
||||
0x1C,
|
||||
0xAE,
|
||||
2,
|
||||
0x42,
|
||||
0x43,
|
||||
]);
|
||||
final parsed = parseChannelDataFrame(frame)!;
|
||||
expect(parsed.snrRaw, -8);
|
||||
@@ -704,7 +716,15 @@ void main() {
|
||||
|
||||
test('a flooded frame exposes hop count and hash width', () {
|
||||
final frame = Uint8List.fromList(<int>[
|
||||
0x1B, 0x04, 0, 0, 0, 0x43, 0x1C, 0xAE, 0,
|
||||
0x1B,
|
||||
0x04,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0x43,
|
||||
0x1C,
|
||||
0xAE,
|
||||
0,
|
||||
]);
|
||||
final parsed = parseChannelDataFrame(frame)!;
|
||||
expect(parsed.arrivedByFlood, isTrue);
|
||||
@@ -729,50 +749,53 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('transport sends chunks strictly sequentially and reassembles', () async {
|
||||
final sent = <Uint8List>[];
|
||||
var inFlight = 0;
|
||||
var maxInFlight = 0;
|
||||
final received = <ImageReassemblyResult>[];
|
||||
final rxWithCallback = ImageReassembler(
|
||||
selfPrefix: senderB,
|
||||
onImage: received.add,
|
||||
);
|
||||
test(
|
||||
'transport sends chunks strictly sequentially and reassembles',
|
||||
() async {
|
||||
final sent = <Uint8List>[];
|
||||
var inFlight = 0;
|
||||
var maxInFlight = 0;
|
||||
final received = <ImageReassemblyResult>[];
|
||||
final rxWithCallback = ImageReassembler(
|
||||
selfPrefix: senderB,
|
||||
onImage: received.add,
|
||||
);
|
||||
|
||||
final tx = ImageChunkTransport(
|
||||
senderPrefix: senderA,
|
||||
reassembler: rxWithCallback,
|
||||
idAllocator: ImageIdAllocator(seed: 60),
|
||||
send: (blob, channelIndex) async {
|
||||
inFlight++;
|
||||
maxInFlight = maxInFlight > inFlight ? maxInFlight : inFlight;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1));
|
||||
sent.add(blob);
|
||||
inFlight--;
|
||||
},
|
||||
);
|
||||
final tx = ImageChunkTransport(
|
||||
senderPrefix: senderA,
|
||||
reassembler: rxWithCallback,
|
||||
idAllocator: ImageIdAllocator(seed: 60),
|
||||
send: (blob, channelIndex) async {
|
||||
inFlight++;
|
||||
maxInFlight = maxInFlight > inFlight ? maxInFlight : inFlight;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1));
|
||||
sent.add(blob);
|
||||
inFlight--;
|
||||
},
|
||||
);
|
||||
|
||||
final payload = payloadOf(460, seed: 9);
|
||||
final set = await tx.sendImage(
|
||||
payload: payload,
|
||||
metadata: highMeta,
|
||||
channelIndex: 1,
|
||||
);
|
||||
expect(set.imgId, 60);
|
||||
expect(sent.length, 4);
|
||||
expect(maxInFlight, 1);
|
||||
final payload = payloadOf(460, seed: 9);
|
||||
final set = await tx.sendImage(
|
||||
payload: payload,
|
||||
metadata: highMeta,
|
||||
channelIndex: 1,
|
||||
);
|
||||
expect(set.imgId, 60);
|
||||
expect(sent.length, 4);
|
||||
expect(maxInFlight, 1);
|
||||
|
||||
// Loop the blobs back through the receive path as real frames.
|
||||
for (final blob in sent) {
|
||||
final frame = BytesBuilder()
|
||||
..add(<int>[0x1B, 0x10, 0, 0, 1, 0xFF, 0x1C, 0xAE, blob.length])
|
||||
..add(blob);
|
||||
tx.handleFrame(frame.toBytes());
|
||||
}
|
||||
expect(received.length, 1);
|
||||
expect(received.single.data, payload);
|
||||
expect(received.single.key.channelIndex, 1);
|
||||
});
|
||||
// Loop the blobs back through the receive path as real frames.
|
||||
for (final blob in sent) {
|
||||
final frame = BytesBuilder()
|
||||
..add(<int>[0x1B, 0x10, 0, 0, 1, 0xFF, 0x1C, 0xAE, blob.length])
|
||||
..add(blob);
|
||||
tx.handleFrame(frame.toBytes());
|
||||
}
|
||||
expect(received.length, 1);
|
||||
expect(received.single.data, payload);
|
||||
expect(received.single.key.channelIndex, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test('handleFrame ignores other data types and other frames', () {
|
||||
final rx = ImageReassembler();
|
||||
@@ -805,7 +828,14 @@ void main() {
|
||||
final a = tx.sendImage(payload: payloadOf(300), metadata: stdMeta);
|
||||
final b = tx.sendImage(payload: payloadOf(300), metadata: stdMeta);
|
||||
await Future.wait(<Future<ImageChunkSet>>[a, b]);
|
||||
expect(order, <String>['100:0', '100:1', '100:2', '101:0', '101:1', '101:2']);
|
||||
expect(order, <String>[
|
||||
'100:0',
|
||||
'100:1',
|
||||
'100:2',
|
||||
'101:0',
|
||||
'101:1',
|
||||
'101:2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -818,10 +848,7 @@ void main() {
|
||||
test('a new image reusing a just-completed img_id is not swallowed', () {
|
||||
final delivered = <ImageReassemblyResult>[];
|
||||
final failed = <ImageReassemblyFailure>[];
|
||||
final r = ImageReassembler(
|
||||
onImage: delivered.add,
|
||||
onFailed: failed.add,
|
||||
);
|
||||
final r = ImageReassembler(onImage: delivered.add, onFailed: failed.add);
|
||||
|
||||
final first = buildImageChunks(
|
||||
payload: payloadOf(200, seed: 1),
|
||||
@@ -870,38 +897,40 @@ void main() {
|
||||
// PROBE B/C: a flipped bit in the parity length byte used to yield a
|
||||
// silently TRUNCATED image reported as `completed`. Only the last data
|
||||
// chunk may be short.
|
||||
test('corrupt parity length is rejected rather than silently truncating',
|
||||
() {
|
||||
for (final payloadLen in [300, 400]) {
|
||||
final set = buildImageChunks(
|
||||
payload: payloadOf(payloadLen, seed: 3),
|
||||
metadata: stdMeta,
|
||||
senderPrefix: senderA,
|
||||
imgId: 7,
|
||||
);
|
||||
final data = set.blobs.sublist(0, set.dataChunkCount);
|
||||
final parity = Uint8List.fromList(set.blobs.last);
|
||||
// Corrupt the len_xor byte (first body byte, just after the header).
|
||||
parity[kImageChunkHeaderBytes] ^= 0x02;
|
||||
test(
|
||||
'corrupt parity length is rejected rather than silently truncating',
|
||||
() {
|
||||
for (final payloadLen in [300, 400]) {
|
||||
final set = buildImageChunks(
|
||||
payload: payloadOf(payloadLen, seed: 3),
|
||||
metadata: stdMeta,
|
||||
senderPrefix: senderA,
|
||||
imgId: 7,
|
||||
);
|
||||
final data = set.blobs.sublist(0, set.dataChunkCount);
|
||||
final parity = Uint8List.fromList(set.blobs.last);
|
||||
// Corrupt the len_xor byte (first body byte, just after the header).
|
||||
parity[kImageChunkHeaderBytes] ^= 0x02;
|
||||
|
||||
final delivered = <ImageReassemblyResult>[];
|
||||
final r = ImageReassembler(onImage: delivered.add);
|
||||
// Drop a NON-FINAL chunk (index 1 of >=3, else index 0) and supply
|
||||
// the corrupted parity.
|
||||
final dropIndex = data.length >= 3 ? 1 : 0;
|
||||
final kept = <Uint8List>[
|
||||
for (var i = 0; i < data.length; i++)
|
||||
if (i != dropIndex) data[i],
|
||||
parity,
|
||||
];
|
||||
feed(r, kept);
|
||||
expect(
|
||||
delivered,
|
||||
isEmpty,
|
||||
reason: 'payload $payloadLen: truncated recovery must not complete',
|
||||
);
|
||||
}
|
||||
});
|
||||
final delivered = <ImageReassemblyResult>[];
|
||||
final r = ImageReassembler(onImage: delivered.add);
|
||||
// Drop a NON-FINAL chunk (index 1 of >=3, else index 0) and supply
|
||||
// the corrupted parity.
|
||||
final dropIndex = data.length >= 3 ? 1 : 0;
|
||||
final kept = <Uint8List>[
|
||||
for (var i = 0; i < data.length; i++)
|
||||
if (i != dropIndex) data[i],
|
||||
parity,
|
||||
];
|
||||
feed(r, kept);
|
||||
expect(
|
||||
delivered,
|
||||
isEmpty,
|
||||
reason: 'payload $payloadLen: truncated recovery must not complete',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('parity still recovers a genuinely lost non-final chunk', () {
|
||||
final payload = payloadOf(400, seed: 5);
|
||||
@@ -927,17 +956,16 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
group('completed-image map is capped', () {
|
||||
/// A lone parity chunk of a `total == 1` image completes that image by
|
||||
/// itself — one packet, one remembered entry. That is the amplification the
|
||||
/// cap exists to bound.
|
||||
List<Uint8List> loneParity(int imgId) => buildImageChunks(
|
||||
payload: payloadOf(20, seed: imgId),
|
||||
metadata: stdMeta,
|
||||
senderPrefix: senderA,
|
||||
imgId: imgId,
|
||||
).blobs;
|
||||
payload: payloadOf(20, seed: imgId),
|
||||
metadata: stdMeta,
|
||||
senderPrefix: senderA,
|
||||
imgId: imgId,
|
||||
).blobs;
|
||||
|
||||
test('one packet per img_id can complete an image (the attack)', () {
|
||||
final r = ImageReassembler();
|
||||
@@ -947,23 +975,26 @@ void main() {
|
||||
expect(r.pendingCount, 0);
|
||||
});
|
||||
|
||||
test('the map never exceeds maxCompletedStreams and evicts oldest first',
|
||||
() {
|
||||
final start = DateTime(2026, 3, 3);
|
||||
final r = ImageReassembler(maxCompletedStreams: 3);
|
||||
for (var i = 0; i < 20; i++) {
|
||||
r.addChunk(
|
||||
loneParity(100 + i)[1],
|
||||
now: start.add(Duration(seconds: i)),
|
||||
);
|
||||
expect(r.completedCount, lessThanOrEqualTo(3));
|
||||
}
|
||||
expect(r.completedCount, 3);
|
||||
expect(
|
||||
r.completedKeys.map((k) => k.imgId).toList()..sort(),
|
||||
<int>[117, 118, 119],
|
||||
);
|
||||
});
|
||||
test(
|
||||
'the map never exceeds maxCompletedStreams and evicts oldest first',
|
||||
() {
|
||||
final start = DateTime(2026, 3, 3);
|
||||
final r = ImageReassembler(maxCompletedStreams: 3);
|
||||
for (var i = 0; i < 20; i++) {
|
||||
r.addChunk(
|
||||
loneParity(100 + i)[1],
|
||||
now: start.add(Duration(seconds: i)),
|
||||
);
|
||||
expect(r.completedCount, lessThanOrEqualTo(3));
|
||||
}
|
||||
expect(r.completedCount, 3);
|
||||
expect(r.completedKeys.map((k) => k.imgId).toList()..sort(), <int>[
|
||||
117,
|
||||
118,
|
||||
119,
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test('default cap matches the pending cap', () {
|
||||
final start = DateTime(2026, 3, 4);
|
||||
@@ -1010,10 +1041,14 @@ void main() {
|
||||
// conflated, ft32 would go on air as 4.
|
||||
expect(AeicRatePoint.values.length, 5);
|
||||
expect(AeicRatePoint.ft32.wireValue, 4);
|
||||
expect(aeicRatePointForUi(ImageCodecRatePoint.standard),
|
||||
AeicRatePoint.ft32);
|
||||
expect(imageRateWireCode(ImageCodecRatePoint.standard),
|
||||
kImageRateWireStandard);
|
||||
expect(
|
||||
aeicRatePointForUi(ImageCodecRatePoint.standard),
|
||||
AeicRatePoint.ft32,
|
||||
);
|
||||
expect(
|
||||
imageRateWireCode(ImageCodecRatePoint.standard),
|
||||
kImageRateWireStandard,
|
||||
);
|
||||
expect(kImageRateWireStandard, 0);
|
||||
expect(kImageRateWireHigh, 1);
|
||||
expect(
|
||||
@@ -1057,7 +1092,10 @@ void main() {
|
||||
|
||||
final delivered = <ImageReassemblyResult>[];
|
||||
final failures = <ImageReassemblyFailure>[];
|
||||
final r = ImageReassembler(onImage: delivered.add, onFailed: failures.add);
|
||||
final r = ImageReassembler(
|
||||
onImage: delivered.add,
|
||||
onFailed: failures.add,
|
||||
);
|
||||
expect(r.addChunk(bad).status, ImageChunkStatus.accepted);
|
||||
final done = r.addChunk(set.blobs[1]);
|
||||
expect(done.status, ImageChunkStatus.unsupportedFormat);
|
||||
@@ -1093,7 +1131,6 @@ void main() {
|
||||
ImageChunkStatus.unsupportedFormat,
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
group('metadata byte: aspect ratio', () {
|
||||
|
||||
+83
-70
@@ -9,80 +9,93 @@ import 'package:meshcore_open/models/image_codec_support.dart';
|
||||
import 'package:meshcore_open/widgets/image_send_codec_binding.dart';
|
||||
|
||||
void main() {
|
||||
test('ImageStreamReassembler.selfPrefix override suppresses our own echo', () {
|
||||
final store = ReceivedImageStore();
|
||||
final r = ImageStreamReassembler(store: store);
|
||||
final set = buildImageChunks(
|
||||
payload: Uint8List.fromList(List<int>.generate(120, (i) => i)),
|
||||
metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard),
|
||||
senderPrefix: 0xABCD,
|
||||
imgId: 7,
|
||||
);
|
||||
// Before SELF_INFO the prefix is unknown, so nothing is suppressed.
|
||||
expect(r.addChunk(set.blobs[0], channelIndex: 1).status,
|
||||
ImageChunkStatus.completed);
|
||||
r.clear();
|
||||
// After SELF_INFO the superclass must see the overridden getter.
|
||||
r.selfPrefix = 0xABCD;
|
||||
expect(r.addChunk(set.blobs[0], channelIndex: 1).status,
|
||||
ImageChunkStatus.fromSelf);
|
||||
});
|
||||
test(
|
||||
'ImageStreamReassembler.selfPrefix override suppresses our own echo',
|
||||
() {
|
||||
final store = ReceivedImageStore();
|
||||
final r = ImageStreamReassembler(store: store);
|
||||
final set = buildImageChunks(
|
||||
payload: Uint8List.fromList(List<int>.generate(120, (i) => i)),
|
||||
metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard),
|
||||
senderPrefix: 0xABCD,
|
||||
imgId: 7,
|
||||
);
|
||||
// Before SELF_INFO the prefix is unknown, so nothing is suppressed.
|
||||
expect(
|
||||
r.addChunk(set.blobs[0], channelIndex: 1).status,
|
||||
ImageChunkStatus.completed,
|
||||
);
|
||||
r.clear();
|
||||
// After SELF_INFO the superclass must see the overridden getter.
|
||||
r.selfPrefix = 0xABCD;
|
||||
expect(
|
||||
r.addChunk(set.blobs[0], channelIndex: 1).status,
|
||||
ImageChunkStatus.fromSelf,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('addChunk forwards outcomes to the store with the channel index',
|
||||
() async {
|
||||
final store = ReceivedImageStore();
|
||||
final r = ImageStreamReassembler(store: store);
|
||||
final set = buildImageChunks(
|
||||
payload: Uint8List.fromList(List<int>.generate(400, (i) => i & 0xFF)),
|
||||
metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard),
|
||||
senderPrefix: 0x1234,
|
||||
imgId: 3,
|
||||
);
|
||||
r.addChunk(set.blobs[0], channelIndex: 5);
|
||||
await store.settle();
|
||||
final entry = store.entries.single;
|
||||
expect(entry.channelIndex, 5);
|
||||
expect(entry.senderPrefix, 0x1234);
|
||||
expect(entry.state, ReceivedImageState.receiving);
|
||||
});
|
||||
test(
|
||||
'addChunk forwards outcomes to the store with the channel index',
|
||||
() async {
|
||||
final store = ReceivedImageStore();
|
||||
final r = ImageStreamReassembler(store: store);
|
||||
final set = buildImageChunks(
|
||||
payload: Uint8List.fromList(List<int>.generate(400, (i) => i & 0xFF)),
|
||||
metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard),
|
||||
senderPrefix: 0x1234,
|
||||
imgId: 3,
|
||||
);
|
||||
r.addChunk(set.blobs[0], channelIndex: 5);
|
||||
await store.settle();
|
||||
final entry = store.entries.single;
|
||||
expect(entry.channelIndex, 5);
|
||||
expect(entry.senderPrefix, 0x1234);
|
||||
expect(entry.state, ReceivedImageState.receiving);
|
||||
},
|
||||
);
|
||||
|
||||
group('AppSettings image codec block', () {
|
||||
test('round-trips through toJson/fromJson on the ImageCodecPreferences keys',
|
||||
() {
|
||||
final settings = AppSettings(
|
||||
imageCodecEnabled: true,
|
||||
imageCodecSelectedModelId: 'qdq_conv_pct_novae',
|
||||
imageCodecModelSourceUrl: 'https://example.invalid/model.onnx',
|
||||
imageCodecRatePoint: 4,
|
||||
imageCodecDownloadedModels: [
|
||||
ImageCodecModelRecord(
|
||||
id: 'qdq_conv_pct_novae',
|
||||
name: 'AEIC ft32 (int8)',
|
||||
sourceUrl: 'https://example.invalid/model.onnx',
|
||||
localPath: '/tmp/model.onnx',
|
||||
downloadedAt: DateTime.fromMillisecondsSinceEpoch(1000),
|
||||
fileSizeBytes: 872 * 1024 * 1024,
|
||||
),
|
||||
],
|
||||
);
|
||||
final json = settings.toJson();
|
||||
// The keys must be the ones ImageCodecPreferences already used, so a
|
||||
// build that wrote them through the old standalone store still reads.
|
||||
expect(json['image_codec_enabled'], isTrue);
|
||||
expect(json['image_codec_selected_model_id'], 'qdq_conv_pct_novae');
|
||||
expect(json['image_codec_rate_point'], 4);
|
||||
test(
|
||||
'round-trips through toJson/fromJson on the ImageCodecPreferences keys',
|
||||
() {
|
||||
final settings = AppSettings(
|
||||
imageCodecEnabled: true,
|
||||
imageCodecSelectedModelId: 'qdq_conv_pct_novae',
|
||||
imageCodecModelSourceUrl: 'https://example.invalid/model.onnx',
|
||||
imageCodecRatePoint: 4,
|
||||
imageCodecDownloadedModels: [
|
||||
ImageCodecModelRecord(
|
||||
id: 'qdq_conv_pct_novae',
|
||||
name: 'AEIC ft32 (int8)',
|
||||
sourceUrl: 'https://example.invalid/model.onnx',
|
||||
localPath: '/tmp/model.onnx',
|
||||
downloadedAt: DateTime.fromMillisecondsSinceEpoch(1000),
|
||||
fileSizeBytes: 872 * 1024 * 1024,
|
||||
),
|
||||
],
|
||||
);
|
||||
final json = settings.toJson();
|
||||
// The keys must be the ones ImageCodecPreferences already used, so a
|
||||
// build that wrote them through the old standalone store still reads.
|
||||
expect(json['image_codec_enabled'], isTrue);
|
||||
expect(json['image_codec_selected_model_id'], 'qdq_conv_pct_novae');
|
||||
expect(json['image_codec_rate_point'], 4);
|
||||
|
||||
final restored = AppSettings.fromJson(json);
|
||||
expect(restored.imageCodecEnabled, isTrue);
|
||||
expect(restored.imageCodecSelectedModelId, 'qdq_conv_pct_novae');
|
||||
expect(
|
||||
restored.imageCodecModelSourceUrl,
|
||||
'https://example.invalid/model.onnx',
|
||||
);
|
||||
expect(restored.imageCodecRatePoint, 4);
|
||||
expect(restored.imageCodecDownloadedModels.single.localPath,
|
||||
'/tmp/model.onnx');
|
||||
});
|
||||
final restored = AppSettings.fromJson(json);
|
||||
expect(restored.imageCodecEnabled, isTrue);
|
||||
expect(restored.imageCodecSelectedModelId, 'qdq_conv_pct_novae');
|
||||
expect(
|
||||
restored.imageCodecModelSourceUrl,
|
||||
'https://example.invalid/model.onnx',
|
||||
);
|
||||
expect(restored.imageCodecRatePoint, 4);
|
||||
expect(
|
||||
restored.imageCodecDownloadedModels.single.localPath,
|
||||
'/tmp/model.onnx',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('defaults are off, ft32, and empty', () {
|
||||
const prefs = ImageCodecPreferences();
|
||||
|
||||
+71
-34
@@ -12,14 +12,13 @@ RadioSettings _radio({
|
||||
LoRaSpreadingFactor sf = LoRaSpreadingFactor.sf10,
|
||||
LoRaBandwidth bw = LoRaBandwidth.bw250,
|
||||
LoRaCodingRate cr = LoRaCodingRate.cr4_5,
|
||||
}) =>
|
||||
RadioSettings(
|
||||
frequencyMHz: 869.525,
|
||||
bandwidth: bw,
|
||||
spreadingFactor: sf,
|
||||
codingRate: cr,
|
||||
txPowerDbm: 22,
|
||||
);
|
||||
}) => RadioSettings(
|
||||
frequencyMHz: 869.525,
|
||||
bandwidth: bw,
|
||||
spreadingFactor: sf,
|
||||
codingRate: cr,
|
||||
txPowerDbm: 22,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('loraTimeOnAir reference values (255-byte packet)', () {
|
||||
@@ -86,16 +85,18 @@ void main() {
|
||||
group('airtime monotonicity / sanity', () {
|
||||
test('longer payload never takes less airtime', () {
|
||||
Duration at(int pl) => loraTimeOnAir(
|
||||
payloadBytes: pl,
|
||||
spreadingFactor: 10,
|
||||
bandwidthHz: 250000,
|
||||
codingRate: 5,
|
||||
);
|
||||
payloadBytes: pl,
|
||||
spreadingFactor: 10,
|
||||
bandwidthHz: 250000,
|
||||
codingRate: 5,
|
||||
);
|
||||
var previous = at(0);
|
||||
for (var pl = 1; pl <= 255; pl++) {
|
||||
final current = at(pl);
|
||||
expect(current.inMicroseconds,
|
||||
greaterThanOrEqualTo(previous.inMicroseconds));
|
||||
expect(
|
||||
current.inMicroseconds,
|
||||
greaterThanOrEqualTo(previous.inMicroseconds),
|
||||
);
|
||||
previous = current;
|
||||
}
|
||||
});
|
||||
@@ -147,10 +148,7 @@ void main() {
|
||||
// the estimator drifting away from the chunker.
|
||||
expect(imageChunkCount(110), 1);
|
||||
expect(imageChunkCount(209), 2);
|
||||
expect(
|
||||
imageChunkCount(156),
|
||||
156 <= kImageChunkFirstCapacity ? 1 : 2,
|
||||
);
|
||||
expect(imageChunkCount(156), 156 <= kImageChunkFirstCapacity ? 1 : 2);
|
||||
for (final pl in [110, 156, 209]) {
|
||||
expect(imageChunkCount(pl), inInclusiveRange(1, 2));
|
||||
}
|
||||
@@ -192,14 +190,19 @@ void main() {
|
||||
test('parity adds exactly one packet', () {
|
||||
final radio = _radio();
|
||||
for (final pl in [110, 209, 288, 409]) {
|
||||
final without =
|
||||
estimateSend(payloadBytes: pl, radio: radio, parity: false);
|
||||
final without = estimateSend(
|
||||
payloadBytes: pl,
|
||||
radio: radio,
|
||||
parity: false,
|
||||
);
|
||||
final with_ = estimateSend(payloadBytes: pl, radio: radio);
|
||||
expect(with_.chunkCount, without.chunkCount + 1);
|
||||
expect(without.includesParity, isFalse);
|
||||
expect(with_.includesParity, isTrue);
|
||||
expect(with_.totalAirtime!.inMicroseconds,
|
||||
greaterThan(without.totalAirtime!.inMicroseconds));
|
||||
expect(
|
||||
with_.totalAirtime!.inMicroseconds,
|
||||
greaterThan(without.totalAirtime!.inMicroseconds),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -254,15 +257,19 @@ void main() {
|
||||
});
|
||||
|
||||
test('total bytes account for chunk headers and metadata', () {
|
||||
final est =
|
||||
estimateSend(payloadBytes: 209, radio: _radio(), parity: false);
|
||||
final est = estimateSend(
|
||||
payloadBytes: 209,
|
||||
radio: _radio(),
|
||||
parity: false,
|
||||
);
|
||||
// A data blob is header + body (chunk 0's body opens with the metadata
|
||||
// byte). Only the PARITY blob carries the length byte, and it is always a
|
||||
// full kImageChunkBlobBytes because the XOR body is zero-padded.
|
||||
final sizes = imageChunkPayloadSizes(209);
|
||||
var expected = 0;
|
||||
for (var i = 0; i < sizes.length; i++) {
|
||||
expected += kImageChunkHeaderBytes +
|
||||
expected +=
|
||||
kImageChunkHeaderBytes +
|
||||
(i == 0 ? kImageChunkZeroMetadataBytes : 0) +
|
||||
sizes[i];
|
||||
}
|
||||
@@ -285,7 +292,8 @@ void main() {
|
||||
var expected = 0;
|
||||
for (var i = 0; i < sizes.length; i++) {
|
||||
expected += loraTimeOnAir(
|
||||
payloadBytes: kImageChunkHeaderBytes +
|
||||
payloadBytes:
|
||||
kImageChunkHeaderBytes +
|
||||
(i == 0 ? kImageChunkZeroMetadataBytes : 0) +
|
||||
sizes[i],
|
||||
spreadingFactor: 9,
|
||||
@@ -320,8 +328,11 @@ void main() {
|
||||
|
||||
group('paced wall clock', () {
|
||||
test('single-packet send has no pacing gap', () {
|
||||
final est =
|
||||
estimateSend(payloadBytes: 110, radio: _radio(), parity: false);
|
||||
final est = estimateSend(
|
||||
payloadBytes: 110,
|
||||
radio: _radio(),
|
||||
parity: false,
|
||||
);
|
||||
expect(est.chunkCount, 1);
|
||||
expect(est.pacedWallClock, est.totalAirtime);
|
||||
});
|
||||
@@ -369,7 +380,8 @@ void main() {
|
||||
expect(
|
||||
imageSendChunkGap(toa),
|
||||
Duration(
|
||||
microseconds: kImageSendChunkGapBase.inMicroseconds +
|
||||
microseconds:
|
||||
kImageSendChunkGapBase.inMicroseconds +
|
||||
(toa.inMicroseconds * kImageSendChunkGapAirtimeFactor).round(),
|
||||
),
|
||||
);
|
||||
@@ -446,7 +458,11 @@ void main() {
|
||||
bandwidthHz: 250000,
|
||||
codingRate: 5,
|
||||
);
|
||||
expect(est.totalAirtime, isNull, reason: 'sf=$sf must not produce airtime');
|
||||
expect(
|
||||
est.totalAirtime,
|
||||
isNull,
|
||||
reason: 'sf=$sf must not produce airtime',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -462,9 +478,30 @@ void main() {
|
||||
});
|
||||
|
||||
test('areLoRaParamsValid accepts the boundary values', () {
|
||||
expect(areLoRaParamsValid(spreadingFactor: 5, bandwidthHz: 7800, codingRate: 5), isTrue);
|
||||
expect(areLoRaParamsValid(spreadingFactor: 12, bandwidthHz: 500000, codingRate: 8), isTrue);
|
||||
expect(areLoRaParamsValid(spreadingFactor: null, bandwidthHz: 250000, codingRate: 5), isFalse);
|
||||
expect(
|
||||
areLoRaParamsValid(
|
||||
spreadingFactor: 5,
|
||||
bandwidthHz: 7800,
|
||||
codingRate: 5,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
areLoRaParamsValid(
|
||||
spreadingFactor: 12,
|
||||
bandwidthHz: 500000,
|
||||
codingRate: 8,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
areLoRaParamsValid(
|
||||
spreadingFactor: null,
|
||||
bandwidthHz: 250000,
|
||||
codingRate: 5,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -114,7 +114,10 @@ void main() {
|
||||
// existing role would hand ORT the wrong graph.
|
||||
final restored = ImageCodecModelRecord.fromJson(<String, dynamic>{
|
||||
'asset_file_names': ['a.onnx', 'b.bin'],
|
||||
'asset_roles': {'a.onnx': 'somethingFromTheFuture', 'b.bin': 'cdfTables'},
|
||||
'asset_roles': {
|
||||
'a.onnx': 'somethingFromTheFuture',
|
||||
'b.bin': 'cdfTables',
|
||||
},
|
||||
});
|
||||
expect(restored.assetRoles.keys, ['b.bin']);
|
||||
expect(restored.fileNameForRole(ImageCodecAssetRole.cdfTables), 'b.bin');
|
||||
@@ -357,8 +360,10 @@ void main() {
|
||||
for (final asset in preset.assets) {
|
||||
expect(asset.fileName, isNot(contains('novae')));
|
||||
}
|
||||
expect(preset.assetFor(ImageCodecAssetRole.decoderWeights).sizeBytes,
|
||||
872896480);
|
||||
expect(
|
||||
preset.assetFor(ImageCodecAssetRole.decoderWeights).sizeBytes,
|
||||
872896480,
|
||||
);
|
||||
});
|
||||
|
||||
test('carries all five roles exactly once', () {
|
||||
@@ -400,8 +405,7 @@ void main() {
|
||||
expect(reordered.fileName, preset.fileName);
|
||||
});
|
||||
|
||||
test('the entropy graph and the tables are the ones that were validated',
|
||||
() {
|
||||
test('the entropy graph and the tables are the ones that were validated', () {
|
||||
final preset = imageCodecPresetModels.single;
|
||||
final entropy = preset.assetFor(ImageCodecAssetRole.entropyGraph);
|
||||
// op17 fp32: byte-identical bitstreams on 26/26 images. Not op20, not int8.
|
||||
@@ -429,10 +433,7 @@ void main() {
|
||||
],
|
||||
);
|
||||
expect(decoderOnly.isComplete, isFalse);
|
||||
expect(
|
||||
decoderOnly.maybeAssetFor(ImageCodecAssetRole.cdfTables),
|
||||
isNull,
|
||||
);
|
||||
expect(decoderOnly.maybeAssetFor(ImageCodecAssetRole.cdfTables), isNull);
|
||||
expect(
|
||||
() => decoderOnly.assetFor(ImageCodecAssetRole.cdfTables),
|
||||
throwsStateError,
|
||||
@@ -483,17 +484,19 @@ void main() {
|
||||
});
|
||||
|
||||
group('exceptions', () {
|
||||
test('an incomplete install is NOT the same failure as a missing build',
|
||||
() {
|
||||
// Both are ImageCodecUnimplemented, but only one has a remedy the user
|
||||
// can act on, and the UI branches on exactly that difference.
|
||||
const incomplete = ImageCodecBundleIncomplete();
|
||||
const missing = ImageCodecEntropyPathMissing();
|
||||
expect(incomplete, isA<ImageCodecUnimplemented>());
|
||||
expect(missing, isA<ImageCodecUnimplemented>());
|
||||
expect(incomplete, isNot(isA<ImageCodecEntropyPathMissing>()));
|
||||
expect(incomplete.toString(), contains('re-download'));
|
||||
});
|
||||
test(
|
||||
'an incomplete install is NOT the same failure as a missing build',
|
||||
() {
|
||||
// Both are ImageCodecUnimplemented, but only one has a remedy the user
|
||||
// can act on, and the UI branches on exactly that difference.
|
||||
const incomplete = ImageCodecBundleIncomplete();
|
||||
const missing = ImageCodecEntropyPathMissing();
|
||||
expect(incomplete, isA<ImageCodecUnimplemented>());
|
||||
expect(missing, isA<ImageCodecUnimplemented>());
|
||||
expect(incomplete, isNot(isA<ImageCodecEntropyPathMissing>()));
|
||||
expect(incomplete.toString(), contains('re-download'));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('parseImageCodecStatus', () {
|
||||
|
||||
@@ -116,7 +116,8 @@ void main() {
|
||||
});
|
||||
|
||||
test('rejects trailing garbage', () {
|
||||
final Uint8List extra = Uint8List(raw.length + 1)..setRange(0, raw.length, raw);
|
||||
final Uint8List extra = Uint8List(raw.length + 1)
|
||||
..setRange(0, raw.length, raw);
|
||||
expect(
|
||||
() => EntropyTables.parse(extra),
|
||||
throwsA(isA<EntropyTableFormatException>()),
|
||||
|
||||
@@ -16,21 +16,19 @@ import 'package:meshcore_open/services/image_codec_session_io.dart';
|
||||
/// needs to rebuild a bundle that can decode.
|
||||
void main() {
|
||||
ImageCodecBundle fiveAssetBundle() => const ImageCodecBundle(
|
||||
decoderGraphPath: '/models/aeic_decoder_qdq_conv_pct.onnx',
|
||||
entropyGraphPath: '/models/aeic_entropy_side_fp32_op17.onnx',
|
||||
entropyDecodeGraphPath: '/models/aeic_entropy_decode_fp32_op17.onnx',
|
||||
tablesPath: '/models/aeic_cdf_ft32.bin',
|
||||
ratePoint: AeicRatePoint.ft32,
|
||||
);
|
||||
decoderGraphPath: '/models/aeic_decoder_qdq_conv_pct.onnx',
|
||||
entropyGraphPath: '/models/aeic_entropy_side_fp32_op17.onnx',
|
||||
entropyDecodeGraphPath: '/models/aeic_entropy_decode_fp32_op17.onnx',
|
||||
tablesPath: '/models/aeic_cdf_ft32.bin',
|
||||
ratePoint: AeicRatePoint.ft32,
|
||||
);
|
||||
|
||||
group('codec worker boot payload', () {
|
||||
test('a five-asset bundle survives the round trip and can still decode', () {
|
||||
final sent = fiveAssetBundle();
|
||||
expect(sent.supportsDecode, isTrue, reason: 'precondition');
|
||||
|
||||
final rebuilt = debugBundleFromBootPayload(
|
||||
debugBootPayloadFor(sent),
|
||||
);
|
||||
final rebuilt = debugBundleFromBootPayload(debugBootPayloadFor(sent));
|
||||
|
||||
expect(rebuilt.decoderGraphPath, sent.decoderGraphPath);
|
||||
expect(rebuilt.entropyGraphPath, sent.entropyGraphPath);
|
||||
@@ -56,20 +54,22 @@ void main() {
|
||||
expect(payload[6], '/models/aeic_entropy_decode_fp32_op17.onnx');
|
||||
});
|
||||
|
||||
test('a send-only bundle rebuilds as send-only rather than half-decoding',
|
||||
() {
|
||||
const sendOnly = ImageCodecBundle(
|
||||
decoderGraphPath: '/models/decoder.onnx',
|
||||
entropyGraphPath: '/models/entropy.onnx',
|
||||
tablesPath: '/models/tables.bin',
|
||||
ratePoint: AeicRatePoint.ft32,
|
||||
);
|
||||
final rebuilt = debugBundleFromBootPayload(
|
||||
debugBootPayloadFor(sendOnly),
|
||||
);
|
||||
expect(rebuilt.entropyDecodeGraphPath, isNull);
|
||||
expect(rebuilt.supportsDecode, isFalse);
|
||||
});
|
||||
test(
|
||||
'a send-only bundle rebuilds as send-only rather than half-decoding',
|
||||
() {
|
||||
const sendOnly = ImageCodecBundle(
|
||||
decoderGraphPath: '/models/decoder.onnx',
|
||||
entropyGraphPath: '/models/entropy.onnx',
|
||||
tablesPath: '/models/tables.bin',
|
||||
ratePoint: AeicRatePoint.ft32,
|
||||
);
|
||||
final rebuilt = debugBundleFromBootPayload(
|
||||
debugBootPayloadFor(sendOnly),
|
||||
);
|
||||
expect(rebuilt.entropyDecodeGraphPath, isNull);
|
||||
expect(rebuilt.supportsDecode, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('a short payload does not crash the worker', () {
|
||||
// An older sender, or a truncated message, must degrade to "cannot
|
||||
|
||||
@@ -250,10 +250,7 @@ void main() {
|
||||
expect(await graph.readAsBytes(), small);
|
||||
expect(await weights.length(), large.length);
|
||||
expect(_sha256(await weights.readAsBytes()), _sha256(large));
|
||||
expect(
|
||||
await File('${tempDir.path}/entropy.onnx').readAsBytes(),
|
||||
entropy,
|
||||
);
|
||||
expect(await File('${tempDir.path}/entropy.onnx').readAsBytes(), entropy);
|
||||
// Two different entropy exports land side by side; neither may overwrite
|
||||
// or be mistaken for the other.
|
||||
expect(
|
||||
|
||||
@@ -121,45 +121,49 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
test('ENCODE: Dart bitstream is byte-identical to the C++ bitstream',
|
||||
() async {
|
||||
final _ReplayNetwork network = _ReplayNetwork(r);
|
||||
final AeicEntropyCodec codec = AeicEntropyCodec(
|
||||
geometry: geometry,
|
||||
network: network,
|
||||
coders: coders,
|
||||
);
|
||||
final Uint8List got = await codec.encode(
|
||||
Uint8List(geometry.resolution * geometry.resolution * 3),
|
||||
);
|
||||
expect(network.encodeCalls, 1);
|
||||
_expectSameBytes(got, r.u8('enc/bitstream'), name);
|
||||
expect(
|
||||
sha256.convert(got).toString(),
|
||||
r.meta['bitstream_sha256'],
|
||||
reason: '$name: bitstream sha256',
|
||||
);
|
||||
});
|
||||
test(
|
||||
'ENCODE: Dart bitstream is byte-identical to the C++ bitstream',
|
||||
() async {
|
||||
final _ReplayNetwork network = _ReplayNetwork(r);
|
||||
final AeicEntropyCodec codec = AeicEntropyCodec(
|
||||
geometry: geometry,
|
||||
network: network,
|
||||
coders: coders,
|
||||
);
|
||||
final Uint8List got = await codec.encode(
|
||||
Uint8List(geometry.resolution * geometry.resolution * 3),
|
||||
);
|
||||
expect(network.encodeCalls, 1);
|
||||
_expectSameBytes(got, r.u8('enc/bitstream'), name);
|
||||
expect(
|
||||
sha256.convert(got).toString(),
|
||||
r.meta['bitstream_sha256'],
|
||||
reason: '$name: bitstream sha256',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('DECODE: y_hat from the C++ bitstream is exactly the recorded y_hat',
|
||||
() async {
|
||||
final _ReplayNetwork network = _ReplayNetwork(r);
|
||||
final AeicEntropyCodec codec = AeicEntropyCodec(
|
||||
geometry: geometry,
|
||||
network: network,
|
||||
coders: coders,
|
||||
);
|
||||
final Float32List got = await codec.decodeToLatent(
|
||||
r.u8('enc/bitstream'),
|
||||
);
|
||||
expect(network.hyperCalls, 1);
|
||||
expect(network.stageCalls, <int>[0, 1, 2, 3]);
|
||||
_expectSameFloats(got, r.f32('dec/y_hat'), '$name: y_hat');
|
||||
// The recording asserts the decoder's latent equals the encoder's; if
|
||||
// that holds in Python it must hold here too.
|
||||
expect(r.meta['decoded_y_hat_equals_encoder_y_hat'], isTrue);
|
||||
_expectSameFloats(got, r.f32('enc/y_hat'), '$name: y_hat vs encoder');
|
||||
});
|
||||
test(
|
||||
'DECODE: y_hat from the C++ bitstream is exactly the recorded y_hat',
|
||||
() async {
|
||||
final _ReplayNetwork network = _ReplayNetwork(r);
|
||||
final AeicEntropyCodec codec = AeicEntropyCodec(
|
||||
geometry: geometry,
|
||||
network: network,
|
||||
coders: coders,
|
||||
);
|
||||
final Float32List got = await codec.decodeToLatent(
|
||||
r.u8('enc/bitstream'),
|
||||
);
|
||||
expect(network.hyperCalls, 1);
|
||||
expect(network.stageCalls, <int>[0, 1, 2, 3]);
|
||||
_expectSameFloats(got, r.f32('dec/y_hat'), '$name: y_hat');
|
||||
// The recording asserts the decoder's latent equals the encoder's; if
|
||||
// that holds in Python it must hold here too.
|
||||
expect(r.meta['decoded_y_hat_equals_encoder_y_hat'], isTrue);
|
||||
_expectSameFloats(got, r.f32('enc/y_hat'), '$name: y_hat vs encoder');
|
||||
},
|
||||
);
|
||||
|
||||
test('a single flipped bitstream byte does not still pass', () async {
|
||||
// Byte 3 is the first payload byte of sub-stream 0 (1-byte flag +
|
||||
@@ -185,7 +189,8 @@ void main() {
|
||||
expect(
|
||||
_sameFloats(got, r.f32('dec/y_hat')),
|
||||
isFalse,
|
||||
reason: '$name: corrupting the stream changed nothing — the '
|
||||
reason:
|
||||
'$name: corrupting the stream changed nothing — the '
|
||||
'comparison is vacuous',
|
||||
);
|
||||
});
|
||||
@@ -253,7 +258,8 @@ class _ReplayNetwork implements AeicEntropyNetwork {
|
||||
'stage $stage input base',
|
||||
);
|
||||
}
|
||||
final Map<String, dynamic> outputs = call['outputs'] as Map<String, dynamic>;
|
||||
final Map<String, dynamic> outputs =
|
||||
call['outputs'] as Map<String, dynamic>;
|
||||
return AeicStageParams(
|
||||
meansSupp: r.f32(outputs['means'] as String),
|
||||
scalesSupp: r.f32(outputs['scales'] as String),
|
||||
@@ -265,12 +271,11 @@ class _ReplayNetwork implements AeicEntropyNetwork {
|
||||
/// 32-byte header, an 8-byte-aligned tensor blob, then a UTF-8 JSON index.
|
||||
class _Recording {
|
||||
_Recording(this.index, this.bytes)
|
||||
: _entries = <String, Map<String, dynamic>>{
|
||||
for (final Map<String, dynamic> e
|
||||
in (index['entries'] as List<dynamic>)
|
||||
.cast<Map<String, dynamic>>())
|
||||
e['name'] as String: e,
|
||||
};
|
||||
: _entries = <String, Map<String, dynamic>>{
|
||||
for (final Map<String, dynamic> e
|
||||
in (index['entries'] as List<dynamic>).cast<Map<String, dynamic>>())
|
||||
e['name'] as String: e,
|
||||
};
|
||||
|
||||
final Map<String, dynamic> index;
|
||||
final Uint8List bytes;
|
||||
@@ -291,7 +296,9 @@ class _Recording {
|
||||
final int indexLength = bd.getUint32(24, Endian.little);
|
||||
final Map<String, dynamic> index =
|
||||
jsonDecode(
|
||||
utf8.decode(bytes.sublist(indexOffset, indexOffset + indexLength)),
|
||||
utf8.decode(
|
||||
bytes.sublist(indexOffset, indexOffset + indexLength),
|
||||
),
|
||||
)
|
||||
as Map<String, dynamic>;
|
||||
return _Recording(index, bytes);
|
||||
|
||||
@@ -40,25 +40,281 @@ class _Golden {
|
||||
];
|
||||
|
||||
static const List<List<int>> symbols = <List<int>>[
|
||||
<int>[-1, -1, -2, 4, 0, 0, 6, -3, 2, 1, 1, -1, -5, 1, 0, 0, -1, -1, -2, 4,
|
||||
0, 0, 5, -3, 2, 1, 1, -1, -5, 1, 0, 0],
|
||||
<int>[-1, -1, 4, 4, 0, 0, 0, 5, 2, 1, -1, -1, 3, -5, 1, 0, -1, -2, 4, 4,
|
||||
0, 0, 0, 5, 1, 1, -1, -1, 3, -5, 1, 0],
|
||||
<int>[-1, -2, -2, 4, 1, 0, 0, 5, 2, 0, -1, -1, 3, -5, 1, 0, -1, -2, -2, 4,
|
||||
0, 0, 0, 5, 2, -1, -1, -1, 3, -5, 1, 0],
|
||||
<int>[-1, -1, -2, -2, 0, 0, 5, -3, 2, 1, -1, -1, -5, -5, 0, 0, -1, -1, -2,
|
||||
-2, 0, 0, 5, -3, 2, 1, -1, -1, -5, 1, 0, 0],
|
||||
<int>[
|
||||
-1,
|
||||
-1,
|
||||
-2,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
6,
|
||||
-3,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
-1,
|
||||
-5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
-1,
|
||||
-1,
|
||||
-2,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
-3,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
-1,
|
||||
-5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
<int>[
|
||||
-1,
|
||||
-1,
|
||||
4,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
2,
|
||||
1,
|
||||
-1,
|
||||
-1,
|
||||
3,
|
||||
-5,
|
||||
1,
|
||||
0,
|
||||
-1,
|
||||
-2,
|
||||
4,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
-1,
|
||||
-1,
|
||||
3,
|
||||
-5,
|
||||
1,
|
||||
0,
|
||||
],
|
||||
<int>[
|
||||
-1,
|
||||
-2,
|
||||
-2,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
-1,
|
||||
-1,
|
||||
3,
|
||||
-5,
|
||||
1,
|
||||
0,
|
||||
-1,
|
||||
-2,
|
||||
-2,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
2,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
3,
|
||||
-5,
|
||||
1,
|
||||
0,
|
||||
],
|
||||
<int>[
|
||||
-1,
|
||||
-1,
|
||||
-2,
|
||||
-2,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
-3,
|
||||
2,
|
||||
1,
|
||||
-1,
|
||||
-1,
|
||||
-5,
|
||||
-5,
|
||||
0,
|
||||
0,
|
||||
-1,
|
||||
-1,
|
||||
-2,
|
||||
-2,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
-3,
|
||||
2,
|
||||
1,
|
||||
-1,
|
||||
-1,
|
||||
-5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
];
|
||||
|
||||
static const List<List<int>> indexes = <List<int>>[
|
||||
<int>[-1, 6, -1, 8, 12, 15, 13, 16, 0, 9, 3, 10, 13, 16, 14, 17, 0, 9, 3,
|
||||
10, 13, 16, 14, 17, 5, 11, 7, 12, 15, 17, 15, 18],
|
||||
<int>[14, 11, 15, 12, 7, 0, 8, 2, 15, 13, 16, 14, 9, 4, 11, 6, 15, 13, 16,
|
||||
14, 10, 4, 11, 6, 16, 14, 17, 15, 11, 7, 12, 9],
|
||||
<int>[11, 14, 12, 15, -1, 8, 0, 9, 12, 16, 13, 16, 2, 10, 5, 11, 13, 16,
|
||||
13, 16, 3, 10, 5, 11, 14, 17, 15, 17, 7, 12, 8, 13],
|
||||
<int>[5, -1, 7, 0, 15, 12, 15, 13, 8, 1, 10, 4, 16, 14, 16, 14, 8, 2, 10,
|
||||
4, 16, 14, 16, 14, 11, 6, 12, 8, 17, 15, 17, 16],
|
||||
<int>[
|
||||
-1,
|
||||
6,
|
||||
-1,
|
||||
8,
|
||||
12,
|
||||
15,
|
||||
13,
|
||||
16,
|
||||
0,
|
||||
9,
|
||||
3,
|
||||
10,
|
||||
13,
|
||||
16,
|
||||
14,
|
||||
17,
|
||||
0,
|
||||
9,
|
||||
3,
|
||||
10,
|
||||
13,
|
||||
16,
|
||||
14,
|
||||
17,
|
||||
5,
|
||||
11,
|
||||
7,
|
||||
12,
|
||||
15,
|
||||
17,
|
||||
15,
|
||||
18,
|
||||
],
|
||||
<int>[
|
||||
14,
|
||||
11,
|
||||
15,
|
||||
12,
|
||||
7,
|
||||
0,
|
||||
8,
|
||||
2,
|
||||
15,
|
||||
13,
|
||||
16,
|
||||
14,
|
||||
9,
|
||||
4,
|
||||
11,
|
||||
6,
|
||||
15,
|
||||
13,
|
||||
16,
|
||||
14,
|
||||
10,
|
||||
4,
|
||||
11,
|
||||
6,
|
||||
16,
|
||||
14,
|
||||
17,
|
||||
15,
|
||||
11,
|
||||
7,
|
||||
12,
|
||||
9,
|
||||
],
|
||||
<int>[
|
||||
11,
|
||||
14,
|
||||
12,
|
||||
15,
|
||||
-1,
|
||||
8,
|
||||
0,
|
||||
9,
|
||||
12,
|
||||
16,
|
||||
13,
|
||||
16,
|
||||
2,
|
||||
10,
|
||||
5,
|
||||
11,
|
||||
13,
|
||||
16,
|
||||
13,
|
||||
16,
|
||||
3,
|
||||
10,
|
||||
5,
|
||||
11,
|
||||
14,
|
||||
17,
|
||||
15,
|
||||
17,
|
||||
7,
|
||||
12,
|
||||
8,
|
||||
13,
|
||||
],
|
||||
<int>[
|
||||
5,
|
||||
-1,
|
||||
7,
|
||||
0,
|
||||
15,
|
||||
12,
|
||||
15,
|
||||
13,
|
||||
8,
|
||||
1,
|
||||
10,
|
||||
4,
|
||||
16,
|
||||
14,
|
||||
16,
|
||||
14,
|
||||
8,
|
||||
2,
|
||||
10,
|
||||
4,
|
||||
16,
|
||||
14,
|
||||
16,
|
||||
14,
|
||||
11,
|
||||
6,
|
||||
12,
|
||||
8,
|
||||
17,
|
||||
15,
|
||||
17,
|
||||
16,
|
||||
],
|
||||
];
|
||||
|
||||
// --- full case: C = 256, H = W = 16 (the shipping geometry) ---
|
||||
@@ -113,7 +369,21 @@ class _Golden {
|
||||
10000.0,
|
||||
];
|
||||
static const List<int> probeIndexes = <int>[
|
||||
-1, -1, -1, -1, 0, 0, 0, 12, 17, 23, 34, 51, 62, 63, 63,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
12,
|
||||
17,
|
||||
23,
|
||||
34,
|
||||
51,
|
||||
62,
|
||||
63,
|
||||
63,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -220,10 +490,7 @@ void main() {
|
||||
final geometry = AeicEntropyGeometry.forResolution(512);
|
||||
final masks = AeicMaskSet(geometry);
|
||||
for (var stage = 0; stage < 4; stage++) {
|
||||
final live = masks
|
||||
.maskTensor(stage)
|
||||
.where((v) => v == 1.0)
|
||||
.length;
|
||||
final live = masks.maskTensor(stage).where((v) => v == 1.0).length;
|
||||
expect(live, geometry.symbolsPerStage);
|
||||
}
|
||||
});
|
||||
@@ -401,34 +668,37 @@ void main() {
|
||||
});
|
||||
|
||||
group('AeicEntropyCodec', () {
|
||||
test('encode pushes z, y0, y1, y2, y3 in that order and no other', () async {
|
||||
final geometry = AeicEntropyGeometry.forResolution(512);
|
||||
final network = _FakeNetwork(geometry);
|
||||
final coders = _RecordingCoders();
|
||||
final codec = AeicEntropyCodec(
|
||||
geometry: geometry,
|
||||
network: network,
|
||||
coders: coders,
|
||||
);
|
||||
final progress = <double>[];
|
||||
final stream = await codec.encode(
|
||||
Uint8List(512 * 512 * 3),
|
||||
onProgress: progress.add,
|
||||
);
|
||||
test(
|
||||
'encode pushes z, y0, y1, y2, y3 in that order and no other',
|
||||
() async {
|
||||
final geometry = AeicEntropyGeometry.forResolution(512);
|
||||
final network = _FakeNetwork(geometry);
|
||||
final coders = _RecordingCoders();
|
||||
final codec = AeicEntropyCodec(
|
||||
geometry: geometry,
|
||||
network: network,
|
||||
coders: coders,
|
||||
);
|
||||
final progress = <double>[];
|
||||
final stream = await codec.encode(
|
||||
Uint8List(512 * 512 * 3),
|
||||
onProgress: progress.add,
|
||||
);
|
||||
|
||||
expect(coders.encoder.groups, <int>[
|
||||
kAeicZCdfGroup,
|
||||
kAeicYCdfGroup,
|
||||
kAeicYCdfGroup,
|
||||
kAeicYCdfGroup,
|
||||
kAeicYCdfGroup,
|
||||
]);
|
||||
expect(coders.encoder.lengths, <int>[2048, 16384, 16384, 16384, 16384]);
|
||||
expect(stream, isNotEmpty);
|
||||
expect(progress.last, 1.0);
|
||||
// The image really was normalized before the graph saw it: 0 -> -1.
|
||||
expect(network.lastInput!.first, -1.0);
|
||||
});
|
||||
expect(coders.encoder.groups, <int>[
|
||||
kAeicZCdfGroup,
|
||||
kAeicYCdfGroup,
|
||||
kAeicYCdfGroup,
|
||||
kAeicYCdfGroup,
|
||||
kAeicYCdfGroup,
|
||||
]);
|
||||
expect(coders.encoder.lengths, <int>[2048, 16384, 16384, 16384, 16384]);
|
||||
expect(stream, isNotEmpty);
|
||||
expect(progress.last, 1.0);
|
||||
// The image really was normalized before the graph saw it: 0 -> -1.
|
||||
expect(network.lastInput!.first, -1.0);
|
||||
},
|
||||
);
|
||||
|
||||
test('encode refuses a graph that returns the wrong z size', () async {
|
||||
final geometry = AeicEntropyGeometry.forResolution(512);
|
||||
@@ -697,7 +967,8 @@ class _FakeNetwork implements AeicEntropyNetwork {
|
||||
],
|
||||
scales: <Float32List>[
|
||||
for (var i = 0; i < 4; i++)
|
||||
Float32List(geometry.yElements)..fillRange(0, geometry.yElements, 1.0),
|
||||
Float32List(geometry.yElements)
|
||||
..fillRange(0, geometry.yElements, 1.0),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ void main() {
|
||||
final Map<String, dynamic> manifest =
|
||||
jsonDecode(File('${goldenDir.path}/manifest.json').readAsStringSync())
|
||||
as Map<String, dynamic>;
|
||||
final List<Map<String, dynamic>> images = (manifest['images'] as List<dynamic>)
|
||||
.cast<Map<String, dynamic>>();
|
||||
final List<Map<String, dynamic>> images =
|
||||
(manifest['images'] as List<dynamic>).cast<Map<String, dynamic>>();
|
||||
final List<Map<String, dynamic>> synthetic =
|
||||
(manifest['synthetic'] as List<dynamic>).cast<Map<String, dynamic>>();
|
||||
|
||||
|
||||
@@ -59,7 +59,10 @@ class _FakeDecoder implements ReceivedImageDecoder {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ImageCodecResult?> _run(AeicRatePoint ratePoint, int resolution) async {
|
||||
Future<ImageCodecResult?> _run(
|
||||
AeicRatePoint ratePoint,
|
||||
int resolution,
|
||||
) async {
|
||||
if (delay > Duration.zero) await Future<void>.delayed(delay);
|
||||
switch (mode) {
|
||||
case 'null':
|
||||
@@ -149,7 +152,11 @@ void main() {
|
||||
imgId: 7,
|
||||
firstSeen: DateTime.fromMillisecondsSinceEpoch(0x693F21 * 1000),
|
||||
);
|
||||
expect(id, '1a2b07' '00693f21');
|
||||
expect(
|
||||
id,
|
||||
'1a2b07'
|
||||
'00693f21',
|
||||
);
|
||||
expect(id.length, 14);
|
||||
expect(ReceivedImageRef.parse(ReceivedImageRef.encode(id)), id);
|
||||
});
|
||||
@@ -161,8 +168,10 @@ void main() {
|
||||
expect(ReceivedImageRef.parse('aeic:1:1A2B0700693F21'), isNull);
|
||||
expect(ReceivedImageRef.parse('aeic:2:1a2b0700693f21'), isNull);
|
||||
expect(ReceivedImageRef.parse('@[Bob] hi'), isNull);
|
||||
expect(ReceivedImageRef.parse(' aeic:1:1a2b0700693f21 '),
|
||||
'1a2b0700693f21');
|
||||
expect(
|
||||
ReceivedImageRef.parse(' aeic:1:1a2b0700693f21 '),
|
||||
'1a2b0700693f21',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,8 +193,11 @@ void main() {
|
||||
expect(entry!.state, ReceivedImageState.reassembled);
|
||||
expect(entry.rate, AeicRatePoint.ft32);
|
||||
expect(entry.metadataAssumed, isFalse);
|
||||
expect(h.blobs.hasBitstream(entry.streamId), isTrue,
|
||||
reason: 'bitstream must be written before the state changes');
|
||||
expect(
|
||||
h.blobs.hasBitstream(entry.streamId),
|
||||
isTrue,
|
||||
reason: 'bitstream must be written before the state changes',
|
||||
);
|
||||
|
||||
await h.store.settle();
|
||||
final decoded = h.store.entryFor(entry.streamId)!;
|
||||
@@ -218,8 +230,11 @@ void main() {
|
||||
h.reassembler.addChunk(set.blobs[1], channelIndex: 3),
|
||||
channelIndex: 3,
|
||||
);
|
||||
expect(second!.streamId, first.streamId,
|
||||
reason: 'the sentinel must not change as chunks arrive');
|
||||
expect(
|
||||
second!.streamId,
|
||||
first.streamId,
|
||||
reason: 'the sentinel must not change as chunks arrive',
|
||||
);
|
||||
expect(second.state, ReceivedImageState.reassembled);
|
||||
await h.store.settle();
|
||||
expect(
|
||||
@@ -300,8 +315,10 @@ void main() {
|
||||
expect(entry!.state, ReceivedImageState.reassembled);
|
||||
expect(entry.recoveredWithParity, isTrue);
|
||||
await h.store.settle();
|
||||
expect(h.store.entryFor(entry.streamId)!.state,
|
||||
ReceivedImageState.decoded);
|
||||
expect(
|
||||
h.store.entryFor(entry.streamId)!.state,
|
||||
ReceivedImageState.decoded,
|
||||
);
|
||||
});
|
||||
|
||||
test('loopback and malformed blobs create nothing', () async {
|
||||
@@ -574,37 +591,42 @@ void main() {
|
||||
});
|
||||
|
||||
group('eviction', () {
|
||||
test('byte budget drops the oldest PNG first and keeps the bitstream',
|
||||
() async {
|
||||
final decoder = _FakeDecoder(
|
||||
png: Uint8List.fromList(List<int>.filled(1000, 3)),
|
||||
);
|
||||
final h = _build(decoder: decoder, maxBytes: 2500);
|
||||
final ids = <String>[];
|
||||
for (var i = 0; i < 3; i++) {
|
||||
final entry = await _completeOne(h, imgId: 40 + i, seed: i);
|
||||
await h.store.settle();
|
||||
ids.add(entry.streamId);
|
||||
h.advance(const Duration(seconds: 5));
|
||||
}
|
||||
// 3 x 1000 B PNG > 2500 B, so the oldest PNG must have gone.
|
||||
expect(h.store.entryFor(ids[0])!.state, ReceivedImageState.evicted);
|
||||
expect(h.store.entryFor(ids[0])!.pngStored, isFalse);
|
||||
expect(h.blobs.hasPng(ids[0]), isFalse);
|
||||
expect(h.blobs.hasBitstream(ids[0]), isTrue,
|
||||
reason: 'a ~156 B bitstream is cheap; keep it so we can re-decode');
|
||||
expect(h.store.entryFor(ids[0])!.canRetryDecode, isTrue);
|
||||
expect(h.store.entryFor(ids[2])!.state, ReceivedImageState.decoded);
|
||||
expect(h.store.totalBytes, lessThanOrEqualTo(2500));
|
||||
test(
|
||||
'byte budget drops the oldest PNG first and keeps the bitstream',
|
||||
() async {
|
||||
final decoder = _FakeDecoder(
|
||||
png: Uint8List.fromList(List<int>.filled(1000, 3)),
|
||||
);
|
||||
final h = _build(decoder: decoder, maxBytes: 2500);
|
||||
final ids = <String>[];
|
||||
for (var i = 0; i < 3; i++) {
|
||||
final entry = await _completeOne(h, imgId: 40 + i, seed: i);
|
||||
await h.store.settle();
|
||||
ids.add(entry.streamId);
|
||||
h.advance(const Duration(seconds: 5));
|
||||
}
|
||||
// 3 x 1000 B PNG > 2500 B, so the oldest PNG must have gone.
|
||||
expect(h.store.entryFor(ids[0])!.state, ReceivedImageState.evicted);
|
||||
expect(h.store.entryFor(ids[0])!.pngStored, isFalse);
|
||||
expect(h.blobs.hasPng(ids[0]), isFalse);
|
||||
expect(
|
||||
h.blobs.hasBitstream(ids[0]),
|
||||
isTrue,
|
||||
reason: 'a ~156 B bitstream is cheap; keep it so we can re-decode',
|
||||
);
|
||||
expect(h.store.entryFor(ids[0])!.canRetryDecode, isTrue);
|
||||
expect(h.store.entryFor(ids[2])!.state, ReceivedImageState.decoded);
|
||||
expect(h.store.totalBytes, lessThanOrEqualTo(2500));
|
||||
|
||||
// "Decode again" works off the surviving bitstream, and the image we just
|
||||
// decoded is never the budget's own victim (that would thrash forever).
|
||||
await h.store.requestDecode(ids[0]);
|
||||
await h.store.settle();
|
||||
expect(h.store.entryFor(ids[0])!.state, ReceivedImageState.decoded);
|
||||
expect(h.store.entryFor(ids[1])!.state, ReceivedImageState.evicted);
|
||||
expect(h.store.totalBytes, lessThanOrEqualTo(2500));
|
||||
});
|
||||
// "Decode again" works off the surviving bitstream, and the image we just
|
||||
// decoded is never the budget's own victim (that would thrash forever).
|
||||
await h.store.requestDecode(ids[0]);
|
||||
await h.store.settle();
|
||||
expect(h.store.entryFor(ids[0])!.state, ReceivedImageState.decoded);
|
||||
expect(h.store.entryFor(ids[1])!.state, ReceivedImageState.evicted);
|
||||
expect(h.store.totalBytes, lessThanOrEqualTo(2500));
|
||||
},
|
||||
);
|
||||
|
||||
test('image-count budget evicts oldest first', () async {
|
||||
final h = _build(maxImages: 2);
|
||||
@@ -625,8 +647,10 @@ void main() {
|
||||
final h = _build(maxAge: const Duration(minutes: 10));
|
||||
final entry = await _completeOne(h);
|
||||
await h.store.settle();
|
||||
expect(h.store.entryFor(entry.streamId)!.state,
|
||||
ReceivedImageState.decoded);
|
||||
expect(
|
||||
h.store.entryFor(entry.streamId)!.state,
|
||||
ReceivedImageState.decoded,
|
||||
);
|
||||
h.advance(const Duration(minutes: 11));
|
||||
final evicted = await h.store.evictToBudget();
|
||||
expect(evicted, contains(entry.streamId));
|
||||
@@ -762,8 +786,11 @@ void main() {
|
||||
expect(entry.state, ReceivedImageState.reassembled);
|
||||
expect(entry.needsManualDecode, isTrue);
|
||||
expect(h.store.decodeQueue, isEmpty);
|
||||
expect(h.decoder.calls, 0,
|
||||
reason: 'a radio packet must never start a 2.16 GiB decode by itself');
|
||||
expect(
|
||||
h.decoder.calls,
|
||||
0,
|
||||
reason: 'a radio packet must never start a 2.16 GiB decode by itself',
|
||||
);
|
||||
// Everything the placeholder card needs is already on the entry.
|
||||
expect(entry.senderPrefix, kSender);
|
||||
expect(entry.bitstreamByteCount, greaterThan(0));
|
||||
@@ -779,28 +806,30 @@ void main() {
|
||||
expect(h.decoder.calls, 1);
|
||||
});
|
||||
|
||||
test('a finished model download does not decode the whole backlog',
|
||||
() async {
|
||||
final decoder = _FakeDecoder(
|
||||
availability: ImageCodecAvailability.disabled,
|
||||
);
|
||||
final h = _build(decoder: decoder, processAutomatically: false);
|
||||
final ids = <String>[];
|
||||
for (var i = 0; i < 4; i++) {
|
||||
ids.add((await _completeOne(h, imgId: 80 + i, seed: i)).streamId);
|
||||
h.advance(const Duration(seconds: 2));
|
||||
}
|
||||
// The model lands.
|
||||
decoder.availability = ImageCodecAvailability.ready;
|
||||
h.store.notifyDecoderChanged();
|
||||
await h.store.settle();
|
||||
expect(decoder.calls, 0);
|
||||
expect(h.store.decodeQueue, isEmpty);
|
||||
for (final id in ids) {
|
||||
expect(h.store.entryFor(id)!.state, ReceivedImageState.reassembled);
|
||||
expect(h.store.entryFor(id)!.needsManualDecode, isTrue);
|
||||
}
|
||||
});
|
||||
test(
|
||||
'a finished model download does not decode the whole backlog',
|
||||
() async {
|
||||
final decoder = _FakeDecoder(
|
||||
availability: ImageCodecAvailability.disabled,
|
||||
);
|
||||
final h = _build(decoder: decoder, processAutomatically: false);
|
||||
final ids = <String>[];
|
||||
for (var i = 0; i < 4; i++) {
|
||||
ids.add((await _completeOne(h, imgId: 80 + i, seed: i)).streamId);
|
||||
h.advance(const Duration(seconds: 2));
|
||||
}
|
||||
// The model lands.
|
||||
decoder.availability = ImageCodecAvailability.ready;
|
||||
h.store.notifyDecoderChanged();
|
||||
await h.store.settle();
|
||||
expect(decoder.calls, 0);
|
||||
expect(h.store.decodeQueue, isEmpty);
|
||||
for (final id in ids) {
|
||||
expect(h.store.entryFor(id)!.state, ReceivedImageState.reassembled);
|
||||
expect(h.store.entryFor(id)!.needsManualDecode, isTrue);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('turning the setting on affects future arrivals only', () async {
|
||||
final h = _build(processAutomatically: false);
|
||||
@@ -810,15 +839,21 @@ void main() {
|
||||
h.store.processAutomatically = true;
|
||||
await h.store.settle();
|
||||
expect(h.decoder.calls, 0, reason: 'no retro-decode of the backlog');
|
||||
expect(h.store.entryFor(parked.streamId)!.state,
|
||||
ReceivedImageState.reassembled);
|
||||
expect(
|
||||
h.store.entryFor(parked.streamId)!.state,
|
||||
ReceivedImageState.reassembled,
|
||||
);
|
||||
|
||||
final fresh = await _completeOne(h, imgId: 91, seed: 5);
|
||||
await h.store.settle();
|
||||
expect(h.store.entryFor(fresh.streamId)!.state,
|
||||
ReceivedImageState.decoded);
|
||||
expect(h.store.entryFor(parked.streamId)!.state,
|
||||
ReceivedImageState.reassembled);
|
||||
expect(
|
||||
h.store.entryFor(fresh.streamId)!.state,
|
||||
ReceivedImageState.decoded,
|
||||
);
|
||||
expect(
|
||||
h.store.entryFor(parked.streamId)!.state,
|
||||
ReceivedImageState.reassembled,
|
||||
);
|
||||
});
|
||||
|
||||
test('a reassembled entry restored from disk is always tappable', () async {
|
||||
@@ -849,10 +884,7 @@ void main() {
|
||||
h.decoder.availability = ImageCodecAvailability.disabled;
|
||||
expect(h.store.decoderAvailability, ImageCodecAvailability.disabled);
|
||||
final noDecoder = ReceivedImageStore(decoder: null);
|
||||
expect(
|
||||
noDecoder.decoderAvailability,
|
||||
ImageCodecAvailability.unavailable,
|
||||
);
|
||||
expect(noDecoder.decoderAvailability, ImageCodecAvailability.unavailable);
|
||||
noDecoder.dispose();
|
||||
});
|
||||
});
|
||||
@@ -874,8 +906,11 @@ void main() {
|
||||
await h.store.settle();
|
||||
|
||||
expect(decoder.calls, 10);
|
||||
expect(decoder.maxConcurrent, 1,
|
||||
reason: 'two concurrent decodes is ~4.3 GiB and an OOM kill');
|
||||
expect(
|
||||
decoder.maxConcurrent,
|
||||
1,
|
||||
reason: 'two concurrent decodes is ~4.3 GiB and an OOM kill',
|
||||
);
|
||||
for (final id in ids) {
|
||||
expect(h.store.entryFor(id)!.state, ReceivedImageState.decoded);
|
||||
}
|
||||
@@ -895,33 +930,35 @@ void main() {
|
||||
});
|
||||
|
||||
group('deletion hooks', () {
|
||||
test('deleteImagesForChannel reclaims every image of one conversation',
|
||||
() async {
|
||||
final h = _build();
|
||||
final a = await _completeOne(h, imgId: 130);
|
||||
h.advance(const Duration(seconds: 2));
|
||||
final b = await _completeOne(h, imgId: 131, seed: 1);
|
||||
await h.store.settle();
|
||||
// A third image on another channel must survive.
|
||||
final other = await h.store.registerOutgoing(
|
||||
channelIndex: 9,
|
||||
senderPrefix: kSelf,
|
||||
imgId: 5,
|
||||
previewPng: Uint8List.fromList(List<int>.filled(32, 2)),
|
||||
rate: AeicRatePoint.ft32,
|
||||
chunkCount: 1,
|
||||
);
|
||||
test(
|
||||
'deleteImagesForChannel reclaims every image of one conversation',
|
||||
() async {
|
||||
final h = _build();
|
||||
final a = await _completeOne(h, imgId: 130);
|
||||
h.advance(const Duration(seconds: 2));
|
||||
final b = await _completeOne(h, imgId: 131, seed: 1);
|
||||
await h.store.settle();
|
||||
// A third image on another channel must survive.
|
||||
final other = await h.store.registerOutgoing(
|
||||
channelIndex: 9,
|
||||
senderPrefix: kSelf,
|
||||
imgId: 5,
|
||||
previewPng: Uint8List.fromList(List<int>.filled(32, 2)),
|
||||
rate: AeicRatePoint.ft32,
|
||||
chunkCount: 1,
|
||||
);
|
||||
|
||||
final removed = await h.store.deleteImagesForChannel(3);
|
||||
expect(removed, containsAll(<String>[a.streamId, b.streamId]));
|
||||
expect(h.store.entryFor(a.streamId), isNull);
|
||||
expect(h.store.entryFor(b.streamId), isNull);
|
||||
expect(h.blobs.hasPng(a.streamId), isFalse);
|
||||
expect(h.blobs.hasBitstream(a.streamId), isFalse);
|
||||
expect(h.blobs.hasSidecar(a.streamId), isFalse);
|
||||
expect(h.store.entryFor(other.streamId), isNotNull);
|
||||
expect(h.store.totalBytes, other.storedBytes);
|
||||
});
|
||||
final removed = await h.store.deleteImagesForChannel(3);
|
||||
expect(removed, containsAll(<String>[a.streamId, b.streamId]));
|
||||
expect(h.store.entryFor(a.streamId), isNull);
|
||||
expect(h.store.entryFor(b.streamId), isNull);
|
||||
expect(h.blobs.hasPng(a.streamId), isFalse);
|
||||
expect(h.blobs.hasBitstream(a.streamId), isFalse);
|
||||
expect(h.blobs.hasSidecar(a.streamId), isFalse);
|
||||
expect(h.store.entryFor(other.streamId), isNotNull);
|
||||
expect(h.store.totalBytes, other.storedBytes);
|
||||
},
|
||||
);
|
||||
|
||||
test('deleteImageForSentinel takes the message text', () async {
|
||||
final h = _build();
|
||||
@@ -950,9 +987,8 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
FileReceivedImageBlobStore newStore() => FileReceivedImageBlobStore(
|
||||
baseDirectory: () async => tempDir,
|
||||
);
|
||||
FileReceivedImageBlobStore newStore() =>
|
||||
FileReceivedImageBlobStore(baseDirectory: () async => tempDir);
|
||||
|
||||
test('round-trips bytes and sidecars through real files', () async {
|
||||
final blobs = newStore();
|
||||
@@ -964,7 +1000,9 @@ void main() {
|
||||
expect(await blobs.readPng('1a2b0700693f21'), _payload(4096, 3));
|
||||
expect(await blobs.bitstreamSize('1a2b0700693f21'), 155);
|
||||
expect(await blobs.pngSize('1a2b0700693f21'), 4096);
|
||||
expect(await blobs.readSidecars(), {'1a2b0700693f21': '{"streamId":"x"}'});
|
||||
expect(await blobs.readSidecars(), {
|
||||
'1a2b0700693f21': '{"streamId":"x"}',
|
||||
});
|
||||
expect(blobs.pngPath('1a2b0700693f21'), endsWith('1a2b0700693f21.png'));
|
||||
expect(File(blobs.pngPath('1a2b0700693f21')!).existsSync(), isTrue);
|
||||
|
||||
@@ -1044,8 +1082,10 @@ void main() {
|
||||
at: now,
|
||||
);
|
||||
await store.settle();
|
||||
expect(store.entryFor(entry!.streamId)!.state,
|
||||
ReceivedImageState.decoded);
|
||||
expect(
|
||||
store.entryFor(entry!.streamId)!.state,
|
||||
ReceivedImageState.decoded,
|
||||
);
|
||||
store.dispose();
|
||||
|
||||
final reborn = ReceivedImageStore(
|
||||
@@ -1055,8 +1095,11 @@ void main() {
|
||||
);
|
||||
await reborn.load();
|
||||
final loaded = reborn.entryFor(entry.streamId);
|
||||
expect(loaded, isNotNull,
|
||||
reason: 'the whole point: images outlive the process');
|
||||
expect(
|
||||
loaded,
|
||||
isNotNull,
|
||||
reason: 'the whole point: images outlive the process',
|
||||
);
|
||||
expect(loaded!.state, ReceivedImageState.decoded);
|
||||
expect(loaded.pngStored, isTrue);
|
||||
expect(loaded.pngBytes, isNull, reason: 'pixels are read lazily');
|
||||
@@ -1098,33 +1141,35 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('listeners fire for the message list and for the single bubble',
|
||||
() async {
|
||||
final h = _build();
|
||||
var storeNotifications = 0;
|
||||
h.store.addListener(() => storeNotifications++);
|
||||
final set = buildImageChunks(
|
||||
payload: _payload(kImageChunkFirstCapacity + 1),
|
||||
metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard),
|
||||
senderPrefix: kSender,
|
||||
imgId: 77,
|
||||
);
|
||||
final first = await h.store.handleOutcome(
|
||||
h.reassembler.addChunk(set.blobs[0], channelIndex: 3),
|
||||
channelIndex: 3,
|
||||
);
|
||||
final listenable = h.store.listenableFor(first!.streamId);
|
||||
var bubbleNotifications = 0;
|
||||
listenable.addListener(() => bubbleNotifications++);
|
||||
await h.store.handleOutcome(
|
||||
h.reassembler.addChunk(set.blobs[1], channelIndex: 3),
|
||||
channelIndex: 3,
|
||||
);
|
||||
await h.store.settle();
|
||||
expect(storeNotifications, greaterThan(1));
|
||||
expect(bubbleNotifications, greaterThan(1));
|
||||
expect(listenable.value!.state, ReceivedImageState.decoded);
|
||||
});
|
||||
test(
|
||||
'listeners fire for the message list and for the single bubble',
|
||||
() async {
|
||||
final h = _build();
|
||||
var storeNotifications = 0;
|
||||
h.store.addListener(() => storeNotifications++);
|
||||
final set = buildImageChunks(
|
||||
payload: _payload(kImageChunkFirstCapacity + 1),
|
||||
metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard),
|
||||
senderPrefix: kSender,
|
||||
imgId: 77,
|
||||
);
|
||||
final first = await h.store.handleOutcome(
|
||||
h.reassembler.addChunk(set.blobs[0], channelIndex: 3),
|
||||
channelIndex: 3,
|
||||
);
|
||||
final listenable = h.store.listenableFor(first!.streamId);
|
||||
var bubbleNotifications = 0;
|
||||
listenable.addListener(() => bubbleNotifications++);
|
||||
await h.store.handleOutcome(
|
||||
h.reassembler.addChunk(set.blobs[1], channelIndex: 3),
|
||||
channelIndex: 3,
|
||||
);
|
||||
await h.store.settle();
|
||||
expect(storeNotifications, greaterThan(1));
|
||||
expect(bubbleNotifications, greaterThan(1));
|
||||
expect(listenable.value!.state, ReceivedImageState.decoded);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Delivers a whole single-chunk image and returns its entry (state
|
||||
|
||||
@@ -83,8 +83,9 @@ List<String> _texts(WidgetTester tester) => tester
|
||||
.toList();
|
||||
|
||||
void main() {
|
||||
testWidgets('renders with a fake codec and shows the image preview',
|
||||
(tester) async {
|
||||
testWidgets('renders with a fake codec and shows the image preview', (
|
||||
tester,
|
||||
) async {
|
||||
await _openSheet(tester, radio: _knownRadio);
|
||||
|
||||
expect(find.byType(ImageSendPreviewSheet), findsOneWidget);
|
||||
@@ -94,8 +95,9 @@ void main() {
|
||||
expect(send.onPressed, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('shows the packet count for the encoded ft32 payload',
|
||||
(tester) async {
|
||||
testWidgets('shows the packet count for the encoded ft32 payload', (
|
||||
tester,
|
||||
) async {
|
||||
await _openSheet(tester, radio: _knownRadio);
|
||||
|
||||
// FakeImageSendCodec emits the measured ft32 mean (156 B) -> one data chunk,
|
||||
@@ -110,8 +112,9 @@ void main() {
|
||||
expect(_texts(tester), contains('${expected.chunkCount}'));
|
||||
});
|
||||
|
||||
testWidgets('shows a concrete airtime when the radio settings are known',
|
||||
(tester) async {
|
||||
testWidgets('shows a concrete airtime when the radio settings are known', (
|
||||
tester,
|
||||
) async {
|
||||
await _openSheet(tester, radio: _knownRadio);
|
||||
|
||||
final texts = _texts(tester);
|
||||
@@ -128,8 +131,9 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('headline time is the paced wall clock, not raw airtime',
|
||||
(tester) async {
|
||||
testWidgets('headline time is the paced wall clock, not raw airtime', (
|
||||
tester,
|
||||
) async {
|
||||
await _openSheet(tester, radio: _knownRadio);
|
||||
|
||||
final expected = estimateSendFromRadioParams(
|
||||
@@ -154,8 +158,9 @@ void main() {
|
||||
expect(texts, isNot(contains('${seconds(expected.totalAirtime!)} s')));
|
||||
});
|
||||
|
||||
testWidgets('renders "unknown" airtime when the radio params are absent',
|
||||
(tester) async {
|
||||
testWidgets('renders "unknown" airtime when the radio params are absent', (
|
||||
tester,
|
||||
) async {
|
||||
await _openSheet(tester, radio: _unknownRadio);
|
||||
|
||||
final texts = _texts(tester);
|
||||
@@ -223,8 +228,9 @@ void main() {
|
||||
expect(find.byType(ImageSendPreviewSheet), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('confirming returns the payload, packet count and both times',
|
||||
(tester) async {
|
||||
testWidgets('confirming returns the payload, packet count and both times', (
|
||||
tester,
|
||||
) async {
|
||||
ImageSendPreviewResult? result;
|
||||
|
||||
await tester.pumpWidget(
|
||||
@@ -270,8 +276,9 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a codec that is still downloading cannot be sent from',
|
||||
(tester) async {
|
||||
testWidgets('a codec that is still downloading cannot be sent from', (
|
||||
tester,
|
||||
) async {
|
||||
await _openSheet(
|
||||
tester,
|
||||
radio: _knownRadio,
|
||||
@@ -289,7 +296,8 @@ void main() {
|
||||
|
||||
testWidgets('an unavailable codec explains itself with unavailableReason, '
|
||||
'not the generic string', (tester) async {
|
||||
const reason = 'This build ships the image decoder only. Encoding and '
|
||||
const reason =
|
||||
'This build ships the image decoder only. Encoding and '
|
||||
'decoding a bitstream also needs the entropy-side graph and the rANS '
|
||||
'coder, which are not included yet.';
|
||||
await _openSheet(
|
||||
|
||||
@@ -96,8 +96,9 @@ Future<void> _pumpBubble(
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('decoded incoming bubble carries the R6 badge and caption',
|
||||
(tester) async {
|
||||
testWidgets('decoded incoming bubble carries the R6 badge and caption', (
|
||||
tester,
|
||||
) async {
|
||||
final blobs = InMemoryReceivedImageBlobStore();
|
||||
final store = ReceivedImageStore(blobs: blobs);
|
||||
final png = base64Decode(_png1x1);
|
||||
@@ -161,7 +162,6 @@ void main() {
|
||||
expect(find.textContaining('Fine detail is generated'), findsOneWidget);
|
||||
});
|
||||
|
||||
|
||||
testWidgets('receiving state shows the packet count', (tester) async {
|
||||
final store = ReceivedImageStore(decoder: null);
|
||||
final reassembler = ImageReassembler(selfPrefix: 0xbeef);
|
||||
@@ -193,21 +193,23 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'awaiting card shows the bitstream size, the packet count and the '
|
||||
'tap-to-process affordance', (tester) async {
|
||||
final store = await _awaitingStore(bytes: 156, packets: 2);
|
||||
final entry = store.entryFor('await1')!;
|
||||
expect(entry.state, ReceivedImageState.reassembled);
|
||||
expect(entry.needsManualDecode, isTrue);
|
||||
'awaiting card shows the bitstream size, the packet count and the '
|
||||
'tap-to-process affordance',
|
||||
(tester) async {
|
||||
final store = await _awaitingStore(bytes: 156, packets: 2);
|
||||
final entry = store.entryFor('await1')!;
|
||||
expect(entry.state, ReceivedImageState.reassembled);
|
||||
expect(entry.needsManualDecode, isTrue);
|
||||
|
||||
await _pumpBubble(tester, store);
|
||||
await _pumpBubble(tester, store);
|
||||
|
||||
expect(find.text('156 bytes · 2 packets'), findsOneWidget);
|
||||
expect(find.text('Tap to process'), findsOneWidget);
|
||||
// A placeholder is never dressed up as a picture.
|
||||
expect(find.text('AI-reconstructed'), findsNothing);
|
||||
expect(find.byType(Image), findsNothing);
|
||||
});
|
||||
expect(find.text('156 bytes · 2 packets'), findsOneWidget);
|
||||
expect(find.text('Tap to process'), findsOneWidget);
|
||||
// A placeholder is never dressed up as a picture.
|
||||
expect(find.text('AI-reconstructed'), findsNothing);
|
||||
expect(find.byType(Image), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('awaiting card honours injected strings', (tester) async {
|
||||
final store = await _awaitingStore(bytes: 209, packets: 3);
|
||||
@@ -338,8 +340,9 @@ void main() {
|
||||
expect(decoder.decodeCalls, 1);
|
||||
});
|
||||
|
||||
testWidgets('every non-decoded state renders a legible label',
|
||||
(tester) async {
|
||||
testWidgets('every non-decoded state renders a legible label', (
|
||||
tester,
|
||||
) async {
|
||||
const cases = <String, String>{
|
||||
'failedIncomplete': 'Image incomplete — 1 of 3 packets arrived',
|
||||
'failedCorrupt': 'Image could not be reconstructed',
|
||||
@@ -413,8 +416,9 @@ void main() {
|
||||
expect(find.textContaining('Fine detail is generated'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the caption quotes the real bitstream size, not a nominal one',
|
||||
(tester) async {
|
||||
testWidgets('the caption quotes the real bitstream size, not a nominal one', (
|
||||
tester,
|
||||
) async {
|
||||
// It used to hardcode "~156 bytes" under every image, so a 209-byte image
|
||||
// and a 110-byte one both claimed 156. The label's whole purpose is
|
||||
// honesty about what was actually transmitted.
|
||||
@@ -448,15 +452,22 @@ void main() {
|
||||
await _pumpBubble(tester, store, streamId: id);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('$bytes bytes'), findsOneWidget,
|
||||
reason: 'caption must quote $bytes');
|
||||
expect(find.textContaining('156 bytes'), findsNothing,
|
||||
reason: 'no nominal size may leak into the caption');
|
||||
expect(
|
||||
find.textContaining('$bytes bytes'),
|
||||
findsOneWidget,
|
||||
reason: 'caption must quote $bytes',
|
||||
);
|
||||
expect(
|
||||
find.textContaining('156 bytes'),
|
||||
findsNothing,
|
||||
reason: 'no nominal size may leak into the caption',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('the R6 caption is never truncated in a narrow bubble',
|
||||
(tester) async {
|
||||
testWidgets('the R6 caption is never truncated in a narrow bubble', (
|
||||
tester,
|
||||
) async {
|
||||
// It used to be maxLines: 2 and clipped mid-sentence — the rendered text
|
||||
// read "Details are invented. Not a", cutting off exactly where the
|
||||
// warning was. A half-shown warning is worse than none.
|
||||
|
||||
Reference in New Issue
Block a user