Skip to content

Commit f5ba05b

Browse files
authored
Merge pull request #1 from OOCAZ/dev/add-linux
added testing support for mac and linux
2 parents 82ec311 + af56583 commit f5ba05b

File tree

3 files changed

+199
-59
lines changed

3 files changed

+199
-59
lines changed

.github/workflows/tests.yml

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,41 @@ on:
1111
workflow_dispatch:
1212

1313
jobs:
14-
test:
15-
runs-on: ${{ matrix.os }}
14+
test-windows:
15+
runs-on: windows-latest
1616
strategy:
17-
fail-fast: false
17+
fail-fast: true
18+
matrix:
19+
python-version: ["3.9", "3.10", "3.11", "3.12"]
20+
21+
steps:
22+
- name: Checkout code
23+
uses: actions/checkout@v4
24+
25+
- name: Set up Python ${{ matrix.python-version }}
26+
uses: actions/setup-python@v5
27+
with:
28+
python-version: ${{ matrix.python-version }}
29+
30+
- name: Install dependencies
31+
run: |
32+
python -m pip install --upgrade pip
33+
pip install pytest pytest-cov
34+
35+
- name: Install project dependencies
36+
run: |
37+
pip install pyautogui ttkbootstrap darkdetect
38+
39+
- name: Run tests with pytest
40+
run: |
41+
pytest tests/ -v --cov=src --cov-report=xml --cov-report=term
42+
43+
test-ubuntu:
44+
runs-on: ubuntu-latest
45+
needs: test-windows
46+
strategy:
47+
fail-fast: true
1848
matrix:
19-
os: [windows-latest]
2049
python-version: ["3.9", "3.10", "3.11", "3.12"]
2150

2251
steps:
@@ -29,7 +58,6 @@ jobs:
2958
python-version: ${{ matrix.python-version }}
3059

3160
- name: Install system dependencies (Linux)
32-
if: runner.os == 'Linux'
3361
run: |
3462
sudo apt-get update
3563
sudo apt-get install -y python3-tk python3-dev xvfb
@@ -51,10 +79,40 @@ jobs:
5179
pytest tests/ -v --cov=src --cov-report=xml --cov-report=term
5280
5381
- name: Upload coverage to Codecov
54-
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11'
82+
if: matrix.python-version == '3.11'
5583
uses: codecov/codecov-action@v4
5684
with:
5785
file: ./coverage.xml
5886
flags: unittests
5987
name: codecov-umbrella
6088
fail_ci_if_error: false
89+
90+
test-macos:
91+
runs-on: macos-latest
92+
needs: test-ubuntu
93+
strategy:
94+
fail-fast: true
95+
matrix:
96+
python-version: ["3.9", "3.10", "3.11", "3.12"]
97+
98+
steps:
99+
- name: Checkout code
100+
uses: actions/checkout@v4
101+
102+
- name: Set up Python ${{ matrix.python-version }}
103+
uses: actions/setup-python@v5
104+
with:
105+
python-version: ${{ matrix.python-version }}
106+
107+
- name: Install dependencies
108+
run: |
109+
python -m pip install --upgrade pip
110+
pip install pytest pytest-cov
111+
112+
- name: Install project dependencies
113+
run: |
114+
pip install pyautogui ttkbootstrap darkdetect
115+
116+
- name: Run tests with pytest
117+
run: |
118+
pytest tests/ -v --cov=src --cov-report=xml --cov-report=term

src/loctight.py

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
# Written by OOCAZ (Zac Poorman)
33
# LOCTight - A simple timer to keep your computer active and open.
44

5-
import ctypes
65
import os
76
import subprocess
87
import sys
@@ -11,6 +10,10 @@
1110
from sys import platform
1211
from tkinter import messagebox
1312

13+
# Windows-specific import
14+
if sys.platform == "win32":
15+
import ctypes
16+
1417
import darkdetect
1518
import pyautogui
1619
import ttkbootstrap as tb
@@ -22,6 +25,40 @@
2225
sys.exit(1)
2326

2427

