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,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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user