mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-08-11 18:26:27 +10:00
1172 lines
40 KiB
Dart
1172 lines
40 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:meshcore_open/models/image_codec_support.dart';
|
|
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),
|
|
);
|
|
|
|
const ImageStreamMetadata stdMeta = ImageStreamMetadata(
|
|
rate: ImageCodecRatePoint.standard,
|
|
);
|
|
const ImageStreamMetadata highMeta = ImageStreamMetadata(
|
|
rate: ImageCodecRatePoint.high,
|
|
);
|
|
|
|
const int senderA = 0x1234;
|
|
const int senderB = 0xBEEF;
|
|
|
|
/// A copy of [blob] with the byte at [offset] flipped by [mask].
|
|
Uint8List flipByte(Uint8List blob, int offset, {int mask = 0x01}) {
|
|
final copy = Uint8List.fromList(blob);
|
|
copy[offset] ^= mask;
|
|
return copy;
|
|
}
|
|
|
|
/// Feeds [blobs] to [r] in the given order and returns the completed image, if
|
|
/// any completed.
|
|
ImageReassemblyResult? feed(
|
|
ImageReassembler r,
|
|
List<Uint8List> blobs, {
|
|
int channelIndex = 0,
|
|
DateTime? now,
|
|
}) {
|
|
ImageReassemblyResult? result;
|
|
for (final blob in blobs) {
|
|
final outcome = r.addChunk(blob, channelIndex: channelIndex, now: now);
|
|
result ??= outcome.result;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
void main() {
|
|
group('constants', () {
|
|
test('every blob fits the binding transport limit', () {
|
|
expect(kImageChunkBlobBytes, 163);
|
|
expect(kImageChunkHeaderBytes, 4);
|
|
expect(kImageChunkBodyBytes, 158);
|
|
// Chunk 0 spends 1 byte on metadata and nothing else: the CRC-16 that
|
|
// briefly lived here was removed once it turned out the LoRa PHY CRC and
|
|
// MeshCore's per-packet HMAC already cover a delivered chunk.
|
|
expect(kImageChunkMetadataBytes, 1);
|
|
expect(kImageChunkZeroMetadataBytes, 1);
|
|
expect(kImageChunkFirstCapacity, 157);
|
|
expect(kImageChunkCapacity, 158);
|
|
expect(kImageMaxPayloadBytes, 157 + 14 * 158);
|
|
});
|
|
|
|
test('measured codec worst cases hit the design chunk counts', () {
|
|
// ft32 (standard) max 209 B, ft16 (high) max 409 B.
|
|
expect(imageDataChunkCount(209), 2);
|
|
expect(imageDataChunkCount(409), 3);
|
|
// With chunk 0 back at 157 data bytes the measured ft32 MEAN fits in a
|
|
// single data chunk (2 packets with parity), which is the whole point of
|
|
// dropping the CRC.
|
|
expect(ImageCodecRateStats.standard.meanBytes, 156);
|
|
expect(imageDataChunkCount(ImageCodecRateStats.standard.meanBytes), 1);
|
|
expect(imageDataChunkCount(157), 1);
|
|
expect(imageDataChunkCount(158), 2);
|
|
expect(imageDataChunkCount(ImageCodecRateStats.standard.minBytes), 1);
|
|
expect(imageDataChunkCount(ImageCodecRateStats.high.meanBytes), 2);
|
|
});
|
|
});
|
|
|
|
group('metadata byte', () {
|
|
test('round-trips both rate points', () {
|
|
for (final rate in ImageCodecRatePoint.values) {
|
|
final meta = ImageStreamMetadata(rate: rate);
|
|
expect(ImageStreamMetadata.decode(meta.encode()), meta);
|
|
}
|
|
});
|
|
|
|
test('rejects an unrepresentable resolution', () {
|
|
expect(
|
|
() => const ImageStreamMetadata(
|
|
rate: ImageCodecRatePoint.standard,
|
|
squareSize: 999,
|
|
).encode(),
|
|
throwsArgumentError,
|
|
);
|
|
});
|
|
|
|
test('an unknown RATE code is what signals a format break', () {
|
|
// The byte is aspect(4) | resolution(2) | rate(2). Resolution is now a
|
|
// closed 2-bit field: all four codes are legal, so an unknown resolution
|
|
// is no longer expressible. Rate keeps two spare codes (2 and 3), and
|
|
// that is the channel a future incompatible format must use so older
|
|
// receivers reject it instead of guessing.
|
|
expect(ImageStreamMetadata.decode(0x02), isNull); // rate code 2
|
|
expect(ImageStreamMetadata.decode(0x03), isNull); // rate code 3
|
|
// 0xF0 is aspect 15 (unknown shape), resolution 0, rate 0 — all legal.
|
|
final legal = ImageStreamMetadata.decode(0xF0);
|
|
expect(legal, isNotNull);
|
|
expect(legal!.aspectCode, kImageAspectUnknown);
|
|
expect(legal.isSquare, isTrue);
|
|
});
|
|
});
|
|
|
|
group('chunking', () {
|
|
test('one, two and three chunk payloads produce the right blob shapes', () {
|
|
final cases = <int, int>{
|
|
1: 1,
|
|
kImageChunkFirstCapacity: 1,
|
|
kImageChunkFirstCapacity + 1: 2,
|
|
kImageChunkFirstCapacity + kImageChunkCapacity: 2,
|
|
kImageChunkFirstCapacity + kImageChunkCapacity + 1: 3,
|
|
};
|
|
cases.forEach((length, expectedChunks) {
|
|
final set = buildImageChunks(
|
|
payload: payloadOf(length),
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 3,
|
|
);
|
|
expect(set.dataChunkCount, expectedChunks, reason: 'len $length');
|
|
expect(set.blobs.length, expectedChunks + 1); // + parity
|
|
for (final blob in set.blobs) {
|
|
expect(blob.length, lessThanOrEqualTo(kImageChunkBlobBytes));
|
|
}
|
|
});
|
|
});
|
|
|
|
test('header fields are on the wire where the spec says', () {
|
|
final set = buildImageChunks(
|
|
payload: payloadOf(400),
|
|
metadata: highMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 0x5A,
|
|
);
|
|
expect(set.dataChunkCount, 3);
|
|
for (var i = 0; i < set.blobs.length; i++) {
|
|
final blob = set.blobs[i];
|
|
expect(blob[0], 0x12);
|
|
expect(blob[1], 0x34);
|
|
expect(blob[2], 0x5A);
|
|
expect((blob[3] >> 4) & 0x0F, i);
|
|
expect(blob[3] & 0x0F, 3);
|
|
}
|
|
expect(parseImageChunkHeader(set.blobs.last)!.isParity, isTrue);
|
|
expect(set.blobs[4 - 1].length, greaterThan(kImageChunkHeaderBytes));
|
|
// Chunk 0 carries the metadata byte first.
|
|
expect(set.blobs[0][kImageChunkHeaderBytes], highMeta.encode());
|
|
});
|
|
|
|
test('rejects a payload larger than the framing can address', () {
|
|
expect(
|
|
() => buildImageChunks(
|
|
payload: payloadOf(kImageMaxPayloadBytes + 1),
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 1,
|
|
),
|
|
throwsArgumentError,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('round trip', () {
|
|
for (final length in <int>[
|
|
100, // 1 chunk
|
|
300, // 2 chunks
|
|
460, // 3 chunks
|
|
]) {
|
|
test('$length bytes round-trips in order', () {
|
|
final payload = payloadOf(length);
|
|
final set = buildImageChunks(
|
|
payload: payload,
|
|
metadata: highMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 11,
|
|
);
|
|
final r = ImageReassembler(selfPrefix: senderB);
|
|
final result = feed(r, set.blobs);
|
|
expect(result, isNotNull);
|
|
expect(result!.data, payload);
|
|
expect(result.metadata, highMeta);
|
|
expect(result.recoveredWithParity, isFalse);
|
|
expect(result.chunkCount, set.dataChunkCount);
|
|
expect(r.pendingCount, 0, reason: 'trailing parity must not linger');
|
|
});
|
|
}
|
|
|
|
test('completion callback fires exactly once', () {
|
|
final payload = payloadOf(300);
|
|
final set = buildImageChunks(
|
|
payload: payload,
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 4,
|
|
);
|
|
final results = <ImageReassemblyResult>[];
|
|
final r = ImageReassembler(onImage: results.add);
|
|
feed(r, set.blobs);
|
|
expect(results.length, 1);
|
|
expect(results.single.data, payload);
|
|
});
|
|
|
|
test('out-of-order arrival still reassembles', () {
|
|
final payload = payloadOf(460, seed: 3);
|
|
final set = buildImageChunks(
|
|
payload: payload,
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 9,
|
|
);
|
|
// All three data chunks arrive out of order, parity last (so this
|
|
// exercises reordering, not parity recovery).
|
|
final shuffled = <Uint8List>[
|
|
set.blobs[2],
|
|
set.blobs[0],
|
|
set.blobs[1],
|
|
set.blobs[3], // parity
|
|
];
|
|
final r = ImageReassembler();
|
|
final result = feed(r, shuffled);
|
|
expect(result, isNotNull);
|
|
expect(result!.data, payload);
|
|
expect(result.recoveredWithParity, isFalse);
|
|
});
|
|
|
|
test('duplicate chunks are ignored', () {
|
|
final payload = payloadOf(460); // 3 data chunks + parity
|
|
final set = buildImageChunks(
|
|
payload: payload,
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 2,
|
|
);
|
|
final r = ImageReassembler();
|
|
expect(r.addChunk(set.blobs[0]).status, ImageChunkStatus.accepted);
|
|
expect(r.addChunk(set.blobs[0]).status, ImageChunkStatus.duplicate);
|
|
expect(r.addChunk(set.blobs[2]).status, ImageChunkStatus.accepted);
|
|
expect(r.addChunk(set.blobs[2]).status, ImageChunkStatus.duplicate);
|
|
final done = r.addChunk(set.blobs[1]);
|
|
expect(done.status, ImageChunkStatus.completed);
|
|
expect(done.result!.data, payload);
|
|
expect(done.result!.recoveredWithParity, isFalse);
|
|
// Trailing parity and late duplicates are ignored, not restarted.
|
|
expect(r.addChunk(set.blobs[3]).status, ImageChunkStatus.duplicate);
|
|
expect(r.addChunk(set.blobs[1]).status, ImageChunkStatus.duplicate);
|
|
expect(r.pendingCount, 0);
|
|
});
|
|
|
|
test('a duplicate parity chunk is ignored', () {
|
|
final set = buildImageChunks(
|
|
payload: payloadOf(460),
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 15,
|
|
);
|
|
final r = ImageReassembler();
|
|
expect(r.addChunk(set.blobs[3]).status, ImageChunkStatus.accepted);
|
|
expect(r.addChunk(set.blobs[3]).status, ImageChunkStatus.duplicate);
|
|
expect(r.pendingCount, 1);
|
|
});
|
|
|
|
test('parity completes an image that is one chunk short', () {
|
|
final payload = payloadOf(300); // 2 data chunks
|
|
final set = buildImageChunks(
|
|
payload: payload,
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 16,
|
|
);
|
|
final r = ImageReassembler();
|
|
expect(r.addChunk(set.blobs[0]).status, ImageChunkStatus.accepted);
|
|
final done = r.addChunk(set.blobs[2]); // parity rebuilds chunk 1
|
|
expect(done.status, ImageChunkStatus.completed);
|
|
expect(done.result!.data, payload);
|
|
expect(done.result!.recoveredWithParity, isTrue);
|
|
});
|
|
|
|
test('two senders interleaved on one channel do not mix', () {
|
|
final a = payloadOf(300, seed: 1);
|
|
final b = payloadOf(300, seed: 2);
|
|
final setA = buildImageChunks(
|
|
payload: a,
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 5,
|
|
);
|
|
final setB = buildImageChunks(
|
|
payload: b,
|
|
metadata: highMeta,
|
|
senderPrefix: senderB,
|
|
imgId: 5, // same img_id on purpose
|
|
);
|
|
final got = <int, Uint8List>{};
|
|
final r = ImageReassembler(
|
|
onImage: (res) => got[res.key.senderPrefix] = res.data,
|
|
);
|
|
feed(r, <Uint8List>[
|
|
setA.blobs[0],
|
|
setB.blobs[1],
|
|
setA.blobs[1],
|
|
setB.blobs[0],
|
|
]);
|
|
expect(got[senderA], a);
|
|
expect(got[senderB], b);
|
|
});
|
|
|
|
test('the same img_id on different channels stays separate', () {
|
|
final payload = payloadOf(300);
|
|
final set = buildImageChunks(
|
|
payload: payload,
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 8,
|
|
);
|
|
final r = ImageReassembler();
|
|
expect(
|
|
r.addChunk(set.blobs[0], channelIndex: 0).status,
|
|
ImageChunkStatus.accepted,
|
|
);
|
|
expect(
|
|
r.addChunk(set.blobs[1], channelIndex: 1).status,
|
|
ImageChunkStatus.accepted,
|
|
);
|
|
expect(r.pendingCount, 2);
|
|
});
|
|
});
|
|
|
|
group('parity recovery', () {
|
|
test('recovers any single lost data chunk (2 and 3 chunk images)', () {
|
|
for (final length in <int>[300, 460]) {
|
|
final payload = payloadOf(length, seed: length);
|
|
final set = buildImageChunks(
|
|
payload: payload,
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 6,
|
|
);
|
|
for (var lost = 0; lost < set.dataChunkCount; lost++) {
|
|
final blobs = <Uint8List>[
|
|
for (var i = 0; i < set.blobs.length; i++)
|
|
if (i != lost) set.blobs[i],
|
|
];
|
|
final r = ImageReassembler();
|
|
final result = feed(r, blobs);
|
|
expect(result, isNotNull, reason: 'len $length lost $lost');
|
|
expect(result!.data, payload, reason: 'len $length lost $lost');
|
|
expect(result.metadata, stdMeta);
|
|
expect(result.recoveredWithParity, isTrue);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('recovers the short final chunk (length is carried by parity)', () {
|
|
// 158 + 1 => final chunk is a single byte long.
|
|
final payload = payloadOf(kImageChunkFirstCapacity + 1);
|
|
final set = buildImageChunks(
|
|
payload: payload,
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 12,
|
|
);
|
|
expect(set.dataChunkCount, 2);
|
|
final r = ImageReassembler();
|
|
final result = feed(r, <Uint8List>[set.blobs[0], set.blobs[2]]);
|
|
expect(result, isNotNull);
|
|
expect(result!.data, payload);
|
|
expect(result.recoveredWithParity, isTrue);
|
|
});
|
|
|
|
test('total=1 with parity recovers the only data chunk', () {
|
|
final payload = payloadOf(100);
|
|
final set = buildImageChunks(
|
|
payload: payload,
|
|
metadata: highMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 1,
|
|
);
|
|
expect(set.dataChunkCount, 1);
|
|
expect(set.blobs.length, 2);
|
|
final r = ImageReassembler();
|
|
final result = feed(r, <Uint8List>[set.blobs[1]]); // parity only
|
|
expect(result, isNotNull);
|
|
expect(result!.data, payload);
|
|
expect(result.metadata, highMeta);
|
|
expect(result.recoveredWithParity, isTrue);
|
|
});
|
|
|
|
test('losing two chunks fails and never yields a wrong image', () {
|
|
final payload = payloadOf(460);
|
|
final set = buildImageChunks(
|
|
payload: payload,
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 7,
|
|
);
|
|
final r = ImageReassembler();
|
|
// Deliver only chunk 1 and parity: two data chunks are missing.
|
|
final result = feed(r, <Uint8List>[set.blobs[1], set.blobs[3]]);
|
|
expect(result, isNull);
|
|
expect(r.pendingCount, 1);
|
|
});
|
|
|
|
test('parity is optional', () {
|
|
final payload = payloadOf(300);
|
|
final set = buildImageChunks(
|
|
payload: payload,
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 3,
|
|
parity: false,
|
|
);
|
|
expect(set.hasParity, isFalse);
|
|
expect(set.blobs.length, 2);
|
|
final r = ImageReassembler();
|
|
expect(feed(r, set.blobs)!.data, payload);
|
|
});
|
|
});
|
|
|
|
group('TTL and eviction', () {
|
|
test('a stalled image expires and is reported as failed', () {
|
|
final set = buildImageChunks(
|
|
payload: payloadOf(460),
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 21,
|
|
);
|
|
final failures = <ImageReassemblyFailure>[];
|
|
final start = DateTime(2026, 1, 1, 12);
|
|
final r = ImageReassembler(
|
|
ttl: const Duration(seconds: 60),
|
|
onFailed: failures.add,
|
|
);
|
|
r.addChunk(set.blobs[0], now: start);
|
|
r.addChunk(set.blobs[1], now: start.add(const Duration(seconds: 30)));
|
|
expect(r.pendingCount, 1);
|
|
|
|
expect(
|
|
r.evictExpired(now: start.add(const Duration(seconds: 59))),
|
|
isEmpty,
|
|
);
|
|
expect(r.pendingCount, 1);
|
|
|
|
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);
|
|
expect(expired.single.hadParity, isFalse);
|
|
expect(expired.single.missingChunks, 1);
|
|
expect(failures.length, 1);
|
|
expect(r.pendingCount, 0);
|
|
});
|
|
|
|
test('addChunk sweeps expired streams', () {
|
|
final setOld = buildImageChunks(
|
|
payload: payloadOf(460),
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 30,
|
|
);
|
|
final setNew = buildImageChunks(
|
|
payload: payloadOf(300),
|
|
metadata: stdMeta,
|
|
senderPrefix: senderB,
|
|
imgId: 31,
|
|
);
|
|
final failures = <ImageReassemblyFailure>[];
|
|
final start = DateTime(2026, 1, 1);
|
|
final r = ImageReassembler(onFailed: failures.add);
|
|
r.addChunk(setOld.blobs[0], now: start);
|
|
r.addChunk(setNew.blobs[0], now: start.add(const Duration(minutes: 5)));
|
|
expect(failures.length, 1);
|
|
expect(failures.single.key.imgId, 30);
|
|
expect(r.pendingCount, 1);
|
|
});
|
|
|
|
test('an unrelated chunk after expiry does not resurrect the old data', () {
|
|
final set = buildImageChunks(
|
|
payload: payloadOf(300),
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 40,
|
|
);
|
|
final start = DateTime(2026, 5, 5);
|
|
final r = ImageReassembler();
|
|
r.addChunk(set.blobs[0], now: start);
|
|
final late = r.addChunk(
|
|
set.blobs[1],
|
|
now: start.add(const Duration(minutes: 2)),
|
|
);
|
|
// Chunk 0 expired, so chunk 1 alone cannot complete anything.
|
|
expect(late.status, ImageChunkStatus.accepted);
|
|
expect(late.result, isNull);
|
|
});
|
|
|
|
test('too many concurrent streams evicts the oldest', () {
|
|
final start = DateTime(2026, 2, 2);
|
|
final failures = <ImageReassemblyFailure>[];
|
|
final r = ImageReassembler(
|
|
maxConcurrentStreams: 2,
|
|
onFailed: failures.add,
|
|
);
|
|
for (var i = 0; i < 3; i++) {
|
|
final set = buildImageChunks(
|
|
payload: payloadOf(300),
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 50 + i,
|
|
);
|
|
r.addChunk(set.blobs[0], now: start.add(Duration(seconds: i)));
|
|
}
|
|
expect(r.pendingCount, 2);
|
|
expect(failures.length, 1);
|
|
expect(failures.single.key.imgId, 50);
|
|
});
|
|
});
|
|
|
|
group('boundary cases', () {
|
|
test('empty payload round-trips as one chunk', () {
|
|
final set = buildImageChunks(
|
|
payload: Uint8List(0),
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 0,
|
|
);
|
|
expect(set.dataChunkCount, 1);
|
|
expect(
|
|
set.blobs[0].length,
|
|
kImageChunkHeaderBytes + kImageChunkZeroMetadataBytes,
|
|
);
|
|
final r = ImageReassembler();
|
|
final result = feed(r, set.blobs);
|
|
expect(result, isNotNull);
|
|
expect(result!.data, isEmpty);
|
|
expect(result.metadata, stdMeta);
|
|
});
|
|
|
|
test('empty payload recovers from parity alone', () {
|
|
final set = buildImageChunks(
|
|
payload: Uint8List(0),
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 0,
|
|
);
|
|
final r = ImageReassembler();
|
|
final result = feed(r, <Uint8List>[set.blobs[1]]);
|
|
expect(result, isNotNull);
|
|
expect(result!.data, isEmpty);
|
|
});
|
|
|
|
test('maximum addressable payload round-trips', () {
|
|
final payload = payloadOf(kImageMaxPayloadBytes);
|
|
final set = buildImageChunks(
|
|
payload: payload,
|
|
metadata: highMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 255,
|
|
);
|
|
expect(set.dataChunkCount, kImageMaxDataChunks);
|
|
expect(set.blobs.length, kImageMaxDataChunks + 1);
|
|
for (final blob in set.blobs) {
|
|
expect(blob.length, lessThanOrEqualTo(kImageChunkBlobBytes));
|
|
}
|
|
final r = ImageReassembler();
|
|
expect(feed(r, set.blobs.reversed.toList())!.data, payload);
|
|
});
|
|
|
|
test('maximum payload still recovers a single loss', () {
|
|
final payload = payloadOf(kImageMaxPayloadBytes, seed: 5);
|
|
final set = buildImageChunks(
|
|
payload: payload,
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 254,
|
|
);
|
|
final blobs = <Uint8List>[
|
|
for (var i = 0; i < set.blobs.length; i++)
|
|
if (i != kImageMaxDataChunks - 1) set.blobs[i],
|
|
];
|
|
final r = ImageReassembler();
|
|
final result = feed(r, blobs);
|
|
expect(result!.data, payload);
|
|
expect(result.recoveredWithParity, isTrue);
|
|
});
|
|
|
|
test('img_id wraps around 255 -> 0', () {
|
|
final alloc = ImageIdAllocator(seed: 254);
|
|
expect(alloc.next(), 254);
|
|
expect(alloc.next(), 255);
|
|
expect(alloc.next(), 0);
|
|
expect(alloc.next(), 1);
|
|
});
|
|
|
|
test('img_id wraparound reusing a key restarts the stream', () {
|
|
final first = buildImageChunks(
|
|
payload: payloadOf(460, seed: 1),
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 77,
|
|
);
|
|
final second = payloadOf(300, seed: 2);
|
|
final secondSet = buildImageChunks(
|
|
payload: second,
|
|
metadata: highMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 77, // wrapped back onto the same id
|
|
);
|
|
final r = ImageReassembler();
|
|
// Partially deliver the first image (3 data chunks), then the second
|
|
// image (2 data chunks) arrives under the same key.
|
|
expect(r.addChunk(first.blobs[0]).status, ImageChunkStatus.accepted);
|
|
expect(
|
|
r.addChunk(secondSet.blobs[0]).status,
|
|
ImageChunkStatus.conflicting,
|
|
);
|
|
final done = r.addChunk(secondSet.blobs[1]);
|
|
expect(done.status, ImageChunkStatus.completed);
|
|
expect(done.result!.data, second);
|
|
});
|
|
|
|
test('chunks bearing our own prefix are dropped as loopback', () {
|
|
final set = buildImageChunks(
|
|
payload: payloadOf(300),
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 1,
|
|
);
|
|
final r = ImageReassembler(selfPrefix: senderA);
|
|
expect(r.addChunk(set.blobs[0]).status, ImageChunkStatus.fromSelf);
|
|
expect(r.pendingCount, 0);
|
|
});
|
|
|
|
test('malformed blobs are rejected, not stored', () {
|
|
final r = ImageReassembler();
|
|
expect(
|
|
r.addChunk(Uint8List.fromList(<int>[1, 2, 3])).status,
|
|
ImageChunkStatus.malformed,
|
|
);
|
|
// total == 0 is illegal.
|
|
expect(
|
|
r.addChunk(Uint8List.fromList(<int>[0, 1, 2, 0x00, 9])).status,
|
|
ImageChunkStatus.malformed,
|
|
);
|
|
// index > total is illegal.
|
|
expect(
|
|
r.addChunk(Uint8List.fromList(<int>[0, 1, 2, 0x32, 9])).status,
|
|
ImageChunkStatus.malformed,
|
|
);
|
|
// Over-long blob cannot have come from this framing.
|
|
expect(
|
|
r.addChunk(Uint8List(kImageChunkBlobBytes + 1)).status,
|
|
ImageChunkStatus.malformed,
|
|
);
|
|
expect(r.pendingCount, 0);
|
|
});
|
|
|
|
test('senderPrefixFromKey needs two bytes', () {
|
|
expect(senderPrefixFromKey(null), isNull);
|
|
expect(senderPrefixFromKey(<int>[0x12]), isNull);
|
|
expect(senderPrefixFromKey(<int>[0x12, 0x34, 0x56]), 0x1234);
|
|
});
|
|
});
|
|
|
|
group('protocol glue', () {
|
|
test('CMD_SEND_CHANNEL_DATA flood frame matches the wire spec', () {
|
|
final frame = buildSendChannelDataFrame(
|
|
channelIndex: 0,
|
|
dataType: dataTypeAeicImage,
|
|
payload: Uint8List.fromList(<int>[0xAA, 0xBB]),
|
|
);
|
|
expect(frame, <int>[0x3E, 0x00, 0xFF, 0x1C, 0xAE, 0xAA, 0xBB]);
|
|
});
|
|
|
|
test('a direct-path frame inserts the path bytes before the data type', () {
|
|
final frame = buildSendChannelDataFrame(
|
|
channelIndex: 2,
|
|
dataType: dataTypeAeicImage,
|
|
payload: Uint8List.fromList(<int>[0x01]),
|
|
pathLen: 0x02,
|
|
path: Uint8List.fromList(<int>[0x11, 0x22]),
|
|
);
|
|
expect(frame, <int>[0x3E, 0x02, 0x02, 0x11, 0x22, 0x1C, 0xAE, 0x01]);
|
|
});
|
|
|
|
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,
|
|
]);
|
|
final parsed = parseChannelDataFrame(frame)!;
|
|
expect(parsed.snrRaw, -8);
|
|
expect(parsed.snrDb, -2.0);
|
|
expect(parsed.channelIndex, 3);
|
|
expect(parsed.arrivedByFlood, isFalse);
|
|
expect(parsed.hopCount, isNull);
|
|
expect(parsed.dataType, dataTypeAeicImage);
|
|
expect(parsed.payload, <int>[0x42, 0x43]);
|
|
});
|
|
|
|
test('a flooded frame exposes hop count and hash width', () {
|
|
final frame = Uint8List.fromList(<int>[
|
|
0x1B, 0x04, 0, 0, 0, 0x43, 0x1C, 0xAE, 0,
|
|
]);
|
|
final parsed = parseChannelDataFrame(frame)!;
|
|
expect(parsed.arrivedByFlood, isTrue);
|
|
expect(parsed.hopCount, 3);
|
|
expect(parsed.pathHashWidth, 2);
|
|
expect(parsed.payload, isEmpty);
|
|
});
|
|
|
|
test('truncated or foreign frames parse as null', () {
|
|
expect(parseChannelDataFrame(Uint8List(4)), isNull);
|
|
expect(
|
|
parseChannelDataFrame(
|
|
Uint8List.fromList(<int>[0x1B, 0, 0, 0, 0, 0xFF, 0x1C, 0xAE, 5, 1]),
|
|
),
|
|
isNull,
|
|
);
|
|
expect(
|
|
parseChannelDataFrame(
|
|
Uint8List.fromList(<int>[0x10, 0, 0, 0, 0, 0xFF, 0x1C, 0xAE, 0]),
|
|
),
|
|
isNull,
|
|
);
|
|
});
|
|
|
|
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 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);
|
|
});
|
|
|
|
test('handleFrame ignores other data types and other frames', () {
|
|
final rx = ImageReassembler();
|
|
final tx = ImageChunkTransport(
|
|
senderPrefix: senderA,
|
|
reassembler: rx,
|
|
send: (_, _) async {},
|
|
);
|
|
expect(
|
|
tx.handleFrame(
|
|
Uint8List.fromList(<int>[0x1B, 0, 0, 0, 0, 0xFF, 0x01, 0x00, 0]),
|
|
),
|
|
isNull,
|
|
);
|
|
expect(tx.handleFrame(Uint8List.fromList(<int>[0x00])), isNull);
|
|
expect(rx.pendingCount, 0);
|
|
});
|
|
|
|
test('concurrent sendImage calls do not interleave on the wire', () async {
|
|
final order = <String>[];
|
|
final tx = ImageChunkTransport(
|
|
senderPrefix: senderA,
|
|
reassembler: ImageReassembler(),
|
|
idAllocator: ImageIdAllocator(seed: 100),
|
|
send: (blob, _) async {
|
|
await Future<void>.delayed(const Duration(milliseconds: 1));
|
|
order.add('${blob[2]}:${(blob[3] >> 4) & 0x0F}');
|
|
},
|
|
);
|
|
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']);
|
|
});
|
|
});
|
|
|
|
group('regressions confirmed by adversarial review', () {
|
|
// PROBE A: an image completes; a DIFFERENT image reusing the same img_id
|
|
// arrives within the TTL. The recently-completed shortcut used to report
|
|
// every chunk of it as `duplicate`, so onImage never fired, onFailed never
|
|
// fired, and the image was lost with no diagnostic. ImageIdAllocator seeds
|
|
// from Random().nextInt(256), so a restart really can re-roll a live id.
|
|
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 first = buildImageChunks(
|
|
payload: payloadOf(200, seed: 1),
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 42,
|
|
);
|
|
feed(r, first.blobs);
|
|
expect(delivered, hasLength(1), reason: 'first image should complete');
|
|
|
|
// Same sender, same img_id, different content and a different length.
|
|
final second = buildImageChunks(
|
|
payload: payloadOf(300, seed: 99),
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 42,
|
|
);
|
|
feed(r, second.blobs);
|
|
|
|
expect(delivered, hasLength(2), reason: 'second image must not be lost');
|
|
expect(delivered.last.data, equals(payloadOf(300, seed: 99)));
|
|
expect(failed, isEmpty);
|
|
});
|
|
|
|
test('a genuine re-send of a delivered chunk is still a duplicate', () {
|
|
final delivered = <ImageReassemblyResult>[];
|
|
final r = ImageReassembler(onImage: delivered.add);
|
|
final set = buildImageChunks(
|
|
payload: payloadOf(200, seed: 1),
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 42,
|
|
);
|
|
feed(r, set.blobs);
|
|
expect(delivered, hasLength(1));
|
|
|
|
// Replay every blob verbatim: none may open a new stream.
|
|
for (final blob in set.blobs) {
|
|
final outcome = r.addChunk(blob, channelIndex: 0);
|
|
expect(outcome.status, ImageChunkStatus.duplicate);
|
|
}
|
|
expect(delivered, hasLength(1));
|
|
expect(r.pendingCount, 0);
|
|
});
|
|
|
|
// 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;
|
|
|
|
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);
|
|
final set = buildImageChunks(
|
|
payload: payload,
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 8,
|
|
);
|
|
final data = set.blobs.sublist(0, set.dataChunkCount);
|
|
expect(data.length, greaterThanOrEqualTo(3));
|
|
|
|
final delivered = <ImageReassemblyResult>[];
|
|
final r = ImageReassembler(onImage: delivered.add);
|
|
feed(r, <Uint8List>[
|
|
for (var i = 0; i < data.length; i++)
|
|
if (i != 1) data[i],
|
|
set.blobs.last,
|
|
]);
|
|
expect(delivered, hasLength(1));
|
|
expect(delivered.single.data, equals(payload));
|
|
expect(delivered.single.recoveredWithParity, isTrue);
|
|
});
|
|
});
|
|
|
|
|
|
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;
|
|
|
|
test('one packet per img_id can complete an image (the attack)', () {
|
|
final r = ImageReassembler();
|
|
final done = r.addChunk(loneParity(1)[1]);
|
|
expect(done.status, ImageChunkStatus.completed);
|
|
expect(r.completedCount, 1);
|
|
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('default cap matches the pending cap', () {
|
|
final start = DateTime(2026, 3, 4);
|
|
final r = ImageReassembler();
|
|
expect(r.maxCompletedStreams, 8);
|
|
expect(r.maxConcurrentStreams, 8);
|
|
for (var i = 0; i < 30; i++) {
|
|
r.addChunk(loneParity(i)[1], now: start.add(Duration(seconds: i)));
|
|
}
|
|
expect(r.completedCount, 8);
|
|
});
|
|
|
|
test('capping does not break straggler suppression for recent images', () {
|
|
final start = DateTime(2026, 3, 5);
|
|
final r = ImageReassembler(maxCompletedStreams: 2);
|
|
final set = buildImageChunks(
|
|
payload: payloadOf(300),
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 200,
|
|
);
|
|
feed(r, set.blobs.sublist(0, 2), now: start);
|
|
// Trailing parity of the newest image is still recognised as a duplicate.
|
|
expect(
|
|
r.addChunk(set.blobs[2], now: start).status,
|
|
ImageChunkStatus.duplicate,
|
|
);
|
|
expect(r.pendingCount, 0);
|
|
});
|
|
|
|
test('clear() empties the completed map too', () {
|
|
final r = ImageReassembler();
|
|
r.addChunk(loneParity(9)[1]);
|
|
expect(r.completedCount, 1);
|
|
r.clear();
|
|
expect(r.completedCount, 0);
|
|
});
|
|
});
|
|
|
|
group('rate point wire codes', () {
|
|
test('the chunk-0 nibble is not the AeicRatePoint ordinal', () {
|
|
// The trap: AeicRatePoint.wireValue is 0..4 and selects a MODEL; ft32 is 4
|
|
// there but 0 in the chunk-0 metadata nibble. If the two were ever
|
|
// 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(kImageRateWireStandard, 0);
|
|
expect(kImageRateWireHigh, 1);
|
|
expect(
|
|
const ImageStreamMetadata(rate: ImageCodecRatePoint.standard).encode() &
|
|
0x0F,
|
|
0,
|
|
);
|
|
// ...and an AeicRatePoint ordinal on the wire is refused outright.
|
|
expect(imageRatePointFromWireCode(AeicRatePoint.ft32.wireValue), isNull);
|
|
expect(imageRatePointFromWireCode(AeicRatePoint.ft8.wireValue), isNull);
|
|
});
|
|
|
|
test('every wire code round-trips and unknown codes are null', () {
|
|
for (final rate in ImageCodecRatePoint.values) {
|
|
final code = imageRateWireCode(rate);
|
|
expect(code, lessThan(kImageRateWireCodeCount));
|
|
expect(imageRatePointFromWireCode(code), rate);
|
|
}
|
|
expect(
|
|
ImageCodecRatePoint.values.map(imageRateWireCode).toSet(),
|
|
hasLength(ImageCodecRatePoint.values.length),
|
|
);
|
|
for (var code = kImageRateWireCodeCount; code < 16; code++) {
|
|
expect(imageRatePointFromWireCode(code), isNull, reason: 'code $code');
|
|
}
|
|
});
|
|
|
|
test('an unknown rate code fails cleanly instead of decoding wrong', () {
|
|
final payload = payloadOf(300);
|
|
final set = buildImageChunks(
|
|
payload: payload,
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 80,
|
|
);
|
|
// Overwrite the 2-bit rate field with 2 — a code no shipping build
|
|
// emits. Without an explicit rate table this would have decoded as a
|
|
// valid rate point and fed the wrong model.
|
|
final bad = Uint8List.fromList(set.blobs[0]);
|
|
bad[kImageChunkHeaderBytes] = (bad[kImageChunkHeaderBytes] & 0xFC) | 0x02;
|
|
|
|
final delivered = <ImageReassemblyResult>[];
|
|
final failures = <ImageReassemblyFailure>[];
|
|
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);
|
|
expect(done.result, isNull);
|
|
expect(delivered, isEmpty);
|
|
expect(failures, hasLength(1));
|
|
expect(
|
|
failures.single.reason,
|
|
ImageReassemblyFailureReason.unsupportedFormat,
|
|
);
|
|
expect(failures.single.isCorrupt, isTrue);
|
|
expect(r.pendingCount, 0);
|
|
});
|
|
|
|
test('a reserved rate code fails the same way through reassembly', () {
|
|
// Resolution is a closed 2-bit field now, so it cannot carry an unknown
|
|
// value; the reserved RATE codes are what a future format must use to be
|
|
// rejected rather than misread. This asserts the rejection survives the
|
|
// whole reassembly path, not just ImageStreamMetadata.decode.
|
|
final set = buildImageChunks(
|
|
payload: payloadOf(300),
|
|
metadata: stdMeta,
|
|
senderPrefix: senderA,
|
|
imgId: 81,
|
|
);
|
|
final bad = Uint8List.fromList(set.blobs[0]);
|
|
bad[kImageChunkHeaderBytes] =
|
|
(bad[kImageChunkHeaderBytes] & 0xFC) | 0x03; // reserved rate code 3
|
|
final r = ImageReassembler();
|
|
r.addChunk(bad);
|
|
expect(
|
|
r.addChunk(set.blobs[1]).status,
|
|
ImageChunkStatus.unsupportedFormat,
|
|
);
|
|
});
|
|
|
|
});
|
|
|
|
group('metadata byte: aspect ratio', () {
|
|
// The codec stretches the WHOLE frame into 512x512 rather than cropping, so
|
|
// nothing outside the frame is lost — but the stretch is not invertible
|
|
// from pixels alone. The sender names the source shape in spare bits of the
|
|
// metadata byte and the receiver letterboxes back.
|
|
test('round-trips every aspect code with rate and resolution intact', () {
|
|
for (var code = 0; code < kImageAspectCodes.length; code++) {
|
|
final m = ImageStreamMetadata(
|
|
rate: ImageCodecRatePoint.standard,
|
|
squareSize: 512,
|
|
aspectCode: code,
|
|
);
|
|
final decoded = ImageStreamMetadata.decode(m.encode());
|
|
expect(decoded, isNotNull, reason: 'aspect code $code');
|
|
expect(decoded!.aspectCode, code);
|
|
expect(decoded.rate, ImageCodecRatePoint.standard);
|
|
expect(decoded.squareSize, 512);
|
|
}
|
|
});
|
|
|
|
test('the whole byte still fits in 8 bits for every legal combination', () {
|
|
for (final size in kImageResolutionCodes) {
|
|
for (final rate in ImageCodecRatePoint.values) {
|
|
for (var code = 0; code < kImageAspectCodes.length; code++) {
|
|
final byte = ImageStreamMetadata(
|
|
rate: rate,
|
|
squareSize: size,
|
|
aspectCode: code,
|
|
).encode();
|
|
expect(byte, inInclusiveRange(0, 255));
|
|
final back = ImageStreamMetadata.decode(byte)!;
|
|
expect(back.squareSize, size);
|
|
expect(back.rate, rate);
|
|
expect(back.aspectCode, code);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('common phone shapes snap exactly, not approximately', () {
|
|
expect(imageAspectCodeFor(4032, 3024), 2); // 4:3
|
|
expect(imageAspectCodeFor(3024, 4032), 9); // 3:4
|
|
expect(imageAspectCodeFor(1920, 1080), 5); // 16:9
|
|
expect(imageAspectCodeFor(1080, 1920), 12); // 9:16
|
|
expect(imageAspectCodeFor(1000, 1000), 0); // 1:1
|
|
// A ratio between table entries still picks the nearest.
|
|
expect(kImageAspectCodes[imageAspectCodeFor(1500, 1000)], <int>[3, 2]);
|
|
});
|
|
|
|
test('extremes and nonsense degrade to unknown, not a wrong shape', () {
|
|
expect(imageAspectCodeFor(4000, 500), kImageAspectUnknown); // 8:1 pano
|
|
expect(imageAspectCodeFor(500, 4000), kImageAspectUnknown);
|
|
expect(imageAspectCodeFor(0, 100), kImageAspectUnknown);
|
|
expect(imageAspectCodeFor(100, 0), kImageAspectUnknown);
|
|
expect(imageAspectCodeFor(-4, 3), kImageAspectUnknown);
|
|
final m = ImageStreamMetadata(
|
|
rate: ImageCodecRatePoint.standard,
|
|
aspectCode: kImageAspectUnknown,
|
|
);
|
|
expect(m.isSquare, isTrue);
|
|
expect(m.aspectRatio, 1.0);
|
|
});
|
|
|
|
test('aspectRatio reconstructs the source shape', () {
|
|
final m = ImageStreamMetadata(
|
|
rate: ImageCodecRatePoint.standard,
|
|
aspectCode: imageAspectCodeFor(1920, 1080),
|
|
);
|
|
expect(m.aspectRatio, closeTo(16 / 9, 1e-9));
|
|
expect(m.isSquare, isFalse);
|
|
});
|
|
});
|
|
}
|