28+
def lock_workstation():
29+
"""Lock the workstation based on the current platform."""
30+
if platform == "linux" or platform == "linux2":
31+
# Try multiple Linux screen lockers in order of preference
32+
lockers = [
33+
["loginctl", "lock-session"],
34+
["xdg-screensaver", "lock"],
35+
["gnome-screensaver-command", "--lock"],
36+
["dm-tool", "lock"],
37+
["xscreensaver-command", "-lock"],
38+
]
39+
for locker in lockers:
40+
try:
41+
subprocess.run(locker, check=True, capture_output=True)
42+
return # Success, exit function
43+
except (subprocess.CalledProcessError, FileNotFoundError):
44+
continue # Try next locker
45+
# If all fail, show warning but don't crash
46+
messagebox.showwarning(
47+
"Screen Lock Failed",
48+
"Could not lock screen. No supported screen locker found.",
49+
)
50+
elif platform == "darwin":
51+
subprocess.run(
52+
[
53+
"/System/Library/CoreServices/Menu Extras/User.menu/Contents/Resources/CGSession",
54+
"-suspend",
55+
],
56+
check=True,
57+
)
58+
else: # Windows
59+
ctypes.windll.user32.LockWorkStation()
60+
61+
2562
def jiggle(x, checks):
2663
a = 0
2764
while a < x and timer_running[0]:
@@ -32,18 +69,7 @@ def jiggle(x, checks):
3269
b += 1
3370
a += 1
3471
if checks == 0 and timer_running[0]:
35-
if platform == "linux" or platform == "linux2":
36-
subprocess.call(
37-
r"/System/Library/CoreServices/Menu\ Extras/User.menu/Contents/Resources/CGSession -suspend",
38-
shell=True,
39-
)
40-
elif platform == "darwin":
41-
subprocess.call(
42-
r"/System/Library/CoreServices/Menu\ Extras/User.menu/Contents/Resources/CGSession -suspend",
43-
shell=True,
44-
)
45-
else:
46-
ctypes.windll.user32.LockWorkStation()
72+
lock_workstation()
4773

4874

4975
paused = [False]
@@ -76,18 +102,7 @@ def countdown(variable, checks):
76102
update_time_label(0, 0)
77103
# Check the IntVar at the end, not at the start! That way user can change mind
78104
if checks.get() == 0:
79-
if platform == "linux" or platform == "linux2":
80-
subprocess.call(
81-
r"/System/Library/CoreServices/Menu\ Extras/User.menu/Contents/Resources/CGSession -suspend",
82-
shell=True,
83-
)
84-
elif platform == "darwin":
85-
subprocess.call(
86-
r"/System/Library/CoreServices/Menu\ Extras/User.menu/Contents/Resources/CGSession -suspend",
87-
shell=True,
88-
)
89-
else:
90-
ctypes.windll.user32.LockWorkStation()
105+
lock_workstation()
91106
timer_running[0] = False
92107
paused[0] = False
93108
pause_button.config(text="Pause Timer", state=tb.DISABLED)

tests/test_core_logic.py

