mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-08-11 18:26:27 +10:00
498 lines
16 KiB
Dart
498 lines
16 KiB
Dart
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);
|
|
});
|
|
}
|