Files
buzz/mobile/test/features/channels/message_content_stability_test.dart
klopez4212 42b395cdef Polish mobile bubbles, mentions, galleries, and huddle cards (#8359)
Builds on the merged themes work in #8358 and inherits its appearance
and chat-color settings.

Polishes mobile conversations with uniform rounded bubbles,
desktop-style text-only mentions and agent icon, aligned gallery counts,
fading gallery avatars, and standard iOS scrolling. Huddles use rounded
cards with attached reaction trays, without duplicate start/end notices;
other activity rows stay centered with smaller text.

Validation: mobile checks, the full mobile test suite, and the gallery
regression test passed. Release build installed on iPhone. Full `just
ci` could not finish: desktop compilation exhausted local disk space.

### Snapshots
Flutter-rendered light and dark previews using seeded sample messages.

| Light | Dark |
| --- | --- |
|
![light](https://raw.githubusercontent.com/block/buzz/62346699199dd707579f46d21608c2a49a8095ef/pr-8359--light.png)
|
![dark](https://raw.githubusercontent.com/block/buzz/62346699199dd707579f46d21608c2a49a8095ef/pr-8359--dark.png)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-15 12:39:21 -04:00

292 lines
10 KiB
Dart

import 'package:buzz/shared/widgets/buzz_agent_icon.dart';
import 'package:buzz/features/channels/message_content.dart';
import 'package:buzz/features/channels/media_viewer_page.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_render.dart';
import 'package:buzz/shared/emoji/emoji_only.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class _Fixture {
final revision = ValueNotifier(0);
String content = 'Hello @Alice, visit #sample.';
Map<String, String> mentions = {'alice-key': 'Alice'};
final channels = {'sample': 'channel-one'};
Set<String> agents = {};
List<List<String>> tags = [];
List<CustomEmoji> palette = [];
bool scaleEmoji = false;
bool mentionHandler = true;
bool mediaHandlers = false;
int? tappedRevision;
String? tappedId;
Widget build() => ProviderScope(
overrides: [customEmojiListProvider.overrideWith((ref) => palette)],
child: MaterialApp(
theme: AppTheme.light(),
home: AppMarkdownTheme(
child: Scaffold(
body: ValueListenableBuilder<int>(
valueListenable: revision,
builder: (_, generation, _) => MessageContent(
content: content,
mentionNames: Map.of(mentions),
channelNames: Map.of(channels),
agentMentionPubkeys: Set.of(agents),
tags: tags,
scaleEmojiOnly: scaleEmoji,
onMediaReply: mediaHandlers
? () {
tappedRevision = generation;
tappedId = 'reply';
}
: null,
onMediaMore: mediaHandlers
? (_, url) {
tappedRevision = generation;
tappedId = url;
}
: null,
onMentionTap: mentionHandler
? (id) {
tappedRevision = generation;
tappedId = id;
}
: null,
onChannelTap: (id) {
tappedRevision = generation;
tappedId = id;
},
),
),
),
),
),
);
Future<void> refresh(WidgetTester tester) async {
revision.value++;
await tester.pump();
}
}
List<MarkdownComponent> _components(WidgetTester tester) => tester
.widget<GptMarkdown>(find.byType(GptMarkdown).first)
.inlineComponents!;
void main() {
testWidgets('explicit channel links retain the current callback', (
tester,
) async {
const id = '580ca78b-9dae-46f3-8854-bd671853ba32';
final fixture = _Fixture()..content = '[Open channel](buzz://channel/$id)';
addTearDown(fixture.revision.dispose);
await tester.pumpWidget(fixture.build());
final before = _components(tester);
await fixture.refresh(tester);
expect(_components(tester), same(before));
await tester.tap(find.text('Open channel', findRichText: true));
expect(fixture.tappedId, id);
expect(fixture.tappedRevision, fixture.revision.value);
});
testWidgets(
'inline media retains current actions and invalidates metadata and action presence',
(tester) async {
const url = 'https://relay.example/image.png';
final fixture = _Fixture()
..content = '![image]($url)\n\nAfter image'
..mediaHandlers = true
..tags = [
['imeta', 'url $url', 'm image/png', 'alt First label'],
];
addTearDown(fixture.revision.dispose);
await tester.pumpWidget(fixture.build());
final before = _components(tester);
await fixture.refresh(tester);
expect(_components(tester), same(before));
await tester.tap(
find.byKey(const ValueKey('message-media-image-preview:$url')),
);
await tester.pumpAndSettle();
var viewer = tester.widget<MediaImageViewerPage>(
find.byType(MediaImageViewerPage),
);
viewer.onReply!();
expect(fixture.tappedId, 'reply');
expect(fixture.tappedRevision, fixture.revision.value);
viewer.onMore!(tester.element(find.byType(MediaImageViewerPage)), url);
expect(fixture.tappedId, url);
expect(fixture.tappedRevision, fixture.revision.value);
Navigator.of(tester.element(find.byType(MediaImageViewerPage))).pop();
await tester.pumpAndSettle();
fixture.tags[0][3] = 'alt Second label';
await fixture.refresh(tester);
expect(_components(tester), isNot(same(before)));
await tester.tap(
find.byKey(const ValueKey('message-media-image-preview:$url')),
);
await tester.pumpAndSettle();
viewer = tester.widget<MediaImageViewerPage>(
find.byType(MediaImageViewerPage),
);
expect(viewer.semanticLabel, 'Second label');
Navigator.of(tester.element(find.byType(MediaImageViewerPage))).pop();
await tester.pumpAndSettle();
final beforeRemoval = _components(tester);
fixture.mediaHandlers = false;
await fixture.refresh(tester);
expect(_components(tester), isNot(same(beforeRemoval)));
await tester.tap(
find.byKey(const ValueKey('message-media-image-preview:$url')),
);
await tester.pumpAndSettle();
viewer = tester.widget<MediaImageViewerPage>(
find.byType(MediaImageViewerPage),
);
expect(viewer.onReply, isNull);
expect(viewer.onMore, isNull);
},
);
testWidgets(
'equal presentation retains parser components and current callbacks',
(tester) async {
final fixture = _Fixture();
addTearDown(fixture.revision.dispose);
await tester.pumpWidget(fixture.build());
final before = _components(tester);
for (var i = 0; i < 3; i++) {
await fixture.refresh(tester);
// This is the production dependency seam: gpt_markdown's config.isSame
// compares these component identities before deciding whether to parse.
expect(_components(tester), same(before));
}
await tester.tap(find.text('Alice'));
expect(fixture.tappedRevision, fixture.revision.value);
expect(fixture.tappedId, 'alice-key');
await tester.tap(find.text('sample'));
expect(fixture.tappedRevision, fixture.revision.value);
expect(fixture.tappedId, 'channel-one');
fixture.channels['sample'] = 'channel-two';
await fixture.refresh(tester);
expect(_components(tester), isNot(same(before)));
await tester.tap(find.text('sample'));
expect(fixture.tappedId, 'channel-two');
},
);
testWidgets(
'mention labels, bindings, agent status and handler presence invalidate',
(tester) async {
final fixture = _Fixture();
addTearDown(fixture.revision.dispose);
await tester.pumpWidget(fixture.build());
var before = _components(tester);
fixture.mentions['alice-key'] = 'ALICE';
await fixture.refresh(tester);
expect(_components(tester), isNot(same(before)));
expect(find.text('ALICE'), findsOneWidget);
before = _components(tester);
fixture.agents.add('alice-key');
await fixture.refresh(tester);
expect(_components(tester), isNot(same(before)));
expect(find.byType(BuzzAgentIcon), findsOneWidget);
before = _components(tester);
fixture.mentionHandler = false;
fixture.tappedId = null;
await fixture.refresh(tester);
expect(_components(tester), isNot(same(before)));
await tester.tap(find.text('ALICE'));
expect(fixture.tappedId, isNull);
fixture.mentionHandler = true;
fixture.mentions = {'second-key': 'ALICE'};
await fixture.refresh(tester);
await tester.tap(find.text('ALICE'));
expect(fixture.tappedId, 'second-key');
expect(find.byType(BuzzAgentIcon), findsNothing);
},
);
testWidgets('signed qualified mention changes cannot retain an old binding', (
tester,
) async {
final key = 'a' * 64;
final fixture = _Fixture()
..content = '@Alice ($key)'
..mentions = {}
..tags = [
['p', key],
];
addTearDown(fixture.revision.dispose);
await tester.pumpWidget(fixture.build());
final before = _components(tester);
await tester.tap(find.text('Alice (aaaaaaaa…aaaa)'));
expect(fixture.tappedId, key);
fixture.tags = [];
fixture.tappedId = null;
await fixture.refresh(tester);
expect(_components(tester), isNot(same(before)));
expect(find.text('Alice (aaaaaaaa…aaaa)'), findsNothing);
});
testWidgets('community emoji palette and emoji-only size invalidate', (
tester,
) async {
final fixture = _Fixture()
..content = ':sample:'
..palette = [
const CustomEmoji(
shortcode: 'sample',
url: 'https://relay.example/one.png',
),
];
addTearDown(fixture.revision.dispose);
await tester.pumpWidget(fixture.build());
var before = _components(tester);
expect(
tester.widget<CustomEmojiImage>(find.byType(CustomEmojiImage)).url,
'https://relay.example/one.png',
);
fixture.palette = [
const CustomEmoji(
shortcode: 'sample',
url: 'https://relay.example/two.png',
),
];
ProviderScope.containerOf(
tester.element(find.byType(MessageContent)),
).invalidate(customEmojiListProvider);
await fixture.refresh(tester);
expect(_components(tester), isNot(same(before)));
expect(
tester.widget<CustomEmojiImage>(find.byType(CustomEmojiImage)).url,
'https://relay.example/two.png',
);
before = _components(tester);
fixture.scaleEmoji = true;
await fixture.refresh(tester);
expect(_components(tester), isNot(same(before)));
expect(
tester.widget<CustomEmojiImage>(find.byType(CustomEmojiImage)).size,
kEmojiOnlyCustomEmojiSize,
);
before = _components(tester);
fixture.tags = [
['emoji', 'sample', 'https://relay.example/tag.png'],
];
await fixture.refresh(tester);
expect(_components(tester), isNot(same(before)));
expect(
tester.widget<CustomEmojiImage>(find.byType(CustomEmojiImage)).url,
'https://relay.example/tag.png',
);
});
}