Files
buzz/mobile/test/shared/theme/community_theme_preference_test.dart
kenny lopez bbffe43ecc fix(theme): snapshot pre-migration appearance for per-community carry-forward
The appearance-widening migration read the live global glass keys, which this
feature now rewrites on every community switch, and gated the carry-forward on
a single per-profile latch. A brand-new or unreachable community visited first
therefore reset glassBackground/glassOpacity/prominentActiveTab to defaults and
republished the loss, and the one-shot latch only ever carried settings forward
into whichever community hydrated first.

Capture the profile's pre-migration appearance once into a durable snapshot,
before the first mount rewrites the global keys, and derive both the empty-scope
and legacy-record fallbacks from that snapshot. Every community now inherits the
same former glass and prominent-tab choices regardless of hydration order or
relay reachability. Replaces the per-profile appearance-migrated latch.

On mobile, defaultCommunityTheme and legacyPreference() were constructor-built
with the appearance presence flags defaulting to true, so a pre-hydration edit
serialized false/65/false over a real desktop record. Mark those two origins as
carrying no desktop appearance opinion so they omit the desktop-only fields,
matching how a three-field payload parses.

Co-authored-by: kenny lopez <klopez4212@gmail.com>
Reviewed-by: Mongo <mongo@buzz.block.builderlab.xyz>
Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-08-20 08:09:44 +01:00

276 lines
8.4 KiB
Dart

import 'dart:convert';
import 'dart:io';
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('complete desktop v1 payload round-trips exactly', () {
final preference = CommunityThemePreference.fromJson({
'version': 1,
'theme': 'github-dark',
'accent': '#c0a2f1',
'followSystem': false,
'glassBackground': true,
'glassOpacity': 47,
'prominentActiveTab': false,
});
expect(preference.mode, ThemeMode.dark);
expect(
jsonEncode(preference.toJson()),
'{"version":1,"theme":"github-dark","accent":"#c0a2f1","followSystem":false,"glassBackground":true,"glassOpacity":47,"prominentActiveTab":false}',
);
});
test('older v1 payloads retain missing desktop-only fields', () {
final preference = CommunityThemePreference.fromJson({
'version': 1,
'theme': 'buzz',
'accent': '#3b82f6',
'followSystem': true,
});
expect(preference.glassBackground, isFalse);
expect(preference.glassOpacity, 65);
expect(preference.prominentActiveTab, isFalse);
expect(preference.toJson(), isNot(contains('glassBackground')));
expect(preference.toJson(), isNot(contains('glassOpacity')));
expect(preference.toJson(), isNot(contains('prominentActiveTab')));
});
test('mobile edits do not widen legacy appearance fields with defaults', () {
final legacy = CommunityThemePreference.fromJson({
'version': 1,
'theme': 'buzz',
'accent': '#3b82f6',
'followSystem': true,
});
final edited = legacy.copyWith(accent: '#ef4444');
expect(edited.toJson(), {
'version': 1,
'theme': 'buzz',
'accent': '#ef4444',
'followSystem': true,
});
expect(legacy.copyWith(glassOpacity: 70).toJson(), {
'version': 1,
'theme': 'buzz',
'accent': '#3b82f6',
'followSystem': true,
'glassOpacity': 70,
});
});
test('mobile appearance limits match the shared wire contract', () {
final contract =
jsonDecode(File('../schema/community-theme-v1.json').readAsStringSync())
as Map<String, dynamic>;
final properties = contract['properties'] as Map<String, dynamic>;
final glassBackground =
properties['glassBackground'] as Map<String, dynamic>;
final glassOpacity = properties['glassOpacity'] as Map<String, dynamic>;
final prominentActiveTab =
properties['prominentActiveTab'] as Map<String, dynamic>;
expect(defaultCommunityGlassBackground, glassBackground['default'] as bool);
expect(defaultCommunityGlassOpacity, glassOpacity['default'] as int);
expect(minCommunityGlassOpacity, glassOpacity['minimum'] as int);
expect(maxCommunityGlassOpacity, glassOpacity['maximum'] as int);
expect(
defaultCommunityProminentActiveTab,
prominentActiveTab['default'] as bool,
);
});
test('rejects invalid theme and appearance values', () {
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,
},
{
'version': 1,
'theme': 'buzz',
'accent': '#3b82f6',
'followSystem': true,
'glassBackground': 'true',
},
{
'version': 1,
'theme': 'buzz',
'accent': '#3b82f6',
'followSystem': true,
'glassOpacity': 29,
},
{
'version': 1,
'theme': 'buzz',
'accent': '#3b82f6',
'followSystem': true,
'prominentActiveTab': 1,
},
{
'version': 1,
'theme': 'buzz',
'accent': '#3b82f6',
'followSystem': true,
'glassBackground': null,
},
{
'version': 1,
'theme': 'buzz',
'accent': '#3b82f6',
'followSystem': true,
'glassOpacity': null,
},
{
'version': 1,
'theme': 'buzz',
'accent': '#3b82f6',
'followSystem': true,
'prominentActiveTab': null,
},
]) {
expect(
() => CommunityThemePreference.fromJson(payload),
throwsFormatException,
);
}
});
test('a fresh community default omits desktop-only appearance fields', () {
// Regression: a pre-hydration mobile edit built from the default must not
// publish false/65/false over a real desktop record on the relay.
expect(defaultCommunityTheme.toJson(), {
'version': 1,
'theme': 'buzz',
'accent': '#3b82f6',
'followSystem': true,
});
expect(defaultCommunityTheme.copyWith(accent: '#ef4444').toJson(), {
'version': 1,
'theme': 'buzz',
'accent': '#ef4444',
'followSystem': true,
});
});
test('supported edits preserve appearance values mobile cannot render', () {
const desktopPreference = CommunityThemePreference(
theme: 'buzz',
accent: '#3b82f6',
followSystem: true,
glassBackground: true,
glassOpacity: 42,
prominentActiveTab: false,
);
final mobileEdit = desktopPreference.copyWith(theme: 'dracula');
expect(mobileEdit.theme, 'dracula');
expect(mobileEdit.glassBackground, isTrue);
expect(mobileEdit.glassOpacity, 42);
expect(mobileEdit.prominentActiveTab, isFalse);
});
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,
// The legacy mobile theme carries no desktop appearance opinion, so it
// must not serialize desktop-only defaults over a real desktop record.
includesGlassBackground: false,
includesGlassOpacity: false,
includesProminentActiveTab: false,
),
);
expect(preference.toJson(), isNot(contains('glassBackground')));
expect(preference.toJson(), isNot(contains('glassOpacity')));
expect(preference.toJson(), isNot(contains('prominentActiveTab')));
expect(preference.mode, ThemeMode.dark);
expect(legacyAccentWireValue(6), '#a855f7');
expect(legacyAccentWireValue(7), '#6366f1');
});
}