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,61 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:buzz/shared/auth/auth_provider.dart';
import 'package:buzz/shared/community/community.dart';
import 'package:buzz/shared/community/community_provider.dart';
import 'package:buzz/shared/community/community_storage.dart';
import '../community/community_storage_test.dart';
void main() {
test(
'removes an invalid saved community instead of authenticating',
() async {
final storage = CommunityStorage(secure: FakeSecureStorage());
final invalid = Community.create(
name: 'Invalid',
relayUrl: 'https://relay.example',
nsec: 'not-an-nsec',
);
await storage.save(invalid);
await storage.saveActiveId(invalid.id);
final container = ProviderContainer(
overrides: [communityStorageProvider.overrideWithValue(storage)],
);
addTearDown(container.dispose);
final auth = await container.read(authProvider.future);
expect(auth.status, AuthStatus.unauthenticated);
expect(await storage.loadAll(), isEmpty);
expect(await storage.loadActiveId(), isNull);
},
);
test('falls through to the next valid saved community', () async {
final storage = CommunityStorage(secure: FakeSecureStorage());
final invalid = Community.create(
name: 'Invalid',
relayUrl: 'https://invalid.example',
);
final valid = Community.create(
name: 'Valid',
relayUrl: 'https://valid.example',
nsec: nostr.Keys.generate().nsec,
);
await storage.save(invalid);
await storage.save(valid);
await storage.saveActiveId(invalid.id);
final container = ProviderContainer(
overrides: [communityStorageProvider.overrideWithValue(storage)],
);
addTearDown(container.dispose);
final auth = await container.read(authProvider.future);
expect(auth.status, AuthStatus.authenticated);
expect(auth.community?.id, valid.id);
expect(await storage.loadActiveId(), valid.id);
});
}
@@ -0,0 +1,93 @@
import 'package:buzz/shared/community/community_icon_provider.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
void main() {
test('fetches the NIP-11 icon over the relay HTTP URL', () async {
late http.Request capturedRequest;
final client = http_testing.MockClient((request) async {
capturedRequest = request;
return http.Response('{"icon":"data:image/png;base64,icon-bytes"}', 200);
});
final container = ProviderContainer(
overrides: [communityIconHttpClientProvider.overrideWithValue(client)],
);
addTearDown(container.dispose);
final icon = await container.read(
communityIconProvider('wss://relay.example.com').future,
);
expect(capturedRequest.url, Uri.parse('https://relay.example.com'));
expect(capturedRequest.headers['Accept'], 'application/nostr+json');
expect(icon, 'data:image/png;base64,icon-bytes');
});
test('returns null when the relay has no usable icon', () async {
final client = http_testing.MockClient(
(_) async => http.Response('{"icon":""}', 200),
);
final container = ProviderContainer(
overrides: [communityIconHttpClientProvider.overrideWithValue(client)],
);
addTearDown(container.dispose);
final icon = await container.read(
communityIconProvider('https://relay.example.com').future,
);
expect(icon, isNull);
});
test('returns null when relay information cannot be loaded', () async {
final client = http_testing.MockClient(
(_) async => http.Response('unavailable', 503),
);
final container = ProviderContainer(
overrides: [communityIconHttpClientProvider.overrideWithValue(client)],
);
addTearDown(container.dispose);
final icon = await container.read(
communityIconProvider('https://relay.example.com').future,
);
expect(icon, isNull);
});
test('refreshes a transient failure when explicitly invalidated', () async {
var requestCount = 0;
final client = http_testing.MockClient((_) async {
requestCount++;
if (requestCount == 1) {
return http.Response('unavailable', 503);
}
return http.Response(
'{"icon":"https://relay.example.com/icon.png"}',
200,
);
});
final container = ProviderContainer(
overrides: [communityIconHttpClientProvider.overrideWithValue(client)],
);
final provider = communityIconProvider('https://relay.example.com');
final subscription = container.listen(provider, (_, _) {});
addTearDown(() {
subscription.close();
container.dispose();
});
expect(await container.read(provider.future), isNull);
expect(requestCount, 1);
container.invalidate(provider);
expect(
await container.read(provider.future),
'https://relay.example.com/icon.png',
);
expect(requestCount, 2);
});
}
@@ -0,0 +1,152 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/shared/community/community.dart';
import 'package:buzz/shared/community/community_provider.dart';
import 'package:buzz/shared/community/community_storage.dart';
import 'community_storage_test.dart';
void main() {
late FakeSecureStorage fakeSecure;
late CommunityStorage communityStorage;
late ProviderContainer container;
setUp(() {
fakeSecure = FakeSecureStorage();
communityStorage = CommunityStorage(secure: fakeSecure);
});
tearDown(() => container.dispose());
ProviderContainer createContainer() {
return ProviderContainer(
overrides: [communityStorageProvider.overrideWithValue(communityStorage)],
);
}
group('CommunityListNotifier', () {
test('loads empty list initially', () async {
container = createContainer();
final communities = await container.read(communityListProvider.future);
expect(communities, isEmpty);
});
test('addCommunity adds to list', () async {
container = createContainer();
await container.read(communityListProvider.future);
final ws = Community.create(
name: 'Test',
relayUrl: 'https://test.example.com',
);
await container.read(communityListProvider.notifier).addCommunity(ws);
final communities = await container.read(communityListProvider.future);
expect(communities, hasLength(1));
expect(communities.first.name, 'Test');
});
test('removeCommunity removes from list', () async {
container = createContainer();
await container.read(communityListProvider.future);
final ws = Community.create(
name: 'Test',
relayUrl: 'https://test.example.com',
);
await container.read(communityListProvider.notifier).addCommunity(ws);
await container
.read(communityListProvider.notifier)
.removeCommunity(ws.id);
final communities = await container.read(communityListProvider.future);
expect(communities, isEmpty);
});
test('renameCommunity updates name', () async {
container = createContainer();
await container.read(communityListProvider.future);
final ws = Community.create(
name: 'Original',
relayUrl: 'https://test.example.com',
);
await container.read(communityListProvider.notifier).addCommunity(ws);
await container
.read(communityListProvider.notifier)
.renameCommunity(ws.id, 'Renamed');
final communities = await container.read(communityListProvider.future);
expect(communities.first.name, 'Renamed');
});
test('switchCommunity updates active ID', () async {
container = createContainer();
await container.read(communityListProvider.future);
final ws1 = Community.create(
name: 'One',
relayUrl: 'https://one.example.com',
);
final ws2 = Community.create(
name: 'Two',
relayUrl: 'https://two.example.com',
);
final notifier = container.read(communityListProvider.notifier);
await notifier.addCommunity(ws1);
await notifier.addCommunity(ws2);
await notifier.switchCommunity(ws2.id);
final activeId = await communityStorage.loadActiveId();
expect(activeId, ws2.id);
});
});
group('activeCommunityProvider', () {
test('returns null when no communities', () async {
container = createContainer();
final active = await container.read(activeCommunityProvider.future);
expect(active, isNull);
});
test('returns community matching active ID', () async {
container = createContainer();
await container.read(communityListProvider.future);
final ws = Community.create(
name: 'Test',
relayUrl: 'https://test.example.com',
);
final notifier = container.read(communityListProvider.notifier);
await notifier.addCommunity(ws);
await notifier.switchCommunity(ws.id);
final active = await container.read(activeCommunityProvider.future);
expect(active, isNotNull);
expect(active!.id, ws.id);
expect(active.name, 'Test');
});
test('falls back to first community if active ID is invalid', () async {
container = createContainer();
await container.read(communityListProvider.future);
final ws = Community.create(
name: 'Fallback',
relayUrl: 'https://test.example.com',
);
final notifier = container.read(communityListProvider.notifier);
await notifier.addCommunity(ws);
// Set an invalid active ID.
await communityStorage.saveActiveId('nonexistent-id');
// Re-read — should fall back.
container.invalidate(activeCommunityProvider);
final active = await container.read(activeCommunityProvider.future);
expect(active, isNotNull);
expect(active!.id, ws.id);
});
});
}
@@ -0,0 +1,237 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/community/community.dart';
import 'package:buzz/shared/community/community_storage.dart';
/// In-memory fake that extends Fake to satisfy all FlutterSecureStorage
/// interface methods, but implements the core read/write/delete with real
/// in-memory logic.
class FakeSecureStorage extends Fake implements FlutterSecureStorage {
final Map<String, String> _data = {};
@override
Future<String?> read({
required String key,
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async => _data[key];
@override
Future<void> write({
required String key,
required String? value,
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async {
if (value != null) {
_data[key] = value;
} else {
_data.remove(key);
}
}
@override
Future<void> delete({
required String key,
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async => _data.remove(key);
@override
Future<Map<String, String>> readAll({
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async => Map.from(_data);
@override
Future<void> deleteAll({
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async => _data.clear();
@override
Future<bool> containsKey({
required String key,
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async => _data.containsKey(key);
// Convenience for setting up test data.
String? operator [](String key) => _data[key];
void operator []=(String key, String value) => _data[key] = value;
}
void main() {
late FakeSecureStorage fakeSecure;
late CommunityStorage storage;
setUp(() {
fakeSecure = FakeSecureStorage();
storage = CommunityStorage(secure: fakeSecure);
});
group('CommunityStorage', () {
test('loadAll returns empty list when no data', () async {
final result = await storage.loadAll();
expect(result, isEmpty);
});
test('save and loadAll round-trips a community', () async {
final ws = Community.create(
name: 'Test',
relayUrl: 'https://relay.example.com',
pubkey: 'abc123',
);
await storage.save(ws);
final loaded = await storage.loadAll();
expect(loaded, hasLength(1));
expect(loaded.first.id, ws.id);
expect(loaded.first.name, 'Test');
expect(loaded.first.relayUrl, 'https://relay.example.com');
expect(loaded.first.pubkey, 'abc123');
});
test('save updates existing community with same id', () async {
final ws = Community.create(
name: 'Original',
relayUrl: 'https://relay.example.com',
);
await storage.save(ws);
await storage.save(ws.copyWith(name: 'Updated'));
final loaded = await storage.loadAll();
expect(loaded, hasLength(1));
expect(loaded.first.name, 'Updated');
});
test('remove deletes a community', () async {
final ws1 = Community.create(
name: 'One',
relayUrl: 'https://one.example.com',
);
final ws2 = Community.create(
name: 'Two',
relayUrl: 'https://two.example.com',
);
await storage.save(ws1);
await storage.save(ws2);
await storage.remove(ws1.id);
final loaded = await storage.loadAll();
expect(loaded, hasLength(1));
expect(loaded.first.id, ws2.id);
});
test('active community ID persists', () async {
await storage.saveActiveId('ws-123');
final id = await storage.loadActiveId();
expect(id, 'ws-123');
});
test('clearActiveId removes active ID', () async {
await storage.saveActiveId('ws-123');
await storage.clearActiveId();
final id = await storage.loadActiveId();
expect(id, isNull);
});
group('migration', () {
test('migrates saved workspaces to communities', () async {
final legacy = Community.create(
name: 'Legacy',
relayUrl: 'https://legacy.example.com',
);
fakeSecure['buzz_workspaces'] = jsonEncode([legacy.toJson()]);
fakeSecure['buzz_active_workspace_id'] = legacy.id;
final loaded = await storage.loadAll();
expect(loaded.single.id, legacy.id);
expect(await storage.loadActiveId(), legacy.id);
expect(fakeSecure['buzz_communities'], isNotNull);
expect(fakeSecure['buzz_workspaces'], isNull);
expect(fakeSecure['buzz_active_workspace_id'], isNull);
});
test('migrates legacy keys to community on first load', () async {
fakeSecure['buzz_relay_url'] = 'https://legacy.example.com';
fakeSecure['buzz_token'] = 'legacy_token';
fakeSecure['buzz_pubkey'] = 'legacy_pub';
fakeSecure['buzz_nsec'] = 'legacy_nsec';
final loaded = await storage.loadAll();
expect(loaded, hasLength(1));
expect(loaded.first.relayUrl, 'https://legacy.example.com');
expect(loaded.first.pubkey, 'legacy_pub');
expect(loaded.first.nsec, 'legacy_nsec');
expect(loaded.first.name, isNotEmpty);
// Legacy keys should be deleted.
expect(fakeSecure['buzz_relay_url'], isNull);
expect(fakeSecure['buzz_token'], isNull);
expect(fakeSecure['buzz_pubkey'], isNull);
expect(fakeSecure['buzz_nsec'], isNull);
// Active ID should be set.
final activeId = await storage.loadActiveId();
expect(activeId, loaded.first.id);
});
test('does not migrate when no legacy keys exist', () async {
final loaded = await storage.loadAll();
expect(loaded, isEmpty);
});
test('does not re-migrate after first load', () async {
fakeSecure['buzz_relay_url'] = 'https://legacy.example.com';
fakeSecure['buzz_token'] = 'legacy_token';
final first = await storage.loadAll();
expect(first, hasLength(1));
final second = await storage.loadAll();
expect(second, hasLength(1));
expect(second.first.id, first.first.id);
});
test('migration generates name from localhost URL', () async {
fakeSecure['buzz_relay_url'] = 'http://localhost:3000';
fakeSecure['buzz_token'] = 'tok';
final loaded = await storage.loadAll();
expect(loaded.first.name, 'Local Dev');
});
});
});
}
@@ -0,0 +1,21 @@
import 'package:buzz/shared/crypto/nip44.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('decrypts a desktop nostr-rs NIP-44 v2 self-encrypted payload', () {
const privateKey =
'0000000000000000000000000000000000000000000000000000000000000001';
const publicKey =
'79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798';
const desktopCiphertext =
'Au0C/BZ3gT83RnPFiPYGr70BuEyKDlZrk1nEJUDZbkoNgpSjE7JUKRb3VRbegcQYUvNT2Qayf3DkfuSb1M6l70IDpsQ25y8xwDA+uEreyRxDdZ5tQF+C9iB3Qr0vinFQpbR9f0SIvUahwAzyHBMdZ1butlCHi9aqv0C1/w1MWMWeoGaPm4XtkhJSPawCGMuFVw1Z8r64bxMSI6EThc4HtR9p4Q==';
expect(
nip44Decrypt(
getConversationKey(privateKey, publicKey),
desktopCiphertext,
),
'{"version":1,"theme":"catppuccin-latte","accent":"#f97316","followSystem":false}',
);
});
}
@@ -0,0 +1,79 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:pointycastle/digests/sha256.dart';
import 'package:buzz/shared/crypto/nip_oa.dart';
String _sha256Hex(String input) {
final digest = SHA256Digest().process(Uint8List.fromList(utf8.encode(input)));
return digest.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
List<String> authTag(
nostr.Keys owner,
String agentPubkey, {
String conditions = '',
}) {
final message = _sha256Hex('nostr:agent-auth:$agentPubkey:$conditions');
final sig = nostr.Schnorr.sign(secretKey: owner.secret, message: message);
return ['auth', owner.public, conditions, sig];
}
void main() {
final owner = nostr.Keys.generate();
final agent = nostr.Keys.generate();
test('returns the owner pubkey for a valid auth tag', () {
final tag = authTag(owner, agent.public);
expect(
verifiedOaOwnerPubkey([tag], agent.public),
owner.public.toLowerCase(),
);
});
test('accepts valid conditions strings', () {
final tag = authTag(owner, agent.public, conditions: 'kind=0');
expect(
verifiedOaOwnerPubkey([tag], agent.public),
owner.public.toLowerCase(),
);
});
test('rejects a signature over a different agent pubkey', () {
final otherAgent = nostr.Keys.generate();
final tag = authTag(owner, otherAgent.public);
expect(verifiedOaOwnerPubkey([tag], agent.public), isNull);
});
test('rejects a tampered signature', () {
final tag = authTag(owner, agent.public);
final tampered = [...tag];
tampered[3] = tampered[3].replaceRange(
0,
1,
tampered[3][0] == '0' ? '1' : '0',
);
expect(verifiedOaOwnerPubkey([tampered], agent.public), isNull);
});
test('rejects self-attestation', () {
final tag = authTag(agent, agent.public);
expect(verifiedOaOwnerPubkey([tag], agent.public), isNull);
});
test('rejects malformed conditions', () {
final tag = authTag(owner, agent.public, conditions: 'kind=abc');
expect(verifiedOaOwnerPubkey([tag], agent.public), isNull);
});
test('ignores unrelated tags', () {
expect(
verifiedOaOwnerPubkey([
['p', owner.public],
], agent.public),
isNull,
);
});
}
@@ -0,0 +1,165 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
import 'package:buzz/shared/relay/nostr_models.dart';
NostrEvent _event(
String pubkey,
List<List<String>> emojiTags, {
int createdAt = 0,
}) {
return NostrEvent(
id: 'id-$pubkey',
pubkey: pubkey,
createdAt: createdAt,
kind: kindEmojiSet,
tags: [
['d', customEmojiSetDTag],
...emojiTags,
],
content: '',
sig: '',
);
}
void main() {
group('normalizeShortcode', () {
test('strips colons and lowercases', () {
expect(normalizeShortcode(':PartyParrot:'), 'partyparrot');
expect(normalizeShortcode(' meow '), 'meow');
expect(normalizeShortcode('a_b-c1'), 'a_b-c1');
});
test('rejects invalid chars and empty', () {
expect(normalizeShortcode(':: ::'), isNull);
expect(normalizeShortcode('has space'), isNull);
expect(normalizeShortcode('emoji!'), isNull);
expect(normalizeShortcode(''), isNull);
});
});
group('customEmojiFromTags', () {
test('parses valid emoji tags, normalizing shortcodes', () {
final result = customEmojiFromTags([
['emoji', 'Meow', 'https://a/meow.png'],
['p', 'someone'],
['emoji', ':Wave:', 'https://a/wave.png'],
]);
expect(result, [
const CustomEmoji(shortcode: 'meow', url: 'https://a/meow.png'),
const CustomEmoji(shortcode: 'wave', url: 'https://a/wave.png'),
]);
});
test('skips malformed and dup-within-event (first wins)', () {
final result = customEmojiFromTags([
['emoji', 'meow'], // missing url
['emoji', '', 'https://a/x.png'], // empty shortcode
['emoji', 'meow', 'https://a/meow1.png'],
['emoji', 'meow', 'https://a/meow2.png'], // dup
]);
expect(result, [
const CustomEmoji(shortcode: 'meow', url: 'https://a/meow1.png'),
]);
});
});
group('unionCustomEmoji', () {
test(
'collapses to one per shortcode; same-time tie-breaks to smaller URL',
() {
final palette = unionCustomEmoji([
_event('alice', [
['emoji', 'meow', 'https://z/meow.png'],
['emoji', 'wave', 'https://a/wave.png'],
]),
_event('bob', [
['emoji', 'meow', 'https://a/meow.png'], // tie → smaller URL wins
]),
]);
expect(palette, [
const CustomEmoji(shortcode: 'meow', url: 'https://a/meow.png'),
const CustomEmoji(shortcode: 'wave', url: 'https://a/wave.png'),
]);
},
);
test('most recently published set wins, regardless of URL order', () {
final older = _event('alice', [
['emoji', 'x', 'https://a/x.png'],
], createdAt: 100);
final newer = _event('bob', [
['emoji', 'x', 'https://z/x.png'],
], createdAt: 200);
const expected = [CustomEmoji(shortcode: 'x', url: 'https://z/x.png')];
expect(unionCustomEmoji([older, newer]), expected);
expect(unionCustomEmoji([newer, older]), expected);
});
test('deterministic regardless of event order', () {
final a = _event('alice', [
['emoji', 'x', 'https://z/x.png'],
]);
final b = _event('bob', [
['emoji', 'x', 'https://a/x.png'],
]);
expect(unionCustomEmoji([a, b]), unionCustomEmoji([b, a]));
});
test('empty input → empty palette', () {
expect(unionCustomEmoji([]), isEmpty);
});
});
group('buildCustomEmojiTags', () {
final palette = [
const CustomEmoji(shortcode: 'meow', url: 'https://a/meow.png'),
const CustomEmoji(shortcode: 'wave', url: 'https://a/wave.png'),
];
test(
'emits one tag per distinct known shortcode, first-appearance order',
() {
final tags = buildCustomEmojiTags(
'hi :wave: and :meow: and :wave:',
palette,
);
expect(tags, [
['emoji', 'wave', 'https://a/wave.png'],
['emoji', 'meow', 'https://a/meow.png'],
]);
},
);
test('case-insensitive match, canonical lowercase emitted', () {
final tags = buildCustomEmojiTags(':MEOW:', palette);
expect(tags, [
['emoji', 'meow', 'https://a/meow.png'],
]);
});
test('ignores unknown shortcodes', () {
expect(buildCustomEmojiTags(':unknown:', palette), isEmpty);
});
test('empty palette → no tags', () {
expect(buildCustomEmojiTags(':meow:', const []), isEmpty);
});
});
group('reactionEmojiUrl', () {
final palette = [
const CustomEmoji(shortcode: 'meow', url: 'https://a/meow.png'),
];
test('resolves a known custom-emoji reaction', () {
expect(reactionEmojiUrl(':meow:', palette), 'https://a/meow.png');
expect(reactionEmojiUrl(':MEOW:', palette), 'https://a/meow.png');
});
test('returns null for unicode / unknown / no palette', () {
expect(reactionEmojiUrl('👍', palette), isNull);
expect(reactionEmojiUrl(':unknown:', palette), isNull);
expect(reactionEmojiUrl(':meow:', null), isNull);
});
});
}
@@ -0,0 +1,295 @@
import 'package:buzz/shared/deeplink/deep_link.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
_inviteTests();
_buildMessageLinkTests();
group('parseMessageDeepLink', () {
test('parses channel and id', () {
final link = parseMessageDeepLink(
Uri.parse('buzz://message?channel=d14cd131&id=abc123'),
);
expect(
link,
const MessageDeepLink(channelId: 'd14cd131', messageId: 'abc123'),
);
});
test('parses optional thread param', () {
final link = parseMessageDeepLink(
Uri.parse('buzz://message?channel=d14cd131&id=abc123&thread=root99'),
);
expect(link?.threadRootId, 'root99');
});
test('treats empty thread as absent', () {
final link = parseMessageDeepLink(
Uri.parse('buzz://message?channel=d14cd131&id=abc123&thread='),
);
expect(link, isNotNull);
expect(link?.threadRootId, isNull);
});
test('rejects missing channel', () {
expect(parseMessageDeepLink(Uri.parse('buzz://message?id=abc')), isNull);
});
test('rejects empty channel', () {
expect(
parseMessageDeepLink(Uri.parse('buzz://message?channel=&id=abc')),
isNull,
);
});
test('rejects missing id', () {
expect(
parseMessageDeepLink(Uri.parse('buzz://message?channel=d14cd131')),
isNull,
);
});
test('rejects non-buzz scheme', () {
expect(
parseMessageDeepLink(Uri.parse('https://message?channel=a&id=b')),
isNull,
);
});
test('rejects non-message host (connect is desktop-only)', () {
expect(
parseMessageDeepLink(Uri.parse('buzz://connect?relay=wss://x')),
isNull,
);
});
});
}
void _inviteTests() {
group('parseInviteDeepLink', () {
test('parses canonical HTTPS invite URL', () {
final link = parseInviteDeepLink(
Uri.parse('https://relay.example.com/invite/abc123'),
);
expect(
link,
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'abc123',
),
);
});
test('parses HTTP invite URL for local/dev relays', () {
final link = parseInviteDeepLink(
Uri.parse('http://localhost:3000/invite/dev-code'),
);
expect(
link,
const InviteDeepLink(relayUrl: 'ws://localhost:3000', code: 'dev-code'),
);
});
test('parses buzz join handoff link', () {
final link = parseInviteDeepLink(
Uri.parse(
'buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=abc123',
),
);
expect(
link,
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'abc123',
),
);
});
test('normalizes trailing slash in buzz join handoff', () {
final link = parseInviteDeepLink(
Uri.parse(
'buzz://join?relay=wss%3A%2F%2Frelay.example.com%2F&code=abc123',
),
);
expect(link?.relayUrl, 'wss://relay.example.com');
});
test('rejects plaintext public buzz join handoff', () {
final relay = Uri.encodeQueryComponent('ws://relay.example.com');
expect(
parseInviteDeepLink(Uri.parse('buzz://join?relay=$relay&code=abc')),
isNull,
);
});
test('preserves policy receipt in buzz join handoff', () {
final link = parseInviteDeepLink(
Uri.parse(
'buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=abc123&policy_receipt=receipt.value',
),
);
expect(
link,
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'abc123',
policyReceipt: 'receipt.value',
),
);
});
test('rejects non-invite HTTPS paths', () {
expect(
parseInviteDeepLink(Uri.parse('https://relay.example.com/api/invites')),
isNull,
);
expect(
parseInviteDeepLink(Uri.parse('https://relay.example.com/invite/')),
isNull,
);
expect(
parseInviteDeepLink(Uri.parse('https://relay.example.com/invite/a/b')),
isNull,
);
});
test('rejects credentials and fragments', () {
expect(
parseInviteDeepLink(
Uri.parse('https://user:pass@relay.example.com/invite/abc'),
),
isNull,
);
expect(
parseInviteDeepLink(
Uri.parse('https://relay.example.com/invite/abc#x'),
),
isNull,
);
expect(
parseInviteDeepLink(
Uri.parse(
'buzz://join?relay=wss%3A%2F%2Fuser%3Apass%40relay.example.com&code=abc',
),
),
isNull,
);
});
test('rejects buzz join without websocket relay or code', () {
expect(
parseInviteDeepLink(
Uri.parse('buzz://join?relay=https://relay.example.com&code=abc'),
),
isNull,
);
expect(
parseInviteDeepLink(
Uri.parse('buzz://join?relay=wss://relay.example.com'),
),
isNull,
);
expect(
parseInviteDeepLink(Uri.parse('buzz://connect?relay=wss://x')),
isNull,
);
});
test('rejects non-public invite relay destinations', () {
for (final url in [
'https://127.0.0.1/invite/abc',
'https://169.254.169.254/invite/abc',
'https://192.168.1.1/invite/abc',
'https://[::1]/invite/abc',
'https://[::ffff:127.0.0.1]/invite/abc',
]) {
expect(parseInviteDeepLink(Uri.parse(url)), isNull, reason: url);
}
});
test('rejects buzz join with dangerous relay schemes', () {
// The `relay=` param is an allowlist — only `ws` / `wss` are safe to
// hand to a Nostr relay session. Anything else must be dropped by the
// parser so a hostile QR / share link can't smuggle a browser scheme
// (`javascript:`, `data:`), a local resource (`file:`), or an
// unrelated transport (`ftp:`, `chrome:`) into the join flow.
for (final hostile in [
'javascript:alert(1)',
'data:text/html,evil',
'file:///etc/passwd',
'ftp://relay.example.com',
'chrome://settings',
'about:blank',
'ssh://relay.example.com',
]) {
final encoded = Uri.encodeQueryComponent(hostile);
expect(
parseInviteDeepLink(Uri.parse('buzz://join?relay=$encoded&code=abc')),
isNull,
reason: 'must reject relay scheme in $hostile',
);
}
});
});
}
void _buildMessageLinkTests() {
group('buildMessageLink', () {
test('builds channel + id link', () {
expect(
buildMessageLink(channelId: 'd14cd131', messageId: 'abc123'),
'buzz://message?channel=d14cd131&id=abc123',
);
});
test('includes thread root when present', () {
expect(
buildMessageLink(
channelId: 'd14cd131',
messageId: 'abc123',
threadRootId: 'root99',
),
'buzz://message?channel=d14cd131&id=abc123&thread=root99',
);
});
test('treats empty thread root as absent', () {
expect(
buildMessageLink(
channelId: 'd14cd131',
messageId: 'abc123',
threadRootId: '',
),
'buzz://message?channel=d14cd131&id=abc123',
);
});
test('round-trips through parseMessageDeepLink', () {
final url = buildMessageLink(
channelId: 'chan-1',
messageId: 'msg-1',
threadRootId: 'root-1',
);
final parsed = parseMessageDeepLink(Uri.parse(url));
expect(
parsed,
const MessageDeepLink(
channelId: 'chan-1',
messageId: 'msg-1',
threadRootId: 'root-1',
),
);
});
test('throws on empty channel or id', () {
expect(
() => buildMessageLink(channelId: '', messageId: 'abc'),
throwsArgumentError,
);
expect(
() => buildMessageLink(channelId: 'chan', messageId: ''),
throwsArgumentError,
);
});
});
}
@@ -0,0 +1,227 @@
import 'package:buzz/shared/emoji/emoji_burst.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
const _fire = '\u{1F525}';
/// Run the controller forward until it says it is done, capped so a physics
/// regression fails the test instead of hanging it.
int _runToCompletion(EmojiBurstController controller, {int maxSteps = 600}) {
var steps = 0;
while (controller.hasParticles && steps < maxSteps) {
controller.step();
steps += 1;
}
return steps;
}
void main() {
group('EmojiBurstController', () {
test('a burst spawns particles and eventually clears itself', () {
final controller = EmojiBurstController();
addTearDown(controller.dispose);
expect(controller.hasParticles, isFalse);
controller.burst(_fire, const Offset(100, 200));
expect(controller.hasParticles, isTrue);
final steps = _runToCompletion(controller);
expect(controller.hasParticles, isFalse);
// Desktop's particles live 108 frames and fade over the last 24%.
expect(steps, lessThanOrEqualTo(108));
expect(steps, greaterThan(60));
});
test('ignores a blank emoji', () {
final controller = EmojiBurstController();
addTearDown(controller.dispose);
controller.burst(' ', Offset.zero);
expect(controller.hasParticles, isFalse);
});
test('notifies on spawn and on every step', () {
final controller = EmojiBurstController();
addTearDown(controller.dispose);
var notifications = 0;
controller.addListener(() => notifications += 1);
controller.burst(_fire, Offset.zero);
expect(notifications, 1);
controller.step();
expect(notifications, 2);
});
test('calls onSpawn so the overlay can start its ticker', () {
final controller = EmojiBurstController();
addTearDown(controller.dispose);
var spawns = 0;
controller.onSpawn = () => spawns += 1;
controller.burst(_fire, Offset.zero);
controller.burst(_fire, Offset.zero);
expect(spawns, 2);
});
test('caps the active particle count', () {
final controller = EmojiBurstController();
addTearDown(controller.dispose);
// Far more bursts than the cap allows, with no steps in between so
// nothing expires.
for (var i = 0; i < 200; i += 1) {
controller.burst(_fire, Offset.zero);
}
// Still bounded, and still animating rather than wedged.
expect(controller.hasParticles, isTrue);
_runToCompletion(controller);
expect(controller.hasParticles, isFalse);
});
test('clear drops everything immediately', () {
final controller = EmojiBurstController();
addTearDown(controller.dispose);
controller.burst(_fire, Offset.zero);
controller.clear();
expect(controller.hasParticles, isFalse);
});
});
group('PendingReactionBurstNotifier', () {
ProviderContainer container() {
final result = ProviderContainer();
addTearDown(result.dispose);
return result;
}
test('arms only positive emoji', () {
final ref = container();
final notifier = ref.read(pendingReactionBurstProvider.notifier);
notifier.arm('msg-1', '\u{1F44E}'); // 👎
expect(ref.read(pendingReactionBurstProvider), isNull);
notifier.arm('msg-1', _fire);
expect(
ref.read(pendingReactionBurstProvider),
const PendingReactionBurst(messageId: 'msg-1', emoji: _fire),
);
});
test('claim succeeds once, so one pill bursts when a message is on screen '
'twice', () {
final ref = container();
final notifier = ref.read(pendingReactionBurstProvider.notifier);
const target = PendingReactionBurst(messageId: 'msg-1', emoji: _fire);
notifier.arm('msg-1', _fire);
expect(notifier.claim(target), isTrue);
expect(notifier.claim(target), isFalse);
expect(ref.read(pendingReactionBurstProvider), isNull);
});
test('a claim for a different message or emoji does not consume it', () {
final ref = container();
final notifier = ref.read(pendingReactionBurstProvider.notifier);
notifier.arm('msg-1', _fire);
expect(
notifier.claim(
const PendingReactionBurst(messageId: 'msg-2', emoji: _fire),
),
isFalse,
);
expect(
notifier.claim(
const PendingReactionBurst(messageId: 'msg-1', emoji: '\u{1F389}'),
),
isFalse,
);
expect(ref.read(pendingReactionBurstProvider), isNotNull);
});
test('a newer arm replaces the pending one', () {
final ref = container();
final notifier = ref.read(pendingReactionBurstProvider.notifier);
notifier.arm('msg-1', _fire);
notifier.arm('msg-2', '\u{1F389}');
expect(
ref.read(pendingReactionBurstProvider),
const PendingReactionBurst(messageId: 'msg-2', emoji: '\u{1F389}'),
);
});
});
group('EmojiBurstOverlay', () {
testWidgets('paints its child and drains the burst', (tester) async {
late EmojiBurstController controller;
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
home: Consumer(
builder: (context, ref, _) {
controller = ref.watch(emojiBurstControllerProvider);
return const EmojiBurstOverlay(child: Text('body'));
},
),
),
),
);
expect(find.text('body'), findsOneWidget);
controller.burst(_fire, const Offset(120, 240));
expect(controller.hasParticles, isTrue);
// Physics advances a bounded number of fixed steps per frame (no
// catch-up spiral), so drain it a frame at a time — 130 frames is past
// the 108-frame particle life.
await tester.pump();
for (var frame = 0; frame < 130; frame += 1) {
await tester.pump(const Duration(milliseconds: 16));
}
expect(controller.hasParticles, isFalse);
});
testWidgets('the overlay never takes a tap from the app below', (
tester,
) async {
var taps = 0;
late EmojiBurstController controller;
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
home: Consumer(
builder: (context, ref, _) {
controller = ref.watch(emojiBurstControllerProvider);
return EmojiBurstOverlay(
child: Center(
child: GestureDetector(
onTap: () => taps += 1,
child: const Text('tap me'),
),
),
);
},
),
),
),
);
controller.burst(_fire, const Offset(200, 400));
await tester.pump();
await tester.tap(find.text('tap me'));
expect(taps, 1);
});
});
}
@@ -0,0 +1,81 @@
import 'dart:convert';
import 'dart:io';
import 'package:buzz/shared/emoji/emoji_data.dart';
import 'package:buzz/shared/emoji/emoji_data_provider.dart';
import 'package:flutter_test/flutter_test.dart';
/// Reads the committed asset straight off disk rather than through
/// `rootBundle`, so this test guards the generated file itself — if
/// `just mobile-emoji-data` ever emits a different shape, this fails.
EmojiDataset _loadFromDisk() {
final source = File(emojiDataAssetPath).readAsStringSync();
return EmojiDataset.fromJson(jsonDecode(source) as Map<String, dynamic>);
}
void main() {
late EmojiDataset dataset;
setUpAll(() {
dataset = _loadFromDisk();
});
test('parses every emoji-mart category in order', () {
expect(dataset.categories.map((category) => category.id), [
'people',
'nature',
'foods',
'activity',
'places',
'objects',
'symbols',
'flags',
]);
});
test('carries the full standard set, not a hand-picked subset', () {
// The hardcoded list this replaced had ~140 glyphs; emoji-mart ships ~1.9k.
expect(dataset.all.length, greaterThan(1800));
expect(
dataset.all.length,
dataset.categories.fold<int>(
0,
(total, category) => total + category.emoji.length,
),
);
});
test('every entry has an id, a glyph, and a category', () {
for (final entry in dataset.all) {
expect(entry.id, isNotEmpty);
expect(entry.native, isNotEmpty);
expect(entry.categoryId, isNotEmpty);
}
});
test('resolves glyphs back to desktop-identical shortcodes', () {
expect(dataset.nativeToShortcode['💯'], ':100:');
expect(dataset.nativeToShortcode['🔥'], ':fire:');
expect(dataset.nativeToShortcode['☝️'], ':point_up:');
expect(dataset.nativeToShortcode['👍🏽'], ':+1:');
expect(
dataset.all
.where((entry) => entry.id == '+1')
.map((entry) => entry.native),
containsAll(['👍', '👍🏻', '👍🏼', '👍🏽', '👍🏾', '👍🏿']),
);
});
test('displayName mirrors desktop emojiDisplayName', () {
expect(dataset.displayName('🎉'), ':tada:');
// Custom emoji already arrive as a shortcode and pass through untouched.
expect(dataset.displayName(':party_parrot:'), ':party_parrot:');
// Unknown glyphs fall back to themselves rather than a bogus name.
expect(dataset.displayName('\u{1FAF6}\u{200D}\u{2764}'), isNotEmpty);
});
test('empty dataset degrades safely', () {
expect(EmojiDataset.empty.isEmpty, isTrue);
expect(EmojiDataset.empty.displayName('🔥'), '🔥');
});
}
@@ -0,0 +1,81 @@
import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
import 'package:buzz/shared/emoji/emoji_only.dart';
import 'package:flutter_test/flutter_test.dart';
const _customEmoji = [
CustomEmoji(shortcode: 'buzz', url: 'https://example.test/buzz.png'),
];
/// Keycaps carry no `Extended_Pictographic` code point of their own, so they
/// only pass via the dataset's glyph set — the one case that needs it.
const _keycapOne = '1\u{FE0F}\u{20E3}';
bool emojiOnly(
String content, {
Set<String> nativeEmoji = const {},
List<CustomEmoji> customEmoji = const [],
}) => isEmojiOnlyMessage(
content,
nativeEmoji: nativeEmoji,
customEmoji: customEmoji,
);
void main() {
group('isEmojiOnlyMessage', () {
test('accepts emoji, alone and in runs', () {
// Mirrors the cases in desktop's emojiOnly.test.mjs.
expect(emojiOnly('\u{1F600}'), isTrue);
expect(
emojiOnly('\u{1F600} \u{1F44D}\u{1F3FD}\n\u{2764}\u{FE0F}'),
isTrue,
);
expect(emojiOnly('\u{1F3F3}\u{FE0F}\u{200D}\u{1F308}'), isTrue);
expect(
emojiOnly(
'\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}',
),
isTrue,
);
});
test('rejects anything with text in it', () {
expect(emojiOnly('hi \u{1F600}'), isFalse);
expect(emojiOnly('\u{1F600}!'), isFalse);
expect(emojiOnly('nice'), isFalse);
});
test('rejects empty and whitespace-only bodies', () {
expect(emojiOnly(''), isFalse);
expect(emojiOnly(' \n '), isFalse);
});
test('counts a known custom shortcode as emoji', () {
expect(emojiOnly(':buzz:', customEmoji: _customEmoji), isTrue);
expect(emojiOnly(':buzz: \u{1F600}', customEmoji: _customEmoji), isTrue);
expect(emojiOnly('hi :buzz:', customEmoji: _customEmoji), isFalse);
});
test('an unknown shortcode is just text', () {
// Desktop behaves the same: without the palette it renders literally, so
// it must not be scaled up as if it were a glyph.
expect(emojiOnly(':buzz:'), isFalse);
expect(emojiOnly(':nope:', customEmoji: _customEmoji), isFalse);
expect(emojiOnly(':', customEmoji: _customEmoji), isFalse);
expect(emojiOnly('::', customEmoji: _customEmoji), isFalse);
});
test('keycaps need the dataset glyph set', () {
expect(emojiOnly(_keycapOne), isFalse);
expect(emojiOnly(_keycapOne, nativeEmoji: {_keycapOne}), isTrue);
});
});
group('emoji-only sizing', () {
test('matches desktops step up from body text', () {
// Desktop goes text-sm (14px) to text-4xl; mobile body is the same 14px.
expect(kEmojiOnlyFontSize, 36.0);
// Desktop sizes inline custom emoji at 1.45em of the surrounding text.
expect(kEmojiOnlyCustomEmojiSize, closeTo(36.0 * 1.45, 0.001));
});
});
}
@@ -0,0 +1,121 @@
import 'package:buzz/shared/emoji/emoji_data.dart';
import 'package:buzz/shared/emoji/emoji_search.dart';
import 'package:flutter_test/flutter_test.dart';
EmojiEntry _entry(
String id, {
String? name,
List<String> keywords = const [],
String native = '*',
}) {
return EmojiEntry(
id: id,
name: name ?? id,
keywords: keywords,
native: native,
categoryId: 'people',
);
}
void main() {
group('collapseSeparators', () {
test('lowercases and drops shortcode punctuation', () {
expect(collapseSeparators(':Point_Up:'), 'pointup');
expect(collapseSeparators('white-check-mark'), 'whitecheckmark');
expect(collapseSeparators(' thumbs up '), 'thumbsup');
});
});
group('scoreShortcodeMatch', () {
test('ranks exact above prefix above substring above subsequence', () {
final exact = scoreShortcodeMatch('point_up', 'point_up')!;
final prefix = scoreShortcodeMatch('point', 'point_up')!;
final substring = scoreShortcodeMatch('up', 'point_up')!;
final subsequence = scoreShortcodeMatch('pntup', 'point_up')!;
expect(exact.tier, lessThan(prefix.tier));
expect(prefix.tier, lessThan(substring.tier));
expect(substring.tier, lessThan(subsequence.tier));
});
test('crosses the separator emoji-mart cannot', () {
expect(scoreShortcodeMatch('pointup', 'point_up'), isNotNull);
expect(scoreShortcodeMatch('pointup', 'point_up')!.tier, 0);
});
test('returns null for an empty query or no match', () {
expect(scoreShortcodeMatch('', 'point_up'), isNull);
expect(scoreShortcodeMatch(' ', 'point_up'), isNull);
expect(scoreShortcodeMatch('zzzz', 'point_up'), isNull);
});
});
group('searchEmoji', () {
final entries = [
_entry(
'point_up',
name: 'Index Pointing Up',
keywords: ['point', 'hand'],
),
_entry('fire', name: 'Fire', keywords: ['flame', 'hot', 'lit']),
_entry(
'fire_engine',
name: 'Fire Engine',
keywords: ['fire', 'truck', 'emergency'],
),
_entry('firecracker', name: 'Firecracker', keywords: ['dynamite']),
_entry('smile', name: 'Grinning Face With Smiling Eyes'),
];
test('exact shortcode wins over longer prefixes', () {
final results = searchEmoji('fire', entries);
expect(results.first.id, 'fire');
expect(
results.map((entry) => entry.id),
containsAll(['fire', 'fire_engine', 'firecracker']),
);
});
test('shortcode prefix outranks a keyword-only hit', () {
final results = searchEmoji('flame', entries);
expect(results.single.id, 'fire');
});
test('separator-insensitive shortcode search still works', () {
expect(searchEmoji('pointup', entries).first.id, 'point_up');
expect(searchEmoji('fireengine', entries).first.id, 'fire_engine');
});
test('matches on name words when the shortcode does not contain them', () {
expect(searchEmoji('grinning', entries).single.id, 'smile');
});
test('honors the limit and returns empty for a blank query', () {
expect(searchEmoji('fire', entries, limit: 2), hasLength(2));
expect(searchEmoji('fire', entries, limit: 0), isEmpty);
expect(searchEmoji('', entries), isEmpty);
});
});
group('rankByShortcode', () {
test('ranks custom-emoji shortcodes with the desktop tiers', () {
final ranked = rankByShortcode('parrot', const [
'party_parrot',
'parrot',
'pirate_parrot',
], (code) => code);
expect(ranked.first, 'parrot');
expect(ranked, hasLength(3));
});
test('prefers the shorter shortcode within a tier', () {
final ranked = rankByShortcode('par', const [
'parrotparrot',
'park',
], (code) => code);
expect(ranked.first, 'park');
});
});
}
@@ -0,0 +1,37 @@
import 'package:buzz/shared/emoji/native_emoji_glyph.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('lifts the glyph one logical pixel on iOS', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
try {
await tester.pumpWidget(
const MaterialApp(
home: Center(child: NativeEmojiGlyph(emoji: '🔥', size: 24)),
),
);
final transform = tester.widget<Transform>(find.byType(Transform));
expect(transform.transform.getTranslation().y, -1);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
testWidgets('keeps the glyph unshifted on Android', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
try {
await tester.pumpWidget(
const MaterialApp(
home: Center(child: NativeEmojiGlyph(emoji: '🔥', size: 24)),
),
);
expect(find.byType(Transform), findsNothing);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
}
@@ -0,0 +1,48 @@
import 'package:buzz/shared/emoji/positive_emoji.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('isPositiveEmojiParticle', () {
test('accepts each group desktop celebrates', () {
expect(isPositiveEmojiParticle('\u{1F44D}'), isTrue); // 👍
expect(isPositiveEmojiParticle('\u{1F389}'), isTrue); // 🎉
expect(isPositiveEmojiParticle('\u{2764}\u{FE0F}'), isTrue); // ❤️
expect(isPositiveEmojiParticle('\u{1F602}'), isTrue); // 😂
});
test('rejects everything outside the set', () {
expect(isPositiveEmojiParticle('\u{1F44E}'), isFalse); // 👎
expect(isPositiveEmojiParticle('\u{1F622}'), isFalse); // 😢
expect(isPositiveEmojiParticle('\u{1F440}'), isFalse); // 👀
expect(isPositiveEmojiParticle(':partyparrot:'), isFalse);
expect(isPositiveEmojiParticle(''), isFalse);
});
test('ignores the presentation selector and surrounding space', () {
// The same emoji arrives with and without U+FE0F depending on the
// producer, and reactions come off the wire as well as from our picker.
expect(isPositiveEmojiParticle('\u{2764}'), isTrue);
expect(isPositiveEmojiParticle('\u{2B50}\u{FE0F}'), isTrue);
expect(isPositiveEmojiParticle(' \u{1F525} '), isTrue);
});
test('every entry in the exported list passes its own gate', () {
for (final emoji in positiveEmojiParticles) {
expect(
isPositiveEmojiParticle(emoji),
isTrue,
reason: 'expected $emoji to be a positive particle',
);
}
});
test('the three groups match desktop counts', () {
// Guards against a half-applied port: desktop has 23 hearts, 14 reaction
// emoji, and 15 faces.
expect(heartParticleEmojis, hasLength(23));
expect(positiveReactionParticleEmojis, hasLength(14));
expect(positiveFaceParticleEmojis, hasLength(15));
expect(positiveEmojiParticles, hasLength(52));
});
});
}
@@ -0,0 +1,51 @@
import 'package:buzz/shared/l10n/l10n.dart';
import 'package:buzz/shared/theme/theme_provider.dart';
import 'package:flutter/material.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('follows the system by default and persists explicit choices', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final container = ProviderContainer(
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
);
addTearDown(container.dispose);
expect(container.read(appLocaleProvider), isNull);
container
.read(appLocaleProvider.notifier)
.setLocale(const Locale('zh', 'CN'));
expect(container.read(appLocaleProvider), const Locale('zh', 'CN'));
expect(prefs.getString('buzz_locale'), 'zh_CN');
container.read(appLocaleProvider.notifier).setLocale(null);
expect(container.read(appLocaleProvider), isNull);
expect(prefs.getString('buzz_locale'), 'system');
});
test('restores a saved English choice', () async {
SharedPreferences.setMockInitialValues({'buzz_locale': 'en'});
final prefs = await SharedPreferences.getInstance();
final container = ProviderContainer(
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
);
addTearDown(container.dispose);
expect(container.read(appLocaleProvider), const Locale('en'));
});
test('loads Simplified Chinese labels for core mobile surfaces', () async {
final l10n = await AppLocalizations.delegate.load(
const Locale('zh', 'CN'),
);
expect(l10n.activityTitle, '动态');
expect(l10n.searchPrompt, '搜索消息、频道和人员');
expect(l10n.manageChannel, '管理频道');
expect(l10n.replyInThreadHint, '在话题中回复…');
});
}
@@ -0,0 +1,228 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/features/channels/agent_activity/working_bots_provider.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
void main() {
test('refreshes channel bot roles from live membership updates', () async {
final relaySession = _MembershipRelaySessionNotifier([
_membershipEvent(role: 'bot'),
_membershipEvent(role: 'member'),
]);
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => relaySession)],
);
addTearDown(container.dispose);
final keepAlive = container.listen(
channelBotPubkeysProvider(_channelId),
(_, _) {},
fireImmediately: true,
);
addTearDown(keepAlive.close);
expect(await container.read(channelBotPubkeysProvider(_channelId).future), {
_agentPubkey,
});
await relaySession.subscribed;
expect(relaySession.liveFilters.single.kinds, const [39002]);
expect(relaySession.liveFilters.single.tags['#h'], [_channelId]);
relaySession.emit(_membershipEvent(role: 'member'));
await _pumpEventQueue();
expect(
await container.read(channelBotPubkeysProvider(_channelId).future),
isEmpty,
);
});
test('refreshes channel members from live membership updates', () async {
final relaySession = _MembershipRelaySessionNotifier([
_membershipEvent(role: 'bot'),
_membershipEvent(role: 'member'),
]);
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => relaySession)],
);
addTearDown(container.dispose);
final keepAlive = container.listen(
channelMembersProvider(_channelId),
(_, _) {},
fireImmediately: true,
);
addTearDown(keepAlive.close);
expect(
(await container.read(
channelMembersProvider(_channelId).future,
)).single.role,
'bot',
);
await relaySession.subscribed;
relaySession.emit(_membershipEvent(role: 'member'));
await _pumpEventQueue();
expect(
(await container.read(
channelMembersProvider(_channelId).future,
)).single.role,
'member',
);
});
test('disposes the live role subscription without consumers', () async {
final relaySession = _MembershipRelaySessionNotifier([
_membershipEvent(role: 'bot'),
]);
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => relaySession)],
);
addTearDown(container.dispose);
final keepAlive = container.listen(
channelBotPubkeysProvider(_channelId),
(_, _) {},
fireImmediately: true,
);
await container.read(channelBotPubkeysProvider(_channelId).future);
await relaySession.subscribed;
keepAlive.close();
await container.pump();
expect(relaySession.unsubscribeCount, 1);
});
test(
'does not retain a live role subscription through working bots',
() async {
final relaySession = _MembershipRelaySessionNotifier([
_membershipEvent(role: 'bot'),
]);
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => relaySession)],
);
addTearDown(container.dispose);
final keepAlive = container.listen(
workingBotPubkeysProvider(_channelId),
(_, _) {},
fireImmediately: true,
);
await relaySession.subscribed;
keepAlive.close();
await container.pump();
expect(relaySession.unsubscribeCount, 1);
},
);
test('blank profile labels defer to the directory label', () {
const pubkey = 'deadbeef0123456789';
expect(
mentionNamesWithDirectoryLabels(
mentionPubkeys: const [pubkey],
profileMentionNames: const {pubkey: ' '},
directoryDisplayNames: const {pubkey: 'Directory bot'},
agentMentionPubkeys: const {pubkey},
),
const {pubkey: 'Directory bot'},
);
});
}
const _channelId = '11111111-1111-4111-8111-111111111111';
const _agentPubkey =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
NostrEvent _membershipEvent({required String role}) => NostrEvent(
id: 'membership-$role',
pubkey: 'owner',
createdAt: 1,
kind: 39002,
tags: [
['d', _channelId],
['h', _channelId],
['p', _agentPubkey, 'wss://relay.example', role],
],
content: '',
sig: 'sig',
);
Future<void> _pumpEventQueue() async {
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
}
class _MembershipRelaySessionNotifier extends RelaySessionNotifier {
final List<NostrEvent> _memberships;
final List<NostrFilter> liveFilters = [];
final List<_LiveSubscription> _subscriptions = [];
final Completer<void> _subscribed = Completer<void>();
var unsubscribeCount = 0;
var _membershipIndex = 0;
_MembershipRelaySessionNotifier(this._memberships);
Future<void> get subscribed => _subscribed.future;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
return [_memberships[_membershipIndex++]];
}
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async {
liveFilters.add(filter);
final subscription = _LiveSubscription(filter, onEvent);
_subscriptions.add(subscription);
if (!_subscribed.isCompleted) _subscribed.complete();
return () {
unsubscribeCount++;
_subscriptions.remove(subscription);
};
}
void emit(NostrEvent event) {
for (final subscription in List.of(_subscriptions)) {
if (_matches(subscription.filter, event)) {
subscription.onEvent(event);
}
}
}
}
class _LiveSubscription {
final NostrFilter filter;
final void Function(NostrEvent) onEvent;
const _LiveSubscription(this.filter, this.onEvent);
}
bool _matches(NostrFilter filter, NostrEvent event) {
if (!filter.kinds.contains(event.kind)) return false;
return filter.tags.entries.every((entry) {
final tagName = entry.key.substring(1);
return event.tags.any(
(tag) =>
tag.isNotEmpty &&
tag.first == tagName &&
tag.skip(1).any(entry.value.contains),
);
});
}
@@ -0,0 +1,469 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/relay/animated_image_sanitizer.dart';
void main() {
test('strips APNG metadata without changing animation chunks', () {
final clean = _animatedPng(metadata: false);
final dirty = _animatedPng(metadata: true)
..addAll(utf8.encode('trailing metadata'));
expect(
sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/png'),
clean,
);
});
test('strips identity APNG orientation but rejects display transforms', () {
final clean = _animatedPng(metadata: false);
expect(
sanitizeAnimatedImageForUpload(
Uint8List.fromList(_animatedPng(metadata: false, orientation: 1)),
'image/png',
),
clean,
);
for (final endian in [Endian.little, Endian.big]) {
expect(
() => sanitizeAnimatedImageForUpload(
Uint8List.fromList(
_animatedPng(
metadata: false,
orientation: 6,
orientationEndian: endian,
),
),
'image/png',
),
throwsA(
isA<FormatException>().having(
(error) => error.message,
'message',
contains('orientation'),
),
),
);
}
});
test('rejects APNG ICC profiles that affect color rendering', () {
expect(
() => sanitizeAnimatedImageForUpload(
Uint8List.fromList(_animatedPng(metadata: false, iccProfile: true)),
'image/png',
),
throwsA(
isA<FormatException>().having(
(error) => error.message,
'message',
contains('ICC profile'),
),
),
);
});
test('strips animated WebP metadata and clears metadata flags', () {
final clean = _animatedWebp(metadata: false);
final dirty = _animatedWebp(metadata: true)
..addAll(utf8.encode('trailing metadata'));
expect(
sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/webp'),
clean,
);
});
test('strips identity WebP orientation but rejects display transforms', () {
final clean = _animatedWebp(metadata: false);
expect(
sanitizeAnimatedImageForUpload(
Uint8List.fromList(_animatedWebp(metadata: false, orientation: 1)),
'image/webp',
),
clean,
);
for (final endian in [Endian.little, Endian.big]) {
expect(
() => sanitizeAnimatedImageForUpload(
Uint8List.fromList(
_animatedWebp(
metadata: false,
orientation: 6,
orientationEndian: endian,
),
),
'image/webp',
),
throwsA(
isA<FormatException>().having(
(error) => error.message,
'message',
contains('orientation'),
),
),
);
}
});
test('rejects animated WebP ICC profiles that affect color rendering', () {
expect(
() => sanitizeAnimatedImageForUpload(
Uint8List.fromList(_animatedWebp(metadata: false, iccProfile: true)),
'image/webp',
),
throwsA(
isA<FormatException>().having(
(error) => error.message,
'message',
contains('ICC profile'),
),
),
);
});
test('removes metadata chunks nested inside animated WebP frames', () {
expect(
sanitizeAnimatedImageForUpload(
Uint8List.fromList(
_animatedWebp(metadata: false, nestedMetadata: true),
),
'image/webp',
),
_animatedWebp(metadata: false),
);
});
test('strips GIF metadata without changing animation blocks', () {
final clean = _minimalGif();
final dirty = <int>[
...clean.sublist(0, 19),
..._gifCommentExtension(),
..._gifApplicationExtension('XMP DataXMP', utf8.encode('<x/>')),
...clean.sublist(19),
...utf8.encode('trailing metadata'),
];
expect(
sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/gif'),
clean,
);
});
test('canonicalizes GIF loop extensions with hidden sub-blocks', () {
final clean = _minimalGif();
final cleanLoop = _gifLoopExtension();
final dirty = <int>[
...clean.sublist(0, 19),
..._gifLoopExtension(extra: utf8.encode('location')),
...clean.sublist(19 + cleanLoop.length),
];
expect(
sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/gif'),
clean,
);
});
test('drops GIF applications with binary authentication codes', () {
final clean = _minimalGif();
final dirty = <int>[
...clean.sublist(0, 19),
..._gifApplicationExtensionBytes([
...ascii.encode('FOREIGN1'),
0xff,
0x80,
0x00,
], utf8.encode('private')),
...clean.sublist(19),
];
expect(
sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/gif'),
clean,
);
});
test('removes a GIF graphic control consumed by stripped plain text', () {
final clean = _minimalGif();
final dirty = <int>[
...clean.sublist(0, 19),
0x21,
0xf9,
4,
0x09,
0x1e,
0,
1,
0,
..._gifPlainTextExtension(),
...clean.sublist(19),
];
expect(
sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/gif'),
clean,
);
});
test('keeps clean animated containers byte-identical', () {
for (final (mimeType, bytes) in [
('image/png', _animatedPng(metadata: false)),
('image/webp', _animatedWebp(metadata: false)),
('image/gif', _minimalGif()),
]) {
expect(
sanitizeAnimatedImageForUpload(Uint8List.fromList(bytes), mimeType),
bytes,
);
}
});
test('fails closed for malformed animated containers', () {
for (final (mimeType, bytes) in [
('image/png', <int>[0x89, 0x50, 0x4e, 0x47]),
('image/webp', ascii.encode('RIFFxxxxWEBP')),
('image/gif', ascii.encode('GIF89a')),
]) {
expect(
() =>
sanitizeAnimatedImageForUpload(Uint8List.fromList(bytes), mimeType),
throwsFormatException,
);
}
});
}
List<int> _pngChunk(String type, List<int> payload) {
return [
..._uint32BigEndian(payload.length),
...ascii.encode(type),
...payload,
0,
0,
0,
0,
];
}
List<int> _animatedPng({
required bool metadata,
int? orientation,
Endian orientationEndian = Endian.little,
bool iccProfile = false,
}) {
return [
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
..._pngChunk('IHDR', List.filled(13, 0)),
..._pngChunk('acTL', [0, 0, 0, 2, 0, 0, 0, 0]),
if (iccProfile) ..._pngChunk('iCCP', utf8.encode('profile')),
if (orientation != null)
..._pngChunk(
'eXIf',
_exifOrientation(
orientation,
orientationEndian,
includePreamble: false,
),
),
if (metadata) ..._pngChunk('tEXt', utf8.encode('Location\u0000secret')),
if (metadata) ..._pngChunk('pHYs', List.filled(9, 0)),
..._pngChunk('fcTL', List.filled(26, 0)),
..._pngChunk('IDAT', [1, 2, 3]),
..._pngChunk('fdAT', [0, 0, 0, 1, 4, 5]),
..._pngChunk('IEND', const []),
];
}
List<int> _webpChunk(String type, List<int> payload) {
return [
...ascii.encode(type),
..._uint32LittleEndian(payload.length),
...payload,
if (payload.length.isOdd) 0,
];
}
List<int> _animatedWebp({
required bool metadata,
int? orientation,
Endian orientationEndian = Endian.little,
bool iccProfile = false,
bool nestedMetadata = false,
}) {
final hasExif = metadata || orientation != null;
final frame = <int>[
...List.filled(16, 0),
..._webpChunk('VP8 ', [1, 2, 3]),
if (nestedMetadata) ..._webpChunk('JUNK', utf8.encode('location')),
];
final chunks = <int>[
..._webpChunk('VP8X', [
0x02 | (hasExif ? 0x0c : 0) | (iccProfile ? 0x20 : 0),
0,
0,
0,
0,
0,
0,
0,
0,
0,
]),
..._webpChunk('ANIM', List.filled(6, 0)),
if (iccProfile) ..._webpChunk('ICCP', utf8.encode('profile')),
if (orientation != null)
..._webpChunk(
'EXIF',
_exifOrientation(orientation, orientationEndian, includePreamble: true),
)
else if (metadata)
..._webpChunk('EXIF', utf8.encode('location')),
if (metadata) ..._webpChunk('XMP ', utf8.encode('<xmp/>')),
if (metadata) ..._webpChunk('JUNK', utf8.encode('private')),
..._webpChunk('ANMF', frame),
];
return [
...ascii.encode('RIFF'),
..._uint32LittleEndian(chunks.length + 4),
...ascii.encode('WEBP'),
...chunks,
];
}
List<int> _exifOrientation(
int orientation,
Endian endian, {
required bool includePreamble,
}) {
final bytes = BytesBuilder();
if (includePreamble) {
bytes
..add(ascii.encode('Exif'))
..add(const [0, 0]);
}
final tiff = ByteData(26);
if (endian == Endian.little) {
tiff.setUint8(0, 0x49);
tiff.setUint8(1, 0x49);
} else {
tiff.setUint8(0, 0x4d);
tiff.setUint8(1, 0x4d);
}
tiff.setUint16(2, 42, endian);
tiff.setUint32(4, 8, endian);
tiff.setUint16(8, 1, endian);
tiff.setUint16(10, 0x0112, endian);
tiff.setUint16(12, 3, endian);
tiff.setUint32(14, 1, endian);
tiff.setUint16(18, orientation, endian);
tiff.setUint32(22, 0, endian);
bytes.add(tiff.buffer.asUint8List());
return bytes.takeBytes();
}
List<int> _minimalGif() {
return [
...ascii.encode('GIF89a'),
2,
0,
2,
0,
0x80,
0,
0,
0,
0,
0,
0xff,
0xff,
0xff,
..._gifLoopExtension(),
0x21,
0xf9,
4,
0,
10,
0,
0,
0,
0x2c,
0,
0,
0,
0,
2,
0,
2,
0,
0,
2,
2,
0x44,
1,
0,
0x3b,
];
}
List<int> _gifLoopExtension({List<int> extra = const []}) {
return [
0x21,
0xff,
11,
...ascii.encode('NETSCAPE2.0'),
3,
1,
0,
0,
if (extra.isNotEmpty) ...[extra.length, ...extra],
0,
];
}
List<int> _gifCommentExtension() {
return [0x21, 0xfe, 5, ...ascii.encode('hello'), 0];
}
List<int> _gifApplicationExtension(String identifier, List<int> payload) {
return _gifApplicationExtensionBytes(ascii.encode(identifier), payload);
}
List<int> _gifApplicationExtensionBytes(
List<int> identifier,
List<int> payload,
) {
return [0x21, 0xff, 11, ...identifier, payload.length, ...payload, 0];
}
List<int> _gifPlainTextExtension() {
return [0x21, 0x01, 12, 0, 0, 0, 0, 2, 0, 2, 0, 1, 1, 1, 0, 1, 0x78, 0];
}
List<int> _uint32BigEndian(int value) {
return [
value >> 24 & 0xff,
value >> 16 & 0xff,
value >> 8 & 0xff,
value & 0xff,
];
}
List<int> _uint32LittleEndian(int value) {
return [
value & 0xff,
value >> 8 & 0xff,
value >> 16 & 0xff,
value >> 24 & 0xff,
];
}
@@ -0,0 +1,92 @@
import 'package:buzz/shared/relay/identity_scoped_prefs.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const canonical = 'k_v1:https://relay.example:pk';
const legacy = 'k_v1:wss://relay.example:pk';
Future<SharedPreferences> prefsWith(Map<String, Object> values) async {
SharedPreferences.setMockInitialValues(values);
return SharedPreferences.getInstance();
}
String? readString(SharedPreferences prefs) => readMigratedPref<String>(
prefs,
canonicalKey: canonical,
legacyKey: legacy,
read: prefs.getString,
write: prefs.setString,
);
test('returns the canonical value when present', () async {
final prefs = await prefsWith({canonical: 'new', legacy: 'old'});
expect(readString(prefs), 'new');
});
test('falls back to the legacy value and returns it immediately', () async {
final prefs = await prefsWith({legacy: 'carried over'});
expect(readString(prefs), 'carried over');
});
test('promotes the legacy value onto the canonical key', () async {
final prefs = await prefsWith({legacy: 'carried over'});
readString(prefs);
await Future<void>.delayed(Duration.zero);
expect(prefs.getString(canonical), 'carried over');
expect(prefs.getString(legacy), isNull, reason: 'legacy entry is cleared');
});
test('is idempotent across repeated reads', () async {
final prefs = await prefsWith({legacy: 'carried over'});
readString(prefs);
await Future<void>.delayed(Duration.zero);
expect(readString(prefs), 'carried over');
await Future<void>.delayed(Duration.zero);
expect(prefs.getString(canonical), 'carried over');
});
test('returns null when neither key holds a value', () async {
final prefs = await prefsWith({});
expect(readString(prefs), isNull);
});
test('does not touch storage when the keys coincide', () async {
// A pairing-created community already stores an HTTP origin, so canonical
// and legacy are the same string and there is nothing to migrate.
SharedPreferences.setMockInitialValues({canonical: 'only'});
final prefs = await SharedPreferences.getInstance();
final value = readMigratedPref<String>(
prefs,
canonicalKey: canonical,
legacyKey: canonical,
read: prefs.getString,
write: prefs.setString,
);
await Future<void>.delayed(Duration.zero);
expect(value, 'only');
expect(prefs.getString(canonical), 'only');
});
test('migrates string lists as well as strings', () async {
final prefs = await prefsWith({
legacy: <String>['alpha', 'beta'],
});
final value = readMigratedPref<List<String>>(
prefs,
canonicalKey: canonical,
legacyKey: legacy,
read: prefs.getStringList,
write: prefs.setStringList,
);
await Future<void>.delayed(Duration.zero);
expect(value, ['alpha', 'beta']);
expect(prefs.getStringList(canonical), ['alpha', 'beta']);
expect(prefs.getStringList(legacy), isNull);
});
}
@@ -0,0 +1,239 @@
import 'dart:convert';
import 'dart:async';
import 'package:buzz/shared/relay/media_auth.dart';
import 'package:buzz/shared/relay/media_image.dart';
import 'package:flutter/painting.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:nostr/nostr.dart' as nostr;
// Minimal valid 1x1 transparent PNG.
final _pngBytes = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAA'
'AAYAAjCB0C8AAAAASUVORK5CYII=',
);
const _relayBase = 'https://relay.example.com';
const _mediaUrl = '$_relayBase/media/abc123.png';
MediaGetAuthService _auth({String? nsec, DateTime Function()? now}) =>
MediaGetAuthService(baseUrl: _relayBase, nsec: nsec, now: now);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
MediaImageProvider.debugResetCooldowns();
MediaImageProvider.debugNow = DateTime.now;
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
});
group('MediaGetAuthService memoization', () {
test('repeated calls return byte-identical headers', () {
final nsec = nostr.Keys.generate().nsec;
final auth = _auth(nsec: nsec);
final first = auth.headersFor(_mediaUrl);
final second = auth.headersFor(_mediaUrl);
expect(first, isNotEmpty);
expect(identical(first, second), isTrue);
});
test('re-signs only at the refresh margin before expiry', () {
final nsec = nostr.Keys.generate().nsec;
var current = DateTime.utc(2026, 7, 21, 12);
final auth = _auth(nsec: nsec, now: () => current);
final first = auth.headersFor(_mediaUrl);
// 600s lifetime - 60s margin = re-sign boundary at +540s.
current = current.add(const Duration(seconds: 539));
expect(identical(auth.headersFor(_mediaUrl), first), isTrue);
current = current.add(const Duration(seconds: 2));
final refreshed = auth.headersFor(_mediaUrl);
expect(identical(refreshed, first), isFalse);
expect(refreshed['Authorization'], isNot(first['Authorization']));
});
test('non-relay URLs get no headers even with a key', () {
final nsec = nostr.Keys.generate().nsec;
final auth = _auth(nsec: nsec);
expect(auth.headersFor('https://elsewhere.com/media/abc.png'), isEmpty);
expect(auth.headersFor('$_relayBase/not-media/abc.png'), isEmpty);
});
});
group('MediaImageProvider cache identity', () {
test('equal url + same auth service => equal keys', () {
final auth = _auth(nsec: nostr.Keys.generate().nsec);
final client = http.Client();
addTearDown(client.close);
final a = MediaImageProvider(url: _mediaUrl, auth: auth, client: client);
final b = MediaImageProvider(url: _mediaUrl, auth: auth, client: client);
expect(a, equals(b));
expect(a.hashCode, equals(b.hashCode));
});
test('different auth service (relay/account switch) => unequal keys', () {
final client = http.Client();
addTearDown(client.close);
final a = MediaImageProvider(
url: _mediaUrl,
auth: _auth(nsec: nostr.Keys.generate().nsec),
client: client,
);
final b = MediaImageProvider(
url: _mediaUrl,
auth: _auth(nsec: nostr.Keys.generate().nsec),
client: client,
);
expect(a, isNot(equals(b)));
});
test('transport client is not part of identity', () {
final auth = _auth(nsec: nostr.Keys.generate().nsec);
final c1 = http.Client();
final c2 = http.Client();
addTearDown(c1.close);
addTearDown(c2.close);
expect(
MediaImageProvider(url: _mediaUrl, auth: auth, client: c1),
equals(MediaImageProvider(url: _mediaUrl, auth: auth, client: c2)),
);
});
});
group('MediaImageProvider fetching', () {
test('resolves one fetch for repeated resolves of the same key', () async {
var fetches = 0;
final client = http_testing.MockClient((request) async {
fetches += 1;
return http.Response.bytes(_pngBytes, 200);
});
final auth = _auth(nsec: nostr.Keys.generate().nsec);
for (var i = 0; i < 3; i++) {
final provider = MediaImageProvider(
url: _mediaUrl,
auth: auth,
client: client,
);
final completer = provider.resolve(ImageConfiguration.empty);
await _wait(completer);
}
expect(fetches, 1);
});
test('sends auth headers with the fetch', () async {
Map<String, String>? seen;
final client = http_testing.MockClient((request) async {
seen = request.headers;
return http.Response.bytes(_pngBytes, 200);
});
final auth = _auth(nsec: nostr.Keys.generate().nsec);
final provider = MediaImageProvider(
url: _mediaUrl,
auth: auth,
client: client,
);
await _wait(provider.resolve(ImageConfiguration.empty));
expect(seen?['Authorization'], startsWith('Nostr '));
});
test('failed URL is not refetched until cooldown elapses', () async {
var fetches = 0;
final client = http_testing.MockClient((request) async {
fetches += 1;
return http.Response('rate limited', 429);
});
final auth = _auth(nsec: nostr.Keys.generate().nsec);
var current = DateTime.utc(2026, 7, 21, 12);
MediaImageProvider.debugNow = () => current;
Future<Object?> attempt() async {
final provider = MediaImageProvider(
url: _mediaUrl,
auth: auth,
client: client,
);
return _waitError(provider.resolve(ImageConfiguration.empty));
}
expect(await attempt(), isA<NetworkImageLoadException>());
expect(fetches, 1);
// Within cooldown: suppressed, no network call.
expect(await attempt(), isA<MediaImageCooldownException>());
expect(fetches, 1);
// After cooldown: retried.
current = current.add(const Duration(seconds: 31));
expect(await attempt(), isA<NetworkImageLoadException>());
expect(fetches, 2);
});
test('honors Retry-After on 429 (capped)', () async {
var fetches = 0;
final client = http_testing.MockClient((request) async {
fetches += 1;
return http.Response(
'rate limited',
429,
headers: {'retry-after': '120'},
);
});
final auth = _auth(nsec: nostr.Keys.generate().nsec);
var current = DateTime.utc(2026, 7, 21, 12);
MediaImageProvider.debugNow = () => current;
Future<Object?> attempt() async {
final provider = MediaImageProvider(
url: _mediaUrl,
auth: auth,
client: client,
);
return _waitError(provider.resolve(ImageConfiguration.empty));
}
await attempt();
expect(fetches, 1);
current = current.add(const Duration(seconds: 60));
expect(await attempt(), isA<MediaImageCooldownException>());
expect(fetches, 1);
current = current.add(const Duration(seconds: 61));
await attempt();
expect(fetches, 2);
});
});
}
Future<void> _wait(ImageStream stream) {
final done = Completer<void>();
late final ImageStreamListener listener;
listener = ImageStreamListener(
(image, sync) {
image.dispose();
stream.removeListener(listener);
if (!done.isCompleted) done.complete();
},
onError: (error, stack) {
stream.removeListener(listener);
if (!done.isCompleted) done.completeError(error, stack);
},
);
stream.addListener(listener);
return done.future;
}
Future<Object?> _waitError(ImageStream stream) async {
try {
await _wait(stream);
return null;
} catch (e) {
return e;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,145 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:buzz/shared/relay/mp4_fast_start.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
late Directory tempDirectory;
setUp(() async {
tempDirectory = await Directory.systemTemp.createTemp(
'buzz-faststart-test-',
);
});
tearDown(() async {
await tempDirectory.delete(recursive: true);
});
for (final offsetBox in ['stco', 'co64']) {
test('moves moov before mdat and patches $offsetBox offsets', () async {
final ftyp = _box('ftyp', [
...ascii.encode('isom'),
0,
0,
0,
0,
...ascii.encode('isom'),
]);
final mdat = _box('mdat', [1, 2, 3, 4]);
final originalMediaOffset = ftyp.length + 8;
final offsetPayload = BytesBuilder()
..add([0, 0, 0, 0])
..add(_uint32(1))
..add(
offsetBox == 'stco'
? _uint32(originalMediaOffset)
: _uint64(originalMediaOffset),
);
final sampleTable = _box(offsetBox, offsetPayload.takeBytes());
final moov = _nestedMoov(sampleTable);
final sourceBytes = Uint8List.fromList([...ftyp, ...mdat, ...moov]);
final source = File('${tempDirectory.path}/source.mp4');
final destination = File('${tempDirectory.path}/output.mp4');
await source.writeAsBytes(sourceBytes);
await rewriteMp4ForFastStart(source, destination);
final output = await destination.readAsBytes();
expect(_topLevelTypes(output), ['ftyp', 'moov', 'mdat']);
expect(
output.sublist(ftyp.length + moov.length + 8),
equals([1, 2, 3, 4]),
);
final typeOffset = _findAscii(output, offsetBox);
expect(typeOffset, greaterThanOrEqualTo(4));
final entryOffset = typeOffset + 12;
final adjusted = offsetBox == 'stco'
? _readUint32(output, entryOffset)
: _readUint64(output, entryOffset);
expect(adjusted, originalMediaOffset + moov.length);
});
}
test(
'rejects excessive nested box depth and deletes partial output',
() async {
final ftyp = _box('ftyp', [
...ascii.encode('isom'),
0,
0,
0,
0,
...ascii.encode('isom'),
]);
final mdat = _box('mdat', [1]);
var nested = _box('stco', [0, 0, 0, 0, ..._uint32(0)]);
for (var index = 0; index < 34; index++) {
nested = _box('stbl', nested);
}
final source = File('${tempDirectory.path}/deep.mp4');
final destination = File('${tempDirectory.path}/output.mp4');
await source.writeAsBytes([...ftyp, ...mdat, ..._box('moov', nested)]);
await expectLater(
rewriteMp4ForFastStart(source, destination),
throwsA(isA<FormatException>()),
);
expect(await destination.exists(), isFalse);
},
);
}
Uint8List _nestedMoov(List<int> leaf) =>
_box('moov', _box('trak', _box('mdia', _box('minf', _box('stbl', leaf)))));
Uint8List _box(String type, List<int> payload) => Uint8List.fromList([
..._uint32(payload.length + 8),
...ascii.encode(type),
...payload,
]);
Uint8List _uint32(int value) {
final bytes = ByteData(4)..setUint32(0, value, Endian.big);
return bytes.buffer.asUint8List();
}
Uint8List _uint64(int value) {
final bytes = ByteData(8)..setUint64(0, value, Endian.big);
return bytes.buffer.asUint8List();
}
int _readUint32(Uint8List bytes, int offset) =>
ByteData.sublistView(bytes).getUint32(offset, Endian.big);
int _readUint64(Uint8List bytes, int offset) =>
ByteData.sublistView(bytes).getUint64(offset, Endian.big);
List<String> _topLevelTypes(Uint8List bytes) {
final types = <String>[];
var offset = 0;
while (offset < bytes.length) {
final size = _readUint32(bytes, offset);
types.add(ascii.decode(bytes.sublist(offset + 4, offset + 8)));
offset += size;
}
return types;
}
int _findAscii(Uint8List bytes, String value) {
final needle = ascii.encode(value);
for (var offset = 0; offset + needle.length <= bytes.length; offset++) {
var matches = true;
for (var index = 0; index < needle.length; index++) {
if (bytes[offset + index] != needle[index]) {
matches = false;
break;
}
}
if (matches) return offset;
}
return -1;
}
@@ -0,0 +1,101 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/relay/nostr_models.dart';
void main() {
test('NostrFilter serializes and preserves authors', () {
const filter = NostrFilter(
kinds: [EventKind.readState],
authors: ['pubkey-a'],
tags: {
'#t': ['read-state'],
},
since: 10,
limit: 5,
);
expect(filter.toJson(), {
'kinds': [EventKind.readState],
'limit': 5,
'authors': ['pubkey-a'],
'since': 10,
'#t': ['read-state'],
});
final copied = filter.copyWithSince(20);
expect(copied.toJson(), {
'kinds': [EventKind.readState],
'limit': 5,
'authors': ['pubkey-a'],
'since': 20,
'#t': ['read-state'],
});
});
test('NostrFilter can serialize broad deletion subscriptions', () {
const filter = NostrFilter(kinds: [EventKind.deletion], limit: 0);
expect(filter.toJson(), {
'kinds': [EventKind.deletion],
'limit': 0,
});
});
test('NostrFilter serializes relay query extensions', () {
const filter = NostrFilter(
kinds: EventKind.channelTimelineContentKinds,
tags: {
'#h': ['channel-id'],
},
limit: 50,
extensions: {
'top_level': true,
'include_summaries': true,
'include_aux': true,
'before_id': 'cursor-id',
},
);
expect(filter.toJson(), {
'kinds': EventKind.channelTimelineContentKinds,
'limit': 50,
'#h': ['channel-id'],
'top_level': true,
'include_summaries': true,
'include_aux': true,
'before_id': 'cursor-id',
});
});
test('channel unread activity kinds exclude non-message updates', () {
expect(
EventKind.channelMessageEventKinds,
contains(EventKind.streamMessage),
);
expect(EventKind.channelMessageEventKinds, contains(EventKind.forumPost));
expect(
EventKind.channelMessageEventKinds,
contains(EventKind.forumComment),
);
expect(
EventKind.channelMessageEventKinds,
isNot(contains(EventKind.reaction)),
);
expect(
EventKind.channelMessageEventKinds,
isNot(contains(EventKind.streamMessageEdit)),
);
expect(
EventKind.channelMessageEventKinds,
isNot(contains(EventKind.streamMessageDiff)),
);
expect(
EventKind.channelMessageEventKinds,
isNot(contains(EventKind.deletion)),
);
expect(
EventKind.channelMessageEventKinds,
isNot(contains(EventKind.systemMessage)),
);
});
}
@@ -0,0 +1,45 @@
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('classifyRelayClosed', () {
const cases = {
'rate-limited: quota exceeded; retry in 4s': RelayClosedClass.rateLimited,
'rate-limited: too many concurrent requests':
RelayClosedClass.rateLimited,
'restricted: access revoked': RelayClosedClass.terminal,
'auth-required: verification failed': RelayClosedClass.terminal,
'blocked: community policy': RelayClosedClass.terminal,
'invalid: malformed filter': RelayClosedClass.terminal,
'pow: insufficient work': RelayClosedClass.terminal,
'duplicate: subscription already exists': RelayClosedClass.terminal,
'unsupported: filter extension': RelayClosedClass.terminal,
'error: mixed search and channel filter': RelayClosedClass.terminal,
'error: too many subscriptions': RelayClosedClass.terminal,
'error: relay temporarily unavailable': RelayClosedClass.retryable,
'subscription closed by relay': RelayClosedClass.retryable,
};
for (final entry in cases.entries) {
test(entry.key, () {
expect(classifyRelayClosed(entry.key), entry.value);
});
}
});
test('parseRateLimitRetrySeconds handles canonical and absent hints', () {
expect(
parseRateLimitRetrySeconds('rate-limited: quota exceeded; retry in 17s'),
17,
);
expect(parseRateLimitRetrySeconds('RETRY IN 2S'), 2);
expect(parseRateLimitRetrySeconds('retry in 0s'), 0);
expect(parseRateLimitRetrySeconds('retry in 999999s'), 999999);
final oversizedHint = List.filled(1000, '9').join();
expect(parseRateLimitRetrySeconds('retry in ${oversizedHint}s'), isNull);
expect(
parseRateLimitRetrySeconds('rate-limited: too many concurrent requests'),
isNull,
);
});
}
@@ -0,0 +1,67 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/relay/relay_provider.dart';
void main() {
group('RelayConfig.baseUrl normalization', () {
test('folds a wss:// community URL to https://', () {
// Invite joins persist the relay URL straight off the invite link, which
// deep_link.dart always emits as ws:// or wss://.
final config = RelayConfig(baseUrl: 'wss://relay.example.com');
expect(config.baseUrl, 'https://relay.example.com');
});
test('folds a ws:// community URL to http://', () {
final config = RelayConfig(baseUrl: 'ws://relay.example.com:3000');
expect(config.baseUrl, 'http://relay.example.com:3000');
});
test('leaves an https:// community URL untouched', () {
// Device pairing rejects anything but https://, so these already conform.
final config = RelayConfig(baseUrl: 'https://relay.example.com');
expect(config.baseUrl, 'https://relay.example.com');
});
test('leaves an http:// community URL untouched', () {
final config = RelayConfig(baseUrl: 'http://localhost:3000');
expect(config.baseUrl, 'http://localhost:3000');
});
test('preserves a non-default port', () {
final config = RelayConfig(baseUrl: 'wss://relay.example.com:8443');
expect(config.baseUrl, 'https://relay.example.com:8443');
});
});
group('RelayConfig.wsUrl', () {
test('keeps TLS for a relay joined by invite', () {
// Regression: a wss:// base used to fall through to the non-https branch
// and downgrade to ws://, dialing port 80 — which never connects on a
// relay that only serves 443, and drops TLS everywhere else.
final config = RelayConfig(baseUrl: 'wss://relay.example.com');
expect(config.wsUrl, 'wss://relay.example.com');
});
test('keeps TLS for a relay added by pairing', () {
final config = RelayConfig(baseUrl: 'https://relay.example.com');
expect(config.wsUrl, 'wss://relay.example.com');
});
test('both onboarding paths agree on the same relay', () {
final invited = RelayConfig(baseUrl: 'wss://relay.example.com');
final paired = RelayConfig(baseUrl: 'https://relay.example.com');
expect(invited.wsUrl, paired.wsUrl);
expect(invited.baseUrl, paired.baseUrl);
});
test('stays plaintext for local development', () {
final config = RelayConfig(baseUrl: 'http://localhost:3000');
expect(config.wsUrl, 'ws://localhost:3000');
});
test('preserves a non-default port', () {
final config = RelayConfig(baseUrl: 'wss://relay.example.com:8443');
expect(config.wsUrl, 'wss://relay.example.com:8443');
});
});
}
@@ -0,0 +1,118 @@
import 'dart:async';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('uses the default for absent and non-positive hints', () {
var now = DateTime.utc(2026);
final timers = <_ManualTimer>[];
final gate = RelayRateLimitGate(
now: () => now,
timerFactory: (duration, callback) {
final timer = _ManualTimer(duration, callback);
timers.add(timer);
return timer;
},
);
gate.activate(null);
expect(gate.remainingMs(), 10000);
expect(timers.single.duration, const Duration(seconds: 10));
now = now.add(const Duration(seconds: 11));
gate.activate(0);
expect(timers.last.duration, const Duration(seconds: 10));
});
test('clamps large hints to five minutes', () {
final timers = <_ManualTimer>[];
final gate = RelayRateLimitGate(
now: () => DateTime.utc(2026),
timerFactory: (duration, callback) {
final timer = _ManualTimer(duration, callback);
timers.add(timer);
return timer;
},
);
gate.activate(999999);
expect(timers.single.duration, const Duration(seconds: 300));
expect(gate.remainingMs(), 300000);
});
test('overlapping activations only extend the active window', () async {
var now = DateTime.utc(2026);
final timers = <_ManualTimer>[];
final gate = RelayRateLimitGate(
now: () => now,
timerFactory: (duration, callback) {
final timer = _ManualTimer(duration, callback);
timers.add(timer);
return timer;
},
);
gate.activate(30);
final wait = gate.wait();
final firstTimer = timers.single;
now = now.add(const Duration(seconds: 5));
gate.activate(10);
expect(timers, hasLength(1));
expect(gate.remainingMs(), 25000);
gate.activate(40);
expect(timers, hasLength(2));
expect(firstTimer.isActive, isFalse);
expect(gate.remainingMs(), 40000);
timers.last.fire();
await wait;
expect(gate.isActive, isFalse);
expect(gate.remainingMs(), 0);
});
test('reset releases waiters and cancels the active timer', () async {
final timers = <_ManualTimer>[];
final gate = RelayRateLimitGate(
timerFactory: (duration, callback) {
final timer = _ManualTimer(duration, callback);
timers.add(timer);
return timer;
},
);
gate.activate(20);
final wait = gate.wait();
gate.reset();
await wait;
expect(timers.single.isActive, isFalse);
expect(gate.isActive, isFalse);
});
}
class _ManualTimer implements Timer {
_ManualTimer(this.duration, this._callback);
final Duration duration;
final void Function() _callback;
bool _active = true;
void fire() {
if (!_active) return;
_active = false;
_callback();
}
@override
void cancel() => _active = false;
@override
bool get isActive => _active;
@override
int get tick => _active ? 0 : 1;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,111 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:buzz/shared/relay/relay_socket.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
/// A server that completes the WS handshake then never speaks again: no pongs,
/// no close frame. Only a client-side ping timeout can notice.
Future<ServerSocket> _silentAfterHandshakeServer() async {
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
server.listen((client) {
client.listen(
(data) {
final match = RegExp(
r'Sec-WebSocket-Key: (.*)\r\n',
caseSensitive: false,
).firstMatch(String.fromCharCodes(data));
if (match == null) return;
final accept = base64.encode(
sha1
.convert(
utf8.encode(
'${match.group(1)!.trim()}258EAFA5-E914-47DA-95CA-C5AB0DC85B11',
),
)
.bytes,
);
client.write(
'HTTP/1.1 101 Switching Protocols\r\n'
'Upgrade: websocket\r\nConnection: Upgrade\r\n'
'Sec-WebSocket-Accept: $accept\r\n\r\n',
);
},
onError: (_) {},
onDone: () {},
);
});
return server;
}
void main() {
const testPingInterval = Duration(milliseconds: 150);
setUp(() {
RelaySocket.debugPingInterval = testPingInterval;
});
tearDown(() {
RelaySocket.debugPingInterval = RelaySocket.pingInterval;
});
test('detects a peer that stops answering pings', () async {
final server = await _silentAfterHandshakeServer();
final disconnected = Completer<Object?>();
final socket = RelaySocket(
wsUrl: 'ws://127.0.0.1:${server.port}',
nsec: null,
onMessage: (_) {},
onConnected: () {},
onDisconnected: (error) {
if (!disconnected.isCompleted) disconnected.complete(error);
},
);
unawaited(socket.connect());
var detected = true;
try {
await disconnected.future.timeout(testPingInterval * 4);
} on TimeoutException {
detected = false;
}
expect(
detected,
isTrue,
reason:
'RelaySocket must surface an unanswered ping through onDisconnected',
);
socket.dispose();
await server.close();
});
test('keeps an idle but healthy peer connected', () async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
server.transform(WebSocketTransformer()).listen((ws) {
// A healthy relay answers pings without sending application data.
ws.listen((_) {}, onError: (_) {}, onDone: () {});
});
var tornDown = false;
final socket = RelaySocket(
wsUrl: 'ws://127.0.0.1:${server.port}',
nsec: null,
onMessage: (_) {},
onConnected: () {},
onDisconnected: (_) => tornDown = true,
);
unawaited(socket.connect());
await Future<void>.delayed(testPingInterval * 4);
expect(tornDown, isFalse);
await socket.disconnect();
await server.close(force: true);
});
}
@@ -0,0 +1,161 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/relay/relay_validation.dart';
void main() {
group('validateInviteRelayUri', () {
test('accepts public secure relay origins', () {
for (final url in [
'wss://relay.example.com',
'wss://relay.example.com/',
'wss://relay.example.com:8443',
'wss://8.8.8.8',
'wss://[2001:4860:4860::8888]',
'wss://[2001:1::1]',
'wss://[2001:1::2]',
'wss://[2001:1::3]',
'wss://[2001:3::1]',
'wss://[2001:4:112::1]',
'wss://[2001:20::1]',
'wss://[2001:30::1]',
'wss://[2001:200::1]',
'wss://[2620:4f:8000::1]',
'wss://[3fff:1000::1]',
'wss://[2606:4700:4700::1111]',
]) {
expect(
() => validateInviteRelayUri(
Uri.parse(url),
allowInsecureLocalhost: false,
),
returnsNormally,
reason: url,
);
}
});
test('allows plaintext localhost only when explicitly enabled', () {
for (final url in ['ws://localhost:3000', 'ws://relay.localhost:3000']) {
expect(
() => validateInviteRelayUri(
Uri.parse(url),
allowInsecureLocalhost: true,
),
returnsNormally,
reason: url,
);
expect(
() => validateInviteRelayUri(
Uri.parse(url),
allowInsecureLocalhost: false,
),
throwsFormatException,
reason: url,
);
}
});
test('rejects plaintext public relays', () {
expect(
() => validateInviteRelayUri(
Uri.parse('ws://relay.example.com'),
allowInsecureLocalhost: false,
),
throwsFormatException,
);
});
test('rejects non-public IPv4 literals', () {
for (final host in [
'0.0.0.0',
'10.0.0.1',
'100.64.0.1',
'127.0.0.1',
'169.254.169.254',
'172.16.0.1',
'192.168.0.1',
'198.18.0.1',
'224.0.0.1',
]) {
expect(
() => validateInviteRelayUri(
Uri.parse('wss://$host'),
allowInsecureLocalhost: false,
),
throwsFormatException,
reason: host,
);
}
});
test('rejects non-public IPv6 literals and mapped IPv4', () {
for (final host in [
'[::]',
'[::1]',
'[::ffff:127.0.0.1]',
'[::ffff:169.254.169.254]',
'[::ffff:a9fe:a9fe]',
'[64:ff9b::a9fe:a9fe]',
'[100::1]',
'[100:0:0:1::1]',
'[2001::1]',
'[2001:1::4]',
'[2001:2::1]',
'[2001:4:111::1]',
'[2001:10::1]',
'[2001:40::1]',
'[2001:db8::1]',
'[2002:a9fe:a9fe::1]',
'[3fff::1]',
'[3fff:fff::1]',
'[5f00::1]',
'[fc00::1]',
'[fd00::1]',
'[fe80::1]',
'[fec0::1]',
'[ff02::1]',
]) {
expect(
() => validateInviteRelayUri(
Uri.parse('wss://$host'),
allowInsecureLocalhost: false,
),
throwsFormatException,
reason: host,
);
}
});
test('rejects legacy numeric IPv4 spellings', () {
for (final host in ['2130706433', '0x7f000001', '0177.0.0.1', '127.1']) {
expect(
() => validateInviteRelayUri(
Uri.parse('wss://$host'),
allowInsecureLocalhost: false,
),
throwsFormatException,
reason: host,
);
}
});
test('rejects non-origin URLs', () {
for (final url in [
'https://relay.example.com',
'wss://user@relay.example.com',
'wss://relay.example.com/path',
'wss://relay.example.com?query=x',
'wss://relay.example.com#fragment',
]) {
expect(
() => validateInviteRelayUri(
Uri.parse(url),
allowInsecureLocalhost: false,
),
throwsFormatException,
reason: url,
);
}
});
});
}
@@ -0,0 +1,128 @@
import 'package:buzz/shared/reminders/remind_me_later_sheet.dart';
import 'package:buzz/shared/reminders/reminder_service.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
const _target = ReminderTarget(
eventId: 'msg-1',
channelId: 'chan-1',
preview: 'hello',
authorPubkey: 'alice',
);
/// Records createReminder calls; throws when [error] is set.
class _RecordingReminderService extends ReminderService {
final Object? error;
final List<int> submittedNotBefore = [];
_RecordingReminderService({this.error})
: super(
signedEventRelay: SignedEventRelay(
session: RelaySessionNotifier(),
nsec: nostr.Keys.generate().nsec,
),
crypto: _stubCrypto(),
);
@override
Future<void> createReminder({
required ReminderTarget target,
required int notBefore,
String? note,
}) async {
final error = this.error;
if (error != null) throw error;
submittedNotBefore.add(notBefore);
}
}
ReminderCrypto _stubCrypto() {
final keys = nostr.Keys.generate();
return ReminderCrypto(keys.nsec, keys.public);
}
Future<void> _pumpSheet(
WidgetTester tester, {
required ReminderService service,
}) async {
await tester.pumpWidget(
ProviderScope(
overrides: [reminderServiceProvider.overrideWithValue(service)],
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Consumer(
builder: (context, ref, _) => TextButton(
onPressed: () => showRemindMeLaterSheet(
context: context,
ref: ref,
target: _target,
),
child: const Text('open'),
),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
void main() {
group('showRemindMeLaterSheet', () {
testWidgets('cancelling the custom date picker keeps the reminder sheet '
'open', (tester) async {
final service = _RecordingReminderService();
await _pumpSheet(tester, service: service);
await tester.ensureVisible(find.text('Pick a date & time'));
await tester.pumpAndSettle();
await tester.tap(find.text('Pick a date & time'));
await tester.pumpAndSettle();
// Native date picker is up; cancel it.
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
// The preset sheet stays open for a retry, and nothing was submitted.
expect(find.text('Remind me about this message'), findsOneWidget);
expect(find.text('Pick a date & time'), findsOneWidget);
expect(service.submittedNotBefore, isEmpty);
});
testWidgets('preset submission failure shows stable copy without the '
'raw error', (tester) async {
final service = _RecordingReminderService(
error: Exception('nip44 conversation key mismatch'),
);
await _pumpSheet(tester, service: service);
await tester.tap(find.text('In 1 hour'));
await tester.pumpAndSettle();
expect(find.text('Couldnt set reminder. Try again.'), findsOneWidget);
expect(
find.textContaining('nip44 conversation key mismatch'),
findsNothing,
);
});
testWidgets('preset submission success confirms with a snackbar', (
tester,
) async {
final service = _RecordingReminderService();
await _pumpSheet(tester, service: service);
await tester.tap(find.text('In 1 hour'));
await tester.pumpAndSettle();
expect(service.submittedNotBefore, hasLength(1));
expect(find.text('Reminder set'), findsOneWidget);
});
});
}
@@ -0,0 +1,124 @@
import 'dart:convert';
import 'package:buzz/shared/reminders/reminder_service.dart';
import 'package:buzz/shared/reminders/reminder_time_presets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
void main() {
const target = ReminderTarget(
eventId: 'event-1',
channelId: 'channel-1',
preview: 'hello world',
authorPubkey: 'author-pk',
);
group('buildReminderPlaintext', () {
test('matches the desktop NIP-ER content shape', () {
final plaintext = buildReminderPlaintext(target: target, note: 'ping');
expect(jsonDecode(plaintext), {
'target': {
'eventId': 'event-1',
'channelId': 'channel-1',
'preview': 'hello world',
'authorPubkey': 'author-pk',
},
'note': 'ping',
'status': 'pending',
});
});
test('omits note when absent or empty', () {
for (final note in [null, '']) {
final decoded =
jsonDecode(buildReminderPlaintext(target: target, note: note))
as Map<String, dynamic>;
expect(decoded.containsKey('note'), isFalse);
expect(decoded['status'], 'pending');
}
});
});
group('randomReminderDTag', () {
test('is 32 lowercase hex chars (128 bits)', () {
final dTag = randomReminderDTag();
expect(dTag, matches(RegExp(r'^[0-9a-f]{32}$')));
});
test('is unique across calls', () {
expect(randomReminderDTag(), isNot(randomReminderDTag()));
});
});
group('buildReminderTags', () {
test('emits d and strict-decimal not_before tags', () {
expect(buildReminderTags(dTag: 'abc', notBefore: 1753000000), [
['d', 'abc'],
['not_before', '1753000000'],
]);
});
test('rejects negative timestamps', () {
expect(
() => buildReminderTags(dTag: 'abc', notBefore: -1),
throwsArgumentError,
);
});
});
group('ReminderCrypto', () {
test('encrypts to self and decrypts back', () {
final keys = nostr.Keys.generate();
final crypto = ReminderCrypto(keys.nsec, keys.public);
const plaintext = '{"status":"pending","note":"hi"}';
final ciphertext = crypto.encrypt(plaintext);
expect(ciphertext, isNot(contains('pending')));
expect(crypto.decrypt(ciphertext), plaintext);
});
});
group('reminder time presets', () {
test('presets resolve strictly in the future', () {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
for (final preset in reminderTimePresets) {
expect(preset.getTimestamp(), greaterThan(now), reason: preset.label);
}
});
test('nextDayAt9am rolls past instants forward', () {
final tenAm = DateTime(2026, 7, 25, 10);
final next = nextDayAt9am(0, now: tenAm);
expect(
DateTime.fromMillisecondsSinceEpoch(next * 1000),
DateTime(2026, 7, 26, 9),
);
});
test('nextDayAt9am keeps future same-day instants', () {
final eightAm = DateTime(2026, 7, 25, 8);
final next = nextDayAt9am(0, now: eightAm);
expect(
DateTime.fromMillisecondsSinceEpoch(next * 1000),
DateTime(2026, 7, 25, 9),
);
});
test('daysUntilNextMonday is 1-7 and lands on Monday', () {
for (var day = 20; day < 27; day++) {
final now = DateTime(2026, 7, day);
final days = daysUntilNextMonday(now);
expect(days, inInclusiveRange(1, 7));
expect(now.add(Duration(days: days)).weekday, DateTime.monday);
}
});
test('combineCustomDateTime rejects past instants', () {
final yesterday = DateTime.now().subtract(const Duration(days: 1));
expect(combineCustomDateTime(yesterday, 9, 0), isNull);
final tomorrow = DateTime.now().add(const Duration(days: 1));
expect(combineCustomDateTime(tomorrow, 9, 0), isNotNull);
});
});
}
@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/theme/theme.dart';
void main() {
group('default accent', () {
test('uses black on the default light scheme', () {
final resolved = resolveSchemes(null, ThemeMode.light);
final accented = applyAccent(resolved.light, defaultAccentIndex);
expect(accented.primary, const Color(0xFF000000));
});
test('uses the theme foreground on forced dark schemes', () {
final resolved = resolveSchemes('github-dark', ThemeMode.dark);
final base = resolved.dark;
final accented = applyAccent(base, defaultAccentIndex);
expect(resolved.forcedMode, ThemeMode.dark);
expect(accented.primary, base.onSurface);
expect(accented.primary, isNot(const Color(0xFF000000)));
expect(
_contrastRatio(accented.primary, accented.surface),
greaterThanOrEqualTo(4.5),
);
expect(accented.onPrimary, contrastForeground(accented.primary));
});
});
}
double _contrastRatio(Color a, Color b) {
final aLum = a.computeLuminance();
final bLum = b.computeLuminance();
final lightest = aLum > bLum ? aLum : bLum;
final darkest = aLum > bLum ? bLum : aLum;
return (lightest + 0.05) / (darkest + 0.05);
}
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/theme/app_theme.dart';
void main() {
test('disables Material touch ripples in every app theme', () {
expect(AppTheme.light().splashFactory, NoSplash.splashFactory);
expect(AppTheme.dark().splashFactory, NoSplash.splashFactory);
});
test('uses Inter and the shared elevated popover treatment', () {
final theme = AppTheme.light();
final popupTheme = theme.popupMenuTheme;
final shape = popupTheme.shape! as RoundedRectangleBorder;
final side = shape.side;
expect(popupTheme.textStyle?.fontFamily, 'Inter');
expect(popupTheme.elevation, 8);
expect(
popupTheme.shadowColor,
theme.colorScheme.shadow.withValues(alpha: 0.18),
);
expect(shape.borderRadius, BorderRadius.circular(Radii.popover));
expect(side.color, Colors.black.withValues(alpha: 0.04));
expect(side.width, 1);
});
}
@@ -0,0 +1,267 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
void main() {
group('Buzz theme catalog entries', () {
test('both halves are in the catalog', () {
expect(findTheme(buzzThemeName), isNotNull);
expect(findTheme(buzzDarkThemeName), isNotNull);
});
test('borrow the GitHub palettes', () {
final buzz = findTheme(buzzThemeName)!;
final github = findTheme('github-light')!;
expect(buzz.bg, github.bg);
expect(buzz.fg, github.fg);
expect(buzz.comment, github.comment);
final buzzDark = findTheme(buzzDarkThemeName)!;
final githubDark = findTheme('github-dark')!;
expect(buzzDark.bg, githubDark.bg);
expect(buzzDark.fg, githubDark.fg);
expect(buzzDark.comment, githubDark.comment);
});
test('are a light/dark pair', () {
expect(findTheme(buzzThemeName)!.isDark, isFalse);
expect(findTheme(buzzDarkThemeName)!.isDark, isTrue);
expect(themePairFor(buzzThemeName), buzzDarkThemeName);
expect(themePairFor(buzzDarkThemeName), buzzThemeName);
});
test('appear as a single System-mode option labelled "Buzz"', () {
final paired = themeGroups().paired.map((t) => t.name);
expect(paired, contains(buzzThemeName));
expect(paired, isNot(contains(buzzDarkThemeName)));
expect(pairedThemeLabel(buzzThemeName), 'Buzz');
expect(themeSelectionLabel(buzzThemeName, ThemeMode.system), 'Buzz');
expect(themeSelectionLabel(buzzDarkThemeName, ThemeMode.system), 'Buzz');
});
test('forces neutral rendering without changing the stored accent', () {
const storedAccent = '#ef4444';
expect(
effectiveAccentIndex(buzzThemeName, storedAccent),
neutralAccentIndex,
);
expect(
effectiveAccentIndex(buzzDarkThemeName, storedAccent),
neutralAccentIndex,
);
expect(
effectiveAccentIndex('github-light', storedAccent),
accentIndexForWireValue(storedAccent),
);
expect(storedAccent, '#ef4444');
});
test('resolve across brightnesses like any other pair', () {
final resolved = resolveSchemes(buzzThemeName, ThemeMode.system);
expect(resolved.forcedMode, isNull);
expect(resolved.light.brightness, Brightness.light);
expect(resolved.dark.brightness, Brightness.dark);
expect(resolved.lightTheme?.name, buzzThemeName);
expect(resolved.darkTheme?.name, buzzDarkThemeName);
expect(
effectiveTheme(buzzThemeName, ThemeMode.dark)?.name,
buzzDarkThemeName,
);
expect(
effectiveTheme(buzzDarkThemeName, ThemeMode.light)?.name,
buzzThemeName,
);
});
test(
'fallbacks expose the effective Buzz theme for gradient selection',
() {
final coerced = resolveSchemes('nord', ThemeMode.light);
expect(coerced.lightTheme?.name, buzzThemeName);
expect(
buzzTopSectionGradient(
coerced.lightTheme!.name,
coerced.light.brightness,
),
isNotNull,
);
final unknown = resolveSchemes('not-a-theme', ThemeMode.light);
expect(unknown.lightTheme?.name, buzzThemeName);
expect(
buzzTopSectionGradient(
unknown.lightTheme!.name,
unknown.light.brightness,
),
isNotNull,
);
},
);
});
group('buzzTopSectionGradient', () {
test('is null for non-Buzz themes', () {
expect(buzzTopSectionGradient('github-light', Brightness.light), isNull);
expect(buzzTopSectionGradient('nord', Brightness.dark), isNull);
});
test('paints top to bottom for both halves of the pair', () {
for (final name in [buzzThemeName, buzzDarkThemeName]) {
final gradient = buzzTopSectionGradient(name, Brightness.light);
expect(gradient, isNotNull, reason: '$name should be gradient-backed');
expect(gradient!.begin, Alignment.topCenter);
expect(gradient.end, Alignment.bottomCenter);
expect(gradient.colors, hasLength(2));
}
});
test('brightness selects the stops, not the theme name', () {
// Both halves enable the gradient, so System mode keeps it on across an
// OS switch — the applied brightness alone decides which stops are used.
final light = buzzTopSectionGradient(buzzThemeName, Brightness.light)!;
final dark = buzzTopSectionGradient(buzzThemeName, Brightness.dark)!;
expect(light.colors, isNot(dark.colors));
expect(
buzzTopSectionGradient(buzzDarkThemeName, Brightness.dark)!.colors,
dark.colors,
);
expect(
buzzTopSectionGradient(buzzDarkThemeName, Brightness.light)!.colors,
light.colors,
);
});
test('is opaque so the color replaces the frosted fill', () {
for (final brightness in Brightness.values) {
final gradient = buzzTopSectionGradient(buzzThemeName, brightness)!;
for (final color in gradient.colors) {
expect(color.a, 1.0);
}
}
});
});
group('theme threading', () {
BoxDecoration barDecoration(WidgetTester tester) {
final container = tester
.widgetList<Container>(
find.descendant(
of: find.byType(FrostedAppBar),
matching: find.byType(Container),
),
)
.first;
return container.decoration! as BoxDecoration;
}
Widget harness(ThemeData theme) => MaterialApp(
theme: theme,
home: Builder(
builder: (context) => Stack(
children: [
FrostedAppBar(
gradient: context.appColors.topSectionGradient,
title: const Text('Home'),
),
],
),
),
);
testWidgets('AppTheme carries the gradient to the top section', (
tester,
) async {
await tester.pumpWidget(
harness(
AppTheme.light(
topSectionGradient: buzzTopSectionGradient(
buzzThemeName,
Brightness.light,
),
),
),
);
final decoration = barDecoration(tester);
expect(decoration.gradient, isNotNull);
// A BoxDecoration cannot paint a color and a gradient at once.
expect(decoration.color, isNull);
});
testWidgets('non-Buzz themes keep the frosted surface fill', (
tester,
) async {
await tester.pumpWidget(harness(AppTheme.light()));
final decoration = barDecoration(tester);
expect(decoration.gradient, isNull);
expect(decoration.color, isNotNull);
});
testWidgets('Buzz section labels use 80% neutral foreground', (
tester,
) async {
await tester.pumpWidget(
harness(
AppTheme.light(
topSectionGradient: buzzTopSectionGradient(
buzzThemeName,
Brightness.light,
),
),
),
);
final context = tester.element(find.text('Home'));
expect(
navigationSectionForeground(context),
Colors.black.withValues(alpha: 0.8),
);
});
testWidgets('navigation roles inherit non-Buzz theme tokens', (
tester,
) async {
const primaryForeground = Color(0xFF123456);
const secondaryForeground = Color(0xFF789ABC);
const searchSurface = Color(0xFFDEF012);
final theme = ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple).copyWith(
onSurface: primaryForeground,
onSurfaceVariant: secondaryForeground,
surfaceContainerHighest: searchSurface,
),
);
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: const Scaffold(body: SizedBox()),
),
);
final context = tester.element(find.byType(SizedBox));
expect(navigationPrimaryForeground(context), primaryForeground);
expect(navigationSecondaryForeground(context), secondaryForeground);
expect(navigationSectionForeground(context), secondaryForeground);
expect(navigationSearchSurface(context), searchSurface);
expect(
navigationDivider(context, 0.15),
primaryForeground.withValues(alpha: 0.15),
);
});
});
group('isBuzzTheme', () {
test('matches only the Buzz pair', () {
expect(isBuzzTheme(buzzThemeName), isTrue);
expect(isBuzzTheme(buzzDarkThemeName), isTrue);
expect(isBuzzTheme('github-light'), isFalse);
expect(isBuzzTheme(''), isFalse);
});
});
}
@@ -0,0 +1,123 @@
import 'dart:convert';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
test('desktop v1 payload round-trips exactly', () {
final preference = CommunityThemePreference.fromJson({
'version': 1,
'theme': 'github-dark',
'accent': '#c0a2f1',
'followSystem': false,
});
expect(preference.mode, ThemeMode.dark);
expect(
jsonEncode(preference.toJson()),
'{"version":1,"theme":"github-dark","accent":"#c0a2f1","followSystem":false}',
);
});
test('rejects unknown themes, accents, and future versions', () {
for (final payload in [
{
'version': 2,
'theme': 'buzz',
'accent': '#3b82f6',
'followSystem': true,
},
{
'version': 1,
'theme': 'unknown',
'accent': '#3b82f6',
'followSystem': true,
},
{
'version': 1,
'theme': 'buzz',
'accent': '#000000',
'followSystem': true,
},
]) {
expect(
() => CommunityThemePreference.fromJson(payload),
throwsFormatException,
);
}
});
test('storage is scoped by pubkey and normalized relay URL', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final storage = CommunityThemeStorage(prefs);
const a = CommunityThemePreference(
theme: 'buzz',
accent: '#3b82f6',
followSystem: true,
);
const b = CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
);
await storage.write('pk', 'WSS://Relay.Example///', a);
await storage.write('pk', 'wss://other.example', b);
expect(storage.read('pk', 'wss://relay.example'), a);
expect(storage.read('pk', 'wss://other.example/'), b);
expect(storage.read('other-pk', 'wss://relay.example'), isNull);
});
test('dirty outbox survives restart and clears only exact ack', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final storage = CommunityThemeStorage(prefs);
const pending = CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
);
const newer = CommunityThemePreference(
theme: 'houston',
accent: '#a855f7',
followSystem: false,
);
await storage.writeOutbox('pk', 'wss://relay.example', pending);
expect(
CommunityThemeStorage(prefs).readOutbox('pk', 'wss://relay.example'),
pending,
);
await storage.writeOutbox('pk', 'wss://relay.example', newer);
await storage.clearOutbox('pk', 'wss://relay.example', pending);
expect(storage.readOutbox('pk', 'wss://relay.example'), newer);
await storage.clearOutbox('pk', 'wss://relay.example', newer);
expect(storage.readOutbox('pk', 'wss://relay.example'), isNull);
});
test('legacy accent indexes migrate without inventing wire values', () async {
SharedPreferences.setMockInitialValues({
'buzz_theme_mode': 'dark',
'buzz_color_scheme': 'dracula',
'buzz_accent_color': 8,
});
final prefs = await SharedPreferences.getInstance();
final preference = CommunityThemeStorage(prefs).legacyPreference();
expect(
preference,
const CommunityThemePreference(
theme: 'dracula',
accent: 'neutral',
followSystem: false,
),
);
expect(preference.mode, ThemeMode.dark);
expect(legacyAccentWireValue(6), '#a855f7');
expect(legacyAccentWireValue(7), '#6366f1');
});
}
@@ -0,0 +1,311 @@
import 'dart:async';
import 'dart:convert';
import 'package:buzz/shared/crypto/nip44.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
void main() {
test('delayed absence seeds the intervening local edit', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keys = nostr.Keys.generate();
final history = Completer<List<NostrEvent>>();
final session = _ThemeRelaySession(
keys.nsec,
keys.public,
historyFuture: history.future,
);
final storage = CommunityThemeStorage(prefs);
final container = ProviderContainer(
overrides: [
communityThemeStorageProvider.overrideWithValue(storage),
relayConfigProvider.overrideWith(() => _RelayConfig(keys.nsec)),
relaySessionProvider.overrideWith(() => session),
],
);
addTearDown(container.dispose);
container.listen(communityThemeProvider, (_, _) {}, fireImmediately: true);
container.read(communityThemeProvider.notifier).setTheme('dracula');
history.complete([]);
await _waitUntil(() => session.published != null);
expect(container.read(communityThemeProvider).theme, 'dracula');
expect(
storage.read(keys.public, 'https://relay.example')?.theme,
'dracula',
);
});
test('edit during delayed absence seed wins durable state', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keys = nostr.Keys.generate();
final history = Completer<List<NostrEvent>>();
final session = _ThemeRelaySession(
keys.nsec,
keys.public,
historyFuture: history.future,
);
final storage = _DelayedThemeStorage(prefs);
final container = ProviderContainer(
overrides: [
communityThemeStorageProvider.overrideWithValue(storage),
relayConfigProvider.overrideWith(() => _RelayConfig(keys.nsec)),
relaySessionProvider.overrideWith(() => session),
],
);
addTearDown(container.dispose);
container.listen(communityThemeProvider, (_, _) {}, fireImmediately: true);
history.complete([]);
await storage.cacheWriteStarted.future;
container.read(communityThemeProvider.notifier).setTheme('dracula');
storage.allowCacheWrite.complete();
storage.allowOutboxWrite.complete();
await _waitUntil(() => session.published != null);
expect(container.read(communityThemeProvider).theme, 'dracula');
expect(
storage.read(keys.public, 'https://relay.example')?.theme,
'dracula',
);
final privateHex = nostr.Nip19.decode(payload: keys.nsec).data;
final key = getConversationKey(privateHex, keys.public);
expect(
jsonDecode(nip44Decrypt(key, session.published!.content))['theme'],
'dracula',
);
});
test(
'local edit stays authoritative before persistence through exact ack',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keys = nostr.Keys.generate();
final session = _ThemeRelaySession(keys.nsec, keys.public);
final storage = _DelayedThemeStorage(prefs);
final container = ProviderContainer(
overrides: [
communityThemeStorageProvider.overrideWithValue(storage),
relayConfigProvider.overrideWith(() => _RelayConfig(keys.nsec)),
relaySessionProvider.overrideWith(() => session),
],
);
addTearDown(container.dispose);
final subscription = container.listen(
communityThemeProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(subscription.close);
await session.subscribed.future;
final notifier = container.read(communityThemeProvider.notifier);
notifier.setTheme('dracula');
const local = CommunityThemePreference(
theme: 'dracula',
accent: '#3b82f6',
followSystem: true,
);
expect(container.read(communityThemeProvider), local);
session.emit(session.remoteEvent(theme: 'houston', id: 'remote-z'));
expect(container.read(communityThemeProvider), local);
storage.allowCacheWrite.complete();
await storage.outboxWriteStarted.future;
session.emit(session.remoteEvent(theme: 'solarized', id: 'remote-a'));
expect(container.read(communityThemeProvider), local);
storage.allowOutboxWrite.complete();
await _waitUntil(() => session.published != null);
expect(container.read(communityThemeProvider), local);
session.emit(session.published!);
await _pumpEventQueue();
expect(container.read(communityThemeProvider), local);
expect(storage.readOutbox(keys.public, 'https://relay.example'), isNull);
},
);
test(
'provider rebuild preserves delayed local edit and publishes on replacement manager',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keys = nostr.Keys.generate();
final session = _ThemeRelaySession(keys.nsec, keys.public);
final storage = _DelayedThemeStorage(prefs);
final container = ProviderContainer(
overrides: [
communityThemeStorageProvider.overrideWithValue(storage),
relayConfigProvider.overrideWith(() => _RelayConfig(keys.nsec)),
relaySessionProvider.overrideWith(() => session),
],
);
addTearDown(container.dispose);
final subscription = container.listen(
communityThemeProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(subscription.close);
await session.subscribed.future;
container.read(communityThemeProvider.notifier).setTheme('dracula');
expect(container.read(communityThemeProvider).theme, 'dracula');
await storage.cacheWriteStarted.future;
session.setStatus(SessionStatus.reconnecting);
await _pumpEventQueue();
expect(container.read(communityThemeProvider).theme, 'dracula');
session.setStatus(SessionStatus.connected);
await _waitUntil(() => session.subscribeCalls == 2);
expect(container.read(communityThemeProvider).theme, 'dracula');
storage.allowCacheWrite.complete();
storage.allowOutboxWrite.complete();
await _waitUntil(() => session.published != null);
final privateHex = nostr.Nip19.decode(payload: keys.nsec).data;
final key = getConversationKey(privateHex, keys.public);
expect(
jsonDecode(nip44Decrypt(key, session.published!.content))['theme'],
'dracula',
);
expect(container.read(communityThemeProvider).theme, 'dracula');
},
);
}
class _DelayedThemeStorage extends CommunityThemeStorage {
_DelayedThemeStorage(super.prefs);
final allowCacheWrite = Completer<void>();
final allowOutboxWrite = Completer<void>();
final cacheWriteStarted = Completer<void>();
final outboxWriteStarted = Completer<void>();
@override
Future<bool> write(
String pubkey,
String relayUrl,
CommunityThemePreference preference,
) async {
if (!cacheWriteStarted.isCompleted) cacheWriteStarted.complete();
await allowCacheWrite.future;
return super.write(pubkey, relayUrl, preference);
}
@override
Future<bool> writeOutbox(
String pubkey,
String relayUrl,
CommunityThemePreference preference,
) async {
if (!outboxWriteStarted.isCompleted) outboxWriteStarted.complete();
await allowOutboxWrite.future;
return super.writeOutbox(pubkey, relayUrl, preference);
}
}
class _RelayConfig extends RelayConfigNotifier {
_RelayConfig(this.nsec);
final String nsec;
@override
RelayConfig build() =>
RelayConfig(baseUrl: 'https://relay.example', nsec: nsec);
}
class _ThemeRelaySession extends RelaySessionNotifier {
_ThemeRelaySession(this.nsec, this.pubkey, {this.historyFuture});
final String nsec;
final String pubkey;
final Future<List<NostrEvent>>? historyFuture;
final subscribed = Completer<void>();
int subscribeCalls = 0;
void Function(NostrEvent)? _listener;
NostrEvent? published;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => historyFuture ?? [remoteEvent(theme: 'buzz', id: 'initial')];
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async {
subscribeCalls++;
_listener = onEvent;
if (!subscribed.isCompleted) subscribed.complete();
return () => _listener = null;
}
@override
Future<NostrEvent> publish(
NostrEvent event, {
Duration timeout = const Duration(seconds: 8),
}) async {
published = event;
return event;
}
void emit(NostrEvent event) => _listener?.call(event);
void setStatus(SessionStatus status) {
state = SessionState(status: status);
}
NostrEvent remoteEvent({required String theme, required String id}) {
final privateHex = nostr.Nip19.decode(payload: nsec).data;
final key = getConversationKey(privateHex, pubkey);
final preference = CommunityThemePreference(
theme: theme,
accent: '#3b82f6',
followSystem: true,
);
return NostrEvent(
id: id,
pubkey: pubkey,
createdAt: 1,
kind: 30078,
tags: const [
['d', communityThemeDTag],
['t', communityThemeDTag],
],
content: nip44Encrypt(key, jsonEncode(preference.toJson())),
sig: 'sig',
);
}
}
Future<void> _pumpEventQueue() async {
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
}
Future<void> _waitUntil(bool Function() condition) async {
final deadline = DateTime.now().add(const Duration(seconds: 3));
while (!condition()) {
if (DateTime.now().isAfter(deadline)) fail('condition not met');
await Future<void>.delayed(const Duration(milliseconds: 5));
}
}
@@ -0,0 +1,546 @@
import 'dart:async';
import 'dart:convert';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const local = CommunityThemePreference(
theme: 'buzz',
accent: '#3b82f6',
followSystem: true,
);
test('confirmed absence seeds exact NIP-78 coordinate', () async {
final session = _FakeSession();
final relay = _FakeSignedRelay();
final manager = _manager(session, relay);
final result = await manager.initialize();
expect(result.status, CommunityThemeRemoteStatus.absent);
manager.publish(local);
await manager.flush();
expect(relay.submissions, hasLength(1));
expect(relay.submissions.single.kind, 30078);
expect(
relay.submissions.single.tags,
containsAll(<List<String>>[
['d', 'community-theme'],
['t', 'community-theme'],
]),
);
expect(jsonDecode(relay.submissions.single.content), local.toJson());
});
test(
'live replacement closes the history-to-subscription absence gap',
() async {
const replacement = CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
);
final applied = <CommunityThemePreference>[];
late final _FakeSession session;
session = _FakeSession(
onFetchHistory: () {
session.emit(
_event(
id: 'replacement',
createdAt: 100,
content: jsonEncode(replacement.toJson()),
),
);
},
);
final manager = _manager(
session,
_FakeSignedRelay(),
onRemote: (remote) => applied.add(remote.preference),
);
final result = await manager.initialize();
expect(session.subscribeCalls, 1);
expect(result.status, CommunityThemeRemoteStatus.valid);
expect(result.remote?.preference, replacement);
expect(applied, [replacement]);
},
);
test('invalid and unavailable records never seed', () async {
for (final session in [
_FakeSession(history: [_event(content: '{bad json')]),
_FakeSession(error: StateError('offline')),
]) {
final relay = _FakeSignedRelay();
final result = await _manager(session, relay).initialize();
expect(
result.status,
anyOf(
CommunityThemeRemoteStatus.invalid,
CommunityThemeRemoteStatus.unavailable,
),
);
expect(relay.submissions, isEmpty);
}
});
test(
'newest valid event wins with deterministic same-second ordering',
() async {
final applied = <CommunityThemePreference>[];
final session = _FakeSession(
history: [
_event(
id: 'z',
createdAt: 50,
content: jsonEncode(
const CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
).toJson(),
),
),
_event(id: 'a', createdAt: 50, content: jsonEncode(local.toJson())),
],
);
final manager = _manager(
session,
_FakeSignedRelay(),
onRemote: (r) => applied.add(r.preference),
);
await manager.initialize();
expect(applied.single.theme, 'buzz');
session.emit(
_event(
id: 'z',
createdAt: 50,
content: jsonEncode(
const CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
).toJson(),
),
),
);
expect(applied, hasLength(1));
},
);
test('remote hydration never cancels a newer pending local write', () async {
final relay = _FakeSignedRelay();
final session = _FakeSession();
final manager = _manager(session, relay);
await manager.initialize();
manager.cancelPending();
manager.publish(
const CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
),
);
session.emit(
_event(id: 'remote', createdAt: 100, content: jsonEncode(local.toJson())),
);
await manager.flush();
expect(relay.submissions, hasLength(1));
expect(jsonDecode(relay.submissions.single.content)['theme'], 'dracula');
manager.publish(local);
manager.dispose();
await manager.flush();
expect(relay.submissions, hasLength(1));
});
test(
'relay CLOSED resubscribes then catches up latest replacement event',
() async {
final applied = <CommunityThemePreference>[];
final session = _FakeSession();
final manager = _manager(
session,
_FakeSignedRelay(),
onRemote: (remote) => applied.add(remote.preference),
);
await manager.initialize();
manager.cancelPending();
expect(session.subscribeCalls, 1);
const replacement = CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
);
session.history = [
_event(
id: 'replacement',
createdAt: 100,
content: jsonEncode(replacement.toJson()),
),
];
session.closeLiveSubscription('rate-limited: quota exceeded');
await _waitUntil(() => session.subscribeCalls == 2 && applied.isNotEmpty);
expect(applied.single, replacement);
expect(session.activeListeners, 1);
manager.dispose();
},
);
test('relay CLOSED after dispose never resubscribes', () async {
final session = _FakeSession();
final manager = _manager(session, _FakeSignedRelay());
await manager.initialize();
final close = session.latestClosedCallback;
manager.dispose();
close?.call('late close');
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(session.subscribeCalls, 1);
});
test('publish failure retries and acknowledges exact preference', () async {
final relay = _FakeSignedRelay(failuresRemaining: 1);
final acknowledgements = <CommunityThemePreference>[];
final manager = _manager(
_FakeSession(),
relay,
onPublished: acknowledgements.add,
);
manager.publish(local);
await manager.flush();
expect(manager.pending, local);
await _waitUntil(() => acknowledgements.length == 1);
expect(relay.attempts, 2);
expect(manager.pending, isNull);
expect(acknowledgements, [local]);
});
test('serializes in-flight publish before latest edit', () async {
final firstSubmission = Completer<void>();
final relay = _FakeSignedRelay(firstSubmissionGate: firstSubmission.future);
final manager = _manager(_FakeSession(), relay);
const latest = CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
);
manager.publish(local);
final firstFlush = manager.flush();
await _waitUntil(() => relay.attempts == 1);
manager.publish(latest);
await manager.flush();
expect(relay.attempts, 1);
firstSubmission.complete();
await firstFlush;
await _waitUntil(() => relay.attempts == 2);
expect(relay.submittedEvents, hasLength(2));
expect(
relay.submittedEvents[1].createdAt,
greaterThan(relay.submittedEvents[0].createdAt),
);
expect(jsonDecode(relay.submissions[1].content)['theme'], 'dracula');
expect(manager.pending, isNull);
});
test(
'republishes above remote observed while publish is in flight',
() async {
final firstSubmission = Completer<void>();
final relay = _FakeSignedRelay(
firstSubmissionGate: firstSubmission.future,
);
final session = _FakeSession();
final acknowledgements = <CommunityThemePreference>[];
final manager = _manager(
session,
relay,
onPublished: acknowledgements.add,
);
await manager.initialize();
manager.publish(local);
final firstFlush = manager.flush();
await _waitUntil(() => relay.attempts == 1);
session.emit(
_event(
id: 'remote-winner',
createdAt: 2000000000,
content: jsonEncode(
const CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
).toJson(),
),
),
);
firstSubmission.complete();
await firstFlush;
expect(acknowledgements, isEmpty);
expect(manager.pending, local);
await _waitUntil(() => relay.attempts == 2);
expect(relay.submittedEvents[1].createdAt, 2000000001);
expect(acknowledgements, [local]);
expect(manager.pending, isNull);
},
);
test('remote coordinate advances pending local publish timestamp', () async {
final relay = _FakeSignedRelay();
final session = _FakeSession();
final manager = _manager(session, relay);
await manager.initialize();
manager.publish(local);
session.emit(
_event(
id: 'remote',
createdAt: 2000000000,
content: jsonEncode(local.toJson()),
),
);
await manager.flush();
expect(relay.submittedEvents.single.createdAt, 2000000001);
});
test(
'remote replacement invalidates A to B to A no-op suppression',
() async {
final relay = _FakeSignedRelay();
final session = _FakeSession();
final manager = _manager(session, relay);
await manager.initialize();
manager.publish(local);
await manager.flush();
const remotePreference = CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
);
session.emit(
_event(
id: 'remote',
createdAt: relay.submittedEvents.single.createdAt + 1,
content: jsonEncode(remotePreference.toJson()),
),
);
manager.publish(local);
await manager.flush();
expect(relay.submissions, hasLength(2));
expect(manager.pending, isNull);
},
);
test(
'published coordinate rejects delayed same-second initialization result',
() async {
const stale = CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
);
final history = Completer<List<NostrEvent>>();
final session = _FakeSession(historyFuture: history.future);
final relay = _FakeSignedRelay(eventId: 'published-a');
final applied = <CommunityThemePreference>[];
final manager = _manager(
session,
relay,
onRemote: (remote) => applied.add(remote.preference),
);
final initializing = manager.initialize();
manager.publish(local);
await manager.flush();
final createdAt = relay.submittedEvents.single.createdAt;
history.complete([
_event(
id: 'published-z',
createdAt: createdAt,
content: jsonEncode(stale.toJson()),
),
]);
await initializing;
expect(applied, isEmpty);
expect(manager.pending, isNull);
},
);
}
CommunityThemeSyncManager _manager(
_FakeSession session,
_FakeSignedRelay relay, {
void Function(RemoteCommunityTheme)? onRemote,
void Function(CommunityThemePreference)? onPublished,
}) => CommunityThemeSyncManager(
pubkey: 'pk',
relaySession: session,
signedEventRelay: relay,
crypto: const CommunityThemeCrypto(encrypt: _identity, decrypt: _identity),
debounce: const Duration(days: 1),
publishRetryBase: const Duration(milliseconds: 1),
publishRetryMax: const Duration(milliseconds: 4),
subscriptionRetryBase: const Duration(milliseconds: 1),
onRemote: onRemote ?? (_) {},
onPublished: onPublished ?? (_) {},
);
String _identity(String value) => value;
NostrEvent _event({
String id = 'event',
int createdAt = 1,
required String content,
}) => NostrEvent(
id: id,
pubkey: 'pk',
createdAt: createdAt,
kind: 30078,
tags: const [
['d', 'community-theme'],
],
content: content,
sig: 'sig',
);
class _FakeSession extends RelaySessionNotifier {
_FakeSession({
List<NostrEvent> history = const [],
this.historyFuture,
this.error,
this.onFetchHistory,
}) : history = List.of(history);
List<NostrEvent> history;
final Future<List<NostrEvent>>? historyFuture;
final Object? error;
final void Function()? onFetchHistory;
int subscribeCalls = 0;
final List<void Function(NostrEvent)> _listeners = [];
final List<void Function(String)> _closedCallbacks = [];
int get activeListeners => _listeners.length;
void Function(String)? get latestClosedCallback =>
_closedCallbacks.isEmpty ? null : _closedCallbacks.last;
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (error != null) throw error!;
onFetchHistory?.call();
if (historyFuture != null) return historyFuture!;
return history;
}
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String)? onClosed,
}) async {
subscribeCalls++;
_listeners.add(onEvent);
_closedCallbacks.add(onClosed ?? (_) {});
return () {
final index = _listeners.indexOf(onEvent);
if (index < 0) return;
_listeners.removeAt(index);
_closedCallbacks.removeAt(index);
};
}
void emit(NostrEvent event) {
for (final listener in List.of(_listeners)) {
listener(event);
}
}
void closeLiveSubscription(String message) {
if (_listeners.isEmpty) return;
_listeners.removeAt(0);
_closedCallbacks.removeAt(0)(message);
}
}
Future<void> _waitUntil(
bool Function() condition, {
Duration timeout = const Duration(seconds: 2),
}) async {
final deadline = DateTime.now().add(timeout);
while (!condition()) {
if (DateTime.now().isAfter(deadline)) {
fail('condition not met within $timeout');
}
await Future<void>.delayed(const Duration(milliseconds: 1));
}
}
class _Submission {
final int kind;
final String content;
final List<List<String>> tags;
const _Submission(this.kind, this.content, this.tags);
}
class _FakeSignedRelay implements SignedEventRelay {
_FakeSignedRelay({
this.failuresRemaining = 0,
this.eventId = 'event',
this.firstSubmissionGate,
});
int failuresRemaining;
final String eventId;
final Future<void>? firstSubmissionGate;
int attempts = 0;
final submissions = <_Submission>[];
final submittedEvents = <NostrEvent>[];
@override
String? get pubkey => 'pk';
@override
Future<NostrEvent> submit({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
void Function(NostrEvent)? onSigned,
}) async {
attempts++;
if (attempts == 1 && firstSubmissionGate != null) {
await firstSubmissionGate;
}
if (failuresRemaining > 0) {
failuresRemaining--;
throw StateError('publish failed');
}
submissions.add(_Submission(kind, content, tags));
final event = _event(
id: eventId,
content: content,
createdAt: createdAt ?? 0,
);
onSigned?.call(event);
submittedEvents.add(event);
return event;
}
}
@@ -0,0 +1,148 @@
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
void expectStyle(
TextStyle style, {
required double fontSize,
required FontWeight fontWeight,
required double lineHeight,
required double letterSpacing,
}) {
expect(style.fontFamily, 'Inter');
expect(style.fontSize, fontSize);
expect(style.fontWeight, fontWeight);
expect(style.height, closeTo(lineHeight / fontSize, 0.0001));
expect(style.letterSpacing, letterSpacing);
}
test('message typography matches the shared mobile scale', () {
expectStyle(
messageBodyTextStyle,
fontSize: 15,
fontWeight: FontWeight.w400,
lineHeight: 20,
letterSpacing: 0,
);
expectStyle(
messageUsernameTextStyle,
fontSize: 15,
fontWeight: FontWeight.w600,
lineHeight: 17,
letterSpacing: 0,
);
expectStyle(
messageTimestampTextStyle,
fontSize: 15,
fontWeight: FontWeight.w400,
lineHeight: 17,
letterSpacing: 0,
);
expect(messageTimestampTextStyle, messageMetadataTextStyle);
expectStyle(
replyPreviewTextStyle,
fontSize: 13.1,
fontWeight: FontWeight.w400,
lineHeight: 17,
letterSpacing: 0,
);
expectStyle(
reactionCountTextStyle,
fontSize: 13.1,
fontWeight: FontWeight.w500,
lineHeight: 17,
letterSpacing: 0,
);
expectStyle(
channelTitleTextStyle,
fontSize: 20,
fontWeight: FontWeight.w700,
lineHeight: 24,
letterSpacing: 0,
);
expectStyle(
systemMessageHeadingTextStyle,
fontSize: 15,
fontWeight: FontWeight.w600,
lineHeight: 17,
letterSpacing: 0,
);
expectStyle(
systemMessageBodyTextStyle,
fontSize: 15,
fontWeight: FontWeight.w400,
lineHeight: 20,
letterSpacing: 0,
);
});
test('activity typography matches the conversation row scale', () {
expectStyle(
activityUsernameTextStyle,
fontSize: 15,
fontWeight: FontWeight.w600,
lineHeight: 17,
letterSpacing: 0,
);
expectStyle(
activityTimestampTextStyle,
fontSize: 15,
fontWeight: FontWeight.w400,
lineHeight: 17,
letterSpacing: 0,
);
expectStyle(
activityContextTextStyle,
fontSize: 13.1,
fontWeight: FontWeight.w500,
lineHeight: 17,
letterSpacing: 0,
);
expectStyle(
activityPreviewTextStyle,
fontSize: 15,
fontWeight: FontWeight.w400,
lineHeight: 20,
letterSpacing: 0,
);
});
test('content list typography matches the compact list scale', () {
expectStyle(
contentListTitleTextStyle,
fontSize: 15,
fontWeight: FontWeight.w600,
lineHeight: 17,
letterSpacing: 0,
);
expectStyle(
contentListBodyTextStyle,
fontSize: 13.1,
fontWeight: FontWeight.w400,
lineHeight: 17,
letterSpacing: 0,
);
expectStyle(
contentListTimestampTextStyle,
fontSize: 15,
fontWeight: FontWeight.w400,
lineHeight: 17,
letterSpacing: 0,
);
expectStyle(
filterChipTextStyle,
fontSize: 15,
fontWeight: FontWeight.w400,
lineHeight: 15,
letterSpacing: 0,
);
});
test('message and activity avatars use their surface sizes', () {
expect(messageAvatarSize, 42);
expect(activityAvatarSize, 42);
expect(compactMessageAvatarSize, 42);
expect(messageAvatarContentGap, Grid.twelve);
});
}
@@ -0,0 +1,254 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/theme/theme.dart';
void main() {
group('themePairs', () {
test('every pair member exists in the catalog', () {
for (final entry in themePairs.entries) {
expect(
findTheme(entry.key),
isNotNull,
reason: '${entry.key} is not in themeCatalog',
);
expect(
findTheme(entry.value),
isNotNull,
reason: '${entry.value} is not in themeCatalog',
);
}
});
test('pairs map a light theme to a dark one', () {
for (final entry in themePairs.entries) {
expect(
findTheme(entry.key)!.isDark,
isFalse,
reason: '${entry.key} should be light',
);
expect(
findTheme(entry.value)!.isDark,
isTrue,
reason: '${entry.value} should be dark',
);
}
});
test('resolves in both directions', () {
expect(themePairFor('github-light'), 'github-dark');
expect(themePairFor('github-dark'), 'github-light');
expect(themePairFor('andromeeda'), isNull);
expect(isPairedTheme('catppuccin-latte'), isTrue);
expect(isPairedTheme('andromeeda'), isFalse);
});
});
group('themeGroups', () {
test('light and dark partition the whole catalog', () {
final groups = themeGroups();
expect(groups.light.length + groups.dark.length, themeCatalog.length);
expect(groups.light.every((t) => !t.isDark), isTrue);
expect(groups.dark.every((t) => t.isDark), isTrue);
});
test('paired holds only light members of pairs', () {
final groups = themeGroups();
expect(groups.paired.length, themePairs.length);
for (final theme in groups.paired) {
expect(theme.isDark, isFalse);
expect(themePairs.containsKey(theme.name), isTrue);
}
});
test('groups are sorted by display name', () {
final groups = themeGroups();
for (final list in [groups.paired, groups.light, groups.dark]) {
final names = list.map((t) => t.displayName).toList();
expect(names, orderedEquals(List.of(names)..sort()));
}
});
});
group('pairedThemeLabel', () {
test('strips brightness tokens', () {
expect(pairedThemeLabel('github-light'), 'Github');
expect(pairedThemeLabel('github-light-default'), 'Github Default');
expect(pairedThemeLabel('gruvbox-light-soft'), 'Gruvbox Soft');
expect(pairedThemeLabel('material-theme-lighter'), 'Material Theme');
expect(pairedThemeLabel('catppuccin-latte'), 'Catppuccin');
});
test('falls back to the raw name when stripping empties it', () {
expect(pairedThemeLabel('light-plus'), 'Light Plus');
});
test('never produces a blank label for a real pair', () {
for (final lightName in themePairs.keys) {
expect(pairedThemeLabel(lightName), isNotEmpty);
}
});
});
group('resolveSchemes', () {
test('system mode pairs the selection across brightnesses', () {
final resolved = resolveSchemes('github-light', ThemeMode.system);
// Null forcedMode leaves MaterialApp following the OS.
expect(resolved.forcedMode, isNull);
expect(resolved.light.brightness, Brightness.light);
expect(resolved.dark.brightness, Brightness.dark);
});
test('system mode works from the dark half of a pair too', () {
final resolved = resolveSchemes('github-dark', ThemeMode.system);
expect(resolved.forcedMode, isNull);
expect(resolved.light.brightness, Brightness.light);
expect(resolved.dark.brightness, Brightness.dark);
});
test('system mode pins an unpaired theme to its own brightness', () {
final resolved = resolveSchemes('andromeeda', ThemeMode.system);
// No counterpart exists, so both sides render the selected theme rather
// than jumping to an unrelated one.
expect(resolved.forcedMode, ThemeMode.dark);
expect(resolved.light, resolved.dark);
expect(resolved.dark.brightness, Brightness.dark);
});
test('system mode pins an unpaired light theme to light mode', () {
final resolved = resolveSchemes('snazzy-light', ThemeMode.system);
expect(resolved.forcedMode, ThemeMode.light);
expect(resolved.light, resolved.dark);
expect(resolved.light.brightness, Brightness.light);
});
test('light mode forces light and swaps a dark selection for its pair', () {
final resolved = resolveSchemes('github-dark', ThemeMode.light);
expect(resolved.forcedMode, ThemeMode.light);
expect(resolved.light, resolved.dark);
expect(resolved.light.brightness, Brightness.light);
expect(resolved.light, generateColorScheme(findTheme('github-light')!));
});
test('dark mode forces dark and swaps a light selection for its pair', () {
final resolved = resolveSchemes('github-light', ThemeMode.dark);
expect(resolved.forcedMode, ThemeMode.dark);
expect(resolved.dark.brightness, Brightness.dark);
expect(resolved.dark, generateColorScheme(findTheme('github-dark')!));
});
test('dark mode falls back to the default pair when pick is unpaired', () {
// 'snazzy-light' is light and has no dark counterpart.
final resolved = resolveSchemes('snazzy-light', ThemeMode.dark);
expect(resolved.forcedMode, ThemeMode.dark);
expect(resolved.dark.brightness, Brightness.dark);
expect(resolved.darkTheme?.name, buzzDarkThemeName);
expect(resolved.dark, generateColorScheme(findTheme(buzzDarkThemeName)!));
});
test('light mode falls back to the default pair when pick is unpaired', () {
final resolved = resolveSchemes('andromeeda', ThemeMode.light);
expect(resolved.forcedMode, ThemeMode.light);
expect(resolved.light.brightness, Brightness.light);
expect(resolved.lightTheme?.name, buzzThemeName);
expect(resolved.light, generateColorScheme(findTheme(buzzThemeName)!));
});
test('an unknown scheme name falls back to the default theme', () {
final resolved = resolveSchemes('not-a-theme', ThemeMode.light);
expect(
resolved.light,
generateColorScheme(findTheme(defaultSchemeName)!),
);
});
});
group('schemeForAppearanceMode', () {
test('system mode replaces an unpaired selection with a paired theme', () {
expect(
schemeForAppearanceMode('snazzy-light', ThemeMode.system),
themeGroups().paired.first.name,
);
expect(
schemeForAppearanceMode('nord', ThemeMode.system),
themeGroups().paired.first.name,
);
});
test('system mode preserves either half of a paired selection', () {
expect(
schemeForAppearanceMode('github-light', ThemeMode.system),
'github-light',
);
expect(
schemeForAppearanceMode('github-dark', ThemeMode.system),
'github-dark',
);
});
test('pinned modes preserve an unpaired selection', () {
expect(
schemeForAppearanceMode('snazzy-light', ThemeMode.light),
'snazzy-light',
);
expect(schemeForAppearanceMode('nord', ThemeMode.dark), 'nord');
});
});
group('effectiveTheme', () {
test('system mode keeps the stored selection', () {
expect(
effectiveTheme('github-dark', ThemeMode.system)?.name,
'github-dark',
);
});
test('pinned modes report the coerced theme', () {
expect(
effectiveTheme('github-dark', ThemeMode.light)?.name,
'github-light',
);
expect(
effectiveTheme('github-light', ThemeMode.dark)?.name,
'github-dark',
);
});
test('a null selection resolves to the default theme', () {
expect(effectiveTheme(null, ThemeMode.light)?.name, defaultSchemeName);
});
});
group('themeSelectionLabel', () {
test('names the pair as a whole in system mode', () {
expect(themeSelectionLabel('github-light', ThemeMode.system), 'Github');
expect(themeSelectionLabel('github-dark', ThemeMode.system), 'Github');
});
test('names the concrete theme in pinned modes', () {
expect(
themeSelectionLabel('github-light', ThemeMode.light),
'Github Light',
);
expect(
themeSelectionLabel('github-light', ThemeMode.dark),
'Github Dark',
);
});
test('falls back to the theme display name when unpaired', () {
expect(themeSelectionLabel('andromeeda', ThemeMode.system), 'Andromeeda');
});
});
}
@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/app_list.dart';
import 'package:buzz/shared/widgets/app_list_card.dart';
Widget _host(Widget child) => MaterialApp(
theme: AppTheme.light(),
home: Scaffold(body: child),
);
void main() {
group('AppListRow', () {
testWidgets('centres the leading icon against a two-line row', (
tester,
) async {
await tester.pumpWidget(
_host(
const AppListRow(
icon: Icons.dns,
title: 'Connected to',
subtitle: 'https://relay.example.com',
),
),
);
final row = tester.getRect(find.byType(AppListRow));
final icon = tester.getRect(find.byType(Icon));
expect(icon.center.dy, closeTo(row.center.dy, 0.5));
});
testWidgets('centres the trailing control against a two-line row', (
tester,
) async {
await tester.pumpWidget(
_host(
const AppListRow(
icon: Icons.key,
title: 'Identity',
subtitle: 'deadbeef',
trailing: Icon(Icons.copy, key: Key('trailing')),
),
),
);
final row = tester.getRect(find.byType(AppListRow));
final trailing = tester.getRect(find.byKey(const Key('trailing')));
expect(trailing.center.dy, closeTo(row.center.dy, 0.5));
});
testWidgets('keeps the value flush against the trailing edge', (
tester,
) async {
await tester.pumpWidget(
_host(
const AppListRow(
icon: Icons.palette,
title: 'Theme',
value: 'Buzz',
trailing: Icon(Icons.chevron_right, key: Key('chevron')),
),
),
);
final value = tester.getRect(find.text('Buzz'));
final chevron = tester.getRect(find.byKey(const Key('chevron')));
// Only the row's own inter-widget gap sits between them — no flex slack.
expect(chevron.left - value.right, closeTo(Grid.xxs, 0.5));
});
});
group('AppListCard', () {
testWidgets('separates rows with dividers that clear the icon column', (
tester,
) async {
await tester.pumpWidget(
_host(
const AppListCard(
label: 'Style',
children: [
AppListRow(icon: Icons.sunny, title: 'Appearance'),
AppListRow(icon: Icons.palette, title: 'Theme'),
AppListRow(icon: Icons.water_drop, title: 'Accent color'),
],
),
),
);
final dividers = find.byType(Divider);
expect(dividers, findsNWidgets(2));
final divider = tester.widget<Divider>(dividers.first);
final scheme = AppTheme.light().colorScheme;
// Derived from the text color, not the scheme's border tokens — those sit
// within a few levels of the card fill and vanish.
expect(divider.color, scheme.onSurface.withValues(alpha: 0.12));
// The indent is painted inside the divider's box, so the line starts at
// the box's left edge plus the indent — level with the row titles.
final titleLeft = tester.getRect(find.text('Appearance')).left;
final lineLeft = tester.getRect(dividers.first).left + divider.indent!;
expect(lineLeft, closeTo(titleLeft, 0.5));
});
testWidgets('fills with the softest container step', (tester) async {
await tester.pumpWidget(
_host(
const AppListCard(children: [AppListRow(title: 'Remove community')]),
),
);
final material = tester.widget<Material>(
find.descendant(
of: find.byType(AppListCard),
matching: find.byType(Material),
),
);
expect(
material.color,
AppTheme.light().colorScheme.surfaceContainerHighest,
);
});
});
}
@@ -0,0 +1,74 @@
import 'dart:convert';
import 'package:buzz/shared/widgets/avatar_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">'
'<text x="16" y="24" text-anchor="middle">🦝</text></svg>';
Widget subject(String? imageUrl) => MaterialApp(
home: AvatarImage(
imageUrl: imageUrl,
radius: 16,
fallback: const Text('R'),
),
);
testWidgets('renders raccoon percent-encoded SVG data avatar', (
tester,
) async {
const raccoonAvatar =
'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22512%22%20height%3D%22512%22%20viewBox%3D%220%200%20512%20512%22%3E%3Crect%20width%3D%22512%22%20height%3D%22512%22%20rx%3D%22256%22%20fill%3D%22%233399FF%22%2F%3E%3Ctext%20x%3D%2250%25%22%20y%3D%2256%25%22%20dominant-baseline%3D%22middle%22%20text-anchor%3D%22middle%22%20font-size%3D%22258%22%3E%F0%9F%A6%9D%3C%2Ftext%3E%3C%2Fsvg%3E';
await tester.pumpWidget(subject(raccoonAvatar));
await tester.pumpAndSettle();
final emoji = tester.widget<Text>(find.text('🦝'));
expect(emoji.style?.height, 1);
expect(find.byType(SvgPicture), findsNothing);
expect(find.text('R'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('renders base64 SVG data avatar', (tester) async {
await tester.pumpWidget(
subject('data:image/svg+xml;base64,${base64Encode(utf8.encode(svg))}'),
);
expect(find.byType(SvgPicture), findsOneWidget);
});
testWidgets('centers fallback when no avatar is configured', (tester) async {
await tester.pumpWidget(subject(null));
final avatarCenter = tester.getCenter(find.byType(CircleAvatar));
final fallbackCenter = tester.getCenter(find.text('R'));
expect(fallbackCenter, avatarCenter);
});
testWidgets('uses fallback for malformed image data', (tester) async {
await tester.pumpWidget(subject('data:image/svg+xml;base64,%%%'));
expect(find.text('R'), findsOneWidget);
});
testWidgets('reuses parsed raster bytes across parent rebuilds', (
tester,
) async {
const png =
'data:image/png;base64,'
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
await tester.pumpWidget(subject(png));
final firstBytes = tester.widget<Image>(find.byType(Image)).image;
await tester.pumpWidget(subject(png));
final rebuiltBytes = tester.widget<Image>(find.byType(Image)).image;
final firstMemory = firstBytes as MemoryImage;
final rebuiltMemory = rebuiltBytes as MemoryImage;
expect(rebuiltMemory.bytes, same(firstMemory.bytes));
});
}
@@ -0,0 +1,379 @@
import 'dart:async';
import 'package:buzz/shared/widgets/bee_refresh_indicator.dart';
import 'package:buzz/shared/widgets/flapping_bee.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/widget_helpers.dart';
void main() {
testWidgets('shows the bee while pulling to refresh', (tester) async {
const contentKey = ValueKey('loading-content');
var refreshes = 0;
final refreshCompleter = Completer<void>();
await tester.pumpWidget(
WidgetHelpers.testable(
child: BeeRefreshIndicator(
onRefresh: () {
refreshes++;
return refreshCompleter.future;
},
child: ListView(
children: const [SizedBox(key: contentKey, height: 800)],
),
),
),
);
final listFinder = find.byType(ListView);
final restingTop = tester.getTopLeft(listFinder).dy;
final restingContentTop = tester.getTopLeft(find.byKey(contentKey)).dy;
await tester.timedDrag(
listFinder,
const Offset(0, 320),
const Duration(milliseconds: 500),
);
await tester.pump(const Duration(milliseconds: 16));
await tester.pump(const Duration(milliseconds: 300));
final beeFinder = find.byType(FlappingBee);
final loadingTop = tester.getTopLeft(listFinder).dy;
final loadingContentTop = tester.getTopLeft(find.byKey(contentKey)).dy;
final gapTransform = tester.widget<Transform>(
find.byKey(const ValueKey('bee-refresh-retained-gap')),
);
expect(beeFinder, findsOneWidget);
expect(refreshes, 1);
expect(gapTransform.transform.getTranslation().y, closeTo(72, 1));
expect(loadingTop - restingTop, closeTo(72, 1));
final loadingBeeRect = tester.getRect(beeFinder);
final loadingGap = loadingContentTop - restingContentTop;
expect(
loadingBeeRect.center.dy,
closeTo(
restingContentTop +
(loadingGap - loadingBeeRect.height) * 0.75 +
loadingBeeRect.height / 2,
1,
),
);
refreshCompleter.complete();
await tester.pump();
await tester.pump(const Duration(milliseconds: 90));
final closingTop = tester.getTopLeft(listFinder).dy;
expect(closingTop, greaterThan(restingTop));
expect(closingTop, lessThan(loadingTop));
await tester.pumpAndSettle();
expect(tester.getTopLeft(listFinder).dy, closeTo(restingTop, 1));
expect(beeFinder, findsNothing);
});
testWidgets('tracks each stage of an active pull', (tester) async {
tester.view.physicalSize = const Size(420, 912);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final hapticCalls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call);
return null;
});
addTearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null),
);
const contentKey = ValueKey('pull-content');
final refreshCompleter = Completer<void>();
await tester.pumpWidget(
WidgetHelpers.testable(
child: BeeRefreshIndicator(
onRefresh: () => refreshCompleter.future,
child: ListView(
children: const [SizedBox(key: contentKey, height: 800)],
),
),
),
);
final restingContentTop = tester.getTopLeft(find.byKey(contentKey)).dy;
final gesture = await tester.startGesture(
tester.getCenter(find.byType(ListView)),
pointer: 1,
);
await gesture.moveBy(const Offset(0, 12));
await tester.pump();
final beeFinder = find.byType(FlappingBee);
expect(beeFinder, findsNothing);
expect(
tester.getTopLeft(find.byKey(contentKey)).dy,
greaterThan(restingContentTop),
);
await gesture.moveBy(const Offset(0, 44));
await tester.pump();
final earlyTop = tester.getTopLeft(beeFinder).dy;
final partialOpacity = tester.widget<Opacity>(
find.byKey(const ValueKey('bee-refresh-opacity')),
);
expect(partialOpacity.opacity, greaterThan(0));
expect(partialOpacity.opacity, lessThan(1));
final partialScale = tester
.widget<Transform>(find.byKey(const ValueKey('bee-refresh-scale')))
.transform
.storage[0];
expect(partialScale, greaterThan(0.6));
expect(partialScale, lessThan(1));
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
expect(hapticCalls, isEmpty);
await gesture.moveBy(const Offset(0, 120));
await tester.pump();
final pulledContentTop = tester.getTopLeft(find.byKey(contentKey)).dy;
expect(tester.getTopLeft(beeFinder).dy, greaterThan(earlyTop));
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
expect(find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsNothing);
final pulledBeeRect = tester.getRect(beeFinder);
final pulledGap = pulledContentTop - restingContentTop;
expect(
pulledBeeRect.center.dy,
closeTo(
restingContentTop +
(pulledGap - pulledBeeRect.height) * 0.75 +
pulledBeeRect.height / 2,
1,
),
);
await gesture.moveBy(const Offset(0, 120));
await tester.pump();
expect(
tester
.widget<Transform>(find.byKey(const ValueKey('bee-refresh-scale')))
.transform
.storage[0],
closeTo(1, 0.001),
);
expect(hapticCalls, hasLength(1));
expect(hapticCalls.single.arguments, 'HapticFeedbackType.mediumImpact');
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
expect(find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsNothing);
await tester.pump(const Duration(milliseconds: 350));
await gesture.moveBy(
const Offset(0, 120),
timeStamp: const Duration(milliseconds: 350),
);
await tester.pump();
final pupilProgress = tester.widget<FlappingBee>(beeFinder).eyeProgress;
expect(pupilProgress, greaterThan(0));
expect(pupilProgress, lessThan(0.5));
expect(find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsNothing);
expect(hapticCalls, hasLength(1));
await tester.pump(const Duration(milliseconds: 400));
await gesture.moveBy(
const Offset(0, 240),
timeStamp: const Duration(milliseconds: 750),
);
await tester.pump();
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
expect(
find.byKey(const ValueKey('bee-refresh-eyes-emoji')),
findsOneWidget,
);
expect(hapticCalls, hasLength(2));
expect(hapticCalls.last.arguments, 'HapticFeedbackType.heavyImpact');
expect(
tester
.widget<Transform>(
find.byKey(const ValueKey('bee-refresh-eyes-emoji-offset')),
)
.transform
.storage[12],
2,
);
final initialShake = tester
.widget<Transform>(
find.byKey(const ValueKey('bee-refresh-eyes-emoji-shake')),
)
.transform
.storage[12];
await tester.pump(const Duration(milliseconds: 35));
final movedShake = tester
.widget<Transform>(
find.byKey(const ValueKey('bee-refresh-eyes-emoji-shake')),
)
.transform
.storage[12];
expect(movedShake, isNot(closeTo(initialShake, 0.01)));
expect(movedShake.abs(), lessThanOrEqualTo(0.75));
await tester.pump(const Duration(milliseconds: 105));
expect(hapticCalls, hasLength(3));
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
final secondGesture = await tester.startGesture(
tester.getCenter(find.byType(ListView)),
pointer: 2,
);
await secondGesture.moveBy(
const Offset(0, 40),
timeStamp: const Duration(milliseconds: 800),
);
await gesture.up();
await tester.pump();
expect(
find.byKey(const ValueKey('bee-refresh-eyes-emoji')),
findsOneWidget,
);
expect(hapticCalls, hasLength(3));
await secondGesture.moveBy(
const Offset(0, 80),
timeStamp: const Duration(milliseconds: 900),
);
await tester.pump();
expect(
find.byKey(const ValueKey('bee-refresh-eyes-emoji')),
findsOneWidget,
);
expect(hapticCalls, hasLength(3));
await secondGesture.up();
await tester.pump();
final hapticsAfterRelease = hapticCalls.length;
await tester.pump(const Duration(milliseconds: 300));
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
expect(find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsNothing);
expect(hapticCalls, hasLength(hapticsAfterRelease));
refreshCompleter.complete();
await tester.pumpAndSettle();
});
testWidgets('keeps expressive eyes hidden for a quick refresh flick', (
tester,
) async {
final hapticCalls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call);
return null;
});
addTearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null),
);
final refreshCompleter = Completer<void>();
var refreshes = 0;
await tester.pumpWidget(
WidgetHelpers.testable(
child: BeeRefreshIndicator(
onRefresh: () {
refreshes++;
return refreshCompleter.future;
},
child: ListView(children: const [SizedBox(height: 800)]),
),
),
);
final gesture = await tester.startGesture(
tester.getCenter(find.byType(ListView)),
);
final beeFinder = find.byType(FlappingBee);
for (var step = 0; step < 10; step++) {
await gesture.moveBy(
const Offset(0, 50),
timeStamp: Duration(milliseconds: (step + 1) * 10),
);
await tester.pump(const Duration(milliseconds: 10));
if (beeFinder.evaluate().isNotEmpty) {
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
expect(
find.byKey(const ValueKey('bee-refresh-eyes-emoji')),
findsNothing,
);
}
}
await gesture.up();
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(refreshes, 1);
expect(hapticCalls, hasLength(1));
expect(hapticCalls.single.arguments, 'HapticFeedbackType.mediumImpact');
expect(tester.widget<FlappingBee>(beeFinder).eyeProgress, isNull);
expect(find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsNothing);
refreshCompleter.complete();
await tester.pumpAndSettle();
});
testWidgets('keeps the bee static when motion is disabled', (tester) async {
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(disableAnimations: true),
child: WidgetHelpers.testable(
child: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(context).copyWith(disableAnimations: true),
child: BeeRefreshIndicator(
onRefresh: () async {},
child: ListView(children: const [SizedBox(height: 800)]),
),
),
),
),
),
);
await tester.timedDrag(
find.byType(ListView),
const Offset(0, 160),
const Duration(milliseconds: 400),
);
await tester.pump();
final bee = tester.widget<FlappingBee>(find.byType(FlappingBee));
expect(bee.flapAmount, 0);
});
testWidgets('provides elastic always-scrollable physics', (tester) async {
late ScrollPhysics physics;
await tester.pumpWidget(
WidgetHelpers.testable(
child: BeeRefreshIndicator(
onRefresh: () async {},
child: Builder(
builder: (context) {
physics = ScrollConfiguration.of(
context,
).getScrollPhysics(context);
return ListView(children: const [SizedBox(height: 20)]);
},
),
),
),
);
expect(physics, isA<BouncingScrollPhysics>());
expect(physics.parent, isA<AlwaysScrollableScrollPhysics>());
});
}
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/buzz_loading_indicator.dart';
Widget _testable({required bool disableAnimations}) {
return ProviderScope(
child: MaterialApp(
theme: AppTheme.light(),
home: MediaQuery(
data: const MediaQueryData().copyWith(
disableAnimations: disableAnimations,
),
child: const Scaffold(
body: BuzzLoadingIndicator(semanticLabel: 'Loading photos'),
),
),
),
);
}
void main() {
testWidgets('animates the shared arc spinner', (tester) async {
final semantics = tester.ensureSemantics();
await tester.pumpWidget(_testable(disableAnimations: false));
await tester.pump(const Duration(milliseconds: 70));
final spinner = tester.widget<RotationTransition>(
find.byKey(const ValueKey('buzz-loading-indicator-spinner')),
);
expect(spinner.turns.value, greaterThan(0));
expect(find.bySemanticsLabel('Loading photos'), findsOneWidget);
semantics.dispose();
});
testWidgets('holds a static pose when reduced motion is enabled', (
tester,
) async {
await tester.pumpWidget(_testable(disableAnimations: true));
await tester.pump(const Duration(milliseconds: 70));
final spinner = tester.widget<RotationTransition>(
find.byKey(const ValueKey('buzz-loading-indicator-spinner')),
);
expect(spinner.turns.value, 0);
expect(tester.binding.hasScheduledFrame, isFalse);
});
}
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/filter_chip_bar.dart';
void main() {
testWidgets('selected chip resolves the accent (scheme.primary) as fill', (
tester,
) async {
final accented = applyAccent(darkColorScheme, 0); // Blue accent
final accent = accented.primary;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.dark(colorScheme: accented),
home: Scaffold(
body: FilterChipBar<int>(
selected: 0,
onSelected: (_) {},
items: const [
FilterChipItem(id: 0, label: 'Everyone'),
FilterChipItem(id: 1, label: 'Following'),
],
),
),
),
);
await tester.pumpAndSettle();
// The chip's container color comes from ChipThemeData.color resolved for
// the selected state. Resolve it the same way the chip does and assert
// it's the accent.
final chipTheme = ChipTheme.of(
tester.element(find.widgetWithText(RawChip, 'Everyone')),
);
final resolved = chipTheme.color?.resolve({WidgetState.selected});
expect(resolved, accent);
final selectedLabel = tester.widget<Text>(find.text('Everyone'));
final unselectedLabel = tester.widget<Text>(find.text('Following'));
expect(selectedLabel.style?.fontSize, filterChipTextStyle.fontSize);
expect(selectedLabel.style?.height, filterChipTextStyle.height);
expect(selectedLabel.style?.fontFamily, 'Inter');
expect(selectedLabel.style?.fontWeight, FontWeight.w500);
expect(unselectedLabel.style?.fontSize, filterChipTextStyle.fontSize);
expect(unselectedLabel.style?.height, filterChipTextStyle.height);
expect(unselectedLabel.style?.fontWeight, FontWeight.w400);
final chip = tester.widget<FilterChip>(
find.widgetWithText(FilterChip, 'Everyone'),
);
final shape = chip.shape! as RoundedRectangleBorder;
expect(shape.borderRadius, BorderRadius.circular(Radii.lg));
expect(chip.labelPadding, EdgeInsets.zero);
});
testWidgets('search labels are vertically centered in their chips', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: SizedBox(
width: 390,
child: FilterChipBar<int>(
expandItems: true,
visualDensity: const VisualDensity(horizontal: -2),
chipVerticalPadding: Grid.xxs,
barVerticalPadding: Grid.twelve,
selected: 0,
onSelected: (_) {},
items: const [
FilterChipItem(id: 0, label: 'All'),
FilterChipItem(id: 1, label: 'Messages'),
FilterChipItem(id: 2, label: 'Channels'),
FilterChipItem(id: 3, label: 'People'),
],
),
),
),
),
);
await tester.pumpAndSettle();
for (final label in ['All', 'Messages', 'Channels', 'People']) {
final text = find.text(label);
final chip = find.ancestor(of: text, matching: find.byType(RawChip));
expect(chip, findsOneWidget);
expect(
tester.getCenter(text).dy,
closeTo(tester.getCenter(chip).dy, 0.01),
);
}
});
testWidgets('expanded chips preserve large accessible text scaling', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: MediaQuery(
data: const MediaQueryData(textScaler: TextScaler.linear(2)),
child: Scaffold(
body: SizedBox(
width: 240,
child: FilterChipBar<int>(
expandItems: true,
selected: 0,
onSelected: (_) {},
items: const [
FilterChipItem(id: 0, label: 'All'),
FilterChipItem(id: 1, label: 'Messages'),
FilterChipItem(id: 2, label: 'Channels'),
FilterChipItem(id: 3, label: 'People'),
],
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(FilterChipBar<int>),
matching: find.byType(FittedBox),
),
findsNothing,
);
expect(
tester.getSize(find.text('Messages')).height,
greaterThanOrEqualTo(30),
);
expect(tester.takeException(), isNull);
});
}
@@ -0,0 +1,86 @@
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('title row and reported height grow with accessible text', (
tester,
) async {
const titleStyle = TextStyle(fontSize: 22, height: 1.3);
late double reportedHeight;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: MediaQuery(
data: const MediaQueryData(textScaler: TextScaler.linear(2)),
child: Builder(
builder: (context) {
reportedHeight = frostedAppBarHeight(
context,
titleStyle: titleStyle,
);
return const Stack(
children: [
FrostedAppBar(title: Text('Search'), titleStyle: titleStyle),
],
);
},
),
),
),
);
await tester.pumpAndSettle();
final clip = find.descendant(
of: find.byType(FrostedAppBar),
matching: find.byType(ClipRect),
);
expect(reportedHeight, greaterThan(48));
expect(tester.getSize(clip).height, closeTo(reportedHeight, 0.01));
expect(tester.getSize(find.text('Search')).height, greaterThan(48));
expect(tester.takeException(), isNull);
});
testWidgets('custom multi-line title height matches reported height', (
tester,
) async {
const titleContentHeight = 64.0;
late double reportedHeight;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Builder(
builder: (context) {
reportedHeight = frostedAppBarHeight(
context,
titleContentHeight: titleContentHeight,
);
return const Stack(
children: [
FrostedAppBar(
titleContentHeight: titleContentHeight,
title: Column(
mainAxisSize: MainAxisSize.min,
children: [Text('Title'), Text('Subtitle')],
),
),
],
);
},
),
),
);
await tester.pumpAndSettle();
final clip = find.descendant(
of: find.byType(FrostedAppBar),
matching: find.byType(ClipRect),
);
expect(tester.getSize(clip).height, closeTo(reportedHeight, 0.01));
expect(reportedHeight, closeTo(titleContentHeight + Grid.xs + 1, 0.01));
expect(tester.takeException(), isNull);
});
}
@@ -0,0 +1,159 @@
import 'package:buzz/shared/widgets/keyboard_dismiss_on_drag.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// A focused field inside a `Scaffold` body, which is the only arrangement
/// either message list ever runs in.
Widget _testable({
required FocusNode focusNode,
VoidCallback? onUserScrollStart,
}) {
return MaterialApp(
home: Scaffold(
body: Column(
children: [
TextField(focusNode: focusNode),
Expanded(
child: KeyboardDismissOnDrag(
onUserScrollStart: onUserScrollStart,
child: ListView(
children: [
for (var i = 0; i < 40; i++)
SizedBox(height: 60, child: Text('row $i')),
],
),
),
),
],
),
),
);
}
/// Raise the keyboard the way the platform does — on the view, not via a
/// synthetic `MediaQuery` wrapper. It has to travel the real path, because
/// `Scaffold` strips the bottom view inset from its body once it has resized to
/// account for it, and that stripping is the whole reason this widget broke.
void _raiseKeyboard(WidgetTester tester) {
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
addTearDown(tester.view.reset);
}
Future<void> _dragDown(
WidgetTester tester,
double distance, {
String on = 'row 3',
}) async {
final gesture = await tester.startGesture(tester.getCenter(find.text(on)));
// Several small moves, the way a real finger reports — the accumulator has to
// add them up rather than needing one big delta.
final step = distance / 6;
for (var i = 0; i < 6; i++) {
await gesture.moveBy(Offset(0, step));
await tester.pump();
}
await gesture.up();
await tester.pump();
}
void main() {
group('KeyboardDismissOnDrag', () {
testWidgets('a deliberate downward drag drops focus', (tester) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
_raiseKeyboard(tester);
await tester.pumpWidget(_testable(focusNode: focusNode));
focusNode.requestFocus();
await tester.pump();
expect(focusNode.hasFocus, isTrue);
await _dragDown(tester, keyboardDismissDragThreshold * 2);
// At rest the list can't scroll down, so this gesture is pure overscroll
// — the case that reports `OverscrollNotification` rather than
// `ScrollUpdateNotification`, and the one a focused composer is usually
// sitting in.
expect(focusNode.hasFocus, isFalse);
});
testWidgets('an ordinary downward scroll dismisses too', (tester) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
_raiseKeyboard(tester);
await tester.pumpWidget(_testable(focusNode: focusNode));
focusNode.requestFocus();
await tester.pump();
// Move off the resting edge so dragging down actually moves the position,
// covering the `ScrollUpdateNotification` path rather than overscroll.
await tester.drag(find.text('row 3'), const Offset(0, -400));
await tester.pumpAndSettle();
expect(focusNode.hasFocus, isTrue);
await _dragDown(tester, keyboardDismissDragThreshold * 2, on: 'row 10');
expect(focusNode.hasFocus, isFalse);
});
testWidgets('a short scroll leaves the keyboard alone', (tester) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
_raiseKeyboard(tester);
await tester.pumpWidget(_testable(focusNode: focusNode));
focusNode.requestFocus();
await tester.pump();
await _dragDown(tester, keyboardDismissDragThreshold / 2);
expect(focusNode.hasFocus, isTrue);
});
testWidgets('reports a user-started scroll', (tester) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
var userScrollStarts = 0;
await tester.pumpWidget(
_testable(
focusNode: focusNode,
onUserScrollStart: () => userScrollStarts += 1,
),
);
await tester.drag(find.text('row 3'), const Offset(0, -100));
await tester.pumpAndSettle();
expect(userScrollStarts, 1);
});
testWidgets('an upward drag never dismisses, however far it goes', (
tester,
) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
_raiseKeyboard(tester);
await tester.pumpWidget(_testable(focusNode: focusNode));
focusNode.requestFocus();
await tester.pump();
await _dragDown(tester, -keyboardDismissDragThreshold * 4);
expect(focusNode.hasFocus, isTrue);
});
testWidgets('with the keyboard already down, focus is left as it is', (
tester,
) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
// No _raiseKeyboard: the keyboard is down.
await tester.pumpWidget(_testable(focusNode: focusNode));
focusNode.requestFocus();
await tester.pump();
await _dragDown(tester, keyboardDismissDragThreshold * 2);
// Nothing to dismiss, so don't yank focus out of whatever holds it.
expect(focusNode.hasFocus, isTrue);
});
});
}
@@ -0,0 +1,147 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
void main() {
group('avatarBadgeMaskPath', () {
const size = 128.0;
const geometry = AvatarBadgeMaskGeometry.badge;
test('keeps the avatar but cuts the notch out of it', () {
final path = avatarBadgeMaskPath(size: size, geometry: geometry);
expect(path.contains(const Offset(size / 2, size / 2)), isTrue);
expect(path.contains(geometry.cutoutCenter(size)), isFalse);
// The far edge is untouched — the notch only bites the bottom-right.
expect(path.contains(const Offset(4, size / 2)), isTrue);
});
test('stays inside the avatar circle on the unnotched sides', () {
// Checked with contains() rather than getBounds(), which reports
// control-point bounds and so overshoots the near-full-circle arc.
final path = avatarBadgeMaskPath(size: size, geometry: geometry);
expect(path.contains(const Offset(-1, size / 2)), isFalse);
expect(path.contains(const Offset(size / 2, -1)), isFalse);
expect(path.contains(const Offset(2, 2)), isFalse);
});
test('fillets the notch rather than subtracting a bare circle', () {
// A plain difference meets the cutout at two cusps. The fillets round
// those off, which necessarily removes extra material just outside the
// cutout circle — so the masked path must be a strict subset there.
final masked = avatarBadgeMaskPath(size: size, geometry: geometry);
final cutoutCenter = geometry.cutoutCenter(size);
final cutoutRadius = geometry.cutoutRadius(size);
final bare = Path.combine(
PathOperation.difference,
Path()..addOval(
Rect.fromCircle(
center: const Offset(size / 2, size / 2),
radius: size / 2,
),
),
Path()..addOval(
Rect.fromCircle(center: cutoutCenter, radius: cutoutRadius),
),
);
// Sample the ring just outside the cutout, where the fillets live.
var filleted = 0;
for (var step = 0; step < 360; step++) {
final angle = step * math.pi / 180;
final point =
cutoutCenter +
Offset(math.cos(angle), math.sin(angle)) * (cutoutRadius + 2);
if (bare.contains(point) && !masked.contains(point)) filleted++;
// The fillet only adds material to the cut, never puts it back.
expect(
masked.contains(point) && !bare.contains(point),
isFalse,
reason: 'mask should not extend beyond the plain difference',
);
}
expect(filleted, greaterThan(0));
});
test('falls back to a plain circle when the notch misses the avatar', () {
// A notch tucked at the centre never crosses the avatar's edge, so there
// is no crossing to fillet and nothing should be cut.
const detached = AvatarBadgeMaskGeometry(
cutoutCenterRatio: 0.5,
cutoutRadiusRatio: 0.05,
badgeSizeRatio: 0.08,
curve: AvatarBadgeCurve.standard,
);
final path = avatarBadgeMaskPath(size: size, geometry: detached);
expect(path.contains(detached.cutoutCenter(size)), isTrue);
expect(path.getBounds().size, const Size(size, size));
});
test(
'scales geometry with the avatar, matching desktop at its own size',
() {
// Desktop's sidebar avatar is 32px with the cutout at (28, 28) r 7.5.
const presence = AvatarBadgeMaskGeometry.presenceDot;
expect(presence.cutoutCenter(32), const Offset(28, 28));
expect(presence.cutoutRadius(32), 7.5);
expect(presence.badgeSize(32), 14);
// Twice the avatar, twice the notch.
expect(presence.cutoutCenter(64), const Offset(56, 56));
expect(presence.cutoutRadius(64), 15);
},
);
});
group('MaskedAvatarBadge', () {
Widget harness({Widget? badge}) => MaterialApp(
theme: AppTheme.light(),
home: Center(
child: MaskedAvatarBadge(
size: 128,
geometry: AvatarBadgeMaskGeometry.badge,
avatar: const ColoredBox(color: Color(0xFF123456)),
badge: badge,
),
),
);
testWidgets('masks the avatar and centres the badge in the notch', (
tester,
) async {
await tester.pumpWidget(
harness(badge: const ColoredBox(color: Color(0xFFABCDEF))),
);
final clip = tester.widget<ClipPath>(find.byType(ClipPath));
expect(clip.clipper, isA<AvatarBadgeMaskClipper>());
const geometry = AvatarBadgeMaskGeometry.badge;
final frame = tester.getRect(find.byType(MaskedAvatarBadge));
final badgeRect = tester.getRect(find.byType(ColoredBox).last);
expect(badgeRect.width, closeTo(geometry.badgeSize(128), 0.01));
expect(
badgeRect.center - frame.topLeft,
within(distance: 0.01, from: geometry.cutoutCenter(128)),
);
});
testWidgets('leaves the avatar whole when there is no badge', (
tester,
) async {
await tester.pumpWidget(harness());
expect(find.byType(ClipPath), findsNothing);
expect(
tester.getSize(find.byType(MaskedAvatarBadge)),
const Size(128, 128),
);
});
});
}
@@ -0,0 +1,117 @@
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/message_author_meta.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('keeps the separator and timestamp next to a short name', (
tester,
) async {
const displayNameKey = Key('author-display-name');
const timestampKey = Key('author-timestamp');
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const Scaffold(
body: SizedBox(
width: 300,
child: MessageAuthorMeta(
displayName: 'Alice',
timestamp: '2m',
displayNameKey: displayNameKey,
timestampKey: timestampKey,
nameColor: Colors.black,
metadataColor: Colors.grey,
),
),
),
),
);
await tester.pumpAndSettle();
final displayNameRect = tester.getRect(find.byKey(displayNameKey));
final separatorRect = tester.getRect(find.text('·'));
final timestampRect = tester.getRect(find.byKey(timestampKey));
expect(separatorRect.left - displayNameRect.right, Grid.half);
expect(timestampRect.left - separatorRect.right, Grid.half);
expect(tester.takeException(), isNull);
});
testWidgets('reallocates unused metadata width to the display name', (
tester,
) async {
const displayNameKey = Key('author-display-name');
const timestampKey = Key('author-timestamp');
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const Scaffold(
body: SizedBox(
width: 300,
child: MessageAuthorMeta(
displayName: 'A display name that needs the available width',
username: 'al',
timestamp: '2m',
displayNameKey: displayNameKey,
timestampKey: timestampKey,
nameColor: Colors.black,
metadataColor: Colors.grey,
),
),
),
),
);
await tester.pumpAndSettle();
final row = find.byType(MessageAuthorMeta);
final displayName = find.byKey(displayNameKey);
final timestamp = find.byKey(timestampKey);
expect(
tester.getSize(displayName).width,
greaterThan(tester.getSize(row).width / 2),
);
expect(
tester.getTopRight(timestamp).dx,
closeTo(tester.getTopRight(row).dx, 0.01),
);
expect(tester.takeException(), isNull);
});
testWidgets('constrains long metadata at large accessible text sizes', (
tester,
) async {
const timestampKey = Key('author-timestamp');
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const MediaQuery(
data: MediaQueryData(textScaler: TextScaler.linear(2)),
child: Scaffold(
body: SizedBox(
width: 220,
child: MessageAuthorMeta(
displayName: 'A very long display name',
username: 'a-very-long-username',
timestamp: 'Mar 15, 2025',
timestampKey: timestampKey,
nameColor: Colors.black,
metadataColor: Colors.grey,
),
),
),
),
),
);
await tester.pumpAndSettle();
final timestamp = tester.widget<Text>(find.byKey(timestampKey));
expect(timestamp.maxLines, 1);
expect(timestamp.overflow, TextOverflow.ellipsis);
expect(tester.takeException(), isNull);
});
}
@@ -0,0 +1,43 @@
import 'package:buzz/shared/widgets/mobile_tab_footer_backdrop.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('shared gradient fades from transparent to the page surface', (
tester,
) async {
LinearGradient? gradient;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
gradient = mobileTabFooterBackdropGradient(context);
return const SizedBox();
},
),
),
);
expect(gradient?.stops, [0, 0.18, 0.38, 0.6, 0.8, 1]);
expect(gradient?.colors.first.a, 0);
expect(gradient?.colors[1].a, closeTo(0.03, 0.01));
expect(gradient?.colors[3].a, closeTo(0.34, 0.01));
expect(gradient?.colors.last.a, 1);
});
testWidgets('uses a fixed 180px footer backdrop height', (tester) async {
double? height;
await tester.pumpWidget(
Builder(
builder: (context) {
height = mobileTabFooterBackdropHeight(context);
return const SizedBox();
},
),
);
expect(height, 180);
});
}
@@ -0,0 +1,226 @@
import 'package:buzz/shared/widgets/concentric_sheet_surface.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/modal_presentation.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets(
'keeps an opaque Flutter surface when iOS native support is unavailable',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const ConcentricSheetSurface(
enabled: true,
color: Colors.red,
child: SizedBox(height: 80, child: Text('Sheet body')),
),
),
);
await tester.pumpAndSettle();
expect(
find.byWidgetPredicate(
(widget) => widget is Material && widget.color == Colors.red,
),
findsOneWidget,
);
} finally {
debugDefaultTargetPlatformOverride = null;
}
},
);
testWidgets(
'replaces the Flutter fallback when native support is available',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
const surfaceChannel = MethodChannel('buzz/concentric_sheet_surface');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
surfaceChannel,
(call) async => call.method == 'isSupported' ? true : null,
);
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const ConcentricSheetSurface(
enabled: true,
color: Colors.red,
child: SizedBox(height: 80, child: Text('Sheet body')),
),
),
);
await tester.pump();
expect(find.byType(UiKitView), findsOneWidget);
expect(
find.byWidgetPredicate(
(widget) => widget is Material && widget.color == Colors.red,
),
findsNothing,
);
} finally {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
surfaceChannel,
null,
);
debugDefaultTargetPlatformOverride = null;
}
},
);
testWidgets(
'non-iOS sheets use Flutter drag handle and shared close control',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Builder(
builder: (context) => FilledButton(
onPressed: () => showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => const Text('Sheet body'),
),
child: const Text('Open sheet'),
),
),
),
),
);
await tester.tap(find.text('Open sheet'));
await tester.pumpAndSettle();
final closeButton = find.byTooltip('Close sheet');
expect(closeButton, findsOneWidget);
expect(tester.getSize(closeButton), const Size.square(44));
final closeGutter = find.ancestor(
of: closeButton,
matching: find.byWidgetPredicate(
(widget) =>
widget is Padding &&
widget.padding ==
const EdgeInsets.only(
top: Grid.xxs,
left: Grid.gutter,
right: Grid.gutter,
bottom: Grid.xs,
),
),
);
expect(closeGutter, findsOneWidget);
final gutterRect = tester.getRect(closeGutter);
final closeRect = tester.getRect(closeButton);
expect(closeRect.top - gutterRect.top, Grid.gutter);
expect(gutterRect.right - closeRect.right, Grid.gutter);
expect(
tester.widget<BottomSheet>(find.byType(BottomSheet)).showDragHandle,
isTrue,
);
expect(
find.byKey(const ValueKey('buzz-sheet-drag-handle')),
findsNothing,
);
expect(find.text('Sheet body'), findsOneWidget);
await tester.tap(closeButton);
await tester.pumpAndSettle();
expect(find.text('Sheet body'), findsNothing);
} finally {
debugDefaultTargetPlatformOverride = null;
}
},
);
testWidgets('iOS paints the drag handle inside the concentric surface', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Builder(
builder: (context) => FilledButton(
onPressed: () => showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => const Text('Sheet body'),
),
child: const Text('Open sheet'),
),
),
),
),
);
await tester.tap(find.text('Open sheet'));
await tester.pumpAndSettle();
expect(
tester.widget<BottomSheet>(find.byType(BottomSheet)).showDragHandle,
isFalse,
);
final internalHandle = find.byKey(
const ValueKey('buzz-sheet-drag-handle'),
);
expect(internalHandle, findsOneWidget);
expect(tester.getSize(internalHandle), const Size(32, 4));
expect(find.bySemanticsLabel('Drag handle'), findsOneWidget);
expect(find.byTooltip('Close sheet'), findsOneWidget);
expect(find.text('Sheet body'), findsOneWidget);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
testWidgets('iOS compact sheets can omit X but retain the inside handle', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Builder(
builder: (context) => FilledButton(
onPressed: () => showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
showCloseButton: false,
builder: (_) => const Text('Compact sheet body'),
),
child: const Text('Open compact sheet'),
),
),
),
),
);
await tester.tap(find.text('Open compact sheet'));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('buzz-sheet-drag-handle')),
findsOneWidget,
);
expect(find.byTooltip('Close sheet'), findsNothing);
expect(find.text('Compact sheet body'), findsOneWidget);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
}
@@ -0,0 +1,186 @@
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/skeleton.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
Widget buildShimmerTestable({
bool disableAnimations = false,
bool enabled = true,
}) {
return MaterialApp(
theme: AppTheme.light(),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(disableAnimations: disableAnimations),
child: child!,
),
home: Scaffold(
body: SkeletonShimmer(
enabled: enabled,
child: const SkeletonBar(width: 120, height: 16),
),
),
);
}
Widget buildRevealTestable({
required ValueNotifier<bool> loading,
bool disableAnimations = false,
}) {
return MaterialApp(
theme: AppTheme.light(),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(disableAnimations: disableAnimations),
child: child!,
),
home: Scaffold(
body: ValueListenableBuilder(
valueListenable: loading,
builder: (context, isLoading, _) => SkeletonReveal(
loading: isLoading,
skeleton: const SkeletonBar(width: 120, height: 16),
content: const Text('Loaded content'),
),
),
),
);
}
double layerOpacity(WidgetTester tester, String key) =>
tester.widget<Opacity>(find.byKey(Key(key))).opacity;
bool contentFocusExcluded(WidgetTester tester) => tester
.widget<ExcludeFocus>(
find.descendant(
of: find.byKey(const Key('skeleton-reveal-content')),
matching: find.byType(ExcludeFocus),
),
)
.excluding;
testWidgets('sweeps a highlight across skeleton elements while loading', (
tester,
) async {
await tester.pumpWidget(buildShimmerTestable());
final shimmer = find.byType(SkeletonShimmer);
expect(find.byType(SkeletonBar), findsOneWidget);
expect(
find.descendant(of: shimmer, matching: find.byType(ShaderMask)),
findsOneWidget,
);
final boundary = tester.renderObject<RenderRepaintBoundary>(
find
.descendant(of: shimmer, matching: find.byType(RepaintBoundary))
.first,
);
final beforeBytes = await tester.runAsync(() async {
final image = await boundary.toImage();
return image.toByteData();
});
await tester.pump(const Duration(milliseconds: 1000));
final afterBytes = await tester.runAsync(() async {
final image = await boundary.toImage();
return image.toByteData();
});
expect(
beforeBytes!.buffer.asUint8List(),
isNot(equals(afterBytes!.buffer.asUint8List())),
);
});
testWidgets('keeps skeleton elements static for reduced motion', (
tester,
) async {
await tester.pumpWidget(buildShimmerTestable(disableAnimations: true));
final shimmer = find.byType(SkeletonShimmer);
expect(find.byType(SkeletonBar), findsOneWidget);
expect(
find.descendant(of: shimmer, matching: find.byType(ShaderMask)),
findsNothing,
);
});
testWidgets('keeps skeleton elements static when shimmer is disabled', (
tester,
) async {
await tester.pumpWidget(buildShimmerTestable(enabled: false));
final shimmer = find.byType(SkeletonShimmer);
expect(find.byType(SkeletonBar), findsOneWidget);
expect(
find.descendant(of: shimmer, matching: find.byType(ShaderMask)),
findsNothing,
);
});
testWidgets('reveals content in the same slot with a 400ms cross-fade', (
tester,
) async {
final loading = ValueNotifier(true);
await tester.pumpWidget(buildRevealTestable(loading: loading));
expect(layerOpacity(tester, 'skeleton-reveal-placeholder'), 1);
expect(layerOpacity(tester, 'skeleton-reveal-content'), 0);
expect(contentFocusExcluded(tester), isTrue);
loading.value = false;
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(
layerOpacity(tester, 'skeleton-reveal-placeholder'),
closeTo(0.5, 0.01),
);
expect(layerOpacity(tester, 'skeleton-reveal-content'), closeTo(0.5, 0.01));
expect(contentFocusExcluded(tester), isTrue);
await tester.pump(const Duration(milliseconds: 200));
expect(layerOpacity(tester, 'skeleton-reveal-placeholder'), 0);
expect(layerOpacity(tester, 'skeleton-reveal-content'), 1);
expect(contentFocusExcluded(tester), isFalse);
expect(find.text('Loaded content'), findsOneWidget);
});
testWidgets('resets to the skeleton without animating backwards', (
tester,
) async {
final loading = ValueNotifier(false);
await tester.pumpWidget(buildRevealTestable(loading: loading));
expect(layerOpacity(tester, 'skeleton-reveal-content'), 1);
loading.value = true;
await tester.pump();
expect(layerOpacity(tester, 'skeleton-reveal-placeholder'), 1);
expect(layerOpacity(tester, 'skeleton-reveal-content'), 0);
});
testWidgets('reveals immediately for reduced motion', (tester) async {
final loading = ValueNotifier(true);
await tester.pumpWidget(
buildRevealTestable(loading: loading, disableAnimations: true),
);
final reveal = find.byType(SkeletonReveal);
expect(
find.descendant(of: reveal, matching: find.byType(ShaderMask)),
findsNothing,
);
loading.value = false;
await tester.pump();
expect(layerOpacity(tester, 'skeleton-reveal-placeholder'), 0);
expect(layerOpacity(tester, 'skeleton-reveal-content'), 1);
});
}