Lines changed: 95 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,18 @@
33
Tests the timer and mouse jiggle functions in isolation
44
"""
55

6+
import subprocess
67
import sys
78
import time
89
import unittest
910
from unittest.mock import MagicMock, Mock, patch
1011

12+
# Mock pyautogui to avoid X11/display requirements in CI
13+
# Create a proper mock module with the attributes we need
14+
mock_pyautogui = MagicMock()
15+
mock_pyautogui.moveRel = MagicMock()
16+
sys.modules["pyautogui"] = mock_pyautogui
17+
1118

1219
class TestTimerFunctions(unittest.TestCase):
1320
"""Test the core timer logic"""
@@ -182,41 +189,101 @@ def test_preset_timers(self):
182189
class TestPlatformLocking(unittest.TestCase):
183190
"""Test platform-specific lock commands"""
184191

185-
@patch("ctypes.windll.user32.LockWorkStation")
186192
@patch("sys.platform", "win32")
187-
def test_windows_lock(self, mock_lock):
193+
@patch("src.loctight.platform", "win32")
194+
def test_windows_lock(self):
188195
"""Test Windows lock workstation call"""
189-
import ctypes
196+
# Mock ctypes module
197+
mock_ctypes = MagicMock()
198+
with patch.dict("sys.modules", {"ctypes": mock_ctypes}):
199+
# Import after mocking to ensure ctypes is available
200+
import importlib
201+
202+
import src.loctight
203+
204+
# Inject the mock into the module
205+
src.loctight.ctypes = mock_ctypes
190206

191-
# Simulate Windows lock
192-
if sys.platform == "win32":
193-
mock_lock()
207+
from src.loctight import lock_workstation
194208

195-
mock_lock.assert_called_once()
209+
lock_workstation()
210+
mock_ctypes.windll.user32.LockWorkStation.assert_called_once()
196211

197-
@patch("subprocess.call")
198-
@patch("sys.platform", "darwin")
212+
@patch("src.loctight.subprocess.run")
213+
@patch("src.loctight.platform", "darwin")
199214
def test_macos_lock(self, mock_subprocess):
200215
"""Test macOS lock command"""
201-
if sys.platform == "darwin":
202-
mock_subprocess(
203-
r"/System/Library/CoreServices/Menu\ Extras/User.menu/Contents/Resources/CGSession -suspend",
204-
shell=True,
205-
)
206-
207-
self.assertTrue(mock_subprocess.called)
208-
209-
@patch("subprocess.call")
210-
@patch("sys.platform", "linux")
211-
def test_linux_lock(self, mock_subprocess):
212-
"""Test Linux lock command"""
213-
if sys.platform.startswith("linux"):
214-
mock_subprocess(
215-
r"/System/Library/CoreServices/Menu\ Extras/User.menu/Contents/Resources/CGSession -suspend",
216-
shell=True,
217-
)
218-
219-
self.assertTrue(mock_subprocess.called)
216+
from src.loctight import lock_workstation
217+
218+
lock_workstation()
219+
220+
mock_subprocess.assert_called_once_with(
221+
[
222+
"/System/Library/CoreServices/Menu Extras/User.menu/Contents/Resources/CGSession",
223+
"-suspend",
224+
],
225+
check=True,
226+
)
227+
228+
@patch("src.loctight.subprocess.run")
229+
@patch("src.loctight.platform", "linux")
230+
def test_linux_lock_first_locker_succeeds(self, mock_subprocess):
231+
"""Test Linux lock succeeds on first locker"""
232+
from src.loctight import lock_workstation
233+
234+
# Mock successful lock on first try
235+
mock_subprocess.return_value = MagicMock()
236+
237+
lock_workstation()
238+
239+
# Should only call the first locker
240+
mock_subprocess.assert_called_once_with(
241+
["loginctl", "lock-session"], check=True, capture_output=True
242+
)
243+
244+
@patch("src.loctight.subprocess.run")
245+
@patch("src.loctight.platform", "linux")
246+
def test_linux_lock_fallback_mechanism(self, mock_subprocess):
247+
"""Test Linux lock tries multiple lockers on failure"""
248+
from src.loctight import lock_workstation
249+
250+
# First two lockers fail, third succeeds
251+
mock_subprocess.side_effect = [
252+
FileNotFoundError(), # loginctl not found
253+
subprocess.CalledProcessError(1, "xdg-screensaver"), # xdg fails
254+
MagicMock(), # gnome-screensaver succeeds
255+
]
256+
257+
lock_workstation()
258+
259+
# Should have tried three lockers
260+
self.assertEqual(mock_subprocess.call_count, 3)
261+
calls = mock_subprocess.call_args_list
262+
self.assertEqual(calls[0][0][0], ["loginctl", "lock-session"]) # First attempt
263+
self.assertEqual(calls[1][0][0], ["xdg-screensaver", "lock"]) # Second attempt
264+
self.assertEqual(
265+
calls[2][0][0], ["gnome-screensaver-command", "--lock"]
266+
) # Third attempt
267+
268+
@patch("src.loctight.subprocess.run")
269+
@patch("src.loctight.platform", "linux")
270+
@patch("src.loctight.messagebox.showwarning")
271+
def test_linux_lock_all_fail(self, mock_messagebox, mock_subprocess):
272+
"""Test Linux lock handles all lockers failing gracefully"""
273+
from src.loctight import lock_workstation
274+
275+
# All lockers fail
276+
mock_subprocess.side_effect = FileNotFoundError()
277+
278+
lock_workstation()
279+
280+
# Should have tried all 5 lockers
281+
self.assertEqual(mock_subprocess.call_count, 5)
282+
# Should show warning messagebox
283+
mock_messagebox.assert_called_once_with(
284+
"Screen Lock Failed",
285+
"Could not lock screen. No supported screen locker found.",
286+
)
220287

221288

222289
class TestButtonStateManagement(unittest.TestCase):

0 commit comments

Comments
 (0)