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,377 @@
import 'dart:async';
import 'dart:convert';
import 'package:buzz/features/channels/channel_sort/channel_sort_manager.dart';
import 'package:buzz/features/channels/channel_sort/channel_sort_storage.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
void main() {
late SharedPreferences prefs;
late nostr.Keys keys;
late ChannelSortCrypto crypto;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
keys = nostr.Keys.generate();
crypto = ChannelSortCrypto(keys.nsec, keys.public);
});
NostrEvent event(
Map<String, String> groups,
int createdAt, {
String id = 'e',
}) => NostrEvent(
id: id,
pubkey: keys.public,
createdAt: createdAt,
kind: EventKind.readState,
tags: const [
['d', 'channel-sort'],
],
content: crypto.encrypt(jsonEncode({'version': 1, 'groups': groups})),
sig: 'sig',
);
ChannelSortManager manager(
_FakeRelaySession relay,
_RecordingSignedEventRelay signed, {
Duration retry = const Duration(milliseconds: 5),
}) => ChannelSortManager(
pubkey: keys.public,
relayUrl: 'wss://relay.example',
prefs: prefs,
crypto: crypto,
relaySession: relay,
signedEventRelay: signed,
remoteEnabled: true,
onChanged: () {},
startupRetryBaseDelay: retry,
publishDelay: const Duration(milliseconds: 5),
);
test(
'adopts desktop payload and defaults unspecified groups to alpha',
() async {
final relay = _FakeRelaySession()
..historyEvents = [
event({'channels': 'recent', 'section:abc': 'recent'}, 100),
];
final subject = manager(relay, _RecordingSignedEventRelay());
await subject.initialize();
expect(subject.sortModeFor('channels'), ChannelSortMode.recent);
expect(subject.sortModeFor('section:abc'), ChannelSortMode.recent);
expect(subject.sortModeFor('dms'), ChannelSortMode.alpha);
subject.dispose();
},
);
test('publishes local edit using desktop encrypted wire format', () async {
final relay = _FakeRelaySession();
final signed = _RecordingSignedEventRelay();
final subject = manager(relay, signed);
await subject.initialize();
subject.setSortModeFor('dms', ChannelSortMode.recent);
final submitted = await signed.submitted.future.timeout(
const Duration(seconds: 1),
);
expect(
submitted.tags.any((tag) => tag[0] == 'd' && tag[1] == 'channel-sort'),
isTrue,
);
final payload =
jsonDecode(crypto.decrypt(submitted.content)) as Map<String, dynamic>;
expect(payload, {
'version': 1,
'groups': {'dms': 'recent'},
});
subject.dispose();
});
test(
'failed publish preflight retries without submitting stale state',
() async {
final relay = _FakeRelaySession();
final signed = _RecordingSignedEventRelay();
final subject = manager(relay, signed);
await subject.initialize();
relay.fetchFailures = 1;
subject.setSortModeFor('dms', ChannelSortMode.recent);
await Future<void>.delayed(const Duration(milliseconds: 8));
expect(signed.submitCount, 0);
final submitted = await signed.submitted.future.timeout(
const Duration(seconds: 1),
);
expect(relay.fetchCount, greaterThanOrEqualTo(4));
final payload =
jsonDecode(crypto.decrypt(submitted.content)) as Map<String, dynamic>;
expect(payload['groups'], {'dms': 'recent'});
subject.dispose();
},
);
test(
'pending local edit survives manager rebuild and older relay state',
() async {
final firstRelay = _FakeRelaySession();
final first = manager(firstRelay, _RecordingSignedEventRelay());
await first.initialize();
first.setSortModeFor('channels', ChannelSortMode.recent);
first.dispose();
final secondRelay = _FakeRelaySession()
..historyEvents = [
event({'dms': 'recent'}, 100),
];
final signed = _RecordingSignedEventRelay();
final second = manager(secondRelay, signed);
await second.initialize();
expect(second.store.groups, {'channels': ChannelSortMode.recent});
final submitted = await signed.submitted.future.timeout(
const Duration(seconds: 1),
);
final payload =
jsonDecode(crypto.decrypt(submitted.content)) as Map<String, dynamic>;
expect(payload['groups'], {'channels': 'recent'});
second.dispose();
},
);
test('newer remote event cancels pending local whole-blob write', () async {
final relay = _FakeRelaySession();
final signed = _RecordingSignedEventRelay();
final subject = manager(relay, signed);
await subject.initialize();
subject.setSortModeFor('channels', ChannelSortMode.recent);
relay.emit(
event({
'dms': 'recent',
}, DateTime.now().millisecondsSinceEpoch ~/ 1000 + 1),
);
await Future<void>.delayed(const Duration(milliseconds: 30));
expect(subject.store.groups, {'dms': ChannelSortMode.recent});
expect(signed.submitted.isCompleted, isFalse);
subject.dispose();
});
test('same-second remote does not erase a pending local edit', () async {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
ChannelSortStorage(prefs).writeSyncState(
keys.public,
'wss://relay.example',
ChannelSortSyncState(updatedAt: now - 1, eventId: 'remote'),
);
final relay = _FakeRelaySession();
final signed = _RecordingSignedEventRelay();
final subject = manager(relay, signed);
await subject.initialize();
subject.setSortModeFor('channels', ChannelSortMode.recent);
relay.emit(event({'dms': 'recent'}, now, id: 'a'));
expect(subject.store.groups, {'channels': ChannelSortMode.recent});
await signed.submitted.future.timeout(const Duration(seconds: 1));
subject.dispose();
});
test('publishing does not advance the persisted remote cursor', () async {
final storage = ChannelSortStorage(prefs);
storage.writeSyncState(
keys.public,
'wss://relay.example',
const ChannelSortSyncState(updatedAt: 100, eventId: 'remote'),
);
final signed = _RecordingSignedEventRelay();
final subject = manager(_FakeRelaySession(), signed);
await subject.initialize();
subject.setSortModeFor('channels', ChannelSortMode.recent);
await signed.submitted.future.timeout(const Duration(seconds: 1));
await Future<void>.delayed(Duration.zero);
final syncState = storage.readSyncState(keys.public, 'wss://relay.example');
expect(syncState.updatedAt, 100);
expect(syncState.eventId, 'remote');
expect(syncState.hasPendingLocalChanges, isFalse);
expect(syncState.pendingUpdatedAt, 0);
subject.dispose();
});
test('legacy pending state resets its ambiguous remote cursor', () {
final storage = ChannelSortStorage(prefs);
// Simulate the pre-migration JSON, which had no pendingUpdatedAt field.
prefs.setString(
'${channelSortKey(keys.public, 'wss://relay.example')}:sync',
jsonEncode({
'updatedAt': 4102444800,
'eventId': 'local-publication',
'hasPendingLocalChanges': true,
}),
);
final syncState = storage.readSyncState(keys.public, 'wss://relay.example');
expect(syncState.updatedAt, 0);
expect(syncState.eventId, isEmpty);
expect(syncState.pendingUpdatedAt, 4102444800);
});
test('lower event ID wins when remote timestamps tie', () async {
final relay = _FakeRelaySession()
..historyEvents = [
event({'channels': 'recent'}, 200, id: 'f'),
];
final subject = manager(relay, _RecordingSignedEventRelay());
await subject.initialize();
relay.emit(event({'dms': 'recent'}, 200, id: 'a'));
expect(subject.store.groups, {'dms': ChannelSortMode.recent});
relay.emit(event({'starred': 'recent'}, 200, id: 'z'));
expect(subject.store.groups, {'dms': ChannelSortMode.recent});
subject.dispose();
});
test('adopts a newer relay replacement after publishing', () async {
final relay = _FakeRelaySession();
final signed = _RecordingSignedEventRelay();
final subject = manager(relay, signed);
await subject.initialize();
subject.setSortModeFor('channels', ChannelSortMode.recent);
final submitted = await signed.submitted.future.timeout(
const Duration(seconds: 1),
);
relay.emit(event({'dms': 'recent'}, submitted.createdAt! + 1, id: 'a'));
expect(subject.store.groups, {'dms': ChannelSortMode.recent});
subject.dispose();
});
test(
'future-dated remote event is ignored and cannot wedge publishing',
() async {
final relay = _FakeRelaySession()
..historyEvents = [
event({'channels': 'recent'}, 4102444800),
];
final signed = _RecordingSignedEventRelay();
final subject = manager(relay, signed);
await subject.initialize();
expect(subject.sortModeFor('channels'), ChannelSortMode.alpha);
subject.setSortModeFor('dms', ChannelSortMode.recent);
await signed.submitted.future.timeout(const Duration(seconds: 1));
subject.dispose();
},
);
test('retries failed startup and closes fetch-subscribe gap', () async {
final relay = _FakeRelaySession()..fetchFailures = 1;
final subject = manager(relay, _RecordingSignedEventRelay());
await subject.initialize();
relay.historyEvents = [
event({'starred': 'recent'}, 300),
];
await Future<void>.delayed(const Duration(milliseconds: 40));
expect(subject.sortModeFor('starred'), ChannelSortMode.recent);
expect(relay.fetchCount, greaterThanOrEqualTo(3));
subject.dispose();
});
test('setSortModeFor prunes deleted custom section keys', () async {
final subject = manager(_FakeRelaySession(), _RecordingSignedEventRelay());
await subject.initialize();
subject.setSortModeFor('section:dead', ChannelSortMode.recent);
subject.setSortModeFor(
'channels',
ChannelSortMode.recent,
liveSectionIds: ['live'],
);
expect(subject.store.groups.keys, ['channels']);
subject.dispose();
});
}
class _SubmittedEvent {
final String content;
final List<List<String>> tags;
final int? createdAt;
const _SubmittedEvent(this.content, this.tags, this.createdAt);
}
class _RecordingSignedEventRelay implements SignedEventRelay {
final submitted = Completer<_SubmittedEvent>();
int submitCount = 0;
@override
String? get pubkey => null;
@override
Future<NostrEvent> submit({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
void Function(NostrEvent event)? onSigned,
}) async {
submitCount++;
onSigned?.call(
const NostrEvent(
id: 'signed-event',
pubkey: '',
createdAt: 0,
kind: 0,
tags: [],
content: '',
sig: '',
),
);
if (!submitted.isCompleted) {
submitted.complete(_SubmittedEvent(content, tags, createdAt));
}
return const NostrEvent(
id: 'ack',
pubkey: '',
createdAt: 0,
kind: 0,
tags: [],
content: '',
sig: '',
);
}
}
class _FakeRelaySession extends RelaySessionNotifier {
List<NostrEvent> historyEvents = [];
int fetchFailures = 0;
int fetchCount = 0;
void Function(NostrEvent)? _listener;
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
fetchCount++;
if (fetchFailures > 0) {
fetchFailures--;
throw Exception('rate limited');
}
return historyEvents;
}
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async {
_listener = onEvent;
return () => _listener = null;
}
void emit(NostrEvent event) => _listener?.call(event);
}
@@ -0,0 +1,124 @@
import 'dart:convert';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_sort/channel_sort_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
group('ChannelSortStore JSON', () {
test('round-trips desktop wire format and drops invalid modes', () {
final store = ChannelSortStore(
groups: {
'channels': ChannelSortMode.recent,
'dms': ChannelSortMode.alpha,
},
);
expect(store.toJson()['groups'], {'channels': 'recent', 'dms': 'alpha'});
expect(ChannelSortStore.fromJson(store.toJson()).groups, store.groups);
expect(
ChannelSortStore.fromJson({
'version': 1,
'groups': {'channels': 'recent', 'starred': 'bogus'},
}).groups,
{'channels': ChannelSortMode.recent},
);
});
});
group('ChannelSortStorage', () {
test('normalizes relay scope and isolates communities', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final storage = ChannelSortStorage(prefs);
final store = ChannelSortStore(
groups: {'channels': ChannelSortMode.recent},
);
storage.write('pk', ' WSS://Relay.Example/ ', store);
expect(storage.read('pk', 'wss://relay.example').groups, store.groups);
expect(storage.read('pk', 'wss://other.example').groups, isEmpty);
});
test('migrates legacy unscoped cache into the first relay scope', () async {
SharedPreferences.setMockInitialValues({
legacyChannelSortKey('pk'): jsonEncode({
'version': 1,
'groups': {'dms': 'recent'},
}),
});
final prefs = await SharedPreferences.getInstance();
final storage = ChannelSortStorage(prefs);
expect(storage.read('pk', 'wss://one').groups, {
'dms': ChannelSortMode.recent,
});
expect(prefs.getString(channelSortKey('pk', 'wss://one')), isNotNull);
expect(prefs.getString(legacyChannelSortKey('pk')), isNull);
expect(storage.read('pk', 'wss://two').groups, isEmpty);
});
test('ignores corrupt and unsupported payloads', () async {
SharedPreferences.setMockInitialValues({
channelSortKey('pk', 'wss://one'): 'nope',
channelSortKey('pk', 'wss://two'): '{"version":2,"groups":{}}',
});
final prefs = await SharedPreferences.getInstance();
final storage = ChannelSortStorage(prefs);
expect(storage.read('pk', 'wss://one').groups, isEmpty);
expect(storage.read('pk', 'wss://two').groups, isEmpty);
});
});
test('prunes orphaned section modes but keeps fixed groups', () {
final store = ChannelSortStore(
groups: {
'channels': ChannelSortMode.recent,
'section:live': ChannelSortMode.recent,
'section:dead': ChannelSortMode.alpha,
},
);
expect(stripOrphanedSectionModes(store, ['live']).groups.keys, [
'channels',
'section:live',
]);
});
group('sortChannelsForList', () {
Channel channel(String id, String name, {DateTime? lastMessageAt}) =>
Channel(
id: id,
name: name,
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: 'pk',
createdAt: DateTime.utc(2026),
memberCount: 1,
lastMessageAt: lastMessageAt,
);
test('alpha matches desktop code-unit collation and id tie-break', () {
final sorted = sortChannelsForList([
channel('2', 'zeta'),
channel('3', 'Alpha'),
channel('1', 'alpha'),
channel('4', 'Éclair'),
], ChannelSortMode.alpha);
expect(sorted.map((c) => c.id), ['1', '3', '2', '4']);
});
test('recent puts newest first and quiet channels alpha last', () {
final now = DateTime.utc(2026, 7, 25);
final sorted = sortChannelsForList([
channel('a', 'quiet-z'),
channel(
'b',
'old',
lastMessageAt: now.subtract(const Duration(days: 2)),
),
channel('c', 'new', lastMessageAt: now),
channel('d', 'quiet-a'),
], ChannelSortMode.recent);
expect(sorted.map((c) => c.id), ['c', 'b', 'd', 'a']);
});
});
}