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
Signed-off-by: cls_宁波本机 <908705107@qq.com>
391 lines
12 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|