feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,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');
});
});
}