Files
pyramid/test/settings_prefs_test.dart
T
Bernd SteckmeisterandClaude Opus 5.5 c5f10901e6 feat: UI-Umbau - weiches Dunkel, Schriftgroessen-Regler, Sprechblasen umschaltbar, Einstellungen als Vollbild
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]>
2026-10-07 17:16:07 +02:00

283 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});
}