Files
buzz/mobile/test/features/channels/reaction_row_test.dart
T
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

391 lines
12 KiB
Dart

import 'package:buzz/features/channels/reaction_row.dart';
import 'package:buzz/features/channels/timeline_message.dart';
import 'package:buzz/shared/emoji/emoji_burst.dart';
import 'package:buzz/shared/emoji/emoji_data.dart';
import 'package:buzz/shared/emoji/emoji_data_provider.dart';
import 'package:buzz/features/profile/user_cache_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../helpers/widget_helpers.dart';
const _fire = '\u{1F525}';
/// 👀 — a real reaction that is deliberately not in the positive-burst set.
const _eyes = '\u{1F440}';
TimelineReaction _reaction({
String emoji = _fire,
int count = 1,
bool reactedByCurrentUser = false,
List<String> userPubkeys = const ['alice'],
}) => TimelineReaction(
emoji: emoji,
count: count,
reactedByCurrentUser: reactedByCurrentUser,
userPubkeys: userPubkeys,
);
final _dataset = EmojiDataset(
categories: const [],
all: const [
EmojiEntry(
id: 'fire',
name: 'Fire',
keywords: [],
native: _fire,
categoryId: 'nature',
),
],
nativeToShortcode: const {_fire: ':fire:'},
);
const _messageId = 'msg-1';
/// Pumps the row and hands back the enclosing container, so tests can arm a
/// pending burst or inspect the particle controller.
///
/// Re-pumping keeps the same `ProviderScope` state, which is what lets a test
/// swap the reactions list — standing in for the relay echo — without losing
/// the pending burst that was armed before it.
Future<ProviderContainer> _pumpRow(
WidgetTester tester, {
required List<TimelineReaction> reactions,
void Function(String emoji)? onToggle,
bool showAddButton = false,
VoidCallback? onAddReaction,
String messageId = _messageId,
}) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
emojiDatasetOrEmptyProvider.overrideWithValue(_dataset),
userCacheProvider.overrideWith(
() => _FakeUserCacheNotifier({
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
}),
),
],
child: ReactionRow(
messageId: messageId,
reactions: reactions,
onToggle: onToggle ?? (_) {},
showAddButton: showAddButton,
onAddReaction: onAddReaction,
),
),
);
await tester.pumpAndSettle();
return ProviderScope.containerOf(tester.element(find.byType(ReactionRow)));
}
class _FakeUserCacheNotifier extends UserCacheNotifier {
final Map<String, UserProfile> _profiles;
_FakeUserCacheNotifier(this._profiles);
@override
Map<String, UserProfile> build() => _profiles;
@override
Future<void> preload(Iterable<String> pubkeys) async {}
}
void main() {
group('ReactionRow', () {
testWidgets('shows the count even at one, matching desktop', (
tester,
) async {
await _pumpRow(tester, reactions: [_reaction()]);
expect(
find.byKey(const ValueKey('reaction-pill-$_fire')),
findsOneWidget,
);
expect(find.text('1'), findsOneWidget);
});
testWidgets('collapses entirely when there is nothing to show', (
tester,
) async {
await _pumpRow(tester, reactions: const []);
expect(find.byType(Wrap), findsNothing);
expect(find.byKey(const ValueKey('add-reaction-pill')), findsNothing);
});
testWidgets('renders the + pill on an empty row when asked', (
tester,
) async {
var taps = 0;
await _pumpRow(
tester,
reactions: const [],
showAddButton: true,
onAddReaction: () => taps++,
);
final addPill = find.byKey(const ValueKey('add-reaction-pill'));
expect(addPill, findsOneWidget);
await tester.tap(addPill);
expect(taps, 1);
});
testWidgets('+ pill trails the existing reactions', (tester) async {
await _pumpRow(
tester,
reactions: [
_reaction(),
_reaction(emoji: '\u{1F44D}', count: 3),
],
showAddButton: true,
onAddReaction: () {},
);
final firstPill = tester.getRect(
find.byKey(const ValueKey('reaction-pill-$_fire')),
);
final lastPill = tester.getRect(
find.byKey(const ValueKey('reaction-pill-\u{1F44D}')),
);
final addPill = tester.getRect(
find.byKey(const ValueKey('add-reaction-pill')),
);
// One row, in order, with the + last.
expect(lastPill.left, greaterThan(firstPill.left));
expect(addPill.left, greaterThan(lastPill.left));
expect(addPill.top, firstPill.top);
// Pills hug their content. A `Container.alignment` here would silently
// stretch each one to the full row width and stack them vertically.
expect(firstPill.height, 28);
expect(firstPill.width, greaterThanOrEqualTo(48));
expect(firstPill.width, lessThan(120));
expect(addPill.width, 40);
});
testWidgets('the + pill stays hidden without a handler', (tester) async {
await _pumpRow(tester, reactions: [_reaction()], showAddButton: true);
expect(find.byKey(const ValueKey('add-reaction-pill')), findsNothing);
});
testWidgets('tapping a pill toggles that emoji', (tester) async {
final toggled = <String>[];
await _pumpRow(tester, reactions: [_reaction()], onToggle: toggled.add);
await tester.tap(find.byKey(const ValueKey('reaction-pill-$_fire')));
expect(toggled, [_fire]);
});
testWidgets('long-press opens the detail sheet named from the dataset', (
tester,
) async {
await _pumpRow(
tester,
reactions: [
_reaction(count: 1, userPubkeys: const ['alice']),
],
);
await tester.longPress(
find.byKey(const ValueKey('reaction-pill-$_fire')),
);
await tester.pumpAndSettle();
expect(find.text(':fire:'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
});
});
group('reaction bursts', () {
testWidgets('adding a positive reaction bursts from the pill', (
tester,
) async {
final container = await _pumpRow(tester, reactions: [_reaction()]);
final controller = container.read(emojiBurstControllerProvider);
expect(controller.hasParticles, isFalse);
await tester.tap(find.byKey(const ValueKey('reaction-pill-$_fire')));
await tester.pump();
expect(controller.hasParticles, isTrue);
});
testWidgets('a non-positive reaction does not burst', (tester) async {
final container = await _pumpRow(
tester,
reactions: [_reaction(emoji: _eyes)],
);
final controller = container.read(emojiBurstControllerProvider);
await tester.tap(find.byKey(const ValueKey('reaction-pill-$_eyes')));
await tester.pump();
expect(controller.hasParticles, isFalse);
});
testWidgets('removing your own reaction does not burst', (tester) async {
final container = await _pumpRow(
tester,
reactions: [_reaction(reactedByCurrentUser: true)],
);
final controller = container.read(emojiBurstControllerProvider);
await tester.tap(find.byKey(const ValueKey('reaction-pill-$_fire')));
await tester.pump();
expect(controller.hasParticles, isFalse);
});
testWidgets('a pending burst fires when the new pill arrives', (
tester,
) async {
// Reacting from the quick row or the picker arms a burst, then the pill
// only appears once the relay echoes the reaction back.
final container = await _pumpRow(tester, reactions: const []);
final controller = container.read(emojiBurstControllerProvider);
container
.read(pendingReactionBurstProvider.notifier)
.arm(_messageId, _fire);
await _pumpRow(
tester,
reactions: [_reaction(reactedByCurrentUser: true)],
);
await tester.pump();
expect(controller.hasParticles, isTrue);
// Claimed, so a second row showing the same message can't double-burst.
expect(container.read(pendingReactionBurstProvider), isNull);
});
testWidgets('a pending burst waits for our own reaction, not anyone\'s', (
tester,
) async {
final container = await _pumpRow(tester, reactions: const []);
final controller = container.read(emojiBurstControllerProvider);
container
.read(pendingReactionBurstProvider.notifier)
.arm(_messageId, _fire);
// Someone else's reaction lands first.
await _pumpRow(tester, reactions: [_reaction()]);
await tester.pump();
expect(controller.hasParticles, isFalse);
expect(container.read(pendingReactionBurstProvider), isNotNull);
});
testWidgets('a pending burst armed for another message is left alone', (
tester,
) async {
final container = await _pumpRow(tester, reactions: const []);
final controller = container.read(emojiBurstControllerProvider);
container
.read(pendingReactionBurstProvider.notifier)
.arm('some-other-message', _fire);
await _pumpRow(
tester,
reactions: [_reaction(reactedByCurrentUser: true)],
);
await tester.pump();
expect(controller.hasParticles, isFalse);
expect(container.read(pendingReactionBurstProvider), isNotNull);
});
testWidgets(
'a pill under a pushed route leaves the burst for the top one',
(tester) async {
// The channel timeline stays mounted under a pushed thread page and
// rebuilds first, so without a route check it claimed the burst and the
// user saw nothing until they popped back.
final navigatorKey = GlobalKey<NavigatorState>();
late ProviderContainer container;
await tester.pumpWidget(
ProviderScope(
overrides: [
emojiDatasetOrEmptyProvider.overrideWithValue(_dataset),
userCacheProvider.overrideWith(
() => _FakeUserCacheNotifier(const {}),
),
],
child: MaterialApp(
navigatorKey: navigatorKey,
home: Scaffold(
body: ReactionRow(
messageId: _messageId,
reactions: [_reaction(reactedByCurrentUser: true)],
onToggle: (_) {},
),
),
),
),
);
await tester.pumpAndSettle();
container = ProviderScope.containerOf(
tester.element(find.byType(ReactionRow)),
);
final controller = container.read(emojiBurstControllerProvider);
// Push a route over it, then arm and let the covered pill rebuild.
navigatorKey.currentState!.push(
MaterialPageRoute<void>(
builder: (_) => const Scaffold(body: Text('thread')),
),
);
await tester.pumpAndSettle();
container
.read(pendingReactionBurstProvider.notifier)
.arm(_messageId, _fire);
await tester.pump();
expect(controller.hasParticles, isFalse);
// Still armed, so the pill on the visible route can claim it.
expect(container.read(pendingReactionBurstProvider), isNotNull);
},
);
testWidgets('reduced motion suppresses the burst', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
emojiDatasetOrEmptyProvider.overrideWithValue(_dataset),
userCacheProvider.overrideWith(
() => _FakeUserCacheNotifier(const {}),
),
],
child: MediaQuery(
data: const MediaQueryData(disableAnimations: true),
child: MaterialApp(
home: Scaffold(
body: ReactionRow(
messageId: _messageId,
reactions: [_reaction()],
onToggle: (_) {},
),
),
),
),
),
);
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.byType(ReactionRow)),
);
final controller = container.read(emojiBurstControllerProvider);
await tester.tap(find.byKey(const ValueKey('reaction-pill-$_fire')));
await tester.pump();
expect(controller.hasParticles, isFalse);
});
});
}