mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
## Summary Transparent PNG and GIF avatars render on a black background in mobile avatars and iOS notification avatars. Both load the relay's small thumbnail, which was always a JPEG with no alpha channel. [block/buzz#9003](https://github.com/block/buzz/pull/9003) makes relays write a `{sha}.thumb.webp` thumbnail that keeps transparency, alongside the `{sha}.thumb.jpg` that every relay writes. This PR loads the WebP first. - `MediaImage` with `preferThumbnail` tries `.thumb.webp`, then `.thumb.jpg`, then the original. Only a 404 moves on to the next source; any other failure still shows the error widget. Missing thumbnails are remembered as before, so later builds skip straight to the source that exists. - `BuzzNotificationAvatarLoader` follows the same order for notification avatars. Relays without 9003, and images uploaded before it, keep rendering from the JPEG as they do today. ### Screenshots A transparent PNG avatar rendered by `MediaImage` on the same relay, before and after (widget harness on an iOS simulator). | Before | After | | --- | --- | |  |  | ### Related issue AIDA-2308 (internal). The desktop counterpart is [block/buzz-app#2477](https://github.com/block/buzz-app/pull/2477). ### Testing - `missing thumbnails fall back to the JPEG, then the original` (`avatar_image_test.dart`): when the relay 404s every thumbnail, the avatar requests the WebP, then the JPEG, then the original. When the WebP fails with a 500, the avatar shows its fallback and requests neither the JPEG nor the original. - The relay thumbnail journey tests run against a relay that 404s `.thumb.webp`, so they cover the older-relay path end to end. - `BuzzNotificationAvatarLoaderTests` checks the WebP-then-JPEG thumbnail order for relay media, and that non-relay media gets no thumbnails. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
555 lines
19 KiB
Dart
555 lines
19 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:buzz/shared/relay/media_auth.dart';
|
|
import 'package:buzz/shared/relay/media_image.dart';
|
|
import 'package:buzz/shared/widgets/avatar_shape.dart';
|
|
import 'package:buzz/shared/widgets/avatar_image.dart';
|
|
import 'package:buzz/shared/emoji/native_emoji_glyph.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_svg/flutter_svg.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:hooks_riverpod/misc.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart' as http_testing;
|
|
|
|
// Minimal valid 1x1 transparent PNG.
|
|
final _pngBytes = base64Decode(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAA'
|
|
'AAYAAjCB0C8AAAAASUVORK5CYII=',
|
|
);
|
|
|
|
void main() {
|
|
const svg =
|
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">'
|
|
'<text x="16" y="24" text-anchor="middle">🦝</text></svg>';
|
|
|
|
Widget subject(
|
|
String? imageUrl, {
|
|
Color? backgroundColor,
|
|
bool isAgent = false,
|
|
}) => ProviderScope(
|
|
child: MaterialApp(
|
|
home: AvatarImage(
|
|
imageUrl: imageUrl,
|
|
radius: 16,
|
|
backgroundColor: backgroundColor,
|
|
fallback: const Text('R'),
|
|
isAgent: isAgent,
|
|
),
|
|
),
|
|
);
|
|
|
|
testWidgets('only animated agent artwork shrinks inside its unchanged frame', (
|
|
tester,
|
|
) async {
|
|
const poster = 'https://relay.example/poster.png';
|
|
final animated =
|
|
'$poster#buzz-anim=${Uri.encodeComponent('https://relay.example/animation.png')}';
|
|
const preset =
|
|
'https://dwwgwmfqqjotj.cloudfront.net/avatars/20260925T154625166Z/poster/pollies/pollies-10.png';
|
|
for (final url in [poster, animated, preset]) {
|
|
await tester.pumpWidget(subject(url, isAgent: true));
|
|
final clip = find.byType(ClipPath);
|
|
final scale = find.descendant(of: clip, matching: find.byType(Transform));
|
|
if (url != poster) {
|
|
expect(scale, findsOneWidget);
|
|
expect(tester.widget<Transform>(scale).transform.entry(0, 0), 0.85);
|
|
final content = find.byType(AvatarImageContent);
|
|
expect(tester.getSize(content), tester.getSize(clip));
|
|
} else {
|
|
expect(scale, findsNothing);
|
|
}
|
|
}
|
|
});
|
|
|
|
testWidgets('small relay avatars load the relay thumbnail', (tester) async {
|
|
const relay = 'https://relay.example';
|
|
final hash = 'ab' * 32;
|
|
final original = '$relay/media/$hash.png';
|
|
final requested = <String>[];
|
|
final client = http_testing.MockClient((request) async {
|
|
requested.add(request.url.toString());
|
|
return http.Response('', 404);
|
|
});
|
|
tester.view.devicePixelRatio = 3;
|
|
addTearDown(tester.view.resetDevicePixelRatio);
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
mediaGetAuthServiceProvider.overrideWithValue(
|
|
MediaGetAuthService(baseUrl: relay, nsec: null),
|
|
),
|
|
mediaHttpClientProvider.overrideWithValue(client),
|
|
],
|
|
child: MaterialApp(
|
|
home: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// 40pt = 120px, within the thumbnail.
|
|
AvatarImage(
|
|
imageUrl: original,
|
|
radius: 20,
|
|
fallback: const Text('R'),
|
|
),
|
|
// 120pt = 360px, larger than the thumbnail.
|
|
AvatarImage(
|
|
imageUrl: original,
|
|
radius: 60,
|
|
fallback: const Text('R'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final shown = tester.widgetList<Image>(find.byType(Image)).map((image) {
|
|
final provider = (image.image as ResizeImage).imageProvider;
|
|
return (provider as MediaImageProvider).url;
|
|
});
|
|
expect(shown, ['$relay/media/$hash.thumb.webp', original]);
|
|
});
|
|
|
|
testWidgets('missing thumbnails fall back to the JPEG, then the original', (
|
|
tester,
|
|
) async {
|
|
const relay = 'https://relay.example';
|
|
final missing = '$relay/media/${'cd' * 32}.jpg';
|
|
final broken = '$relay/media/${'ef' * 32}.jpg';
|
|
final requested = <String>[];
|
|
final client = http_testing.MockClient((request) async {
|
|
final url = request.url.toString();
|
|
requested.add(url);
|
|
if (url.startsWith(broken.replaceFirst('.jpg', ''))) {
|
|
return http.Response('', 500);
|
|
}
|
|
return http.Response('', 404);
|
|
});
|
|
tester.view.devicePixelRatio = 3;
|
|
addTearDown(tester.view.resetDevicePixelRatio);
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
mediaGetAuthServiceProvider.overrideWithValue(
|
|
MediaGetAuthService(baseUrl: relay, nsec: null),
|
|
),
|
|
mediaHttpClientProvider.overrideWithValue(client),
|
|
],
|
|
child: MaterialApp(
|
|
home: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
for (final url in [missing, broken])
|
|
AvatarImage(
|
|
imageUrl: url,
|
|
radius: 20,
|
|
fallback: Text(url == missing ? 'M' : 'B'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
for (var i = 0; i < 4; i++) {
|
|
await tester.runAsync(
|
|
() => Future<void>.delayed(const Duration(milliseconds: 20)),
|
|
);
|
|
await tester.pump();
|
|
}
|
|
|
|
String thumb(String url, String ext) =>
|
|
url.replaceFirst('.jpg', '.thumb.$ext');
|
|
// A 404 tries the next source; any other failure shows the fallback.
|
|
expect(
|
|
requested,
|
|
containsAll([
|
|
thumb(missing, 'webp'),
|
|
thumb(missing, 'jpg'),
|
|
missing,
|
|
thumb(broken, 'webp'),
|
|
]),
|
|
);
|
|
expect(requested, isNot(contains(thumb(broken, 'jpg'))));
|
|
expect(requested, isNot(contains(broken)));
|
|
expect(find.text('B'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('missing thumbnails stay bounded and out of the cooldown', (
|
|
tester,
|
|
) async {
|
|
const relay = 'https://relay.example';
|
|
MediaImageProvider.debugResetCooldowns();
|
|
MediaImage.debugResetMissingThumbnails();
|
|
addTearDown(MediaImageProvider.debugResetCooldowns);
|
|
addTearDown(MediaImage.debugResetMissingThumbnails);
|
|
final client = http_testing.MockClient(
|
|
(request) async => http.Response('', 404),
|
|
);
|
|
tester.view.devicePixelRatio = 3;
|
|
addTearDown(tester.view.resetDevicePixelRatio);
|
|
String avatar(int i) =>
|
|
'$relay/media/${i.toRadixString(16).padLeft(64, '0')}.png';
|
|
for (var batch = 0; batch < 6; batch++) {
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
mediaGetAuthServiceProvider.overrideWithValue(
|
|
MediaGetAuthService(baseUrl: relay, nsec: null),
|
|
),
|
|
mediaHttpClientProvider.overrideWithValue(client),
|
|
],
|
|
child: MaterialApp(
|
|
home: Wrap(
|
|
children: [
|
|
for (var i = batch * 50; i < (batch + 1) * 50; i++)
|
|
AvatarImage(
|
|
imageUrl: avatar(i),
|
|
radius: 20,
|
|
fallback: const Text('?'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
for (var i = 0; i < 4; i++) {
|
|
await tester.runAsync(
|
|
() => Future<void>.delayed(const Duration(milliseconds: 20)),
|
|
);
|
|
await tester.pump();
|
|
}
|
|
}
|
|
|
|
expect(MediaImage.debugMissingThumbnailCount, 256);
|
|
expect(
|
|
MediaImageProvider.debugCooldownUrls.where(
|
|
(url) => url.contains('.thumb.'),
|
|
),
|
|
isEmpty,
|
|
);
|
|
});
|
|
|
|
group('relay thumbnail journeys', () {
|
|
const relay = 'https://relay.example';
|
|
late List<String> requested;
|
|
late Future<http.Response> Function(String url) respond;
|
|
final valueOf = ValueNotifier<Widget>(const SizedBox());
|
|
|
|
setUp(() {
|
|
MediaImageProvider.debugResetCooldowns();
|
|
MediaImage.debugResetMissingThumbnails();
|
|
requested = [];
|
|
respond = (_) async => http.Response.bytes(_pngBytes, 200);
|
|
valueOf.value = const SizedBox();
|
|
});
|
|
tearDown(() {
|
|
MediaImageProvider.debugResetCooldowns();
|
|
MediaImage.debugResetMissingThumbnails();
|
|
});
|
|
|
|
Future<void> mount(
|
|
WidgetTester tester, {
|
|
List<Override> overrides = const [],
|
|
}) async {
|
|
tester.view.devicePixelRatio = 3;
|
|
addTearDown(tester.view.resetDevicePixelRatio);
|
|
final client = http_testing.MockClient((request) async {
|
|
final url = request.url.toString();
|
|
requested.add(url);
|
|
// These journeys run against a relay from before WebP thumbnails.
|
|
if (url.endsWith('.thumb.webp')) return http.Response('', 404);
|
|
return respond(url);
|
|
});
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
mediaGetAuthServiceProvider.overrideWithValue(
|
|
MediaGetAuthService(baseUrl: relay, nsec: null),
|
|
),
|
|
mediaHttpClientProvider.overrideWithValue(client),
|
|
...overrides,
|
|
],
|
|
child: MaterialApp(
|
|
home: Center(
|
|
child: ValueListenableBuilder<Widget>(
|
|
valueListenable: valueOf,
|
|
builder: (_, child, _) => child,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> show(WidgetTester tester, Widget child) async {
|
|
valueOf.value = child;
|
|
for (var i = 0; i < 4; i++) {
|
|
await tester.runAsync(
|
|
() => Future<void>.delayed(const Duration(milliseconds: 20)),
|
|
);
|
|
await tester.pump();
|
|
}
|
|
}
|
|
|
|
String shownUrl(WidgetTester tester) {
|
|
final image = tester.widget<Image>(find.byType(Image));
|
|
final provider = (image.image as ResizeImage).imageProvider;
|
|
return (provider as MediaImageProvider).url;
|
|
}
|
|
|
|
testWidgets(
|
|
'an avatar that left before its thumbnail 404 later shows the original',
|
|
(tester) async {
|
|
final original = '$relay/media/${'12' * 32}.png';
|
|
final thumbnail = '$relay/media/${'12' * 32}.thumb.jpg';
|
|
final slow404 = Completer<http.Response>();
|
|
respond = (url) async => url == thumbnail
|
|
? slow404.future
|
|
: http.Response.bytes(_pngBytes, 200);
|
|
await mount(tester);
|
|
|
|
// A 40pt avatar asks for the thumbnail on a slow link, then
|
|
// scrolls away before the relay's 404 arrives.
|
|
await show(
|
|
tester,
|
|
AvatarImage(
|
|
imageUrl: original,
|
|
radius: 20,
|
|
fallback: const Text('M'),
|
|
),
|
|
);
|
|
expect(requested, [thumbnail.replaceFirst('.jpg', '.webp'), thumbnail]);
|
|
await show(tester, const SizedBox());
|
|
slow404.complete(http.Response('', 404));
|
|
await show(tester, const SizedBox());
|
|
|
|
// Back at another size, it loads and renders the original rather
|
|
// than the initials.
|
|
await show(
|
|
tester,
|
|
AvatarImage(
|
|
imageUrl: original,
|
|
radius: 16,
|
|
fallback: const Text('M'),
|
|
),
|
|
);
|
|
expect(requested.where((url) => url == original), hasLength(1));
|
|
expect(shownUrl(tester), original);
|
|
expect(find.text('M'), findsNothing);
|
|
expect(MediaImageProvider.debugCooldownUrls, isEmpty);
|
|
|
|
// A profile URL that is itself thumbnail-shaped, on another host or
|
|
// this relay, is not a fallback MediaImage chose: its 404 keeps the
|
|
// cooldown, so showing it again does not request it again.
|
|
respond = (_) async => http.Response('', 404);
|
|
for (final (i, thumbShaped) in [
|
|
'https://elsewhere.example/media/${'56' * 32}.thumb.jpg',
|
|
'$relay/media/${'78' * 32}.thumb.jpg',
|
|
].indexed) {
|
|
MediaImageProvider.debugResetCooldowns();
|
|
for (final radius in [16.0, 17.0]) {
|
|
await show(
|
|
tester,
|
|
AvatarImage(
|
|
key: ValueKey((i, radius)),
|
|
imageUrl: thumbShaped,
|
|
radius: radius,
|
|
fallback: const Text('E'),
|
|
),
|
|
);
|
|
expect(find.text('E'), findsOneWidget);
|
|
}
|
|
expect(requested.where((url) => url == thumbShaped), hasLength(1));
|
|
expect(MediaImageProvider.debugCooldownUrls, [thumbShaped]);
|
|
}
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'a direct thumbnail URL and a fallback to it keep their own 404 handling',
|
|
(tester) async {
|
|
final original = '$relay/media/${'9a' * 32}.png';
|
|
final thumbnail = '$relay/media/${'9a' * 32}.thumb.jpg';
|
|
final slow404 = Completer<http.Response>();
|
|
respond = (url) async => url == thumbnail
|
|
? slow404.future
|
|
: http.Response.bytes(_pngBytes, 200);
|
|
await mount(tester);
|
|
|
|
// One profile stores the thumbnail URL itself; another stores the
|
|
// original, so its avatar substitutes the same thumbnail. Both are
|
|
// waiting on the relay when its 404 arrives.
|
|
await show(
|
|
tester,
|
|
Column(
|
|
children: [
|
|
AvatarImage(
|
|
imageUrl: thumbnail,
|
|
radius: 20,
|
|
fallback: const Text('D'),
|
|
),
|
|
AvatarImage(
|
|
imageUrl: original,
|
|
radius: 20,
|
|
fallback: const Text('F'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
expect(requested.where((url) => url == thumbnail), hasLength(2));
|
|
slow404.complete(http.Response('', 404));
|
|
await show(tester, const SizedBox());
|
|
|
|
// The direct URL is cooling down, and the fallback recorded the
|
|
// missing thumbnail, so the original avatar shown again at another
|
|
// size loads the original instead of showing initials.
|
|
expect(MediaImageProvider.debugCooldownUrls, [thumbnail]);
|
|
await show(
|
|
tester,
|
|
AvatarImage(
|
|
imageUrl: original,
|
|
radius: 16,
|
|
fallback: const Text('F'),
|
|
),
|
|
);
|
|
expect(shownUrl(tester), original);
|
|
expect(find.text('F'), findsNothing);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'a fallback after a direct thumbnail 404 still loads the original',
|
|
(tester) async {
|
|
final original = '$relay/media/${'9b' * 32}.png';
|
|
final thumbnail = '$relay/media/${'9b' * 32}.thumb.jpg';
|
|
respond = (url) async => url == thumbnail
|
|
? http.Response('', 404)
|
|
: http.Response.bytes(_pngBytes, 200);
|
|
await mount(tester);
|
|
|
|
// A profile that stores the thumbnail URL itself fails first and
|
|
// puts that URL on cooldown.
|
|
await show(
|
|
tester,
|
|
AvatarImage(
|
|
imageUrl: thumbnail,
|
|
radius: 20,
|
|
fallback: const Text('D'),
|
|
),
|
|
);
|
|
await show(tester, const SizedBox());
|
|
expect(MediaImageProvider.debugCooldownUrls, [thumbnail]);
|
|
|
|
// During that cooldown, an avatar for the original falls back to the
|
|
// same thumbnail. Its own request records the thumbnail as missing,
|
|
// so the avatar ends up on the original rather than initials.
|
|
await show(
|
|
tester,
|
|
AvatarImage(
|
|
imageUrl: original,
|
|
radius: 20,
|
|
fallback: const Text('F'),
|
|
),
|
|
);
|
|
expect(requested.where((url) => url == thumbnail), hasLength(2));
|
|
expect(requested, contains(original));
|
|
expect(find.text('F'), findsNothing);
|
|
},
|
|
);
|
|
});
|
|
|
|
testWidgets('clips agents to the desktop squircle', (tester) async {
|
|
await tester.pumpWidget(subject(null, isAgent: true));
|
|
|
|
expect(find.byType(CircleAvatar), findsNothing);
|
|
final clip = tester.widget<ClipPath>(find.byType(ClipPath));
|
|
expect(
|
|
(clip.clipper! as ShapeBorderClipper).shape,
|
|
avatarShape(isAgent: true),
|
|
);
|
|
});
|
|
|
|
testWidgets('renders raccoon percent-encoded SVG data avatar', (
|
|
tester,
|
|
) async {
|
|
const raccoonAvatar =
|
|
'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22512%22%20height%3D%22512%22%20viewBox%3D%220%200%20512%20512%22%3E%3Crect%20width%3D%22512%22%20height%3D%22512%22%20rx%3D%22256%22%20fill%3D%22%233399FF%22%2F%3E%3Ctext%20x%3D%2250%25%22%20y%3D%2256%25%22%20dominant-baseline%3D%22middle%22%20text-anchor%3D%22middle%22%20font-size%3D%22258%22%3E%F0%9F%A6%9D%3C%2Ftext%3E%3C%2Fsvg%3E';
|
|
await tester.pumpWidget(subject(raccoonAvatar));
|
|
await tester.pumpAndSettle();
|
|
|
|
final emoji = tester.widget<Text>(find.text('🦝'));
|
|
expect(emoji.style?.height, 1);
|
|
final glyph = tester.widget<NativeEmojiGlyph>(
|
|
find.byType(NativeEmojiGlyph),
|
|
);
|
|
expect(glyph.size, closeTo(32 * 258 / 512, 0.001));
|
|
expect(glyph.opticalBoxSize, glyph.size);
|
|
expect(find.byType(SvgPicture), findsNothing);
|
|
expect(find.text('R'), findsNothing);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('renders base64 SVG data avatar', (tester) async {
|
|
await tester.pumpWidget(
|
|
subject('data:image/svg+xml;base64,${base64Encode(utf8.encode(svg))}'),
|
|
);
|
|
|
|
expect(find.byType(SvgPicture), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('centers fallback when no avatar is configured', (tester) async {
|
|
await tester.pumpWidget(subject(null));
|
|
|
|
final avatarCenter = tester.getCenter(find.byType(CircleAvatar));
|
|
final fallbackCenter = tester.getCenter(find.text('R'));
|
|
expect(fallbackCenter, avatarCenter);
|
|
});
|
|
|
|
testWidgets('uses fallback for malformed image data', (tester) async {
|
|
await tester.pumpWidget(subject('data:image/svg+xml;base64,%%%'));
|
|
|
|
expect(find.text('R'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('renders animated-avatar posters without an opaque background', (
|
|
tester,
|
|
) async {
|
|
const posterUrl = 'https://relay.example/media/poster.png';
|
|
const animationUrl = 'https://relay.example/media/animation.png';
|
|
final animatedUrl =
|
|
'$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}';
|
|
|
|
await tester.pumpWidget(subject(animatedUrl, backgroundColor: Colors.red));
|
|
|
|
expect(
|
|
tester.widget<CircleAvatar>(find.byType(CircleAvatar)).backgroundColor,
|
|
Colors.transparent,
|
|
);
|
|
expect(
|
|
tester
|
|
.widget<AvatarImageContent>(find.byType(AvatarImageContent))
|
|
.imageUrl,
|
|
posterUrl,
|
|
);
|
|
});
|
|
|
|
testWidgets('reuses parsed raster bytes across parent rebuilds', (
|
|
tester,
|
|
) async {
|
|
const png =
|
|
'data:image/png;base64,'
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
|
await tester.pumpWidget(subject(png));
|
|
final firstBytes = tester.widget<Image>(find.byType(Image)).image;
|
|
|
|
await tester.pumpWidget(subject(png));
|
|
final rebuiltBytes = tester.widget<Image>(find.byType(Image)).image;
|
|
|
|
final firstMemory = firstBytes as MemoryImage;
|
|
final rebuiltMemory = rebuiltBytes as MemoryImage;
|
|
expect(rebuiltMemory.bytes, same(firstMemory.bytes));
|
|
});
|
|
}
|