#!/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.")