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

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,194 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/features/profile/presence_cache_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
/// Tests for [PresenceCacheNotifier] in the pure-Nostr world.
///
/// The cache is now purely WS-driven: the notifier subscribes to kind:20001
/// (presence updates) over the relay session and only mutates state for
/// pubkeys that have been registered via [PresenceCacheNotifier.track].
/// There is no longer a REST backstop — the previous test seeded state via
/// a `GET /api/presence` call which has been removed.
void main() {
test('WS presence event updates cache for tracked pubkey', () async {
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(relaySession: relaySession);
addTearDown(container.dispose);
// Initialize the notifier (triggers build → subscribes to WS).
container.read(presenceCacheProvider);
await _pumpEventQueue();
// Track alice, then emit her initial 'online' status.
container.read(presenceCacheProvider.notifier).track(['alice']);
relaySession.emit(_presence('alice', 'online'));
expect(container.read(presenceCacheProvider)['alice'], 'online');
// Simulate a WS presence event: alice goes away.
relaySession.emit(_presence('alice', 'away'));
expect(container.read(presenceCacheProvider)['alice'], 'away');
});
test('WS presence event ignores untracked pubkeys', () async {
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(relaySession: relaySession);
addTearDown(container.dispose);
container.read(presenceCacheProvider);
await _pumpEventQueue();
// Track only alice.
container.read(presenceCacheProvider.notifier).track(['alice']);
// Emit event for bob (untracked).
relaySession.emit(_presence('bob', 'online'));
// Bob should NOT appear in the cache.
expect(container.read(presenceCacheProvider).containsKey('bob'), isFalse);
});
test('WS presence event ignores invalid status values', () async {
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(relaySession: relaySession);
addTearDown(container.dispose);
container.read(presenceCacheProvider);
await _pumpEventQueue();
container.read(presenceCacheProvider.notifier).track(['alice']);
relaySession.emit(_presence('alice', 'online'));
expect(container.read(presenceCacheProvider)['alice'], 'online');
// Emit event with garbage status — should be rejected.
relaySession.emit(_presence('alice', 'garbage-status'));
// Status should remain 'online'.
expect(container.read(presenceCacheProvider)['alice'], 'online');
});
test('WS presence event skips no-op updates', () async {
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(relaySession: relaySession);
addTearDown(container.dispose);
container.read(presenceCacheProvider);
await _pumpEventQueue();
container.read(presenceCacheProvider.notifier).track(['alice']);
relaySession.emit(_presence('alice', 'online'));
// Listen for state changes after initial setup.
var stateChangeCount = 0;
container.listen(presenceCacheProvider, (prev, next) => stateChangeCount++);
// Emit event with same status as current.
relaySession.emit(_presence('alice', 'online'));
// No state change should occur — it's a no-op.
expect(stateChangeCount, 0);
});
test('subscribes to kind:20001 with limit 0', () async {
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(relaySession: relaySession);
addTearDown(container.dispose);
container.read(presenceCacheProvider);
await _pumpEventQueue();
// Should have subscribed with the correct filter.
expect(relaySession.filters, hasLength(1));
expect(relaySession.filters.single.kinds, [EventKind.presenceUpdate]);
expect(relaySession.filters.single.limit, 0);
});
test('WS event uses pubkey variable, not literal string', () async {
// Regression test for the map key bug where `{...state, pubkey: status}`
// used the literal string "pubkey" instead of the variable's value.
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(relaySession: relaySession);
addTearDown(container.dispose);
container.read(presenceCacheProvider);
await _pumpEventQueue();
container.read(presenceCacheProvider.notifier).track([
'deadbeef',
'cafebabe',
]);
// Seed cafebabe -> offline, then set deadbeef online.
relaySession.emit(_presence('cafebabe', 'offline'));
relaySession.emit(_presence('deadbeef', 'online'));
final cache = container.read(presenceCacheProvider);
// deadbeef should be online (the actual pubkey, not a literal "pubkey" key).
expect(cache['deadbeef'], 'online');
// cafebabe should still be offline (not clobbered).
expect(cache['cafebabe'], 'offline');
// There should be no literal "pubkey" key in the map.
expect(cache.containsKey('pubkey'), isFalse);
});
}
NostrEvent _presence(String pubkey, String status) => NostrEvent(
id: 'evt-$pubkey-$status',
pubkey: pubkey,
createdAt: 1000,
kind: EventKind.presenceUpdate,
tags: const [],
content: status,
sig: 'sig',
);
Future<void> _pumpEventQueue() async {
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
}
ProviderContainer _buildContainer({
required _RecordingRelaySessionNotifier relaySession,
}) {
return ProviderContainer(
overrides: [
appLifecycleProvider.overrideWith(() => _FakeAppLifecycleNotifier()),
relaySessionProvider.overrideWith(() => relaySession),
],
);
}
class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
final List<NostrFilter> filters = [];
final List<void Function(NostrEvent)> _listeners = [];
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async {
filters.add(filter);
_listeners.add(onEvent);
return () {
filters.remove(filter);
_listeners.remove(onEvent);
};
}
/// Emit an event synchronously to all live subscribers.
void emit(NostrEvent event) {
for (final listener in List.of(_listeners)) {
listener(event);
}
}
}
class _FakeAppLifecycleNotifier extends AppLifecycleNotifier {
@override
AppLifecycleState build() => AppLifecycleState.resumed;
}
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/features/profile/profile_avatar.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
void main() {
Widget harness({bool showPresence = true, String presence = 'online'}) {
return ProviderScope(
overrides: [
profileProvider.overrideWith(_FakeProfileNotifier.new),
presenceProvider.overrideWith(() => _FakePresenceNotifier(presence)),
],
child: MaterialApp(
theme: AppTheme.light(),
home: Center(child: ProfileAvatar(showPresence: showPresence)),
),
);
}
testWidgets('seats the presence dot in a notch instead of over the avatar', (
tester,
) async {
await tester.pumpWidget(harness());
await tester.pumpAndSettle();
final badge = tester.widget<MaskedAvatarBadge>(
find.byType(MaskedAvatarBadge),
);
expect(badge.geometry, AvatarBadgeMaskGeometry.presenceDot);
expect(
tester.widget<ClipPath>(find.byType(ClipPath).last).clipper,
isA<AvatarBadgeMaskClipper>(),
);
// The notch supplies the separation, so the dot carries no border ring.
final dot = tester
.widgetList<DecoratedBox>(find.byType(DecoratedBox))
.map((box) => box.decoration)
.whereType<BoxDecoration>()
.firstWhere((decoration) => decoration.shape == BoxShape.circle);
expect(dot.border, isNull);
});
Color dotColor(WidgetTester tester) => tester
.widgetList<DecoratedBox>(find.byType(DecoratedBox))
.map((box) => box.decoration)
.whereType<BoxDecoration>()
.firstWhere((decoration) => decoration.shape == BoxShape.circle)
.color!;
final theme = AppTheme.light();
testWidgets('paints the dot with the presence color', (tester) async {
await tester.pumpWidget(harness());
await tester.pumpAndSettle();
expect(dotColor(tester), theme.extension<AppColors>()!.success);
});
testWidgets('mutes the dot when offline', (tester) async {
await tester.pumpWidget(harness(presence: 'offline'));
await tester.pumpAndSettle();
expect(dotColor(tester), theme.colorScheme.outline);
});
testWidgets('leaves the avatar unmasked when presence is hidden', (
tester,
) async {
await tester.pumpWidget(harness(showPresence: false));
await tester.pumpAndSettle();
final badge = tester.widget<MaskedAvatarBadge>(
find.byType(MaskedAvatarBadge),
);
expect(badge.badge, isNull);
expect(
find.descendant(
of: find.byType(MaskedAvatarBadge),
matching: find.byType(ClipPath),
),
findsNothing,
);
});
}
class _FakeProfileNotifier extends ProfileNotifier {
@override
Future<UserProfile?> build() async =>
const UserProfile(pubkey: 'aabb', displayName: 'Test');
}
class _FakePresenceNotifier extends PresenceNotifier {
_FakePresenceNotifier(this._presence);
final String _presence;
@override
Future<String> build() async => _presence;
}
@@ -0,0 +1,90 @@
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
test(
'manual presence persists until Online restores automatic mode',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
var container = _buildContainer(prefs);
expect(
await container
.read(presenceProvider.future)
.timeout(
const Duration(seconds: 2),
onTimeout: () =>
throw StateError('initial presence did not resolve'),
),
'online',
);
await container
.read(presenceProvider.notifier)
.setPresence('away')
.timeout(
const Duration(seconds: 2),
onTimeout: () => throw StateError('setting Away did not resolve'),
);
expect(container.read(presenceProvider).value, 'away');
expect(prefs.getString('buzz_presence_preference_aabb'), 'away');
container.dispose();
container = _buildContainer(prefs);
addTearDown(container.dispose);
expect(
await container
.read(presenceProvider.future)
.timeout(
const Duration(seconds: 2),
onTimeout: () =>
throw StateError('stored presence did not resolve'),
),
'away',
);
await container
.read(presenceProvider.notifier)
.setPresence('online')
.timeout(
const Duration(seconds: 2),
onTimeout: () => throw StateError('setting Online did not resolve'),
);
expect(container.read(presenceProvider).value, 'online');
expect(prefs.getString('buzz_presence_preference_aabb'), 'auto');
},
);
}
ProviderContainer _buildContainer(SharedPreferences prefs) => ProviderContainer(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
myPubkeyProvider.overrideWithValue('aabb'),
profileProvider.overrideWith(_FakeProfileNotifier.new),
relaySessionProvider.overrideWith(_DisconnectedRelaySession.new),
appLifecycleProvider.overrideWith(_ResumedLifecycle.new),
],
);
class _FakeProfileNotifier extends ProfileNotifier {
@override
Future<UserProfile?> build() async =>
const UserProfile(pubkey: 'aabb', displayName: 'Test');
}
class _DisconnectedRelaySession extends RelaySessionNotifier {
@override
SessionState build() =>
const SessionState(status: SessionStatus.disconnected);
}
class _ResumedLifecycle extends AppLifecycleNotifier {
@override
AppLifecycleState build() => AppLifecycleState.resumed;
}
@@ -0,0 +1,72 @@
import 'package:buzz/features/profile/set_status_sheet.dart';
import 'package:buzz/features/profile/user_status.dart';
import 'package:buzz/features/profile/user_status_provider.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../helpers/widget_helpers.dart';
void main() {
testWidgets('removes only the emoji from an existing status', (tester) async {
final statusNotifier = _RecordingUserStatusNotifier();
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
customEmojiListProvider.overrideWithValue(const []),
userStatusProvider.overrideWith(() => statusNotifier),
],
child: Builder(
builder: (context) => FilledButton(
onPressed: () => showSetStatusSheet(
context,
currentStatus: const UserStatus(
text: 'Focusing',
emoji: '\u{1F3AF}',
updatedAt: 1,
),
),
child: const Text('Open status editor'),
),
),
),
);
await tester.tap(find.text('Open status editor'));
await tester.pumpAndSettle();
expect(find.text('\u{1F3AF}'), findsOneWidget);
expect(find.byTooltip('Remove status emoji'), findsOneWidget);
await tester.tap(find.byTooltip('Remove status emoji'));
await tester.pump();
expect(find.text('\u{1F3AF}'), findsNothing);
expect(find.byIcon(LucideIcons.smilePlus), findsOneWidget);
expect(
tester.widget<TextField>(find.byType(TextField)).controller?.text,
'Focusing',
);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(statusNotifier.savedText, 'Focusing');
expect(statusNotifier.savedEmoji, isEmpty);
});
}
class _RecordingUserStatusNotifier extends UserStatusNotifier {
String? savedText;
String? savedEmoji;
@override
Future<UserStatus?> build() async => null;
@override
Future<void> setStatus(String text, String emoji) async {
savedText = text;
savedEmoji = emoji;
}
}
@@ -0,0 +1,175 @@
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/settings_profile_header.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/features/profile/user_status.dart';
import 'package:buzz/features/profile/user_status_provider.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../helpers/widget_helpers.dart';
void main() {
testWidgets('uses a bounded icon for an unresolved status shortcode', (
tester,
) async {
const missingShortcode = ':very_long_missing_custom_emoji:';
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(_FakeProfileNotifier.new),
presenceProvider.overrideWith(() => _FakePresenceNotifier('online')),
userStatusProvider.overrideWith(
() => _FakeUserStatusNotifier(
const UserStatus(
text: 'Focusing',
emoji: missingShortcode,
updatedAt: 1,
),
),
),
customEmojiListProvider.overrideWithValue(const []),
],
child: const SettingsProfileHeader(),
),
);
await tester.pumpAndSettle();
expect(find.byType(Hero), findsNothing);
final badge = find.byType(MaskedAvatarBadge);
expect(
find.descendant(of: badge, matching: find.text(missingShortcode)),
findsNothing,
);
expect(
find.descendant(of: badge, matching: find.byIcon(LucideIcons.smile)),
findsOneWidget,
);
});
testWidgets(
'keeps text-only status visible beside a changeable presence pill',
(tester) async {
final presenceNotifier = _FakePresenceNotifier('away');
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(_FakeProfileNotifier.new),
presenceProvider.overrideWith(() => presenceNotifier),
userStatusProvider.overrideWith(
() => _FakeUserStatusNotifier(
const UserStatus(text: 'Focusing', emoji: '', updatedAt: 1),
),
),
customEmojiListProvider.overrideWithValue(const []),
],
child: const SettingsProfileHeader(),
),
);
await tester.pumpAndSettle();
expect(find.text('Focusing'), findsOneWidget);
await tester.tap(find.text('Focusing'));
await tester.pumpAndSettle();
expect(find.text('Set a status'), findsOneWidget);
expect(
tester.widget<TextField>(find.byType(TextField)).controller?.text,
'Focusing',
);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('settings-presence-label')),
findsOneWidget,
);
expect(find.text('Away'), findsOneWidget);
expect(
tester
.widget<Text>(find.byKey(const ValueKey('settings-presence-label')))
.style
?.fontSize,
filterChipTextStyle.fontSize,
);
expect(
tester
.getSize(find.byKey(const ValueKey('settings-presence-target')))
.height,
48,
);
expect(
tester
.getSize(find.byKey(const ValueKey('settings-presence-pill')))
.height,
greaterThanOrEqualTo(31),
);
final presenceTarget = find.byKey(
const ValueKey('settings-presence-target'),
);
final targetRect = tester.getRect(presenceTarget);
await tester.tapAt(Offset(targetRect.center.dx, targetRect.bottom - 1));
await tester.pump();
final scale = tester.widget<ScaleTransition>(
find.byKey(const ValueKey('activity-popover-scale')),
);
expect(scale.alignment, Alignment.topCenter);
expect(
find.byKey(const ValueKey('settings-presence-popover')),
findsOneWidget,
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('settings-presence-online')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('settings-presence-away')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('settings-presence-offline')),
findsOneWidget,
);
await tester.tap(find.byKey(const ValueKey('settings-presence-offline')));
await tester.pumpAndSettle();
expect(presenceNotifier.selected, ['offline']);
},
);
}
class _FakeProfileNotifier extends ProfileNotifier {
@override
Future<UserProfile?> build() async =>
const UserProfile(pubkey: 'aabb', displayName: 'Test');
}
class _FakeUserStatusNotifier extends UserStatusNotifier {
_FakeUserStatusNotifier(this._status);
final UserStatus _status;
@override
Future<UserStatus?> build() async => _status;
}
class _FakePresenceNotifier extends PresenceNotifier {
_FakePresenceNotifier(this._presence);
final String _presence;
final List<String> selected = [];
@override
Future<String> build() async => _presence;
@override
Future<void> setPresence(String status) async {
selected.add(status);
}
}