rebuild translations

This commit is contained in:
zjs81
2026-08-11 00:15:45 -07:00
parent eee72b4c65
commit cf6c93c50a
63 changed files with 6584 additions and 4040 deletions
+148 -111
View File
@@ -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
View File
@@ -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
View File
@@ -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,
);
});
});
+23 -20
View File
@@ -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', () {
+2 -1
View File
@@ -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
+1 -4
View File
@@ -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(
+54 -47
View File
@@ -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);
+320 -49
View File
@@ -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),
],
);
}
+2 -2
View File
@@ -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>>();
+184 -139
View File
@@ -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
+23 -15
View File
@@ -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(
+37 -26
View File
@@ -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.