feat: import Chinese-localized Buzz source snapshot
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
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>
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:buzz/features/forum/forum_models.dart';
|
||||
|
||||
Map<String, dynamic> _postJson({
|
||||
String eventId = 'evt1',
|
||||
String pubkey = 'alice',
|
||||
String content = 'Hello world',
|
||||
int kind = 45001,
|
||||
int createdAt = 1000,
|
||||
String channelId = 'ch1',
|
||||
List<List<String>>? tags,
|
||||
Map<String, dynamic>? threadSummary,
|
||||
}) => {
|
||||
'event_id': eventId,
|
||||
'pubkey': pubkey,
|
||||
'content': content,
|
||||
'kind': kind,
|
||||
'created_at': createdAt,
|
||||
'channel_id': channelId,
|
||||
'tags':
|
||||
tags ??
|
||||
[
|
||||
['h', channelId],
|
||||
],
|
||||
...?threadSummary != null ? {'thread_summary': threadSummary} : null,
|
||||
};
|
||||
|
||||
Map<String, dynamic> _replyJson({
|
||||
String eventId = 'reply1',
|
||||
String pubkey = 'bob',
|
||||
String content = 'Nice post',
|
||||
int kind = 45003,
|
||||
int createdAt = 2000,
|
||||
String channelId = 'ch1',
|
||||
String? parentEventId = 'evt1',
|
||||
String? rootEventId = 'evt1',
|
||||
int depth = 1,
|
||||
}) => {
|
||||
'event_id': eventId,
|
||||
'pubkey': pubkey,
|
||||
'content': content,
|
||||
'kind': kind,
|
||||
'created_at': createdAt,
|
||||
'channel_id': channelId,
|
||||
'tags': [
|
||||
['h', channelId],
|
||||
],
|
||||
'parent_event_id': parentEventId,
|
||||
'root_event_id': rootEventId,
|
||||
'depth': depth,
|
||||
};
|
||||
|
||||
Map<String, dynamic> _summaryJson({
|
||||
int replyCount = 3,
|
||||
int descendantCount = 5,
|
||||
int? lastReplyAt = 3000,
|
||||
List<String> participants = const ['bob', 'carol'],
|
||||
}) => {
|
||||
'reply_count': replyCount,
|
||||
'descendant_count': descendantCount,
|
||||
'last_reply_at': lastReplyAt,
|
||||
'participants': participants,
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('ForumPost.fromJson', () {
|
||||
test('parses complete post with thread summary', () {
|
||||
final json = _postJson(
|
||||
threadSummary: _summaryJson(),
|
||||
tags: [
|
||||
['h', 'ch1'],
|
||||
['p', 'bob'],
|
||||
],
|
||||
);
|
||||
final post = ForumPost.fromJson(json);
|
||||
|
||||
expect(post.eventId, 'evt1');
|
||||
expect(post.pubkey, 'alice');
|
||||
expect(post.content, 'Hello world');
|
||||
expect(post.kind, 45001);
|
||||
expect(post.createdAt, 1000);
|
||||
expect(post.channelId, 'ch1');
|
||||
expect(post.tags, hasLength(2));
|
||||
expect(post.threadSummary, isNotNull);
|
||||
expect(post.threadSummary!.replyCount, 3);
|
||||
expect(post.threadSummary!.descendantCount, 5);
|
||||
expect(post.threadSummary!.lastReplyAt, 3000);
|
||||
expect(post.threadSummary!.participants, ['bob', 'carol']);
|
||||
});
|
||||
|
||||
test('parses post without thread summary', () {
|
||||
final post = ForumPost.fromJson(_postJson());
|
||||
expect(post.threadSummary, isNull);
|
||||
});
|
||||
|
||||
test('extracts mention pubkeys from p-tags', () {
|
||||
final post = ForumPost.fromJson(
|
||||
_postJson(
|
||||
tags: [
|
||||
['h', 'ch1'],
|
||||
['p', 'bob'],
|
||||
['p', 'carol'],
|
||||
],
|
||||
),
|
||||
);
|
||||
expect(post.mentionPubkeys, ['bob', 'carol']);
|
||||
});
|
||||
|
||||
test('returns empty mentions when no p-tags', () {
|
||||
final post = ForumPost.fromJson(
|
||||
_postJson(
|
||||
tags: [
|
||||
['h', 'ch1'],
|
||||
],
|
||||
),
|
||||
);
|
||||
expect(post.mentionPubkeys, isEmpty);
|
||||
});
|
||||
|
||||
test('skips malformed p-tags with length < 2', () {
|
||||
final post = ForumPost.fromJson(
|
||||
_postJson(
|
||||
tags: [
|
||||
['h', 'ch1'],
|
||||
['p'],
|
||||
['p', 'bob'],
|
||||
],
|
||||
),
|
||||
);
|
||||
expect(post.mentionPubkeys, ['bob']);
|
||||
});
|
||||
});
|
||||
|
||||
group('ForumThreadSummary.fromJson', () {
|
||||
test('parses all fields', () {
|
||||
final summary = ForumThreadSummary.fromJson(_summaryJson());
|
||||
expect(summary.replyCount, 3);
|
||||
expect(summary.descendantCount, 5);
|
||||
expect(summary.lastReplyAt, 3000);
|
||||
expect(summary.participants, ['bob', 'carol']);
|
||||
});
|
||||
|
||||
test('defaults missing fields', () {
|
||||
final summary = ForumThreadSummary.fromJson(const <String, dynamic>{});
|
||||
expect(summary.replyCount, 0);
|
||||
expect(summary.descendantCount, 0);
|
||||
expect(summary.lastReplyAt, isNull);
|
||||
expect(summary.participants, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('ThreadReply.fromJson', () {
|
||||
test('parses a full reply', () {
|
||||
final reply = ThreadReply.fromJson(_replyJson());
|
||||
expect(reply.eventId, 'reply1');
|
||||
expect(reply.pubkey, 'bob');
|
||||
expect(reply.content, 'Nice post');
|
||||
expect(reply.kind, 45003);
|
||||
expect(reply.parentEventId, 'evt1');
|
||||
expect(reply.rootEventId, 'evt1');
|
||||
expect(reply.depth, 1);
|
||||
});
|
||||
|
||||
test('defaults depth to 0 when missing', () {
|
||||
final json = _replyJson();
|
||||
json.remove('depth');
|
||||
final reply = ThreadReply.fromJson(json);
|
||||
expect(reply.depth, 0);
|
||||
});
|
||||
|
||||
test('handles null parent and root', () {
|
||||
final reply = ThreadReply.fromJson(
|
||||
_replyJson(parentEventId: null, rootEventId: null),
|
||||
);
|
||||
expect(reply.parentEventId, isNull);
|
||||
expect(reply.rootEventId, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('ForumPostsResponse.fromJson', () {
|
||||
test('parses paginated response', () {
|
||||
final response = ForumPostsResponse.fromJson({
|
||||
'messages': [_postJson(), _postJson(eventId: 'evt2')],
|
||||
'next_cursor': 900,
|
||||
});
|
||||
expect(response.posts, hasLength(2));
|
||||
expect(response.nextCursor, 900);
|
||||
});
|
||||
|
||||
test('handles empty messages', () {
|
||||
final response = ForumPostsResponse.fromJson({
|
||||
'messages': <dynamic>[],
|
||||
'next_cursor': null,
|
||||
});
|
||||
expect(response.posts, isEmpty);
|
||||
expect(response.nextCursor, isNull);
|
||||
});
|
||||
|
||||
test('handles missing messages key', () {
|
||||
final response = ForumPostsResponse.fromJson(const <String, dynamic>{});
|
||||
expect(response.posts, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('ForumThreadResponse.fromJson', () {
|
||||
test('parses thread with root and replies', () {
|
||||
final response = ForumThreadResponse.fromJson({
|
||||
'root': _postJson(),
|
||||
'replies': [_replyJson(), _replyJson(eventId: 'reply2')],
|
||||
'total_replies': 2,
|
||||
'next_cursor': 'abc',
|
||||
});
|
||||
expect(response.post.eventId, 'evt1');
|
||||
expect(response.replies, hasLength(2));
|
||||
expect(response.totalReplies, 2);
|
||||
expect(response.nextCursor, 'abc');
|
||||
});
|
||||
|
||||
test('handles empty replies', () {
|
||||
final response = ForumThreadResponse.fromJson({
|
||||
'root': _postJson(),
|
||||
'replies': <dynamic>[],
|
||||
'total_replies': 0,
|
||||
});
|
||||
expect(response.replies, isEmpty);
|
||||
expect(response.totalReplies, 0);
|
||||
expect(response.nextCursor, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('formatRelativeTime', () {
|
||||
int now() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
test('returns just now for < 60s', () {
|
||||
expect(formatRelativeTime(now() - 30), 'just now');
|
||||
});
|
||||
|
||||
test('returns minutes format', () {
|
||||
expect(formatRelativeTime(now() - 120), '2m ago');
|
||||
});
|
||||
|
||||
test('returns hours format', () {
|
||||
expect(formatRelativeTime(now() - 7200), '2h ago');
|
||||
});
|
||||
|
||||
test('returns days format', () {
|
||||
expect(formatRelativeTime(now() - 172800), '2d ago');
|
||||
});
|
||||
|
||||
test('returns date format for > 7 days', () {
|
||||
final ts = now() - (8 * 86400);
|
||||
final result = formatRelativeTime(ts);
|
||||
// Should be M/D/YYYY format.
|
||||
expect(result, matches(RegExp(r'^\d{1,2}/\d{1,2}/\d{4}$')));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,853 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:buzz/features/channels/channel.dart';
|
||||
import 'package:buzz/features/forum/forum_models.dart';
|
||||
import 'package:buzz/features/forum/forum_post_card.dart';
|
||||
import 'package:buzz/features/forum/forum_posts_view.dart';
|
||||
import 'package:buzz/features/forum/forum_provider.dart';
|
||||
import 'package:buzz/features/forum/forum_thread_page.dart';
|
||||
import 'package:buzz/features/profile/profile_provider.dart';
|
||||
import 'package:buzz/features/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/l10n/l10n.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const _channelId = 'forum-channel';
|
||||
|
||||
final _forumChannel = Channel(
|
||||
id: _channelId,
|
||||
name: 'design-forum',
|
||||
channelType: 'forum',
|
||||
visibility: 'open',
|
||||
description: '',
|
||||
createdBy: 'abc123',
|
||||
createdAt: DateTime(2025),
|
||||
memberCount: 5,
|
||||
isMember: true,
|
||||
);
|
||||
|
||||
ForumPost _makePost({
|
||||
String eventId = 'post1',
|
||||
String pubkey = 'alice',
|
||||
String content = 'Hello forum',
|
||||
int createdAt = 1000,
|
||||
List<List<String>> tags = const [
|
||||
['h', 'forum-channel'],
|
||||
],
|
||||
ForumThreadSummary? threadSummary,
|
||||
}) => ForumPost(
|
||||
eventId: eventId,
|
||||
pubkey: pubkey,
|
||||
content: content,
|
||||
kind: 45001,
|
||||
createdAt: createdAt,
|
||||
channelId: _channelId,
|
||||
tags: tags,
|
||||
threadSummary: threadSummary,
|
||||
);
|
||||
|
||||
const _aliceProfile = UserProfile(pubkey: 'alice', displayName: 'Alice');
|
||||
|
||||
void _setSurfaceSize(WidgetTester tester, Size size) {
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
tester.view.physicalSize = size;
|
||||
}
|
||||
|
||||
Widget _buildPostCard({
|
||||
required ForumPost post,
|
||||
String? currentPubkey = 'self',
|
||||
Map<String, UserProfile> users = const {},
|
||||
VoidCallback? onTap,
|
||||
void Function(String)? onDelete,
|
||||
TextScaler textScaler = TextScaler.noScaling,
|
||||
}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(users)),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
theme: AppTheme.light(),
|
||||
home: Builder(
|
||||
builder: (context) => MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
|
||||
child: Scaffold(
|
||||
body: ForumPostCard(
|
||||
post: post,
|
||||
currentPubkey: currentPubkey,
|
||||
onTap: onTap ?? () {},
|
||||
onDelete: onDelete,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPostsView({
|
||||
required ForumPostsResponse postsResponse,
|
||||
Channel? channel,
|
||||
Map<String, UserProfile> users = const {},
|
||||
}) {
|
||||
final ch = channel ?? _forumChannel;
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(users)),
|
||||
profileProvider.overrideWith(() => _FakeProfileNotifier()),
|
||||
forumPostsProvider(ch.id).overrideWith((ref) async => postsResponse),
|
||||
relayClientProvider.overrideWithValue(
|
||||
RelayClient(baseUrl: 'http://localhost:3000'),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: ForumPostsView(channel: ch, currentPubkey: 'self'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared mock prefs for the compose bar's draft store. Initialized in
|
||||
/// [main].
|
||||
late SharedPreferences _testPrefs;
|
||||
|
||||
Widget _buildThreadPage({
|
||||
required ForumThreadResponse threadResponse,
|
||||
String postEventId = 'post1',
|
||||
String? currentPubkey = 'self',
|
||||
bool isMember = true,
|
||||
bool isArchived = false,
|
||||
Map<String, UserProfile> users = const {},
|
||||
TextScaler textScaler = TextScaler.noScaling,
|
||||
}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(users)),
|
||||
profileProvider.overrideWith(() => _FakeProfileNotifier()),
|
||||
forumThreadProvider((
|
||||
channelId: _channelId,
|
||||
eventId: postEventId,
|
||||
)).overrideWith((ref) async => threadResponse),
|
||||
savedPrefsProvider.overrideWithValue(_testPrefs),
|
||||
relayClientProvider.overrideWithValue(
|
||||
RelayClient(baseUrl: 'http://localhost:3000'),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
theme: AppTheme.light(),
|
||||
home: Builder(
|
||||
builder: (context) => MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
|
||||
child: ForumThreadPage(
|
||||
channelId: _channelId,
|
||||
postEventId: postEventId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
_testPrefs = await SharedPreferences.getInstance();
|
||||
});
|
||||
|
||||
test('cancels a captured forum delivery after the community changes', () {
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
container
|
||||
.read(relayConfigProvider.notifier)
|
||||
.update(baseUrl: 'https://first.example');
|
||||
final delivery = ForumEventDelivery.capture(container);
|
||||
|
||||
container
|
||||
.read(relayConfigProvider.notifier)
|
||||
.update(baseUrl: 'https://second.example');
|
||||
|
||||
expect(
|
||||
delivery.createPost(channelId: _channelId, content: 'Queued post'),
|
||||
throwsA(
|
||||
isA<StateError>().having(
|
||||
(error) => error.message,
|
||||
'message',
|
||||
contains('active community changed'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
group('ForumPostCard', () {
|
||||
testWidgets('renders author name and content', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildPostCard(
|
||||
post: _makePost(),
|
||||
users: const {'alice': _aliceProfile},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Alice'), findsOneWidget);
|
||||
expect(find.text('Hello forum'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows truncated pubkey when no profile', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildPostCard(post: _makePost(pubkey: 'abcdef1234567890')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('abcdef12\u2026'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'constrains an older timestamp at large accessible text sizes',
|
||||
(tester) async {
|
||||
_setSurfaceSize(tester, const Size(240, 600));
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildPostCard(
|
||||
post: _makePost(
|
||||
createdAt:
|
||||
DateTime.utc(2025, 12, 31, 12).millisecondsSinceEpoch ~/ 1000,
|
||||
),
|
||||
users: const {
|
||||
'alice': UserProfile(
|
||||
pubkey: 'alice',
|
||||
displayName: 'A very long display name',
|
||||
),
|
||||
},
|
||||
textScaler: const TextScaler.linear(2),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final timestamp = tester.widget<Text>(find.text('12/31/2025'));
|
||||
expect(timestamp.maxLines, 1);
|
||||
expect(timestamp.overflow, TextOverflow.ellipsis);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('gives the author unused timestamp width', (tester) async {
|
||||
_setSurfaceSize(tester, const Size(320, 600));
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120;
|
||||
const displayName = 'A moderately long forum author name';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildPostCard(
|
||||
post: _makePost(createdAt: createdAt),
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: displayName),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getSize(find.text(displayName)).width, greaterThan(150));
|
||||
expect(find.text('2m ago'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('truncates long content', (tester) async {
|
||||
final longContent = 'A' * 300;
|
||||
await tester.pumpWidget(
|
||||
_buildPostCard(post: _makePost(content: longContent)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should show 200 chars + "..."
|
||||
expect(find.textContaining('${'A' * 200}...'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows reply count with correct pluralization', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildPostCard(
|
||||
post: _makePost(
|
||||
threadSummary: const ForumThreadSummary(
|
||||
replyCount: 1,
|
||||
descendantCount: 1,
|
||||
participants: [],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('1 reply'), findsOneWidget);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildPostCard(
|
||||
post: _makePost(
|
||||
threadSummary: const ForumThreadSummary(
|
||||
replyCount: 5,
|
||||
descendantCount: 5,
|
||||
participants: [],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('5 replies'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hides thread summary when reply count is 0', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildPostCard(
|
||||
post: _makePost(
|
||||
threadSummary: const ForumThreadSummary(
|
||||
replyCount: 0,
|
||||
descendantCount: 0,
|
||||
participants: [],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('0 replies'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('calls onTap when tapped', (tester) async {
|
||||
var tapped = false;
|
||||
await tester.pumpWidget(
|
||||
_buildPostCard(post: _makePost(), onTap: () => tapped = true),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(ForumPostCard));
|
||||
expect(tapped, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('keeps media previews non-interactive in the post list', (
|
||||
tester,
|
||||
) async {
|
||||
var tapped = false;
|
||||
const imageUrl = 'https://example.com/media/card.png';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildPostCard(
|
||||
post: _makePost(
|
||||
content: '',
|
||||
tags: const [
|
||||
['h', _channelId],
|
||||
[
|
||||
'imeta',
|
||||
'url https://example.com/media/card.png',
|
||||
'm image/png',
|
||||
],
|
||||
],
|
||||
),
|
||||
onTap: () => tapped = true,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final preview = find.byKey(
|
||||
const ValueKey(
|
||||
'message-media-image-preview:https://example.com/media/card.png',
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tapAt(tester.getCenter(preview));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tapped, isTrue);
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-media-image-viewer')),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('long press opens action sheet with Copy text', (tester) async {
|
||||
await tester.pumpWidget(_buildPostCard(post: _makePost()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.longPress(find.byType(ForumPostCard));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Copy text'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('long press shows Delete only for own posts', (tester) async {
|
||||
// Own post — Delete should appear.
|
||||
await tester.pumpWidget(
|
||||
_buildPostCard(
|
||||
post: _makePost(pubkey: 'self'),
|
||||
currentPubkey: 'self',
|
||||
onDelete: (_) {},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.longPress(find.byType(ForumPostCard));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Delete post'), findsOneWidget);
|
||||
|
||||
// Dismiss sheet.
|
||||
await tester.tapAt(Offset.zero);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Other's post — Delete should NOT appear.
|
||||
await tester.pumpWidget(
|
||||
_buildPostCard(
|
||||
post: _makePost(pubkey: 'other'),
|
||||
currentPubkey: 'self',
|
||||
onDelete: (_) {},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.longPress(find.byType(ForumPostCard));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Delete post'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('delete confirmation dialog triggers onDelete', (tester) async {
|
||||
String? deletedId;
|
||||
await tester.pumpWidget(
|
||||
_buildPostCard(
|
||||
post: _makePost(pubkey: 'self', eventId: 'evt-to-delete'),
|
||||
currentPubkey: 'self',
|
||||
onDelete: (id) => deletedId = id,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Long press → action sheet.
|
||||
await tester.longPress(find.byType(ForumPostCard));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap Delete post.
|
||||
await tester.tap(find.text('Delete post'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Confirmation dialog appears.
|
||||
expect(find.text('This cannot be undone.'), findsOneWidget);
|
||||
|
||||
// Tap Delete button.
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(deletedId, 'evt-to-delete');
|
||||
});
|
||||
});
|
||||
|
||||
group('ForumPostsView', () {
|
||||
testWidgets('shows empty state for members', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildPostsView(postsResponse: const ForumPostsResponse(posts: [])),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('No posts yet'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Start a discussion by creating the first post.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('shows empty state for non-members', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildPostsView(
|
||||
postsResponse: const ForumPostsResponse(posts: []),
|
||||
channel: Channel(
|
||||
id: _channelId,
|
||||
name: 'design-forum',
|
||||
channelType: 'forum',
|
||||
visibility: 'open',
|
||||
description: '',
|
||||
createdBy: 'abc123',
|
||||
createdAt: DateTime(2025),
|
||||
memberCount: 5,
|
||||
isMember: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Join this forum to create posts.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows FAB for members', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildPostsView(postsResponse: const ForumPostsResponse(posts: [])),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(FloatingActionButton), findsOneWidget);
|
||||
expect(find.byTooltip('New post'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hides FAB for non-members', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildPostsView(
|
||||
postsResponse: const ForumPostsResponse(posts: []),
|
||||
channel: Channel(
|
||||
id: _channelId,
|
||||
name: 'design-forum',
|
||||
channelType: 'forum',
|
||||
visibility: 'open',
|
||||
description: '',
|
||||
createdBy: 'abc123',
|
||||
createdAt: DateTime(2025),
|
||||
memberCount: 5,
|
||||
isMember: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(FloatingActionButton), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('renders post list', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildPostsView(
|
||||
postsResponse: ForumPostsResponse(
|
||||
posts: [
|
||||
_makePost(content: 'First post'),
|
||||
_makePost(eventId: 'post2', content: 'Second post'),
|
||||
],
|
||||
),
|
||||
users: const {'alice': _aliceProfile},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('First post'), findsOneWidget);
|
||||
expect(find.text('Second post'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('ForumThreadPage', () {
|
||||
testWidgets('shows original post and replies header', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildThreadPage(
|
||||
threadResponse: ForumThreadResponse(
|
||||
post: _makePost(content: 'Thread root'),
|
||||
replies: const [],
|
||||
totalReplies: 0,
|
||||
),
|
||||
users: const {'alice': _aliceProfile},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Thread'), findsOneWidget); // App bar title
|
||||
expect(find.text('0 replies'), findsOneWidget);
|
||||
expect(
|
||||
find.text('No replies yet. Be the first to respond.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('shows reply count with replies', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildThreadPage(
|
||||
threadResponse: ForumThreadResponse(
|
||||
post: _makePost(),
|
||||
replies: [
|
||||
const ThreadReply(
|
||||
eventId: 'r1',
|
||||
pubkey: 'bob',
|
||||
content: 'Great post!',
|
||||
kind: 45003,
|
||||
createdAt: 2000,
|
||||
channelId: _channelId,
|
||||
tags: [
|
||||
['h', _channelId],
|
||||
],
|
||||
depth: 1,
|
||||
),
|
||||
],
|
||||
totalReplies: 1,
|
||||
),
|
||||
users: const {
|
||||
'alice': _aliceProfile,
|
||||
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('1 reply'), findsOneWidget);
|
||||
expect(find.text('Bob'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('constrains post and reply timestamps at large text sizes', (
|
||||
tester,
|
||||
) async {
|
||||
final oldTimestamp =
|
||||
DateTime.utc(2025, 12, 31, 12).millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildThreadPage(
|
||||
threadResponse: ForumThreadResponse(
|
||||
post: _makePost(createdAt: oldTimestamp),
|
||||
replies: [
|
||||
ThreadReply(
|
||||
eventId: 'old-reply',
|
||||
pubkey: 'bob',
|
||||
content: 'An older reply',
|
||||
kind: 45003,
|
||||
createdAt: oldTimestamp,
|
||||
channelId: _channelId,
|
||||
tags: const [
|
||||
['h', _channelId],
|
||||
],
|
||||
depth: 1,
|
||||
),
|
||||
],
|
||||
totalReplies: 1,
|
||||
),
|
||||
users: const {
|
||||
'alice': _aliceProfile,
|
||||
'bob': UserProfile(
|
||||
pubkey: 'bob',
|
||||
displayName: 'A very long reply author name',
|
||||
),
|
||||
},
|
||||
textScaler: const TextScaler.linear(2),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final timestamps = tester.widgetList<Text>(find.text('12/31/2025'));
|
||||
expect(timestamps, hasLength(2));
|
||||
for (final timestamp in timestamps) {
|
||||
expect(timestamp.maxLines, 1);
|
||||
expect(timestamp.overflow, TextOverflow.ellipsis);
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('gives thread authors unused timestamp width', (tester) async {
|
||||
_setSurfaceSize(tester, const Size(320, 800));
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120;
|
||||
const postAuthor = 'A moderately long original author';
|
||||
const replyAuthor = 'A moderately long reply author';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildThreadPage(
|
||||
threadResponse: ForumThreadResponse(
|
||||
post: _makePost(createdAt: createdAt),
|
||||
replies: [
|
||||
ThreadReply(
|
||||
eventId: 'reply',
|
||||
pubkey: 'bob',
|
||||
content: 'A reply',
|
||||
kind: 45003,
|
||||
createdAt: createdAt,
|
||||
channelId: _channelId,
|
||||
tags: const [
|
||||
['h', _channelId],
|
||||
],
|
||||
depth: 1,
|
||||
),
|
||||
],
|
||||
totalReplies: 1,
|
||||
),
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: postAuthor),
|
||||
'bob': UserProfile(pubkey: 'bob', displayName: replyAuthor),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getSize(find.text(postAuthor)).width, greaterThan(150));
|
||||
expect(tester.getSize(find.text(replyAuthor)).width, greaterThan(140));
|
||||
expect(find.text('2m ago'), findsNWidgets(2));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('shows compose bar for members', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildThreadPage(
|
||||
threadResponse: ForumThreadResponse(
|
||||
post: _makePost(),
|
||||
replies: const [],
|
||||
totalReplies: 0,
|
||||
),
|
||||
isMember: true,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Reply to this post\u2026'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders media previews for forum posts', (tester) async {
|
||||
const imageUrl = 'https://example.com/media/forum.png';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildThreadPage(
|
||||
threadResponse: ForumThreadResponse(
|
||||
post: _makePost(
|
||||
content: '',
|
||||
tags: const [
|
||||
['h', _channelId],
|
||||
[
|
||||
'imeta',
|
||||
'url https://example.com/media/forum.png',
|
||||
'm image/png',
|
||||
],
|
||||
],
|
||||
),
|
||||
replies: const [],
|
||||
totalReplies: 0,
|
||||
),
|
||||
users: const {'alice': _aliceProfile},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(
|
||||
const ValueKey(
|
||||
'message-media-image-preview:https://example.com/media/forum.png',
|
||||
),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('keeps tall forum image previews bounded inline', (
|
||||
tester,
|
||||
) async {
|
||||
_setSurfaceSize(tester, const Size(400, 800));
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
const imageUrl = 'https://example.com/media/forum-tall.png';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildThreadPage(
|
||||
threadResponse: ForumThreadResponse(
|
||||
post: _makePost(
|
||||
content: '',
|
||||
tags: const [
|
||||
['h', _channelId],
|
||||
[
|
||||
'imeta',
|
||||
'url https://example.com/media/forum-tall.png',
|
||||
'm image/png',
|
||||
'dim 1200x2400',
|
||||
],
|
||||
],
|
||||
),
|
||||
replies: const [],
|
||||
totalReplies: 0,
|
||||
),
|
||||
users: const {'alice': _aliceProfile},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final preview = find.byKey(
|
||||
const ValueKey(
|
||||
'message-media-image-preview:https://example.com/media/forum-tall.png',
|
||||
),
|
||||
);
|
||||
final size = tester.getSize(preview);
|
||||
|
||||
expect(size.height, closeTo(240, 0.1));
|
||||
expect(size.width, closeTo(120, 0.1));
|
||||
});
|
||||
|
||||
testWidgets('hides compose bar for non-members', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildThreadPage(
|
||||
threadResponse: ForumThreadResponse(
|
||||
post: _makePost(),
|
||||
replies: const [],
|
||||
totalReplies: 0,
|
||||
),
|
||||
isMember: false,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Reply to this post\u2026'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows 3-dot in app bar for own post', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildThreadPage(
|
||||
threadResponse: ForumThreadResponse(
|
||||
post: _makePost(pubkey: 'self'),
|
||||
replies: const [],
|
||||
totalReplies: 0,
|
||||
),
|
||||
currentPubkey: 'self',
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byTooltip('Post actions'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hides 3-dot in app bar for others post', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildThreadPage(
|
||||
threadResponse: ForumThreadResponse(
|
||||
post: _makePost(pubkey: 'alice'),
|
||||
replies: const [],
|
||||
totalReplies: 0,
|
||||
),
|
||||
currentPubkey: 'self',
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byTooltip('Post actions'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeUserCacheNotifier extends UserCacheNotifier {
|
||||
final Map<String, UserProfile> _users;
|
||||
_FakeUserCacheNotifier(this._users);
|
||||
|
||||
@override
|
||||
Map<String, UserProfile> build() => _users;
|
||||
|
||||
@override
|
||||
UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()];
|
||||
}
|
||||
|
||||
class _FakeProfileNotifier extends ProfileNotifier {
|
||||
@override
|
||||
Future<UserProfile?> build() async =>
|
||||
const UserProfile(pubkey: 'self', displayName: 'Self');
|
||||
}
|
||||
Reference in New Issue
Block a user