|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# SPDX-FileCopyrightText: 2026 mxmilkiib |
| 3 | +# SPDX-License-Identifier: GPL-2.0-or-later |
| 4 | + |
| 5 | +"""Tests for disk-tree persistence and Collapse All feature.""" |
| 6 | + |
| 7 | +import os |
| 8 | +import sys |
| 9 | +import tempfile |
| 10 | + |
| 11 | +os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') |
| 12 | + |
| 13 | +_REPO = os.path.join(os.path.dirname(__file__), '..') |
| 14 | +sys.path.insert(0, os.path.abspath(os.path.join(_REPO, 'source', 'frontend'))) |
| 15 | +sys.path.insert(0, os.path.abspath(os.path.join(_REPO, 'bin'))) |
| 16 | + |
| 17 | +import pytest |
| 18 | +from unittest.mock import MagicMock, patch, PropertyMock |
| 19 | + |
| 20 | + |
| 21 | +@pytest.fixture(scope='session') |
| 22 | +def qapp(): |
| 23 | + from PyQt6.QtWidgets import QApplication |
| 24 | + app = QApplication.instance() or QApplication([]) |
| 25 | + yield app |
| 26 | + |
| 27 | + |
| 28 | +# ---------------------------------------------------------------------------- |
| 29 | +# QSafeSettings bool coercion |
| 30 | +# ---------------------------------------------------------------------------- |
| 31 | + |
| 32 | +def test_qsafesettings_reads_true_string_as_bool_true(qapp): |
| 33 | + """String 'true' stored in INI must come back as Python True.""" |
| 34 | + from PyQt6.QtCore import QSettings |
| 35 | + from utils.qsafesettings import QSafeSettings |
| 36 | + |
| 37 | + with tempfile.NamedTemporaryFile(suffix='.ini', delete=False) as f: |
| 38 | + path = f.name |
| 39 | + try: |
| 40 | + raw = QSettings(path, QSettings.Format.IniFormat) |
| 41 | + raw.setValue('Section/BoolKey', 'true') |
| 42 | + del raw |
| 43 | + |
| 44 | + qs = QSafeSettings(path, QSettings.Format.IniFormat) |
| 45 | + v = qs.value('Section/BoolKey', False, bool) |
| 46 | + assert v is True, f"Expected True, got {v!r}" |
| 47 | + finally: |
| 48 | + os.unlink(path) |
| 49 | + |
| 50 | + |
| 51 | +def test_qsafesettings_reads_false_string_as_bool_false(qapp): |
| 52 | + """String 'false' stored in INI must come back as Python False.""" |
| 53 | + from PyQt6.QtCore import QSettings |
| 54 | + from utils.qsafesettings import QSafeSettings |
| 55 | + |
| 56 | + with tempfile.NamedTemporaryFile(suffix='.ini', delete=False) as f: |
| 57 | + path = f.name |
| 58 | + try: |
| 59 | + raw = QSettings(path, QSettings.Format.IniFormat) |
| 60 | + raw.setValue('Section/BoolKey', 'false') |
| 61 | + del raw |
| 62 | + |
| 63 | + qs = QSafeSettings(path, QSettings.Format.IniFormat) |
| 64 | + v = qs.value('Section/BoolKey', True, bool) |
| 65 | + assert v is False, f"Expected False, got {v!r}" |
| 66 | + finally: |
| 67 | + os.unlink(path) |
| 68 | + |
| 69 | + |
| 70 | +def test_qsafesettings_missing_key_returns_default(qapp): |
| 71 | + """A key that was never written must return the defaultValue.""" |
| 72 | + from PyQt6.QtCore import QSettings |
| 73 | + from utils.qsafesettings import QSafeSettings |
| 74 | + |
| 75 | + with tempfile.NamedTemporaryFile(suffix='.ini', delete=False) as f: |
| 76 | + path = f.name |
| 77 | + try: |
| 78 | + qs = QSafeSettings(path, QSettings.Format.IniFormat) |
| 79 | + v = qs.value('Nonexistent/Key', False, bool) |
| 80 | + assert v is False |
| 81 | + finally: |
| 82 | + os.unlink(path) |
| 83 | + |
| 84 | + |
| 85 | +def test_qsafesettings_roundtrip_bool_true(qapp): |
| 86 | + """setValue(True) round-trips correctly through a file.""" |
| 87 | + from PyQt6.QtCore import QSettings |
| 88 | + from utils.qsafesettings import QSafeSettings |
| 89 | + |
| 90 | + with tempfile.NamedTemporaryFile(suffix='.ini', delete=False) as f: |
| 91 | + path = f.name |
| 92 | + try: |
| 93 | + qs_write = QSafeSettings(path, QSettings.Format.IniFormat) |
| 94 | + qs_write.setValue('Section/Flag', True) |
| 95 | + del qs_write |
| 96 | + |
| 97 | + qs_read = QSafeSettings(path, QSettings.Format.IniFormat) |
| 98 | + v = qs_read.value('Section/Flag', False, bool) |
| 99 | + assert v is True, f"Expected True, got {v!r}" |
| 100 | + finally: |
| 101 | + os.unlink(path) |
| 102 | + |
| 103 | + |
| 104 | +# ---------------------------------------------------------------------------- |
| 105 | +# HostWindow structural checks (source-text guards) |
| 106 | +# ---------------------------------------------------------------------------- |
| 107 | + |
| 108 | +def _host_src(): |
| 109 | + path = os.path.join(_REPO, 'source', 'frontend', 'carla_host.py') |
| 110 | + return open(path).read() |
| 111 | + |
| 112 | + |
| 113 | +def test_host_has_fExpandedDirs(): |
| 114 | + assert 'fExpandedDirs' in _host_src() |
| 115 | + |
| 116 | + |
| 117 | +def test_host_has_slot_fileTreeExpanded(): |
| 118 | + assert 'slot_fileTreeExpanded' in _host_src() |
| 119 | + |
| 120 | + |
| 121 | +def test_host_has_slot_fileTreeCollapsed(): |
| 122 | + assert 'slot_fileTreeCollapsed' in _host_src() |
| 123 | + |
| 124 | + |
| 125 | +def test_host_has_slot_diskCollapseAll(): |
| 126 | + assert 'slot_diskCollapseAll' in _host_src() |
| 127 | + |
| 128 | + |
| 129 | +def test_host_saves_DiskExpandedDirs(): |
| 130 | + assert '"DiskExpandedDirs"' in _host_src() |
| 131 | + |
| 132 | + |
| 133 | +def test_host_restores_DiskExpandedDirs(): |
| 134 | + src = _host_src() |
| 135 | + assert 'DiskExpandedDirs' in src |
| 136 | + assert '_restoreExpandedDirs' in src |
| 137 | + |
| 138 | + |
| 139 | +def test_host_connects_b_disk_collapse(): |
| 140 | + src = _host_src() |
| 141 | + assert 'b_disk_collapse' in src |
| 142 | + assert 'slot_diskCollapseAll' in src |
| 143 | + |
| 144 | + |
| 145 | +def test_host_connects_fileTreeView_expanded(): |
| 146 | + src = _host_src() |
| 147 | + assert 'fileTreeView.expanded' in src |
| 148 | + |
| 149 | + |
| 150 | +def test_host_connects_fileTreeView_collapsed(): |
| 151 | + src = _host_src() |
| 152 | + assert 'fileTreeView.collapsed' in src |
| 153 | + |
| 154 | + |
| 155 | +# ---------------------------------------------------------------------------- |
| 156 | +# carla_host.ui structural checks |
| 157 | +# ---------------------------------------------------------------------------- |
| 158 | + |
| 159 | +def _ui_src(): |
| 160 | + path = os.path.join(_REPO, 'resources', 'ui', 'carla_host.ui') |
| 161 | + return open(path).read() |
| 162 | + |
| 163 | + |
| 164 | +def test_ui_has_b_disk_collapse(): |
| 165 | + assert 'b_disk_collapse' in _ui_src() |
| 166 | + |
| 167 | + |
| 168 | +def test_ui_disk_collapse_has_tooltip(): |
| 169 | + src = _ui_src() |
| 170 | + assert 'Collapse all folders' in src |
| 171 | + |
| 172 | + |
| 173 | +# ---------------------------------------------------------------------------- |
| 174 | +# Slot logic (unit-level) |
| 175 | +# ---------------------------------------------------------------------------- |
| 176 | + |
| 177 | +class _FakeDirModel: |
| 178 | + """Minimal stand-in for QFileSystemModel.""" |
| 179 | + def __init__(self, path_map): |
| 180 | + self._map = path_map # index → path |
| 181 | + |
| 182 | + def filePath(self, idx): |
| 183 | + return self._map.get(idx, '') |
| 184 | + |
| 185 | + |
| 186 | +class _FakeTreeView: |
| 187 | + def __init__(self): |
| 188 | + self.collapsed_all = False |
| 189 | + self.expanded_set = set() |
| 190 | + |
| 191 | + def collapseAll(self): |
| 192 | + self.collapsed_all = True |
| 193 | + |
| 194 | + def expand(self, idx): |
| 195 | + self.expanded_set.add(idx) |
| 196 | + |
| 197 | + |
| 198 | +def _make_host_stub(): |
| 199 | + """Build a minimal object that mimics the relevant HostWindow state.""" |
| 200 | + host = MagicMock() |
| 201 | + host.fExpandedDirs = set() |
| 202 | + host.fDirModel = _FakeDirModel({'idx_a': '/home/user/music', 'idx_b': '/home/user/samples'}) |
| 203 | + host.ui = MagicMock() |
| 204 | + host.ui.fileTreeView = _FakeTreeView() |
| 205 | + return host |
| 206 | + |
| 207 | + |
| 208 | +def test_slot_fileTreeExpanded_adds_path(): |
| 209 | + from carla_host import HostWindow |
| 210 | + # test the logic directly without creating a full HostWindow |
| 211 | + fExpandedDirs = set() |
| 212 | + dirModel = _FakeDirModel({'i': '/foo/bar'}) |
| 213 | + |
| 214 | + def slot_fileTreeExpanded(modelIndex): |
| 215 | + path = dirModel.filePath(modelIndex) |
| 216 | + fExpandedDirs.add(path) |
| 217 | + |
| 218 | + slot_fileTreeExpanded('i') |
| 219 | + assert '/foo/bar' in fExpandedDirs |
| 220 | + |
| 221 | + |
| 222 | +def test_slot_fileTreeCollapsed_removes_path(): |
| 223 | + fExpandedDirs = {'/foo/bar', '/baz/qux'} |
| 224 | + dirModel = _FakeDirModel({'i': '/foo/bar'}) |
| 225 | + |
| 226 | + def slot_fileTreeCollapsed(modelIndex): |
| 227 | + path = dirModel.filePath(modelIndex) |
| 228 | + fExpandedDirs.discard(path) |
| 229 | + |
| 230 | + slot_fileTreeCollapsed('i') |
| 231 | + assert '/foo/bar' not in fExpandedDirs |
| 232 | + assert '/baz/qux' in fExpandedDirs |
| 233 | + |
| 234 | + |
| 235 | +def test_slot_diskCollapseAll_clears_expanded(): |
| 236 | + fExpandedDirs = {'/foo', '/bar'} |
| 237 | + treeView = _FakeTreeView() |
| 238 | + |
| 239 | + def slot_diskCollapseAll(): |
| 240 | + treeView.collapseAll() |
| 241 | + fExpandedDirs.clear() |
| 242 | + |
| 243 | + slot_diskCollapseAll() |
| 244 | + assert treeView.collapsed_all |
| 245 | + assert len(fExpandedDirs) == 0 |
| 246 | + |
| 247 | + |
| 248 | +def test_restoreExpandedDirs_expands_valid_paths(qapp): |
| 249 | + """_restoreExpandedDirs expands each valid index.""" |
| 250 | + from PyQt6.QtGui import QFileSystemModel |
| 251 | + |
| 252 | + treeView = _FakeTreeView() |
| 253 | + model = QFileSystemModel() |
| 254 | + model.setRootPath('/') |
| 255 | + home = os.path.expanduser('~') |
| 256 | + idx = model.index(home) |
| 257 | + |
| 258 | + expanded = [] |
| 259 | + |
| 260 | + def restore(paths): |
| 261 | + for path in paths: |
| 262 | + i = model.index(path) |
| 263 | + if i.isValid(): |
| 264 | + treeView.expand(i) |
| 265 | + expanded.append(path) |
| 266 | + |
| 267 | + restore([home, '/nonexistent_path_xyz_abc']) |
| 268 | + assert home in expanded |
| 269 | + assert '/nonexistent_path_xyz_abc' not in expanded |
0 commit comments