Skip to content

Commit 881cd4e

Browse files
Alessandro/tests/UI (#199)
* Add pytest-qt dependency and enhance test coverage for UI components * Add unit tests for various UI dialogs including actuator, AI model, FTP cameras, Telegram, and USB cameras * Add installation step for QT dependencies in CI workflow * Fix CI workflow to install missing QT dependencies and run tests with xvfb * Remove USB cameras dialog tests * Apply suggestion from @alessandropalla * Fix formatting in test.yaml by removing trailing whitespace in dependency installation step * Update requirements.txt to reflect changes in dependencies * Update requirements.txt to include new dependencies --------- Co-authored-by: Stefano Dell'Osa <stefano.dellosa@gmail.com>
1 parent a379704 commit 881cd4e

11 files changed

+565
-3
lines changed

.github/workflows/test.yaml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ jobs:
2525
source .venv/bin/activate
2626
pip install --upgrade pip
2727
pip install -r requirements.txt
28+
- name: Install QT dependencies
29+
run: |
30+
sudo apt-get update
31+
sudo apt-get install -y '^libxcb.*-dev' libx11-xcb-dev libglu1-mesa-dev libxrender-dev libxi-dev libxkbcommon-dev libxkbcommon-x11-dev libegl1 libxcb-cursor0 xvfb
2832
- name: Login to Hugging Face
2933
run: |
3034
source .venv/bin/activate
@@ -33,9 +37,9 @@ jobs:
3337
- name: Run tests
3438
run: |
3539
source .venv/bin/activate
36-
python -m pytest test -sv --cov=. --cov-report=html
40+
xvfb-run python -m pytest test -sv --cov=. --cov-report=html
3741
# To enforce minimum coverage, uncomment the following line:
38-
# python -m pytest test -sv --cov=. --cov-report=html --cov-fail-under=80
42+
# xvfb-run python -m pytest test -sv --cov=. --cov-report=html --cov-fail-under=80
3943
- name: Upload coverage report
4044
uses: actions/upload-artifact@v4
4145
with:

requirements.txt

36 Bytes
Binary file not shown.

setup.cfg

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
[flake8]
2-
max-line-length = 100
2+
max-line-length = 100
3+
4+
[coverage:run]
5+
omit = test/*

test/ui/conftest.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Ensure we can import from wadas
2+
import pathlib
3+
import sys
4+
from unittest.mock import MagicMock
5+
6+
import pytest
7+
8+
sys.path.append(str(pathlib.Path(__file__).parents[2]))
9+
10+
from wadas.domain.notifier import Notifier # # noqa: E402
11+
12+
13+
@pytest.fixture
14+
def mock_notifier_state(monkeypatch):
15+
"""Mock the global Notifier.notifiers dictionary."""
16+
# Save original state
17+
original_notifiers = Notifier.notifiers.copy()
18+
19+
# Reset for test
20+
Notifier.notifiers = {
21+
Notifier.NotifierTypes.EMAIL.value: None,
22+
Notifier.NotifierTypes.TELEGRAM.value: None,
23+
Notifier.NotifierTypes.WHATSAPP.value: None,
24+
}
25+
26+
yield
27+
28+
# Restore original state
29+
Notifier.notifiers = original_notifiers
30+
31+
32+
@pytest.fixture
33+
def mock_keyring(monkeypatch):
34+
"""Mock keyring to avoid system access."""
35+
mock = MagicMock()
36+
monkeypatch.setattr("wadas.ui.configure_email_dialog.keyring", mock)
37+
return mock
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from unittest.mock import patch
2+
3+
import pytest
4+
from PySide6.QtWidgets import QComboBox, QDialogButtonBox, QLineEdit
5+
6+
from wadas.domain.actuator import Actuator
7+
from wadas.domain.fastapi_actuator_server import FastAPIActuatorServer
8+
from wadas.ui.configure_actuators_dialog import DialogConfigureActuators
9+
10+
11+
@pytest.fixture
12+
def mock_actuator_state(monkeypatch):
13+
"""Mock Actuator and Server state."""
14+
# Save original state
15+
original_actuators = Actuator.actuators.copy()
16+
original_server = FastAPIActuatorServer.actuator_server
17+
18+
# Reset
19+
Actuator.actuators = []
20+
FastAPIActuatorServer.actuator_server = None
21+
22+
yield
23+
24+
# Restore
25+
Actuator.actuators = original_actuators
26+
FastAPIActuatorServer.actuator_server = original_server
27+
28+
29+
def test_actuators_dialog_initial_state(qtbot, mock_actuator_state, mock_notifier_state):
30+
"""Test initial state of actuators dialog."""
31+
dialog = DialogConfigureActuators()
32+
qtbot.addWidget(dialog)
33+
34+
# Check default values
35+
assert dialog.ui.lineEdit_server_ip.text() == "0.0.0.0"
36+
assert dialog.ui.lineEdit_server_port.text() == "8443"
37+
38+
# Check buttons
39+
assert dialog.ui.pushButton_stop_server.isEnabled() is False
40+
assert dialog.ui.buttonBox.button(QDialogButtonBox.Ok).isEnabled() is False
41+
42+
43+
def test_actuators_dialog_add_actuator(qtbot, mock_actuator_state, mock_notifier_state):
44+
"""Test adding an actuator row."""
45+
dialog = DialogConfigureActuators()
46+
qtbot.addWidget(dialog)
47+
48+
# Initially one row is added by __init__
49+
# grid = dialog.findChild(QGridLayout, "gridLayout_actuators")
50+
# We can't easily count rows in QGridLayout, but we can check if widgets exist
51+
# Row 0 should exist
52+
assert dialog.findChild(QComboBox, "comboBox_actuator_type_0") is not None
53+
54+
# Add another actuator
55+
dialog.ui.pushButton_add_actuator.click()
56+
57+
# Row 1 should exist
58+
assert dialog.findChild(QComboBox, "comboBox_actuator_type_1") is not None
59+
60+
61+
def test_actuators_dialog_validation(qtbot, mock_actuator_state, mock_notifier_state):
62+
"""Test validation logic enables OK button."""
63+
dialog = DialogConfigureActuators()
64+
qtbot.addWidget(dialog)
65+
66+
# Fill valid server info
67+
dialog.ui.lineEdit_server_ip.setText("127.0.0.1")
68+
dialog.ui.lineEdit_server_port.setText("8080")
69+
dialog.ui.lineEdit_actuator_timeout.setText("10")
70+
71+
# Fill actuator info (row 0)
72+
# We need to find the widgets dynamically created
73+
# Based on code: lineEdit_actuator_id_{row}
74+
id_edit = dialog.findChild(QLineEdit, "lineEdit_actuator_id_0")
75+
76+
# Ensure widgets are visible and active before interaction
77+
dialog.show()
78+
# qtbot.waitForWindowShown(dialog) # Deprecated
79+
80+
# Mock file existence for SSL key/cert
81+
with patch("os.path.isfile", return_value=True):
82+
# Set valid file paths (mocked)
83+
dialog.ui.label_key_file.setText("key.pem")
84+
dialog.ui.label_cert_file.setText("cert.pem")
85+
86+
# Set ID
87+
id_edit.setText("Actuator1")
88+
89+
# Trigger validation
90+
dialog.validate()
91+
92+
# Check if OK button is enabled
93+
assert dialog.ui.buttonBox.button(QDialogButtonBox.Ok).isEnabled() is True
94+
95+
# Check if OK button is enabled
96+
# Note: validation might require more fields or specific conditions
97+
# Let's check the validate method in code if possible, or just assert
98+
assert dialog.ui.buttonBox.button(QDialogButtonBox.Ok).isEnabled() is True
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from unittest.mock import MagicMock
2+
3+
import pytest
4+
from PySide6.QtWidgets import QDialogButtonBox
5+
6+
from wadas.domain.ai_model import AiModel
7+
from wadas.ui.configure_ai_model_dialog import ConfigureAiModel
8+
9+
10+
@pytest.fixture
11+
def mock_ai_model_state(monkeypatch):
12+
"""Mock AiModel state."""
13+
# Mock class attributes
14+
monkeypatch.setattr(AiModel, "classification_threshold", 0.5)
15+
monkeypatch.setattr(AiModel, "detection_threshold", 0.5)
16+
monkeypatch.setattr(AiModel, "video_fps", 1)
17+
monkeypatch.setattr(AiModel, "tunnel_mode_detection_threshold", 0.5)
18+
monkeypatch.setattr(AiModel, "language", "en")
19+
monkeypatch.setattr(AiModel, "detection_device", "auto")
20+
monkeypatch.setattr(AiModel, "classification_device", "auto")
21+
monkeypatch.setattr(AiModel, "classification_model_version", "DFv1.2")
22+
23+
# Mock txt_animalclasses
24+
mock_classes = {"DFv1.2": ["en", "it"]}
25+
monkeypatch.setattr("wadas.ui.configure_ai_model_dialog.txt_animalclasses", mock_classes)
26+
27+
# Mock openvino
28+
mock_ov = MagicMock()
29+
mock_ov.Core.return_value.get_available_devices.return_value = ["CPU", "GPU"]
30+
monkeypatch.setattr("wadas.ui.configure_ai_model_dialog.ov", mock_ov)
31+
32+
33+
def test_ai_model_dialog_initial_state(qtbot, mock_ai_model_state, mock_notifier_state):
34+
"""Test initial state of AI model dialog."""
35+
dialog = ConfigureAiModel()
36+
qtbot.addWidget(dialog)
37+
38+
# Check default values
39+
assert dialog.ui.lineEdit_classificationThreshold.text() == "0.5"
40+
assert dialog.ui.lineEdit_detectionThreshold.text() == "0.5"
41+
assert dialog.ui.lineEdit_video_fps.text() == "1"
42+
43+
# Check dropdowns
44+
assert dialog.ui.comboBox_class_lang.currentText() == "en"
45+
assert dialog.ui.comboBox_detection_dev.currentText() == "auto"
46+
47+
# OK button should be enabled if data is valid (default data is valid)
48+
assert dialog.ui.buttonBox.button(QDialogButtonBox.Ok).isEnabled() is True
49+
50+
51+
def test_ai_model_dialog_validation(qtbot, mock_ai_model_state, mock_notifier_state):
52+
"""Test validation logic."""
53+
dialog = ConfigureAiModel()
54+
qtbot.addWidget(dialog)
55+
56+
# Invalid threshold
57+
dialog.ui.lineEdit_classificationThreshold.setText("1.5")
58+
assert dialog.ui.buttonBox.button(QDialogButtonBox.Ok).isEnabled() is False
59+
60+
# Valid threshold
61+
dialog.ui.lineEdit_classificationThreshold.setText("0.8")
62+
assert dialog.ui.buttonBox.button(QDialogButtonBox.Ok).isEnabled() is True
63+
64+
# Invalid FPS
65+
dialog.ui.lineEdit_video_fps.setText("-1")
66+
assert dialog.ui.buttonBox.button(QDialogButtonBox.Ok).isEnabled() is False
67+
68+
69+
def test_ai_model_dialog_save(qtbot, mock_ai_model_state, mock_notifier_state):
70+
"""Test saving configuration."""
71+
dialog = ConfigureAiModel()
72+
qtbot.addWidget(dialog)
73+
74+
# Change values
75+
dialog.ui.lineEdit_classificationThreshold.setText("0.7")
76+
dialog.ui.comboBox_class_lang.setCurrentText("it")
77+
78+
# Accept
79+
dialog.accept_and_close()
80+
81+
# Verify AiModel updated
82+
assert AiModel.classification_threshold == 0.7
83+
assert AiModel.language == "it"
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from PySide6.QtWidgets import QDialogButtonBox
2+
3+
from wadas.domain.email_notifier import EmailNotifier
4+
from wadas.domain.notifier import Notifier
5+
from wadas.ui.configure_email_dialog import DialogInsertEmail
6+
7+
8+
def test_email_dialog_initial_state(qtbot, mock_notifier_state, mock_keyring):
9+
"""Test that the dialog initializes correctly with empty state."""
10+
dialog = DialogInsertEmail()
11+
qtbot.addWidget(dialog)
12+
13+
# Check initial state
14+
assert dialog.ui.buttonBox.button(QDialogButtonBox.Ok).isEnabled() is False
15+
assert dialog.ui.pushButton_testEmail.isEnabled() is False
16+
assert dialog.ui.checkBox_email_en.isChecked() is True
17+
18+
19+
def test_email_dialog_validation(qtbot, mock_notifier_state, mock_keyring):
20+
"""Test that the OK button is enabled only when all fields are valid."""
21+
dialog = DialogInsertEmail()
22+
qtbot.addWidget(dialog)
23+
24+
# Simulate typing valid data
25+
qtbot.keyClicks(dialog.ui.lineEdit_senderEmail, "sender@example.com")
26+
qtbot.keyClicks(dialog.ui.lineEdit_smtpServer, "smtp.example.com")
27+
qtbot.keyClicks(dialog.ui.lineEdit_port, "587")
28+
qtbot.keyClicks(dialog.ui.lineEdit_password, "password123")
29+
30+
# Recipient email is a TextEdit, keyClicks might be slow or tricky,
31+
# use setText for simplicity or keyClicks if needed
32+
# dialog.ui.textEdit_recipient_email.setText("recipient@example.com")
33+
# But to trigger textChanged, we might need to simulate input or call
34+
# the slot manually if setText doesn't trigger it.
35+
# PySide6 setText usually triggers signals? No, usually not for QLineEdit/QTextEdit.
36+
# Let's use keyClicks for a short string.
37+
qtbot.keyClicks(dialog.ui.textEdit_recipient_email, "recipient@example.com")
38+
39+
# Check if OK button is enabled
40+
# Note: The validation logic in the dialog seems to rely on individual validators setting flags
41+
# and then calling check_enable_ok_button (implied).
42+
# Let's verify if the button is enabled.
43+
assert dialog.ui.buttonBox.button(QDialogButtonBox.Ok).isEnabled() is True
44+
45+
46+
def test_email_dialog_save(qtbot, mock_notifier_state, mock_keyring):
47+
"""Test that accepting the dialog saves the configuration."""
48+
dialog = DialogInsertEmail()
49+
qtbot.addWidget(dialog)
50+
51+
# Fill data
52+
dialog.ui.lineEdit_senderEmail.setText("sender@example.com")
53+
dialog.ui.lineEdit_smtpServer.setText("smtp.example.com")
54+
dialog.ui.lineEdit_port.setText("587")
55+
dialog.ui.lineEdit_password.setText("password123")
56+
dialog.ui.textEdit_recipient_email.setText("recipient@example.com")
57+
58+
# Manually trigger validation because setText doesn't always
59+
# trigger textChanged signals in tests or we can just call the
60+
# validation methods directly if we want to be sure,
61+
# but better to simulate user interaction if possible.
62+
# However, for saving, we just need the data to be there when accept_and_close is called.
63+
# The accept_and_close method reads from the UI elements.
64+
65+
# We need to make sure the internal flags are set if accept_and_close checks them?
66+
# Looking at the code: accept_and_close reads text() from widgets.
67+
# It creates EmailNotifier.
68+
69+
# Trigger accept
70+
dialog.accept_and_close()
71+
72+
# Check if Notifier was updated
73+
email_notifier = Notifier.notifiers[Notifier.NotifierTypes.EMAIL.value]
74+
assert isinstance(email_notifier, EmailNotifier)
75+
assert email_notifier.sender_email == "sender@example.com"
76+
assert email_notifier.smtp_hostname == "smtp.example.com"
77+
assert email_notifier.smtp_port == "587"
78+
assert "recipient@example.com" in email_notifier.recipients_email

0 commit comments

Comments
 (0)