Add ability to send images over lora

This commit is contained in:
Zach
2026-08-10 23:20:46 -07:00
parent 736eb064bf
commit eee72b4c65
129 changed files with 27099 additions and 58 deletions
@@ -0,0 +1,331 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/l10n/app_localizations.dart';
import 'package:meshcore_open/utils/lora_airtime.dart';
import 'package:meshcore_open/widgets/image_send_codec_binding.dart';
import 'package:meshcore_open/widgets/image_send_preview_sheet.dart';
/// A real 1x1 PNG. Image.memory reports decode failures through FlutterError,
/// which fails the test even though the sheet has an errorBuilder, so the test
/// must hand it bytes that actually decode.
final Uint8List _onePixelPng = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/'
'q842iQAAAABJRU5ErkJggg==',
);
/// Radio settings a connected device would report: SF10 / BW 250k / CR 4/5.
/// The coding rate is given in the raw 1..4 firmware domain on purpose, to keep
/// the sheet's normalisation on the tested path.
const ImageSendRadio _knownRadio = ImageSendRadio(
spreadingFactor: 10,
bandwidthHz: 250000,
rawCodingRate: 1,
);
/// Packets the sheet must report for the fake codec's ft32 mean payload:
/// the chunker's own data-chunk count plus the XOR parity packet, which is on by
/// default. Derived, because the chunk capacities have moved before.
final int _expectedPackets =
imageChunkCount(ImageCodecRateStats.standard.meanBytes) + 1;
/// Nothing known yet — the state right after connecting, before SELF_INFO.
const ImageSendRadio _unknownRadio = ImageSendRadio();
Future<ImageSendPreviewResult?> _openSheet(
WidgetTester tester, {
required ImageSendRadio radio,
ImageSendCodec codec = const FakeImageSendCodec(latency: Duration.zero),
}) async {
ImageSendPreviewResult? result;
var closed = false;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () async {
result = await showImageSendPreviewSheet(
context: context,
imageBytes: _onePixelPng,
originalFileBytes: 2 * 1024 * 1024,
codec: codec,
radio: radio,
);
closed = true;
},
child: const Text('open'),
),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(closed, isFalse, reason: 'sheet should still be open');
return result;
}
/// Every string rendered by the sheet, so assertions can look for a value
/// without knowing which widget carries it.
List<String> _texts(WidgetTester tester) => tester
.widgetList<Text>(find.byType(Text))
.map((t) => t.data)
.whereType<String>()
.toList();
void main() {
testWidgets('renders with a fake codec and shows the image preview',
(tester) async {
await _openSheet(tester, radio: _knownRadio);
expect(find.byType(ImageSendPreviewSheet), findsOneWidget);
expect(find.byType(Image), findsOneWidget);
// The send action is enabled once the encode has settled.
final send = tester.widget<FilledButton>(find.byType(FilledButton));
expect(send.onPressed, isNotNull);
});
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,
// plus the XOR parity packet, which is on by default.
final expected = estimateSendFromRadioParams(
payloadBytes: ImageCodecRateStats.standard.meanBytes,
spreadingFactor: 10,
bandwidthHz: 250000,
codingRate: 1,
);
expect(expected.chunkCount, inInclusiveRange(2, 3));
expect(_texts(tester), contains('${expected.chunkCount}'));
});
testWidgets('shows a concrete airtime when the radio settings are known',
(tester) async {
await _openSheet(tester, radio: _knownRadio);
final texts = _texts(tester);
expect(
texts,
isNot(contains('')),
reason: 'a connected radio must not render the unknown placeholder',
);
// The headline time figure is formatted as "<n> s" by the sheet.
expect(
texts.any((t) => RegExp(r'^\d+(\.\d)? s$').hasMatch(t)),
isTrue,
reason: 'expected a seconds figure among: $texts',
);
});
testWidgets('headline time is the paced wall clock, not raw airtime',
(tester) async {
await _openSheet(tester, radio: _knownRadio);
final expected = estimateSendFromRadioParams(
payloadBytes: ImageCodecRateStats.standard.meanBytes,
spreadingFactor: 10,
bandwidthHz: 250000,
codingRate: 1,
);
// Two packets, so pacing must have widened the figure.
expect(
expected.pacedWallClock!.inMicroseconds,
greaterThan(expected.totalAirtime!.inMicroseconds),
);
String seconds(Duration d) {
final total = d.inMilliseconds / 1000.0;
return total < 10 ? total.toStringAsFixed(1) : total.round().toString();
}
final texts = _texts(tester);
expect(texts, contains('${seconds(expected.pacedWallClock!)} s'));
expect(texts, isNot(contains('${seconds(expected.totalAirtime!)} s')));
});
testWidgets('renders "unknown" airtime when the radio params are absent',
(tester) async {
await _openSheet(tester, radio: _unknownRadio);
final texts = _texts(tester);
// The em dash placeholder, never a fabricated duration.
expect(texts, contains(''));
expect(
texts.any((t) => RegExp(r'^\d+(\.\d)? s$').hasMatch(t)),
isFalse,
reason: 'no duration may be invented; got: $texts',
);
// The packet count is still meaningful and must survive.
expect(texts, contains('$_expectedPackets'));
});
testWidgets('offers no quality selector', (tester) async {
await _openSheet(tester, radio: _knownRadio);
final texts = _texts(tester);
final l10n = await AppLocalizations.delegate.load(const Locale('en'));
expect(texts, isNot(contains(l10n.imageSend_quality)));
expect(texts, isNot(contains(l10n.imageSend_qualityStandard)));
expect(texts, isNot(contains(l10n.imageSend_qualityHigh)));
expect(find.byIcon(Icons.radio_button_checked), findsNothing);
expect(find.byIcon(Icons.radio_button_unchecked), findsNothing);
});
testWidgets('returns null when the user cancels', (tester) async {
ImageSendPreviewResult? result;
var closed = false;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () async {
result = await showImageSendPreviewSheet(
context: context,
imageBytes: _onePixelPng,
originalFileBytes: 1024,
codec: const FakeImageSendCodec(latency: Duration.zero),
radio: _knownRadio,
);
closed = true;
},
child: const Text('open'),
),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
await tester.tap(find.byType(OutlinedButton));
await tester.pumpAndSettle();
expect(closed, isTrue);
expect(result, isNull);
expect(find.byType(ImageSendPreviewSheet), findsNothing);
});
testWidgets('confirming returns the payload, packet count and both times',
(tester) async {
ImageSendPreviewResult? result;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () async {
result = await showImageSendPreviewSheet(
context: context,
imageBytes: _onePixelPng,
originalFileBytes: 1024,
codec: const FakeImageSendCodec(latency: Duration.zero),
radio: _knownRadio,
);
},
child: const Text('open'),
),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
await tester.tap(find.byType(FilledButton));
await tester.pumpAndSettle();
expect(result, isNotNull);
expect(result!.payload.length, ImageCodecRateStats.standard.meanBytes);
// ft32 is the only rate point the sheet can produce.
expect(result!.rate, kImageSendRatePoint);
expect(result!.includeParity, isTrue);
expect(result!.packetCount, _expectedPackets);
expect(result!.airtime, isNotNull);
expect(
result!.wallClock!.inMicroseconds,
greaterThan(result!.airtime!.inMicroseconds),
);
});
testWidgets('a codec that is still downloading cannot be sent from',
(tester) async {
await _openSheet(
tester,
radio: _knownRadio,
codec: const FakeImageSendCodec(
availability: ImageCodecAvailability.downloading,
latency: Duration.zero,
),
);
final l10n = await AppLocalizations.delegate.load(const Locale('en'));
expect(_texts(tester), contains(l10n.imageSend_codecDownloading));
final send = tester.widget<FilledButton>(find.byType(FilledButton));
expect(send.onPressed, isNull);
});
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 '
'decoding a bitstream also needs the entropy-side graph and the rANS '
'coder, which are not included yet.';
await _openSheet(
tester,
radio: _knownRadio,
codec: const FakeImageSendCodec(
availability: ImageCodecAvailability.unavailable,
unavailableReason: reason,
latency: Duration.zero,
),
);
final l10n = await AppLocalizations.delegate.load(const Locale('en'));
final texts = _texts(tester);
expect(texts, contains(reason));
expect(
texts,
isNot(contains(l10n.imageSend_codecUnavailable)),
reason: 'the concrete reason must REPLACE the generic sentence',
);
final send = tester.widget<FilledButton>(find.byType(FilledButton));
expect(send.onPressed, isNull);
});
testWidgets('an unavailable codec that gives no reason falls back to the '
'generic string', (tester) async {
await _openSheet(
tester,
radio: _knownRadio,
codec: const FakeImageSendCodec(
availability: ImageCodecAvailability.unavailable,
latency: Duration.zero,
),
);
final l10n = await AppLocalizations.delegate.load(const Locale('en'));
expect(_texts(tester), contains(l10n.imageSend_codecUnavailable));
});
}
@@ -0,0 +1,497 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
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';
import 'package:meshcore_open/services/received_image_store.dart';
import 'package:meshcore_open/widgets/received_image_message.dart';
const String _png1x1 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==';
/// A decoder seam whose availability the test controls.
///
/// [decodeBitstream] never returns a picture: every tap-routing test only cares
/// about which of the two branches the bubble took, and a fake that produced
/// pixels would drag PNG decoding into an assertion about routing.
class _FakeDecoder implements ReceivedImageDecoder {
@override
final ImageCodecAvailability availability;
@override
bool isBusy = false;
int decodeCalls = 0;
int cancelCalls = 0;
_FakeDecoder(this.availability);
@override
Future<ImageCodecResult?> decodeBitstream({
required Uint8List bitstream,
required AeicRatePoint ratePoint,
required int resolution,
}) async {
decodeCalls++;
return null;
}
@override
void cancelCodecJob() => cancelCalls++;
}
/// Builds a store holding one entry that is `reassembled + needsManualDecode`,
/// i.e. the "bitstream complete, waiting for the user" placeholder state.
Future<ReceivedImageStore> _awaitingStore({
ReceivedImageDecoder? decoder,
int bytes = 156,
int packets = 2,
}) async {
final blobs = InMemoryReceivedImageBlobStore();
await blobs.writeBitstream('await1', Uint8List(bytes));
await blobs.writeSidecar(
'await1',
jsonEncode({
'streamId': 'await1',
'senderPrefix': 0x1a2b,
'imgId': 7,
'channelIndex': 0,
'firstSeenMs': DateTime.now().millisecondsSinceEpoch,
'state': 'reassembled',
'receivedChunks': packets,
'totalChunks': packets,
'needsManualDecode': true,
'bitstreamStored': true,
'bitstreamByteCount': bytes,
}),
);
final store = ReceivedImageStore(blobs: blobs, decoder: decoder);
await store.load();
return store;
}
Future<void> _pumpBubble(
WidgetTester tester,
ReceivedImageStore store, {
String streamId = 'await1',
VoidCallback? onOpenCodecSettings,
}) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ReceivedImageMessage(
streamId: streamId,
isOutgoing: false,
fallbackTextColor: Colors.black,
store: store,
onOpenCodecSettings: onOpenCodecSettings,
),
),
),
);
await tester.pump();
}
void main() {
testWidgets('decoded incoming bubble carries the R6 badge and caption',
(tester) async {
final blobs = InMemoryReceivedImageBlobStore();
final store = ReceivedImageStore(blobs: blobs);
final png = base64Decode(_png1x1);
final entry = await store.registerOutgoing(
channelIndex: 0,
senderPrefix: 1,
imgId: 1,
previewPng: Uint8List.fromList(png),
rate: AeicRatePoint.ft32,
chunkCount: 1,
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ReceivedImageMessage(
streamId: entry.streamId,
isOutgoing: true,
fallbackTextColor: Colors.black,
store: store,
),
),
),
);
await tester.pump();
// Outgoing: real crop, so NO label.
expect(find.text('AI-reconstructed'), findsNothing);
// Now an incoming decoded entry.
final blobs2 = InMemoryReceivedImageBlobStore();
await blobs2.writeSidecar(
'incoming',
jsonEncode({
'streamId': 'incoming',
'senderPrefix': 2,
'imgId': 2,
'channelIndex': 0,
'firstSeenMs': DateTime.now().millisecondsSinceEpoch,
'state': 'decoded',
'receivedChunks': 1,
'totalChunks': 1,
}),
);
await blobs2.writePng('incoming', Uint8List.fromList(png));
final store2 = ReceivedImageStore(blobs: blobs2);
await store2.load();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ReceivedImageMessage(
streamId: 'incoming',
isOutgoing: false,
fallbackTextColor: Colors.black,
store: store2,
),
),
),
);
await tester.pump();
await tester.pump();
expect(find.text('AI-reconstructed'), findsOneWidget);
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);
final set = buildImageChunks(
payload: Uint8List(kImageChunkFirstCapacity + 1),
metadata: const ImageStreamMetadata(rate: ImageCodecRatePoint.standard),
senderPrefix: 0x1a2b,
imgId: 5,
);
final entry = await store.handleOutcome(
reassembler.addChunk(set.blobs[0], channelIndex: 0),
channelIndex: 0,
);
expect(entry!.state, ReceivedImageState.receiving);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ReceivedImageMessage(
streamId: entry.streamId,
isOutgoing: false,
fallbackTextColor: Colors.black,
store: store,
),
),
),
);
expect(find.text('1 of 2 packets'), findsOneWidget);
expect(find.text('AI-reconstructed'), findsNothing);
});
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);
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);
});
testWidgets('awaiting card honours injected strings', (tester) async {
final store = await _awaitingStore(bytes: 209, packets: 3);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ReceivedImageMessage(
streamId: 'await1',
isOutgoing: false,
fallbackTextColor: Colors.black,
store: store,
strings: ReceivedImageStrings(
incoming: (r, t) => 'in $r/$t',
queued: 'queued',
tapToDecode: 'tap decode',
awaiting: (b, p) => 'LOC $b B in $p pkts',
tapToProcess: 'LOC process',
decoding: 'decoding',
incomplete: (r, t) => 'incomplete $r/$t',
corrupt: 'corrupt',
decoderMissing: 'missing',
evicted: 'evicted',
retry: 'retry',
decodeAgain: 'again',
openSettings: 'settings',
),
),
),
),
);
await tester.pump();
expect(find.text('LOC 209 B in 3 pkts'), findsOneWidget);
expect(find.text('LOC process'), findsOneWidget);
});
testWidgets('tap with a ready codec requests a decode and does NOT open '
'settings', (tester) async {
final decoder = _FakeDecoder(ImageCodecAvailability.ready);
final store = await _awaitingStore(decoder: decoder);
var settingsOpened = 0;
await _pumpBubble(
tester,
store,
onOpenCodecSettings: () => settingsOpened++,
);
await tester.tap(find.text('Tap to process'));
await tester.pump();
await tester.pump();
expect(settingsOpened, 0);
expect(decoder.decodeCalls, 1);
});
testWidgets('tap with no model installed opens the image-messages setting '
'and never asks the store to decode', (tester) async {
final decoder = _FakeDecoder(ImageCodecAvailability.disabled);
final store = await _awaitingStore(decoder: decoder);
var settingsOpened = 0;
await _pumpBubble(
tester,
store,
onOpenCodecSettings: () => settingsOpened++,
);
await tester.tap(find.text('Tap to process'));
await tester.pump();
await tester.pump();
expect(settingsOpened, 1);
expect(decoder.decodeCalls, 0);
// The entry is untouched: it stays tappable rather than falling into
// `decoderUnavailable` behind the user's back.
expect(store.entryFor('await1')!.state, ReceivedImageState.reassembled);
});
testWidgets('tap with no codec and no settings route still asks the store, '
'so the card is never inert', (tester) async {
final store = await _awaitingStore(decoder: null);
await _pumpBubble(tester, store);
await tester.tap(find.text('Tap to process'));
await tester.pump();
await tester.pump();
// No decoder at all -> the store parks it as decoderUnavailable, which is
// the state that renders the "Set up" body.
expect(
store.entryFor('await1')!.state,
ReceivedImageState.decoderUnavailable,
);
});
testWidgets('a queued (auto-decode) card is still tappable', (tester) async {
final blobs = InMemoryReceivedImageBlobStore();
await blobs.writeBitstream('queued1', Uint8List(120));
await blobs.writeSidecar(
'queued1',
jsonEncode({
'streamId': 'queued1',
'senderPrefix': 3,
'imgId': 3,
'channelIndex': 0,
'firstSeenMs': DateTime.now().millisecondsSinceEpoch,
'state': 'reassembled',
'receivedChunks': 1,
'totalChunks': 1,
'needsManualDecode': false,
'bitstreamStored': true,
'bitstreamByteCount': 120,
}),
);
final decoder = _FakeDecoder(ImageCodecAvailability.ready);
final store = ReceivedImageStore(blobs: blobs, decoder: decoder);
await store.load();
await _pumpBubble(tester, store, streamId: 'queued1');
expect(find.text('Waiting to decode'), findsOneWidget);
// load() does not enqueue, so without a tap target this card would sit on
// "Waiting to decode" forever.
await tester.tap(find.text('Waiting to decode'));
await tester.pump();
await tester.pump();
expect(decoder.decodeCalls, 1);
});
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',
'decoderUnavailable': 'Image received — image decoding is off',
'evicted': 'Image no longer stored',
'decoding': 'Reconstructing… about 1 s',
};
for (final entry in cases.entries) {
// A distinct id per case on purpose: the ListView-recycling guard in
// _ReceivedImageMessageState only re-resolves the store when the
// streamId changes, so reusing one id would keep showing the first case.
final id = 'st_${entry.key}';
final blobs = InMemoryReceivedImageBlobStore();
await blobs.writeSidecar(
id,
jsonEncode({
'streamId': id,
'senderPrefix': 4,
'imgId': 4,
'channelIndex': 0,
'firstSeenMs': DateTime.now().millisecondsSinceEpoch,
'state': entry.key,
'receivedChunks': 1,
'totalChunks': 3,
}),
);
final store = ReceivedImageStore(blobs: blobs, decoder: null);
await store.load();
await _pumpBubble(tester, store, streamId: id);
// `decoding` is repaired to failedIncomplete on load (no bitstream on
// disk), which is exactly the label a user must see after a crash.
final expected = entry.key == 'decoding'
? 'Image incomplete — 1 of 3 packets arrived'
: entry.value;
expect(
find.text(expected),
findsOneWidget,
reason: 'state ${entry.key} rendered nothing legible',
);
}
});
testWidgets('a decoded incoming image always shows the label, whatever '
'strings the caller injects', (tester) async {
final blobs = InMemoryReceivedImageBlobStore();
final png = Uint8List.fromList(base64Decode(_png1x1));
await blobs.writePng('dec', png);
await blobs.writeSidecar(
'dec',
jsonEncode({
'streamId': 'dec',
'senderPrefix': 9,
'imgId': 9,
'channelIndex': 0,
'firstSeenMs': DateTime.now().millisecondsSinceEpoch,
'state': 'decoded',
'receivedChunks': 1,
'totalChunks': 1,
'pngStored': true,
'pngByteCount': 68,
}),
);
final store = ReceivedImageStore(blobs: blobs, decoder: null);
await store.load();
await _pumpBubble(tester, store, streamId: 'dec');
await tester.pump();
expect(find.text('AI-reconstructed'), findsOneWidget);
expect(find.textContaining('Reconstructed by an AI model'), findsOneWidget);
expect(find.textContaining('Fine detail is generated'), findsOneWidget);
});
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.
for (final bytes in <int>[110, 209]) {
final id = 'sz$bytes';
final blobs = InMemoryReceivedImageBlobStore();
await blobs.writePng(id, Uint8List.fromList(base64Decode(_png1x1)));
// A real bitstream on disk: load() derives the size by stat rather than
// trusting the sidecar, which is why this must actually exist.
await blobs.writeBitstream(id, Uint8List(bytes));
await blobs.writeSidecar(
id,
jsonEncode({
'streamId': id,
'senderPrefix': 9,
'imgId': 9,
'channelIndex': 0,
'firstSeenMs': DateTime.now().millisecondsSinceEpoch,
'state': 'decoded',
'receivedChunks': 1,
'totalChunks': 1,
'pngStored': true,
'pngByteCount': 68,
'bitstreamStored': true,
'bitstreamByteCount': bytes,
}),
);
final store = ReceivedImageStore(blobs: blobs, decoder: null);
await store.load();
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');
}
});
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.
final blobs = InMemoryReceivedImageBlobStore();
await blobs.writePng('clip', Uint8List.fromList(base64Decode(_png1x1)));
await blobs.writeBitstream('clip', Uint8List(209));
await blobs.writeSidecar(
'clip',
jsonEncode({
'streamId': 'clip',
'senderPrefix': 9,
'imgId': 9,
'channelIndex': 0,
'firstSeenMs': DateTime.now().millisecondsSinceEpoch,
'state': 'decoded',
'receivedChunks': 1,
'totalChunks': 1,
'pngStored': true,
'pngByteCount': 68,
'bitstreamStored': true,
'bitstreamByteCount': 209,
}),
);
final store = ReceivedImageStore(blobs: blobs, decoder: null);
await store.load();
await _pumpBubble(tester, store, streamId: 'clip');
await tester.pump();
final caption = tester.widget<Text>(
find.textContaining('Reconstructed by an AI model'),
);
expect(caption.maxLines, isNull, reason: 'the sentence must wrap in full');
expect(caption.overflow, isNot(TextOverflow.ellipsis));
// And the tail of the sentence is actually present.
expect(find.textContaining('not transmitted'), findsOneWidget);
});
}