feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,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 desktop’s 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('Couldn’t 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user