"""Contrats locaux du gestionnaire wallpaper, sans recharger la session réelle.""" import importlib.util import fcntl import json from pathlib import Path import tempfile import unittest import sys from unittest import mock from PIL import Image SOURCE = Path(__file__).resolve().parents[1] / 'bin/.local/bin/wallpaper-manager.py' sys.path.insert(0, str(SOURCE.parent)) SPEC = importlib.util.spec_from_file_location('wallpaper_manager', SOURCE) manager = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(manager) class WallpaperManagerTest(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.root = Path(self.temp.name) self.patchers = [ mock.patch.object(manager, 'CACHE', self.root / 'cache'), mock.patch.object(manager, 'ALIASES', self.root / 'aliases'), mock.patch.object(manager, 'PREFERENCES', self.root / 'config/preferences.json'), mock.patch.object(manager, 'STATE', self.root / 'state/effective.json'), mock.patch.object(manager, 'GENERATED', self.root / 'sway/generated/wallpaper.conf'), mock.patch.object(manager, 'GENERATED_THEME', self.root / 'sway/generated/theme.conf'), mock.patch.object(manager, 'SWAY', self.root / 'sway'), ] for patcher in self.patchers: patcher.start() self.image = self.root / "fond d'écran (été).png" Image.new('RGB', (640, 360), 'blue').save(self.image) def tearDown(self): for patcher in reversed(self.patchers): patcher.stop() self.temp.cleanup() def test_scan_thumbnail_and_cache_key(self): bad = self.root / 'cassé.png' bad.write_text('pas une image') (self.root / 'notes.json').write_text('{}') link = self.root / 'lien.png' link.symlink_to(self.image) result = manager.scan(self.root, 0) self.assertEqual({x['filename'] for x in result['items']}, {self.image.name, link.name}) self.assertEqual(len({x['thumbnail'] for x in result['items']}), 1) meta = manager.validate_image(self.image) with Image.open(Path(manager.thumbnail(meta).removeprefix('file://'))) as thumb: self.assertEqual(thumb.size, (320, 180)) self.assertEqual(thumb.format, 'PNG') key = manager.cache_key(meta) meta['mtime'] += 1 self.assertNotEqual(key, manager.cache_key(meta)) def test_invalid_inputs_and_directory_uri(self): for path in (self.root / 'absent.png', self.root, self.root / 'texte.png'): if path.name == 'texte.png': path.write_text('invalide') with self.assertRaises((ValueError, OSError)): manager.validate_image(path) chosen = manager.set_directory(self.root.as_uri()) self.assertEqual(chosen['wallpaperDirectory'], str(self.root)) self.assertEqual(json.loads(manager.PREFERENCES.read_text())['version'], 3) def test_atomic_replace_keeps_old_file_on_write_failure(self): target = self.root / 'atomic.txt' target.write_bytes(b'ancien') with mock.patch.object(manager.os, 'replace', side_effect=OSError('échec')): with self.assertRaises(OSError): manager.atomic_write(target, b'nouveau') self.assertEqual(target.read_bytes(), b'ancien') def test_safe_alias_and_sway_syntax(self): meta = manager.validate_image(self.image) alias = manager.safe_alias(meta) self.assertEqual(Path(alias).resolve(), self.image.resolve()) self.assertNotIn('é', alias) self.assertNotIn(' ', alias) content = manager.render_wallpaper(alias).decode() self.assertIn(alias, content) self.assertNotIn(str(self.image), content) with self.assertRaises(ValueError): manager.render_wallpaper(str(self.image)) def test_validate_checks_sway_stderr_even_with_zero_exit(self): completed = mock.Mock(returncode=0, stderr='Error(s) loading config!', stdout='') with mock.patch.object(manager.subprocess, 'run', return_value=completed): with self.assertRaises(RuntimeError): manager.command('sway', '--validate') def test_simultaneous_apply_is_rejected_before_writing(self): manager.STATE.parent.mkdir(parents=True) lock_path = manager.STATE.parent / 'wallpaper.lock' with open(lock_path, 'a+b') as lock: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) with self.assertRaises(BlockingIOError): manager.apply(str(self.image)) self.assertFalse(manager.GENERATED.exists()) def test_interrupted_thumbnail_is_not_published(self): meta = manager.validate_image(self.image) target = manager.CACHE / (manager.cache_key(meta) + '.png') with mock.patch.object(manager.os, 'replace', side_effect=OSError('interrompu')): with self.assertRaises(OSError): manager.thumbnail(meta) self.assertFalse(target.exists()) self.assertEqual(list(manager.CACHE.glob('.thumbnail-*')), []) def test_reconcile_missing_image_uses_versioned_default(self): manager.write_json(manager.STATE, {'version': 2, 'effectiveWallpaper': str(self.root / 'gone.png')}) with mock.patch.object(manager, 'apply', return_value={'effectiveWallpaper': str(manager.DEFAULT)}) as apply: result = manager.reconcile() apply.assert_called_once_with(str(manager.DEFAULT)) self.assertEqual(result['recoveredFromMissing'], str(self.root / 'gone.png')) def test_apply_preserves_theme_and_rolls_back_on_failure(self): old = {'version': 1, 'effectiveFlavor': 'mocha', 'effectiveAccent': 'lavender', 'effectiveDark': True, 'effectiveHighContrast': False, 'revision': 8} manager.write_json(manager.STATE, old) manager.atomic_write(manager.GENERATED, b'ancien') with mock.patch.object(manager, 'command') as command, \ mock.patch.object(manager, 'swaybg_matches', return_value=True), \ mock.patch.object(manager, 'publish_live'): result = manager.apply(str(self.image)) self.assertEqual(result['revision'], 9) state = manager.read_json(manager.STATE) self.assertEqual(state['effectiveFlavor'], 'mocha') self.assertEqual(state['effectiveAccent'], 'lavender') self.assertEqual(state['version'], 3) self.assertIn(b'set $wallpaper ', manager.GENERATED.read_bytes()) self.assertEqual(command.call_count, 2) newer = self.root / 'autre.png' Image.new('RGB', (32, 32), 'black').save(newer) with mock.patch.object(manager, 'command', side_effect=[RuntimeError('validate'), None]): with self.assertRaises(RuntimeError): manager.apply(str(newer)) self.assertEqual(manager.read_json(manager.STATE), state) self.assertIn(b'set $wallpaper ', manager.GENERATED.read_bytes()) def test_manual_wallpaper_does_not_change_theme(self): manager.write_json(manager.STATE, {'version': 3, 'effectiveFlavor': 'mocha', 'effectiveWallpaper': str(self.image), 'wallpaperMtime': 0, 'revision': 3, 'themeMode': 'manual', 'manualFlavor': 'mocha'}) bright = self.root / 'clair.png' Image.new('RGB', (48, 48), 'white').save(bright) with mock.patch.object(manager, 'command'), mock.patch.object(manager, 'publish_live'), \ mock.patch.object(manager, 'swaybg_matches', return_value=True): result = manager.apply(str(bright)) self.assertEqual(result['effectiveFlavor'], 'mocha') self.assertFalse(manager.GENERATED_THEME.exists()) self.assertEqual(result['revision'], 4) def test_auto_activation_and_manual_restoration(self): manager.write_json(manager.STATE, {'version': 3, 'effectiveFlavor': 'mocha', 'effectiveWallpaper': str(self.image), 'revision': 3, 'themeMode': 'manual', 'manualFlavor': 'mocha'}) with mock.patch.object(manager, 'command'), mock.patch.object(manager, 'publish_live'): automatic = manager.set_theme_mode('wallpaper') self.assertEqual(automatic['manualFlavor'], 'mocha') self.assertEqual(automatic['effectiveFlavor'], 'mocha') restored = manager.set_theme_mode('manual') self.assertEqual(restored['effectiveFlavor'], 'mocha') self.assertEqual(restored['manualFlavor'], 'mocha') self.assertEqual(restored['revision'], 5) def test_auto_wallpaper_and_rollback(self): manager.write_json(manager.STATE, {'version': 3, 'effectiveFlavor': 'mocha', 'effectiveWallpaper': str(self.image), 'revision': 5, 'themeMode': 'wallpaper', 'manualFlavor': 'mocha'}) bright = self.root / 'clair.png' Image.new('RGB', (48, 48), 'white').save(bright) with mock.patch.object(manager, 'command'), mock.patch.object(manager, 'publish_live'), \ mock.patch.object(manager, 'swaybg_matches', return_value=True): result = manager.apply(str(bright)) self.assertEqual(result['effectiveFlavor'], 'latte') self.assertFalse(result['effectiveDark']) previous_theme = manager.GENERATED_THEME.read_bytes() previous_wallpaper = manager.GENERATED.read_bytes() previous_state = manager.STATE.read_bytes() dark = self.root / 'sombre.png' Image.new('RGB', (48, 48), 'black').save(dark) with mock.patch.object(manager, 'command', side_effect=[None, RuntimeError('reload'), None]): with self.assertRaises(RuntimeError): manager.apply(str(dark)) self.assertEqual(manager.GENERATED_THEME.read_bytes(), previous_theme) self.assertEqual(manager.GENERATED.read_bytes(), previous_wallpaper) self.assertEqual(manager.STATE.read_bytes(), previous_state) def test_schema_two_migration_keeps_previous_fields(self): legacy = {'version': 2, 'effectiveFlavor': 'macchiato', 'effectiveAccent': 'lavender', 'effectiveDark': True, 'effectiveHighContrast': False, 'effectiveMode': 'normal', 'effectiveWallpaper': str(self.image), 'revision': 47, 'customFutureField': 'conservé'} manager.write_json(manager.STATE, legacy) manager.write_json(manager.PREFERENCES, {'version': 1, 'wallpaperDirectory': str(self.root), 'wallpaperMode': 'fill'}) with mock.patch.object(manager, 'command') as command: result = manager.reconcile_state() self.assertEqual(result['version'], 3) self.assertEqual(result['revision'], 47) self.assertEqual(result['customFutureField'], 'conservé') self.assertEqual(result['effectiveFlavor'], 'macchiato') self.assertEqual(result['themeMode'], 'manual') self.assertEqual(result['manualFlavor'], 'macchiato') self.assertEqual(result['wallpaperDirectory'], str(self.root)) self.assertEqual(command.call_count, 2) def test_crash_like_reconciliation_uses_effective_state(self): manager.write_json(manager.STATE, {'version': 3, 'effectiveFlavor': 'mocha', 'effectiveWallpaper': str(self.image), 'revision': 6, 'themeMode': 'manual', 'manualFlavor': 'mocha'}) manager.write_json(manager.PREFERENCES, {'version': 2, 'themeMode': 'wallpaper', 'manualFlavor': 'latte', 'wallpaperDirectory': str(self.root)}) manager.atomic_write(manager.GENERATED_THEME, b'interrompu') manager.atomic_write(manager.GENERATED, b'interrompu') with mock.patch.object(manager, 'command') as command: result = manager.reconcile_state() self.assertEqual(result['revision'], 6) self.assertEqual(result['themeMode'], 'manual') self.assertEqual(result['manualFlavor'], 'mocha') self.assertIn(b'set $base ', manager.GENERATED_THEME.read_bytes()) self.assertIn(b'set $wallpaper ', manager.GENERATED.read_bytes()) self.assertEqual(command.call_count, 2) def test_auto_analysis_failure_does_not_change_files(self): old = {'version': 3, 'effectiveFlavor': 'mocha', 'effectiveWallpaper': str(self.image), 'revision': 6, 'themeMode': 'manual', 'manualFlavor': 'mocha'} manager.write_json(manager.STATE, old) with mock.patch.object(manager, 'analyze_wallpaper', side_effect=ValueError('décodeur')): with self.assertRaisesRegex(ValueError, 'Analyse Auto Theme'): manager.set_theme_mode('wallpaper') self.assertEqual(manager.read_json(manager.STATE), old) self.assertFalse(manager.GENERATED_THEME.exists()) def test_auto_reconcile_restores_missing_analysis_without_revision(self): meta = manager.validate_image(self.image) manager.write_json(manager.STATE, {'version': 3, 'effectiveFlavor': 'mocha', 'effectiveWallpaper': str(self.image), 'wallpaperMtime': meta['mtime'], 'revision': 12, 'themeMode': 'wallpaper', 'manualFlavor': 'latte'}) with mock.patch.object(manager, 'command'): result = manager.reconcile_state() self.assertEqual(result['revision'], 12) self.assertEqual(result['effectiveFlavor'], 'mocha') self.assertEqual(result['manualFlavor'], 'latte') self.assertEqual(result['wallpaperAnalysis']['algorithmVersion'], 1) if __name__ == '__main__': unittest.main()