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); }); }); }