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.")