File size: 11,373 Bytes
bbc65cd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 | #!/usr/bin/env python3
"""Tests for shortcut_counter_tab.py
Run with: python3 -m pytest test_shortcut_counter.py -v
or: python3 test_shortcut_counter.py
"""
import sqlite3
import sys
import tempfile
import tomllib
from pathlib import Path
from unittest.mock import MagicMock, patch
# ---------------------------------------------------------------------------
# Test 1: Import check (no GTK required)
# ---------------------------------------------------------------------------
def test_import():
"""Module must be importable without crashing.
GTK may not be available in CI β mock gi before importing."""
# Mock gi and GTK so the import doesn't need a display
gi_mock = MagicMock()
gtk_mock = MagicMock()
glib_mock = MagicMock()
pango_mock = MagicMock()
sys.modules.setdefault("gi", gi_mock)
sys.modules.setdefault("gi.repository", MagicMock())
sys.modules.setdefault("gi.repository.Gtk", gtk_mock)
sys.modules.setdefault("gi.repository.GLib", glib_mock)
sys.modules.setdefault("gi.repository.Pango", pango_mock)
sys.modules.setdefault("theme", MagicMock(
get_palette=lambda: {
"text": "#cdd6f4", "overlay": "#6c7086", "dim": "#585b70",
"green": "#a6e3a1", "yellow": "#f9e2af",
},
hex_to_pango_rgb=lambda h: (0, 0, 0),
))
import importlib
import shortcut_counter_tab as sct
importlib.reload(sct) # reload with mocks in place
assert hasattr(sct, "build_shortcut_counter_tab"), "build_shortcut_counter_tab missing"
assert hasattr(sct, "refresh_shortcut_counter"), "refresh_shortcut_counter missing"
assert callable(sct.build_shortcut_counter_tab)
assert callable(sct.refresh_shortcut_counter)
print("PASS test_import")
# ---------------------------------------------------------------------------
# Test 2: _load_rows β DB missing β returns []
# ---------------------------------------------------------------------------
def test_load_rows_missing_db():
"""_load_rows must return [] when the DB file does not exist."""
import shortcut_counter_tab as sct
with patch.object(sct, "_DB_PATH", Path("/nonexistent/path/shortcuts.db")):
rows = sct._load_rows()
assert rows == [], f"Expected [], got {rows}"
print("PASS test_load_rows_missing_db")
# ---------------------------------------------------------------------------
# Test 3: _load_rows β real SQLite DB
# ---------------------------------------------------------------------------
def test_load_rows_with_db():
"""_load_rows must return dicts with expected keys from a real SQLite file."""
import shortcut_counter_tab as sct
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = Path(f.name)
try:
conn = sqlite3.connect(str(db_path))
conn.execute(
"CREATE TABLE shortcuts "
"(combo TEXT, count INTEGER, last_used TEXT, category TEXT)"
)
conn.execute(
"INSERT INTO shortcuts VALUES (?, ?, ?, ?)",
("Ctrl+C", 42, "2026-03-04T10:00:00", "Editing"),
)
conn.execute(
"INSERT INTO shortcuts VALUES (?, ?, ?, ?)",
("Ctrl+V", 5, None, "Editing"),
)
conn.commit()
conn.close()
with patch.object(sct, "_DB_PATH", db_path):
rows = sct._load_rows()
assert len(rows) == 2, f"Expected 2 rows, got {len(rows)}"
# Should be sorted by count DESC
assert rows[0]["combo"] == "Ctrl+C", f"First row should be Ctrl+C (count 42), got {rows[0]['combo']}"
assert rows[0]["count"] == 42
assert rows[1]["combo"] == "Ctrl+V"
# Keys present
for key in ("combo", "count", "last_used", "category"):
assert key in rows[0], f"Key '{key}' missing from row"
print("PASS test_load_rows_with_db")
finally:
db_path.unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# Test 3b: _load_rows β TIMESTAMP column with ISO "T" values
# ---------------------------------------------------------------------------
def test_load_rows_timestamp_iso_values():
"""_load_rows must tolerate TIMESTAMP columns storing ISO strings."""
import shortcut_counter_tab as sct
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = Path(f.name)
try:
conn = sqlite3.connect(str(db_path))
conn.execute(
"CREATE TABLE shortcuts "
"(combo TEXT, count INTEGER, first_seen TIMESTAMP, last_used TIMESTAMP, category TEXT)"
)
conn.execute(
"INSERT INTO shortcuts (combo, count, first_seen, last_used, category) "
"VALUES (?, ?, ?, ?, ?)",
("Super+L", 10, "2026-04-03T18:00:00", "2026-04-03T18:10:00", "COSMIC"),
)
conn.commit()
conn.close()
with patch.object(sct, "_DB_PATH", db_path):
rows = sct._load_rows()
assert len(rows) == 1, f"Expected 1 row, got {len(rows)}"
assert rows[0]["combo"] == "Super+L"
assert rows[0]["last_used"] == "2026-04-03T18:10:00"
print("PASS test_load_rows_timestamp_iso_values")
finally:
db_path.unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# Test 4: _load_config β config missing β returns {}
# ---------------------------------------------------------------------------
def test_load_config_missing():
"""_load_config must return {} when config.toml does not exist."""
import shortcut_counter_tab as sct
with patch.object(sct, "_CONFIG_PATH", Path("/nonexistent/config.toml")):
config = sct._load_config()
assert config == {}, f"Expected {{}}, got {config}"
print("PASS test_load_config_missing")
# ---------------------------------------------------------------------------
# Test 5: _load_config β real TOML file with [[items]] format
# ---------------------------------------------------------------------------
def test_load_config_with_file():
"""_load_config must parse shortcuts and descriptions from a TOML config."""
import shortcut_counter_tab as sct
toml_content = b"""
[[shortcuts.Editing.items]]
combo = "Ctrl+C"
desc = "Kopieren"
[[shortcuts.Editing.items]]
combo = "Ctrl+V"
desc = "Einfuegen"
[[shortcuts.Editing.items]]
combo = "Ctrl+Z"
desc = "Rueckgaengig"
[[shortcuts.Navigation.items]]
combo = "Ctrl+Tab"
desc = "Tab wechseln"
[[shortcuts.Navigation.items]]
combo = "Alt+F4"
desc = "Fenster schliessen"
"""
with tempfile.NamedTemporaryFile(suffix=".toml", delete=False) as f:
f.write(toml_content)
cfg_path = Path(f.name)
try:
with patch.object(sct, "_CONFIG_PATH", cfg_path):
config = sct._load_config()
assert "Editing" in config, f"Expected 'Editing' category, got {list(config.keys())}"
assert "Navigation" in config, f"Expected 'Navigation' category"
assert len(config["Editing"]) == 3, f"Expected 3 Editing items, got {len(config['Editing'])}"
assert len(config["Navigation"]) == 2, f"Expected 2 Navigation items, got {len(config['Navigation'])}"
# Check structure of items
first = config["Editing"][0]
assert first["combo"] == "Ctrl+C", f"Expected Ctrl+C, got {first['combo']}"
assert first["desc"] == "Kopieren", f"Expected 'Kopieren', got {first['desc']}"
# Check lookup works
all_combos = [it["combo"] for items in config.values() for it in items]
assert "Ctrl+C" in all_combos
assert "Alt+F4" in all_combos
print("PASS test_load_config_with_file")
finally:
cfg_path.unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# Test 6: _status_label thresholds
# ---------------------------------------------------------------------------
def test_status_label():
"""_status_label must return correct labels for count thresholds."""
import shortcut_counter_tab as sct
assert sct._status_label(0) == "Not Used"
assert sct._status_label(1) == "Learning"
assert sct._status_label(49) == "Learning"
assert sct._status_label(50) == "Mastered"
assert sct._status_label(100) == "Mastered"
print("PASS test_status_label")
# ---------------------------------------------------------------------------
# Test 7: _fmt_last_used edge cases
# ---------------------------------------------------------------------------
def test_fmt_last_used():
"""_fmt_last_used must handle None, ISO strings, and datetime objects."""
from datetime import datetime
import shortcut_counter_tab as sct
assert sct._fmt_last_used(None) == "β"
assert sct._fmt_last_used("2026-03-04T10:30:00") == "04.03 10:30"
dt = datetime(2026, 3, 4, 10, 30)
assert sct._fmt_last_used(dt) == "04.03 10:30"
print("PASS test_fmt_last_used")
# ---------------------------------------------------------------------------
# Test 8: Widget creation (GTK required β skipped if no display)
# ---------------------------------------------------------------------------
def test_build_tab_gtk():
"""build_shortcut_counter_tab must return a Gtk.ScrolledWindow.
Skipped if no GTK display is available (e.g. headless CI).
Uses hasattr check to avoid isinstance() issues with mocked types.
"""
# Clean up any mocks from test_import so real GTK is loaded
for key in list(sys.modules.keys()):
if key in ("gi.repository.Gtk", "gi.repository.GLib", "gi.repository.Pango",
"theme", "shortcut_counter_tab"):
sys.modules.pop(key, None)
try:
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk
ok, _ = Gtk.init_check(None)
if not ok:
print("SKIP test_build_tab_gtk (Gtk.init_check failed β no display)")
return
except Exception as e:
print(f"SKIP test_build_tab_gtk (no GTK display: {e})")
return
import importlib
import shortcut_counter_tab
importlib.reload(shortcut_counter_tab)
tab = shortcut_counter_tab.build_shortcut_counter_tab()
# Check widget type by name to avoid isinstance() failures with stale mocks
type_name = type(tab).__name__
assert type_name == "ScrolledWindow", f"Expected ScrolledWindow, got {type_name}"
print("PASS test_build_tab_gtk")
# ---------------------------------------------------------------------------
# Runner
# ---------------------------------------------------------------------------
if __name__ == "__main__":
tests = [
test_import,
test_load_rows_missing_db,
test_load_rows_with_db,
test_load_rows_timestamp_iso_values,
test_load_config_missing,
test_load_config_with_file,
test_status_label,
test_fmt_last_used,
test_build_tab_gtk,
]
failed = []
for t in tests:
try:
t()
except Exception as e:
print(f"FAIL {t.__name__}: {e}")
failed.append(t.__name__)
print()
if failed:
print(f"FAILED: {', '.join(failed)}")
sys.exit(1)
else:
print(f"All {len(tests)} tests passed.")
|