Bernds Entscheidungen 2026-10-07: - theme.dart: ThemeVariant (Dunkel = neues weiches Dunkelgrau als Standard, Schwarz = bisheriges Fast-Schwarz, Hell) mit PyramidPalette; Nebentexte kontrastreicher. Alte Einstellung theme_dark wird uebernommen (dunkel -> weiches Dunkel). Fest verdrahtete Dunkelfarben im Profil-Popup und GIF-Picker folgen jetzt dem Farbmodus. - appearance.dart: Schriftgroesse = Pyramid-Regler x Systemeinstellung (Handy-Standard 115 %, PC 100 %), MessageStyle Automatisch/Sprechblasen/ Liste, gemeinsame isMobileLayout-Regel. - message_bubbles.dart: Sprechblasen-Darstellung (eigene rechts in Akzentfarbe, Gruppen mit Name + Avatar, Bilder ohne Blase in voller Groesse); Listen-Darstellung unveraendert, Weiche in MessageGroup. - Formatierte Nachrichten (u. a. Antworten) nutzten RichText ohne Schriftskalierung -> erschienen kleiner; jetzt Text.rich. - Einstellungen auf dem Handy als ganze Seite, Schalter unter dem Text. - UI-Durchlauf um Liste/Hell/Gruppe/Einstellungen erweitert; Tests fuer Theme-Varianten, Uebernahme alter Werte, Schriftfaktor, Darstellung. Co-Authored-By: Claude Opus 5.5 <[email protected]>
283 lines
10 KiB
Dart
283 lines
10 KiB
Dart
import 'package:flutter/painting.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:pyramid/core/appearance.dart';
|
||
import 'package:pyramid/core/settings_prefs.dart';
|
||
import 'package:pyramid/core/theme.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
|
||
/// Tests für die Storage-Fassade, die laut M2-Modulschnitt zur Pflicht-
|
||
/// Schnittstelle für alle Einstellungs-Zugriffe ausgebaut werden soll –
|
||
/// dieses Vertragsnetz muss bei dem Umbau stehen bleiben.
|
||
void main() {
|
||
TestWidgetsFlutterBinding.ensureInitialized();
|
||
|
||
setUp(() {
|
||
// Frischer In-Memory-Store pro Test (setzt auch die gecachte
|
||
// SharedPreferences-Instanz zurück).
|
||
SharedPreferences.setMockInitialValues({});
|
||
});
|
||
|
||
group('BoolPref', () {
|
||
test('lädt persistierten Wert und überstimmt damit den Default', () async {
|
||
SharedPreferences.setMockInitialValues({'notif_sound': false});
|
||
final pref = BoolPref('notif_sound', true);
|
||
expect(pref.state, true, reason: 'Default gilt, bis geladen ist');
|
||
await pumpEventQueue();
|
||
expect(pref.state, false, reason: 'persistierter Wert muss gewinnen');
|
||
});
|
||
|
||
test(
|
||
'set() sofort nach Erstellung gewinnt gegen den später ladenden '
|
||
'persistierten Wert (Lade-Race)', () async {
|
||
SharedPreferences.setMockInitialValues({'notif_sound': false});
|
||
final pref = BoolPref('notif_sound', false);
|
||
final done = pref.set(true); // bevor _load fertig ist
|
||
await pumpEventQueue();
|
||
await done;
|
||
expect(pref.state, true,
|
||
reason: 'die Nutzeraktion darf nicht vom Lade-Ergebnis '
|
||
'zurücküberschrieben werden');
|
||
final p = await SharedPreferences.getInstance();
|
||
expect(p.getBool('notif_sound'), true);
|
||
});
|
||
|
||
test('set/toggle ändern Zustand und persistieren', () async {
|
||
final pref = BoolPref('notif_desktop', true);
|
||
await pumpEventQueue();
|
||
pref.toggle();
|
||
await pumpEventQueue();
|
||
expect(pref.state, false);
|
||
final p = await SharedPreferences.getInstance();
|
||
expect(p.getBool('notif_desktop'), false);
|
||
});
|
||
});
|
||
|
||
group('StringSetPref', () {
|
||
test('lädt persistierte Liste als Set, add/remove persistieren', () async {
|
||
SharedPreferences.setMockInitialValues({
|
||
'muted_rooms': ['!a:hs', '!b:hs'],
|
||
});
|
||
final pref = StringSetPref('muted_rooms');
|
||
await pumpEventQueue();
|
||
expect(pref.state, {'!a:hs', '!b:hs'});
|
||
|
||
await pref.add('!c:hs');
|
||
await pref.remove('!a:hs');
|
||
final p = await SharedPreferences.getInstance();
|
||
expect(p.getStringList('muted_rooms')!.toSet(), {'!b:hs', '!c:hs'});
|
||
});
|
||
|
||
test(
|
||
'add() sofort nach Erstellung verliert keine bereits persistierten '
|
||
'Einträge (Lade-Race)', () async {
|
||
SharedPreferences.setMockInitialValues({
|
||
'muted_rooms': ['!a:hs'],
|
||
});
|
||
final pref = StringSetPref('muted_rooms');
|
||
final done = pref.add('!b:hs'); // bevor _load fertig ist
|
||
await pumpEventQueue();
|
||
await done;
|
||
expect(pref.state, {'!a:hs', '!b:hs'});
|
||
final p = await SharedPreferences.getInstance();
|
||
expect(p.getStringList('muted_rooms')!.toSet(), {'!a:hs', '!b:hs'},
|
||
reason: 'Read-Modify-Write auf ungeladenem State darf keine '
|
||
'persistierten Einträge verlieren');
|
||
});
|
||
|
||
test('doppeltes add und remove von Fehlendem sind No-Ops', () async {
|
||
final pref = StringSetPref('muted_rooms');
|
||
await pumpEventQueue();
|
||
await pref.add('!a:hs');
|
||
await pref.add('!a:hs');
|
||
await pref.remove('!gibtsnicht:hs');
|
||
expect(pref.state, {'!a:hs'});
|
||
});
|
||
});
|
||
|
||
group('Theme-Prefs', () {
|
||
test('leerer Store liefert die dokumentierten Defaults', () async {
|
||
final t = await loadThemePrefs();
|
||
expect(
|
||
t.variant,
|
||
ThemeVariant.soft,
|
||
reason: 'Standard seit 2026-10-07: weiches Dunkel',
|
||
);
|
||
expect(t.accentIdx, 0);
|
||
expect(t.radius, 12.0);
|
||
expect(t.motion, 1.0);
|
||
expect(t.textScale, isNull, reason: 'null = automatisch');
|
||
expect(t.messageStyle, MessageStyle.auto);
|
||
});
|
||
|
||
test('Roundtrip: gespeicherte Werte kommen zurück, Teil-Save lässt '
|
||
'den Rest unangetastet', () async {
|
||
await saveThemePrefs(
|
||
variant: ThemeVariant.light,
|
||
accentIdx: 3,
|
||
radius: 8,
|
||
motion: 0.5,
|
||
textScale: 1.3,
|
||
messageStyle: MessageStyle.list,
|
||
);
|
||
var t = await loadThemePrefs();
|
||
expect(
|
||
(
|
||
t.variant,
|
||
t.accentIdx,
|
||
t.radius,
|
||
t.motion,
|
||
t.textScale,
|
||
t.messageStyle,
|
||
),
|
||
(ThemeVariant.light, 3, 8.0, 0.5, 1.3, MessageStyle.list),
|
||
);
|
||
|
||
// Nur ein Feld ändern – die anderen dürfen nicht zurückfallen.
|
||
await saveThemePrefs(accentIdx: 1);
|
||
t = await loadThemePrefs();
|
||
expect(
|
||
(
|
||
t.variant,
|
||
t.accentIdx,
|
||
t.radius,
|
||
t.motion,
|
||
t.textScale,
|
||
t.messageStyle,
|
||
),
|
||
(ThemeVariant.light, 1, 8.0, 0.5, 1.3, MessageStyle.list),
|
||
);
|
||
|
||
// Schriftgröße zurück auf automatisch.
|
||
await saveThemePrefs(resetTextScale: true);
|
||
t = await loadThemePrefs();
|
||
expect(t.textScale, isNull);
|
||
});
|
||
|
||
test(
|
||
'Übernahme alter Einstellungen (vor 2026-10-07 nur theme_dark)',
|
||
() async {
|
||
SharedPreferences.setMockInitialValues({'theme_dark': true});
|
||
expect(
|
||
(await loadThemePrefs()).variant,
|
||
ThemeVariant.soft,
|
||
reason: 'wer dunkel hatte, bekommt das neue weiche Dunkel',
|
||
);
|
||
|
||
SharedPreferences.setMockInitialValues({'theme_dark': false});
|
||
expect((await loadThemePrefs()).variant, ThemeVariant.light);
|
||
},
|
||
);
|
||
|
||
test(
|
||
'Variante „Schwarz“ wird gespeichert und theme_dark mitgepflegt',
|
||
() async {
|
||
await saveThemePrefs(variant: ThemeVariant.black);
|
||
final p = await SharedPreferences.getInstance();
|
||
expect(p.getString('theme_variant'), 'black');
|
||
expect(
|
||
p.getBool('theme_dark'),
|
||
true,
|
||
reason: 'ältere App-Versionen lesen nur theme_dark',
|
||
);
|
||
expect((await loadThemePrefs()).variant, ThemeVariant.black);
|
||
},
|
||
);
|
||
});
|
||
|
||
group('Darstellung', () {
|
||
test('Sprechblasen: Automatisch = Handy ja, PC nein', () {
|
||
expect(MessageStyle.auto.useBubbles(mobile: true), isTrue);
|
||
expect(MessageStyle.auto.useBubbles(mobile: false), isFalse);
|
||
expect(MessageStyle.bubbles.useBubbles(mobile: false), isTrue);
|
||
expect(MessageStyle.list.useBubbles(mobile: true), isFalse);
|
||
});
|
||
|
||
test('Schriftfaktor wirkt zusätzlich zur Systemeinstellung', () {
|
||
const system = TextScaler.linear(1.2);
|
||
const scaler = PyramidTextScaler(system, 1.15);
|
||
expect(scaler.scale(14), closeTo(14 * 1.15 * 1.2, 0.001));
|
||
expect(defaultTextScale(mobile: true), greaterThan(1.0));
|
||
expect(defaultTextScale(mobile: false), 1.0);
|
||
});
|
||
|
||
test('Handy-Layout-Grenze', () {
|
||
expect(isMobileLayout(const Size(411, 891)), isTrue);
|
||
expect(isMobileLayout(const Size(1280, 720)), isFalse);
|
||
expect(
|
||
isMobileLayout(const Size(891, 411)),
|
||
isTrue,
|
||
reason: 'Handy quer (flach) bleibt Handy-Layout',
|
||
);
|
||
});
|
||
});
|
||
|
||
group('VoicePrefs', () {
|
||
// Die Fassade (M2, 2026-07-06) hat drei dokumentierte Garantien:
|
||
// historische voice_*-Keys unverändert, null/leer = Pref löschen,
|
||
// Fallback-Lautstärke 1.0. Dieser Vertrag muss bei jedem weiteren
|
||
// Umbau der Call-Schicht stehen bleiben.
|
||
test('leerer Store: Geräte null (= System-Standard), Lautstärke 1.0',
|
||
() async {
|
||
final d = await VoicePrefs.loadDevices();
|
||
expect((d.mic, d.speaker, d.camera), (null, null, null));
|
||
expect(await VoicePrefs.loadOutputVolume(), 1.0);
|
||
});
|
||
|
||
test('liest die historischen voice_*-Keys – Bestands-Einstellungen '
|
||
'(Utas Gerät!) bleiben nach dem Fassaden-Umbau erhalten', () async {
|
||
SharedPreferences.setMockInitialValues({
|
||
'voice_mic_device': 'mic-1',
|
||
'voice_speaker_device': 'spk-1',
|
||
'voice_camera_device': 'cam-1',
|
||
'voice_output_volume': 0.4,
|
||
});
|
||
final d = await VoicePrefs.loadDevices();
|
||
expect((d.mic, d.speaker, d.camera), ('mic-1', 'spk-1', 'cam-1'));
|
||
expect(await VoicePrefs.loadOutputVolume(), 0.4);
|
||
});
|
||
|
||
test('Roundtrip unter denselben Keys; null/leer LÖSCHT das Pref', () async {
|
||
await VoicePrefs.saveMic('mic-2');
|
||
await VoicePrefs.saveSpeaker('spk-2');
|
||
await VoicePrefs.saveCamera('cam-2');
|
||
await VoicePrefs.saveOutputVolume(0.75);
|
||
final p = await SharedPreferences.getInstance();
|
||
// Key-Vertrag: exakt die historischen Keys, keine neuen Namen.
|
||
expect(p.getString('voice_mic_device'), 'mic-2');
|
||
expect(p.getString('voice_speaker_device'), 'spk-2');
|
||
expect(p.getString('voice_camera_device'), 'cam-2');
|
||
expect(p.getDouble('voice_output_volume'), 0.75);
|
||
|
||
await VoicePrefs.saveMic(null);
|
||
await VoicePrefs.saveSpeaker('');
|
||
final d = await VoicePrefs.loadDevices();
|
||
expect((d.mic, d.speaker, d.camera), (null, null, 'cam-2'));
|
||
expect(p.containsKey('voice_mic_device'), false,
|
||
reason: 'null muss das Pref löschen (System-Standard), '
|
||
'nicht einen leeren String speichern');
|
||
expect(p.containsKey('voice_speaker_device'), false,
|
||
reason: 'leerer String zählt wie null');
|
||
});
|
||
});
|
||
|
||
group('Server-Banner', () {
|
||
test('Roundtrip + Entfernen per null/leerem mxcUri', () async {
|
||
await saveServerBanner('https://hs1', 'mxc://hs1/abc');
|
||
await saveServerBanner('https://hs2', 'mxc://hs2/def');
|
||
expect(await loadAllServerBanners(),
|
||
{'https://hs1': 'mxc://hs1/abc', 'https://hs2': 'mxc://hs2/def'});
|
||
|
||
await saveServerBanner('https://hs1', null);
|
||
await saveServerBanner('https://hs2', '');
|
||
expect(await loadAllServerBanners(), isEmpty);
|
||
});
|
||
|
||
test('korruptes JSON im Store crasht nicht, sondern liefert leere Map',
|
||
() async {
|
||
SharedPreferences.setMockInitialValues(
|
||
{'server_banners': '{kein json'});
|
||
expect(await loadAllServerBanners(), isEmpty);
|
||
});
|
||
});
|
||
}
|