From f0bdc63bfd5d5e8507a9f701b332683a44218aaa Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 23 Sep 2026 22:27:19 +0530 Subject: [PATCH 01/34] fix(ui): resolve delayed attachment warning and text overflow (#351, #355) --- clickable.yaml | 1 + qml/app/GlobalWidgets.qml | 9 +- qml/components/feedback/InfoBar.qml | 14 ++- qml/components/workflow/AttachmentManager.qml | 1 + tests/qml/README.md | 103 ++++++++++++++++++ tests/qml/tst_infobar.qml | 72 ++++++++++++ 6 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 tests/qml/README.md create mode 100644 tests/qml/tst_infobar.qml diff --git a/clickable.yaml b/clickable.yaml index e121c029..281f2557 100644 --- a/clickable.yaml +++ b/clickable.yaml @@ -2,6 +2,7 @@ clickable_minimum_required: 8.0.0 builder: cmake kill: qmlscene skip_review: true +test: qmltestrunner -input tests/qml dependencies_target: - libatomic1 dependencies_host: diff --git a/qml/app/GlobalWidgets.qml b/qml/app/GlobalWidgets.qml index 5b5b5df6..a0afa9fd 100644 --- a/qml/app/GlobalWidgets.qml +++ b/qml/app/GlobalWidgets.qml @@ -92,7 +92,14 @@ Item { id: infobar anchors.bottom: parent.bottom anchors.left: parent.left - width: parent.width + anchors.right: parent.right + anchors.bottomMargin: (Qt.inputMethod.visible ? Qt.inputMethod.keyboardRectangle.height : 0) + + (modelDownloadTimerWidget.visible ? (modelDownloadTimerWidget.height + units.gu(1)) : 0) + + (globalTimerWidget.visible ? (globalTimerWidget.height + units.gu(1)) : 0) height: units.gu(10) + + Behavior on anchors.bottomMargin { + NumberAnimation { duration: 200; easing.type: Easing.OutQuad } + } } } diff --git a/qml/components/feedback/InfoBar.qml b/qml/components/feedback/InfoBar.qml index 5aad9ebd..bac861dc 100644 --- a/qml/components/feedback/InfoBar.qml +++ b/qml/components/feedback/InfoBar.qml @@ -54,10 +54,14 @@ Item { id: container anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom - anchors.bottomMargin: units.gu(10) + anchors.bottomMargin: Qt.inputMethod.visible ? units.gu(1) : units.gu(10) implicitWidth: parent.width implicitHeight: parent.height + Behavior on anchors.bottomMargin { + NumberAnimation { duration: 200; easing.type: Easing.OutQuad } + } + Rectangle { anchors.fill: parent color: "#2d2d2d" @@ -67,7 +71,13 @@ Item { Text { id: message - anchors.centerIn: parent + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: units.gu(2) + anchors.rightMargin: units.gu(2) + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter text: "" wrapMode: Text.WordWrap font.pixelSize: units.gu(2) diff --git a/qml/components/workflow/AttachmentManager.qml b/qml/components/workflow/AttachmentManager.qml index cc6013a7..8e39e203 100644 --- a/qml/components/workflow/AttachmentManager.qml +++ b/qml/components/workflow/AttachmentManager.qml @@ -405,6 +405,7 @@ Item { } function openContentPicker() { + Qt.inputMethod.hide(); if (_busy) { return; } diff --git a/tests/qml/README.md b/tests/qml/README.md new file mode 100644 index 00000000..6b2e7364 --- /dev/null +++ b/tests/qml/README.md @@ -0,0 +1,103 @@ +# QML Unit Testing Guide + +This directory contains automated unit tests for QML components using Qt Quick Test (`qmltestrunner` and `QtTest 1.0`). + +## Running Tests + +To run the full QML test suite inside the Clickable container: + +```bash +clickable test +``` + +To run a specific test file or filter by test name: + +```bash +clickable test -- -functions test_openDisplaysMessageAndEmitsSignal +``` + +## Test Directory Structure + +``` +tests/ +├── qml/ +│ ├── README.md # This guide +│ └── tst_infobar.qml # Example test suite for InfoBar component +``` + +Clickable automatically discovers all files matching `tst_*.qml` under `tests/qml/` as configured in `clickable.yaml`. + +## Anatomy of a QML Test Case + +Every test file follows this standard template: + +```qml +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/feedback" + +Item { + width: units.gu(40) + height: units.gu(70) + + // 1. Declare the component under test + InfoBar { + id: testTarget + } + + // 2. Optional: Declare SignalSpies for signal verification + SignalSpy { + id: openedSpy + target: testTarget + signalName: "opened" + } + + // 3. Define the TestCase suite + TestCase { + name: "InfoBarTests" + when: windowShown + + // Run before each test function + function init() { + testTarget.close(); + openedSpy.clear(); + } + + // Test function: Must start with "test_" + function test_initialProperties() { + compare(testTarget.visible, false); + compare(testTarget.text, ""); + } + + function test_openAction() { + testTarget.open("Hello", 2000); + compare(testTarget.visible, true); + compare(openedSpy.count, 1); + } + + // Asynchronous / timer verification + function test_timerAutoClose() { + testTarget.open("Expiring", 100); + tryCompare(testTarget, "visible", false, 500); + } + } +} +``` + +## Key Assertions and Helpers + +| Function | Usage | Description | +|---|---|---| +| `compare(actual, expected)` | `compare(item.visible, true)` | Strict equality check | +| `verify(condition)` | `verify(item.width > 0)` | Boolean condition check | +| `tryCompare(item, property, value, timeout)` | `tryCompare(item, "visible", false, 1000)` | Polls until property matches or timeout expires | +| `wait(ms)` | `wait(200)` | Pauses test execution for specified duration | +| `mouseClick(item, x, y, button)` | `mouseClick(buttonItem)` | Simulates user click on an Item | +| `keyClick(key)` | `keyClick(Qt.Key_Return)` | Simulates keyboard input | + +## Guidelines for New Tests + +1. **Isolation**: Always use `init()` or `cleanup()` to reset component state and clear `SignalSpy` counts between tests. +2. **Dimension Constraints**: Wrap the component inside an `Item` with defined width and height (e.g., using `units.gu(N)`) so layout calculations function properly. +3. **No Network or DB Calls**: Unit tests should focus on presentation components and pure UI state. For backend and sync logic, use mock data fixtures. diff --git a/tests/qml/tst_infobar.qml b/tests/qml/tst_infobar.qml new file mode 100644 index 00000000..4c371ef0 --- /dev/null +++ b/tests/qml/tst_infobar.qml @@ -0,0 +1,72 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/feedback" + +Item { + width: units.gu(40) + height: units.gu(70) + + InfoBar { + id: testInfoBar + } + + SignalSpy { + id: openedSpy + target: testInfoBar + signalName: "opened" + } + + SignalSpy { + id: closedSpy + target: testInfoBar + signalName: "closed" + } + + TestCase { + name: "InfoBarTests" + when: windowShown + + function init() { + testInfoBar.close(); + testInfoBar.setText(""); + openedSpy.clear(); + closedSpy.clear(); + } + + function test_initialProperties() { + compare(testInfoBar.visible, false); + compare(testInfoBar.text, ""); + compare(testInfoBar.autoCloseEnabled, true); + compare(testInfoBar.autoCloseMs, 10000); + } + + function test_openDisplaysMessageAndEmitsSignal() { + testInfoBar.open("Saved successfully", 3000); + compare(testInfoBar.visible, true); + compare(testInfoBar.text, "Saved successfully"); + compare(openedSpy.count, 1); + } + + function test_closeHidesBannerAndEmitsSignal() { + testInfoBar.open("Temporary alert", 5000); + compare(testInfoBar.visible, true); + + testInfoBar.close(); + compare(testInfoBar.visible, false); + compare(closedSpy.count, 1); + } + + function test_setTextUpdatesMessage() { + testInfoBar.setText("Notice message"); + compare(testInfoBar.text, "Notice message"); + } + + function test_autoCloseTimerFires() { + testInfoBar.open("Quick alert", 100); + compare(testInfoBar.visible, true); + tryCompare(testInfoBar, "visible", false, 500); + compare(closedSpy.count, 1); + } + } +} From dad35ecbd5f71d43e2324628e866ca981cdd73d1 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 23 Sep 2026 22:42:23 +0530 Subject: [PATCH 02/34] Modularize backend into submodules and add storage unit tests - Split monolithic src/backend.py into src/backend/ subpackage (storage, auth, attachments, sync, voice) with __init__.py facade for full backward compatibility with QML PyOtherSide calls. - Decouple Vosk speech recognition into dedicated voice submodule. - Add pytest unit test suite under tests/python/ covering storage and attachment path resolution utilities. - Add pytest conftest and test documentation. --- src/backend.py | 1678 ---------------------------------- src/backend/__init__.py | 134 +++ src/backend/attachments.py | 360 ++++++++ src/backend/auth.py | 124 +++ src/backend/storage.py | 154 ++++ src/backend/sync.py | 143 +++ src/backend/voice.py | 747 +++++++++++++++ tests/python/README.md | 46 + tests/python/conftest.py | 9 + tests/python/test_storage.py | 202 ++++ 10 files changed, 1919 insertions(+), 1678 deletions(-) delete mode 100755 src/backend.py create mode 100644 src/backend/__init__.py create mode 100644 src/backend/attachments.py create mode 100644 src/backend/auth.py create mode 100644 src/backend/storage.py create mode 100644 src/backend/sync.py create mode 100644 src/backend/voice.py create mode 100644 tests/python/README.md create mode 100644 tests/python/conftest.py create mode 100644 tests/python/test_storage.py diff --git a/src/backend.py b/src/backend.py deleted file mode 100755 index 9fda42f9..00000000 --- a/src/backend.py +++ /dev/null @@ -1,1678 +0,0 @@ -# -*- coding: utf-8 -*- -# MIT License -# -# Copyright (c) 2025 CIT-Services -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - - -from config import get_all_accounts, initialize_app_settings_db, update_last_synced_at, get_setting -from odoo_client import OdooClient -from sync_from_odoo import sync_all_from_odoo,sync_ondemand_tables_from_odoo -from sync_to_odoo import sync_all_to_odoo -from logger import setup_logger -from bus import send - -log = setup_logger() -import os -import sys -from pathlib import Path -import ctypes - -# Add root directory and voice_to_text/lib to sys.path -root_dir = Path(__file__).parent.parent.resolve() -if str(root_dir) not in sys.path: - sys.path.insert(0, str(root_dir)) - -voice_lib = root_dir / "voice_to_text" / "lib" -if voice_lib.exists() and str(voice_lib) not in sys.path: - sys.path.insert(0, str(voice_lib)) -import platform -import urllib3 -import json -import xmlrpc.client -from common import check_table_exists, write_sync_report_to_db -import threading -import base64 -import mimetypes - -sync_lock = threading.Lock() -sync_in_progress = False # Global flag - -urllib3.disable_warnings() - -http = urllib3.PoolManager(cert_reqs="CERT_NONE") - -# Voice model download state -download_status = { - "in_progress": False, - "progress": 0, - "message": "", - "error": "" -} - - -from urllib.parse import urlparse, unquote -import socket - -def check_server_reachability(url, timeout=3): - """ - Check if the Odoo server is reachable using a lightweight HTTP request. - This uses the same HTTP pool manager as the rest of the application - to ensure compatibility with Ubuntu Touch AppArmor sandbox rules. - """ - try: - normalized = (url or "").strip().rstrip("/") - if not normalized: - return False - - if "://" not in normalized: - normalized = f"https://{normalized}" - - # Try a HEAD request first (fast and lightweight) - response = http.request('HEAD', normalized, timeout=timeout, redirect=True) - # Any response (even error status codes like 404 or 403) means the server is reachable - return True - except Exception as e: - log.warning(f"[REACHABILITY] HTTP HEAD failed for {url}: {e}") - # Fallback to GET in case HEAD is blocked by the server - try: - response = http.request('GET', normalized, timeout=timeout, redirect=True) - return True - except Exception as e2: - log.warning(f"[REACHABILITY] HTTP GET fallback also failed: {e2}") - return False - - -def is_file_present(file_path): - """ - Check if a file exists at the specified path. - - Args: - file_path (str): Path to the file to check - - Returns: - bool: True if file exists and is a file, False otherwise - - Note: - Logs information about file existence status. - """ - file = Path(file_path) - if file.exists() and file.is_file(): - log.info(f"[INFO] File exists: {file_path}") - return True - else: - log.error(f"[ERROR] File NOT found: {file_path}") - return False - - -def resolve_qml_db_path(app_id="ubtms"): - """ - Resolve and find the QML application database path across different environments. - - Args: - app_id (str): Application identifier, defaults to "ubtms" - - Returns: - str or None: Path to the SQLite database file if found, None otherwise - - Note: - Searches for SQLite files in standard user directories and clickable sandbox. - Validates the database by checking for required tables like 'project_project_app'. - Returns the most recently modified valid database file. - """ - db_paths = [] - current_dir = Path(__file__).parent.resolve() - - # Real user home, e.g., /home/gokul - user_home = Path.home() - db_paths.append(user_home / ".local" / "share" / app_id / "Databases") - - # Clickable sandbox, e.g., /home/gokul/.clickable/home/ - clickable_home = user_home / ".clickable" / "home" - db_paths.append(clickable_home / ".local" / "share" / app_id / "Databases") - - for db_dir in db_paths: - log.debug(f"[DEBUG] Checking DB path: {db_dir}") - if not db_dir.exists(): - continue - - sqlite_files = list(db_dir.glob("*.sqlite")) - if sqlite_files: - latest = max(sqlite_files, key=lambda f: f.stat().st_mtime) - log.debug(f"[INFO] Found QML DB: {latest}") - if is_file_present(latest): - log.debug(f"file is present {latest}") - if check_table_exists(latest, "project_project_app"): - log.debug( - f"project_project_app present,confirms that {latest} app db" - ) - return str(latest) - else: - log.critical(f"SQlite file found , but do not see a table") - - log.debug("[ERROR] No QML DB found.") - return None - - -def fetch_databases(url): - """ - Fetch available databases from an Odoo server and determine UI visibility options. - - Args: - url (str): Odoo server URL - - Returns: - list: List of available database names - - Note: - Also sets visibility dictionary for UI components based on database count: - - text_field: True if no databases found - - single_db: Database name if only one database - - menu_items: List of databases if multiple found - """ - database_list = get_db_list(url) - visibility_dict = { - "menu_items": False, - "text_field": False, - "single_db": False, - } - - if not database_list: - visibility_dict["text_field"] = True - elif len(database_list) == 1: - visibility_dict["single_db"] = database_list[0] - else: - visibility_dict["menu_items"] = database_list - - return database_list - - -def login_odoo(selected_url, username, password, selected_db): - """ - Authenticate user credentials against an Odoo server. - - Args: - selected_url (str): Odoo server URL - username (str): Username for authentication - password (str): Password for authentication - selected_db (str): Database name to authenticate against - - Returns: - dict: Authentication result containing: - - status: "pass" if successful, "fail" if failed - - name_of_user: Full name of authenticated user - - database: Database name used - - uid: User ID from Odoo - - Note: - Uses Odoo's XML-RPC API for authentication and fetches user details on success. - """ - common = xmlrpc.client.ServerProxy("{}/xmlrpc/2/common".format(selected_url)) - generated_uid = common.authenticate(selected_db, username, password, {}) - if generated_uid: - models = xmlrpc.client.ServerProxy( - "{}/xmlrpc/2/object".format(selected_url), - ) - user_name = models.execute_kw( - selected_db, - generated_uid, - password, - "res.users", - "read", - [generated_uid], - {"fields": ["name"]}, - ) - return { - "status": "pass", - "name_of_user": user_name[0]["name"], - "database": selected_db, - "uid": generated_uid, - } - return {"result": "fail"} - - -def get_db_list(url): - """ - Retrieve list of available databases from an Odoo server. - - Args: - url (str): Odoo server URL - - Returns: - list: List of database names available on the server. Returns empty list on error. - - Note: - Makes HTTP POST request to /web/database/list endpoint. - Handles connection errors gracefully. - """ - try: - response = http.request( - "POST", - url + "/web/database/list", - body="{}", - headers={"Content-type": "application/json"}, - ) - if response.status == 200: - data = json.loads(response.data) - return data["result"] - else: - return [] - except Exception as e: - log.error(f"[Critical] No DB found {e}") - return [] - return [] - -import os, base64, mimetypes -from pathlib import Path - -def _app_data_dir(): - # adjust to your app-id if you want a subdir - return Path.home() / ".local/share" - -def _safe_ext_for(mime): - ext = mimetypes.guess_extension(mime or "") - return ext or "" - -# --- ADD BELOW EXISTING HELPERS --- - -def _export_path_for(suggested_name, mime): - """ - Build the canonical output path we use for exported attachments. - Keeps name normalization and mime-based extension logic in one place. - """ - base = _app_data_dir() / "ubtms" / "tmp" - base.mkdir(parents=True, exist_ok=True) - - name = (suggested_name or "attachment").strip().replace("/", "_") - root, ext = os.path.splitext(name) - if not ext: - ext = _safe_ext_for(mime) - return base / f"{root}{ext}" - -def get_existing_attachment_path(suggested_name, mime): - """ - Return absolute file path (str) if an attachment is already present on disk, - else None. - """ - out = _export_path_for(suggested_name, mime) - return str(out) if out.exists() and out.is_file() else None - -def is_already_downloaded(suggested_name, mime): - try: - out = _export_path_for(suggested_name, mime) - return is_file_present(out) - except Exception as e: - log.exception("is_already_downloaded error: %s", e) - return False - -def ensure_export_file_from_base64(suggested_name, b64_data, mime): - try: - out = _export_path_for(suggested_name, mime) - with open(out, "wb") as f: - f.write(base64.b64decode(b64_data)) - return str(out) - except Exception as e: - print("ensure_export_file_from_base64 error:", e) - return None - - -def attachment_ondemand_download(settings_db, account_id, remote_record_id): - accounts = get_all_accounts(settings_db) - selected = None - for acc in accounts: - if acc.get("id") == account_id: - selected = acc - break - - if not selected: - return { - "success": False, - "error": "Account not found", - } - - try: - client = OdooClient( - selected["link"], - selected["database"], - selected["username"], - selected["api_key"], - ) - result = client.ondemanddownload( - remote_record_id, - selected["username"], - selected["api_key"], - False, - ) - if isinstance(result, dict): - result["success"] = True - return result - except Exception as e: - log.exception( - "[ATTACHMENT] Failed to download attachment %s for account %s", - remote_record_id, - account_id, - ) - return { - "success": False, - "error": str(e), - } - -def attachment_upload(settings_db, account_id, filepath, res_type, res_id): - try: - filepath = unquote(filepath) - send("ondemand_upload_message", "Initiating upload...") - log.debug(f"[SYNC] Starting attachment_upload to {account_id} : {filepath} , {res_type} ,{res_id}") - accounts = get_all_accounts(settings_db) - selected = None - for acc in accounts: - if acc.get("id") == account_id: - selected = acc - break - - is_local_account = (account_id == 0) or (selected and selected.get("id") == 0) or (selected and not selected.get("link")) - - if not selected and not is_local_account: - send("ondemand_upload_message", "Error: Account not found") - send("ondemand_upload_completed", False) - return None - - send("ondemand_upload_message", "Checking file...") - if not os.path.exists(filepath): - send("ondemand_upload_message", "Error: File does not exist") - send("ondemand_upload_completed", False) - return None - - # 25 MB limit to prevent memory exhaustion and large payload failures - file_size = os.path.getsize(filepath) - if file_size > 25 * 1024 * 1024: - send("ondemand_upload_message", "Error: File exceeds 25MB limit") - send("ondemand_upload_completed", False) - return None - - filename = os.path.basename(filepath) - EXT_TO_MIME = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.gif': 'image/gif', - '.pdf': 'application/pdf', - '.txt': 'text/plain', - '.csv': 'text/csv', - '.mp3': 'audio/mpeg', - '.mp4': 'video/mp4', - '.zip': 'application/zip' - } - send("ondemand_upload_message", "Reading file...") - ext = os.path.splitext(filename)[1].lower() - mimetype = EXT_TO_MIME.get(ext, 'application/octet-stream') - - # Handle Local Account attachment save - if is_local_account: - send("ondemand_upload_message", "Saving attachment locally...") - dest_path = _export_path_for(filename, mimetype) - import shutil - if Path(filepath).resolve() != Path(dest_path).resolve(): - shutil.copy2(filepath, dest_path) - - import sqlite3 - db_path = settings_db or resolve_qml_db_path() - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - cursor.execute("SELECT COALESCE(MAX(odoo_record_id), 0) + 1 FROM ir_attachment_app WHERE account_id = 0") - row = cursor.fetchone() - next_local_record_id = row[0] if (row and row[0] is not None and row[0] > 0) else 1 - - cursor.execute(""" - INSERT INTO ir_attachment_app ( - account_id, name, res_model, res_id, file_path, local_url, url, - file_size, mimetype, odoo_record_id, last_modified, status - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), 'local') - """, ( - 0, - filename, - res_type, - res_id, - str(dest_path), - f"file://{dest_path}", - f"file://{dest_path}", - file_size, - mimetype, - next_local_record_id - )) - - cursor.execute(""" - INSERT OR REPLACE INTO attachment_download_app (account_id, record_id, file_name, downloaded) - VALUES (0, ?, ?, 1) - """, (next_local_record_id, filename)) - - conn.commit() - conn.close() - - send("ondemand_upload_message", "Attachment saved successfully") - send("ondemand_upload_completed", True) - return next_local_record_id - - # Remote Odoo Account upload - send("ondemand_upload_message", "Checking server connection...") - if not check_server_reachability(selected["link"]): - send("ondemand_upload_message", "Error: No internet connection or server unreachable") - send("ondemand_upload_completed", False) - return None - - file_bytes = None - with open(filepath, 'rb') as f: - file_bytes = f.read() - - client = OdooClient( - selected["link"], - selected["database"], - selected["username"], - selected["api_key"], - ) - - vals = { - 'name': filename, - 'type': 'binary', - 'res_model': res_type, - 'res_id': res_id, - 'datas': base64.b64encode(file_bytes).decode('utf-8'), - 'mimetype': mimetype - } - send("ondemand_upload_message", "Uploading file to server...") - attachment_id = client.call('ir.attachment', 'create', [vals]) - if not attachment_id or attachment_id <= 0: - send("ondemand_upload_message", "Error: Server failed to save attachment") - send("ondemand_upload_completed", False) - return None - - send("ondemand_upload_message", "Syncing to local device...") - sync_ondemand_tables_from_odoo(client, selected["id"], settings_db, account_name=selected.get("name", "")) - send("ondemand_upload_completed", True) - return attachment_id - - except Exception as e: - error_msg = str(e) - log.exception(f"[ATTACHMENT] Failed to upload attachment: {error_msg}") - - # User-friendly error message - friendly_error = "Upload failed" - if "connection" in error_msg.lower() or "refused" in error_msg.lower(): - friendly_error = "Connection failed: Check network/server" - elif "timeout" in error_msg.lower(): - friendly_error = "Request timed out" - elif "authentication" in error_msg.lower() or "access denied" in error_msg.lower() or "uid" in error_msg.lower(): - friendly_error = "Authentication failed: Check credentials" - elif "xmlrpc" in error_msg.lower() or "fault" in error_msg.lower(): - friendly_error = f"Server error: {error_msg[:60]}" - else: - friendly_error = f"Error: {error_msg[:60]}" - - send("ondemand_upload_message", friendly_error) - send("ondemand_upload_completed", False) - return None - -def attachment_delete(settings_db, account_id, remote_record_id): - log.debug(f"[SYNC] Starting attachment_delete for account {account_id}, attachment {remote_record_id}") - accounts = get_all_accounts(settings_db) - selected = None - for acc in accounts: - if acc.get("id") == account_id: - selected = acc - break - - is_local_account = (account_id == 0) or (selected and selected.get("id") == 0) or (selected and not selected.get("link")) - - if is_local_account: - try: - import sqlite3 - db_path = settings_db or resolve_qml_db_path() - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - cursor.execute( - "DELETE FROM ir_attachment_app WHERE account_id = 0 AND (odoo_record_id = ? OR id = ?)", - (remote_record_id, remote_record_id) - ) - cursor.execute( - "DELETE FROM attachment_download_app WHERE account_id = 0 AND record_id = ?", - (remote_record_id,) - ) - conn.commit() - conn.close() - - cleanup_orphan_attachment_files(settings_db) - return {"success": True} - except Exception as e: - log.exception(f"[ATTACHMENT] Failed to delete local attachment {remote_record_id}: {e}") - return {"success": False, "error": str(e)} - - if not selected: - return {"success": False, "error": "Account not found"} - - try: - # Check server reachability before proceeding - if not check_server_reachability(selected["link"]): - return {"success": False, "error": "No internet connection or server unreachable"} - - client = OdooClient( - selected["link"], - selected["database"], - selected["username"], - selected["api_key"], - ) - res = client.call('ir.attachment', 'unlink', [[remote_record_id]]) - if not res: - log.warning(f"Unlink returned false or empty response for attachment {remote_record_id}") - return {"success": False, "error": "Server failed to delete attachment"} - - sync_ondemand_tables_from_odoo(client, selected["id"], settings_db, account_name=selected.get("name", "")) - return {"success": True} - except Exception as e: - error_msg = str(e) - log.exception(f"[ATTACHMENT] Failed to delete attachment {remote_record_id} for account {account_id}") - - friendly_error = "Delete failed" - if "connection" in error_msg.lower() or "refused" in error_msg.lower(): - friendly_error = "Connection failed: Check network/server" - elif "timeout" in error_msg.lower(): - friendly_error = "Request timed out" - elif "authentication" in error_msg.lower() or "access denied" in error_msg.lower() or "uid" in error_msg.lower(): - friendly_error = "Authentication failed: Check credentials" - elif "xmlrpc" in error_msg.lower() or "fault" in error_msg.lower(): - friendly_error = f"Server error: {error_msg[:60]}" - else: - friendly_error = f"Error: {error_msg[:60]}" - - return {"success": False, "error": friendly_error} - -def cleanup_orphan_attachment_files(settings_db=None): - """ - Removes cached temporary/exported attachment files from disk that are no longer referenced - by any active attachment record in the database. - """ - try: - tmp_dir = _app_data_dir() / "ubtms" / "tmp" - if not tmp_dir.exists(): - return {"success": True, "deleted_count": 0} - - db_path = settings_db or resolve_qml_db_path() - if not db_path or not Path(db_path).exists(): - return {"success": False, "error": "Database not found"} - - import sqlite3 - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - active_filenames = set() - try: - cursor.execute("SELECT file_name FROM attachment_download_app WHERE downloaded = 1") - for row in cursor.fetchall(): - if row[0]: - active_filenames.add(str(row[0]).strip().replace("/", "_")) - except Exception: - pass - - try: - cursor.execute("SELECT name FROM ir_attachment_app") - for row in cursor.fetchall(): - if row[0]: - active_filenames.add(str(row[0]).strip().replace("/", "_")) - except Exception: - pass - - conn.close() - - deleted_count = 0 - for file in tmp_dir.iterdir(): - if file.is_file(): - if file.name not in active_filenames and file.stem not in active_filenames: - try: - file.unlink() - deleted_count += 1 - log.info(f"[ATTACHMENT] Cleaned up orphan file on disk: {file.name}") - except Exception as err: - log.warning(f"[ATTACHMENT] Could not delete {file.name}: {err}") - - return {"success": True, "deleted_count": deleted_count} - except Exception as e: - log.exception(f"[ATTACHMENT] Error cleaning orphan attachment files: {e}") - return {"success": False, "error": str(e)} - -def sync(settings_db, account_id): - """ - Perform synchronous bidirectional sync between local database and Odoo. - - Args: - settings_db (str): Path to the settings database file - account_id (int): Account ID to sync - - Returns: - bool: True if sync completed successfully - - Note: - Performs complete sync including: - 1. Sync from Odoo to local database - 2. Sync from local database to Odoo - Updates sync report in database with progress and results. - """ - send("progress",0) - write_sync_report_to_db( - settings_db, account_id, "In Progress", "Sync job triggered" - ) - # initialize_app_settings_db(settings_db) done by js - accounts = get_all_accounts(settings_db) - selected = accounts[account_id] - client = OdooClient( - selected["link"], - selected["database"], - selected["username"], - selected["api_key"], - ) - send("progress",20) - log.debug("Syncing from oddo from server" + selected["link"]) - sync_all_from_odoo(client, selected["id"], settings_db, account_name=selected.get("name", "")) - - log.debug("Syncing to odoo") - send("progress",50) - sync_all_to_odoo(client, selected["id"], settings_db) - - write_sync_report_to_db( - settings_db, account_id, "Successful", "Sync completed successfully" - ) - send("progress",100) - return True - - -def sync_background(settings_db, account_id): - """ - Perform asynchronous bidirectional sync in a background thread. - - Args: - settings_db (str): Path to the settings database file - account_id (int): Account ID to sync - - Returns: - bool: True if sync thread started successfully, False if sync already in progress - - Note: - Uses global sync_lock to prevent concurrent sync operations. - Runs sync in separate thread to avoid blocking UI. - Updates sync report with progress and handles errors gracefully. - """ - global sync_in_progress - - with sync_lock: - if sync_in_progress: - log.debug("[SYNC] Already in progress. Ignoring new request.") - return False - sync_in_progress = True - - def do_sync(): - global sync_in_progress - try: - send("sync_progress",0) - log.debug(f"[SYNC] Starting background sync to {settings_db}...") - write_sync_report_to_db( - settings_db, account_id, "In Progress", "Sync job triggered" - ) - # initialize_app_settings_db(settings_db) , done by js - accounts = get_all_accounts(settings_db) - selected = next((acc for acc in accounts if acc["id"] == account_id), None) - send("sync_progress",20) - - if not selected: - write_sync_report_to_db(settings_db, account_id, "Failed", "Account not found") - return - - # Proceed with syncing using `account` - log.debug(f"[SYNC] Found account: {selected['name']} (ID: {selected['id']})") - - send("sync_progress",25) - client = OdooClient( - selected["link"], - selected["database"], - selected["username"], - selected["api_key"], - ) - send("sync_progress",30) - log.debug("Syncing from oddo : ID Is " + selected["link"]) - sync_all_from_odoo(client, account_id, settings_db, account_name=selected.get("name", "")) - send("sync_progress",50) - log.debug("Syncing to odoo") - sync_all_to_odoo(client, account_id, settings_db) - send("sync_progress",90) - - log.debug("[SYNC] Background sync completed.") - write_sync_report_to_db( - settings_db, - account_id, - "Successful", - "Sync completed successfully", - ) - # Record successful sync timestamp for per-account interval tracking - update_last_synced_at(settings_db, account_id) - send("sync_progress",100) - send("sync_completed",True) - except Exception as e: - log.exception(f"[SYNC] Error during background sync: {e}") - write_sync_report_to_db(settings_db, account_id, "Failed", str(e)) - send("sync_completed",False) - finally: - with sync_lock: - sync_in_progress = False - - thread = threading.Thread(target=do_sync) - thread.start() - return True # Return immediately so QML doesn’t wait - - -def start_sync_in_background(settings_db, account_id): - """ - Start a background synchronization process. - - Args: - settings_db (str): Path to the settings database file - account_id (int): Account ID to sync - - Returns: - bool: Result from sync_background function - - Note: - Wrapper function for sync_background to provide a cleaner interface. - """ - return sync_background(settings_db, account_id) - -# Voice to Text STarts Here -_voice_stop_event = threading.Event() - -def stop_voice_recognition(): - """Signals the voice recognition thread to stop recording.""" - log.info("[VOICE] stop_voice_recognition called") - _voice_stop_event.set() - return True - -def get_voice_models_dir(): - """ - Returns the writable directory for voice models. - - On Ubuntu Touch this is normally: - ~/.local/share/ubtms/voice_models - """ - data_home = os.environ.get("XDG_DATA_HOME") - - if data_home: - base_dir = Path(data_home) / "ubtms" - else: - base_dir = Path.home() / ".local" / "share" / "ubtms" - - models_dir = base_dir / "voice_models" - models_dir.mkdir(parents=True, exist_ok=True) - - return models_dir - -def is_valid_vosk_model(model_path): - """ - Check whether a directory contains a supported Vosk model layout. - - Supported layouts: - 1. Mobile/legacy layout: - model/am/ - model/graph/ - - 2. Flat Kaldi/Vosk layout: - model/final.mdl - model/HCLG.fst - or - model/HCLr.fst - """ - try: - path = Path(model_path) - - if not path.is_dir(): - return False - - # Layout used by some Vosk mobile models - has_am_graph = ( - (path / "am").is_dir() and - (path / "graph").is_dir() - ) - - # Flat Kaldi/Vosk model layout - has_flat_model = ( - (path / "final.mdl").is_file() - and ( - (path / "HCLG.fst").is_file() - or (path / "HCLr.fst").is_file() - ) - ) - - return has_am_graph or has_flat_model - - except Exception as e: - log.warning(f"[VOICE] Error validating model {model_path}: {e}") - return False - -def list_installed_models(): - """ - Scans for installed Vosk models in both the app directory - and writable data directory. - Returns a list of dictionaries with model names, paths, - sizes, and sources. - """ - - # App directory models (read-only) - app_models_dir = root_dir / "voice_to_text" - - # User/downloaded models directory - user_models_dir = get_voice_models_dir() - - search_paths = [ - (app_models_dir, "App"), - (user_models_dir, "User"), - (Path.home() / ".clickable" / "home" / ".local" / "share" / "ubtms" / "voice_models", "Clickable") - ] - - models = [] - seen_names = set() - - available_models = list_available_models() - - known_names = { - m["id"]: m["name"] - for m in available_models - if "id" in m - } - - # Bundled model - known_names["model"] = "Indian English" - - for root_dir_to_scan, source_label in search_paths: - if not root_dir_to_scan.exists(): - continue - - for item in root_dir_to_scan.iterdir(): - if item.is_dir() and item.name not in seen_names: - - # Supported Vosk model layouts: - # - # 1. Mobile layout: - # model/am/ - # model/graph/ - # - # 2. Standard Vosk/Kaldi layout: - # model/final.mdl - # model/HCLG.fst - # - # or: - # model/final.mdl - # model/HCLr.fst - - if not is_valid_vosk_model(item): - continue - - if item.name in known_names: - model_name = known_names[item.name] - - else: - # Fallback to folder name - model_name = item.name - - # Try README for a nicer display name - readme_path = item / "README" - - if readme_path.exists(): - try: - with open( - readme_path, - "r", - encoding="utf-8" - ) as f: - first_line = f.readline().strip() - - if first_line: - clean_name = first_line - - noise_phrases = [ - "for mobile Vosk applications", - "for Android and iOS", - "Vosk mobile model", - "Vosk model", - "Vosk", - "model" - ] - - for phrase in noise_phrases: - clean_name = clean_name.replace( - phrase, - "" - ).strip() - - if clean_name: - model_name = clean_name - - except Exception as e: - log.error( - f"[VOICE] Error reading README " - f"for {item.name}: {e}" - ) - - # Bundled models get "(Default)" - display_name = model_name - - if source_label == "App": - display_name = f"{model_name} (Default)" - - # Calculate directory size - total_size = 0 - - try: - for f in item.rglob("*"): - if f.is_file(): - total_size += f.stat().st_size - - size_mb = total_size / (1024 * 1024) - model_size = f"{size_mb:.1f} MB" - - except Exception: - model_size = "Unknown" - - # Store model information - try: - if source_label == "App": - rel_path = item.relative_to(root_dir) - else: - rel_path = item - - models.append({ - "m_name": display_name, - "m_path": str(rel_path), - "m_size": model_size, - "m_source": source_label - }) - - except ValueError: - models.append({ - "m_name": display_name, - "m_path": str(item), - "m_size": model_size, - "m_source": source_label - }) - - seen_names.add(item.name) - - models.sort(key=lambda x: x["m_name"].lower()) - - log.info( - f"[VOICE] Found {len(models)} installed models" - ) - - return models - -def get_installed_voice_models(): - """Returns a list of installed voice model IDs.""" - models_dir = get_voice_models_dir() - if not models_dir.exists(): - return [] - # Any directory in models_dir is considered an installed model - installed = [d.name for d in models_dir.iterdir() if d.is_dir()] - log.info(f"[VOICE] Found {len(installed)} installed models") - return installed - -def get_device_total_ram_mb(): - """Returns total device RAM in MB to check model compatibility.""" - try: - with open('/proc/meminfo', 'r') as f: - for line in f: - if line.startswith('MemTotal:'): - parts = line.split() - return int(parts[1]) // 1024 - except Exception: - pass - # Fallback using sysconf if /proc/meminfo is not available - try: - import os - pagesize = os.sysconf('SC_PAGE_SIZE') - pages = os.sysconf('SC_PHYS_PAGES') - return (pagesize * pages) // (1024 * 1024) - except Exception: - pass - return 2048 # Fallback to 2048MB - - -def get_paused_voice_models(): - """Returns a list of model IDs that have a partial download file.""" - models_dir = get_voice_models_dir() - if not models_dir.exists(): - return [] - paused = [] - for f in models_dir.glob("*.zip.tmp"): - paused.append(f.name.replace(".zip.tmp", "")) - return paused - - -def list_available_models(): - """ - Returns a list of models available for download. - """ - models = [ - {"id": "vosk-model-small-en-us-0.15", "name": "English (US, Small)", "size": "40M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip"}, - {"id": "vosk-model-en-us-0.22", "name": "English (US)", "size": "1.8G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-0.22.zip"}, - {"id": "vosk-model-en-us-0.22-lgraph", "name": "English (US, LGraph)", "size": "128M", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-0.22-lgraph.zip"}, - {"id": "vosk-model-en-us-0.42-gigaspeech", "name": "English (US, Gigaspeech)", "size": "2.3G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-0.42-gigaspeech.zip"}, - {"id": "vosk-model-en-us-daanzu-20200905", "name": "English (US, Daanzu)", "size": "1.0G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-daanzu-20200905.zip"}, - {"id": "vosk-model-en-us-daanzu-20200905-lgraph", "name": "English (US, Daanzu LGraph)", "size": "129M", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-daanzu-20200905-lgraph.zip"}, - {"id": "vosk-model-en-us-librispeech-0.2", "name": "English (US, Librispeech)", "size": "845M", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-librispeech-0.2.zip"}, - {"id": "vosk-model-small-en-us-zamia-0.5", "name": "English (US, Zamia Small)", "size": "49M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-en-us-zamia-0.5.zip"}, - {"id": "vosk-model-en-us-aspire-0.2", "name": "English (US, Aspire)", "size": "1.4G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-aspire-0.2.zip"}, - {"id": "vosk-model-en-us-0.21", "name": "English (US, 0.21)", "size": "1.6G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-0.21.zip"}, - {"id": "vosk-model-en-in-0.5", "name": "English (Indian)", "size": "1G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-in-0.5.zip"}, - {"id": "vosk-model-small-en-in-0.4", "name": "English (Indian, Small)", "size": "36M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-en-in-0.4.zip"}, - {"id": "vosk-model-small-cn-0.22", "name": "Chinese (Small)", "size": "42M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip"}, - {"id": "vosk-model-cn-0.22", "name": "Chinese", "size": "1.3G", "url": "https://alphacephei.com/vosk/models/vosk-model-cn-0.22.zip"}, - {"id": "vosk-model-cn-kaldi-multicn-0.15", "name": "Chinese (Multi-cn)", "size": "1.5G", "url": "https://alphacephei.com/vosk/models/vosk-model-cn-kaldi-multicn-0.15.zip"}, - {"id": "vosk-model-ru-0.42", "name": "Russian", "size": "1.8G", "url": "https://alphacephei.com/vosk/models/vosk-model-ru-0.42.zip"}, - {"id": "vosk-model-small-ru-0.22", "name": "Russian (Small)", "size": "45M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip"}, - {"id": "vosk-model-ru-0.22", "name": "Russian (0.22)", "size": "1.5G", "url": "https://alphacephei.com/vosk/models/vosk-model-ru-0.22.zip"}, - {"id": "vosk-model-ru-0.10", "name": "Russian (0.10)", "size": "2.5G", "url": "https://alphacephei.com/vosk/models/vosk-model-ru-0.10.zip"}, - {"id": "vosk-model-small-fr-0.22", "name": "French (Small)", "size": "41M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-fr-0.22.zip"}, - {"id": "vosk-model-fr-0.22", "name": "French", "size": "1.4G", "url": "https://alphacephei.com/vosk/models/vosk-model-fr-0.22.zip"}, - {"id": "vosk-model-small-fr-pguyot-0.3", "name": "French (pguyot Small)", "size": "39M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-fr-pguyot-0.3.zip"}, - {"id": "vosk-model-fr-0.6-linto-2.2.0", "name": "French (Linto)", "size": "1.5G", "url": "https://alphacephei.com/vosk/models/vosk-model-fr-0.6-linto-2.2.0.zip"}, - {"id": "vosk-model-de-0.21", "name": "German", "size": "1.9G", "url": "https://alphacephei.com/vosk/models/vosk-model-de-0.21.zip"}, - {"id": "vosk-model-de-tuda-0.6-900k", "name": "German (Tuda)", "size": "4.4G", "url": "https://alphacephei.com/vosk/models/vosk-model-de-tuda-0.6-900k.zip"}, - {"id": "vosk-model-small-de-zamia-0.3", "name": "German (Zamia Small)", "size": "49M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-de-zamia-0.3.zip"}, - {"id": "vosk-model-small-de-0.15", "name": "German (Small)", "size": "45M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-de-0.15.zip"}, - {"id": "vosk-model-small-es-0.42", "name": "Spanish (Small)", "size": "39M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-es-0.42.zip"}, - {"id": "vosk-model-es-0.42", "name": "Spanish", "size": "1.4G", "url": "https://alphacephei.com/vosk/models/vosk-model-es-0.42.zip"}, - {"id": "vosk-model-small-pt-0.3", "name": "Portuguese (Small)", "size": "31M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-pt-0.3.zip"}, - {"id": "vosk-model-pt-fb-v0.1.1-20220516_2113", "name": "Portuguese", "size": "1.6G", "url": "https://alphacephei.com/vosk/models/vosk-model-pt-fb-v0.1.1-20220516_2113.zip"}, - {"id": "vosk-model-el-gr-0.7", "name": "Greek", "size": "1.1G", "url": "https://alphacephei.com/vosk/models/vosk-model-el-gr-0.7.zip"}, - {"id": "vosk-model-small-tr-0.3", "name": "Turkish (Small)", "size": "35M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-tr-0.3.zip"}, - {"id": "vosk-model-small-vn-0.4", "name": "Vietnamese (Small)", "size": "32M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-vn-0.4.zip"}, - {"id": "vosk-model-vn-0.4", "name": "Vietnamese", "size": "78M", "url": "https://alphacephei.com/vosk/models/vosk-model-vn-0.4.zip"}, - {"id": "vosk-model-small-it-0.22", "name": "Italian (Small)", "size": "48M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-it-0.22.zip"}, - {"id": "vosk-model-it-0.22", "name": "Italian", "size": "1.2G", "url": "https://alphacephei.com/vosk/models/vosk-model-it-0.22.zip"}, - {"id": "vosk-model-small-nl-0.22", "name": "Dutch (Small)", "size": "39M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-nl-0.22.zip"}, - {"id": "vosk-model-nl-spraakherkenning-0.6", "name": "Dutch", "size": "860M", "url": "https://alphacephei.com/vosk/models/vosk-model-nl-spraakherkenning-0.6.zip"}, - {"id": "vosk-model-nl-spraakherkenning-0.6-lgraph", "name": "Dutch (LGraph)", "size": "100M", "url": "https://alphacephei.com/vosk/models/vosk-model-nl-spraakherkenning-0.6-lgraph.zip"}, - {"id": "vosk-model-small-ca-0.4", "name": "Catalan (Small)", "size": "42M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ca-0.4.zip"}, - {"id": "vosk-model-ar-mgb2-0.4", "name": "Arabic (mgb2)", "size": "318M", "url": "https://alphacephei.com/vosk/models/vosk-model-ar-mgb2-0.4.zip"}, - {"id": "vosk-model-ar-0.22-linto-1.1.0", "name": "Arabic (Linto)", "size": "1.3G", "url": "https://alphacephei.com/vosk/models/vosk-model-ar-0.22-linto-1.1.0.zip"}, - {"id": "vosk-model-small-ar-tn-0.1-linto", "name": "Arabic Tunisian (Small)", "size": "158M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ar-tn-0.1-linto.zip"}, - {"id": "vosk-model-ar-tn-0.1-linto", "name": "Arabic Tunisian", "size": "517M", "url": "https://alphacephei.com/vosk/models/vosk-model-ar-tn-0.1-linto.zip"}, - {"id": "vosk-model-fa-0.42", "name": "Farsi", "size": "1.6G", "url": "https://alphacephei.com/vosk/models/vosk-model-fa-0.42.zip"}, - {"id": "vosk-model-small-fa-0.42", "name": "Farsi (Small)", "size": "53M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-fa-0.42.zip"}, - {"id": "vosk-model-fa-0.5", "name": "Farsi (0.5)", "size": "1G", "url": "https://alphacephei.com/vosk/models/vosk-model-fa-0.5.zip"}, - {"id": "vosk-model-small-fa-0.5", "name": "Farsi (Small 0.5)", "size": "60M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-fa-0.5.zip"}, - {"id": "vosk-model-tl-ph-generic-0.6", "name": "Filipino", "size": "320M", "url": "https://alphacephei.com/vosk/models/vosk-model-tl-ph-generic-0.6.zip"}, - {"id": "vosk-model-small-uk-v3-nano", "name": "Ukrainian (Nano)", "size": "73M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-uk-v3-nano.zip"}, - {"id": "vosk-model-small-uk-v3-small", "name": "Ukrainian (Small)", "size": "133M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-uk-v3-small.zip"}, - {"id": "vosk-model-uk-v3", "name": "Ukrainian", "size": "343M", "url": "https://alphacephei.com/vosk/models/vosk-model-uk-v3.zip"}, - {"id": "vosk-model-uk-v3-lgraph", "name": "Ukrainian (LGraph)", "size": "325M", "url": "https://alphacephei.com/vosk/models/vosk-model-uk-v3-lgraph.zip"}, - {"id": "vosk-model-small-kz-0.42", "name": "Kazakh (Small)", "size": "58M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-kz-0.42.zip"}, - {"id": "vosk-model-kz-0.42", "name": "Kazakh", "size": "1.3G", "url": "https://alphacephei.com/vosk/models/vosk-model-kz-0.42.zip"}, - {"id": "vosk-model-small-sv-rhasspy-0.15", "name": "Swedish (Small)", "size": "289M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-sv-rhasspy-0.15.zip"}, - {"id": "vosk-model-small-ja-0.22", "name": "Japanese (Small)", "size": "48M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ja-0.22.zip"}, - {"id": "vosk-model-ja-0.22", "name": "Japanese", "size": "1Gb", "url": "https://alphacephei.com/vosk/models/vosk-model-ja-0.22.zip"}, - {"id": "vosk-model-small-eo-0.42", "name": "Esperanto (Small)", "size": "42M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-eo-0.42.zip"}, - {"id": "vosk-model-small-hi-0.22", "name": "Hindi (Small)", "size": "42M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-hi-0.22.zip"}, - {"id": "vosk-model-hi-0.22", "name": "Hindi", "size": "1.5Gb", "url": "https://alphacephei.com/vosk/models/vosk-model-hi-0.22.zip"}, - {"id": "vosk-model-small-cs-0.4-rhasspy", "name": "Czech (Small)", "size": "44M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-cs-0.4-rhasspy.zip"}, - {"id": "vosk-model-small-pl-0.22", "name": "Polish (Small)", "size": "50M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-pl-0.22.zip"}, - {"id": "vosk-model-small-uz-0.22", "name": "Uzbek (Small)", "size": "49M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-uz-0.22.zip"}, - {"id": "vosk-model-small-ko-0.22", "name": "Korean (Small)", "size": "82M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ko-0.22.zip"}, - {"id": "vosk-model-br-0.8", "name": "Breton", "size": "70M", "url": "https://alphacephei.com/vosk/models/vosk-model-br-0.8.zip"}, - {"id": "vosk-model-gu-0.42", "name": "Gujarati", "size": "700M", "url": "https://alphacephei.com/vosk/models/vosk-model-gu-0.42.zip"}, - {"id": "vosk-model-small-gu-0.42", "name": "Gujarati (Small)", "size": "100M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-gu-0.42.zip"}, - {"id": "vosk-model-tg-0.22", "name": "Tajik", "size": "327M", "url": "https://alphacephei.com/vosk/models/vosk-model-tg-0.22.zip"}, - {"id": "vosk-model-small-tg-0.22", "name": "Tajik (Small)", "size": "50M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-tg-0.22.zip"}, - {"id": "vosk-model-small-te-0.42", "name": "Telugu (Small)", "size": "58M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-te-0.42.zip"}, - {"id": "vosk-model-small-ky-0.42", "name": "Kyrgyz (Small)", "size": "49M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ky-0.42.zip"}, - {"id": "vosk-model-ky-0.42", "name": "Kyrgyz", "size": "1.1G", "url": "https://alphacephei.com/vosk/models/vosk-model-ky-0.42.zip"}, - {"id": "vosk-model-small-ka-0.42", "name": "Georgian (Small)", "size": "45M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ka-0.42.zip"}, - {"id": "vosk-model-ka-0.42", "name": "Georgian", "size": "700M", "url": "https://alphacephei.com/vosk/models/vosk-model-ka-0.42.zip"} - ] - models.sort(key=lambda x: x["name"].lower()) - return models - - -def get_model_download_status(): - """Returns the current download status for UI polling.""" - return download_status - - -_voice_download_cancel_event = threading.Event() -_voice_download_action = None - -def cancel_voice_model_download(): - """Cancels an ongoing voice model download and deletes partial data.""" - global download_status, _voice_download_action - _voice_download_action = "cancel" - _voice_download_cancel_event.set() - download_status["in_progress"] = False - download_status["is_paused"] = False - download_status["message"] = "Download cancelled" - - # Clean up any partial download files immediately to prevent them from showing up on restart - models_dir = get_voice_models_dir() - if models_dir.exists(): - for f in models_dir.glob("*.zip.tmp"): - try: - f.unlink() - except Exception: - pass - - return {"status": "cancelled"} - -def pause_voice_model_download(): - """Pauses an ongoing voice model download, keeping partial data.""" - global download_status, _voice_download_action - _voice_download_action = "pause" - _voice_download_cancel_event.set() - download_status["in_progress"] = False - download_status["is_paused"] = True - download_status["message"] = "Download paused" - return {"status": "paused"} - -def download_voice_model(model_id, url): - """ - Initiates a background thread to download and extract a voice model. - """ - global download_status - _voice_download_cancel_event.clear() - if download_status["in_progress"]: - return {"status": "error", "message": "Download already in progress"} - - download_status = { - "in_progress": True, - "progress": download_status.get("progress", 0), - "message": "Starting download...", - "error": "", - "model_id": model_id, - "is_paused": False - } - - def do_download(): - import zipfile - import shutil - temp_zip = None - temp_extract_dir = None - try: - models_dir = get_voice_models_dir() - target_path = models_dir / model_id - - if target_path.exists(): - download_status["in_progress"] = False - download_status["message"] = "Model already installed" - download_status["progress"] = 100 - return - - temp_zip = models_dir / f"{model_id}.zip.tmp" - existing_size = 0 - if temp_zip.exists(): - existing_size = temp_zip.stat().st_size - - headers = {} - if existing_size > 0: - headers['Range'] = f'bytes={existing_size}-' - log.info(f"[VOICE] Resuming {model_id} from {existing_size} bytes") - download_status["message"] = "Resuming..." - else: - log.info(f"[VOICE] Downloading model from {url}") - download_status["message"] = "Downloading..." - - # Using urllib3 for download with a timeout to handle sudden internet disconnection - response = http.request('GET', url, headers=headers, preload_content=False, timeout=urllib3.Timeout(connect=5.0, read=10.0)) - if response.status not in (200, 206, 416): - raise Exception(f"Server returned status {response.status}") - - if response.status == 200 and existing_size > 0: - # Server ignored Range header, restart from scratch - log.info(f"[VOICE] Server ignored Range, restarting download") - existing_size = 0 - temp_zip.unlink() - - content_length = response.getheader('Content-Length') - total_size = (int(content_length) + existing_size) if content_length else None - - if total_size and existing_size == 0: - # Check available disk space only on new downloads - try: - _, _, free = shutil.disk_usage(str(models_dir)) - free_mb = free / (1024 * 1024) - required_mb = (total_size * 2.5) / (1024 * 1024) - if free < total_size * 2.5: - raise Exception(f"Insufficient storage. Have {free_mb:.1f} MB, need at least {required_mb:.1f} MB available.") - except Exception as disk_err: - if isinstance(disk_err, Exception) and "Insufficient storage" in str(disk_err): - raise disk_err - log.warning(f"[VOICE] Could not check disk space: {disk_err}") - - downloaded = existing_size - mode = 'ab' if existing_size > 0 else 'wb' - with open(temp_zip, mode) as f: - for chunk in response.stream(1024 * 64): - if _voice_download_cancel_event.is_set(): - action = _voice_download_action - if action == "pause": - raise Exception("PAUSED_BY_USER") - else: - raise Exception("CANCELLED_BY_USER") - f.write(chunk) - downloaded += len(chunk) - if total_size: - progress = int((downloaded / total_size) * 80) # 80% for download - download_status["progress"] = progress - send("download_progress", progress) - - download_status["message"] = "Extracting..." - send("download_message", "Extracting...") - download_status["progress"] = 85 - send("download_progress", 85) - - # Extract to a temporary directory to ensure atomic installation - temp_extract_dir = models_dir / f"{model_id}.extract.tmp" - if temp_extract_dir.exists(): - shutil.rmtree(temp_extract_dir) - temp_extract_dir.mkdir(parents=True, exist_ok=True) - - with zipfile.ZipFile(temp_zip) as z: - z.extractall(temp_extract_dir) - - # Vosk models usually have a single folder inside the zip - # We want to find the directory containing the actual model files ('am' and 'graph') - model_folder = None - for root, dirs, files in os.walk(temp_extract_dir): - if 'am' in dirs and 'graph' in dirs: - model_folder = Path(root) - break - - if not model_folder: - # Fallback: look for any subdirectory - subdirs = [d for d in temp_extract_dir.iterdir() if d.is_dir()] - if len(subdirs) == 1: - model_folder = subdirs[0] - else: - model_folder = temp_extract_dir - - # Move to final location (atomic move on same filesystem) - if target_path.exists(): - shutil.rmtree(target_path) - shutil.move(str(model_folder), str(target_path)) - - download_status["progress"] = 100 - download_status["message"] = "Installation complete" - download_status["in_progress"] = False - send("download_progress", 100) - send("download_completed", True) - log.info(f"[VOICE] Successfully installed model {model_id}") - - except Exception as e: - err_str = str(e) - log.exception(f"[VOICE] Download interrupted: {err_str}") - download_status["in_progress"] = False - - if err_str == "PAUSED_BY_USER": - download_status["is_paused"] = True - download_status["message"] = "Paused" - # Do not unlink temp_zip - elif err_str == "CANCELLED_BY_USER": - download_status["is_paused"] = False - download_status["message"] = "Cancelled" - if temp_zip and temp_zip.exists(): - try: - temp_zip.unlink() - except: - pass - else: - # Network error or other failure - download_status["error"] = err_str - download_status["message"] = "Failed" - download_status["is_paused"] = True # Keep partial file for resume - send("download_error", err_str) - - finally: - if temp_extract_dir and temp_extract_dir.exists(): - try: - shutil.rmtree(temp_extract_dir) - except Exception: - pass - - threading.Thread(target=do_download, daemon=True).start() - return {"status": "started"} - - -def delete_voice_model(model_path): - """ - Deletes a voice model from the user data directory. - Cannot delete bundled models in the app directory. - """ - try: - path = Path(model_path) - - user_models_dir = get_voice_models_dir() - clickable_models_dir = ( - Path.home() / ".clickable" / "home" / - ".local" / "share" / "ubtms" / "voice_models" - ) - - # If relative, it might be a bundled model or a legacy relative path - if not path.is_absolute(): - # Check user and Clickable model directories - potential_path = user_models_dir / model_path - if potential_path.exists(): - path = potential_path - else: - potential_path = clickable_models_dir / model_path - if potential_path.exists(): - path = potential_path - else: - # Check if it's relative to root (bundled models) - potential_path = root_dir / model_path - if potential_path.exists(): - path = potential_path - - if not path.exists(): - return {"status": "error", "message": "Model path not found"} - - # Security check: resolve paths before validation - resolved_path = path.resolve() - resolved_user_dir = user_models_dir.resolve() - resolved_clickable_dir = clickable_models_dir.resolve() - - if ( - resolved_user_dir in resolved_path.parents - or resolved_clickable_dir in resolved_path.parents - ): - import shutil - if resolved_path.is_dir(): - shutil.rmtree(resolved_path) - else: - resolved_path.unlink() - log.info(f"[VOICE] Deleted user model: {model_path}") - return {"status": "success"} - - # Check if it's in the app dir - app_models_dir = (root_dir / "voice_to_text").resolve() - if app_models_dir in resolved_path.parents or resolved_path == app_models_dir: - log.warning(f"[VOICE] Attempted to delete bundled model: {model_path}") - return {"status": "error", "message": "Cannot delete bundled system models"} - - return {"status": "error", "message": "Permission denied"} - except Exception as e: - log.exception(f"[VOICE] Failed to delete model {model_path}: {e}") - return {"status": "error", "message": str(e)} - - -def resolve_settings_db_path(db_name="myDatabase", app_id="ubtms"): - """Finds a specific QML database by its name (hash).""" - import hashlib - db_hash = hashlib.md5(db_name.encode()).hexdigest() - target_filename = f"{db_hash}.sqlite" - - user_home = Path.home() - db_paths = [ - user_home / ".local" / "share" / app_id / "Databases", - user_home / ".clickable" / "home" / ".local" / "share" / app_id / "Databases" - ] - - for db_dir in db_paths: - target = db_dir / target_filename - if target.exists(): - return str(target) - return None - - -def _subprocess_recognize(model_path, pipe, timeout): - """Runs voice recognition in a separate process to avoid blocking the GIL.""" - import signal - def sigterm_handler(signum, frame): - raise SystemExit(0) - signal.signal(signal.SIGTERM, sigterm_handler) - - try: - from voice_to_text.voice2text import recognize_from_mic - - class PipeStopEvent: - def is_set(self): - if pipe.poll(): - msg = pipe.recv() - if msg == "stop": - return True - return False - - def _partial(txt): - pipe.send(("partial", txt)) - - def _status(txt): - pipe.send(("status", txt)) - - text, err = recognize_from_mic(verbose=False, stop_event=PipeStopEvent(), timeout=timeout, partial_callback=_partial, status_callback=_status, model_path=model_path) - pipe.send(("final", text, err)) - except Exception as e: - import traceback - pipe.send(("final", None, f"Subprocess Error: {e}")) - - -def run_voice_recognition(): - """ - Runs voice recognition in a background thread to avoid blocking the UI. - Uses the offline Vosk engine. - """ - def do_recognition(): - import multiprocessing - try: - log.info("[VOICE] Starting offline voice recognition thread") - - # Fetch the active model path from settings - # First try the specific settings DB - db_path = resolve_settings_db_path("myDatabase") - if not db_path: - # Fallback to main app DB - db_path = resolve_qml_db_path() - - low_mem_mode = True # Default to True on mobile - if db_path: - try: - low_mem_mode = get_setting(db_path, "voice_low_memory_mode", "true") == "true" - except Exception as e: - log.warning(f"[VOICE] Could not read low memory setting: {e}") - - active_model_rel_path = "" - if db_path: - active_model_rel_path = get_setting(db_path, "active_voice_model", "") - - # Resolve relative path if necessary - root_dir = Path(__file__).parent.parent.resolve() - - model_path = None - if active_model_rel_path: - if not os.path.isabs(active_model_rel_path): - model_path = root_dir / active_model_rel_path - else: - model_path = Path(active_model_rel_path) - - if not model_path or not model_path.exists(): - installed_models = list_installed_models() - if installed_models: - active_model_rel_path = installed_models[0]["m_path"] - if not os.path.isabs(active_model_rel_path): - model_path = root_dir / active_model_rel_path - else: - model_path = Path(active_model_rel_path) - - if db_path: - try: - from config import set_setting - set_setting(db_path, "active_voice_model", active_model_rel_path) - except Exception as e: - log.warning(f"[VOICE] Could not auto-save active model to db: {e}") - else: - log.error("[VOICE] No language model downloaded") - send("voice_recognition_error", "No language model downloaded. Please download one in Voice Model Settings.") - return - - # Check model graph size to prevent Out of Memory crash - if model_path and model_path.exists(): - # If low memory mode is enabled, temporarily disable rescoring models - # to save ~1.5 - 2 GB of RAM. - rescore_path = model_path / "rescore" - rescore_disabled_path = model_path / "rescore.disabled" - rnnlm_path = model_path / "rnnlm" - rnnlm_disabled_path = model_path / "rnnlm.disabled" - - if low_mem_mode: - if rescore_path.exists() and rescore_path.is_dir(): - try: - rescore_path.rename(rescore_disabled_path) - log.info("[VOICE] Disabled rescore folder for low memory mode") - except Exception as e: - log.warning(f"[VOICE] Could not rename rescore folder: {e}") - if rnnlm_path.exists() and rnnlm_path.is_dir(): - try: - rnnlm_path.rename(rnnlm_disabled_path) - log.info("[VOICE] Disabled rnnlm folder for low memory mode") - except Exception as e: - log.warning(f"[VOICE] Could not rename rnnlm folder: {e}") - else: - if rescore_disabled_path.exists() and rescore_disabled_path.is_dir(): - try: - rescore_disabled_path.rename(rescore_path) - log.info("[VOICE] Re-enabled rescore folder") - except Exception as e: - log.warning(f"[VOICE] Could not restore rescore folder: {e}") - if rnnlm_disabled_path.exists() and rnnlm_disabled_path.is_dir(): - try: - rnnlm_disabled_path.rename(rnnlm_path) - log.info("[VOICE] Re-enabled rnnlm folder") - except Exception as e: - log.warning(f"[VOICE] Could not restore rnnlm folder: {e}") - - try: - # Check the size of the search graph file HCLG.fst, which is the main driver of RAM usage. - # If HCLG.fst is > 1.3 GB, loading it requires 4+ GB of RAM and will crash the app cgroup on mobile. - hclg_path = model_path / "graph" / "HCLG.fst" - if hclg_path.exists(): - hclg_size = hclg_path.stat().st_size - # With low memory mode (rescoring disabled), we can support up to 1.8 GB HCLG.fst. - # Without it, we limit it to 1.0 GB. - limit = 1.8 * 1024 * 1024 * 1024 if low_mem_mode else 1.0 * 1024 * 1024 * 1024 - if hclg_size > limit: - size_mb = hclg_size / (1024 * 1024) - if not low_mem_mode: - err_msg = f"The selected model's search graph ({size_mb:.0f} MB) is too large to run with rescoring enabled. Please enable 'Low Memory Mode' in voice settings to run it." - else: - err_msg = f"The selected model's search graph ({size_mb:.0f} MB) is too large even for Low Memory Mode. Please switch to the LGraph or Small version in settings." - log.error(f"[VOICE] {err_msg}") - send("voice_recognition_error", err_msg) - return - except Exception as e: - log.warning(f"[VOICE] Could not check model graph size: {e}") - - # Paths to search for bundled libraries - base_voice_path = Path(__file__).parent.parent / "voice_to_text" - lib_path = base_voice_path / "lib" - - # Pre-load libatomic if it exists in our bundle (fixes arm64 dependency issue) - atomic_lib = lib_path / "libatomic.so.1" - if atomic_lib.exists(): - try: - ctypes.CDLL(str(atomic_lib)) - log.info(f"[VOICE] Pre-loaded {atomic_lib}") - except Exception as e: - log.warning(f"[VOICE] Could not pre-load libatomic: {e}") - - # Add bundled libs to environment for nested dependencies - env_path = str(lib_path) - if "LD_LIBRARY_PATH" in os.environ: - os.environ["LD_LIBRARY_PATH"] = f"{env_path}:{os.environ['LD_LIBRARY_PATH']}" - else: - os.environ["LD_LIBRARY_PATH"] = env_path - - from voice_to_text.voice2text import list_microphones - - # Reset the stop event - _voice_stop_event.clear() - - # Log available mics for debug - log.info(f"[VOICE] mics: {list_microphones()}") - - # Run recognition in a separate process to avoid GIL blocking and UI freezing - parent_conn, child_conn = multiprocessing.Pipe() - ctx = multiprocessing.get_context("spawn") - p = ctx.Process(target=_subprocess_recognize, args=(str(model_path), child_conn, 30)) - p.start() - - import time - received_final = False - stop_sent_time = None - while p.is_alive(): - if _voice_stop_event.is_set(): - parent_conn.send("stop") - _voice_stop_event.clear() - if stop_sent_time is None: - stop_sent_time = time.time() - - # Watchdog: terminate if process ignores stop signal for > 5 seconds - if stop_sent_time is not None and (time.time() - stop_sent_time > 5.0): - log.warning("[VOICE] Watchdog triggered: Process failed to stop cleanly. Terminating.") - p.terminate() - break - - if parent_conn.poll(0.5): - msg = parent_conn.recv() - if msg[0] == "partial": - send("voice_recognition_partial", msg[1]) - elif msg[0] == "status": - send("voice_recognition_status", msg[1]) - elif msg[0] == "final": - received_final = True - text, error = msg[1], msg[2] - if text: - log.info(f"[VOICE] Recognized text: {text}") - send("voice_recognition_result", text) - else: - log.warning(f"[VOICE] Recognition failed: {error or 'No speech detected'}") - send("voice_recognition_error", error or "No speech detected") - break - - # Ensure process cleanup - p.join(timeout=1) - if p.is_alive(): - p.terminate() - p.join() - - # Handle unexpected crash (like OOM kill) - if not received_final: - exit_code = p.exitcode - if exit_code is not None and exit_code != 0: - err_msg = f"Voice recognition crashed unexpectedly (possibly Out of Memory). Exit code: {exit_code}" - log.error(f"[VOICE] {err_msg}") - send("voice_recognition_error", err_msg) - - except Exception as e: - log.exception(f"[VOICE] Error during voice recognition: {e}") - send("voice_recognition_error", f"System Error: {str(e)}") - - thread = threading.Thread(target=do_recognition) - thread.daemon = True - thread.start() - return True diff --git a/src/backend/__init__.py b/src/backend/__init__.py new file mode 100644 index 00000000..5c5b2b31 --- /dev/null +++ b/src/backend/__init__.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# MIT License +# +# Copyright (c) 2025 CIT-Services +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import sys +from pathlib import Path + +# Add project root and voice_to_text/lib to sys.path +root_dir = Path(__file__).resolve().parents[2] +if str(root_dir) not in sys.path: + sys.path.insert(0, str(root_dir)) + +voice_lib = root_dir / "voice_to_text" / "lib" +if voice_lib.exists() and str(voice_lib) not in sys.path: + sys.path.insert(0, str(voice_lib)) + +from logger import setup_logger +log = setup_logger() + +from .attachments import ( + attachment_delete, + attachment_ondemand_download, + attachment_upload, + cleanup_orphan_attachment_files, +) +from .auth import ( + check_server_reachability, + fetch_databases, + get_db_list, + http, + login_odoo, +) +from .storage import ( + _app_data_dir, + _export_path_for, + _safe_ext_for, + ensure_export_file_from_base64, + get_existing_attachment_path, + is_already_downloaded, + is_file_present, + resolve_qml_db_path, + resolve_settings_db_path, +) +from .sync import ( + start_sync_in_background, + sync, + sync_background, + sync_in_progress, + sync_lock, +) +from .voice import ( + cancel_voice_model_download, + delete_voice_model, + download_status, + download_voice_model, + get_device_total_ram_mb, + get_installed_voice_models, + get_model_download_status, + get_paused_voice_models, + get_voice_models_dir, + is_valid_vosk_model, + list_available_models, + list_installed_models, + pause_voice_model_download, + run_voice_recognition, + stop_voice_recognition, +) + +__all__ = [ + # Logger and HTTP + "log", + "http", + # Auth + "check_server_reachability", + "fetch_databases", + "login_odoo", + "get_db_list", + # Storage + "is_file_present", + "resolve_qml_db_path", + "resolve_settings_db_path", + "_app_data_dir", + "_safe_ext_for", + "_export_path_for", + "get_existing_attachment_path", + "is_already_downloaded", + "ensure_export_file_from_base64", + # Attachments + "attachment_ondemand_download", + "attachment_upload", + "attachment_delete", + "cleanup_orphan_attachment_files", + # Sync + "sync", + "sync_background", + "start_sync_in_background", + "sync_lock", + "sync_in_progress", + # Voice + "stop_voice_recognition", + "get_voice_models_dir", + "is_valid_vosk_model", + "list_installed_models", + "get_installed_voice_models", + "get_device_total_ram_mb", + "get_paused_voice_models", + "list_available_models", + "get_model_download_status", + "cancel_voice_model_download", + "pause_voice_model_download", + "download_voice_model", + "delete_voice_model", + "run_voice_recognition", + "download_status", +] diff --git a/src/backend/attachments.py b/src/backend/attachments.py new file mode 100644 index 00000000..e2632203 --- /dev/null +++ b/src/backend/attachments.py @@ -0,0 +1,360 @@ +# -*- coding: utf-8 -*- +# MIT License +# +# Copyright (c) 2025 CIT-Services +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import base64 +import os +import shutil +import sqlite3 +from pathlib import Path +from urllib.parse import unquote + +from bus import send +from config import get_all_accounts +from logger import setup_logger +from odoo_client import OdooClient +from sync_from_odoo import sync_ondemand_tables_from_odoo + +from .auth import check_server_reachability +from .storage import _app_data_dir, _export_path_for, resolve_qml_db_path + +log = setup_logger() + + +def attachment_ondemand_download(settings_db, account_id, remote_record_id): + accounts = get_all_accounts(settings_db) + selected = None + for acc in accounts: + if acc.get("id") == account_id: + selected = acc + break + + if not selected: + return { + "success": False, + "error": "Account not found", + } + + try: + client = OdooClient( + selected["link"], + selected["database"], + selected["username"], + selected["api_key"], + ) + result = client.ondemanddownload( + remote_record_id, + selected["username"], + selected["api_key"], + False, + ) + if isinstance(result, dict): + result["success"] = True + return result + except Exception as e: + log.exception( + "[ATTACHMENT] Failed to download attachment %s for account %s", + remote_record_id, + account_id, + ) + return { + "success": False, + "error": str(e), + } + + +def attachment_upload(settings_db, account_id, filepath, res_type, res_id): + try: + filepath = unquote(filepath) + send("ondemand_upload_message", "Initiating upload...") + log.debug(f"[SYNC] Starting attachment_upload to {account_id} : {filepath} , {res_type} ,{res_id}") + accounts = get_all_accounts(settings_db) + selected = None + for acc in accounts: + if acc.get("id") == account_id: + selected = acc + break + + is_local_account = (account_id == 0) or (selected and selected.get("id") == 0) or (selected and not selected.get("link")) + + if not selected and not is_local_account: + send("ondemand_upload_message", "Error: Account not found") + send("ondemand_upload_completed", False) + return None + + send("ondemand_upload_message", "Checking file...") + if not os.path.exists(filepath): + send("ondemand_upload_message", "Error: File does not exist") + send("ondemand_upload_completed", False) + return None + + # 25 MB limit to prevent memory exhaustion and large payload failures + file_size = os.path.getsize(filepath) + if file_size > 25 * 1024 * 1024: + send("ondemand_upload_message", "Error: File exceeds 25MB limit") + send("ondemand_upload_completed", False) + return None + + filename = os.path.basename(filepath) + EXT_TO_MIME = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".pdf": "application/pdf", + ".txt": "text/plain", + ".csv": "text/csv", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".zip": "application/zip", + } + send("ondemand_upload_message", "Reading file...") + ext = os.path.splitext(filename)[1].lower() + mimetype = EXT_TO_MIME.get(ext, "application/octet-stream") + + # Handle Local Account attachment save + if is_local_account: + send("ondemand_upload_message", "Saving attachment locally...") + dest_path = _export_path_for(filename, mimetype) + if Path(filepath).resolve() != Path(dest_path).resolve(): + shutil.copy2(filepath, dest_path) + + db_path = settings_db or resolve_qml_db_path() + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + cursor.execute("SELECT COALESCE(MAX(odoo_record_id), 0) + 1 FROM ir_attachment_app WHERE account_id = 0") + row = cursor.fetchone() + next_local_record_id = row[0] if (row and row[0] is not None and row[0] > 0) else 1 + + cursor.execute(""" + INSERT INTO ir_attachment_app ( + account_id, name, res_model, res_id, file_path, local_url, url, + file_size, mimetype, odoo_record_id, last_modified, status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), 'local') + """, ( + 0, + filename, + res_type, + res_id, + str(dest_path), + f"file://{dest_path}", + f"file://{dest_path}", + file_size, + mimetype, + next_local_record_id, + )) + + cursor.execute(""" + INSERT OR REPLACE INTO attachment_download_app (account_id, record_id, file_name, downloaded) + VALUES (0, ?, ?, 1) + """, (next_local_record_id, filename)) + + conn.commit() + conn.close() + + send("ondemand_upload_message", "Attachment saved successfully") + send("ondemand_upload_completed", True) + return next_local_record_id + + # Remote Odoo Account upload + send("ondemand_upload_message", "Checking server connection...") + if not check_server_reachability(selected["link"]): + send("ondemand_upload_message", "Error: No internet connection or server unreachable") + send("ondemand_upload_completed", False) + return None + + with open(filepath, "rb") as f: + file_bytes = f.read() + + client = OdooClient( + selected["link"], + selected["database"], + selected["username"], + selected["api_key"], + ) + + vals = { + "name": filename, + "type": "binary", + "res_model": res_type, + "res_id": res_id, + "datas": base64.b64encode(file_bytes).decode("utf-8"), + "mimetype": mimetype, + } + send("ondemand_upload_message", "Uploading file to server...") + attachment_id = client.call("ir.attachment", "create", [vals]) + if not attachment_id or attachment_id <= 0: + send("ondemand_upload_message", "Error: Server failed to save attachment") + send("ondemand_upload_completed", False) + return None + + send("ondemand_upload_message", "Syncing to local device...") + sync_ondemand_tables_from_odoo(client, selected["id"], settings_db, account_name=selected.get("name", "")) + send("ondemand_upload_completed", True) + return attachment_id + + except Exception as e: + error_msg = str(e) + log.exception(f"[ATTACHMENT] Failed to upload attachment: {error_msg}") + + friendly_error = "Upload failed" + if "connection" in error_msg.lower() or "refused" in error_msg.lower(): + friendly_error = "Connection failed: Check network/server" + elif "timeout" in error_msg.lower(): + friendly_error = "Request timed out" + elif "authentication" in error_msg.lower() or "access denied" in error_msg.lower() or "uid" in error_msg.lower(): + friendly_error = "Authentication failed: Check credentials" + elif "xmlrpc" in error_msg.lower() or "fault" in error_msg.lower(): + friendly_error = f"Server error: {error_msg[:60]}" + else: + friendly_error = f"Error: {error_msg[:60]}" + + send("ondemand_upload_message", friendly_error) + send("ondemand_upload_completed", False) + return None + + +def attachment_delete(settings_db, account_id, remote_record_id): + log.debug(f"[SYNC] Starting attachment_delete for account {account_id}, attachment {remote_record_id}") + accounts = get_all_accounts(settings_db) + selected = None + for acc in accounts: + if acc.get("id") == account_id: + selected = acc + break + + is_local_account = (account_id == 0) or (selected and selected.get("id") == 0) or (selected and not selected.get("link")) + + if is_local_account: + try: + db_path = settings_db or resolve_qml_db_path() + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + cursor.execute( + "DELETE FROM ir_attachment_app WHERE account_id = 0 AND (odoo_record_id = ? OR id = ?)", + (remote_record_id, remote_record_id), + ) + cursor.execute( + "DELETE FROM attachment_download_app WHERE account_id = 0 AND record_id = ?", + (remote_record_id,), + ) + conn.commit() + conn.close() + + cleanup_orphan_attachment_files(settings_db) + return {"success": True} + except Exception as e: + log.exception(f"[ATTACHMENT] Failed to delete local attachment {remote_record_id}: {e}") + return {"success": False, "error": str(e)} + + if not selected: + return {"success": False, "error": "Account not found"} + + try: + if not check_server_reachability(selected["link"]): + return {"success": False, "error": "No internet connection or server unreachable"} + + client = OdooClient( + selected["link"], + selected["database"], + selected["username"], + selected["api_key"], + ) + res = client.call("ir.attachment", "unlink", [[remote_record_id]]) + if not res: + log.warning(f"Unlink returned false or empty response for attachment {remote_record_id}") + return {"success": False, "error": "Server failed to delete attachment"} + + sync_ondemand_tables_from_odoo(client, selected["id"], settings_db, account_name=selected.get("name", "")) + return {"success": True} + except Exception as e: + error_msg = str(e) + log.exception(f"[ATTACHMENT] Failed to delete attachment {remote_record_id} for account {account_id}") + + friendly_error = "Delete failed" + if "connection" in error_msg.lower() or "refused" in error_msg.lower(): + friendly_error = "Connection failed: Check network/server" + elif "timeout" in error_msg.lower(): + friendly_error = "Request timed out" + elif "authentication" in error_msg.lower() or "access denied" in error_msg.lower() or "uid" in error_msg.lower(): + friendly_error = "Authentication failed: Check credentials" + elif "xmlrpc" in error_msg.lower() or "fault" in error_msg.lower(): + friendly_error = f"Server error: {error_msg[:60]}" + else: + friendly_error = f"Error: {error_msg[:60]}" + + return {"success": False, "error": friendly_error} + + +def cleanup_orphan_attachment_files(settings_db=None): + """ + Removes cached temporary/exported attachment files from disk that are no longer referenced + by any active attachment record in the database. + """ + try: + tmp_dir = _app_data_dir() / "ubtms" / "tmp" + if not tmp_dir.exists(): + return {"success": True, "deleted_count": 0} + + db_path = settings_db or resolve_qml_db_path() + if not db_path or not Path(db_path).exists(): + return {"success": False, "error": "Database not found"} + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + active_filenames = set() + try: + cursor.execute("SELECT file_name FROM attachment_download_app WHERE downloaded = 1") + for row in cursor.fetchall(): + if row[0]: + active_filenames.add(str(row[0]).strip().replace("/", "_")) + except Exception: + pass + + try: + cursor.execute("SELECT name FROM ir_attachment_app") + for row in cursor.fetchall(): + if row[0]: + active_filenames.add(str(row[0]).strip().replace("/", "_")) + except Exception: + pass + + conn.close() + + deleted_count = 0 + for file in tmp_dir.iterdir(): + if file.is_file(): + if file.name not in active_filenames and file.stem not in active_filenames: + try: + file.unlink() + deleted_count += 1 + log.info(f"[ATTACHMENT] Cleaned up orphan file on disk: {file.name}") + except Exception as err: + log.warning(f"[ATTACHMENT] Could not delete {file.name}: {err}") + + return {"success": True, "deleted_count": deleted_count} + except Exception as e: + log.exception(f"[ATTACHMENT] Error cleaning orphan attachment files: {e}") + return {"success": False, "error": str(e)} diff --git a/src/backend/auth.py b/src/backend/auth.py new file mode 100644 index 00000000..6be8ae53 --- /dev/null +++ b/src/backend/auth.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +# MIT License +# +# Copyright (c) 2025 CIT-Services +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import json +import xmlrpc.client +import urllib3 +from logger import setup_logger + +log = setup_logger() + +urllib3.disable_warnings() +http = urllib3.PoolManager(cert_reqs="CERT_NONE") + + +def check_server_reachability(url, timeout=3): + """ + Check if the Odoo server is reachable using a lightweight HTTP request. + Uses urllib3 PoolManager to ensure compatibility with Ubuntu Touch AppArmor sandbox rules. + """ + try: + normalized = (url or "").strip().rstrip("/") + if not normalized: + return False + + if "://" not in normalized: + normalized = f"https://{normalized}" + + response = http.request("HEAD", normalized, timeout=timeout, redirect=True) + return True + except Exception as e: + log.warning(f"[REACHABILITY] HTTP HEAD failed for {url}: {e}") + try: + response = http.request("GET", normalized, timeout=timeout, redirect=True) + return True + except Exception as e2: + log.warning(f"[REACHABILITY] HTTP GET fallback also failed: {e2}") + return False + + +def get_db_list(url): + """ + Retrieve list of available databases from an Odoo server. + """ + try: + response = http.request( + "POST", + url.rstrip("/") + "/web/database/list", + body="{}", + headers={"Content-type": "application/json"}, + ) + if response.status == 200: + data = json.loads(response.data) + return data.get("result", []) + return [] + except Exception as e: + log.error(f"[Critical] No DB found {e}") + return [] + + +def fetch_databases(url): + """ + Fetch available databases from an Odoo server and determine UI visibility options. + """ + database_list = get_db_list(url) + visibility_dict = { + "menu_items": False, + "text_field": False, + "single_db": False, + } + + if not database_list: + visibility_dict["text_field"] = True + elif len(database_list) == 1: + visibility_dict["single_db"] = database_list[0] + else: + visibility_dict["menu_items"] = database_list + + return database_list + + +def login_odoo(selected_url, username, password, selected_db): + """ + Authenticate user credentials against an Odoo server. + """ + common = xmlrpc.client.ServerProxy(f"{selected_url}/xmlrpc/2/common") + generated_uid = common.authenticate(selected_db, username, password, {}) + if generated_uid: + models = xmlrpc.client.ServerProxy(f"{selected_url}/xmlrpc/2/object") + user_name = models.execute_kw( + selected_db, + generated_uid, + password, + "res.users", + "read", + [generated_uid], + {"fields": ["name"]}, + ) + return { + "status": "pass", + "name_of_user": user_name[0]["name"], + "database": selected_db, + "uid": generated_uid, + } + return {"result": "fail"} diff --git a/src/backend/storage.py b/src/backend/storage.py new file mode 100644 index 00000000..612bb349 --- /dev/null +++ b/src/backend/storage.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +# MIT License +# +# Copyright (c) 2025 CIT-Services +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import base64 +import hashlib +import mimetypes +import os +from pathlib import Path + +from common import check_table_exists +from logger import setup_logger + +log = setup_logger() + + +def is_file_present(file_path): + """ + Check if a file exists at the specified path. + """ + file = Path(file_path) + if file.exists() and file.is_file(): + log.info(f"[INFO] File exists: {file_path}") + return True + else: + log.error(f"[ERROR] File NOT found: {file_path}") + return False + + +def resolve_qml_db_path(app_id="ubtms"): + """ + Resolve and find the QML application database path across different environments. + """ + db_paths = [] + user_home = Path.home() + db_paths.append(user_home / ".local" / "share" / app_id / "Databases") + + # Clickable sandbox, e.g., /home/user/.clickable/home/ + clickable_home = user_home / ".clickable" / "home" + db_paths.append(clickable_home / ".local" / "share" / app_id / "Databases") + + for db_dir in db_paths: + log.debug(f"[DEBUG] Checking DB path: {db_dir}") + if not db_dir.exists(): + continue + + sqlite_files = list(db_dir.glob("*.sqlite")) + if sqlite_files: + latest = max(sqlite_files, key=lambda f: f.stat().st_mtime) + log.debug(f"[INFO] Found QML DB: {latest}") + if is_file_present(latest): + log.debug(f"file is present {latest}") + if check_table_exists(latest, "project_project_app"): + log.debug( + f"project_project_app present,confirms that {latest} app db" + ) + return str(latest) + else: + log.critical("SQlite file found , but do not see a table") + + log.debug("[ERROR] No QML DB found.") + return None + + +def resolve_settings_db_path(db_name="myDatabase", app_id="ubtms"): + """ + Finds a specific QML database by its name (hash). + """ + db_hash = hashlib.md5(db_name.encode()).hexdigest() + target_filename = f"{db_hash}.sqlite" + + user_home = Path.home() + db_paths = [ + user_home / ".local" / "share" / app_id / "Databases", + user_home / ".clickable" / "home" / ".local" / "share" / app_id / "Databases", + ] + + for db_dir in db_paths: + target = db_dir / target_filename + if target.exists(): + return str(target) + return None + + +def _app_data_dir(): + return Path.home() / ".local/share" + + +def _safe_ext_for(mime): + ext = mimetypes.guess_extension(mime or "") + return ext or "" + + +def _export_path_for(suggested_name, mime): + """ + Build the canonical output path used for exported attachments. + Keeps name normalization and mime-based extension logic in one place. + """ + base = _app_data_dir() / "ubtms" / "tmp" + base.mkdir(parents=True, exist_ok=True) + + name = (suggested_name or "attachment").strip().replace("/", "_") + root, ext = os.path.splitext(name) + if not ext: + ext = _safe_ext_for(mime) + return base / f"{root}{ext}" + + +def get_existing_attachment_path(suggested_name, mime): + """ + Return absolute file path (str) if an attachment is already present on disk, + else None. + """ + out = _export_path_for(suggested_name, mime) + return str(out) if out.exists() and out.is_file() else None + + +def is_already_downloaded(suggested_name, mime): + try: + out = _export_path_for(suggested_name, mime) + return is_file_present(out) + except Exception as e: + log.exception("is_already_downloaded error: %s", e) + return False + + +def ensure_export_file_from_base64(suggested_name, b64_data, mime): + try: + out = _export_path_for(suggested_name, mime) + with open(out, "wb") as f: + f.write(base64.b64decode(b64_data)) + return str(out) + except Exception as e: + log.error("ensure_export_file_from_base64 error: %s", e) + return None diff --git a/src/backend/sync.py b/src/backend/sync.py new file mode 100644 index 00000000..c3965261 --- /dev/null +++ b/src/backend/sync.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +# MIT License +# +# Copyright (c) 2025 CIT-Services +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import threading + +from bus import send +from common import write_sync_report_to_db +from config import get_all_accounts, update_last_synced_at +from logger import setup_logger +from odoo_client import OdooClient +from sync_from_odoo import sync_all_from_odoo +from sync_to_odoo import sync_all_to_odoo + +log = setup_logger() + +sync_lock = threading.Lock() +sync_in_progress = False + + +def sync(settings_db, account_id): + """ + Perform synchronous bidirectional sync between local database and Odoo. + """ + send("progress", 0) + write_sync_report_to_db( + settings_db, account_id, "In Progress", "Sync job triggered" + ) + accounts = get_all_accounts(settings_db) + selected = accounts[account_id] + client = OdooClient( + selected["link"], + selected["database"], + selected["username"], + selected["api_key"], + ) + send("progress", 20) + log.debug("Syncing from odoo from server: " + selected["link"]) + sync_all_from_odoo(client, selected["id"], settings_db, account_name=selected.get("name", "")) + + log.debug("Syncing to odoo") + send("progress", 50) + sync_all_to_odoo(client, selected["id"], settings_db) + + write_sync_report_to_db( + settings_db, account_id, "Successful", "Sync completed successfully" + ) + send("progress", 100) + return True + + +def sync_background(settings_db, account_id): + """ + Perform asynchronous bidirectional sync in a background thread. + """ + global sync_in_progress + + with sync_lock: + if sync_in_progress: + log.debug("[SYNC] Already in progress. Ignoring new request.") + return False + sync_in_progress = True + + def do_sync(): + global sync_in_progress + try: + send("sync_progress", 0) + log.debug(f"[SYNC] Starting background sync to {settings_db}...") + write_sync_report_to_db( + settings_db, account_id, "In Progress", "Sync job triggered" + ) + accounts = get_all_accounts(settings_db) + selected = next((acc for acc in accounts if acc["id"] == account_id), None) + send("sync_progress", 20) + + if not selected: + write_sync_report_to_db(settings_db, account_id, "Failed", "Account not found") + return + + log.debug(f"[SYNC] Found account: {selected['name']} (ID: {selected['id']})") + + send("sync_progress", 25) + client = OdooClient( + selected["link"], + selected["database"], + selected["username"], + selected["api_key"], + ) + send("sync_progress", 30) + log.debug("Syncing from odoo: ID Is " + selected["link"]) + sync_all_from_odoo(client, account_id, settings_db, account_name=selected.get("name", "")) + send("sync_progress", 50) + log.debug("Syncing to odoo") + sync_all_to_odoo(client, account_id, settings_db) + send("sync_progress", 90) + + log.debug("[SYNC] Background sync completed.") + write_sync_report_to_db( + settings_db, + account_id, + "Successful", + "Sync completed successfully", + ) + update_last_synced_at(settings_db, account_id) + send("sync_progress", 100) + send("sync_completed", True) + except Exception as e: + log.exception(f"[SYNC] Error during background sync: {e}") + write_sync_report_to_db(settings_db, account_id, "Failed", str(e)) + send("sync_completed", False) + finally: + with sync_lock: + sync_in_progress = False + + thread = threading.Thread(target=do_sync) + thread.start() + return True + + +def start_sync_in_background(settings_db, account_id): + """ + Start a background synchronization process. + """ + return sync_background(settings_db, account_id) diff --git a/src/backend/voice.py b/src/backend/voice.py new file mode 100644 index 00000000..b4bbacf5 --- /dev/null +++ b/src/backend/voice.py @@ -0,0 +1,747 @@ +# -*- coding: utf-8 -*- +# MIT License +# +# Copyright (c) 2025 CIT-Services +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import ctypes +import multiprocessing +import os +import shutil +import signal +import sys +import threading +import time +import zipfile +from pathlib import Path + +import urllib3 +from bus import send +from config import get_setting, set_setting +from logger import setup_logger + +from .storage import resolve_qml_db_path, resolve_settings_db_path + +log = setup_logger() + +root_dir = Path(__file__).resolve().parents[2] +if str(root_dir) not in sys.path: + sys.path.insert(0, str(root_dir)) + +voice_lib = root_dir / "voice_to_text" / "lib" +if voice_lib.exists() and str(voice_lib) not in sys.path: + sys.path.insert(0, str(voice_lib)) + +urllib3.disable_warnings() +http = urllib3.PoolManager(cert_reqs="CERT_NONE") + +download_status = { + "in_progress": False, + "progress": 0, + "message": "", + "error": "", +} + +_voice_stop_event = threading.Event() +_voice_download_cancel_event = threading.Event() +_voice_download_action = None + + +def stop_voice_recognition(): + """Signals the voice recognition thread to stop recording.""" + log.info("[VOICE] stop_voice_recognition called") + _voice_stop_event.set() + return True + + +def get_voice_models_dir(): + """ + Returns the writable directory for voice models. + On Ubuntu Touch: ~/.local/share/ubtms/voice_models + """ + data_home = os.environ.get("XDG_DATA_HOME") + if data_home: + base_dir = Path(data_home) / "ubtms" + else: + base_dir = Path.home() / ".local" / "share" / "ubtms" + + models_dir = base_dir / "voice_models" + models_dir.mkdir(parents=True, exist_ok=True) + return models_dir + + +def is_valid_vosk_model(model_path): + """ + Check whether a directory contains a supported Vosk model layout. + """ + try: + path = Path(model_path) + if not path.is_dir(): + return False + + has_am_graph = (path / "am").is_dir() and (path / "graph").is_dir() + has_flat_model = (path / "final.mdl").is_file() and ( + (path / "HCLG.fst").is_file() or (path / "HCLr.fst").is_file() + ) + return has_am_graph or has_flat_model + except Exception as e: + log.warning(f"[VOICE] Error validating model {model_path}: {e}") + return False + + +def list_available_models(): + """ + Returns a list of models available for download. + """ + models = [ + {"id": "vosk-model-small-en-us-0.15", "name": "English (US, Small)", "size": "40M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip"}, + {"id": "vosk-model-en-us-0.22", "name": "English (US)", "size": "1.8G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-0.22.zip"}, + {"id": "vosk-model-en-us-0.22-lgraph", "name": "English (US, LGraph)", "size": "128M", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-0.22-lgraph.zip"}, + {"id": "vosk-model-en-us-0.42-gigaspeech", "name": "English (US, Gigaspeech)", "size": "2.3G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-0.42-gigaspeech.zip"}, + {"id": "vosk-model-en-us-daanzu-20200905", "name": "English (US, Daanzu)", "size": "1.0G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-daanzu-20200905.zip"}, + {"id": "vosk-model-en-us-daanzu-20200905-lgraph", "name": "English (US, Daanzu LGraph)", "size": "129M", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-daanzu-20200905-lgraph.zip"}, + {"id": "vosk-model-en-us-librispeech-0.2", "name": "English (US, Librispeech)", "size": "845M", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-librispeech-0.2.zip"}, + {"id": "vosk-model-small-en-us-zamia-0.5", "name": "English (US, Zamia Small)", "size": "49M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-en-us-zamia-0.5.zip"}, + {"id": "vosk-model-en-us-aspire-0.2", "name": "English (US, Aspire)", "size": "1.4G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-aspire-0.2.zip"}, + {"id": "vosk-model-en-us-0.21", "name": "English (US, 0.21)", "size": "1.6G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-us-0.21.zip"}, + {"id": "vosk-model-en-in-0.5", "name": "English (Indian)", "size": "1G", "url": "https://alphacephei.com/vosk/models/vosk-model-en-in-0.5.zip"}, + {"id": "vosk-model-small-en-in-0.4", "name": "English (Indian, Small)", "size": "36M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-en-in-0.4.zip"}, + {"id": "vosk-model-small-cn-0.22", "name": "Chinese (Small)", "size": "42M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip"}, + {"id": "vosk-model-cn-0.22", "name": "Chinese", "size": "1.3G", "url": "https://alphacephei.com/vosk/models/vosk-model-cn-0.22.zip"}, + {"id": "vosk-model-cn-kaldi-multicn-0.15", "name": "Chinese (Multi-cn)", "size": "1.5G", "url": "https://alphacephei.com/vosk/models/vosk-model-cn-kaldi-multicn-0.15.zip"}, + {"id": "vosk-model-ru-0.42", "name": "Russian", "size": "1.8G", "url": "https://alphacephei.com/vosk/models/vosk-model-ru-0.42.zip"}, + {"id": "vosk-model-small-ru-0.22", "name": "Russian (Small)", "size": "45M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip"}, + {"id": "vosk-model-ru-0.22", "name": "Russian (0.22)", "size": "1.5G", "url": "https://alphacephei.com/vosk/models/vosk-model-ru-0.22.zip"}, + {"id": "vosk-model-ru-0.10", "name": "Russian (0.10)", "size": "2.5G", "url": "https://alphacephei.com/vosk/models/vosk-model-ru-0.10.zip"}, + {"id": "vosk-model-small-fr-0.22", "name": "French (Small)", "size": "41M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-fr-0.22.zip"}, + {"id": "vosk-model-fr-0.22", "name": "French", "size": "1.4G", "url": "https://alphacephei.com/vosk/models/vosk-model-fr-0.22.zip"}, + {"id": "vosk-model-small-fr-pguyot-0.3", "name": "French (pguyot Small)", "size": "39M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-fr-pguyot-0.3.zip"}, + {"id": "vosk-model-fr-0.6-linto-2.2.0", "name": "French (Linto)", "size": "1.5G", "url": "https://alphacephei.com/vosk/models/vosk-model-fr-0.6-linto-2.2.0.zip"}, + {"id": "vosk-model-de-0.21", "name": "German", "size": "1.9G", "url": "https://alphacephei.com/vosk/models/vosk-model-de-0.21.zip"}, + {"id": "vosk-model-de-tuda-0.6-900k", "name": "German (Tuda)", "size": "4.4G", "url": "https://alphacephei.com/vosk/models/vosk-model-de-tuda-0.6-900k.zip"}, + {"id": "vosk-model-small-de-0.15", "name": "German (Small)", "size": "45M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-de-0.15.zip"}, + {"id": "vosk-model-small-de-zamia-0.3", "name": "German (Zamia Small)", "size": "49M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-de-zamia-0.3.zip"}, + {"id": "vosk-model-es-0.42", "name": "Spanish", "size": "1.4G", "url": "https://alphacephei.com/vosk/models/vosk-model-es-0.42.zip"}, + {"id": "vosk-model-small-es-0.42", "name": "Spanish (Small)", "size": "39M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-es-0.42.zip"}, + {"id": "vosk-model-pt-fb-v0.1.1-20220516_2113", "name": "Portuguese", "size": "1.6G", "url": "https://alphacephei.com/vosk/models/vosk-model-pt-fb-v0.1.1-20220516_2113.zip"}, + {"id": "vosk-model-small-pt-0.3", "name": "Portuguese (Small)", "size": "31M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-pt-0.3.zip"}, + {"id": "vosk-model-small-tr-0.3", "name": "Turkish (Small)", "size": "35M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-tr-0.3.zip"}, + {"id": "vosk-model-small-vn-0.4", "name": "Vietnamese (Small)", "size": "32M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-vn-0.4.zip"}, + {"id": "vosk-model-vn-0.4", "name": "Vietnamese", "size": "1.4G", "url": "https://alphacephei.com/vosk/models/vosk-model-vn-0.4.zip"}, + {"id": "vosk-model-it-0.22", "name": "Italian", "size": "1.2G", "url": "https://alphacephei.com/vosk/models/vosk-model-it-0.22.zip"}, + {"id": "vosk-model-small-it-0.22", "name": "Italian (Small)", "size": "48M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-it-0.22.zip"}, + {"id": "vosk-model-small-nl-0.22", "name": "Dutch (Small)", "size": "39M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-nl-0.22.zip"}, + {"id": "vosk-model-nl-spraakherkenning-0.6", "name": "Dutch", "size": "860M", "url": "https://alphacephei.com/vosk/models/vosk-model-nl-spraakherkenning-0.6.zip"}, + {"id": "vosk-model-nl-spraakherkenning-0.6-lgraph", "name": "Dutch (LGraph)", "size": "100M", "url": "https://alphacephei.com/vosk/models/vosk-model-nl-spraakherkenning-0.6-lgraph.zip"}, + {"id": "vosk-model-small-ca-0.4", "name": "Catalan (Small)", "size": "42M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ca-0.4.zip"}, + {"id": "vosk-model-small-fa-0.4", "name": "Persian (Small)", "size": "47M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-fa-0.4.zip"}, + {"id": "vosk-model-small-fa-0.5", "name": "Persian (Small, 0.5)", "size": "60M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-fa-0.5.zip"}, + {"id": "vosk-model-fa-0.5", "name": "Persian", "size": "1.0G", "url": "https://alphacephei.com/vosk/models/vosk-model-fa-0.5.zip"}, + {"id": "vosk-model-small-uk-v3-nano", "name": "Ukrainian (Nano)", "size": "73M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-uk-v3-nano.zip"}, + {"id": "vosk-model-small-uk-v3-small", "name": "Ukrainian (Small)", "size": "133M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-uk-v3-small.zip"}, + {"id": "vosk-model-uk-v3-lgraph", "name": "Ukrainian (LGraph)", "size": "335M", "url": "https://alphacephei.com/vosk/models/vosk-model-uk-v3-lgraph.zip"}, + {"id": "vosk-model-uk-v3", "name": "Ukrainian", "size": "343M", "url": "https://alphacephei.com/vosk/models/vosk-model-uk-v3.zip"}, + {"id": "vosk-model-small-kz-0.15", "name": "Kazakh (Small)", "size": "42M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-kz-0.15.zip"}, + {"id": "vosk-model-kz-0.15", "name": "Kazakh", "size": "374M", "url": "https://alphacephei.com/vosk/models/vosk-model-kz-0.15.zip"}, + {"id": "vosk-model-small-sv-rhasspy-0.15", "name": "Swedish (Small)", "size": "260M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-sv-rhasspy-0.15.zip"}, + {"id": "vosk-model-small-ja-0.22", "name": "Japanese (Small)", "size": "48M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ja-0.22.zip"}, + {"id": "vosk-model-ja-0.22", "name": "Japanese", "size": "1.0G", "url": "https://alphacephei.com/vosk/models/vosk-model-ja-0.22.zip"}, + {"id": "vosk-model-small-eo-0.42", "name": "Esperanto (Small)", "size": "42M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-eo-0.42.zip"}, + {"id": "vosk-model-el-gr-0.7", "name": "Greek", "size": "1.4G", "url": "https://alphacephei.com/vosk/models/vosk-model-el-gr-0.7.zip"}, + {"id": "vosk-model-small-hi-0.22", "name": "Hindi (Small)", "size": "42M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-hi-0.22.zip"}, + {"id": "vosk-model-hi-0.22", "name": "Hindi", "size": "1.5G", "url": "https://alphacephei.com/vosk/models/vosk-model-hi-0.22.zip"}, + {"id": "vosk-model-small-cs-0.4-rhasspy", "name": "Czech (Small)", "size": "44M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-cs-0.4-rhasspy.zip"}, + {"id": "vosk-model-small-pl-0.22", "name": "Polish (Small)", "size": "51M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-pl-0.22.zip"}, + {"id": "vosk-model-small-uz-0.22", "name": "Uzbek (Small)", "size": "51M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-uz-0.22.zip"}, + {"id": "vosk-model-small-ko-0.22", "name": "Korean (Small)", "size": "70M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ko-0.22.zip"}, + {"id": "vosk-model-small-tg-0.22", "name": "Tajik (Small)", "size": "37M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-tg-0.22.zip"}, + {"id": "vosk-model-tg-0.22", "name": "Tajik", "size": "342M", "url": "https://alphacephei.com/vosk/models/vosk-model-tg-0.22.zip"}, + {"id": "vosk-model-small-gu-0.42", "name": "Gujarati (Small)", "size": "44M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-gu-0.42.zip"}, + {"id": "vosk-model-small-te-0.42", "name": "Telugu (Small)", "size": "44M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-te-0.42.zip"}, + {"id": "vosk-model-small-ka-0.42", "name": "Georgian (Small)", "size": "45M", "url": "https://alphacephei.com/vosk/models/vosk-model-small-ka-0.42.zip"}, + {"id": "vosk-model-ka-0.42", "name": "Georgian", "size": "700M", "url": "https://alphacephei.com/vosk/models/vosk-model-ka-0.42.zip"}, + ] + models.sort(key=lambda x: x["name"].lower()) + return models + + +def list_installed_models(): + app_models_dir = root_dir / "voice_to_text" + user_models_dir = get_voice_models_dir() + + search_paths = [ + (app_models_dir, "App"), + (user_models_dir, "User"), + (Path.home() / ".clickable" / "home" / ".local" / "share" / "ubtms" / "voice_models", "Clickable"), + ] + + models = [] + seen_names = set() + available_models = list_available_models() + known_names = {m["id"]: m["name"] for m in available_models if "id" in m} + known_names["model"] = "Indian English" + + for root_dir_to_scan, source_label in search_paths: + if not root_dir_to_scan.exists(): + continue + + for item in root_dir_to_scan.iterdir(): + if item.is_dir() and item.name not in seen_names: + if not is_valid_vosk_model(item): + continue + + if item.name in known_names: + model_name = known_names[item.name] + else: + model_name = item.name + readme_path = item / "README" + if readme_path.exists(): + try: + with open(readme_path, "r", encoding="utf-8") as f: + first_line = f.readline().strip() + if first_line: + clean_name = first_line + for phrase in [ + "for mobile Vosk applications", + "for Android and iOS", + "Vosk mobile model", + "Vosk model", + "Vosk", + "model", + ]: + clean_name = clean_name.replace(phrase, "").strip() + if clean_name: + model_name = clean_name + except Exception as e: + log.error(f"[VOICE] Error reading README for {item.name}: {e}") + + display_name = f"{model_name} (Default)" if source_label == "App" else model_name + + total_size = 0 + try: + for f in item.rglob("*"): + if f.is_file(): + total_size += f.stat().st_size + model_size = f"{total_size / (1024 * 1024):.1f} MB" + except Exception: + model_size = "Unknown" + + try: + rel_path = item.relative_to(root_dir) if source_label == "App" else item + models.append({ + "m_name": display_name, + "m_path": str(rel_path), + "m_size": model_size, + "m_source": source_label, + }) + except ValueError: + models.append({ + "m_name": display_name, + "m_path": str(item), + "m_size": model_size, + "m_source": source_label, + }) + + seen_names.add(item.name) + + models.sort(key=lambda x: x["m_name"].lower()) + log.info(f"[VOICE] Found {len(models)} installed models") + return models + + +def get_installed_voice_models(): + models_dir = get_voice_models_dir() + if not models_dir.exists(): + return [] + installed = [d.name for d in models_dir.iterdir() if d.is_dir()] + log.info(f"[VOICE] Found {len(installed)} installed models") + return installed + + +def get_device_total_ram_mb(): + try: + with open("/proc/meminfo", "r") as f: + for line in f: + if line.startswith("MemTotal:"): + return int(line.split()[1]) // 1024 + except Exception: + pass + + try: + pagesize = os.sysconf("SC_PAGE_SIZE") + pages = os.sysconf("SC_PHYS_PAGES") + return (pagesize * pages) // (1024 * 1024) + except Exception: + pass + return 2048 + + +def get_paused_voice_models(): + models_dir = get_voice_models_dir() + if not models_dir.exists(): + return [] + return [f.name.replace(".zip.tmp", "") for f in models_dir.glob("*.zip.tmp")] + + +def get_model_download_status(): + return download_status + + +def cancel_voice_model_download(): + global download_status, _voice_download_action + _voice_download_action = "cancel" + _voice_download_cancel_event.set() + download_status["in_progress"] = False + download_status["is_paused"] = False + download_status["message"] = "Download cancelled" + + models_dir = get_voice_models_dir() + if models_dir.exists(): + for f in models_dir.glob("*.zip.tmp"): + try: + f.unlink() + except Exception: + pass + + return {"status": "cancelled"} + + +def pause_voice_model_download(): + global download_status, _voice_download_action + _voice_download_action = "pause" + _voice_download_cancel_event.set() + download_status["in_progress"] = False + download_status["is_paused"] = True + download_status["message"] = "Download paused" + return {"status": "paused"} + + +def download_voice_model(model_id, url): + global download_status + _voice_download_cancel_event.clear() + if download_status["in_progress"]: + return {"status": "error", "message": "Download already in progress"} + + download_status = { + "in_progress": True, + "progress": download_status.get("progress", 0), + "message": "Starting download...", + "error": "", + "model_id": model_id, + "is_paused": False, + } + + def do_download(): + temp_zip = None + temp_extract_dir = None + try: + models_dir = get_voice_models_dir() + target_path = models_dir / model_id + + if target_path.exists(): + download_status["in_progress"] = False + download_status["message"] = "Model already installed" + download_status["progress"] = 100 + return + + temp_zip = models_dir / f"{model_id}.zip.tmp" + existing_size = temp_zip.stat().st_size if temp_zip.exists() else 0 + + headers = {} + if existing_size > 0: + headers["Range"] = f"bytes={existing_size}-" + log.info(f"[VOICE] Resuming {model_id} from {existing_size} bytes") + download_status["message"] = "Resuming..." + else: + log.info(f"[VOICE] Downloading model from {url}") + download_status["message"] = "Downloading..." + + response = http.request( + "GET", + url, + headers=headers, + preload_content=False, + timeout=urllib3.Timeout(connect=5.0, read=10.0), + ) + if response.status not in (200, 206, 416): + raise Exception(f"Server returned status {response.status}") + + if response.status == 200 and existing_size > 0: + log.info("[VOICE] Server ignored Range, restarting download") + existing_size = 0 + temp_zip.unlink() + + content_length = response.getheader("Content-Length") + total_size = (int(content_length) + existing_size) if content_length else None + + if total_size and existing_size == 0: + try: + _, _, free = shutil.disk_usage(str(models_dir)) + free_mb = free / (1024 * 1024) + required_mb = (total_size * 2.5) / (1024 * 1024) + if free < total_size * 2.5: + raise Exception(f"Insufficient storage. Have {free_mb:.1f} MB, need at least {required_mb:.1f} MB available.") + except Exception as disk_err: + if isinstance(disk_err, Exception) and "Insufficient storage" in str(disk_err): + raise disk_err + log.warning(f"[VOICE] Could not check disk space: {disk_err}") + + downloaded = existing_size + mode = "ab" if existing_size > 0 else "wb" + with open(temp_zip, mode) as f: + for chunk in response.stream(1024 * 64): + if _voice_download_cancel_event.is_set(): + action = _voice_download_action + if action == "pause": + raise Exception("PAUSED_BY_USER") + else: + raise Exception("CANCELLED_BY_USER") + f.write(chunk) + downloaded += len(chunk) + if total_size: + progress = int((downloaded / total_size) * 80) + download_status["progress"] = progress + send("download_progress", progress) + + download_status["message"] = "Extracting..." + send("download_message", "Extracting...") + download_status["progress"] = 85 + send("download_progress", 85) + + temp_extract_dir = models_dir / f"{model_id}.extract.tmp" + if temp_extract_dir.exists(): + shutil.rmtree(temp_extract_dir) + temp_extract_dir.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile(temp_zip) as z: + z.extractall(temp_extract_dir) + + model_folder = None + for root_walk, dirs, files in os.walk(temp_extract_dir): + if "am" in dirs and "graph" in dirs: + model_folder = Path(root_walk) + break + + if not model_folder: + subdirs = [d for d in temp_extract_dir.iterdir() if d.is_dir()] + model_folder = subdirs[0] if len(subdirs) == 1 else temp_extract_dir + + if target_path.exists(): + shutil.rmtree(target_path) + shutil.move(str(model_folder), str(target_path)) + + download_status["progress"] = 100 + download_status["message"] = "Installation complete" + download_status["in_progress"] = False + send("download_progress", 100) + send("download_completed", True) + log.info(f"[VOICE] Successfully installed model {model_id}") + + except Exception as e: + err_str = str(e) + log.exception(f"[VOICE] Download interrupted: {err_str}") + download_status["in_progress"] = False + + if err_str == "PAUSED_BY_USER": + download_status["is_paused"] = True + download_status["message"] = "Paused" + elif err_str == "CANCELLED_BY_USER": + download_status["is_paused"] = False + download_status["message"] = "Cancelled" + if temp_zip and temp_zip.exists(): + try: + temp_zip.unlink() + except Exception: + pass + else: + download_status["error"] = err_str + download_status["message"] = "Failed" + download_status["is_paused"] = True + send("download_error", err_str) + + finally: + if temp_extract_dir and temp_extract_dir.exists(): + try: + shutil.rmtree(temp_extract_dir) + except Exception: + pass + + threading.Thread(target=do_download, daemon=True).start() + return {"status": "started"} + + +def delete_voice_model(model_path): + try: + path = Path(model_path) + user_models_dir = get_voice_models_dir() + clickable_models_dir = ( + Path.home() / ".clickable" / "home" / + ".local" / "share" / "ubtms" / "voice_models" + ) + + if not path.is_absolute(): + potential_path = user_models_dir / model_path + if potential_path.exists(): + path = potential_path + else: + potential_path = clickable_models_dir / model_path + if potential_path.exists(): + path = potential_path + else: + potential_path = root_dir / model_path + if potential_path.exists(): + path = potential_path + + if not path.exists(): + return {"status": "error", "message": "Model path not found"} + + resolved_path = path.resolve() + resolved_user_dir = user_models_dir.resolve() + resolved_clickable_dir = clickable_models_dir.resolve() + + if ( + resolved_user_dir in resolved_path.parents + or resolved_clickable_dir in resolved_path.parents + ): + if resolved_path.is_dir(): + shutil.rmtree(resolved_path) + else: + resolved_path.unlink() + log.info(f"[VOICE] Deleted user model: {model_path}") + return {"status": "success"} + + app_models_dir = (root_dir / "voice_to_text").resolve() + if app_models_dir in resolved_path.parents or resolved_path == app_models_dir: + log.warning(f"[VOICE] Attempted to delete bundled model: {model_path}") + return {"status": "error", "message": "Cannot delete bundled system models"} + + return {"status": "error", "message": "Permission denied"} + except Exception as e: + log.exception(f"[VOICE] Failed to delete model {model_path}: {e}") + return {"status": "error", "message": str(e)} + + +def _subprocess_recognize(model_path, pipe, timeout): + def sigterm_handler(signum, frame): + raise SystemExit(0) + signal.signal(signal.SIGTERM, sigterm_handler) + + try: + from voice_to_text.voice2text import recognize_from_mic + + class PipeStopEvent: + def is_set(self): + if pipe.poll(): + msg = pipe.recv() + if msg == "stop": + return True + return False + + def _partial(txt): + pipe.send(("partial", txt)) + + def _status(txt): + pipe.send(("status", txt)) + + text, err = recognize_from_mic( + verbose=False, + stop_event=PipeStopEvent(), + timeout=timeout, + partial_callback=_partial, + status_callback=_status, + model_path=model_path, + ) + pipe.send(("final", text, err)) + except Exception as e: + pipe.send(("final", None, f"Subprocess Error: {e}")) + + +def run_voice_recognition(): + def do_recognition(): + try: + log.info("[VOICE] Starting offline voice recognition thread") + + db_path = resolve_settings_db_path("myDatabase") or resolve_qml_db_path() + + low_mem_mode = True + if db_path: + try: + low_mem_mode = get_setting(db_path, "voice_low_memory_mode", "true") == "true" + except Exception as e: + log.warning(f"[VOICE] Could not read low memory setting: {e}") + + active_model_rel_path = "" + if db_path: + active_model_rel_path = get_setting(db_path, "active_voice_model", "") + + model_path = None + if active_model_rel_path: + if not os.path.isabs(active_model_rel_path): + model_path = root_dir / active_model_rel_path + else: + model_path = Path(active_model_rel_path) + + if not model_path or not model_path.exists(): + installed_models = list_installed_models() + if installed_models: + active_model_rel_path = installed_models[0]["m_path"] + if not os.path.isabs(active_model_rel_path): + model_path = root_dir / active_model_rel_path + else: + model_path = Path(active_model_rel_path) + + if db_path: + try: + set_setting(db_path, "active_voice_model", active_model_rel_path) + except Exception as e: + log.warning(f"[VOICE] Could not auto-save active model to db: {e}") + else: + log.error("[VOICE] No language model downloaded") + send("voice_recognition_error", "No language model downloaded. Please download one in Voice Model Settings.") + return + + if model_path and model_path.exists(): + rescore_path = model_path / "rescore" + rescore_disabled_path = model_path / "rescore.disabled" + rnnlm_path = model_path / "rnnlm" + rnnlm_disabled_path = model_path / "rnnlm.disabled" + + if low_mem_mode: + if rescore_path.exists() and rescore_path.is_dir(): + try: + rescore_path.rename(rescore_disabled_path) + log.info("[VOICE] Disabled rescore folder for low memory mode") + except Exception as e: + log.warning(f"[VOICE] Could not rename rescore folder: {e}") + if rnnlm_path.exists() and rnnlm_path.is_dir(): + try: + rnnlm_path.rename(rnnlm_disabled_path) + log.info("[VOICE] Disabled rnnlm folder for low memory mode") + except Exception as e: + log.warning(f"[VOICE] Could not rename rnnlm folder: {e}") + else: + if rescore_disabled_path.exists() and rescore_disabled_path.is_dir(): + try: + rescore_disabled_path.rename(rescore_path) + log.info("[VOICE] Re-enabled rescore folder") + except Exception as e: + log.warning(f"[VOICE] Could not restore rescore folder: {e}") + if rnnlm_disabled_path.exists() and rnnlm_disabled_path.is_dir(): + try: + rnnlm_disabled_path.rename(rnnlm_path) + log.info("[VOICE] Re-enabled rnnlm folder") + except Exception as e: + log.warning(f"[VOICE] Could not restore rnnlm folder: {e}") + + try: + hclg_path = model_path / "graph" / "HCLG.fst" + if hclg_path.exists(): + hclg_size = hclg_path.stat().st_size + limit = 1.8 * 1024 * 1024 * 1024 if low_mem_mode else 1.0 * 1024 * 1024 * 1024 + if hclg_size > limit: + size_mb = hclg_size / (1024 * 1024) + if not low_mem_mode: + err_msg = f"The selected model's search graph ({size_mb:.0f} MB) is too large to run with rescoring enabled. Please enable 'Low Memory Mode' in voice settings to run it." + else: + err_msg = f"The selected model's search graph ({size_mb:.0f} MB) is too large even for Low Memory Mode. Please switch to the LGraph or Small version in settings." + log.error(f"[VOICE] {err_msg}") + send("voice_recognition_error", err_msg) + return + except Exception as e: + log.warning(f"[VOICE] Could not check model graph size: {e}") + + base_voice_path = Path(__file__).resolve().parents[2] / "voice_to_text" + lib_path = base_voice_path / "lib" + + atomic_lib = lib_path / "libatomic.so.1" + if atomic_lib.exists(): + try: + ctypes.CDLL(str(atomic_lib)) + log.info(f"[VOICE] Pre-loaded {atomic_lib}") + except Exception as e: + log.warning(f"[VOICE] Could not pre-load libatomic: {e}") + + env_path = str(lib_path) + if "LD_LIBRARY_PATH" in os.environ: + os.environ["LD_LIBRARY_PATH"] = f"{env_path}:{os.environ['LD_LIBRARY_PATH']}" + else: + os.environ["LD_LIBRARY_PATH"] = env_path + + from voice_to_text.voice2text import list_microphones + + _voice_stop_event.clear() + log.info(f"[VOICE] mics: {list_microphones()}") + + parent_conn, child_conn = multiprocessing.Pipe() + ctx = multiprocessing.get_context("spawn") + p = ctx.Process(target=_subprocess_recognize, args=(str(model_path), child_conn, 30)) + p.start() + + received_final = False + stop_sent_time = None + while p.is_alive(): + if _voice_stop_event.is_set(): + parent_conn.send("stop") + _voice_stop_event.clear() + if stop_sent_time is None: + stop_sent_time = time.time() + + if stop_sent_time is not None and (time.time() - stop_sent_time > 5.0): + log.warning("[VOICE] Watchdog triggered: Process failed to stop cleanly. Terminating.") + p.terminate() + break + + if parent_conn.poll(0.5): + msg = parent_conn.recv() + if msg[0] == "partial": + send("voice_recognition_partial", msg[1]) + elif msg[0] == "status": + send("voice_recognition_status", msg[1]) + elif msg[0] == "final": + received_final = True + text, error = msg[1], msg[2] + if text: + log.info(f"[VOICE] Recognized text: {text}") + send("voice_recognition_result", text) + else: + log.warning(f"[VOICE] Recognition failed: {error or 'No speech detected'}") + send("voice_recognition_error", error or "No speech detected") + break + + p.join(timeout=1) + if p.is_alive(): + p.terminate() + p.join() + + if not received_final: + exit_code = p.exitcode + if exit_code is not None and exit_code != 0: + err_msg = f"Voice recognition crashed unexpectedly (possibly Out of Memory). Exit code: {exit_code}" + log.error(f"[VOICE] {err_msg}") + send("voice_recognition_error", err_msg) + + except Exception as e: + log.exception(f"[VOICE] Error during voice recognition: {e}") + send("voice_recognition_error", f"System Error: {str(e)}") + + thread = threading.Thread(target=do_recognition) + thread.daemon = True + thread.start() + return True diff --git a/tests/python/README.md b/tests/python/README.md new file mode 100644 index 00000000..69be2691 --- /dev/null +++ b/tests/python/README.md @@ -0,0 +1,46 @@ +# Python Backend Unit Testing Guide + +This directory contains automated unit tests for the Python backend modules (`src/backend/`). + +## Running Tests + +To run the entire Python test suite: + +```bash +pytest tests/python +``` + +To run with verbose output: + +```bash +pytest tests/python -v +``` + +To run a specific test file: + +```bash +pytest tests/python/test_storage.py -v +``` + +To run a specific test case class or method: + +```bash +pytest tests/python/test_storage.py -k "TestSafeExtFor" +``` + +## Test Directory Structure + +``` +tests/ +└── python/ + ├── README.md # This guide + ├── conftest.py # pytest configuration and sys.path setup + └── test_storage.py # Unit tests for storage and attachment utilities +``` + +## Guidelines for New Backend Tests + +1. **Isolation**: Always use pytest fixtures (`tmp_path`, `monkeypatch`) to avoid side effects on the user's filesystem or real database files. +2. **Fast Execution**: Backend unit tests must run purely in memory or using isolated temporary directories without spawning network requests or live background daemons. +3. **AAA Pattern**: Follow the Arrange-Act-Assert structure in every test method. +4. **Naming**: Test files should be named `test_.py`, classes `Test`, and methods `test_`. diff --git a/tests/python/conftest.py b/tests/python/conftest.py new file mode 100644 index 00000000..9c688480 --- /dev/null +++ b/tests/python/conftest.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +SRC_DIR = REPO_ROOT / "src" + +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) diff --git a/tests/python/test_storage.py b/tests/python/test_storage.py new file mode 100644 index 00000000..f741e13d --- /dev/null +++ b/tests/python/test_storage.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- +import base64 +import hashlib +from pathlib import Path + +import pytest +from backend import storage + + +class TestSafeExtFor: + def test_known_mime_png(self): + assert storage._safe_ext_for("image/png") == ".png" + + def test_known_mime_pdf(self): + assert storage._safe_ext_for("application/pdf") == ".pdf" + + def test_known_mime_json(self): + assert storage._safe_ext_for("application/json") == ".json" + + def test_unknown_mime(self): + assert storage._safe_ext_for("application/x-custom-unknown") == "" + + def test_empty_mime(self): + assert storage._safe_ext_for("") == "" + + def test_none_mime(self): + assert storage._safe_ext_for(None) == "" + + +class TestExportPathFor: + def test_with_explicit_extension(self, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_app_data_dir", lambda: tmp_path) + out = storage._export_path_for("document.pdf", "application/pdf") + + assert out.name == "document.pdf" + assert out.parent == tmp_path / "ubtms" / "tmp" + assert out.parent.is_dir() + + def test_without_extension_inferred_from_mime(self, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_app_data_dir", lambda: tmp_path) + out = storage._export_path_for("avatar", "image/png") + + assert out.name == "avatar.png" + + def test_empty_or_none_name_defaults_to_attachment(self, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_app_data_dir", lambda: tmp_path) + out = storage._export_path_for(None, "application/pdf") + + assert out.name == "attachment.pdf" + + def test_sanitizes_slashes_and_whitespace(self, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_app_data_dir", lambda: tmp_path) + out = storage._export_path_for(" nested/path/to/notes.txt ", "text/plain") + + assert out.name == "nested_path_to_notes.txt" + + +class TestIsFilePresent: + def test_file_exists(self, tmp_path): + sample = tmp_path / "sample.txt" + sample.write_text("content") + + assert storage.is_file_present(sample) is True + + def test_file_missing(self, tmp_path): + sample = tmp_path / "missing.txt" + + assert storage.is_file_present(sample) is False + + def test_directory_returns_false(self, tmp_path): + sample_dir = tmp_path / "sub_dir" + sample_dir.mkdir() + + assert storage.is_file_present(sample_dir) is False + + +class TestExistingAttachmentPath: + def test_returns_path_when_present(self, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_app_data_dir", lambda: tmp_path) + export_file = tmp_path / "ubtms" / "tmp" / "invoice.pdf" + export_file.parent.mkdir(parents=True, exist_ok=True) + export_file.write_text("pdf content") + + result = storage.get_existing_attachment_path("invoice.pdf", "application/pdf") + + assert result == str(export_file) + + def test_returns_none_when_missing(self, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_app_data_dir", lambda: tmp_path) + + result = storage.get_existing_attachment_path("absent.pdf", "application/pdf") + + assert result is None + + +class TestIsAlreadyDownloaded: + def test_true_when_file_exists(self, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_app_data_dir", lambda: tmp_path) + export_file = tmp_path / "ubtms" / "tmp" / "image.png" + export_file.parent.mkdir(parents=True, exist_ok=True) + export_file.write_bytes(b"image bytes") + + assert storage.is_already_downloaded("image.png", "image/png") is True + + def test_false_when_file_does_not_exist(self, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_app_data_dir", lambda: tmp_path) + + assert storage.is_already_downloaded("image.png", "image/png") is False + + def test_handles_exception_gracefully(self, monkeypatch): + def raise_error(*args, **kwargs): + raise RuntimeError("Unexpected failure") + + monkeypatch.setattr(storage, "_export_path_for", raise_error) + + assert storage.is_already_downloaded("file.txt", "text/plain") is False + + +class TestEnsureExportFileFromBase64: + def test_successful_decode_and_write(self, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_app_data_dir", lambda: tmp_path) + raw_payload = b"ubtms test attachment payload" + b64_payload = base64.b64encode(raw_payload).decode("utf-8") + + result_path = storage.ensure_export_file_from_base64("data.bin", b64_payload, "application/octet-stream") + + assert result_path is not None + saved_file = Path(result_path) + assert saved_file.exists() + assert saved_file.read_bytes() == raw_payload + + def test_invalid_base64_returns_none(self, tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_app_data_dir", lambda: tmp_path) + + result = storage.ensure_export_file_from_base64("bad.bin", "!!!not_valid_b64!!!", "text/plain") + + assert result is None + + +class TestResolveSettingsDbPath: + def test_resolves_when_file_exists(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + db_name = "testSettingsDb" + db_hash = hashlib.md5(db_name.encode()).hexdigest() + db_dir = tmp_path / ".local" / "share" / "ubtms" / "Databases" + db_dir.mkdir(parents=True, exist_ok=True) + target_db = db_dir / f"{db_hash}.sqlite" + target_db.write_text("sqlite stub") + + resolved = storage.resolve_settings_db_path(db_name=db_name, app_id="ubtms") + + assert resolved == str(target_db) + + def test_returns_none_when_not_found(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + resolved = storage.resolve_settings_db_path(db_name="absentDb", app_id="ubtms") + + assert resolved is None + + +class TestResolveQmlDbPath: + def test_returns_latest_valid_db(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + db_dir = tmp_path / ".local" / "share" / "ubtms" / "Databases" + db_dir.mkdir(parents=True, exist_ok=True) + + db1 = db_dir / "db1.sqlite" + db1.write_text("older db") + db2 = db_dir / "db2.sqlite" + db2.write_text("newer db") + + # Ensure db2 has later mtime + db1_stat = db1.stat() + import os + os.utime(db2, (db1_stat.st_atime + 10, db1_stat.st_mtime + 10)) + + monkeypatch.setattr(storage, "check_table_exists", lambda path, table: table == "project_project_app") + + resolved = storage.resolve_qml_db_path(app_id="ubtms") + + assert resolved == str(db2) + + def test_returns_none_when_table_missing(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + db_dir = tmp_path / ".local" / "share" / "ubtms" / "Databases" + db_dir.mkdir(parents=True, exist_ok=True) + db = db_dir / "empty.sqlite" + db.write_text("empty") + + monkeypatch.setattr(storage, "check_table_exists", lambda path, table: False) + + resolved = storage.resolve_qml_db_path(app_id="ubtms") + + assert resolved is None + + def test_returns_none_when_no_directory_exists(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + resolved = storage.resolve_qml_db_path(app_id="ubtms") + + assert resolved is None From 6076373b5a66fed8b423620f416df704ec980db8 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 23 Sep 2026 23:05:05 +0530 Subject: [PATCH 03/34] Add unit test suites covering auth, voice, attachments, sync, and facade contract --- tests/python/README.md | 22 +++--- tests/python/test_attachments.py | 53 +++++++++++++ tests/python/test_auth.py | 114 ++++++++++++++++++++++++++++ tests/python/test_backend_facade.py | 64 ++++++++++++++++ tests/python/test_sync.py | 29 +++++++ tests/python/test_voice.py | 74 ++++++++++++++++++ 6 files changed, 347 insertions(+), 9 deletions(-) create mode 100644 tests/python/test_attachments.py create mode 100644 tests/python/test_auth.py create mode 100644 tests/python/test_backend_facade.py create mode 100644 tests/python/test_sync.py create mode 100644 tests/python/test_voice.py diff --git a/tests/python/README.md b/tests/python/README.md index 69be2691..6b1a4021 100644 --- a/tests/python/README.md +++ b/tests/python/README.md @@ -20,12 +20,11 @@ To run a specific test file: ```bash pytest tests/python/test_storage.py -v -``` - -To run a specific test case class or method: - -```bash -pytest tests/python/test_storage.py -k "TestSafeExtFor" +pytest tests/python/test_auth.py -v +pytest tests/python/test_voice.py -v +pytest tests/python/test_attachments.py -v +pytest tests/python/test_sync.py -v +pytest tests/python/test_backend_facade.py -v ``` ## Test Directory Structure @@ -33,9 +32,14 @@ pytest tests/python/test_storage.py -k "TestSafeExtFor" ``` tests/ └── python/ - ├── README.md # This guide - ├── conftest.py # pytest configuration and sys.path setup - └── test_storage.py # Unit tests for storage and attachment utilities + ├── README.md # This guide + ├── conftest.py # pytest configuration and sys.path setup + ├── test_attachments.py # Unit tests for attachment downloads and operations + ├── test_auth.py # Unit tests for reachability, db discovery, and auth + ├── test_backend_facade.py # Unit tests for QML API backward compatibility + ├── test_storage.py # Unit tests for storage and attachment path utilities + ├── test_sync.py # Unit tests for synchronization concurrency and locks + └── test_voice.py # Unit tests for voice models layout and signals ``` ## Guidelines for New Backend Tests diff --git a/tests/python/test_attachments.py b/tests/python/test_attachments.py new file mode 100644 index 00000000..36097d45 --- /dev/null +++ b/tests/python/test_attachments.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +from unittest.mock import MagicMock + +import pytest +from backend import attachments + + +class TestAttachmentOndemandDownload: + def test_account_not_found(self, monkeypatch): + monkeypatch.setattr(attachments, "get_all_accounts", lambda db: []) + + result = attachments.attachment_ondemand_download("fake_db.sqlite", 999, 123) + + assert result == {"success": False, "error": "Account not found"} + + def test_successful_download(self, monkeypatch): + fake_account = { + "id": 1, + "link": "https://odoo.example.com", + "database": "demo", + "username": "user", + "api_key": "key", + } + monkeypatch.setattr(attachments, "get_all_accounts", lambda db: [fake_account]) + + mock_client = MagicMock() + mock_client.ondemanddownload.return_value = {"file_path": "/tmp/test.pdf"} + monkeypatch.setattr(attachments, "OdooClient", lambda *args, **kwargs: mock_client) + + result = attachments.attachment_ondemand_download("fake_db.sqlite", 1, 123) + + assert result["success"] is True + assert result["file_path"] == "/tmp/test.pdf" + + def test_handles_client_exception(self, monkeypatch): + fake_account = { + "id": 1, + "link": "https://odoo.example.com", + "database": "demo", + "username": "user", + "api_key": "key", + } + monkeypatch.setattr(attachments, "get_all_accounts", lambda db: [fake_account]) + + def raise_download_error(*args, **kwargs): + raise ConnectionError("Server refused connection") + + monkeypatch.setattr(attachments, "OdooClient", raise_download_error) + + result = attachments.attachment_ondemand_download("fake_db.sqlite", 1, 123) + + assert result["success"] is False + assert "Server refused connection" in result["error"] diff --git a/tests/python/test_auth.py b/tests/python/test_auth.py new file mode 100644 index 00000000..b184dd1d --- /dev/null +++ b/tests/python/test_auth.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +from unittest.mock import MagicMock + +import pytest +from backend import auth + + +class TestCheckServerReachability: + def test_empty_or_none_url_returns_false(self): + assert auth.check_server_reachability("") is False + assert auth.check_server_reachability(None) is False + assert auth.check_server_reachability(" ") is False + + def test_head_success_returns_true(self, monkeypatch): + mock_response = MagicMock(status=200) + monkeypatch.setattr(auth.http, "request", MagicMock(return_value=mock_response)) + + result = auth.check_server_reachability("example.com") + assert result is True + + def test_head_fails_get_succeeds_returns_true(self, monkeypatch): + def mock_request(method, url, timeout, redirect): + if method == "HEAD": + raise RuntimeError("HEAD not supported") + return MagicMock(status=200) + + monkeypatch.setattr(auth.http, "request", mock_request) + + result = auth.check_server_reachability("https://example.com") + assert result is True + + def test_both_head_and_get_fail_returns_false(self, monkeypatch): + def mock_request(method, url, timeout, redirect): + raise ConnectionError("Host unreachable") + + monkeypatch.setattr(auth.http, "request", mock_request) + + result = auth.check_server_reachability("https://unreachable.local") + assert result is False + + +class TestGetDbList: + def test_successful_response(self, monkeypatch): + mock_response = MagicMock( + status=200, + data=b'{"result": ["production", "staging"]}', + ) + monkeypatch.setattr(auth.http, "request", MagicMock(return_value=mock_response)) + + databases = auth.get_db_list("https://demo.odoo.com") + assert databases == ["production", "staging"] + + def test_non_200_returns_empty_list(self, monkeypatch): + mock_response = MagicMock(status=500, data=b"Internal Error") + monkeypatch.setattr(auth.http, "request", MagicMock(return_value=mock_response)) + + databases = auth.get_db_list("https://demo.odoo.com") + assert databases == [] + + def test_network_exception_returns_empty_list(self, monkeypatch): + def mock_request(*args, **kwargs): + raise ConnectionError("Network down") + + monkeypatch.setattr(auth.http, "request", mock_request) + + databases = auth.get_db_list("https://demo.odoo.com") + assert databases == [] + + +class TestFetchDatabases: + def test_forwards_databases(self, monkeypatch): + monkeypatch.setattr(auth, "get_db_list", lambda url: ["db1", "db2"]) + + result = auth.fetch_databases("https://demo.odoo.com") + assert result == ["db1", "db2"] + + def test_returns_empty_when_no_dbs(self, monkeypatch): + monkeypatch.setattr(auth, "get_db_list", lambda url: []) + + result = auth.fetch_databases("https://demo.odoo.com") + assert result == [] + + +class TestLoginOdoo: + def test_successful_login(self, monkeypatch): + mock_common = MagicMock() + mock_common.authenticate.return_value = 42 + + mock_models = MagicMock() + mock_models.execute_kw.return_value = [{"name": "Jane Doe"}] + + def mock_server_proxy(url): + if "common" in url: + return mock_common + return mock_models + + monkeypatch.setattr(auth.xmlrpc.client, "ServerProxy", mock_server_proxy) + + response = auth.login_odoo("https://demo.odoo.com", "jane", "secret", "demo_db") + assert response == { + "status": "pass", + "name_of_user": "Jane Doe", + "database": "demo_db", + "uid": 42, + } + + def test_failed_authentication(self, monkeypatch): + mock_common = MagicMock() + mock_common.authenticate.return_value = False + + monkeypatch.setattr(auth.xmlrpc.client, "ServerProxy", lambda url: mock_common) + + response = auth.login_odoo("https://demo.odoo.com", "wrong", "wrong", "demo_db") + assert response == {"result": "fail"} diff --git a/tests/python/test_backend_facade.py b/tests/python/test_backend_facade.py new file mode 100644 index 00000000..4ce4a844 --- /dev/null +++ b/tests/python/test_backend_facade.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +import inspect +import pytest +import backend + + +def test_qml_exposed_functions_present(): + required_qml_calls = [ + "resolve_qml_db_path", + "attachment_upload", + "attachment_delete", + "get_existing_attachment_path", + "attachment_ondemand_download", + "ensure_export_file_from_base64", + "list_installed_models", + "list_available_models", + "download_voice_model", + "cancel_voice_model_download", + "pause_voice_model_download", + "get_device_total_ram_mb", + "get_paused_voice_models", + "get_model_download_status", + "delete_voice_model", + "cleanup_orphan_attachment_files", + "start_sync_in_background", + "login_odoo", + "sync", + "sync_background", + "check_server_reachability", + "fetch_databases", + "get_db_list", + "is_file_present", + "resolve_settings_db_path", + "run_voice_recognition", + "stop_voice_recognition", + ] + + for func_name in required_qml_calls: + assert hasattr(backend, func_name), f"backend is missing required function: {func_name}" + assert callable(getattr(backend, func_name)), f"backend.{func_name} is not callable" + + +def test_all_exports_exist(): + for name in backend.__all__: + assert hasattr(backend, name), f"backend.__all__ exports '{name}' but attribute is missing" + + +def test_submodules_importable(): + from backend import storage, auth, attachments, sync, voice + + assert storage is not None + assert auth is not None + assert attachments is not None + assert sync is not None + assert voice is not None + + +def test_facade_routing(): + # Calling through backend facade routes to the underlying submodule implementation + assert backend.is_file_present is backend.storage.is_file_present + assert backend.check_server_reachability is backend.auth.check_server_reachability + assert backend.attachment_upload is backend.attachments.attachment_upload + assert backend.sync is backend.sync_mod.sync if hasattr(backend, "sync_mod") else callable(backend.sync) + assert backend.stop_voice_recognition is backend.voice.stop_voice_recognition diff --git a/tests/python/test_sync.py b/tests/python/test_sync.py new file mode 100644 index 00000000..7a53bfc4 --- /dev/null +++ b/tests/python/test_sync.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +import sys +import pytest +import backend + +sync_module = sys.modules["backend.sync"] + + +class TestSyncLockAndConcurrency: + def test_sync_background_skips_when_already_in_progress(self, monkeypatch): + monkeypatch.setattr(sync_module, "sync_in_progress", True) + + result = sync_module.sync_background("test.sqlite", 1) + + assert result is False + + def test_start_sync_in_background_alias(self, monkeypatch): + called = [] + + def mock_sync_bg(db, acc_id): + called.append((db, acc_id)) + return True + + monkeypatch.setattr(sync_module, "sync_background", mock_sync_bg) + + result = sync_module.start_sync_in_background("test.sqlite", 42) + + assert result is True + assert called == [("test.sqlite", 42)] diff --git a/tests/python/test_voice.py b/tests/python/test_voice.py new file mode 100644 index 00000000..c8f54a5e --- /dev/null +++ b/tests/python/test_voice.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +from pathlib import Path + +import pytest +from backend import voice + + +class TestVoiceModelsDir: + def test_respects_xdg_data_home(self, tmp_path, monkeypatch): + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + models_dir = voice.get_voice_models_dir() + + assert models_dir == tmp_path / "ubtms" / "voice_models" + assert models_dir.is_dir() + + def test_falls_back_to_home(self, tmp_path, monkeypatch): + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + models_dir = voice.get_voice_models_dir() + + assert models_dir == tmp_path / ".local" / "share" / "ubtms" / "voice_models" + assert models_dir.is_dir() + + +class TestIsValidVoskModel: + def test_non_directory_returns_false(self, tmp_path): + sample_file = tmp_path / "regular_file.txt" + sample_file.write_text("text") + + assert voice.is_valid_vosk_model(sample_file) is False + + def test_empty_directory_returns_false(self, tmp_path): + empty_dir = tmp_path / "empty_model" + empty_dir.mkdir() + + assert voice.is_valid_vosk_model(empty_dir) is False + + def test_am_and_graph_structure_returns_true(self, tmp_path): + model_dir = tmp_path / "valid_vosk_am_graph" + (model_dir / "am").mkdir(parents=True) + (model_dir / "graph").mkdir(parents=True) + + assert voice.is_valid_vosk_model(model_dir) is True + + def test_flat_final_mdl_and_hclg_returns_true(self, tmp_path): + model_dir = tmp_path / "valid_vosk_flat" + model_dir.mkdir(parents=True) + (model_dir / "final.mdl").write_bytes(b"model data") + (model_dir / "HCLG.fst").write_bytes(b"fst data") + + assert voice.is_valid_vosk_model(model_dir) is True + + +class TestDeviceRam: + def test_returns_positive_integer_on_linux(self): + ram_mb = voice.get_device_total_ram_mb() + assert isinstance(ram_mb, int) + assert ram_mb > 0 + + +class TestModelDownloadStatus: + def test_returns_status_dictionary(self): + status = voice.get_model_download_status() + assert isinstance(status, dict) + assert "in_progress" in status + assert "progress" in status + assert "message" in status + assert "error" in status + + +class TestVoiceSignals: + def test_stop_voice_recognition(self): + result = voice.stop_voice_recognition() + assert result is True From 39efb13dae5bcf2a0edd6b32ca1741e8708faabe Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 24 Sep 2026 18:23:00 +0530 Subject: [PATCH 04/34] ci: add GitHub Actions workflow for backend unit tests - Configure automated test runner for pytest test suite under tests/python/ - Test across Python 3.10 and 3.12 matrices on ubuntu-22.04 runners - Trigger on push and pull requests targeting main, dev, release, and feature branches --- .github/workflows/tests.yml | 46 +++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..10719210 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,46 @@ +name: Tests + +on: + push: + branches: + - main + - dev + - 'release/**' + - 'refactor/**' + - 'fix/**' + pull_request: + branches: + - main + - dev + - 'release/**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend-unit-tests: + name: Python Unit Tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-22.04 + strategy: + matrix: + python-version: ['3.10', '3.12'] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest urllib3 requests + + - name: Run Backend Unit Tests + run: | + pytest tests/python -v From 2123edaffe140c10f129f58960c850b7086f0c55 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Fri, 25 Sep 2026 13:26:00 +0530 Subject: [PATCH 05/34] test(qml): add test suites for CustomDatePicker and GlobalTimerWidget and CI workflow - Add tests/qml/tst_customdatepicker.qml covering properties, mode switching, signal emission, and open reset - Add tests/qml/tst_globaltimerwidget.qml covering sync lifecycle, progress stages, messages, status resets, and timer signals - Add .github/workflows/qml-tests.yml for isolated headless QML testing on push and pull requests - Update tests/qml/README.md with the expanded test suite inventory --- .github/workflows/qml-tests.yml | 59 +++++++++ tests/qml/README.md | 6 +- tests/qml/tst_customdatepicker.qml | 92 ++++++++++++++ tests/qml/tst_globaltimerwidget.qml | 179 ++++++++++++++++++++++++++++ 4 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/qml-tests.yml create mode 100644 tests/qml/tst_customdatepicker.qml create mode 100644 tests/qml/tst_globaltimerwidget.qml diff --git a/.github/workflows/qml-tests.yml b/.github/workflows/qml-tests.yml new file mode 100644 index 00000000..dcf411c2 --- /dev/null +++ b/.github/workflows/qml-tests.yml @@ -0,0 +1,59 @@ +name: QML Tests + +on: + push: + branches: + - main + - dev + - 'release/**' + - 'refactor/**' + - 'fix/**' + paths: + - 'qml/**' + - 'tests/qml/**' + - 'models/**' + - '.github/workflows/qml-tests.yml' + pull_request: + branches: + - main + - dev + - 'release/**' + paths: + - 'qml/**' + - 'tests/qml/**' + - 'models/**' + - '.github/workflows/qml-tests.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + qml-component-tests: + name: QML Component Tests + runs-on: ubuntu-22.04 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Qt5 & Lomiri test dependencies + run: | + sudo apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + qtdeclarative5-dev-tools \ + qml-module-qttest \ + qml-module-lomiri-components \ + qml-module-lomiri-components-labs \ + qml-module-qtquick-controls2 \ + qml-module-qtquick-layouts \ + qml-module-qtquick-localstorage \ + suru-icon-theme \ + lomiri-ui-toolkit-theme \ + xvfb + echo "/usr/lib/qt5/bin" >> $GITHUB_PATH + + - name: Run QML Component Tests + run: | + xvfb-run -a qmltestrunner -input tests/qml diff --git a/tests/qml/README.md b/tests/qml/README.md index 6b2e7364..f547e76d 100644 --- a/tests/qml/README.md +++ b/tests/qml/README.md @@ -21,8 +21,10 @@ clickable test -- -functions test_openDisplaysMessageAndEmitsSignal ``` tests/ ├── qml/ -│ ├── README.md # This guide -│ └── tst_infobar.qml # Example test suite for InfoBar component +│ ├── README.md # This guide +│ ├── tst_infobar.qml # Test suite for InfoBar feedback component +│ ├── tst_customdatepicker.qml # Test suite for CustomDatePicker picker component +│ └── tst_globaltimerwidget.qml # Test suite for GlobalTimerWidget system component ``` Clickable automatically discovers all files matching `tst_*.qml` under `tests/qml/` as configured in `clickable.yaml`. diff --git a/tests/qml/tst_customdatepicker.qml b/tests/qml/tst_customdatepicker.qml new file mode 100644 index 00000000..99525bb4 --- /dev/null +++ b/tests/qml/tst_customdatepicker.qml @@ -0,0 +1,92 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import Lomiri.Components.Popups 1.3 +import "../../qml/components/pickers" + +MainView { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + CustomDatePicker { + id: testDatePicker + } + + SignalSpy { + id: dateSelectedSpy + target: testDatePicker + signalName: "dateSelected" + } + + TestCase { + name: "CustomDatePickerTests" + when: windowShown + + function init() { + testDatePicker.titleText = "Select Date"; + testDatePicker.selected_date = ""; + testDatePicker.mode = "next"; + testDatePicker.showCustomPicker = false; + testDatePicker.tempCustomDate = ""; + testDatePicker.currentDate = ""; + dateSelectedSpy.clear(); + } + + function test_initialProperties() { + compare(testDatePicker.titleText, "Select Date"); + compare(testDatePicker.selected_date, ""); + compare(testDatePicker.mode, "next"); + compare(testDatePicker.showCustomPicker, false); + compare(testDatePicker.tempCustomDate, ""); + compare(testDatePicker.currentDate, ""); + } + + function test_customTitleText() { + testDatePicker.titleText = "Schedule Follow-up"; + compare(testDatePicker.titleText, "Schedule Follow-up"); + } + + function test_modeSwitching() { + testDatePicker.mode = "previous"; + compare(testDatePicker.mode, "previous"); + + testDatePicker.mode = "next"; + compare(testDatePicker.mode, "next"); + } + + function test_selectedDatePropertyUpdate() { + testDatePicker.selected_date = "2026-09-25"; + compare(testDatePicker.selected_date, "2026-09-25"); + } + + function test_currentDatePropertyUpdate() { + testDatePicker.currentDate = "2026-09-20"; + compare(testDatePicker.currentDate, "2026-09-20"); + } + + function test_showCustomPickerToggle() { + testDatePicker.showCustomPicker = true; + compare(testDatePicker.showCustomPicker, true); + + testDatePicker.showCustomPicker = false; + compare(testDatePicker.showCustomPicker, false); + } + + function test_dateSelectedSignalEmission() { + var targetDate = "2026-10-15"; + testDatePicker.dateSelected(targetDate); + + compare(dateSelectedSpy.count, 1); + compare(dateSelectedSpy.signalArguments[0][0], targetDate); + } + + function test_openResetsCustomPickerState() { + testDatePicker.showCustomPicker = true; + compare(testDatePicker.showCustomPicker, true); + + testDatePicker.open(); + compare(testDatePicker.showCustomPicker, false); + } + } +} diff --git a/tests/qml/tst_globaltimerwidget.qml b/tests/qml/tst_globaltimerwidget.qml new file mode 100644 index 00000000..4f2553fc --- /dev/null +++ b/tests/qml/tst_globaltimerwidget.qml @@ -0,0 +1,179 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/system" + +Item { + width: units.gu(50) + height: units.gu(70) + + GlobalTimerWidget { + id: testTimer + } + + SignalSpy { + id: syncTimedOutSpy + target: testTimer + signalName: "syncTimedOut" + } + + SignalSpy { + id: timerStartedSpy + target: testTimer + signalName: "timerStarted" + } + + SignalSpy { + id: timerStoppedSpy + target: testTimer + signalName: "timerStopped" + } + + SignalSpy { + id: timerPausedSpy + target: testTimer + signalName: "timerPaused" + } + + SignalSpy { + id: timerResumedSpy + target: testTimer + signalName: "timerResumed" + } + + TestCase { + name: "GlobalTimerWidgetTests" + when: windowShown + + function init() { + testTimer.stopSync(); + syncTimedOutSpy.clear(); + timerStartedSpy.clear(); + timerStoppedSpy.clear(); + timerPausedSpy.clear(); + timerResumedSpy.clear(); + } + + function test_initialProperties() { + compare(testTimer.enableTimesheetTimer, true); + compare(testTimer.isTimerRunning, false); + compare(testTimer.isTimerPaused, false); + compare(testTimer.isSyncing, false); + compare(testTimer.syncProgress, 0.0); + compare(testTimer.syncAccountId, -1); + compare(testTimer.syncAccountName, ""); + compare(testTimer.syncSuccessful, false); + compare(testTimer.syncFailed, false); + compare(testTimer.syncStatusMessage, ""); + compare(testTimer.activeTitle, "/"); + compare(testTimer.activeTime, "00:00:00"); + } + + function test_startSyncExplicitAccountName() { + testTimer.startSync(101, "Production Account"); + + compare(testTimer.isSyncing, true); + compare(testTimer.syncAccountId, 101); + compare(testTimer.syncAccountName, "Production Account"); + compare(testTimer.syncProgress, 0.0); + compare(testTimer.syncSuccessful, false); + compare(testTimer.syncFailed, false); + compare(testTimer.syncStatusMessage, "Starting sync..."); + compare(testTimer.visible, true); + } + + function test_startSyncDefaultAccountNameFallback() { + testTimer.startSync(202, ""); + + compare(testTimer.isSyncing, true); + compare(testTimer.syncAccountId, 202); + compare(testTimer.syncAccountName, "Account 202"); + } + + function test_handleSyncProgressStages() { + testTimer.startSync(101, "Production Account"); + + testTimer.handleSyncEvent({ event: "sync_progress", payload: 20 }); + compare(testTimer.syncProgress, 0.20); + + testTimer.handleSyncEvent({ event: "sync_progress", payload: 45 }); + compare(testTimer.syncProgress, 0.45); + + testTimer.handleSyncEvent({ event: "sync_progress", payload: 85 }); + compare(testTimer.syncProgress, 0.85); + + testTimer.handleSyncEvent({ event: "sync_progress", payload: 95 }); + compare(testTimer.syncProgress, 0.95); + } + + function test_handleSyncMessageUpdatesStatus() { + testTimer.startSync(101, "Production Account"); + + testTimer.handleSyncEvent({ event: "sync_message", payload: "Pulling latest work items..." }); + compare(testTimer.syncStatusMessage, "Pulling latest work items..."); + } + + function test_handleSyncCompletedSuccess() { + testTimer.startSync(101, "Production Account"); + + testTimer.handleSyncEvent({ event: "sync_completed", payload: true }); + compare(testTimer.syncSuccessful, true); + compare(testTimer.syncFailed, false); + compare(testTimer.syncProgress, 1.0); + compare(testTimer.syncStatusMessage, testTimer.syncSuccessSubtitle); + } + + function test_handleSyncCompletedFailure() { + testTimer.startSync(101, "Production Account"); + + testTimer.handleSyncEvent({ event: "sync_completed", payload: false }); + compare(testTimer.syncSuccessful, false); + compare(testTimer.syncFailed, true); + compare(testTimer.syncStatusMessage, testTimer.defaultSyncFailedText); + } + + function test_handleSyncErrorCustomMessage() { + testTimer.startSync(101, "Production Account"); + + testTimer.handleSyncEvent({ event: "sync_error", payload: "Connection reset by peer" }); + compare(testTimer.syncFailed, true); + compare(testTimer.syncSuccessful, false); + verify(testTimer.syncStatusMessage.indexOf("Connection reset by peer") !== -1); + } + + function test_stopSyncResetsAllSyncState() { + testTimer.startSync(101, "Production Account"); + testTimer.handleSyncEvent({ event: "sync_progress", payload: 50 }); + + testTimer.stopSync(); + + compare(testTimer.isSyncing, false); + compare(testTimer.syncSuccessful, false); + compare(testTimer.syncFailed, false); + compare(testTimer.syncProgress, 0.0); + compare(testTimer.syncAccountId, -1); + compare(testTimer.syncAccountName, ""); + compare(testTimer.syncStatusMessage, ""); + compare(testTimer.visible, false); + } + + function test_syncTimedOutSignalEmission() { + testTimer.syncTimedOut(303); + + compare(syncTimedOutSpy.count, 1); + compare(syncTimedOutSpy.signalArguments[0][0], 303); + } + + function test_timerLifecycleSignalSpies() { + testTimer.timerStarted(); + testTimer.timerPaused(); + testTimer.timerResumed(); + testTimer.timerStopped(); + + compare(timerStartedSpy.count, 1); + compare(timerPausedSpy.count, 1); + compare(timerResumedSpy.count, 1); + compare(timerStoppedSpy.count, 1); + } + } +} From 20bfb59a972dc07e9b9fe7dd7e1436bc7a7f6be9 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Fri, 25 Sep 2026 13:33:50 +0530 Subject: [PATCH 06/34] ci(qml): use containerized Clickable test runner in GitHub Actions - Replace host apt-get Lomiri installation with Clickable containerized runner (clickable test --non-interactive --arch amd64) - Resolves missing Lomiri package errors on default Ubuntu 22.04 runners --- .github/workflows/qml-tests.yml | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/.github/workflows/qml-tests.yml b/.github/workflows/qml-tests.yml index dcf411c2..7cbddd75 100644 --- a/.github/workflows/qml-tests.yml +++ b/.github/workflows/qml-tests.yml @@ -38,22 +38,16 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Install Qt5 & Lomiri test dependencies + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install Clickable run: | - sudo apt-get update -qq - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - qtdeclarative5-dev-tools \ - qml-module-qttest \ - qml-module-lomiri-components \ - qml-module-lomiri-components-labs \ - qml-module-qtquick-controls2 \ - qml-module-qtquick-layouts \ - qml-module-qtquick-localstorage \ - suru-icon-theme \ - lomiri-ui-toolkit-theme \ - xvfb - echo "/usr/lib/qt5/bin" >> $GITHUB_PATH + python -m pip install --upgrade pip + pip install clickable-ut - name: Run QML Component Tests run: | - xvfb-run -a qmltestrunner -input tests/qml + clickable test --non-interactive --arch amd64 From 5c8b2648715d01b298b3b0bba0ae5dbbb556348c Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Fri, 25 Sep 2026 13:37:59 +0530 Subject: [PATCH 07/34] ci(qml): ensure Clickable target build directory exists before testing - Create build/x86_64-linux-gnu/app prior to clickable test - Satisfies Clickable's build dir validation on clean CI checkouts without triggering redundant native compilation --- .github/workflows/qml-tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/qml-tests.yml b/.github/workflows/qml-tests.yml index 7cbddd75..d2b18ddb 100644 --- a/.github/workflows/qml-tests.yml +++ b/.github/workflows/qml-tests.yml @@ -48,6 +48,9 @@ jobs: python -m pip install --upgrade pip pip install clickable-ut + - name: Prepare build directory + run: mkdir -p build/x86_64-linux-gnu/app + - name: Run QML Component Tests run: | clickable test --non-interactive --arch amd64 From 1cc43ab63335668841008acf1abfb8270b50d333 Mon Sep 17 00:00:00 2001 From: Parvathy Nair Date: Tue, 29 Sep 2026 12:40:38 +0530 Subject: [PATCH 08/34] Add TSSearchBar QML tests and update documentation --- tests/qml/README.md | 3 +- tests/qml/tst_tssearchbar.qml | 329 ++++++++++++++++++++++++++++++++++ 2 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 tests/qml/tst_tssearchbar.qml diff --git a/tests/qml/README.md b/tests/qml/README.md index f547e76d..5eb8d733 100644 --- a/tests/qml/README.md +++ b/tests/qml/README.md @@ -24,7 +24,8 @@ tests/ │ ├── README.md # This guide │ ├── tst_infobar.qml # Test suite for InfoBar feedback component │ ├── tst_customdatepicker.qml # Test suite for CustomDatePicker picker component -│ └── tst_globaltimerwidget.qml # Test suite for GlobalTimerWidget system component +│ ├── tst_globaltimerwidget.qml # Test suite for GlobalTimerWidget system component +│ └── tst_tssearchbar.qml # Test suite for TSSearchBar search input component ``` Clickable automatically discovers all files matching `tst_*.qml` under `tests/qml/` as configured in `clickable.yaml`. diff --git a/tests/qml/tst_tssearchbar.qml b/tests/qml/tst_tssearchbar.qml new file mode 100644 index 00000000..4de9e85d --- /dev/null +++ b/tests/qml/tst_tssearchbar.qml @@ -0,0 +1,329 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/base" + +Item { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + TSSearchBar { + id: testSearchBar + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + } + + SignalSpy { + id: acceptedSpy + target: testSearchBar + signalName: "accepted" + } + + SignalSpy { + id: clearedSpy + target: testSearchBar + signalName: "cleared" + } + + TestCase { + name: "TSSearchBarTests" + when: windowShown + + // Helper to find clear button Item inside testSearchBar + function findClearButton() { + var box = testSearchBar.children[0]; + for (var i = 0; i < box.children.length; ++i) { + var child = box.children[i]; + if (child.hasOwnProperty("cursorShape")) continue; // top MouseArea + // clearSearchButton has width 3.2gu and height 3.2gu + if (child.width === units.gu(3.2) && child.height === units.gu(3.2)) { + return child; + } + } + return null; + } + + // Helper to find clear MouseArea inside clearSearchButton + function findClearMouseArea() { + var clearBtn = findClearButton(); + if (!clearBtn) return null; + for (var i = 0; i < clearBtn.children.length; ++i) { + var child = clearBtn.children[i]; + if (child.cursorShape === Qt.PointingHandCursor) { + return child; + } + } + return null; + } + + // Helper to find TextInput searchField + function findSearchField() { + var box = testSearchBar.children[0]; + for (var i = 0; i < box.children.length; ++i) { + var child = box.children[i]; + if (child.hasOwnProperty("inputMethodHints")) { + return child; + } + } + return null; + } + + // Helper to find background MouseArea inside searchBarBox + function findBackgroundMouseArea() { + var box = testSearchBar.children[0]; + for (var i = 0; i < box.children.length; ++i) { + var child = box.children[i]; + if (child.cursorShape === Qt.IBeamCursor) { + return child; + } + } + return null; + } + + // Helper to find placeholder Text element + function findPlaceholderText() { + var box = testSearchBar.children[0]; + for (var i = 0; i < box.children.length; ++i) { + var child = box.children[i]; + if (child.hasOwnProperty("elide") && child.hasOwnProperty("text")) { + return child; + } + } + return null; + } + + function init() { + testRoot.forceActiveFocus(); + testSearchBar.clear(); + testSearchBar.text = ""; + testSearchBar.placeholderText = "Search..."; + testSearchBar.activeFocusOnPress = true; + testSearchBar.topPadding = units.gu(1.2); + testSearchBar.bottomPadding = units.gu(1.2); + testSearchBar.horizontalPadding = units.gu(1.5); + testSearchBar.capsuleRadius = units.gu(1.2); + acceptedSpy.clear(); + clearedSpy.clear(); + } + + //Initial State & Property Defaults + + function test_initialProperties() { + compare(testSearchBar.text, ""); + compare(testSearchBar.placeholderText, "Search..."); + compare(testSearchBar.clip, true); + compare(testSearchBar.isInputActive, false); + compare(testSearchBar.capsuleRadius, units.gu(1.2)); + compare(testSearchBar.topPadding, units.gu(1.2)); + compare(testSearchBar.bottomPadding, units.gu(1.2)); + compare(testSearchBar.horizontalPadding, units.gu(1.5)); + compare(testSearchBar.implicitWidth, units.gu(30)); + compare(testSearchBar.implicitHeight, testSearchBar.topPadding + units.gu(4.6) + testSearchBar.bottomPadding); + + var clearBtn = findClearButton(); + verify(clearBtn !== null, "Clear button should exist in component hierarchy"); + compare(clearBtn.visible, false); + } + + //Text Input Changes + + function test_textInputChanges() { + testSearchBar.text = "Sprint planning"; + compare(testSearchBar.text, "Sprint planning"); + + testSearchBar.text = "Bug fix #42"; + compare(testSearchBar.text, "Bug fix #42"); + + testSearchBar.placeholderText = "Filter by project name..."; + compare(testSearchBar.placeholderText, "Filter by project name..."); + } + + function test_textChangeToNonEmptyDoesNotEmitClearedSignal() { + clearedSpy.clear(); + testSearchBar.text = "Initial"; + compare(clearedSpy.count, 0); + + testSearchBar.text = "Updated"; + compare(clearedSpy.count, 0); + } + + function test_directTextClearTriggersClearedSignal() { + testSearchBar.text = "Temporary query"; + clearedSpy.clear(); + + testSearchBar.text = ""; + compare(testSearchBar.text, ""); + compare(clearedSpy.count, 1); + } + + function test_textInputFocus() { + var field = findSearchField(); + verify(field !== null, "TextInput field should exist"); + + field.forceActiveFocus(); + tryCompare(testSearchBar, "isInputActive", true, 500); + + compare(testSearchBar.activeFocusOnPress, true); + testSearchBar.activeFocusOnPress = false; + compare(testSearchBar.activeFocusOnPress, false); + testSearchBar.activeFocusOnPress = true; + } + + function test_forceActiveFocusFunction() { + testSearchBar.forceActiveFocus(); + tryCompare(testSearchBar, "isInputActive", true, 500); + } + + function test_clickBackgroundGivesFocus() { + var bgArea = findBackgroundMouseArea(); + verify(bgArea !== null, "Background MouseArea should exist"); + + mouseClick(bgArea); + tryCompare(testSearchBar, "isInputActive", true, 500); + } + + function test_placeholderVisibility() { + var placeholder = findPlaceholderText(); + verify(placeholder !== null, "Placeholder text element should exist"); + + // Empty text and no focus -> placeholder is visible + compare(placeholder.visible, true); + + // Active focus -> placeholder hides + testSearchBar.forceActiveFocus(); + tryCompare(testSearchBar, "isInputActive", true, 500); + compare(placeholder.visible, false); + + // Remove focus -> placeholder is visible again + testRoot.forceActiveFocus(); + tryCompare(testSearchBar, "isInputActive", false, 500); + compare(placeholder.visible, true); + + // Text entered without focus -> placeholder hides + testSearchBar.text = "Filter keyword"; + compare(placeholder.visible, false); + + // Text cleared -> placeholder is visible again + testSearchBar.clear(); + compare(placeholder.visible, true); + } + + //Search Trigger Signal (accepted) + + function test_searchTriggerSignalViaAccepted() { + testSearchBar.text = "Documentation update"; + var field = findSearchField(); + verify(field !== null); + + field.accepted(); + compare(acceptedSpy.count, 1); + compare(acceptedSpy.signalArguments[0][0], "Documentation update"); + } + + function test_searchTriggerSignalMultipleQueries() { + testSearchBar.accepted("First Query"); + compare(acceptedSpy.count, 1); + compare(acceptedSpy.signalArguments[0][0], "First Query"); + + testSearchBar.accepted("Second Query"); + compare(acceptedSpy.count, 2); + compare(acceptedSpy.signalArguments[1][0], "Second Query"); + } + + function test_searchTriggerSignalEmptyQuery() { + testSearchBar.text = ""; + var field = findSearchField(); + verify(field !== null); + + field.accepted(); + compare(acceptedSpy.count, 1); + compare(acceptedSpy.signalArguments[0][0], ""); + } + + function test_keyClickEnterTriggersAccepted() { + var field = findSearchField(); + verify(field !== null); + + field.forceActiveFocus(); + tryCompare(field, "activeFocus", true, 500); + + testSearchBar.text = "Keyboard query"; + keyClick(Qt.Key_Return); + + compare(acceptedSpy.count, 1); + compare(acceptedSpy.signalArguments[0][0], "Keyboard query"); + } + + //Clear Button Behavior + + function test_clearButtonVisibilityTrackingText() { + var clearBtn = findClearButton(); + verify(clearBtn !== null); + + compare(clearBtn.visible, false); + + testSearchBar.text = "Active search"; + compare(clearBtn.visible, true); + + testSearchBar.text = ""; + compare(clearBtn.visible, false); + } + + function test_clearFunctionResetsTextAndEmitsSignal() { + testSearchBar.text = "Query to reset"; + clearedSpy.clear(); + + var clearBtn = findClearButton(); + verify(clearBtn !== null); + compare(clearBtn.visible, true); + + testSearchBar.clear(); + compare(testSearchBar.text, ""); + compare(clearBtn.visible, false); + compare(clearedSpy.count, 1); + } + + function test_clearWhenAlreadyEmptyDoesNothing() { + testSearchBar.text = ""; + clearedSpy.clear(); + + testSearchBar.clear(); + compare(testSearchBar.text, ""); + compare(clearedSpy.count, 0); + } + + function test_clearButtonClickResetsTextAndEmitsSignal() { + testSearchBar.text = "Click to clear"; + clearedSpy.clear(); + + var clearBtn = findClearButton(); + verify(clearBtn !== null); + compare(clearBtn.visible, true); + + var clearArea = findClearMouseArea(); + verify(clearArea !== null); + + mouseClick(clearArea); + + compare(testSearchBar.text, ""); + compare(clearBtn.visible, false); + compare(clearedSpy.count, 1); + } + + //Dimensions & Padding Responsiveness + + function test_customDimensionsAndPadding() { + testSearchBar.topPadding = units.gu(2.0); + testSearchBar.bottomPadding = units.gu(2.5); + compare(testSearchBar.implicitHeight, units.gu(2.0) + units.gu(4.6) + units.gu(2.5)); + + testSearchBar.horizontalPadding = units.gu(3.0); + compare(testSearchBar.horizontalPadding, units.gu(3.0)); + + testSearchBar.capsuleRadius = units.gu(2.0); + compare(testSearchBar.capsuleRadius, units.gu(2.0)); + } + } +} From a6aca9a63810027e0f83874c29c5791149fccde2 Mon Sep 17 00:00:00 2001 From: Parvathy Nair Date: Tue, 29 Sep 2026 16:38:39 +0530 Subject: [PATCH 09/34] Add TSLabel.qml tests and update documentation --- tests/qml/README.md | 1 + tests/qml/tst_tslabel.qml | 240 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 tests/qml/tst_tslabel.qml diff --git a/tests/qml/README.md b/tests/qml/README.md index 5eb8d733..53c2f144 100644 --- a/tests/qml/README.md +++ b/tests/qml/README.md @@ -25,6 +25,7 @@ tests/ │ ├── tst_infobar.qml # Test suite for InfoBar feedback component │ ├── tst_customdatepicker.qml # Test suite for CustomDatePicker picker component │ ├── tst_globaltimerwidget.qml # Test suite for GlobalTimerWidget system component +│ ├── tst_tslabel.qml # Test suite for TSLabel styled text component │ └── tst_tssearchbar.qml # Test suite for TSSearchBar search input component ``` diff --git a/tests/qml/tst_tslabel.qml b/tests/qml/tst_tslabel.qml new file mode 100644 index 00000000..fdef2853 --- /dev/null +++ b/tests/qml/tst_tslabel.qml @@ -0,0 +1,240 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/base" + +Item { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + TSLabel { + id: testLabel + } + + TestCase { + name: "TSLabelTests" + when: windowShown + + // Helper to retrieve the inner Label component + function findInnerLabel() { + if (testLabel.children && testLabel.children.length > 0) { + return testLabel.children[0]; + } + return null; + } + + function init() { + testLabel.text = ""; + testLabel.wrapMode = Text.WordWrap; + testLabel.elide = Text.ElideRight; + testLabel.maximumLineCount = 2; + testLabel.color = "#000000"; + testLabel.fontBold = false; + testLabel.horizontalAlignment = Text.AlignLeft; + testLabel.verticalAlignment = Text.AlignVCenter; + } + + //Initial State & Property Defaults + + function test_initialProperties() { + compare(testLabel.text, ""); + compare(testLabel.wrapMode, Text.WordWrap); + compare(testLabel.elide, Text.ElideRight); + compare(testLabel.maximumLineCount, 2); + compare(testLabel.horizontalAlignment, Text.AlignLeft); + compare(testLabel.verticalAlignment, Text.AlignVCenter); + compare(testLabel.fontBold, false); + compare(testLabel.aspect, LomiriShape.Flat); + + var innerLabel = findInnerLabel(); + verify(innerLabel !== null, "Inner Label should exist"); + compare(innerLabel.wrapMode, Text.WordWrap); + compare(innerLabel.elide, Text.ElideRight); + compare(innerLabel.maximumLineCount, 2); + } + + //Text Wrapping Behavior + + function test_wrapModeWordWrap() { + testLabel.wrapMode = Text.WordWrap; + compare(testLabel.wrapMode, Text.WordWrap); + + var innerLabel = findInnerLabel(); + verify(innerLabel !== null); + compare(innerLabel.wrapMode, Text.WordWrap); + } + + function test_wrapModeNoWrap() { + testLabel.wrapMode = Text.NoWrap; + compare(testLabel.wrapMode, Text.NoWrap); + + var innerLabel = findInnerLabel(); + verify(innerLabel !== null); + compare(innerLabel.wrapMode, Text.NoWrap); + } + + function test_wrapModeWrapAnywhere() { + testLabel.wrapMode = Text.WrapAnywhere; + compare(testLabel.wrapMode, Text.WrapAnywhere); + + var innerLabel = findInnerLabel(); + verify(innerLabel !== null); + compare(innerLabel.wrapMode, Text.WrapAnywhere); + } + + function test_wrapModeWrap() { + testLabel.wrapMode = Text.Wrap; + compare(testLabel.wrapMode, Text.Wrap); + + var innerLabel = findInnerLabel(); + verify(innerLabel !== null); + compare(innerLabel.wrapMode, Text.Wrap); + } + + function test_maximumLineCount() { + compare(testLabel.maximumLineCount, 2); + + testLabel.maximumLineCount = 1; + compare(testLabel.maximumLineCount, 1); + + testLabel.maximumLineCount = 3; + compare(testLabel.maximumLineCount, 3); + + testLabel.maximumLineCount = 5; + compare(testLabel.maximumLineCount, 5); + + testLabel.maximumLineCount = 0; + compare(testLabel.maximumLineCount, 0); + + var innerLabel = findInnerLabel(); + verify(innerLabel !== null); + compare(innerLabel.maximumLineCount, 0); + } + + function test_elideModes() { + compare(testLabel.elide, Text.ElideRight); + + testLabel.elide = Text.ElideNone; + compare(testLabel.elide, Text.ElideNone); + + testLabel.elide = Text.ElideLeft; + compare(testLabel.elide, Text.ElideLeft); + + testLabel.elide = Text.ElideMiddle; + compare(testLabel.elide, Text.ElideMiddle); + + testLabel.elide = Text.ElideRight; + compare(testLabel.elide, Text.ElideRight); + + var innerLabel = findInnerLabel(); + verify(innerLabel !== null); + compare(innerLabel.elide, Text.ElideRight); + } + + function test_wrappingWithLongMultilineText() { + testLabel.width = units.gu(15); + testLabel.wrapMode = Text.WordWrap; + testLabel.maximumLineCount = 3; + testLabel.elide = Text.ElideRight; + testLabel.text = "First long line of text that needs to wrap properly across multiple boundaries in the UI"; + + compare(testLabel.wrapMode, Text.WordWrap); + compare(testLabel.maximumLineCount, 3); + compare(testLabel.elide, Text.ElideRight); + compare(testLabel.text, "First long line of text that needs to wrap properly across multiple boundaries in the UI"); + } + + //Text Color + + function test_textColorHex() { + testLabel.color = "#ff5722"; + compare(testLabel.color, "#ff5722"); + + testLabel.color = "#00ff00"; + compare(testLabel.color, "#00ff00"); + + testLabel.color = "#1e293b"; + compare(testLabel.color, "#1e293b"); + + var innerLabel = findInnerLabel(); + verify(innerLabel !== null); + compare(innerLabel.color, "#1e293b"); + } + + function test_textColorNamed() { + testLabel.color = "red"; + compare(testLabel.color, "#ff0000"); + + testLabel.color = "blue"; + compare(testLabel.color, "#0000ff"); + + testLabel.color = "white"; + compare(testLabel.color, "#ffffff"); + + testLabel.color = "transparent"; + compare(testLabel.color, "#00000000"); + } + + function test_textColorRgba() { + testLabel.color = Qt.rgba(0.5, 0.2, 0.8, 1.0); + compare(testLabel.color, "#8033cc"); + + testLabel.color = Qt.rgba(1.0, 0.0, 0.0, 0.5); + compare(testLabel.color, "#80ff0000"); + } + + //Text Content and Font Styling + + function test_textContent() { + testLabel.text = "Task Summary"; + compare(testLabel.text, "Task Summary"); + + testLabel.text = ""; + compare(testLabel.text, ""); + } + + function test_fontSize() { + testLabel.fontSize = units.gu(2.4); + compare(testLabel.fontSize, units.gu(2.4)); + + testLabel.fontSize = units.gu(1.6); + compare(testLabel.fontSize, units.gu(1.6)); + } + + function test_fontBold() { + compare(testLabel.fontBold, false); + + testLabel.fontBold = true; + compare(testLabel.fontBold, true); + + testLabel.fontBold = false; + compare(testLabel.fontBold, false); + } + + //Text Alignment + + function test_horizontalAlignment() { + compare(testLabel.horizontalAlignment, Text.AlignLeft); + + testLabel.horizontalAlignment = Text.AlignHCenter; + compare(testLabel.horizontalAlignment, Text.AlignHCenter); + + testLabel.horizontalAlignment = Text.AlignRight; + compare(testLabel.horizontalAlignment, Text.AlignRight); + + testLabel.horizontalAlignment = Text.AlignJustify; + compare(testLabel.horizontalAlignment, Text.AlignJustify); + } + + function test_verticalAlignment() { + compare(testLabel.verticalAlignment, Text.AlignVCenter); + + testLabel.verticalAlignment = Text.AlignTop; + compare(testLabel.verticalAlignment, Text.AlignTop); + + testLabel.verticalAlignment = Text.AlignBottom; + compare(testLabel.verticalAlignment, Text.AlignBottom); + } + } +} From 2aaba3f8d3f620d6255e69815558fbc889dfb105 Mon Sep 17 00:00:00 2001 From: Parvathy Nair Date: Wed, 30 Sep 2026 12:16:58 +0530 Subject: [PATCH 10/34] Add TSProgressbar.qml tests and update documentation --- tests/qml/README.md | 1 + tests/qml/tst_tsprogressbar.qml | 322 ++++++++++++++++++++++++++++++++ 2 files changed, 323 insertions(+) create mode 100644 tests/qml/tst_tsprogressbar.qml diff --git a/tests/qml/README.md b/tests/qml/README.md index 53c2f144..a058fd5d 100644 --- a/tests/qml/README.md +++ b/tests/qml/README.md @@ -26,6 +26,7 @@ tests/ │ ├── tst_customdatepicker.qml # Test suite for CustomDatePicker picker component │ ├── tst_globaltimerwidget.qml # Test suite for GlobalTimerWidget system component │ ├── tst_tslabel.qml # Test suite for TSLabel styled text component +│ ├── tst_tsprogressbar.qml # Test suite for TSProgressbar progress indicator │ └── tst_tssearchbar.qml # Test suite for TSSearchBar search input component ``` diff --git a/tests/qml/tst_tsprogressbar.qml b/tests/qml/tst_tsprogressbar.qml new file mode 100644 index 00000000..2f5b59a3 --- /dev/null +++ b/tests/qml/tst_tsprogressbar.qml @@ -0,0 +1,322 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/base" + +Item { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + TSProgressbar { + id: testProgressBar + width: testRoot.width + } + + TestCase { + name: "TSProgressbarTests" + when: windowShown + + // Helper to retrieve the background Rectangle + function findBackgroundRect() { + if (testProgressBar.children && testProgressBar.children.length > 0) { + return testProgressBar.children[0]; + } + return null; + } + + // Helper to retrieve the inner progress fill Rectangle + function findFillRect() { + var bg = findBackgroundRect(); + if (bg && bg.children && bg.children.length > 0) { + return bg.children[0]; + } + return null; + } + + function init() { + testProgressBar.width = testRoot.width; + testProgressBar.height = units.gu(1); + testProgressBar.minimumValue = 0; + testProgressBar.maximumValue = 100; + testProgressBar.value = 40; + } + + // ── Initial State & Property Defaults ── + + function test_initialProperties() { + compare(testProgressBar.minimumValue, 0); + compare(testProgressBar.maximumValue, 100); + compare(testProgressBar.value, 40); + compare(testProgressBar.visible, true); + compare(testProgressBar.height, units.gu(1)); + compare(testProgressBar.width, testRoot.width); + } + + function test_initialVisualHierarchy() { + var bgRect = findBackgroundRect(); + verify(bgRect !== null, "Background Rectangle should exist"); + compare(bgRect.radius, testProgressBar.height / 2); + + var fillRect = findFillRect(); + verify(fillRect !== null, "Fill Rectangle should exist"); + compare(fillRect.color, LomiriColors.orange); + compare(fillRect.height, testProgressBar.height); + compare(fillRect.radius, testProgressBar.height / 2); + + // Initial width: (40 - 0) / (100 - 0) * testProgressBar.width = 0.4 * width + verify(Math.abs(fillRect.width - 0.4 * testProgressBar.width) < 0.001, + "Initial fill width should be 40% of parent width"); + } + + // ── Progress Value Bounds (0 - 100) ── + + function test_progressValueAtLowerBoundZero() { + testProgressBar.value = 0; + compare(testProgressBar.value, 0); + + var fillRect = findFillRect(); + verify(fillRect !== null); + compare(fillRect.width, 0); + } + + function test_progressValueAtUpperBoundHundred() { + testProgressBar.value = 100; + compare(testProgressBar.value, 100); + + var fillRect = findFillRect(); + verify(fillRect !== null); + compare(fillRect.width, testProgressBar.width); + } + + function test_progressValueMidRange() { + var fillRect = findFillRect(); + verify(fillRect !== null); + + testProgressBar.value = 25; + compare(testProgressBar.value, 25); + verify(Math.abs(fillRect.width - 0.25 * testProgressBar.width) < 0.001); + + testProgressBar.value = 50; + compare(testProgressBar.value, 50); + verify(Math.abs(fillRect.width - 0.5 * testProgressBar.width) < 0.001); + + testProgressBar.value = 75; + compare(testProgressBar.value, 75); + verify(Math.abs(fillRect.width - 0.75 * testProgressBar.width) < 0.001); + } + + function test_progressValueBelowLowerBoundClamped() { + // Negative values below minimumValue (0) must clamp to width 0 via Math.max(0, ...) + testProgressBar.value = -10; + compare(testProgressBar.value, -10); + + var fillRect = findFillRect(); + verify(fillRect !== null); + compare(fillRect.width, 0); + + testProgressBar.value = -100; + compare(fillRect.width, 0); + } + + function test_progressValueDecimalPoints() { + var fillRect = findFillRect(); + verify(fillRect !== null); + + testProgressBar.value = 12.5; + compare(testProgressBar.value, 12.5); + verify(Math.abs(fillRect.width - 0.125 * testProgressBar.width) < 0.001); + + testProgressBar.value = 99.5; + compare(testProgressBar.value, 99.5); + verify(Math.abs(fillRect.width - 0.995 * testProgressBar.width) < 0.001); + } + + // ── setValue() Method & Bound Enforcement ── + + function test_setValueNormalRange() { + var fillRect = findFillRect(); + verify(fillRect !== null); + + testProgressBar.setValue(50, 100); + compare(testProgressBar.value, 50); + compare(testProgressBar.maximumValue, 100); + verify(Math.abs(fillRect.width - 0.5 * testProgressBar.width) < 0.001); + + testProgressBar.setValue(20, 100); + compare(testProgressBar.value, 20); + compare(testProgressBar.maximumValue, 100); + verify(Math.abs(fillRect.width - 0.2 * testProgressBar.width) < 0.001); + } + + function test_setValueAtLowerBoundZero() { + testProgressBar.setValue(0, 100); + compare(testProgressBar.value, 0); + compare(testProgressBar.maximumValue, 100); + + var fillRect = findFillRect(); + verify(fillRect !== null); + compare(fillRect.width, 0); + } + + function test_setValueAtUpperBoundHundred() { + testProgressBar.setValue(100, 100); + compare(testProgressBar.value, 100); + compare(testProgressBar.maximumValue, 100); + + var fillRect = findFillRect(); + verify(fillRect !== null); + compare(fillRect.width, testProgressBar.width); + } + + function test_setValueCapsAtMaximumWhenExceeded() { + // setValue uses Math.min(newValue, maximumValue), capping values > max + testProgressBar.setValue(150, 100); + compare(testProgressBar.value, 100); + compare(testProgressBar.maximumValue, 100); + + var fillRect = findFillRect(); + verify(fillRect !== null); + compare(fillRect.width, testProgressBar.width); + + testProgressBar.setValue(500, 100); + compare(testProgressBar.value, 100); + compare(fillRect.width, testProgressBar.width); + } + + function test_setValueWithCustomMax() { + var fillRect = findFillRect(); + verify(fillRect !== null); + + testProgressBar.setValue(25, 50); + compare(testProgressBar.value, 25); + compare(testProgressBar.maximumValue, 50); + verify(Math.abs(fillRect.width - 0.5 * testProgressBar.width) < 0.001); + + testProgressBar.setValue(50, 50); + compare(testProgressBar.value, 50); + compare(testProgressBar.maximumValue, 50); + compare(fillRect.width, testProgressBar.width); + + // Exceeding custom max is capped + testProgressBar.setValue(80, 50); + compare(testProgressBar.value, 50); + compare(testProgressBar.maximumValue, 50); + compare(fillRect.width, testProgressBar.width); + } + + function test_setValueWithInvalidNewValueNaN() { + testProgressBar.value = 75; + testProgressBar.maximumValue = 100; + + testProgressBar.setValue(NaN, 100); + compare(testProgressBar.value, 0); + compare(testProgressBar.maximumValue, 100); + + var fillRect = findFillRect(); + verify(fillRect !== null); + compare(fillRect.width, 0); + } + + function test_setValueWithInvalidNewMaxNaN() { + testProgressBar.value = 75; + testProgressBar.maximumValue = 100; + + testProgressBar.setValue(50, NaN); + compare(testProgressBar.value, 0); + compare(testProgressBar.maximumValue, 100); + + var fillRect = findFillRect(); + verify(fillRect !== null); + compare(fillRect.width, 0); + } + + function test_setValueWithZeroOrNegativeMax() { + testProgressBar.value = 75; + testProgressBar.maximumValue = 100; + + // newMax <= 0 should reset value to 0 and maximumValue to 100 + testProgressBar.setValue(50, 0); + compare(testProgressBar.value, 0); + compare(testProgressBar.maximumValue, 100); + + testProgressBar.setValue(50, -20); + compare(testProgressBar.value, 0); + compare(testProgressBar.maximumValue, 100); + } + + // ── Visibility Logic (maximumValue > 0) ── + + function test_visibilityWhenMaxPositive() { + testProgressBar.maximumValue = 100; + compare(testProgressBar.visible, true); + + testProgressBar.maximumValue = 1; + compare(testProgressBar.visible, true); + + testProgressBar.maximumValue = 50; + compare(testProgressBar.visible, true); + } + + function test_visibilityWhenMaxZeroOrNegative() { + testProgressBar.maximumValue = 0; + compare(testProgressBar.visible, false); + + testProgressBar.maximumValue = -10; + compare(testProgressBar.visible, false); + + // Re-enabling with positive max restores visibility + testProgressBar.maximumValue = 100; + compare(testProgressBar.visible, true); + } + + // ── Custom Minimum and Maximum Range ── + + function test_customMinMaxRange() { + testProgressBar.minimumValue = 50; + testProgressBar.maximumValue = 150; + + var fillRect = findFillRect(); + verify(fillRect !== null); + + // Value at custom minimum (50) -> 0% fill + testProgressBar.value = 50; + compare(fillRect.width, 0); + + // Value midway (100) -> 50% fill: (100 - 50) / (150 - 50) = 50 / 100 = 0.5 + testProgressBar.value = 100; + verify(Math.abs(fillRect.width - 0.5 * testProgressBar.width) < 0.001); + + // Value at custom maximum (150) -> 100% fill + testProgressBar.value = 150; + compare(fillRect.width, testProgressBar.width); + } + + // ── Responsive Dimensions & Scaling ── + + function test_responsiveWidthScaling() { + var fillRect = findFillRect(); + verify(fillRect !== null); + + testProgressBar.value = 50; + + testProgressBar.width = units.gu(20); + verify(Math.abs(fillRect.width - units.gu(10)) < 0.001); + + testProgressBar.width = units.gu(30); + verify(Math.abs(fillRect.width - units.gu(15)) < 0.001); + } + + function test_heightAndRadiusChanges() { + var bgRect = findBackgroundRect(); + var fillRect = findFillRect(); + verify(bgRect !== null); + verify(fillRect !== null); + + testProgressBar.height = units.gu(2); + compare(bgRect.radius, units.gu(1)); + compare(fillRect.height, units.gu(2)); + compare(fillRect.radius, units.gu(1)); + } + } +} From 57642e6d91e9f3211a276be2dc3ddffafcf09d04 Mon Sep 17 00:00:00 2001 From: Parvathy Nair Date: Wed, 30 Sep 2026 12:55:50 +0530 Subject: [PATCH 11/34] [EDITED] TSLabel.qml & TSSearchbar tests --- tests/qml/tst_tslabel.qml | 8 +++++++- tests/qml/tst_tssearchbar.qml | 12 ++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/qml/tst_tslabel.qml b/tests/qml/tst_tslabel.qml index fdef2853..4204316b 100644 --- a/tests/qml/tst_tslabel.qml +++ b/tests/qml/tst_tslabel.qml @@ -12,6 +12,8 @@ Item { id: testLabel } + property int defaultFontSize: testLabel.fontSize + TestCase { name: "TSLabelTests" when: windowShown @@ -24,7 +26,7 @@ Item { return null; } - function init() { + function cleanup() { testLabel.text = ""; testLabel.wrapMode = Text.WordWrap; testLabel.elide = Text.ElideRight; @@ -33,6 +35,10 @@ Item { testLabel.fontBold = false; testLabel.horizontalAlignment = Text.AlignLeft; testLabel.verticalAlignment = Text.AlignVCenter; + testLabel.width = undefined; + if (defaultFontSize > 0) { + testLabel.fontSize = defaultFontSize; + } } //Initial State & Property Defaults diff --git a/tests/qml/tst_tssearchbar.qml b/tests/qml/tst_tssearchbar.qml index 4de9e85d..8c03f26d 100644 --- a/tests/qml/tst_tssearchbar.qml +++ b/tests/qml/tst_tssearchbar.qml @@ -223,11 +223,19 @@ Item { } function test_searchTriggerSignalMultipleQueries() { - testSearchBar.accepted("First Query"); + var field = findSearchField(); + verify(field !== null, "TextInput field should exist"); + + field.forceActiveFocus(); + tryCompare(field, "activeFocus", true, 500); + + testSearchBar.text = "First Query"; + keyClick(Qt.Key_Return); compare(acceptedSpy.count, 1); compare(acceptedSpy.signalArguments[0][0], "First Query"); - testSearchBar.accepted("Second Query"); + testSearchBar.text = "Second Query"; + keyClick(Qt.Key_Return); compare(acceptedSpy.count, 2); compare(acceptedSpy.signalArguments[1][0], "Second Query"); } From 03e84eed1c23534ec47928907899684062694899 Mon Sep 17 00:00:00 2001 From: Parvathy Nair Date: Wed, 30 Sep 2026 14:43:06 +0530 Subject: [PATCH 12/34] [ADDED] tst_outlinedtextfield.qml test script and updated documentation --- tests/qml/README.md | 1 + tests/qml/tst_outlinedtextfield.qml | 382 ++++++++++++++++++++++++++++ 2 files changed, 383 insertions(+) create mode 100644 tests/qml/tst_outlinedtextfield.qml diff --git a/tests/qml/README.md b/tests/qml/README.md index a058fd5d..4b825888 100644 --- a/tests/qml/README.md +++ b/tests/qml/README.md @@ -25,6 +25,7 @@ tests/ │ ├── tst_infobar.qml # Test suite for InfoBar feedback component │ ├── tst_customdatepicker.qml # Test suite for CustomDatePicker picker component │ ├── tst_globaltimerwidget.qml # Test suite for GlobalTimerWidget system component +│ ├── tst_outlinedtextfield.qml # Test suite for OutlinedTextField text input component │ ├── tst_tslabel.qml # Test suite for TSLabel styled text component │ ├── tst_tsprogressbar.qml # Test suite for TSProgressbar progress indicator │ └── tst_tssearchbar.qml # Test suite for TSSearchBar search input component diff --git a/tests/qml/tst_outlinedtextfield.qml b/tests/qml/tst_outlinedtextfield.qml new file mode 100644 index 00000000..12bc781f --- /dev/null +++ b/tests/qml/tst_outlinedtextfield.qml @@ -0,0 +1,382 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/base" + +Item { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + OutlinedTextField { + id: testField + width: testRoot.width + } + + SignalSpy { + id: acceptedSpy + target: testField + signalName: "accepted" + } + + TestCase { + name: "OutlinedTextFieldTests" + when: windowShown + + // Helper to retrieve the borderRect (first Rectangle child) + function findBorderRect() { + if (testField.children && testField.children.length > 0) { + return testField.children[0]; + } + return null; + } + + // Helper to retrieve inputField (TextInput inside borderRect) + function findInputField() { + var border = findBorderRect(); + if (border) { + for (var i = 0; i < border.children.length; i++) { + if (border.children[i] instanceof TextInput) { + return border.children[i]; + } + } + } + return null; + } + + // Helper to retrieve placeholder Text element inside borderRect + function findPlaceholderText() { + var border = findBorderRect(); + if (border) { + for (var i = 0; i < border.children.length; i++) { + if (border.children[i] instanceof Text) { + return border.children[i]; + } + } + } + return null; + } + + // Helper to retrieve the floating label container (second Rectangle child) + function findLabelContainer() { + if (testField.children && testField.children.length > 1) { + return testField.children[1]; + } + return null; + } + + // Helper to retrieve the inner label Text element + function findLabelText() { + var container = findLabelContainer(); + if (container && container.children && container.children.length > 0) { + return container.children[0]; + } + return null; + } + + function cleanup() { + testRoot.forceActiveFocus(); + testField.text = ""; + testField.placeholderText = ""; + testField.labelText = ""; + testField.echoMode = TextInput.Normal; + testField.enabled = true; + testField.readOnly = false; + testField.maximumLength = 32767; + acceptedSpy.clear(); + } + + // ── Initial State & Property Defaults ── + + function test_initialProperties() { + compare(testField.text, ""); + compare(testField.placeholderText, ""); + compare(testField.labelText, ""); + compare(testField.echoMode, TextInput.Normal); + compare(testField.enabled, true); + compare(testField.readOnly, false); + compare(testField.height, units.gu(6.5)); + compare(testField.width, testRoot.width); + } + + function test_initialVisualHierarchy() { + var borderRect = findBorderRect(); + verify(borderRect !== null, "Border Rectangle should exist"); + compare(borderRect.radius, units.gu(0.8)); + compare(borderRect.border.width, 1); + compare(borderRect.clip, true); + + var field = findInputField(); + verify(field !== null, "TextInput should exist inside borderRect"); + compare(field.text, ""); + compare(field.activeFocus, false); + compare(field.selectByMouse, true); + + var placeholder = findPlaceholderText(); + verify(placeholder !== null, "Placeholder Text should exist"); + compare(placeholder.text, ""); + compare(placeholder.visible, true); + + var labelContainer = findLabelContainer(); + verify(labelContainer !== null, "Label container should exist"); + compare(labelContainer.visible, false); + } + + // ── Focus State Management ── + + function test_focusStateTogglesBorderWidthAndColor() { + var borderRect = findBorderRect(); + var field = findInputField(); + verify(borderRect !== null); + verify(field !== null); + + // Unfocused initial state + compare(field.activeFocus, false); + compare(borderRect.border.width, 1); + verify(borderRect.border.color !== LomiriColors.blue, + "Unfocused border color should not be blue"); + + // Acquire focus + field.forceActiveFocus(); + tryCompare(field, "activeFocus", true, 500); + compare(borderRect.border.width, 2); + compare(borderRect.border.color, LomiriColors.blue); + + // Lose focus + testRoot.forceActiveFocus(); + tryCompare(field, "activeFocus", false, 500); + compare(borderRect.border.width, 1); + verify(borderRect.border.color !== LomiriColors.blue); + } + + function test_labelColorUpdatesOnFocus() { + testField.labelText = "Account Name"; + var labelText = findLabelText(); + var field = findInputField(); + verify(labelText !== null); + verify(field !== null); + + // Unfocused label color + verify(labelText.color !== LomiriColors.blue); + + // Focused label color switches to blue + field.forceActiveFocus(); + tryCompare(field, "activeFocus", true, 500); + compare(labelText.color, LomiriColors.blue); + + // Unfocusing restores muted label color + testRoot.forceActiveFocus(); + tryCompare(field, "activeFocus", false, 500); + verify(labelText.color !== LomiriColors.blue); + } + + // ── Placeholder Visibility Dynamics ── + + function test_placeholderTextBinding() { + testField.placeholderText = "Enter server URL..."; + var placeholder = findPlaceholderText(); + verify(placeholder !== null); + compare(placeholder.text, "Enter server URL..."); + + testField.placeholderText = "https://example.com/caldav"; + compare(placeholder.text, "https://example.com/caldav"); + } + + function test_placeholderVisibilityWhenFocusChanges() { + testField.placeholderText = "Username"; + var placeholder = findPlaceholderText(); + var field = findInputField(); + verify(placeholder !== null); + verify(field !== null); + + // 1. Empty & unfocused -> visible + compare(placeholder.visible, true); + + // 2. Empty & focused -> hidden + field.forceActiveFocus(); + tryCompare(field, "activeFocus", true, 500); + compare(placeholder.visible, false); + + // 3. Empty & blur -> visible again + testRoot.forceActiveFocus(); + tryCompare(field, "activeFocus", false, 500); + compare(placeholder.visible, true); + } + + function test_placeholderVisibilityWhenTextEntered() { + testField.placeholderText = "Username"; + var placeholder = findPlaceholderText(); + var field = findInputField(); + verify(placeholder !== null); + verify(field !== null); + + // 1. Text entered without focus -> hidden + testField.text = "john_doe"; + compare(placeholder.visible, false); + + // 2. Text entered with focus -> hidden + field.forceActiveFocus(); + tryCompare(field, "activeFocus", true, 500); + compare(placeholder.visible, false); + + // 3. Clear text with focus -> hidden (still has focus) + testField.text = ""; + compare(placeholder.visible, false); + + // 4. Blur while empty -> visible again + testRoot.forceActiveFocus(); + tryCompare(field, "activeFocus", false, 500); + compare(placeholder.visible, true); + } + + // ── Input Changes & Text Manipulation ── + + function test_textInputChanges() { + var field = findInputField(); + verify(field !== null); + + testField.text = "admin@company.com"; + compare(testField.text, "admin@company.com"); + compare(field.text, "admin@company.com"); + + testField.text = "user@domain.org"; + compare(testField.text, "user@domain.org"); + compare(field.text, "user@domain.org"); + + testField.text = ""; + compare(testField.text, ""); + compare(field.text, ""); + } + + function test_keyClickTyping() { + var field = findInputField(); + verify(field !== null); + + field.forceActiveFocus(); + tryCompare(field, "activeFocus", true, 500); + + testField.text = ""; + keyClick(Qt.Key_P); + keyClick(Qt.Key_A); + keyClick(Qt.Key_S); + keyClick(Qt.Key_S); + compare(testField.text, "pass"); + compare(field.text, "pass"); + } + + function test_maximumLengthRestriction() { + testField.maximumLength = 5; + compare(testField.maximumLength, 5); + + testField.text = "123456789"; + compare(testField.text.length, 5); + compare(testField.text, "12345"); + + testField.maximumLength = 32767; + } + + function test_echoModeChanges() { + var field = findInputField(); + verify(field !== null); + + testField.echoMode = TextInput.Password; + compare(testField.echoMode, TextInput.Password); + compare(field.echoMode, TextInput.Password); + + testField.echoMode = TextInput.NoEcho; + compare(testField.echoMode, TextInput.NoEcho); + compare(field.echoMode, TextInput.NoEcho); + + testField.echoMode = TextInput.Normal; + compare(testField.echoMode, TextInput.Normal); + compare(field.echoMode, TextInput.Normal); + } + + function test_readOnlyStateAndStyling() { + var borderRect = findBorderRect(); + var field = findInputField(); + verify(borderRect !== null); + verify(field !== null); + + compare(testField.readOnly, false); + compare(borderRect.color, "#00000000"); + + testField.readOnly = true; + compare(testField.readOnly, true); + compare(field.readOnly, true); + verify(borderRect.color !== "#00000000", + "ReadOnly should apply a tinted background color"); + + testField.readOnly = false; + compare(testField.readOnly, false); + compare(field.readOnly, false); + compare(borderRect.color, "#00000000"); + } + + function test_enabledState() { + var field = findInputField(); + verify(field !== null); + + testField.enabled = false; + compare(testField.enabled, false); + compare(field.enabled, false); + + testField.enabled = true; + compare(testField.enabled, true); + compare(field.enabled, true); + } + + // ── Accepted Signal & Submission ── + + function test_acceptedSignalOnReturnKey() { + var field = findInputField(); + verify(field !== null); + + field.forceActiveFocus(); + tryCompare(field, "activeFocus", true, 500); + + testField.text = "Submitted via Return"; + acceptedSpy.clear(); + + keyClick(Qt.Key_Return); + compare(acceptedSpy.count, 1); + } + + function test_acceptedSignalOnEnterKey() { + var field = findInputField(); + verify(field !== null); + + field.forceActiveFocus(); + tryCompare(field, "activeFocus", true, 500); + + testField.text = "Submitted via Enter"; + acceptedSpy.clear(); + + keyClick(Qt.Key_Enter); + compare(acceptedSpy.count, 1); + } + + // ── Floating Label Behavior ── + + function test_floatingLabelVisibilityAndText() { + var labelContainer = findLabelContainer(); + var labelText = findLabelText(); + verify(labelContainer !== null); + verify(labelText !== null); + + // Empty labelText -> hidden container + compare(testField.labelText, ""); + compare(labelContainer.visible, false); + + // Set labelText -> visible container with matching text + testField.labelText = "Server Hostname"; + compare(testField.labelText, "Server Hostname"); + compare(labelContainer.visible, true); + compare(labelText.text, "Server Hostname"); + + // Clear labelText -> hidden again + testField.labelText = ""; + compare(labelContainer.visible, false); + } + } +} From 5d95c89943c42717d97810d332a8aba17d1f3939 Mon Sep 17 00:00:00 2001 From: Parvathy Nair Date: Wed, 30 Sep 2026 16:14:33 +0530 Subject: [PATCH 13/34] [ADDED] TSIconButton.qml test script & updated the documentation --- tests/qml/README.md | 1 + tests/qml/tst_tsiconbutton.qml | 288 +++++++++++++++++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 tests/qml/tst_tsiconbutton.qml diff --git a/tests/qml/README.md b/tests/qml/README.md index 4b825888..022d6fe3 100644 --- a/tests/qml/README.md +++ b/tests/qml/README.md @@ -26,6 +26,7 @@ tests/ │ ├── tst_customdatepicker.qml # Test suite for CustomDatePicker picker component │ ├── tst_globaltimerwidget.qml # Test suite for GlobalTimerWidget system component │ ├── tst_outlinedtextfield.qml # Test suite for OutlinedTextField text input component +│ ├── tst_tsiconbutton.qml # Test suite for TSIconButton circular icon button component │ ├── tst_tslabel.qml # Test suite for TSLabel styled text component │ ├── tst_tsprogressbar.qml # Test suite for TSProgressbar progress indicator │ └── tst_tssearchbar.qml # Test suite for TSSearchBar search input component diff --git a/tests/qml/tst_tsiconbutton.qml b/tests/qml/tst_tsiconbutton.qml new file mode 100644 index 00000000..e601a7f5 --- /dev/null +++ b/tests/qml/tst_tsiconbutton.qml @@ -0,0 +1,288 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtGraphicalEffects 1.0 +import Lomiri.Components 1.3 +import "../../models/constants.js" as AppConst +import "../../qml/components/base" + +Item { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + TSIconButton { + id: testButton + } + + SignalSpy { + id: clickedSpy + target: testButton + signalName: "clicked" + } + + TestCase { + name: "TSIconButtonTests" + when: windowShown + + // Helper to retrieve the button background Rectangle + function findButtonRect() { + if (testButton.children && testButton.children.length > 0) { + return testButton.children[0]; + } + return null; + } + + // Helper to retrieve the Icon component + function findIcon() { + var btn = findButtonRect(); + if (btn) { + for (var i = 0; i < btn.children.length; i++) { + if (btn.children[i] instanceof Icon) { + return btn.children[i]; + } + } + } + return null; + } + + // Helper to retrieve the ColorOverlay component + function findColorOverlay() { + var btn = findButtonRect(); + if (btn) { + for (var i = 0; i < btn.children.length; i++) { + if (btn.children[i] instanceof ColorOverlay) { + return btn.children[i]; + } + } + } + return null; + } + + // Helper to retrieve the fallback Text component + function findFallbackText() { + var btn = findButtonRect(); + if (btn) { + for (var i = 0; i < btn.children.length; i++) { + if (btn.children[i] instanceof Text) { + return btn.children[i]; + } + } + } + return null; + } + + // Helper to retrieve the MouseArea + function findMouseArea() { + var btn = findButtonRect(); + if (btn) { + for (var i = 0; i < btn.children.length; i++) { + if (btn.children[i] instanceof MouseArea) { + return btn.children[i]; + } + } + } + return null; + } + + function cleanup() { + testButton.iconName = ""; + testButton.iconText = "+"; + testButton.buttonSize = units.gu(5); + testButton.iconSize = units.gu(3); + testButton.iconBold = true; + testButton.radius = Qt.binding(function() { return testButton.width / 2; }); + testButton.width = Qt.binding(function() { return testButton.buttonSize; }); + testButton.height = Qt.binding(function() { return testButton.buttonSize; }); + testButton.bgColor = AppConst.Colors.Button.toLowerCase(); + testButton.fgColor = AppConst.Colors.ButtonText; + clickedSpy.clear(); + } + + // ── Initial State & Property Defaults ── + + function test_initialProperties() { + compare(testButton.buttonSize, units.gu(5)); + compare(testButton.width, units.gu(5)); + compare(testButton.height, units.gu(5)); + compare(testButton.iconName, ""); + compare(testButton.iconText, "+"); + compare(testButton.iconSize, units.gu(3)); + compare(testButton.iconBold, true); + compare(testButton.radius, units.gu(2.5)); + compare(testButton.bgColor, AppConst.Colors.Button.toLowerCase()); + } + + function test_initialVisualHierarchy() { + var btnRect = findButtonRect(); + verify(btnRect !== null, "Button Rectangle should exist"); + compare(btnRect.radius, units.gu(2.5)); + + var icon = findIcon(); + verify(icon !== null, "Icon element should exist"); + compare(icon.visible, false); + + var overlay = findColorOverlay(); + verify(overlay !== null, "ColorOverlay should exist"); + compare(overlay.visible, false); + + var fallback = findFallbackText(); + verify(fallback !== null, "Fallback Text should exist"); + compare(fallback.visible, true); + compare(fallback.text, "+"); + compare(fallback.font.pixelSize, units.gu(3)); + compare(fallback.font.bold, true); + + var mouseArea = findMouseArea(); + verify(mouseArea !== null, "MouseArea should exist"); + compare(mouseArea.cursorShape, Qt.PointingHandCursor); + } + + // ── Dimensions & Sizing ── + + function test_buttonSizeScaling() { + var btnRect = findButtonRect(); + verify(btnRect !== null); + + // Scale to 6 GU + testButton.buttonSize = units.gu(6); + compare(testButton.buttonSize, units.gu(6)); + compare(testButton.width, units.gu(6)); + compare(testButton.height, units.gu(6)); + compare(testButton.radius, units.gu(3)); + compare(btnRect.radius, units.gu(3)); + + // Scale to 4 GU + testButton.buttonSize = units.gu(4); + compare(testButton.buttonSize, units.gu(4)); + compare(testButton.width, units.gu(4)); + compare(testButton.height, units.gu(4)); + compare(testButton.radius, units.gu(2)); + compare(btnRect.radius, units.gu(2)); + } + + function test_iconSizeAndBoldModifications() { + var icon = findIcon(); + var fallback = findFallbackText(); + verify(icon !== null); + verify(fallback !== null); + + testButton.iconSize = units.gu(2); + compare(testButton.iconSize, units.gu(2)); + compare(icon.width, units.gu(2)); + compare(icon.height, units.gu(2)); + compare(fallback.font.pixelSize, units.gu(2)); + + testButton.iconBold = false; + compare(testButton.iconBold, false); + compare(fallback.font.bold, false); + + testButton.iconBold = true; + compare(testButton.iconBold, true); + compare(fallback.font.bold, true); + } + + // ── Icon Source & Fallback Text ── + + function test_symbolicIconSource() { + var icon = findIcon(); + var fallback = findFallbackText(); + verify(icon !== null); + verify(fallback !== null); + + // Setting iconName shows Icon and hides fallback Text + testButton.iconName = "add"; + compare(testButton.iconName, "add"); + compare(icon.name, "add"); + compare(icon.visible, true); + compare(fallback.visible, false); + + // Changing iconName updates the Icon + testButton.iconName = "edit"; + compare(testButton.iconName, "edit"); + compare(icon.name, "edit"); + compare(icon.visible, true); + compare(fallback.visible, false); + } + + function test_fallbackTextWhenIconNameEmpty() { + var icon = findIcon(); + var fallback = findFallbackText(); + verify(icon !== null); + verify(fallback !== null); + + testButton.iconName = ""; + testButton.iconText = "×"; + compare(testButton.iconText, "×"); + compare(fallback.text, "×"); + compare(fallback.visible, true); + compare(icon.visible, false); + + testButton.iconText = "✓"; + compare(fallback.text, "✓"); + compare(fallback.visible, true); + } + + function test_colorOverlayVisibilityWithIcon() { + var overlay = findColorOverlay(); + verify(overlay !== null); + + // Hidden when no iconName + testButton.iconName = ""; + compare(overlay.visible, false); + + // Visible when iconName is set + testButton.iconName = "delete"; + compare(overlay.visible, true); + + // Hidden when iconName reset to empty + testButton.iconName = ""; + compare(overlay.visible, false); + } + + // ── Click Handling ── + + function test_clickSignalEmission() { + var mouseArea = findMouseArea(); + verify(mouseArea !== null); + + clickedSpy.clear(); + mouseClick(mouseArea); + compare(clickedSpy.count, 1); + } + + function test_multipleClicks() { + var mouseArea = findMouseArea(); + verify(mouseArea !== null); + + clickedSpy.clear(); + mouseClick(mouseArea); + mouseClick(mouseArea); + mouseClick(mouseArea); + compare(clickedSpy.count, 3); + } + + // ── Custom Colors & Radius ── + + function test_customColorsAndRadius() { + var btnRect = findButtonRect(); + var icon = findIcon(); + var fallback = findFallbackText(); + verify(btnRect !== null); + verify(icon !== null); + verify(fallback !== null); + + testButton.bgColor = "#2563eb"; + compare(testButton.bgColor, "#2563eb"); + + testButton.fgColor = "#ffffff"; + compare(testButton.fgColor, "#ffffff"); + compare(icon.color, "#ffffff"); + compare(fallback.color, "#ffffff"); + + // Custom radius override + testButton.radius = units.gu(1); + compare(testButton.radius, units.gu(1)); + compare(btnRect.radius, units.gu(1)); + } + } +} From 3e103525133fcfc6bbb16aef999039eec868ae23 Mon Sep 17 00:00:00 2001 From: Parvathy Nair Date: Wed, 30 Sep 2026 16:59:31 +0530 Subject: [PATCH 14/34] [ADDED] TSSwitch.qml test script & updated documentation --- tests/qml/README.md | 3 +- tests/qml/tst_tsswitch.qml | 339 +++++++++++++++++++++++++++++++++++++ 2 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 tests/qml/tst_tsswitch.qml diff --git a/tests/qml/README.md b/tests/qml/README.md index 022d6fe3..febcb060 100644 --- a/tests/qml/README.md +++ b/tests/qml/README.md @@ -29,7 +29,8 @@ tests/ │ ├── tst_tsiconbutton.qml # Test suite for TSIconButton circular icon button component │ ├── tst_tslabel.qml # Test suite for TSLabel styled text component │ ├── tst_tsprogressbar.qml # Test suite for TSProgressbar progress indicator -│ └── tst_tssearchbar.qml # Test suite for TSSearchBar search input component +│ ├── tst_tssearchbar.qml # Test suite for TSSearchBar search input component +│ └── tst_tsswitch.qml # Test suite for TSSwitch toggle switch component ``` Clickable automatically discovers all files matching `tst_*.qml` under `tests/qml/` as configured in `clickable.yaml`. diff --git a/tests/qml/tst_tsswitch.qml b/tests/qml/tst_tsswitch.qml new file mode 100644 index 00000000..ba5af3ac --- /dev/null +++ b/tests/qml/tst_tsswitch.qml @@ -0,0 +1,339 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/base" + +Item { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + TSSwitch { + id: testSwitch + width: units.gu(4.2) + height: units.gu(2.1) + } + + SignalSpy { + id: clickedSpy + target: testSwitch + signalName: "clicked" + } + + SignalSpy { + id: toggledSpy + target: testSwitch + signalName: "toggled" + } + + TestCase { + name: "TSSwitchTests" + when: windowShown + + // Helper to retrieve the track Rectangle + function findTrack() { + if (testSwitch.children && testSwitch.children.length > 0) { + return testSwitch.children[0]; + } + return null; + } + + // Helper to retrieve the thumb Rectangle inside track + function findThumb() { + var trk = findTrack(); + if (trk && trk.children && trk.children.length > 0) { + return trk.children[0]; + } + return null; + } + + // Helper to retrieve the MouseArea + function findMouseArea() { + if (testSwitch.children) { + for (var i = 0; i < testSwitch.children.length; i++) { + if (testSwitch.children[i] instanceof MouseArea) { + return testSwitch.children[i]; + } + } + } + return null; + } + + function cleanup() { + testSwitch.checked = false; + testSwitch.enabled = true; + testSwitch.interactive = true; + testSwitch.width = units.gu(4.2); + testSwitch.height = units.gu(2.1); + testSwitch.checkedColor = Qt.darker(LomiriColors.orange, 1.35); + testSwitch.uncheckedColor = "#35ffffff"; + testSwitch.thumbColor = "white"; + testSwitch.checkedBorderColor = "transparent"; + testSwitch.uncheckedBorderColor = "#40ffffff"; + clickedSpy.clear(); + toggledSpy.clear(); + var thumb = findThumb(); + if (thumb) { + tryCompare(thumb, "x", units.dp(2), 500); + } + } + + // ── Initial State & Property Defaults ── + + function test_initialProperties() { + compare(testSwitch.checked, false); + compare(testSwitch.enabled, true); + compare(testSwitch.interactive, true); + compare(testSwitch.implicitWidth, units.gu(4.2)); + compare(testSwitch.implicitHeight, units.gu(2.1)); + compare(testSwitch.opacity, 1.0); + compare(testSwitch.checkedColor, Qt.darker(LomiriColors.orange, 1.35)); + compare(testSwitch.borderWidth, units.dp(1)); + compare(testSwitch.borderColor, testSwitch.uncheckedBorderColor); + } + + function test_initialVisualHierarchy() { + var track = findTrack(); + verify(track !== null, "Track Rectangle should exist"); + compare(track.radius, testSwitch.height / 2); + compare(track.border.width, units.dp(1)); + compare(track.border.color, testSwitch.uncheckedBorderColor); + + var thumb = findThumb(); + verify(thumb !== null, "Thumb Rectangle should exist"); + compare(thumb.width, testSwitch.height - units.dp(4)); + compare(thumb.height, thumb.width); + compare(thumb.radius, thumb.width / 2); + compare(thumb.color, testSwitch.thumbColor); + compare(thumb.x, units.dp(2)); + + var mouseArea = findMouseArea(); + verify(mouseArea !== null, "MouseArea should exist"); + compare(mouseArea.enabled, true); + compare(mouseArea.visible, true); + compare(mouseArea.cursorShape, Qt.PointingHandCursor); + } + + // ── Toggle State Changes ── + + function test_toggleCheckedChangesThumbPosition() { + var thumb = findThumb(); + verify(thumb !== null); + + // Initial unchecked position (thumb at left offset 2dp) + compare(testSwitch.checked, false); + compare(thumb.x, units.dp(2)); + + // Toggle checked to true -> thumb slides to the right + testSwitch.checked = true; + var expectedRightX = testSwitch.width - thumb.width - units.dp(2); + tryCompare(thumb, "x", expectedRightX, 500); + + // Toggle checked back to false -> thumb slides back to left + testSwitch.checked = false; + tryCompare(thumb, "x", units.dp(2), 500); + } + + function test_toggleCheckedChangesBorderAndColors() { + var track = findTrack(); + verify(track !== null); + + // Unchecked initial state + compare(testSwitch.checked, false); + compare(testSwitch.borderWidth, units.dp(1)); + compare(track.border.width, units.dp(1)); + compare(testSwitch.borderColor, testSwitch.uncheckedBorderColor); + + // Switch to checked state + testSwitch.checked = true; + compare(testSwitch.borderWidth, 0); + compare(track.border.width, 0); + compare(testSwitch.borderColor, testSwitch.checkedBorderColor); + tryCompare(track, "color", testSwitch.checkedColor, 500); + + // Switch back to unchecked state + testSwitch.checked = false; + compare(testSwitch.borderWidth, units.dp(1)); + compare(track.border.width, units.dp(1)); + compare(testSwitch.borderColor, testSwitch.uncheckedBorderColor); + tryCompare(track, "color", testSwitch.uncheckedColor, 500); + } + + // ── Click Interactions & Signal Emissions ── + + function test_mouseClickEmitsClickedAndToggledSignals() { + var thumb = findThumb(); + var mouseArea = findMouseArea(); + verify(mouseArea !== null); + + clickedSpy.clear(); + toggledSpy.clear(); + + // Click when unchecked -> emits clicked and toggled(true) + compare(testSwitch.checked, false); + mouseClick(mouseArea); + compare(clickedSpy.count, 1); + compare(toggledSpy.count, 1); + compare(toggledSpy.signalArguments[0][0], true); + + // Click when checked -> emits clicked and toggled(false) + testSwitch.checked = true; + mouseClick(mouseArea); + compare(clickedSpy.count, 2); + compare(toggledSpy.count, 2); + compare(toggledSpy.signalArguments[1][0], false); + + testSwitch.checked = false; + if (thumb) { + tryCompare(thumb, "x", units.dp(2), 500); + } + } + + function test_multipleConsecutiveClicks() { + var mouseArea = findMouseArea(); + verify(mouseArea !== null); + + clickedSpy.clear(); + toggledSpy.clear(); + + mouseClick(mouseArea); + mouseClick(mouseArea); + mouseClick(mouseArea); + + compare(clickedSpy.count, 3); + compare(toggledSpy.count, 3); + } + + // ── Disabled State ── + + function test_disabledStateOpacityAndCursor() { + var mouseArea = findMouseArea(); + verify(mouseArea !== null); + + // Enabled default state + compare(testSwitch.enabled, true); + compare(testSwitch.opacity, 1.0); + compare(mouseArea.cursorShape, Qt.PointingHandCursor); + + // Disabled state reduces opacity and changes cursor shape + testSwitch.enabled = false; + compare(testSwitch.enabled, false); + compare(testSwitch.opacity, 0.6); + compare(mouseArea.cursorShape, Qt.ArrowCursor); + compare(mouseArea.enabled, false); + + // Restoring enabled state + testSwitch.enabled = true; + compare(testSwitch.enabled, true); + compare(testSwitch.opacity, 1.0); + compare(mouseArea.cursorShape, Qt.PointingHandCursor); + compare(mouseArea.enabled, true); + } + + function test_disabledStateBlocksClicks() { + var mouseArea = findMouseArea(); + verify(mouseArea !== null); + + testSwitch.enabled = false; + clickedSpy.clear(); + toggledSpy.clear(); + + mouseClick(mouseArea); + compare(clickedSpy.count, 0); + compare(toggledSpy.count, 0); + + // Re-enable and verify clicks are accepted again + testSwitch.enabled = true; + mouseClick(mouseArea); + compare(clickedSpy.count, 1); + compare(toggledSpy.count, 1); + } + + // ── Interactive Property ── + + function test_nonInteractiveStateBlocksInputAndHidesMouseArea() { + var mouseArea = findMouseArea(); + verify(mouseArea !== null); + + testSwitch.interactive = false; + compare(testSwitch.interactive, false); + compare(mouseArea.enabled, false); + compare(mouseArea.visible, false); + compare(mouseArea.cursorShape, Qt.ArrowCursor); + + clickedSpy.clear(); + toggledSpy.clear(); + mouseClick(mouseArea); + compare(clickedSpy.count, 0); + compare(toggledSpy.count, 0); + + // Re-enable interactivity + testSwitch.interactive = true; + compare(mouseArea.enabled, true); + compare(mouseArea.visible, true); + compare(mouseArea.cursorShape, Qt.PointingHandCursor); + } + + // ── Custom Dimensions & Scaling ── + + function test_customDimensionsAndScaling() { + var track = findTrack(); + var thumb = findThumb(); + verify(track !== null); + verify(thumb !== null); + + testSwitch.width = units.gu(6); + testSwitch.height = units.gu(3); + + compare(track.radius, units.gu(1.5)); + compare(thumb.width, units.gu(3) - units.dp(4)); + compare(thumb.height, thumb.width); + compare(thumb.radius, thumb.width / 2); + + // Unchecked thumb position + compare(thumb.x, units.dp(2)); + + // Checked thumb position with larger dimensions + testSwitch.checked = true; + var expectedX = units.gu(6) - (units.gu(3) - units.dp(4)) - units.dp(2); + tryCompare(thumb, "x", expectedX, 500); + + testSwitch.checked = false; + tryCompare(thumb, "x", units.dp(2), 500); + } + + // ── Custom Colors ── + + function test_customColors() { + var track = findTrack(); + var thumb = findThumb(); + verify(track !== null); + verify(thumb !== null); + + testSwitch.checkedColor = "#2563eb"; + testSwitch.uncheckedColor = "#475569"; + testSwitch.thumbColor = "#f8fafc"; + testSwitch.checkedBorderColor = "#1d4ed8"; + testSwitch.uncheckedBorderColor = "#64748b"; + + compare(testSwitch.checkedColor, "#2563eb"); + compare(testSwitch.uncheckedColor, "#475569"); + compare(testSwitch.thumbColor, "#f8fafc"); + compare(thumb.color, "#f8fafc"); + + // Unchecked border and track color + testSwitch.checked = false; + compare(testSwitch.borderColor, "#64748b"); + tryCompare(track, "color", "#475569", 500); + + // Checked border and track color + testSwitch.checked = true; + compare(testSwitch.borderColor, "#1d4ed8"); + tryCompare(track, "color", "#2563eb", 500); + + // Restore unchecked + testSwitch.checked = false; + tryCompare(thumb, "x", units.dp(2), 500); + } + } +} From f371d7431ed399af1f3d7dd40e9ffc5f741b6131 Mon Sep 17 00:00:00 2001 From: Parvathy Nair Date: Wed, 30 Sep 2026 17:37:12 +0530 Subject: [PATCH 15/34] [ADDED] TSButton.qml test script & updated documentation --- tests/qml/README.md | 1 + tests/qml/tst_tsbutton.qml | 365 +++++++++++++++++++++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 tests/qml/tst_tsbutton.qml diff --git a/tests/qml/README.md b/tests/qml/README.md index febcb060..1fd73e5a 100644 --- a/tests/qml/README.md +++ b/tests/qml/README.md @@ -26,6 +26,7 @@ tests/ │ ├── tst_customdatepicker.qml # Test suite for CustomDatePicker picker component │ ├── tst_globaltimerwidget.qml # Test suite for GlobalTimerWidget system component │ ├── tst_outlinedtextfield.qml # Test suite for OutlinedTextField text input component +│ ├── tst_tsbutton.qml # Test suite for TSButton action button component │ ├── tst_tsiconbutton.qml # Test suite for TSIconButton circular icon button component │ ├── tst_tslabel.qml # Test suite for TSLabel styled text component │ ├── tst_tsprogressbar.qml # Test suite for TSProgressbar progress indicator diff --git a/tests/qml/tst_tsbutton.qml b/tests/qml/tst_tsbutton.qml new file mode 100644 index 00000000..af5ed9e6 --- /dev/null +++ b/tests/qml/tst_tsbutton.qml @@ -0,0 +1,365 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../models/constants.js" as AppConst +import "../../qml/components/base" + +Item { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + TSButton { + id: testButton + width: testRoot.width + } + + SignalSpy { + id: clickedSpy + target: testButton + signalName: "clicked" + } + + TestCase { + name: "TSButtonTests" + when: windowShown + + // Helper to retrieve the button background Rectangle + function findButtonRect() { + if (testButton.children && testButton.children.length > 0) { + return testButton.children[0]; + } + return null; + } + + // Helper to retrieve the content Row inside buttonRect + function findContentRow() { + var btn = findButtonRect(); + if (btn) { + for (var i = 0; i < btn.children.length; i++) { + if (btn.children[i] instanceof Row) { + return btn.children[i]; + } + } + } + return null; + } + + // Helper to retrieve the MouseArea inside buttonRect + function findMouseArea() { + var btn = findButtonRect(); + if (btn) { + for (var i = 0; i < btn.children.length; i++) { + if (btn.children[i] instanceof MouseArea) { + return btn.children[i]; + } + } + } + return null; + } + + // Helper to retrieve the built-in Icon inside contentRow + function findBuiltinIcon() { + var row = findContentRow(); + if (row) { + for (var i = 0; i < row.children.length; i++) { + if (row.children[i] instanceof Icon) { + return row.children[i]; + } + } + } + return null; + } + + // Helper to retrieve the custom Image icon inside contentRow + function findCustomIcon() { + var row = findContentRow(); + if (row) { + for (var i = 0; i < row.children.length; i++) { + if (row.children[i] instanceof Image) { + return row.children[i]; + } + } + } + return null; + } + + // Helper to retrieve the Text label inside contentRow + function findLabel() { + var row = findContentRow(); + if (row) { + for (var i = 0; i < row.children.length; i++) { + if (row.children[i] instanceof Text) { + return row.children[i]; + } + } + } + return null; + } + + function cleanup() { + testButton.text = ""; + testButton.fontSize = units.gu(1.5); + testButton.fontBold = false; + testButton.enabled = true; + testButton.width = testRoot.width; + testButton.height = units.gu(5); + testButton.iconName = ""; + testButton.iconSource = ""; + testButton.iconSize = units.gu(1.5); + testButton.iconBold = false; + testButton.spacing = units.gu(1); + testButton.radius = units.gu(0.8); + testButton.borderColor = "transparent"; + testButton.fgColor = AppConst.Colors.ButtonText; + testButton.hoverColor = AppConst.Colors.ButtonHover; + testButton.bgColor = Qt.binding(function() { + return (testButton.enabled) ? AppConst.Colors.Button : AppConst.Colors.ButtonDisabled; + }); + testButton.iconColor = Qt.binding(function() { + return testButton.fgColor; + }); + clickedSpy.clear(); + } + + // ── Initial State & Property Defaults ── + + function test_initialProperties() { + compare(testButton.text, ""); + compare(testButton.fontSize, units.gu(1.5)); + compare(testButton.fontBold, false); + compare(testButton.enabled, true); + compare(testButton.height, units.gu(5)); + compare(testButton.width, testRoot.width); + compare(testButton.iconName, ""); + compare(testButton.iconSource, ""); + compare(testButton.iconSize, units.gu(1.5)); + compare(testButton.iconBold, false); + compare(testButton.spacing, units.gu(1)); + compare(testButton.radius, units.gu(0.8)); + compare(testButton.bgColor, AppConst.Colors.Button.toLowerCase()); + compare(testButton.fgColor, "#ffffff"); + compare(testButton.hoverColor, AppConst.Colors.ButtonHover.toLowerCase()); + compare(testButton.borderColor, "#00000000"); + } + + function test_initialVisualHierarchy() { + var btnRect = findButtonRect(); + verify(btnRect !== null, "Button Rectangle should exist"); + compare(btnRect.radius, units.gu(0.8)); + + var contentRow = findContentRow(); + verify(contentRow !== null, "Content Row should exist"); + compare(contentRow.spacing, units.gu(1)); + + var builtinIcon = findBuiltinIcon(); + verify(builtinIcon !== null, "Builtin Icon should exist"); + compare(builtinIcon.visible, false); + + var customIcon = findCustomIcon(); + verify(customIcon !== null, "Custom Image icon should exist"); + compare(customIcon.visible, false); + + var label = findLabel(); + verify(label !== null, "Label Text should exist"); + compare(label.visible, false); + compare(label.text, ""); + + var mouseArea = findMouseArea(); + verify(mouseArea !== null, "MouseArea should exist"); + compare(mouseArea.hoverEnabled, true); + } + + // ── Click Interactions & Signal Emissions ── + + function test_clickSignalEmission() { + var mouseArea = findMouseArea(); + verify(mouseArea !== null); + + clickedSpy.clear(); + mouseClick(mouseArea); + compare(clickedSpy.count, 1); + } + + function test_multipleClicks() { + var mouseArea = findMouseArea(); + verify(mouseArea !== null); + + clickedSpy.clear(); + mouseClick(mouseArea); + mouseClick(mouseArea); + mouseClick(mouseArea); + compare(clickedSpy.count, 3); + } + + // ── Disabled State Styling ── + + function test_disabledStateBgColor() { + compare(testButton.enabled, true); + compare(testButton.bgColor, AppConst.Colors.Button.toLowerCase()); + + testButton.enabled = false; + compare(testButton.enabled, false); + compare(testButton.bgColor, "#808080"); + + testButton.enabled = true; + compare(testButton.enabled, true); + compare(testButton.bgColor, AppConst.Colors.Button.toLowerCase()); + } + + // ── Text & Typography ── + + function test_textPropertyAndVisibility() { + var label = findLabel(); + verify(label !== null); + + compare(label.visible, false); + + testButton.text = "Confirm Action"; + compare(testButton.text, "Confirm Action"); + compare(label.text, "Confirm Action"); + compare(label.visible, true); + + testButton.text = "Cancel"; + compare(label.text, "Cancel"); + compare(label.visible, true); + + testButton.text = ""; + compare(label.visible, false); + } + + function test_fontPropertiesAliases() { + var label = findLabel(); + verify(label !== null); + + testButton.fontSize = units.gu(2.5); + compare(testButton.fontSize, units.gu(2.5)); + compare(label.font.pixelSize, units.gu(2.5)); + + testButton.fontBold = true; + compare(testButton.fontBold, true); + compare(label.font.bold, true); + + testButton.fontBold = false; + compare(testButton.fontBold, false); + compare(label.font.bold, false); + } + + // ── Icon Handling (Built-in vs Custom) ── + + function test_builtinIconName() { + var builtinIcon = findBuiltinIcon(); + var customIcon = findCustomIcon(); + verify(builtinIcon !== null); + verify(customIcon !== null); + + testButton.iconName = "add"; + compare(testButton.iconName, "add"); + compare(builtinIcon.name, "add"); + compare(builtinIcon.visible, true); + compare(customIcon.visible, false); + + testButton.iconName = "delete"; + compare(builtinIcon.name, "delete"); + compare(builtinIcon.visible, true); + + testButton.iconName = ""; + compare(builtinIcon.visible, false); + } + + function test_customIconSource() { + var builtinIcon = findBuiltinIcon(); + var customIcon = findCustomIcon(); + verify(builtinIcon !== null); + verify(customIcon !== null); + + testButton.iconName = ""; + testButton.iconSource = "qrc:///images/custom_icon.png"; + compare(testButton.iconSource, "qrc:///images/custom_icon.png"); + compare(customIcon.source, "qrc:///images/custom_icon.png"); + compare(customIcon.visible, true); + compare(builtinIcon.visible, false); + + testButton.iconSource = ""; + compare(customIcon.visible, false); + } + + function test_iconPrecedenceBuiltinOverCustom() { + var builtinIcon = findBuiltinIcon(); + var customIcon = findCustomIcon(); + verify(builtinIcon !== null); + verify(customIcon !== null); + + testButton.iconName = "edit"; + testButton.iconSource = "qrc:///images/edit.png"; + + // Built-in icon takes precedence over custom icon + compare(builtinIcon.visible, true); + compare(customIcon.visible, false); + + // Removing iconName allows custom icon to show + testButton.iconName = ""; + compare(builtinIcon.visible, false); + compare(customIcon.visible, true); + } + + function test_iconSizeAndBold() { + var builtinIcon = findBuiltinIcon(); + var customIcon = findCustomIcon(); + verify(builtinIcon !== null); + verify(customIcon !== null); + + testButton.iconName = "search"; + compare(builtinIcon.width, units.gu(1.5)); + compare(builtinIcon.height, units.gu(1.5)); + + // Custom size + testButton.iconSize = units.gu(2); + compare(builtinIcon.width, units.gu(2)); + compare(builtinIcon.height, units.gu(2)); + + // Bold increases size by 1.2 + testButton.iconBold = true; + compare(builtinIcon.width, units.gu(2) * 1.2); + compare(builtinIcon.height, units.gu(2) * 1.2); + + // Switching to custom icon + testButton.iconName = ""; + testButton.iconSource = "qrc:///images/search.png"; + compare(customIcon.width, units.gu(2) * 1.2); + compare(customIcon.opacity, 1.0); + + testButton.iconBold = false; + compare(customIcon.width, units.gu(2)); + compare(customIcon.opacity, 0.9); + } + + // ── Custom Colors & Styling ── + + function test_customColorsAndStyling() { + var btnRect = findButtonRect(); + var label = findLabel(); + var contentRow = findContentRow(); + verify(btnRect !== null); + verify(label !== null); + verify(contentRow !== null); + + testButton.text = "Styled Button"; + testButton.bgColor = "#2563eb"; + testButton.fgColor = "#f8fafc"; + testButton.borderColor = "#1d4ed8"; + testButton.radius = units.gu(1.2); + testButton.spacing = units.gu(2); + + compare(testButton.bgColor, "#2563eb"); + compare(testButton.fgColor, "#f8fafc"); + compare(label.color, "#f8fafc"); + compare(testButton.borderColor, "#1d4ed8"); + compare(btnRect.border.color, "#1d4ed8"); + compare(testButton.radius, units.gu(1.2)); + compare(btnRect.radius, units.gu(1.2)); + compare(testButton.spacing, units.gu(2)); + compare(contentRow.spacing, units.gu(2)); + } + } +} From 4e19a9977e6ccd65fd841244802f975caf59f893 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 1 Oct 2026 13:27:40 +0530 Subject: [PATCH 16/34] Add unit tests for core JS utilities and timer Cover constants, logger, utils, and timer service state transitions via Qt Quick Test. --- tests/qml/README.md | 8 +- tests/qml/tst_constants.qml | 49 ++++++ tests/qml/tst_logger.qml | 137 +++++++++++++++++ tests/qml/tst_timer_service.qml | 260 ++++++++++++++++++++++++++++++++ tests/qml/tst_utils.qml | 259 +++++++++++++++++++++++++++++++ 5 files changed, 711 insertions(+), 2 deletions(-) create mode 100644 tests/qml/tst_constants.qml create mode 100644 tests/qml/tst_logger.qml create mode 100644 tests/qml/tst_timer_service.qml create mode 100644 tests/qml/tst_utils.qml diff --git a/tests/qml/README.md b/tests/qml/README.md index 1fd73e5a..53137a08 100644 --- a/tests/qml/README.md +++ b/tests/qml/README.md @@ -22,16 +22,20 @@ clickable test -- -functions test_openDisplaysMessageAndEmitsSignal tests/ ├── qml/ │ ├── README.md # This guide -│ ├── tst_infobar.qml # Test suite for InfoBar feedback component +│ ├── tst_constants.qml # Test suite for constants and quadrant color configuration │ ├── tst_customdatepicker.qml # Test suite for CustomDatePicker picker component │ ├── tst_globaltimerwidget.qml # Test suite for GlobalTimerWidget system component +│ ├── tst_infobar.qml # Test suite for InfoBar feedback component +│ ├── tst_logger.qml # Test suite for logger levels, formatting, and PII sanitization │ ├── tst_outlinedtextfield.qml # Test suite for OutlinedTextField text input component +│ ├── tst_timer_service.qml # Test suite for timer service state transitions and duration │ ├── tst_tsbutton.qml # Test suite for TSButton action button component │ ├── tst_tsiconbutton.qml # Test suite for TSIconButton circular icon button component │ ├── tst_tslabel.qml # Test suite for TSLabel styled text component │ ├── tst_tsprogressbar.qml # Test suite for TSProgressbar progress indicator │ ├── tst_tssearchbar.qml # Test suite for TSSearchBar search input component -│ └── tst_tsswitch.qml # Test suite for TSSwitch toggle switch component +│ ├── tst_tsswitch.qml # Test suite for TSSwitch toggle switch component +│ └── tst_utils.qml # Test suite for date, time, duration, and URL utilities ``` Clickable automatically discovers all files matching `tst_*.qml` under `tests/qml/` as configured in `clickable.yaml`. diff --git a/tests/qml/tst_constants.qml b/tests/qml/tst_constants.qml new file mode 100644 index 00000000..75752348 --- /dev/null +++ b/tests/qml/tst_constants.qml @@ -0,0 +1,49 @@ +import QtQuick 2.7 +import QtTest 1.0 +import "../../models/constants.js" as Constants + +Item { + width: 200 + height: 200 + + TestCase { + name: "ConstantsTests" + when: windowShown + + function test_version() { + verify(Constants.version !== undefined); + compare(typeof Constants.version, "string"); + verify(Constants.version.length > 0); + compare(Constants.version, "1.3.3"); + } + + function test_fontSizesStructure() { + verify(Constants.FontSizes !== undefined); + compare(typeof Constants.FontSizes, "object"); + compare(Constants.FontSizes.ListHeading, 1.7); + compare(Constants.FontSizes.ListSubHeading, 1.5); + compare(Constants.FontSizes.ListSubSubHeading, 1.7); + } + + function test_colorsBasePalette() { + verify(Constants.Colors !== undefined); + compare(Constants.Colors.StickyNote, "#F5F5F5"); + compare(Constants.Colors.Border, "#B0BEC5"); + compare(Constants.Colors.Shadow, "#55000000"); + compare(Constants.Colors.Button, "#E95420"); + compare(Constants.Colors.ButtonText, "white"); + compare(Constants.Colors.ButtonHover, "#d3481b"); + compare(Constants.Colors.Orange, "#E95420"); + compare(Constants.Colors.ButtonDisabled, "grey"); + } + + function test_quadrantColors() { + verify(Constants.Colors.Quadrants !== undefined); + compare(Constants.Colors.Quadrants.Q1, "#E53935"); + compare(Constants.Colors.Quadrants.Q2, "#1E88E5"); + compare(Constants.Colors.Quadrants.Q3, "#43A047"); + compare(Constants.Colors.Quadrants.Q4, "#757575"); + compare(Constants.Colors.Quadrants.Default, "transparent"); + } + } +} diff --git a/tests/qml/tst_logger.qml b/tests/qml/tst_logger.qml new file mode 100644 index 00000000..dcd5eee3 --- /dev/null +++ b/tests/qml/tst_logger.qml @@ -0,0 +1,137 @@ +import QtQuick 2.7 +import QtTest 1.0 +import "../../models/logger.js" as Logger + +Item { + width: 200 + height: 200 + + TestCase { + name: "LoggerTests" + when: windowShown + + function initTestCase() { + compare(Logger.getLogLevel(), Logger.LogLevel.WARN); + } + + function init() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function cleanup() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function test_logLevelEnumValues() { + verify(Logger.LogLevel !== undefined); + compare(Logger.LogLevel.DEBUG, 0); + compare(Logger.LogLevel.INFO, 1); + compare(Logger.LogLevel.WARN, 2); + compare(Logger.LogLevel.ERROR, 3); + compare(Logger.LogLevel.NONE, 4); + } + + function test_setLogLevelValid() { + Logger.setLogLevel(Logger.LogLevel.DEBUG); + compare(Logger.getLogLevel(), Logger.LogLevel.DEBUG); + + Logger.setLogLevel(Logger.LogLevel.INFO); + compare(Logger.getLogLevel(), Logger.LogLevel.INFO); + + Logger.setLogLevel(Logger.LogLevel.ERROR); + compare(Logger.getLogLevel(), Logger.LogLevel.ERROR); + + Logger.setLogLevel(Logger.LogLevel.NONE); + compare(Logger.getLogLevel(), Logger.LogLevel.NONE); + } + + function test_setLogLevelInvalidIgnoresChange() { + Logger.setLogLevel(Logger.LogLevel.INFO); + compare(Logger.getLogLevel(), Logger.LogLevel.INFO); + + // Out-of-bounds numbers + Logger.setLogLevel(-1); + compare(Logger.getLogLevel(), Logger.LogLevel.INFO); + + Logger.setLogLevel(5); + compare(Logger.getLogLevel(), Logger.LogLevel.INFO); + + Logger.setLogLevel(100); + compare(Logger.getLogLevel(), Logger.LogLevel.INFO); + + // Non-number types + Logger.setLogLevel("DEBUG"); + compare(Logger.getLogLevel(), Logger.LogLevel.INFO); + + Logger.setLogLevel(null); + compare(Logger.getLogLevel(), Logger.LogLevel.INFO); + + Logger.setLogLevel(undefined); + compare(Logger.getLogLevel(), Logger.LogLevel.INFO); + + Logger.setLogLevel({}); + compare(Logger.getLogLevel(), Logger.LogLevel.INFO); + } + + function test_sanitizePIINonStringPassThrough() { + compare(Logger.sanitizePII(null), null); + compare(Logger.sanitizePII(undefined), undefined); + compare(Logger.sanitizePII(12345), 12345); + compare(Logger.sanitizePII(true), true); + } + + function test_sanitizePIINoEmail() { + var plainText = "Task synchronization completed successfully with 42 records."; + compare(Logger.sanitizePII(plainText), plainText); + } + + function test_sanitizePIIStandardEmail() { + var input = "User email is john.doe@example.com for account."; + var expected = "User email is j***@example.com for account."; + compare(Logger.sanitizePII(input), expected); + } + + function test_sanitizePIIShortUsernameEmail() { + // Username with 2 or fewer characters should mask as **@domain + var input = "Contact us at me@domain.org or ab@test.co."; + var expected = "Contact us at **@domain.org or **@test.co."; + compare(Logger.sanitizePII(input), expected); + + var singleChar = "a@sample.com"; + compare(Logger.sanitizePII(singleChar), "**@sample.com"); + } + + function test_sanitizePIIMultipleEmails() { + var input = "Assigned from lead@corp.com to analyst@service.org."; + var expected = "Assigned from l***@corp.com to a***@service.org."; + compare(Logger.sanitizePII(input), expected); + } + + function test_loggingExecutionAtAllLevels() { + // Enable DEBUG to exercise all log branches + Logger.setLogLevel(Logger.LogLevel.DEBUG); + + // Should execute without errors + Logger.debug("TestCategory", "Debug message with confidential info user@example.com"); + Logger.debug("TestCategory", "Debug with payload", { id: 101, status: "ok" }); + + Logger.info("TestCategory", "Info message with secret@domain.com"); + Logger.info("TestCategory", "Info with payload", [1, 2, 3]); + + Logger.warn("TestCategory", "Warning message with warn@alert.com"); + Logger.warn("TestCategory", "Warning with payload", "details"); + + Logger.error("TestCategory", "Error message with admin@system.org"); + Logger.error("TestCategory", "Error with payload", 500); + + // Filter out all logs with NONE + Logger.setLogLevel(Logger.LogLevel.NONE); + Logger.debug("TestCategory", "Should be silenced"); + Logger.info("TestCategory", "Should be silenced"); + Logger.warn("TestCategory", "Should be silenced"); + Logger.error("TestCategory", "Should be silenced"); + + verify(true); + } + } +} diff --git a/tests/qml/tst_timer_service.qml b/tests/qml/tst_timer_service.qml new file mode 100644 index 00000000..78ae55de --- /dev/null +++ b/tests/qml/tst_timer_service.qml @@ -0,0 +1,260 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../models/constants.js" as Constants +import "../../models/logger.js" as Logger +import "../../models/database.js" as DBCommon +import "../../models/timesheet.js" as Model +import "../../models/timer_service.js" as TimerService + +Item { + width: 200 + height: 200 + + TestCase { + name: "TimerServiceTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("CREATE TABLE IF NOT EXISTS project_project_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "odoo_record_id INTEGER, " + + "name TEXT, " + + "color_pallet TEXT)"); + + tx.executeSql("CREATE TABLE IF NOT EXISTS project_task_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "odoo_record_id INTEGER, " + + "name TEXT)"); + + tx.executeSql("CREATE TABLE IF NOT EXISTS account_analytic_line_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "account_id INTEGER, " + + "project_id INTEGER, " + + "sub_project_id INTEGER, " + + "task_id INTEGER, " + + "name TEXT, " + + "unit_amount REAL, " + + "status TEXT, " + + "last_modified TEXT)"); + }); + } + + function cleanupTestCase() { + TimerService.reset(); + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function init() { + TimerService.reset(); + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("DELETE FROM project_task_app"); + tx.executeSql("DELETE FROM project_project_app"); + tx.executeSql("DELETE FROM account_analytic_line_app"); + + // Seed Project + tx.executeSql("INSERT INTO project_project_app (id, odoo_record_id, name, color_pallet) VALUES (1, 101, 'Internal Project', '#EB6E67')"); + + // Seed Task + tx.executeSql("INSERT INTO project_task_app (id, odoo_record_id, name) VALUES (1, 201, 'Core Module Task')"); + + // Seed Timesheets: + // 1: Ready timesheet with 1.0 unit_amount + tx.executeSql("INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, name, unit_amount, status) VALUES (1, 1, 101, 201, 'Feature Implementation', 1.0, 'draft')"); + + // 2: Secondary ready timesheet with 0.5 unit_amount + tx.executeSql("INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, name, unit_amount, status) VALUES (2, 1, 101, 201, 'Bug Investigation', 0.5, 'draft')"); + + // 3: Invalid timesheet (no project_id or sub_project_id) + tx.executeSql("INSERT INTO account_analytic_line_app (id, account_id, project_id, sub_project_id, name, unit_amount, status) VALUES (3, 1, 0, 0, 'Unassigned Task', 0.0, 'draft')"); + + // 4: Finalized timesheet + tx.executeSql("INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, name, unit_amount, status) VALUES (4, 1, 101, 201, 'Approved Timesheet', 2.0, 'updated')"); + }); + } + + function cleanup() { + TimerService.reset(); + } + + function test_initialState() { + compare(TimerService.isRunning(), false); + compare(TimerService.isPaused(), false); + compare(TimerService.getActiveTimesheetId(), null); + compare(TimerService.getStartTime(), 0); + compare(TimerService.getActiveTimesheetName(), ""); + compare(TimerService.getElapsedTime("hhmmss"), "00:00:00"); + compare(TimerService.getElapsedTime("hhmm"), "0.00"); + compare(TimerService.getElapsedDuration(), "0:00"); + compare(TimerService.stop(), "00:00:00"); + } + + function test_updateActiveTimesheetName() { + TimerService.updateActiveTimesheetName("Developing Unit Tests"); + compare(TimerService.getActiveTimesheetName(), "Developing Unit Tests"); + + TimerService.updateActiveTimesheetName(" Padded Title "); + compare(TimerService.getActiveTimesheetName(), "Padded Title"); + + TimerService.updateActiveTimesheetName(null); + compare(TimerService.getActiveTimesheetName(), ""); + + TimerService.updateActiveTimesheetName(12345); + compare(TimerService.getActiveTimesheetName(), ""); + } + + function test_startFailsWithoutProject() { + var result = TimerService.start(3); + compare(result.success, false); + compare(result.error, "Unable to start timer, please select a project first"); + compare(TimerService.isRunning(), false); + compare(TimerService.getActiveTimesheetId(), null); + } + + function test_startSuccess() { + var result = TimerService.start(1); + compare(result.success, true); + compare(result.error, ""); + compare(TimerService.isRunning(), true); + compare(TimerService.isPaused(), false); + compare(TimerService.getActiveTimesheetId(), 1); + compare(TimerService.getActiveTimesheetName(), "Feature Implementation"); + verify(TimerService.getStartTime() > 0); + + // Verify status marked active in DB + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + var rs = tx.executeSql("SELECT status FROM account_analytic_line_app WHERE id = 1"); + compare(rs.rows.item(0).status, "active"); + }); + } + + function test_redundantStartSameTimesheetIgnored() { + TimerService.start(1); + var initialStartTime = TimerService.getStartTime(); + + // Calling start on the same active timesheet does not restart + TimerService.start(1); + compare(TimerService.isRunning(), true); + compare(TimerService.getStartTime(), initialStartTime); + } + + function test_pauseAndResume() { + TimerService.start(1); + compare(TimerService.isRunning(), true); + compare(TimerService.isPaused(), false); + + TimerService.pause(); + compare(TimerService.isRunning(), true); + compare(TimerService.isPaused(), true); + + // Calling pause when already paused does nothing + TimerService.pause(); + compare(TimerService.isPaused(), true); + + // Resume + TimerService.resume(); + compare(TimerService.isRunning(), true); + compare(TimerService.isPaused(), false); + } + + function test_startWhilePausedResumes() { + TimerService.start(1); + TimerService.pause(); + compare(TimerService.isPaused(), true); + + // Calling start(1) or start() while paused on the same timesheet should resume + TimerService.start(1); + compare(TimerService.isPaused(), false); + compare(TimerService.isRunning(), true); + } + + function test_stopRunningTimer() { + TimerService.start(1); + var elapsed = TimerService.stop(); + + verify(elapsed !== undefined); + verify(elapsed.length > 0); + compare(TimerService.isRunning(), false); + compare(TimerService.isPaused(), false); + compare(TimerService.getActiveTimesheetId(), null); + compare(TimerService.getStartTime(), 0); + + // Verify DB status reverted to draft + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + var rs = tx.executeSql("SELECT status FROM account_analytic_line_app WHERE id = 1"); + compare(rs.rows.item(0).status, "draft"); + }); + } + + function test_stopPausedTimer() { + TimerService.start(1); + TimerService.pause(); + var elapsed = TimerService.stop(); + + verify(elapsed !== undefined); + compare(TimerService.isRunning(), false); + compare(TimerService.isPaused(), false); + compare(TimerService.getActiveTimesheetId(), null); + } + + function test_switchTimesheetAutoSavesPrevious() { + TimerService.start(1); + compare(TimerService.getActiveTimesheetId(), 1); + + // Switch to timesheet 2 + TimerService.start(2); + compare(TimerService.getActiveTimesheetId(), 2); + compare(TimerService.isRunning(), true); + + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Timesheet 1 should be reverted to draft and have duration updated + var rs1 = tx.executeSql("SELECT status, unit_amount FROM account_analytic_line_app WHERE id = 1"); + compare(rs1.rows.item(0).status, "draft"); + + // Timesheet 2 should be active + var rs2 = tx.executeSql("SELECT status FROM account_analytic_line_app WHERE id = 2"); + compare(rs2.rows.item(0).status, "active"); + }); + } + + function test_stopFinalizedTimesheetPreservesStatus() { + TimerService.start(1); + compare(TimerService.getActiveTimesheetId(), 1); + + // Simulate timesheet being finalized/updated while timer was active + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("UPDATE account_analytic_line_app SET status = 'updated' WHERE id = 1"); + }); + + // Stop timer + TimerService.stop(); + compare(TimerService.isRunning(), false); + + // Verify status is still updated, NOT reverted to draft + db.transaction(function(tx) { + var rs = tx.executeSql("SELECT status FROM account_analytic_line_app WHERE id = 1"); + compare(rs.rows.item(0).status, "updated"); + }); + } + + function test_resetClearsStateWithoutDbModification() { + TimerService.start(1); + compare(TimerService.isRunning(), true); + + TimerService.reset(); + compare(TimerService.isRunning(), false); + compare(TimerService.isPaused(), false); + compare(TimerService.getActiveTimesheetId(), null); + compare(TimerService.getStartTime(), 0); + compare(TimerService.getActiveTimesheetName(), ""); + } + } +} diff --git a/tests/qml/tst_utils.qml b/tests/qml/tst_utils.qml new file mode 100644 index 00000000..519bd628 --- /dev/null +++ b/tests/qml/tst_utils.qml @@ -0,0 +1,259 @@ +import QtQuick 2.7 +import QtTest 1.0 +import "../../models/utils.js" as Utils + +Item { + width: 200 + height: 200 + + TestCase { + name: "UtilsTests" + when: windowShown + + // --- URL Validation and Cleaning --- + function test_validateAndCleanOdooURL_invalid() { + var emptyRes = Utils.validateAndCleanOdooURL(""); + compare(emptyRes.isValid, false); + compare(emptyRes.cleanedUrl, ""); + + var nullRes = Utils.validateAndCleanOdooURL(null); + compare(nullRes.isValid, false); + + var undefinedRes = Utils.validateAndCleanOdooURL(undefined); + compare(undefinedRes.isValid, false); + + var protoOnly = Utils.validateAndCleanOdooURL("https://"); + compare(protoOnly.isValid, false); + + var spaces = Utils.validateAndCleanOdooURL("not a valid url"); + compare(spaces.isValid, false); + } + + function test_validateAndCleanOdooURL_valid() { + var standard = Utils.validateAndCleanOdooURL("example.odoo.com"); + compare(standard.isValid, true); + compare(standard.cleanedUrl, "https://example.odoo.com"); + + var withHttp = Utils.validateAndCleanOdooURL("http://myerp.local/"); + compare(withHttp.isValid, true); + compare(withHttp.cleanedUrl, "http://myerp.local"); + + var localhostWithPort = Utils.validateAndCleanOdooURL("localhost:8069///"); + compare(localhostWithPort.isValid, true); + compare(localhostWithPort.cleanedUrl, "https://localhost:8069"); + + var ipAddr = Utils.validateAndCleanOdooURL("http://192.168.1.50:8069"); + compare(ipAddr.isValid, true); + compare(ipAddr.cleanedUrl, "http://192.168.1.50:8069"); + } + + // --- Text Manipulation Utilities --- + function test_stripHtmlTags() { + compare(Utils.stripHtmlTags(""), ""); + compare(Utils.stripHtmlTags(null), ""); + compare(Utils.stripHtmlTags(undefined), ""); + compare(Utils.stripHtmlTags("Plain text content"), "Plain text content"); + compare(Utils.stripHtmlTags("

Paragraph with bold and italic

"), "Paragraph with bold and italic"); + compare(Utils.stripHtmlTags(""), "Link"); + } + + function test_cleanText() { + compare(Utils.cleanText(null), ""); + compare(Utils.cleanText(undefined), ""); + compare(Utils.cleanText(12345), ""); + compare(Utils.cleanText(" Hello World "), "Hello World"); + compare(Utils.cleanText("Special space test"), "Special space test"); + // Control characters stripping + var withControlChars = "Clean\u0000ed\u0007 text\u200B"; + compare(Utils.cleanText(withControlChars), "Cleaned text"); + } + + function test_truncateText() { + compare(Utils.truncateText(null, 10), ""); + compare(Utils.truncateText("Short", 10), "Short"); + compare(Utils.truncateText("Exact length", 12), "Exact length"); + compare(Utils.truncateText("This is a much longer description text", 10), "This is a..."); + // HTML within truncated text + var htmlInput = "

Description with formatting that needs truncation

"; + var truncated = Utils.truncateText(htmlInput, 15); + verify(truncated.indexOf("<") === -1); + verify(truncated.indexOf("...") !== -1); + } + + function test_getColorFromOdooIndex() { + compare(Utils.getColorFromOdooIndex(0), "transparent"); + compare(Utils.getColorFromOdooIndex(1), "#EB6E67"); + compare(Utils.getColorFromOdooIndex(2), "#F39C5A"); + compare(Utils.getColorFromOdooIndex(11), "#9B6CC3"); + // Modulo wrapping + compare(Utils.getColorFromOdooIndex(12), "transparent"); + compare(Utils.getColorFromOdooIndex(13), "#EB6E67"); + } + + // --- Time and Duration Conversions --- + function test_convertHHMMtoDecimalHours() { + // Number pass-through + compare(Utils.convertHHMMtoDecimalHours(3.5), 3.5); + + // Invalid or empty inputs + compare(Utils.convertHHMMtoDecimalHours(""), 0); + compare(Utils.convertHHMMtoDecimalHours(null), 0); + compare(Utils.convertHHMMtoDecimalHours("invalid"), 0); + + // Numeric string without colon + compare(Utils.convertHHMMtoDecimalHours("4.25"), 4.25); + + // HH:MM format + compare(Utils.convertHHMMtoDecimalHours("1:30"), 1.5); + compare(Utils.convertHHMMtoDecimalHours("0:45"), 0.75); + compare(Utils.convertHHMMtoDecimalHours("2:15"), 2.25); + compare(Utils.convertHHMMtoDecimalHours("0:00"), 0); + + // HH:MM:SS format + compare(Utils.convertHHMMtoDecimalHours("1:30:00"), 1.5); + compare(Utils.convertHHMMtoDecimalHours("0:30:36"), 0.51); + + // Malformed colon strings + compare(Utils.convertHHMMtoDecimalHours("foo:bar"), 0); + compare(Utils.convertHHMMtoDecimalHours("1:2:3:4"), 0); + } + + function test_convertDecimalHoursToHHMM() { + compare(Utils.convertDecimalHoursToHHMM(0), "0:00"); + compare(Utils.convertDecimalHoursToHHMM(1.5), "1:30"); + compare(Utils.convertDecimalHoursToHHMM(2.25), "2:15"); + compare(Utils.convertDecimalHoursToHHMM(11.8333), "11:50"); + compare(Utils.convertDecimalHoursToHHMM(0.0833), "0:05"); + } + + function test_convertDurationToFloat() { + compare(Utils.convertDurationToFloat(2.5), 2.5); + compare(Utils.convertDurationToFloat(""), 0); + compare(Utils.convertDurationToFloat(null), 0); + compare(Utils.convertDurationToFloat("2:30"), 2.5); + compare(Utils.convertDurationToFloat("1:15"), 1.25); + compare(Utils.convertDurationToFloat("1:30:36"), 1.51); + } + + // --- Date Formatting and Conversions --- + function test_formatDate() { + var date = new Date(2026, 8, 25); // Month is 0-indexed: 8 = September + compare(Utils.formatDate(date), "9/25/2026"); + + var newYears = new Date(2027, 0, 1); + compare(Utils.formatDate(newYears), "1/1/2027"); + } + + function test_convertToISODate() { + compare(Utils.convertToISODate(""), ""); + compare(Utils.convertToISODate(null), ""); + compare(Utils.convertToISODate("9/24/2025"), "2025-09-24"); + compare(Utils.convertToISODate("12/5/2026"), "2026-12-05"); + compare(Utils.convertToISODate("01/02/2025"), "2025-01-02"); + compare(Utils.convertToISODate("invalid-format"), "invalid-format"); + } + + function test_extractDate() { + compare(Utils.extractDate("2025-06-23T13:53:42.834Z"), "2025-06-23"); + compare(Utils.extractDate("2026-12-31T00:00:00.000"), "2026-12-31"); + } + + function test_getTodayAndTomorrowFormat() { + var today = Utils.getToday(); + compare(typeof today, "string"); + compare(today.length, 10); + verify(today.match(/^\d{4}-\d{2}-\d{2}$/) !== null); + + var tomorrow = Utils.getTomorrow(); + compare(typeof tomorrow, "string"); + compare(tomorrow.length, 10); + verify(tomorrow.match(/^\d{4}-\d{2}-\d{2}$/) !== null); + + var diffDays = (new Date(tomorrow) - new Date(today)) / (1000 * 60 * 60 * 24); + compare(Math.round(diffDays), 1); + } + + function test_getYesterday() { + var yesterday = Utils.getYesterday(); + compare(typeof yesterday, "string"); + verify(yesterday.indexOf("T") !== -1); + verify(new Date(yesterday).getTime() < Date.now()); + } + + function test_getNextWeekRange() { + var range = Utils.getNextWeekRange(); + verify(range.start !== undefined); + verify(range.end !== undefined); + compare(range.start.length, 10); + compare(range.end.length, 10); + + var startParts = range.start.split("-"); + var localStartDate = new Date(parseInt(startParts[0]), parseInt(startParts[1]) - 1, parseInt(startParts[2])); + var endParts = range.end.split("-"); + var localEndDate = new Date(parseInt(endParts[0]), parseInt(endParts[1]) - 1, parseInt(endParts[2])); + + verify(localStartDate <= localEndDate); + compare(localStartDate.getDay(), 1); + } + + function test_getNextWeekSameDay() { + var nextWeek = Utils.getNextWeekSameDay("2026-09-10"); + compare(nextWeek, "2026-09-17"); + + var leapYear = Utils.getNextWeekSameDay("2024-02-25"); + compare(leapYear, "2024-03-03"); + } + + function test_getNextMonthRange() { + var range = Utils.getNextMonthRange(); + verify(range.start !== undefined); + verify(range.end !== undefined); + compare(range.start.slice(8, 10), "01"); + verify(new Date(range.start) <= new Date(range.end)); + } + + function test_getNextMonthSameDay() { + var nextMonth = Utils.getNextMonthSameDay("2026-01-15"); + compare(nextMonth, "2026-02-15"); + + // January 31 -> February end clamping + var clamped = Utils.getNextMonthSameDay("2026-01-31"); + compare(clamped, "2026-02-28"); + + // December to January year rollover + var yearRoll = Utils.getNextMonthSameDay("2026-12-10"); + compare(yearRoll, "2027-01-10"); + } + + function test_getFormattedTimestampUTC() { + var timestamp = Utils.getFormattedTimestampUTC(); + compare(typeof timestamp, "string"); + verify(timestamp.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/) !== null); + } + + function test_getTimeStatusInText() { + compare(Utils.getTimeStatusInText(null), "N/A"); + compare(Utils.getTimeStatusInText(""), "N/A"); + compare(Utils.getTimeStatusInText("not-a-date"), "Invalid"); + + // Today + var todayStr = new Date().toISOString(); + compare(Utils.getTimeStatusInText(todayStr), "Due today"); + + // Tomorrow + var tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + compare(Utils.getTimeStatusInText(tomorrow.toISOString()), "1 day remaining"); + + // Future 5 days + var future = new Date(); + future.setDate(future.getDate() + 5); + compare(Utils.getTimeStatusInText(future.toISOString()), "5 days remaining"); + + // Past 3 days overdue + var past = new Date(); + past.setDate(past.getDate() - 3); + compare(Utils.getTimeStatusInText(past.toISOString()), "3 days overdue"); + } + } +} From 4181fb33280c75ae6254089c465d645f2f96400a Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Thu, 1 Oct 2026 13:40:36 +0530 Subject: [PATCH 17/34] Organize JS tests into models subfolder Group JavaScript core model test suites under tests/qml/models/ for clarity. --- tests/qml/README.md | 11 ++++++----- tests/qml/{ => models}/tst_constants.qml | 2 +- tests/qml/{ => models}/tst_logger.qml | 2 +- tests/qml/{ => models}/tst_timer_service.qml | 10 +++++----- tests/qml/{ => models}/tst_utils.qml | 2 +- 5 files changed, 14 insertions(+), 13 deletions(-) rename tests/qml/{ => models}/tst_constants.qml (97%) rename tests/qml/{ => models}/tst_logger.qml (99%) rename tests/qml/{ => models}/tst_timer_service.qml (97%) rename tests/qml/{ => models}/tst_utils.qml (99%) diff --git a/tests/qml/README.md b/tests/qml/README.md index 53137a08..0ea03530 100644 --- a/tests/qml/README.md +++ b/tests/qml/README.md @@ -22,20 +22,21 @@ clickable test -- -functions test_openDisplaysMessageAndEmitsSignal tests/ ├── qml/ │ ├── README.md # This guide -│ ├── tst_constants.qml # Test suite for constants and quadrant color configuration +│ ├── models/ # JavaScript core and model test suites +│ │ ├── tst_constants.qml # Test suite for constants and quadrant color configuration +│ │ ├── tst_logger.qml # Test suite for logger levels, formatting, and PII sanitization +│ │ ├── tst_timer_service.qml # Test suite for timer service state transitions and duration +│ │ └── tst_utils.qml # Test suite for date, time, duration, and URL utilities │ ├── tst_customdatepicker.qml # Test suite for CustomDatePicker picker component │ ├── tst_globaltimerwidget.qml # Test suite for GlobalTimerWidget system component │ ├── tst_infobar.qml # Test suite for InfoBar feedback component -│ ├── tst_logger.qml # Test suite for logger levels, formatting, and PII sanitization │ ├── tst_outlinedtextfield.qml # Test suite for OutlinedTextField text input component -│ ├── tst_timer_service.qml # Test suite for timer service state transitions and duration │ ├── tst_tsbutton.qml # Test suite for TSButton action button component │ ├── tst_tsiconbutton.qml # Test suite for TSIconButton circular icon button component │ ├── tst_tslabel.qml # Test suite for TSLabel styled text component │ ├── tst_tsprogressbar.qml # Test suite for TSProgressbar progress indicator │ ├── tst_tssearchbar.qml # Test suite for TSSearchBar search input component -│ ├── tst_tsswitch.qml # Test suite for TSSwitch toggle switch component -│ └── tst_utils.qml # Test suite for date, time, duration, and URL utilities +│ └── tst_tsswitch.qml # Test suite for TSSwitch toggle switch component ``` Clickable automatically discovers all files matching `tst_*.qml` under `tests/qml/` as configured in `clickable.yaml`. diff --git a/tests/qml/tst_constants.qml b/tests/qml/models/tst_constants.qml similarity index 97% rename from tests/qml/tst_constants.qml rename to tests/qml/models/tst_constants.qml index 75752348..7f053fb7 100644 --- a/tests/qml/tst_constants.qml +++ b/tests/qml/models/tst_constants.qml @@ -1,6 +1,6 @@ import QtQuick 2.7 import QtTest 1.0 -import "../../models/constants.js" as Constants +import "../../../models/constants.js" as Constants Item { width: 200 diff --git a/tests/qml/tst_logger.qml b/tests/qml/models/tst_logger.qml similarity index 99% rename from tests/qml/tst_logger.qml rename to tests/qml/models/tst_logger.qml index dcd5eee3..8576f3b9 100644 --- a/tests/qml/tst_logger.qml +++ b/tests/qml/models/tst_logger.qml @@ -1,6 +1,6 @@ import QtQuick 2.7 import QtTest 1.0 -import "../../models/logger.js" as Logger +import "../../../models/logger.js" as Logger Item { width: 200 diff --git a/tests/qml/tst_timer_service.qml b/tests/qml/models/tst_timer_service.qml similarity index 97% rename from tests/qml/tst_timer_service.qml rename to tests/qml/models/tst_timer_service.qml index 78ae55de..f2957ed8 100644 --- a/tests/qml/tst_timer_service.qml +++ b/tests/qml/models/tst_timer_service.qml @@ -1,11 +1,11 @@ import QtQuick 2.7 import QtTest 1.0 import QtQuick.LocalStorage 2.7 as Sql -import "../../models/constants.js" as Constants -import "../../models/logger.js" as Logger -import "../../models/database.js" as DBCommon -import "../../models/timesheet.js" as Model -import "../../models/timer_service.js" as TimerService +import "../../../models/constants.js" as Constants +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/timesheet.js" as Model +import "../../../models/timer_service.js" as TimerService Item { width: 200 diff --git a/tests/qml/tst_utils.qml b/tests/qml/models/tst_utils.qml similarity index 99% rename from tests/qml/tst_utils.qml rename to tests/qml/models/tst_utils.qml index 519bd628..cf851da1 100644 --- a/tests/qml/tst_utils.qml +++ b/tests/qml/models/tst_utils.qml @@ -1,6 +1,6 @@ import QtQuick 2.7 import QtTest 1.0 -import "../../models/utils.js" as Utils +import "../../../models/utils.js" as Utils Item { width: 200 From 5d73391f5ee65eca47a10b888aee767531d41582 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Mon, 5 Oct 2026 13:20:03 +0530 Subject: [PATCH 18/34] Remove scratch scripts, unused assets, and broken symlink Delete unreferenced test scripts, obsolete image variants, and the broken pulse socket to reduce package bloat (#371). --- pulse-socket | 1 - qml/images/cross.svg | 10 - qml/images/cross_wait.svg | 11 - qml/images/delete.png | Bin 5109 -> 0 bytes qml/images/followup.png | Bin 51433 -> 0 bytes qml/images/hide.png | Bin 14714 -> 0 bytes qml/images/home.svg | 1 - qml/images/pause.svg | 5 - qml/images/play.svg | 4 - qml/images/refreshBlack.svg | 1 - qml/images/reload.png | Bin 14287 -> 0 bytes qml/images/right.svg | 27 -- qml/images/save.png | Bin 11604 -> 0 bytes qml/images/save.svg | 1 - qml/images/setting.svg | 1 - qml/images/start.png | Bin 58109 -> 0 bytes qml/images/start.svg | 40 --- qml/images/stop.svg | 4 - qml/images/timeManagemetLogo.png | Bin 58399 -> 0 bytes qml/images/timemanagementapp_logo.jpg | Bin 75101 -> 0 bytes .../timemanagementapp_logo_only_white.png | Bin 18731 -> 0 bytes qml/images/timesheets_large_logo.png | Bin 40197 -> 0 bytes qml/images/timesheets_small_logo.png | Bin 1616 -> 0 bytes src/add_demo_connection.py | 68 ---- src/autotester.py | 292 ------------------ src/cli.py | 57 ---- src/tool_field_sync.py | 81 ----- src/tool_get_remote_record.py | 89 ------ 28 files changed, 693 deletions(-) delete mode 120000 pulse-socket delete mode 100755 qml/images/cross.svg delete mode 100755 qml/images/cross_wait.svg delete mode 100755 qml/images/delete.png delete mode 100644 qml/images/followup.png delete mode 100755 qml/images/hide.png delete mode 100755 qml/images/home.svg delete mode 100644 qml/images/pause.svg delete mode 100644 qml/images/play.svg delete mode 100755 qml/images/refreshBlack.svg delete mode 100755 qml/images/reload.png delete mode 100755 qml/images/right.svg delete mode 100755 qml/images/save.png delete mode 100755 qml/images/save.svg delete mode 100755 qml/images/setting.svg delete mode 100755 qml/images/start.png delete mode 100755 qml/images/start.svg delete mode 100644 qml/images/stop.svg delete mode 100755 qml/images/timeManagemetLogo.png delete mode 100755 qml/images/timemanagementapp_logo.jpg delete mode 100755 qml/images/timemanagementapp_logo_only_white.png delete mode 100755 qml/images/timesheets_large_logo.png delete mode 100755 qml/images/timesheets_small_logo.png delete mode 100755 src/add_demo_connection.py delete mode 100644 src/autotester.py delete mode 100755 src/cli.py delete mode 100755 src/tool_field_sync.py delete mode 100755 src/tool_get_remote_record.py diff --git a/pulse-socket b/pulse-socket deleted file mode 120000 index f67f038c..00000000 --- a/pulse-socket +++ /dev/null @@ -1 +0,0 @@ -/run/user/1000/pulse/native \ No newline at end of file diff --git a/qml/images/cross.svg b/qml/images/cross.svg deleted file mode 100755 index 3c338838..00000000 --- a/qml/images/cross.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/qml/images/cross_wait.svg b/qml/images/cross_wait.svg deleted file mode 100755 index bab06190..00000000 --- a/qml/images/cross_wait.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/qml/images/delete.png b/qml/images/delete.png deleted file mode 100755 index 0a466f809d780d07e5764d42213d18acbe8ac964..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5109 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 diff --git a/qml/images/followup.png b/qml/images/followup.png deleted file mode 100644 index 3ae748939cbf3343cf1afce0a53a3d4966fd24e1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 51433 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 |F5c$$G6&8xj}g` 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 UJ{tO1eq#UvPgg&ebxsLQ02#9BxBvhE diff --git a/qml/images/hide.png b/qml/images/hide.png deleted file mode 100755 index 62dc80a99b880f2791847fa8b105db150c8a3908..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14714 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 E58bsu`v3p{ diff --git a/qml/images/home.svg b/qml/images/home.svg deleted file mode 100755 index af1854f7..00000000 --- a/qml/images/home.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/qml/images/pause.svg b/qml/images/pause.svg deleted file mode 100644 index 1df8c309..00000000 --- a/qml/images/pause.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/qml/images/play.svg b/qml/images/play.svg deleted file mode 100644 index 2a5d692e..00000000 --- a/qml/images/play.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/qml/images/refreshBlack.svg b/qml/images/refreshBlack.svg deleted file mode 100755 index 4fde5498..00000000 --- a/qml/images/refreshBlack.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/qml/images/reload.png b/qml/images/reload.png deleted file mode 100755 index dbce977c4e91c514d09f0cdd5b64303ad5d4ad6a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14287 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 - - -Created by potrace 1.15, written by Peter Selinger 2001-2017 - - - - - diff --git a/qml/images/save.png b/qml/images/save.png deleted file mode 100755 index 4391edf451e793676e4392c51691ce0c8f585503..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11604 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 \ No newline at end of file diff --git a/qml/images/setting.svg b/qml/images/setting.svg deleted file mode 100755 index 3a36c4bb..00000000 --- a/qml/images/setting.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/qml/images/start.png b/qml/images/start.png deleted file mode 100755 index 97275b71b52e3ffc162024a6a07f727d8794a0f8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 58109 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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/qml/images/stop.svg b/qml/images/stop.svg deleted file mode 100644 index 5bd79d3f..00000000 --- a/qml/images/stop.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/qml/images/timeManagemetLogo.png b/qml/images/timeManagemetLogo.png deleted file mode 100755 index 215b89d135870f74d7bc2d15e5d9298a163d29fe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 58399 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

*- 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

cEy#CO3DT6Kyc=Zzq*(kPdF> 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 C;c09D diff --git a/qml/images/timemanagementapp_logo_only_white.png b/qml/images/timemanagementapp_logo_only_white.png deleted file mode 100755 index 8f448d67c8a37825103a1dc71407b486d21e126f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18731 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 P00000NkvXXu0mjfY+k)- diff --git a/qml/images/timesheets_large_logo.png b/qml/images/timesheets_large_logo.png deleted file mode 100755 index 012aa831bfb0b923ddaf58a0e5d1ddb288bcd10a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40197 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 N-%-<2EmScJ{6EqYp9uf} diff --git a/qml/images/timesheets_small_logo.png b/qml/images/timesheets_small_logo.png deleted file mode 100755 index 831fe93055528a77a339b9af295e2a2a2db796f3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1616 zcmV-W2Cw;vP)P`Dx6lKHm$h9+m82R&1p-fQOMSWaaOaCh)SWkkSZVN;9~GUBWJ5m#|CNCF~M*38xHx z*^fEdi>RceNZO#ke~`w;FH=cz5#{78((Xl@v9Zh4KQI{6ro1e~%kJ=LUW&Fxi04)a z-ss^MFmNeKV^h)D6UM-gecB*xuzq6=AK1Pq!r zhj=O48X=y`@NmFKHOu{^Oh!rZ+{AyY^-oeZWAOT~QxyI?;+!z7)ZH^P+hSni9?z@* zg0P^l^18}$)4Hew??{9LD>6B;`t!MraD;-v+xYKi@&nZM`3e5Ke>}p8J0gsMtlv<> z|NraH6El`o@9m(1xv(HCv!p=XuBL=;Tzh8*FMnA1DD}5>GO__4Mu4IWbBiCkWhGTk zo}}%CemZ#eYU)L6U1gc{|73w!-L%X{FZNzxp)$?1~Pn5Q7eVp3DaWtJN!))Xui{EGWyh6<%v{PMuyH+2Q1mYWdh!sk4 zZ$?-{WBjrN_bk1C6;&{AX+7G>tqeE*xMmYwTC$8D*;vg)bVBQE=-ddEO#ha83xi}S zvj!CV6E;vY!XTljaoE)pFJ4$<`@ZYp!lW0}_Zt+NUcE*mEBba{E3GCiKJe%^wzSIJ(+=vknaLD zA84VyZ|u;1ACLNC)_u5h_cuLUr8`C=^I_rBG0BQkz@@RSzIj&$b+Eof`g*G}NClLo zS-U(mLGAZu@}((Js(Ia1x|^$RuWgkE^lq!SGQ_L;=ASA1jywvp^_YT16jYz*Pn@Uc zE}rBQ^VP_-vrKC)U?($+QE3yx0LUP9++KKrwiS4oh+WhYlYx9cz0^0wpF7T_^?0kX zMJ_mPo!o**??jr1x@u(8VSheHpUspSDye!I~3v609X zYxVVJ&W(i^%t?h!LXHVT5s|0HDJM(Xn-8^XF<8L1YOP1V))M(wtDlaE$C+hgt#s*v zn&WVwLXw!EPizA#Op+GSKi1#U;^lGAk9Ty(9Yk=zrP&oVUi%x4M9uKnhH74?D|{;E z9+!Z)$1@qwztwxt&T0(C_Zfk)$#^AnY5nrDnOijA$d%v$NHb;0iEJ5z2zIGCc2G?& zjD5(2QPg!D!x)5cAMAjf5?0O)n=U)X_vj}{xIIW%^>J;Cq1h46&b}t~ed^*Q@l;ZT;Yu&O{5I`n8}9es zt>vzQdl~kz#6Hns95(M5lvySQeL%0f?q(MUUTX;`(7HO4?i zT*yd+j2)M=Ist(;IMD}8G)TfI7^B|Oj$&B2h!tsAqfdQ-*?Bhlsk5*eCbv~pU?&i?sO-}##1N^Y5XU%G*Y#=j zDb`N=|KtI{ob9XAO|)FXE@79jOV}ms5_So@gk8ce;W;FHDxC=bE5HCHRig8Vca)C+ O0000 diff --git a/src/add_demo_connection.py b/src/add_demo_connection.py deleted file mode 100755 index e85d0cb2..00000000 --- a/src/add_demo_connection.py +++ /dev/null @@ -1,68 +0,0 @@ -# -*- coding: utf-8 -*- -# MIT License -# -# Copyright (c) 2025 CIT-Services -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -import sqlite3 -import os -from datetime import datetime - - -def add_odoo_account( - db_path="app_settings.db", - name="Onestein Demo", - url="https://demo16.onestein.eu/", - database="project_demo", - username="admin", - api_key=None, - connectwith_id=None, -): - if not os.path.exists(db_path): - print(f"Database {db_path} does not exist. Please initialize it first.") - return - - with sqlite3.connect(db_path) as conn: - cursor = conn.cursor() - - # Insert account into users table - cursor.execute( - """ - INSERT INTO users (name, link, last_modified, database, connectwith_id, api_key, username) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - ( - name, - url, - datetime.now().isoformat(), - database, - connectwith_id, - api_key, - username, - ), - ) - - conn.commit() - conn.close() - print("✅ Odoo account added successfully.") - - -if __name__ == "__main__": - add_odoo_account() diff --git a/src/autotester.py b/src/autotester.py deleted file mode 100644 index 699e3ca1..00000000 --- a/src/autotester.py +++ /dev/null @@ -1,292 +0,0 @@ -# -*- coding: utf-8 -*- -# MIT License -# -# Copyright (c) 2025 CIT-Services -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -import shutil -import time -from pathlib import Path -from abc import ABC, abstractmethod -from sync_from_odoo import sync_all_from_odoo -from sync_to_odoo import sync_all_to_odoo -from odoo_client import OdooClient -from config import get_all_accounts - -import sqlite3 -from datetime import datetime - -def get_timestamp(): - return datetime.now().strftime("%Y-%m-%d %H:%M:%S") - -def execute_sql(db_path, sql, values=(), fetch=False): - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - print(f"[SQL] Executing:\n {sql}\n [VALUES] {values}") - cursor.execute(sql, values) - - result = cursor.fetchall() if fetch else None - print(f"***************[SQL] Rows affected: {cursor.rowcount}*******************") - - conn.commit() - conn.close() - - return result - -# ... [keep all your import and helper function sections unchanged] - -class TestCaseBase(ABC): - def __init__(self, temp_db_path, account_index, account_id): - self.temp_db_path = temp_db_path - self.account_index = account_index - self.account_id = account_id - self.data = {} - self.client = self.get_client() - self.result = {"status": "Not Run", "message": ""} - - def mark_success(self, message=""): - self.result["status"] = "Passed" - self.result["message"] = message - - def mark_failure(self, message=""): - self.result["status"] = "Failed" - self.result["message"] = message - - def get_client(self): - accounts = get_all_accounts(self.temp_db_path) - selected = accounts[self.account_index] - return OdooClient( - selected["link"], - selected["database"], - selected["username"], - selected["api_key"] - ) - - def verify_project_exists(self, name): - ids = self.client.search("project.project", [["name", "=", name]]) - return bool(ids) - - def verify_task_exists(self, task_name): - ids = self.client.search("project.task", [["name", "=", task_name]]) - return bool(ids) - - def sync_push(self): - sync_all_to_odoo(self.client, self.account_id, self.temp_db_path) - - def sync_pull(self): - sync_all_from_odoo(self.client, self.account_id, self.temp_db_path) - - @abstractmethod - def run(self): - pass - - @abstractmethod - def check(self): - pass - - -# --- Test Cases (corrected with closing parens and proper field access) - -class CreateProjectTest(TestCaseBase): - def __init__(self, *args): - super().__init__(*args) - self.data = {"name": "ProjectA"} - - def run(self): - ts = get_timestamp() - execute_sql( - self.temp_db_path, - "INSERT INTO project_project_app (name, account_id, last_modified, status, last_update_status) VALUES (?, ?, ?, ?, ?)", - (self.data["name"], self.account_id, ts, "updated", "to_define") - ) - print(f"[TEST] Created project '{self.data['name']}'") - self.sync_push() - - def check(self): - if self.verify_project_exists(self.data["name"]): - self.mark_success(f"[PASS] Project '{self.data['name']}' exists in Odoo.") - else: - self.mark_failure(f"[FAIL] Project '{self.data['name']}' not found in Odoo!") - - -class UpdateProjectTest(TestCaseBase): - def __init__(self, *args): - super().__init__(*args) - self.data = {"old_name": "ProjectA", "new_name": "ProjectB"} - - def run(self): - ts = get_timestamp() - execute_sql( - self.temp_db_path, - "UPDATE project_project_app SET name = ?, last_modified = ?, status = 'updated' WHERE name = ?", - (self.data["new_name"], ts, self.data["old_name"]) - ) - print(f"[TEST] Renamed project '{self.data['old_name']}' → '{self.data['new_name']}'") - self.sync_push() - - def check(self): - if self.verify_project_exists(self.data["new_name"]): - self.mark_success(f"[PASS] Project '{self.data['new_name']}' exists in Odoo.") - else: - self.mark_failure(f"[FAIL] Project '{self.data['new_name']}' not found in Odoo!") - - -class DeleteProjectTest(TestCaseBase): - def __init__(self, *args): - super().__init__(*args) - self.data = {"name": "ProjectB"} - - def run(self): - ts = get_timestamp() - execute_sql( - self.temp_db_path, - "UPDATE project_project_app SET status = 'deleted', last_modified = ? WHERE name = ?", - (ts, self.data["name"]) - ) - print(f"[TEST] Marked project '{self.data['name']}' for deletion") - self.sync_push() - - def check(self): - if not self.verify_project_exists(self.data["name"]): - self.mark_success(f"[PASS] Project '{self.data['name']}' is deleted from Odoo.") - else: - self.mark_failure(f"[FAIL] Project '{self.data['name']}' still exists in Odoo!") - - -class CreateTask(TestCaseBase): - def __init__(self, *args): - super().__init__(*args) - self.data = {"name": "ProjectA", "task_name": "TaskForProject"} - - def run(self): - ts = get_timestamp() - """row = execute_sql( - self.temp_db_path, - "SELECT odoo_record_id FROM project_project_app WHERE name = ?", - (self.data["name"],), - fetch=True - ) - if not row or not row[0][0]: - print(f"[ERROR] Odoo project ID not found for '{self.data['name']}'") - return - - odoo_project_id = int(row[0][0]) - """ - execute_sql( - self.temp_db_path, - "INSERT INTO project_task_app (name, project_id, account_id, last_modified, status) VALUES (?, ?, ?, ?, ?)", - (self.data["task_name"], 0, self.account_id, ts, "updated") - ) - print(f"[TEST] Created task '{self.data['task_name']}' under project '{self.data['name']}'") - self.sync_push() - - def check(self): - if self.verify_task_exists(self.data["task_name"]): - self.mark_success(f"[PASS] Task '{self.data['task_name']}' exists in Odoo.") - else: - self.mark_failure(f"[FAIL] Task '{self.data['task_name']}' not found in Odoo!") - - -class DeleteTask(TestCaseBase): - def __init__(self, *args): - super().__init__(*args) - self.data = {"task_name": "TaskForProject"} - - def run(self): - ts = get_timestamp() - row = execute_sql( - self.temp_db_path, - "SELECT id FROM project_task_app WHERE name = ?", - (self.data["task_name"],), - fetch=True - ) - if not row: - print(f"[ERROR] Task '{self.data['task_name']}' not found.") - return - - execute_sql( - self.temp_db_path, - "UPDATE project_task_app SET status = 'deleted', last_modified = ? WHERE name = ?", - (ts, self.data["task_name"]) - ) - print(f"[TEST] Marked task '{self.data['task_name']}' for deletion") - self.sync_push() - - def check(self): - if not self.verify_task_exists(self.data["task_name"]): - self.mark_success(f"[PASS] Task '{self.data['task_name']}' is deleted from Odoo.") - else: - self.mark_failure(f"[FAIL] Task '{self.data['task_name']}' still exists in Odoo!") - - -# --- Runner with summary - -class OdooTestRunner: - def __init__(self): - self.original_db = self.get_original_db_path() - self.temp_db_path = "test_data/temp_app.db" - self.account_index = 0 - self.account_id = 1 - - def get_original_db_path(self, db_filename="8912ff6b21d680ad2a2832070dca24d0.sqlite"): - base_path = Path.home() / ".clickable" / "home" / ".local" / "share" / "ubtms" / "Databases" - full_path = base_path / db_filename - if not full_path.exists(): - raise FileNotFoundError(f"Database not found: {full_path}") - return str(full_path) - - def setup_temp_db(self): - shutil.copyfile(self.original_db, self.temp_db_path) - print(f"[SETUP] Copied original DB to temp: {self.temp_db_path}") - - def run_all_tests(self): - self.setup_temp_db() - """ - test_sequence = [ - CreateProjectTest, - CreateTask, - DeleteTask, - UpdateProjectTest, - DeleteProjectTest, - ] - """ - test_sequence = [ - CreateTask, - DeleteTask, - ] - test_instances = [] - for test_cls in test_sequence: - test = test_cls(self.temp_db_path, self.account_index, self.account_id) - try: - test.run() - test.check() - except Exception as e: - test.mark_failure(str(e)) - test_instances.append((test_cls.__name__, test.result)) - - print("\n=== TEST SUMMARY ===") - for name, result in test_instances: - print(f"{name}: {result['status']} - {result['message']}") - - -if __name__ == "__main__": - runner = OdooTestRunner() - runner.run_all_tests() diff --git a/src/cli.py b/src/cli.py deleted file mode 100755 index c85178b9..00000000 --- a/src/cli.py +++ /dev/null @@ -1,57 +0,0 @@ -# -*- coding: utf-8 -*- -# MIT License -# -# Copyright (c) 2025 CIT-Services -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -import os -import glob -from backend import sync - - -def find_settings_db(app_id): - # Construct the base path - home_dir = os.path.expanduser("~") - db_path_pattern = os.path.join( - home_dir, - ".clickable", - "home", - ".local", - "share", - app_id, - "Databases", - "*.sqlite", - ) - - # Find matching database files - db_files = glob.glob(db_path_pattern) - - if not db_files: - raise FileNotFoundError(f"No SQLite database found for app '{app_id}'") - - # If multiple, return the first or select based on logic - return db_files[0] - - -# Usage -app_id = "ubtms" -settings_db = find_settings_db(app_id) -choice = 1 -sync(settings_db, choice) diff --git a/src/tool_field_sync.py b/src/tool_field_sync.py deleted file mode 100755 index a2e6fb84..00000000 --- a/src/tool_field_sync.py +++ /dev/null @@ -1,81 +0,0 @@ -# -*- coding: utf-8 -*- -# MIT License -# -# Copyright (c) 2025 CIT-Services -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -import json -import xmlrpc.client -from config import get_all_accounts - - -def fetch_fields(model_name, models, db, uid, password): - """Fetch field metadata for a given model.""" - return models.execute_kw( - db, - uid, - password, - model_name, - "fields_get", - [], - {"attributes": ["string", "type"]}, - ) - - -def main(): - # Select account - settings_db = "/home/gokul/.clickable/home/.local/share/ubtms/Databases/8912ff6b21d680ad2a2832070dca24d0.sqlite" - accounts = get_all_accounts(settings_db) - print("Available accounts:") - for i, acct in enumerate(accounts): - print(f"{i + 1}. {acct['name']} ({acct['link']})") - - selected_index = int(input("Select account: ")) - 1 - selected = accounts[selected_index] - - # Authenticate - common = xmlrpc.client.ServerProxy(f"{selected['link']}/xmlrpc/2/common") - uid = common.authenticate( - selected["database"], selected["username"], selected["api_key"], {} - ) - - if not uid: - print("❌ Authentication failed.") - return - - models = xmlrpc.client.ServerProxy(f"{selected['link']}/xmlrpc/2/object") - - # Fetch fields - fields_data = {} - for model in ["project.project", "project.task","account.analytic.line","res.users","mail.activity","ir.model","ir.attachment","project.update","project.task.type","project.project.stage"]: - print(f"Fetching fields for {model}...") - fields_data[model] = fetch_fields( - model, models, selected["database"], uid, selected["api_key"] - ) - - # Save to JSON - with open("odoo_config/all_odoo_fields.json", "w") as f: - json.dump(fields_data, f, indent=4) - - print("Field metadata saved to odoo_fields.json") - - -if __name__ == "__main__": - main() diff --git a/src/tool_get_remote_record.py b/src/tool_get_remote_record.py deleted file mode 100755 index 01b9187c..00000000 --- a/src/tool_get_remote_record.py +++ /dev/null @@ -1,89 +0,0 @@ -# -*- coding: utf-8 -*- -# MIT License (see top for full terms) - -import json -import xmlrpc.client -from config import get_all_accounts - - -def fetch_fields(model_name, models, db, uid, password): - """Fetch field metadata for a given model.""" - return models.execute_kw( - db, - uid, - password, - model_name, - "fields_get", - [], - {"attributes": ["string", "type"]}, - ) - - -def fetch_record_by_id(model_name, record_id, models, db, uid, password): - """Fetch all fields of a record with given ID.""" - return models.execute_kw( - db, - uid, - password, - model_name, - 'read', - [[record_id]], - {'fields': []} - )[0] - - -def main(): - # Select account - settings_db = "/home/gokul/.clickable/home/.local/share/ubtms/Databases/8912ff6b21d680ad2a2832070dca24d0.sqlite" - accounts = get_all_accounts(settings_db) - print("Available accounts:") - for i, acct in enumerate(accounts): - print(f"{i + 1}. {acct['name']} ({acct['link']})") - - selected_index = int(input("Select account: ")) - 1 - selected = accounts[selected_index] - - # Authenticate - common = xmlrpc.client.ServerProxy(f"{selected['link']}/xmlrpc/2/common") - uid = common.authenticate( - selected["database"], selected["username"], selected["api_key"], {} - ) - - if not uid: - print("❌ Authentication failed.") - return - - models = xmlrpc.client.ServerProxy(f"{selected['link']}/xmlrpc/2/object") - - # Model menu - model_options = [ - "project.project", - "project.task", - "account.analytic.line", - "res.users", - "mail.activity", - "mail.activity.type" - ] - - print("\nAvailable models:") - for i, model in enumerate(model_options): - print(f"{i + 1}. {model}") - - model_index = int(input("Select a model: ")) - 1 - model_name = model_options[model_index] - - # Record ID - record_id = int(input(f"Enter Odoo record ID for model '{model_name}': ")) - - # Fetch and display record - try: - record = fetch_record_by_id(model_name, record_id, models, selected["database"], uid, selected["api_key"]) - print("\n Record Details:") - for k, v in record.items(): - print(f"{k}: {v}") - except Exception as e: - print(f"❌ Error: {e}") - - -if __name__ == "__main__": - main() From b13cb337d5ef3dc8981a6d23712d07f7456fa441 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Tue, 6 Oct 2026 16:49:25 +0530 Subject: [PATCH 19/34] Add unit tests for storage, database, and draft manager Cover database utilities, schema migrations, draft manager, and global state persistence (#364). --- tests/qml/README.md | 4 + tests/qml/models/tst_database.qml | 249 +++++++++++++++++++++++++ tests/qml/models/tst_dbinit.qml | 116 ++++++++++++ tests/qml/models/tst_draft_manager.qml | 201 ++++++++++++++++++++ tests/qml/models/tst_global.qml | 107 +++++++++++ 5 files changed, 677 insertions(+) create mode 100644 tests/qml/models/tst_database.qml create mode 100644 tests/qml/models/tst_dbinit.qml create mode 100644 tests/qml/models/tst_draft_manager.qml create mode 100644 tests/qml/models/tst_global.qml diff --git a/tests/qml/README.md b/tests/qml/README.md index 0ea03530..9c014819 100644 --- a/tests/qml/README.md +++ b/tests/qml/README.md @@ -24,6 +24,10 @@ tests/ │ ├── README.md # This guide │ ├── models/ # JavaScript core and model test suites │ │ ├── tst_constants.qml # Test suite for constants and quadrant color configuration +│ │ ├── tst_database.qml # Test suite for database constants, row conversions, and local seeders +│ │ ├── tst_dbinit.qml # Test suite for database table initialization and auto-sync settings +│ │ ├── tst_draft_manager.qml # Test suite for form draft saving, loading, diffing, and cleanup +│ │ ├── tst_global.qml # Test suite for global assignee filtering and date range persistence │ │ ├── tst_logger.qml # Test suite for logger levels, formatting, and PII sanitization │ │ ├── tst_timer_service.qml # Test suite for timer service state transitions and duration │ │ └── tst_utils.qml # Test suite for date, time, duration, and URL utilities diff --git a/tests/qml/models/tst_database.qml b/tests/qml/models/tst_database.qml new file mode 100644 index 00000000..cee9b67f --- /dev/null +++ b/tests/qml/models/tst_database.qml @@ -0,0 +1,249 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon + +Item { + width: 200 + height: 200 + + TestCase { + name: "DatabaseTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function test_constants() { + verify(DBCommon.NAME !== undefined); + compare(typeof DBCommon.NAME, "string"); + compare(DBCommon.NAME, "myDatabase"); + + verify(DBCommon.VERSION !== undefined); + compare(typeof DBCommon.VERSION, "string"); + compare(DBCommon.VERSION, "1.0"); + + verify(DBCommon.DISPLAY_NAME !== undefined); + compare(typeof DBCommon.DISPLAY_NAME, "string"); + compare(DBCommon.DISPLAY_NAME, "My Database"); + + verify(DBCommon.SIZE !== undefined); + compare(typeof DBCommon.SIZE, "number"); + compare(DBCommon.SIZE, 1000000); + } + + function test_getTimestamp() { + var ts = DBCommon.getTimestamp(); + verify(ts !== undefined); + compare(typeof ts, "string"); + verify(ts.length > 0); + + var parsed = new Date(ts); + verify(!isNaN(parsed.getTime())); + } + + function test_rowToObjectValid() { + var mockRow = { + id: 42, + name: "Test Task", + active: 1, + notes: null + }; + + var obj = DBCommon.rowToObject(mockRow); + verify(obj !== null); + compare(obj.id, 42); + compare(obj.name, "Test Task"); + compare(obj.active, 1); + compare(obj.notes, null); + } + + function test_rowToObjectInvalidInputs() { + var objNull = DBCommon.rowToObject(null); + verify(typeof objNull === "object"); + compare(Object.keys(objNull).length, 0); + + var objUndefined = DBCommon.rowToObject(undefined); + verify(typeof objUndefined === "object"); + compare(Object.keys(objUndefined).length, 0); + + var objPrimitive = DBCommon.rowToObject("not a row"); + verify(typeof objPrimitive === "object"); + } + + function test_createOrUpdateTable() { + var tableName = "tst_db_sample_table"; + var createSQL = "CREATE TABLE IF NOT EXISTS " + tableName + " (id INTEGER PRIMARY KEY, title TEXT)"; + var initialCols = ["title TEXT"]; + + DBCommon.createOrUpdateTable(tableName, createSQL, initialCols); + + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + var res = tx.executeSql("PRAGMA table_info(" + tableName + ")"); + var colNames = []; + for (var i = 0; i < res.rows.length; i++) { + colNames.push(res.rows.item(i).name); + } + verify(colNames.indexOf("id") !== -1); + verify(colNames.indexOf("title") !== -1); + }); + + // Update table with a new column + var updatedCols = ["title TEXT", "priority INTEGER"]; + DBCommon.createOrUpdateTable(tableName, createSQL, updatedCols); + + db.transaction(function(tx) { + var res = tx.executeSql("PRAGMA table_info(" + tableName + ")"); + var colNames = []; + for (var i = 0; i < res.rows.length; i++) { + colNames.push(res.rows.item(i).name); + } + verify(colNames.indexOf("priority") !== -1); + tx.executeSql("DROP TABLE IF EXISTS " + tableName); + }); + } + + function test_ensureDefaultLocalAccountExists() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + + db.transaction(function(tx) { + tx.executeSql("CREATE TABLE IF NOT EXISTS users (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, link TEXT NOT NULL, " + + "last_modified datetime, database TEXT NOT NULL, connectwith_id INTEGER, " + + "api_key TEXT, username TEXT NOT NULL, is_default INTEGER DEFAULT 0)"); + + tx.executeSql("CREATE TABLE IF NOT EXISTS res_users_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, name TEXT, login TEXT, " + + "email TEXT, work_email TEXT, mobile_phone TEXT, job_title TEXT, company_id INTEGER, " + + "share INTEGER, active INTEGER, status TEXT, odoo_record_id INTEGER)"); + + tx.executeSql("CREATE TABLE IF NOT EXISTS project_project_stage_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, odoo_record_id INTEGER, " + + "name TEXT, sequence INTEGER, fold INTEGER, active INTEGER, create_date TEXT, " + + "write_date TEXT, status TEXT)"); + + tx.executeSql("CREATE TABLE IF NOT EXISTS project_project_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, stage INTEGER)"); + + tx.executeSql("CREATE TABLE IF NOT EXISTS project_task_type_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, odoo_record_id INTEGER, " + + "name TEXT, sequence INTEGER, fold INTEGER, is_global INTEGER, active INTEGER, " + + "create_date TEXT, write_date TEXT, status TEXT)"); + + tx.executeSql("CREATE TABLE IF NOT EXISTS project_task_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, state TEXT)"); + }); + + DBCommon.ensureDefaultLocalAccountExists(); + + db.transaction(function(tx) { + var userRes = tx.executeSql("SELECT * FROM users WHERE id = 0"); + verify(userRes.rows.length > 0); + var userRow = userRes.rows.item(0); + compare(userRow.name, "Local"); + compare(userRow.username, "local_user"); + compare(userRow.is_default, 1); + + var resUserRes = tx.executeSql("SELECT * FROM res_users_app WHERE account_id = 0 AND odoo_record_id = -1"); + verify(resUserRes.rows.length > 0); + var resUserRow = resUserRes.rows.item(0); + compare(resUserRow.name, "Local User"); + compare(resUserRow.login, "local_user"); + }); + } + + function test_ensureDefaultLocalActivityTypes() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + + db.transaction(function(tx) { + tx.executeSql("CREATE TABLE IF NOT EXISTS mail_activity_type_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, name TEXT, " + + "status TEXT, odoo_record_id INTEGER)"); + }); + + DBCommon.ensureDefaultLocalActivityTypes(); + + db.transaction(function(tx) { + var res = tx.executeSql("SELECT name FROM mail_activity_type_app WHERE account_id = 0"); + verify(res.rows.length >= 4); + + var names = []; + for (var i = 0; i < res.rows.length; i++) { + names.push(res.rows.item(i).name); + } + + verify(names.indexOf("To Do") !== -1); + verify(names.indexOf("Call") !== -1); + verify(names.indexOf("Email") !== -1); + verify(names.indexOf("Meeting") !== -1); + }); + } + + function test_ensureDefaultLocalProjectStages() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + + db.transaction(function(tx) { + tx.executeSql("CREATE TABLE IF NOT EXISTS project_project_stage_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, odoo_record_id INTEGER, " + + "name TEXT, sequence INTEGER, fold INTEGER, active INTEGER, create_date TEXT, " + + "write_date TEXT, status TEXT)"); + tx.executeSql("CREATE TABLE IF NOT EXISTS project_project_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, stage INTEGER)"); + }); + + DBCommon.ensureDefaultLocalProjectStages(); + + db.transaction(function(tx) { + var res = tx.executeSql("SELECT name, odoo_record_id FROM project_project_stage_app WHERE account_id = 0 ORDER BY sequence ASC"); + verify(res.rows.length >= 4); + + var stageNames = []; + for (var i = 0; i < res.rows.length; i++) { + stageNames.push(res.rows.item(i).name); + } + + verify(stageNames.indexOf("Planning") !== -1); + verify(stageNames.indexOf("In Progress") !== -1); + verify(stageNames.indexOf("Completed") !== -1); + verify(stageNames.indexOf("Cancelled") !== -1); + }); + } + + function test_ensureDefaultLocalTaskStages() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + + db.transaction(function(tx) { + tx.executeSql("CREATE TABLE IF NOT EXISTS project_task_type_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, odoo_record_id INTEGER, " + + "name TEXT, sequence INTEGER, fold INTEGER, is_global INTEGER, active INTEGER, " + + "create_date TEXT, write_date TEXT, status TEXT)"); + tx.executeSql("CREATE TABLE IF NOT EXISTS project_task_app (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, state TEXT)"); + }); + + DBCommon.ensureDefaultLocalTaskStages(); + + db.transaction(function(tx) { + var res = tx.executeSql("SELECT name FROM project_task_type_app WHERE account_id = 0 ORDER BY sequence ASC"); + verify(res.rows.length >= 4); + + var stageNames = []; + for (var i = 0; i < res.rows.length; i++) { + stageNames.push(res.rows.item(i).name); + } + + verify(stageNames.indexOf("New") !== -1); + verify(stageNames.indexOf("In Progress") !== -1); + verify(stageNames.indexOf("Done") !== -1); + verify(stageNames.indexOf("Cancelled") !== -1); + }); + } + } +} diff --git a/tests/qml/models/tst_dbinit.qml b/tests/qml/models/tst_dbinit.qml new file mode 100644 index 00000000..b86483d9 --- /dev/null +++ b/tests/qml/models/tst_dbinit.qml @@ -0,0 +1,116 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DbInit + +Item { + width: 200 + height: 200 + + TestCase { + name: "DbInitTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function test_initializeDatabaseCreatesTables() { + DbInit.initializeDatabase(); + + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + + var expectedTables = [ + "sync_report", + "users", + "notification", + "app_settings", + "project_project_app", + "res_users_app", + "project_task_app", + "account_analytic_line_app", + "mail_activity_app", + "form_drafts" + ]; + + db.transaction(function(tx) { + var res = tx.executeSql("SELECT name FROM sqlite_master WHERE type='table'"); + var existingTables = []; + for (var i = 0; i < res.rows.length; i++) { + existingTables.push(res.rows.item(i).name); + } + + for (var j = 0; j < expectedTables.length; j++) { + var tbl = expectedTables[j]; + verify(existingTables.indexOf(tbl) !== -1, "Expected table: " + tbl + " to exist"); + } + }); + } + + function test_initializeAutoSyncSettings() { + DbInit.initializeDatabase(); + DbInit.initializeAutoSyncSettings(); + + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + var resInterval = tx.executeSql("SELECT value FROM app_settings WHERE key = 'auto_sync_interval'"); + verify(resInterval.rows.length > 0); + compare(resInterval.rows.item(0).value, "15"); + + var resEnabled = tx.executeSql("SELECT value FROM app_settings WHERE key = 'auto_sync_enabled'"); + verify(resEnabled.rows.length > 0); + compare(resEnabled.rows.item(0).value, "false"); + }); + } + + function test_performPostStartupMaintenance() { + DbInit.initializeDatabase(); + // Verify maintenance executes cleanly without error + DbInit.performPostStartupMaintenance(); + verify(true); + } + + function test_purgeCache() { + DbInit.initializeDatabase(); + // Verify cache purge executes safely + DbInit.purgeCache(); + verify(true); + } + + function test_syncDraftFlags() { + DbInit.initializeDatabase(); + + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Clear any existing draft records + tx.executeSql("DELETE FROM form_drafts"); + tx.executeSql("DELETE FROM project_task_app WHERE id = 9999"); + + // Seed task with has_draft = 0 + tx.executeSql("INSERT OR REPLACE INTO project_task_app (id, name, account_id, has_draft) VALUES (9999, 'Draft Sync Task', 0, 0)"); + + // Insert a matching draft for task 9999 + tx.executeSql("INSERT INTO form_drafts (draft_type, record_id, account_id, page_identifier, draft_data, created_at, updated_at) " + + "VALUES ('task', 9999, 0, 'test_page', '{\"name\":\"Draft Sync Task\"}', datetime('now'), datetime('now'))"); + }); + + DbInit.syncDraftFlags(); + + db.transaction(function(tx) { + var res = tx.executeSql("SELECT has_draft FROM project_task_app WHERE id = 9999"); + verify(res.rows.length > 0); + compare(res.rows.item(0).has_draft, 1); + + // Clean up test rows + tx.executeSql("DELETE FROM form_drafts WHERE record_id = 9999"); + tx.executeSql("DELETE FROM project_task_app WHERE id = 9999"); + }); + } + } +} diff --git a/tests/qml/models/tst_draft_manager.qml b/tests/qml/models/tst_draft_manager.qml new file mode 100644 index 00000000..99d4f9ea --- /dev/null +++ b/tests/qml/models/tst_draft_manager.qml @@ -0,0 +1,201 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DbInit +import "../../../models/draft_manager.js" as DraftManager + +Item { + width: 200 + height: 200 + + TestCase { + name: "DraftManagerTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DbInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function init() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("DELETE FROM form_drafts"); + }); + } + + function test_saveDraftValidation() { + var res = DraftManager.saveDraft({}); + verify(!res.success); + compare(res.error, "draftType is required"); + } + + function test_valuesAreEqualPrimitives() { + verify(DraftManager.valuesAreEqual(10, 10)); + verify(!DraftManager.valuesAreEqual(10, 20)); + + verify(DraftManager.valuesAreEqual("hello", "hello")); + verify(!DraftManager.valuesAreEqual("hello", "world")); + + verify(DraftManager.valuesAreEqual(true, true)); + verify(!DraftManager.valuesAreEqual(true, false)); + + verify(DraftManager.valuesAreEqual(null, null)); + verify(DraftManager.valuesAreEqual(undefined, undefined)); + verify(DraftManager.valuesAreEqual(null, undefined)); + verify(DraftManager.valuesAreEqual(null, "")); + verify(DraftManager.valuesAreEqual(undefined, "")); + } + + function test_valuesAreEqualArrays() { + verify(DraftManager.valuesAreEqual([1, 2, 3], [1, 2, 3])); + verify(!DraftManager.valuesAreEqual([1, 2, 3], [1, 2])); + verify(!DraftManager.valuesAreEqual([1, 2, 3], [1, 2, 4])); + verify(DraftManager.valuesAreEqual([], [])); + } + + function test_valuesAreEqualObjects() { + verify(DraftManager.valuesAreEqual({a: 1, b: "test"}, {a: 1, b: "test"})); + verify(!DraftManager.valuesAreEqual({a: 1, b: "test"}, {a: 1, b: "other"})); + verify(!DraftManager.valuesAreEqual({a: 1}, {a: 1, b: 2})); + } + + function test_getChangedFields() { + var original = { title: "Original Title", priority: 1, description: "Desc" }; + var modified = { title: "New Title", priority: 1, description: "Updated Desc" }; + + var changed = DraftManager.getChangedFields(modified, original); + verify(changed !== null); + compare(changed.length, 2); + verify(changed.indexOf("title") !== -1); + verify(changed.indexOf("description") !== -1); + verify(changed.indexOf("priority") === -1); + } + + function test_hasUnsavedChanges() { + var original = { name: "Task A", hours: 2.5 }; + var same = { name: "Task A", hours: 2.5 }; + var changed = { name: "Task B", hours: 2.5 }; + + verify(!DraftManager.hasUnsavedChanges(same, original)); + verify(DraftManager.hasUnsavedChanges(changed, original)); + } + + function test_saveAndLoadDraft() { + var saveParams = { + draftType: "task", + recordId: 501, + accountId: 0, + pageIdentifier: "task_edit_page", + formData: { name: "My Draft Task", priority: 3 }, + originalData: { name: "Original Task", priority: 1 } + }; + + var saveRes = DraftManager.saveDraft(saveParams); + verify(saveRes.success); + verify(saveRes.hasChanges); + verify(saveRes.draftId > 0); + + var loadParams = { + draftType: "task", + recordId: 501, + accountId: 0, + pageIdentifier: "task_edit_page" + }; + + var loaded = DraftManager.loadDraft(loadParams); + verify(loaded !== null); + compare(loaded.draft_type, "task"); + compare(loaded.record_id, 501); + + var parsedData = typeof loaded.draft_data === "string" ? JSON.parse(loaded.draft_data) : loaded.draft_data; + compare(parsedData.name, "My Draft Task"); + compare(parsedData.priority, 3); + } + + function test_deleteDraftById() { + var saveParams = { + draftType: "timesheet", + recordId: 601, + accountId: 0, + formData: { unit_amount: 4.0 }, + originalData: { unit_amount: 1.0 } + }; + + var saveRes = DraftManager.saveDraft(saveParams); + verify(saveRes.success); + var draftId = saveRes.draftId; + + var deleteRes = DraftManager.deleteDraft(draftId); + verify(deleteRes); + + var loaded = DraftManager.loadDraft({ + draftType: "timesheet", + recordId: 601, + accountId: 0 + }); + compare(loaded, null); + } + + function test_deleteDraftsByParams() { + DraftManager.saveDraft({ + draftType: "project", + recordId: 701, + accountId: 0, + formData: { name: "Project Draft 1" }, + originalData: { name: "Old" } + }); + + DraftManager.saveDraft({ + draftType: "project", + recordId: 702, + accountId: 0, + formData: { name: "Project Draft 2" }, + originalData: { name: "Old" } + }); + + var countBefore = DraftManager.getAllDrafts(0).length; + compare(countBefore, 2); + + var delRes = DraftManager.deleteDrafts({ + draftType: "project", + recordId: 701, + accountId: 0 + }); + verify(delRes); + + var countAfter = DraftManager.getAllDrafts(0).length; + compare(countAfter, 1); + } + + function test_getDraftsSummary() { + DraftManager.saveDraft({ + draftType: "task", + recordId: 801, + accountId: 0, + formData: { name: "Task 1" }, + originalData: {} + }); + + DraftManager.saveDraft({ + draftType: "timesheet", + recordId: 802, + accountId: 0, + formData: { hours: 5 }, + originalData: {} + }); + + var summary = DraftManager.getDraftsSummary(0); + verify(summary !== null); + compare(summary.total, 2); + compare(summary.byType.task, 1); + compare(summary.byType.timesheet, 1); + } + } +} diff --git a/tests/qml/models/tst_global.qml b/tests/qml/models/tst_global.qml new file mode 100644 index 00000000..956f625a --- /dev/null +++ b/tests/qml/models/tst_global.qml @@ -0,0 +1,107 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DbInit +import "../../../models/global.js" as Global + +Item { + width: 200 + height: 200 + + TestCase { + name: "GlobalStateTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DbInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + Global.clearAssigneeFilter(); + Global.clearDateRangeFilter(); + } + + function test_assigneeFilterLifecycle() { + Global.clearAssigneeFilter(); + var initial = Global.getAssigneeFilter(); + compare(initial.enabled, false); + compare(initial.assigneeIds.length, 0); + + // Set filter + Global.setAssigneeFilter(true, [10, 20, 30]); + var active = Global.getAssigneeFilter(); + compare(active.enabled, true); + compare(active.assigneeIds.length, 3); + compare(active.assigneeIds[0], 10); + compare(active.assigneeIds[1], 20); + compare(active.assigneeIds[2], 30); + + // Mutating returned array should not mutate internal state (array copy isolation) + active.assigneeIds.push(999); + var rechecked = Global.getAssigneeFilter(); + compare(rechecked.assigneeIds.length, 3); + + // Clear filter + Global.clearAssigneeFilter(); + var cleared = Global.getAssigneeFilter(); + compare(cleared.enabled, false); + compare(cleared.assigneeIds.length, 0); + } + + function test_lastVisitedPageTracking() { + Global.setLastVisitedPage("Tasks"); + compare(Global.getLastVisitedPage(), "Tasks"); + + Global.setLastVisitedPage("Dashboard"); + compare(Global.getLastVisitedPage(), "Dashboard"); + } + + function test_shouldPreserveAssigneeFilter() { + // Task page group transitions + verify(Global.shouldPreserveAssigneeFilter("Task_Page", "Tasks")); + verify(Global.shouldPreserveAssigneeFilter("Tasks", "Task_Page")); + + // Activity page group transitions + verify(Global.shouldPreserveAssigneeFilter("Activity_Page", "Activities")); + verify(Global.shouldPreserveAssigneeFilter("Activities", "Activity_Page")); + + // External transitions + verify(!Global.shouldPreserveAssigneeFilter("Tasks", "Dashboard")); + verify(!Global.shouldPreserveAssigneeFilter("Activity_Page", "Timesheet")); + verify(!Global.shouldPreserveAssigneeFilter("Dashboard", "Projects")); + } + + function test_dateRangeFilterLifecycle() { + Global.setDateRangeFilter(1, "2026-10-01", "2026-10-07", "This Week"); + + var filter = Global.getDateRangeFilter(); + verify(filter !== null); + compare(filter.presetId, 1); + compare(filter.startDate, "2026-10-01"); + compare(filter.endDate, "2026-10-07"); + compare(filter.presetLabel, "This Week"); + + // Verify persistence in app_settings table + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + var res = tx.executeSql("SELECT value FROM app_settings WHERE key = 'dashboard_date_range_filter'"); + verify(res.rows.length > 0); + var data = JSON.parse(res.rows.item(0).value); + compare(data.presetId, 1); + compare(data.startDate, "2026-10-01"); + }); + + // Clear filter + Global.clearDateRangeFilter(); + var cleared = Global.getDateRangeFilter(); + compare(cleared.presetId, -1); + compare(cleared.startDate, ""); + compare(cleared.endDate, ""); + compare(cleared.presetLabel, "No Filter"); + } + } +} From 34c2c9960edcfa14b2892fb5110ec0de9db66039 Mon Sep 17 00:00:00 2001 From: Parvathy Nair Date: Fri, 9 Oct 2026 11:15:04 +0530 Subject: [PATCH 20/34] feat(dashboard): add multi-employee filtering, cross-account aggregation, and user log display - Integrate AssigneeFilterMenu into Dashboard header for single and multi-employee filtering - Support cross-account user matching by email and role-based access filtering - Update database queries across models (Main, project, task, timesheet, accounts) for user filtering - Display user names in timesheet log entries in TaskTimeChart and TaskDetailPage - Remove obsolete selector dialog files and add comprehensive QML test suites - Clean up code formatting and indentation across models and components --- models/Main.js | 38 +- models/accounts.js | 940 ++++++++++++++++-- models/global.js | 17 + models/project.js | 42 +- models/task.js | 68 +- models/timesheet.js | 34 +- qml/TSApp.qml | 2 + .../pickers/FilterableHeaderContents.qml | 3 +- .../selectors/AssigneeFilterMenu.qml | 230 ++++- qml/components/visualization/EHower.qml | 68 +- .../visualization/ProjectPieChart.qml | 41 +- qml/features/dashboard/charts/Charts3.qml | 36 +- qml/features/dashboard/charts/Charts4.qml | 41 +- .../dashboard/charts/TaskTimeChart.qml | 35 +- qml/features/dashboard/pages/Dashboard.qml | 337 ++++++- qml/features/dashboard/pages/Dashboard2.qml | 23 +- qml/features/dashboard/pages/Dashboard3.qml | 11 +- .../dashboard/pages/TaskDetailPage.qml | 38 +- tests/qml/README.md | 6 +- .../qml/models/tst_dashboard_user_filter.qml | 775 +++++++++++++++ tests/qml/tst_assigneefiltermenu.qml | 253 +++++ 21 files changed, 2765 insertions(+), 273 deletions(-) create mode 100644 tests/qml/models/tst_dashboard_user_filter.qml create mode 100644 tests/qml/tst_assigneefiltermenu.qml diff --git a/models/Main.js b/models/Main.js index 59eda064..2eae6cb8 100644 --- a/models/Main.js +++ b/models/Main.js @@ -1,16 +1,16 @@ .import "database.js" as DBCommon -.import QtQuick.LocalStorage 2.7 as Sql + .import QtQuick.LocalStorage 2.7 as Sql + .import "accounts.js" as Account + .import "../models/dbinit.js" as DbInit /* Name: get_quadrant_current_week - * This function will return total of spent time for current week based on quadrants from timesheet entries - * 4 quadrants are as following - * 0 -> Urgent and Important - * 1 -> Import but not Urgent - * 2 -> Not Important but Urgent - * 3 -> Not Important and Not Urgent - */ - -.import "../models/dbinit.js" as DbInit + * This function will return total of spent time for current week based on quadrants from timesheet entries + * 4 quadrants are as following + * 0 -> Urgent and Important + * 1 -> Import but not Urgent + * 2 -> Not Important but Urgent + * 3 -> Not Important and Not Urgent + */ DbInit.initializeDatabase(); function get_quadrant_difference() { @@ -74,7 +74,7 @@ function get_quadrant_current_month() { * return format {: } */ -function get_projects_spent_hours(account, startDate, endDate) { +function get_projects_spent_hours(account, startDate, endDate, userId) { var project_details = []; try { @@ -121,6 +121,13 @@ function get_projects_spent_hours(account, startDate, endDate) { conditions.push("DATE(a.record_date) <= DATE(?)"); params.push(endDate); } + if (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1") { + var userFilter = Account.buildUserFilterSQL(userId, account, "a"); + if (userFilter.clause) { + conditions.push(userFilter.clause); + params = params.concat(userFilter.params); + } + } query += "WHERE " + conditions.join(" AND ") + " "; query += "GROUP BY p.account_id, p.id, p.name, parent.name, s.name ORDER BY total DESC"; @@ -148,7 +155,7 @@ function get_projects_spent_hours(account, startDate, endDate) { * return format {: } */ -function get_tasks_spent_hours(account, startDate, endDate) { +function get_tasks_spent_hours(account, startDate, endDate, userId) { var task_details = {}; try { @@ -186,6 +193,13 @@ function get_tasks_spent_hours(account, startDate, endDate) { conditions.push("DATE(a.record_date) <= DATE(?)"); params.push(endDate); } + if (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1") { + var userFilterTask = Account.buildUserFilterSQL(userId, account, "a"); + if (userFilterTask.clause) { + conditions.push(userFilterTask.clause); + params = params.concat(userFilterTask.params); + } + } query += "WHERE " + conditions.join(" AND ") + " "; query += "GROUP BY a.task_id, t.name ORDER BY total DESC"; diff --git a/models/accounts.js b/models/accounts.js index 257bbc65..036baef7 100644 --- a/models/accounts.js +++ b/models/accounts.js @@ -1,6 +1,6 @@ .import "database.js" as DBCommon -.import "logger.js" as Logger -.import QtQuick.LocalStorage 2.7 as Sql + .import "logger.js" as Logger + .import QtQuick.LocalStorage 2.7 as Sql /** * Retrieves the list of all user accounts from the local SQLite database. @@ -191,20 +191,20 @@ function getUsers(accountId) { try { var db = Sql.LocalStorage.openDatabaseSync( - DBCommon.NAME, - DBCommon.VERSION, - DBCommon.DISPLAY_NAME, - DBCommon.SIZE - ); + DBCommon.NAME, + DBCommon.VERSION, + DBCommon.DISPLAY_NAME, + DBCommon.SIZE + ); db.transaction(function (tx) { var result = tx.executeSql( - "SELECT u.id, u.name, COALESCE(NULLIF(u.login, ''), NULLIF(u.email, ''), NULLIF(u.work_email, ''), '') as email, u.odoo_record_id, u.account_id, a.name as account_name " + - "FROM res_users_app u LEFT JOIN users a ON u.account_id = a.id " + - "WHERE u.account_id = ? AND (u.active IS NULL OR u.active = 1) AND u.name IS NOT NULL AND TRIM(u.name) != '' " + - "ORDER BY u.name COLLATE NOCASE ASC", - [accountId] - ); + "SELECT u.id, u.name, COALESCE(NULLIF(u.login, ''), NULLIF(u.email, ''), NULLIF(u.work_email, ''), '') as email, u.odoo_record_id, u.account_id, a.name as account_name " + + "FROM res_users_app u LEFT JOIN users a ON u.account_id = a.id " + + "WHERE u.account_id = ? AND (u.active IS NULL OR u.active = 1) AND u.name IS NOT NULL AND TRIM(u.name) != '' " + + "ORDER BY u.name COLLATE NOCASE ASC", + [accountId] + ); for (var i = 0; i < result.rows.length; i++) { var row = result.rows.item(i); @@ -238,9 +238,9 @@ function fetchParsedSyncLog(accountId) { db.transaction(function (tx) { var rs = tx.executeSql( - "SELECT timestamp, message FROM sync_report WHERE account_id = ? ORDER BY timestamp DESC", - [accountId] - ); + "SELECT timestamp, message FROM sync_report WHERE account_id = ? ORDER BY timestamp DESC", + [accountId] + ); for (var i = 0; i < rs.rows.length; i++) { var entry = rs.rows.item(i); @@ -284,18 +284,18 @@ function createAccount(name, link, database, username, selectedConnectWithId, ap message: "", duplicateType: null }; - + try { const db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); - + db.transaction(function (tx) { - - + + const nameCheckResult = tx.executeSql( 'SELECT COUNT(*) AS count FROM users WHERE LOWER(name) = LOWER(?)', [name] ); - + if (nameCheckResult.rows.item(0).count > 0) { DBCommon.log("Duplicate account name found (case-insensitive): " + name); result.duplicateFound = true; @@ -303,13 +303,13 @@ function createAccount(name, link, database, username, selectedConnectWithId, ap result.message = "An account with this name already exists."; return; } - - + + const connectionCheckResult = tx.executeSql( 'SELECT COUNT(*) AS count FROM users WHERE link = ? AND database = ? AND username = ? COLLATE BINARY', [link, database, username] ); - + if (connectionCheckResult.rows.item(0).count > 0) { DBCommon.log("Duplicate connection found for: " + link + "/" + database + "/" + username); result.duplicateFound = true; @@ -317,24 +317,24 @@ function createAccount(name, link, database, username, selectedConnectWithId, ap result.message = "An account with this server connection already exists."; return; } - - + + const apiKeyToStore = (selectedConnectWithId === 1) ? apikey : ''; tx.executeSql( 'INSERT INTO users (name, link, database, username, connectwith_id, api_key) VALUES (?, ?, ?, ?, ?, ?)', [name, link, database, username, selectedConnectWithId, apiKeyToStore] ); - + DBCommon.log("New user account created successfully: " + name); result.message = "Account created successfully."; }); - + } catch (e) { DBCommon.logException("createAccount", e); result.duplicateFound = true; result.message = "Error creating account: " + e.message; } - + return result; } @@ -356,57 +356,57 @@ function updateAccount(accountId, name, link, database, username, selectedConnec message: "", duplicateType: null }; - + try { const db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); - + db.transaction(function (tx) { // Check for duplicate account name (excluding current account) const nameCheckResult = tx.executeSql( 'SELECT COUNT(*) AS count FROM users WHERE LOWER(name) = LOWER(?) AND id != ?', [name, accountId] ); - + if (nameCheckResult.rows.item(0).count > 0) { DBCommon.log("Duplicate account name found (case-insensitive): " + name); result.duplicateType = "name"; result.message = "An account with this name already exists."; return; } - + // Check for duplicate connection (excluding current account) const connectionCheckResult = tx.executeSql( 'SELECT COUNT(*) AS count FROM users WHERE link = ? AND database = ? AND username = ? COLLATE BINARY AND id != ?', [link, database, username, accountId] ); - + if (connectionCheckResult.rows.item(0).count > 0) { DBCommon.log("Duplicate connection found for: " + link + "/" + database + "/" + username); result.duplicateType = "connection"; result.message = "An account with this server connection already exists."; return; } - + // Update the account const apiKeyToStore = (selectedConnectWithId === 1) ? apikey : ''; tx.executeSql( 'UPDATE users SET name = ?, link = ?, database = ?, username = ?, connectwith_id = ?, api_key = ? WHERE id = ?', [name, link, database, username, selectedConnectWithId, apiKeyToStore, accountId] ); - + DBCommon.log("User account updated successfully: " + name); result.success = true; result.message = "Account updated successfully."; }); - + } catch (e) { DBCommon.logException("updateAccount", e); result.message = "Error updating account: " + e.message; } - + return result; } - + /** * Deletes a user account and all related records from associated tables in the local SQLite database. * @@ -426,23 +426,23 @@ function deleteAccountAndRelatedData(userId) { db.transaction(function (tx) { const tables = [ - "sync_report", - "project_project_app", - "project_task_app", - "account_analytic_line_app", - "res_users_app", - "mail_activity_type_app", - "ir_model_app", - "mail_activity_app", - "ir_attachment_app", - "project_task_assignee_app", - "project_update_app", - "project_task_type_app", - "project_project_stage_app", - "attachment_download_app", - "form_drafts", - "notification" - ]; + "sync_report", + "project_project_app", + "project_task_app", + "account_analytic_line_app", + "res_users_app", + "mail_activity_type_app", + "ir_model_app", + "mail_activity_app", + "ir_attachment_app", + "project_task_assignee_app", + "project_update_app", + "project_task_type_app", + "project_project_stage_app", + "attachment_download_app", + "form_drafts", + "notification" + ]; var numericUserId = parseInt(userId, 10); for (let i = 0; i < tables.length; i++) { @@ -515,7 +515,7 @@ function getCurrentUserOdooId(accountId) { DBCommon.log(`Found username: ${username}, now checking res_users_app`); - const userResult = tx.executeSql("SELECT odoo_record_id FROM res_users_app WHERE login = ? AND account_id = ?", [username, accountId]); + const userResult = tx.executeSql("SELECT odoo_record_id FROM res_users_app WHERE (login = ? OR email = ? OR work_email = ?) AND account_id = ? LIMIT 1", [username, username, username, accountId]); if (userResult.rows.length > 0) { odooId = userResult.rows.item(0).odoo_record_id; @@ -678,6 +678,826 @@ function getUserNameByOdooId(odoo_record_id) { return userName; } +/** + * Combines user objects that share the same email or login across different accounts. + * + * @param {Array} userList - The list of user objects. + * @param {Object} [tx] - Active SQLite transaction. + * @returns {Array} Combined user list with duplicate accounts merged. + */ +function combineUsersByEmail(userList, tx) { + if (!userList || userList.length <= 1) { + return userList || []; + } + + var groups = {}; + var groupOrder = []; + + for (var i = 0; i < userList.length; i++) { + var u = userList[i]; + var email = (u.email || u.work_email || "").trim().toLowerCase(); + var login = (u.login || "").trim().toLowerCase(); + + var key = ""; + if (email !== "") { + key = "email:" + email; + } else if (login !== "") { + key = "login:" + login; + } else { + key = "id:" + u.id; + } + + if (!groups[key]) { + groups[key] = { + items: [u], + emails: email !== "" ? [email] : [], + logins: login !== "" ? [login] : [], + allUserIds: [u.id], + allAccountIds: (u.accountId !== undefined && u.accountId !== null) ? [u.accountId] : [] + }; + groupOrder.push(key); + } else { + var g = groups[key]; + g.items.push(u); + if (g.allUserIds.indexOf(u.id) === -1) { + g.allUserIds.push(u.id); + } + if (u.accountId !== undefined && u.accountId !== null && g.allAccountIds.indexOf(u.accountId) === -1) { + g.allAccountIds.push(u.accountId); + } + if (email !== "" && g.emails.indexOf(email) === -1) { + g.emails.push(email); + } + if (login !== "" && g.logins.indexOf(login) === -1) { + g.logins.push(login); + } + } + } + + var combined = []; + for (var j = 0; j < groupOrder.length; j++) { + var grp = groups[groupOrder[j]]; + var items = grp.items; + + // Pick best name: longest non-placeholder name + var bestName = items[0].name || ""; + var bestAvatar = items[0].avatar || ""; + for (var k = 0; k < items.length; k++) { + var candName = (items[k].name || "").trim(); + if (candName !== "" && !/^User #\d+$/i.test(candName)) { + if (bestName === "" || /^User #\d+$/i.test(bestName) || candName.length > bestName.length) { + bestName = candName; + } + } + if (!bestAvatar && items[k].avatar) { + bestAvatar = items[k].avatar; + } + } + + // Query res_users_app for any other account/user IDs for these emails + if (tx && grp.emails.length > 0) { + try { + var emPlaceholders = grp.emails.map(function () { return "?"; }).join(","); + var qRes = tx.executeSql( + "SELECT account_id, (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid " + + "FROM res_users_app WHERE (LOWER(TRIM(email)) IN (" + emPlaceholders + ") OR LOWER(TRIM(work_email)) IN (" + emPlaceholders + "))", + grp.emails.concat(grp.emails) + ); + for (var m = 0; m < qRes.rows.length; m++) { + var rowUid = Number(qRes.rows.item(m).uid); + var rowAccId = Number(qRes.rows.item(m).account_id); + if (grp.allUserIds.indexOf(rowUid) === -1) { + grp.allUserIds.push(rowUid); + } + if (grp.allAccountIds.indexOf(rowAccId) === -1) { + grp.allAccountIds.push(rowAccId); + } + } + } catch (err) { + // Ignore query error, fall back to known items + } + } + + var accountNames = []; + if (tx && grp.allAccountIds && grp.allAccountIds.length > 0) { + try { + var aPl = grp.allAccountIds.map(function () { return "?"; }).join(","); + var aRes = tx.executeSql("SELECT id, name FROM users WHERE id IN (" + aPl + ")", grp.allAccountIds); + for (var an = 0; an < aRes.rows.length; an++) { + var aRow = aRes.rows.item(an); + if (aRow.name && accountNames.indexOf(aRow.name) === -1) { + accountNames.push(aRow.name); + } + } + } catch (aErr) { + // Ignore query error + } + } + + var primaryItem = items[0]; + combined.push({ + id: primaryItem.id, + name: bestName || primaryItem.name || ("User #" + primaryItem.id), + login: primaryItem.login || (grp.logins.length > 0 ? grp.logins[0] : ""), + email: primaryItem.email || (grp.emails.length > 0 ? grp.emails[0] : ""), + avatar: bestAvatar, + accountId: primaryItem.accountId, + account_id: primaryItem.accountId, + account_names: accountNames, + account_name: accountNames.join(", "), + allUserIds: grp.allUserIds, + allAccountIds: grp.allAccountIds + }); + } + + return combined; +} + +/** + * Resolves all { accountId, userId } pairs corresponding to a user filter. + * When accountId is -1 (All Accounts), users with the same email or login across + * different accounts are combined, and their corresponding user IDs in each account are returned. + * When accountId is specific (> -1), it resolves the user ID valid for that specific account. + * + * @param {number|string} userId - The selected user ID (-1 for all users). + * @param {number|string} [accountId=-1] - The active account ID (-1 for all accounts). + * @returns {Array} List of { accountId: number, userId: number } pairs. + */ +function getUserFilterAccountPairs(userId, accountId) { + var pairs = []; + if (userId === undefined || userId === null || userId === -1 || userId === "-1") { + return pairs; + } + + var numAccountId = (accountId !== undefined && accountId !== null) ? Number(accountId) : -1; + + // Handle single selection object as array + if (typeof userId === "object" && !Array.isArray(userId)) { + var objUid = (userId.user_id !== undefined) ? userId.user_id : userId.userId; + if (objUid === -1 || objUid === "-1") { + return pairs; + } + userId = [userId]; + } + + // Handle Array of users/selections + if (Array.isArray(userId)) { + if (userId.length === 0) { + return pairs; + } + var seenMapArr = {}; + for (var a = 0; a < userId.length; a++) { + var item = userId[a]; + if (item === undefined || item === null || item === -1 || item === "-1") { + continue; + } + if (typeof item === "object") { + var itemUid = (item.user_id !== undefined) ? item.user_id : item.userId; + var itemAccId = (item.account_id !== undefined) ? item.account_id : item.accountId; + if (itemUid === undefined || itemUid === null || itemUid === -1 || itemUid === "-1") { + continue; + } + var nItemUid = Number(itemUid); + var nItemAccId = (itemAccId !== undefined && itemAccId !== null) ? Number(itemAccId) : -1; + if (nItemAccId >= 0 && (numAccountId === -1 || numAccountId === nItemAccId)) { + var k = nItemAccId + ":" + nItemUid; + if (!seenMapArr[k]) { + seenMapArr[k] = true; + pairs.push({ accountId: nItemAccId, userId: nItemUid }); + } + } else { + var subPairs = getUserFilterAccountPairs(nItemUid, accountId); + for (var s = 0; s < subPairs.length; s++) { + var kSub = subPairs[s].accountId + ":" + subPairs[s].userId; + if (!seenMapArr[kSub]) { + seenMapArr[kSub] = true; + pairs.push(subPairs[s]); + } + } + } + } else { + var subPairsScalar = getUserFilterAccountPairs(item, accountId); + for (var ss = 0; ss < subPairsScalar.length; ss++) { + var kSubSc = subPairsScalar[ss].accountId + ":" + subPairsScalar[ss].userId; + if (!seenMapArr[kSubSc]) { + seenMapArr[kSubSc] = true; + pairs.push(subPairsScalar[ss]); + } + } + } + } + return pairs; + } + + var numUserId = Number(userId); + if (isNaN(numUserId) || numUserId < 0) { + return pairs; + } + + try { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + + db.transaction(function (tx) { + // 1. Look up res_users_app records for the given numUserId + var userRecords = []; + var findSql = "SELECT account_id, (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid, email, work_email, login " + + "FROM res_users_app WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?))"; + var findRes = tx.executeSql(findSql, [numUserId, numUserId]); + for (var i = 0; i < findRes.rows.length; i++) { + userRecords.push(findRes.rows.item(i)); + } + + // Collect non-empty emails and logins + var emails = []; + var logins = []; + for (var j = 0; j < userRecords.length; j++) { + var r = userRecords[j]; + var em = (r.email || r.work_email || "").trim().toLowerCase(); + var lg = (r.login || "").trim().toLowerCase(); + if (em !== "" && emails.indexOf(em) === -1) { + emails.push(em); + } + if (lg !== "" && logins.indexOf(lg) === -1) { + logins.push(lg); + } + } + + // 2. If single account target (numAccountId !== -1): + if (numAccountId !== -1) { + for (var k = 0; k < userRecords.length; k++) { + if (Number(userRecords[k].account_id) === numAccountId) { + pairs.push({ accountId: numAccountId, userId: Number(userRecords[k].uid) }); + return; + } + } + + // If not directly matching this account by ID, match by email or login within this account + if (emails.length > 0 || logins.length > 0) { + var matchConds = []; + var matchParams = []; + if (emails.length > 0) { + var emPl = emails.map(function () { return "?"; }).join(","); + matchConds.push("(NULLIF(TRIM(email), '') IS NOT NULL AND LOWER(TRIM(email)) IN (" + emPl + "))"); + matchConds.push("(NULLIF(TRIM(work_email), '') IS NOT NULL AND LOWER(TRIM(work_email)) IN (" + emPl + "))"); + matchParams = matchParams.concat(emails).concat(emails); + } + if (logins.length > 0) { + var lgPl = logins.map(function () { return "?"; }).join(","); + matchConds.push("LOWER(TRIM(login)) IN (" + lgPl + ")"); + matchParams = matchParams.concat(logins); + } + + var accSql = "SELECT account_id, (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid " + + "FROM res_users_app WHERE account_id = ? AND (" + matchConds.join(" OR ") + ") LIMIT 1"; + var accRes = tx.executeSql(accSql, [numAccountId].concat(matchParams)); + if (accRes.rows.length > 0) { + pairs.push({ accountId: numAccountId, userId: Number(accRes.rows.item(0).uid) }); + return; + } + } + + // Fallback for single account + pairs.push({ accountId: numAccountId, userId: numUserId }); + return; + } + + // 3. All accounts (numAccountId === -1): + var seenMap = {}; + if (emails.length > 0 || logins.length > 0) { + var allConds = []; + var allParams = []; + if (emails.length > 0) { + var emPlAll = emails.map(function () { return "?"; }).join(","); + allConds.push("(NULLIF(TRIM(email), '') IS NOT NULL AND LOWER(TRIM(email)) IN (" + emPlAll + "))"); + allConds.push("(NULLIF(TRIM(work_email), '') IS NOT NULL AND LOWER(TRIM(work_email)) IN (" + emPlAll + "))"); + allParams = allParams.concat(emails).concat(emails); + } + if (logins.length > 0) { + var lgPlAll = logins.map(function () { return "?"; }).join(","); + allConds.push("LOWER(TRIM(login)) IN (" + lgPlAll + ")"); + allParams = allParams.concat(logins); + } + + var allSql = "SELECT account_id, (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid " + + "FROM res_users_app WHERE " + allConds.join(" OR "); + var allRes = tx.executeSql(allSql, allParams); + for (var m = 0; m < allRes.rows.length; m++) { + var itm = allRes.rows.item(m); + var acctId = Number(itm.account_id); + var uId = Number(itm.uid); + var key = acctId + ":" + uId; + if (!seenMap[key]) { + seenMap[key] = true; + pairs.push({ accountId: acctId, userId: uId }); + } + } + } + + // If no pairs found via email/login, include records from initial lookup + if (pairs.length === 0 && userRecords.length > 0) { + for (var n = 0; n < userRecords.length; n++) { + var uRec = userRecords[n]; + var keyU = Number(uRec.account_id) + ":" + Number(uRec.uid); + if (!seenMap[keyU]) { + seenMap[keyU] = true; + pairs.push({ accountId: Number(uRec.account_id), userId: Number(uRec.uid) }); + } + } + } + }); + } catch (e) { + DBCommon.logException("getUserFilterAccountPairs", e); + } + + if (pairs.length === 0) { + pairs.push({ accountId: numAccountId, userId: numUserId }); + } + + return pairs; +} + +/** + * Builds a SQL WHERE clause fragment and parameters array for filtering timesheets by user. + * Supports scalar user ID (-1 for all users), array of user IDs, or array of selection objects. + * + * @param {number|string|Array} userId - The user ID or array of IDs/selection objects. + * @param {number|string} [accountId=-1] - The active account ID (-1 for all accounts). + * @param {string} [tableAlias=""] - Optional table alias (e.g. "aal" or "a"). + * @returns {{ clause: string, params: Array }} + */ +function buildUserFilterSQL(userId, accountId, tableAlias) { + if (userId === undefined || userId === null || userId === -1 || userId === "-1") { + return { clause: "", params: [] }; + } + if (typeof userId === "object" && !Array.isArray(userId)) { + var singleObjUid = (userId.user_id !== undefined) ? userId.user_id : userId.userId; + if (singleObjUid === -1 || singleObjUid === "-1") { + return { clause: "", params: [] }; + } + userId = [userId]; + } + if (Array.isArray(userId)) { + if (userId.length === 0) { + return { clause: "", params: [] }; + } + if (userId.length === 1 && (userId[0] === -1 || userId[0] === "-1" || (typeof userId[0] === "object" && (userId[0].user_id === -1 || userId[0].userId === -1)))) { + return { clause: "", params: [] }; + } + } + + var numAccountId = (accountId !== undefined && accountId !== null) ? Number(accountId) : -1; + var pairs = getUserFilterAccountPairs(userId, accountId); + var prefix = tableAlias ? (tableAlias + ".") : ""; + + if (pairs.length === 0) { + return { clause: "", params: [] }; + } + + if (numAccountId !== -1) { + var userIdsInAccount = []; + for (var p = 0; p < pairs.length; p++) { + if (userIdsInAccount.indexOf(pairs[p].userId) === -1) { + userIdsInAccount.push(pairs[p].userId); + } + } + if (userIdsInAccount.length === 0) { + return { clause: "", params: [] }; + } + if (userIdsInAccount.length === 1) { + return { clause: prefix + "user_id = ?", params: [userIdsInAccount[0]] }; + } + var placeholders = userIdsInAccount.map(function () { return "?"; }).join(", "); + return { + clause: prefix + "user_id IN (" + placeholders + ")", + params: userIdsInAccount + }; + } + + if (pairs.length === 1) { + if (pairs[0].accountId >= 0) { + return { + clause: "(" + prefix + "account_id = ? AND " + prefix + "user_id = ?)", + params: [pairs[0].accountId, pairs[0].userId] + }; + } else { + return { clause: prefix + "user_id = ?", params: [pairs[0].userId] }; + } + } + + var byAccount = {}; + var accList = []; + for (var i = 0; i < pairs.length; i++) { + var acct = pairs[i].accountId; + var u = pairs[i].userId; + if (!byAccount[acct]) { + byAccount[acct] = []; + accList.push(acct); + } + if (byAccount[acct].indexOf(u) === -1) { + byAccount[acct].push(u); + } + } + + var clauses = []; + var params = []; + for (var aIdx = 0; aIdx < accList.length; aIdx++) { + var aId = accList[aIdx]; + var uIds = byAccount[aId]; + if (aId >= 0) { + if (uIds.length === 1) { + clauses.push("(" + prefix + "account_id = ? AND " + prefix + "user_id = ?)"); + params.push(aId); + params.push(uIds[0]); + } else { + var inPlaceholders = uIds.map(function () { return "?"; }).join(", "); + clauses.push("(" + prefix + "account_id = ? AND " + prefix + "user_id IN (" + inPlaceholders + "))"); + params.push(aId); + params = params.concat(uIds); + } + } else { + if (uIds.length === 1) { + clauses.push(prefix + "user_id = ?"); + params.push(uIds[0]); + } else { + var inPl = uIds.map(function () { return "?"; }).join(", "); + clauses.push(prefix + "user_id IN (" + inPl + ")"); + params = params.concat(uIds); + } + } + } + + return { + clause: "(" + clauses.join(" OR ") + ")", + params: params + }; +} + +/** + * Retrieves active users available for filtering in the dashboard for a given account. + * When accountId is -1 (All Accounts), users with the same email or login across + * different accounts are combined into a single entry with combined metrics. + * + * @param {number} accountId - The account ID (-1 for all accounts). + * @returns {Array} List of users: [{ id, name, login, email, avatar, accountId, allUserIds }] + */ +function getDashboardFilterUsers(accountId) { + var users = []; + var seenIds = {}; + + try { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + + db.transaction(function (tx) { + // Local Account (account_id = 0) + if (accountId === 0 || accountId === "0") { + users.push({ + id: 1, + name: "Local User", + login: "local_user", + email: "", + avatar: "", + accountId: 0, + allUserIds: [1] + }); + return; + } + + // 1. Find distinct user IDs that have timesheet records in the local database + var tsQuery = "SELECT DISTINCT account_id, user_id FROM account_analytic_line_app WHERE (status IS NULL OR status != 'deleted') AND user_id IS NOT NULL AND user_id > 0 "; + var tsParams = []; + if (accountId !== -1 && accountId !== undefined && accountId !== null) { + tsQuery += "AND account_id = ? "; + tsParams.push(accountId); + } + + var tsResult = tx.executeSql(tsQuery, tsParams); + var localUsers = []; + for (var i = 0; i < tsResult.rows.length; i++) { + var rowItem = tsResult.rows.item(i); + var uKey = rowItem.account_id + ":" + rowItem.user_id; + if (!seenIds[uKey]) { + seenIds[uKey] = true; + localUsers.push({ accountId: rowItem.account_id, userId: rowItem.user_id }); + } + } + + // 2. Ensure current logged-in user for this account is included ("including own name") + if (accountId !== -1 && accountId !== undefined && accountId !== null) { + var loggedInUid = getCurrentUserOdooId(accountId); + var lKey = accountId + ":" + loggedInUid; + if (loggedInUid && loggedInUid > 0 && !seenIds[lKey]) { + seenIds[lKey] = true; + localUsers.push({ accountId: accountId, userId: loggedInUid }); + } + } else { + // For all accounts, include logged in user of every active account + var accRes = tx.executeSql("SELECT id FROM users WHERE id > 0"); + for (var a = 0; a < accRes.rows.length; a++) { + var instAccId = accRes.rows.item(a).id; + var instLoggedInUid = getCurrentUserOdooId(instAccId); + var instKey = instAccId + ":" + instLoggedInUid; + if (instLoggedInUid && instLoggedInUid > 0 && !seenIds[instKey]) { + seenIds[instKey] = true; + localUsers.push({ accountId: instAccId, userId: instLoggedInUid }); + } + } + } + + // 3. If user records were found from timesheets or logged-in user, enrich from res_users_app + if (localUsers.length > 0) { + for (var j = 0; j < localUsers.length; j++) { + var target = localUsers[j]; + var uQuery = "SELECT (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS user_id, " + + "name, login, email, work_email, avatar_128, account_id " + + "FROM res_users_app " + + "WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?)) "; + var uParams = [target.userId, target.userId]; + if (target.accountId !== undefined && target.accountId !== null && target.accountId !== -1) { + uQuery += "AND account_id = ? "; + uParams.push(target.accountId); + } + uQuery += "LIMIT 1"; + + var uRes = tx.executeSql(uQuery, uParams); + if (uRes.rows.length > 0) { + var row = uRes.rows.item(0); + var accIdVal = row.account_id !== undefined ? row.account_id : target.accountId; + users.push({ + id: target.userId, + name: row.name || row.login || getUserNameByOdooId(target.userId) || ("User #" + target.userId), + login: row.login || "", + email: row.email || row.work_email || "", + avatar: row.avatar_128 || "", + accountId: accIdVal, + account_id: accIdVal, + allUserIds: [target.userId] + }); + } else { + users.push({ + id: target.userId, + name: getUserNameByOdooId(target.userId) || ("User #" + target.userId), + login: "", + email: "", + avatar: "", + accountId: target.accountId, + account_id: target.accountId, + allUserIds: [target.userId] + }); + } + } + } else { + // 4. Fallback: If no timesheet records exist yet, fall back to active users from res_users_app + var fallbackQuery = "SELECT (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS user_id, " + + "name, login, email, work_email, avatar_128, account_id " + + "FROM res_users_app " + + "WHERE (active = 1 OR active IS NULL) "; + var fallbackParams = []; + if (accountId !== -1 && accountId !== undefined && accountId !== null) { + fallbackQuery += "AND account_id = ? "; + fallbackParams.push(accountId); + } + fallbackQuery += "ORDER BY name COLLATE NOCASE ASC"; + + var fallbackRes = tx.executeSql(fallbackQuery, fallbackParams); + for (var k = 0; k < fallbackRes.rows.length; k++) { + var fRow = fallbackRes.rows.item(k); + var fUid = fRow.user_id; + var fbKey = fRow.account_id + ":" + fUid; + if (!seenIds[fbKey]) { + seenIds[fbKey] = true; + users.push({ + id: fUid, + name: fRow.name || fRow.login || ("User #" + fUid), + login: fRow.login || "", + email: fRow.email || fRow.work_email || "", + avatar: fRow.avatar_128 || "", + accountId: fRow.account_id, + account_id: fRow.account_id, + allUserIds: [fUid] + }); + } + } + } + + // 5. If all accounts (-1), combine users by email across accounts + if (accountId === -1 || accountId === "-1" || accountId === undefined || accountId === null) { + users = combineUsersByEmail(users, tx); + } + + // Sort alphabetically by name + users.sort(function (a, b) { + return (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" }); + }); + }); + } catch (e) { + DBCommon.logException("getDashboardFilterUsers", e); + } + + return users; +} + +/** + * Resolves the default user to display on the dashboard for a given account. + * By default, the dashboard must display the currently logged-in user's personal metrics upon initial load, + * regardless of access level (standard employee, manager, or admin). + * Aggregated team data ("All Users") is only displayed when explicitly selected from the filter dropdown. + * + * @param {number} accountId - The account ID. + * @param {Array} [availableUsers] - Optional pre-fetched users list. + * @returns {Object|null} The default user object ({ id, name }), or null if none can be resolved. + */ +function getDashboardSingleUserDefault(accountId, availableUsers) { + if (!availableUsers) { + availableUsers = getDashboardFilterUsers(accountId); + } + + var parsedAccountId = (accountId !== undefined && accountId !== null) ? parseInt(accountId, 10) : -1; + + // Local Account (0) + if (parsedAccountId === 0) { + if (availableUsers && availableUsers.length > 0) { + return availableUsers[0]; + } + return { id: 1, name: "Local User" }; + } + + // Instance Account (> 0): Always default to currently logged-in user + if (parsedAccountId > 0) { + var loggedInUid = getCurrentUserOdooId(parsedAccountId); + if (loggedInUid && loggedInUid > 0) { + if (availableUsers && availableUsers.length > 0) { + for (var i = 0; i < availableUsers.length; i++) { + if (availableUsers[i].id === loggedInUid) { + return availableUsers[i]; + } + } + } + var userName = getUserNameByOdooId(loggedInUid); + return { + id: loggedInUid, + name: userName || ("User #" + loggedInUid) + }; + } + } + + // All Accounts (-1): Try to default to currently logged-in user of default account or any active account + if (parsedAccountId === -1) { + var targetUid = -1; + var defAccId = getDefaultRemoteAccountId(); + if (defAccId > 0) { + targetUid = getCurrentUserOdooId(defAccId); + } + if (targetUid <= 0) { + var allAccs = getActiveAccounts(); + for (var a = 0; a < allAccs.length; a++) { + var aUid = getCurrentUserOdooId(allAccs[a].id); + if (aUid > 0) { + targetUid = aUid; + break; + } + } + } + if (targetUid > 0 && availableUsers && availableUsers.length > 0) { + for (var m = 0; m < availableUsers.length; m++) { + var cand = availableUsers[m]; + if (cand.id === targetUid || (cand.allUserIds && cand.allUserIds.indexOf(targetUid) !== -1)) { + return cand; + } + } + } + } + + // Fallback if exactly one user's data exists + if (availableUsers && availableUsers.length === 1) { + return availableUsers[0]; + } + + return null; +} + +/** + * Checks whether the specified account grants manager or admin access. + * + * An account grants manager or admin access if: + * 1. The account is an admin account (username or login is 'admin', or Odoo UID is 2). + * 2. The user is a Project Manager on at least one active project in the account. + * 3. The user has Timesheet/Team Manager access (timesheets belonging to other employees exist in the account). + * + * Standard access accounts (e.g. standard employees with no PM/admin rights, + * and local offline accounts) return false. + * For All Accounts (-1), returns true if any configured remote account grants manager/admin access. + * + * @param {number} accountId - The account ID (-1 for all accounts, 0 for local account). + * @param {Object} [transaction] - Optional SQLite transaction to reuse. + * @returns {boolean} True if manager or admin access is granted, false otherwise. + */ +function hasManagerOrAdminAccess(accountId, transaction) { + var parsedAccountId = (accountId !== undefined && accountId !== null) ? parseInt(accountId, 10) : -1; + if (parsedAccountId === 0) { + return false; + } + + if (transaction) { + return _checkManagerOrAdminAccessTx(parsedAccountId, transaction); + } + + var hasAccess = false; + try { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + hasAccess = _checkManagerOrAdminAccessTx(parsedAccountId, tx); + }); + } catch (e) { + DBCommon.logException("hasManagerOrAdminAccess", e); + } + return hasAccess; +} + +function _checkManagerOrAdminAccessTx(accountId, tx) { + if (accountId === 0) { + return false; + } + + // All Accounts (-1): Check if any active remote account grants manager/admin access + if (accountId === -1) { + var allAccs = tx.executeSql("SELECT id FROM users WHERE id > 0"); + for (var a = 0; a < allAccs.rows.length; a++) { + var instId = allAccs.rows.item(a).id; + if (_checkManagerOrAdminAccessTx(instId, tx)) { + return true; + } + } + return false; + } + + // 1. Check if username on the account is 'admin' + var accRes = tx.executeSql("SELECT username FROM users WHERE id = ?", [accountId]); + var username = ""; + if (accRes.rows.length > 0) { + username = accRes.rows.item(0).username || ""; + if (username.toLowerCase() === "admin") { + return true; + } + } + + // 2. Resolve current user's Odoo UID and check if user login is 'admin' or UID is 2 + var currentUid = null; + if (username) { + var uRes = tx.executeSql( + "SELECT odoo_record_id, login FROM res_users_app WHERE (login = ? OR email = ? OR work_email = ?) AND account_id = ? LIMIT 1", + [username, username, username, accountId] + ); + if (uRes.rows.length > 0) { + var uRow = uRes.rows.item(0); + currentUid = uRow.odoo_record_id; + var uLogin = uRow.login || ""; + if (uLogin.toLowerCase() === "admin") { + return true; + } + } + } + + if (!currentUid) { + currentUid = getCurrentUserOdooId(accountId); + } + + if (currentUid === 2) { + return true; + } + + // 3. Check if user is a Project Manager on any active project for this account + if (currentUid && currentUid > 0) { + var pmRes = tx.executeSql( + "SELECT COUNT(*) AS cnt FROM project_project_app WHERE account_id = ? AND (status IS NULL OR status != 'deleted') AND (user_id = ? OR user_id = CAST(? AS TEXT))", + [accountId, currentUid, currentUid] + ); + if (pmRes.rows.length > 0 && pmRes.rows.item(0).cnt > 0) { + return true; + } + } + + // 4. Check if user has Timesheet/Team Manager access (timesheets of other employees exist) + if (currentUid && currentUid > 0) { + var teamRes = tx.executeSql( + "SELECT COUNT(*) AS cnt FROM account_analytic_line_app WHERE account_id = ? AND (status IS NULL OR status != 'deleted') AND user_id IS NOT NULL AND user_id > 0 AND user_id != ?", + [accountId, currentUid] + ); + if (teamRes.rows.length > 0 && teamRes.rows.item(0).cnt > 0) { + return true; + } + } else { + var tsRes = tx.executeSql( + "SELECT COUNT(DISTINCT user_id) AS cnt FROM account_analytic_line_app WHERE account_id = ? AND (status IS NULL OR status != 'deleted') AND user_id IS NOT NULL AND user_id > 0", + [accountId] + ); + if (tsRes.rows.length > 0 && tsRes.rows.item(0).cnt > 1) { + return true; + } + } + + return false; +} + /** * Retrieves the Odoo model ID (`odoo_record_id`) from the local SQLite database * based on the given account ID and technical model name. @@ -707,7 +1527,7 @@ function getOdooModelId(accountId, technicalName) { if (rs.rows.length > 0) { odooRecordId = rs.rows.item(0).odoo_record_id; - // console.log("Found Odoo Model ID:", odooRecordId); + // console.log("Found Odoo Model ID:", odooRecordId); } else { Logger.warn("Accounts", "No matching ir.model found for:", technicalName) } @@ -722,7 +1542,7 @@ function getOdooModelId(accountId, technicalName) { function clearDefaultAccount() { try { var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); - db.transaction(function(tx) { + db.transaction(function (tx) { tx.executeSql("UPDATE users SET is_default = 0"); }); } catch (e) { @@ -749,7 +1569,7 @@ function updateAccountSyncSettings(accountId, intervalMinutes, direction, enable ); }); DBCommon.log("Updated sync settings for account " + accountId + - ": interval=" + intervalMinutes + ", direction=" + direction + ", enabled=" + enabled); + ": interval=" + intervalMinutes + ", direction=" + direction + ", enabled=" + enabled); } catch (e) { DBCommon.logException("updateAccountSyncSettings", e); } diff --git a/models/global.js b/models/global.js index ceaff954..766c30ab 100644 --- a/models/global.js +++ b/models/global.js @@ -29,6 +29,23 @@ var dateRangeEndDate = "" // yyyy-MM-dd var dateRangePresetLabel = "This Month" var dateRangeInitialized = false +// Global Dashboard User Filter state +var dashboardUserId = -1; +var dashboardUserName = "All Users"; + +function setDashboardUserFilter(userId, userName) { + dashboardUserId = (userId !== undefined && userId !== null) ? userId : -1; + dashboardUserName = userName || "All Users"; +} + +function getDashboardUserFilter() { + return { + userId: dashboardUserId, + userName: dashboardUserName + }; +} + + // Functions to manage assignee filter state function setAssigneeFilter(enabled, assigneeIds) { assigneeFilterEnabled = enabled; diff --git a/models/project.js b/models/project.js index 7d33f945..da510260 100644 --- a/models/project.js +++ b/models/project.js @@ -1,8 +1,8 @@ .import "logger.js" as Logger -.import QtQuick.LocalStorage 2.7 as Sql - .import "database.js" as DBCommon - .import "utils.js" as Utils - .import "accounts.js" as Account + .import QtQuick.LocalStorage 2.7 as Sql + .import "database.js" as DBCommon + .import "utils.js" as Utils + .import "accounts.js" as Account /** * Get local project ID from Odoo record ID @@ -1217,7 +1217,7 @@ function markProjectUpdateAsDeleted(updateId) { * @param {number|string} [accountId] - Optional account id to filter by. Use -1 for all accounts. * @returns {Array} - A list of objects with `project_id`, `name`, and `spentHours`. */ -function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) { +function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate, userId) { var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); var resultList = []; @@ -1247,7 +1247,7 @@ function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) if (isAllAccounts) { Logger.debug("Project", " Aggregating spent hours for ALL accounts") - var sqlAll = "SELECT aal.project_id, aal.account_id, COALESCE(u.name, 'Unknown') AS account_name, " + + var sqlAll = "SELECT COALESCE(p.odoo_record_id, p.id) AS project_id, aal.account_id, COALESCE(u.name, 'Unknown') AS account_name, " + "COALESCE(p.name, 'Unknown') AS project_name, parent.name AS parent_name, SUM(aal.unit_amount) AS total_spent " + "FROM account_analytic_line_app aal " + "LEFT JOIN users u ON aal.account_id = u.id " + @@ -1272,8 +1272,15 @@ function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) sqlAll += "AND DATE(aal.record_date) <= DATE(?) "; paramsAll.push(endDate); } + if (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1") { + var userFilterAll = Account.buildUserFilterSQL(userId, -1, "aal"); + if (userFilterAll.clause) { + sqlAll += "AND " + userFilterAll.clause + " "; + paramsAll = paramsAll.concat(userFilterAll.params); + } + } - sqlAll += "GROUP BY aal.project_id, aal.account_id, u.name, p.id, p.name, parent.name ORDER BY total_spent DESC"; + sqlAll += "GROUP BY aal.account_id, u.name, p.id, p.name, parent.name ORDER BY total_spent DESC"; result = tx.executeSql(sqlAll, paramsAll); @@ -1302,7 +1309,7 @@ function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) Logger.debug("Project", " Aggregating spent hours for single account:", acctNum) - var sqlSingle = "SELECT aal.project_id, COALESCE(p.name, 'Unknown') AS project_name, parent.name AS parent_name, SUM(aal.unit_amount) AS total_spent " + + var sqlSingle = "SELECT COALESCE(p.odoo_record_id, p.id) AS project_id, COALESCE(p.name, 'Unknown') AS project_name, parent.name AS parent_name, SUM(aal.unit_amount) AS total_spent " + "FROM account_analytic_line_app aal " + "JOIN project_project_app p ON (" + " (p.odoo_record_id = COALESCE(NULLIF(aal.sub_project_id, 0), aal.project_id) OR " + @@ -1324,8 +1331,15 @@ function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) sqlSingle += "AND DATE(aal.record_date) <= DATE(?) "; paramsSingle.push(endDate); } + if (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1") { + var userFilterSingle = Account.buildUserFilterSQL(userId, acctNum, "aal"); + if (userFilterSingle.clause) { + sqlSingle += "AND " + userFilterSingle.clause + " "; + paramsSingle = paramsSingle.concat(userFilterSingle.params); + } + } - sqlSingle += "GROUP BY aal.project_id, p.id, p.name, parent.name ORDER BY total_spent DESC"; + sqlSingle += "GROUP BY p.account_id, p.id, p.name, parent.name ORDER BY total_spent DESC"; result = tx.executeSql(sqlSingle, paramsSingle); @@ -1356,9 +1370,10 @@ function getProjectSpentHoursList(is_work_state, accountId, startDate, endDate) * @param {number} accountId - Account ID, or -1 for all accounts. * @param {string} [startDate] - Optional start date (yyyy-MM-dd). * @param {string} [endDate] - Optional end date (yyyy-MM-dd). + * @param {number} [userId] - Optional user ID to filter timesheet hours by. * @returns {Array} Project summary rows for dashboard charts. */ -function getDashboardProjectTaskSummary(accountId, startDate, endDate) { +function getDashboardProjectTaskSummary(accountId, startDate, endDate, userId) { var resultList = []; try { @@ -1376,6 +1391,13 @@ function getDashboardProjectTaskSummary(accountId, startDate, endDate) { tsConditions.push("DATE(record_date) <= DATE(?)"); params.push(endDate); } + if (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1") { + var userFilter = Account.buildUserFilterSQL(userId, accountId, ""); + if (userFilter.clause) { + tsConditions.push(userFilter.clause); + params = params.concat(userFilter.params); + } + } var accountWhere = ""; if (accountId !== -1 && accountId !== undefined && accountId !== null) { diff --git a/models/task.js b/models/task.js index 2915680f..7bf89a00 100644 --- a/models/task.js +++ b/models/task.js @@ -2,17 +2,17 @@ /* * MULTIPLE ASSIGNEES IMPLEMENTATION * ================================= - * - * This module now supports multiple assignees per task by storing them as comma-separated + * + * This module now supports multiple assignees per task by storing them as comma-separated * user IDs in the user_id field of the project_task_app table. - * + * * Key changes: * - Multiple assignee IDs are stored as comma-separated string in user_id field * - getTaskAssignees() parses the comma-separated IDs and returns assignee objects * - setTaskAssignees() updates task with multiple assignees * - saveOrUpdateTask() handles both single and multiple assignees * - migrateTaskAssignees() provides backward compatibility - * + * * Usage: * - For single assignee: store single ID in user_id field (backward compatible) * - For multiple assignees: store comma-separated IDs like "123,456,789" in user_id field @@ -24,6 +24,7 @@ .import "database.js" as DBCommon .import "utils.js" as Utils .import "draft_manager.js" as DraftManager + .import "accounts.js" as Accounts // Helper to handle -1/null function validId(value) { @@ -552,7 +553,7 @@ function markTaskAsDeleted(taskId, forceDelete = false) { result.hasChildren = true; result.childTasks = childTasks; - Logger.warn("Task", "Deletion blocked: Task has "+ childTasks.length + "child tasks") + Logger.warn("Task", "Deletion blocked: Task has " + childTasks.length + "child tasks") return; } @@ -566,7 +567,7 @@ function markTaskAsDeleted(taskId, forceDelete = false) { deletedTimesheetIds = markRelatedTimesheetsAsDeleted(tx, [taskId], timestamp); if (forceDelete && childTasks.length > 0) { - Logger.warn("Task", "Force delete enabled - deleted parent task with "+ childTasks.length + "children") + Logger.warn("Task", "Force delete enabled - deleted parent task with " + childTasks.length + "children") result.message = "Task '" + taskName + "' deleted (forced deletion with " + childTasks.length + " child tasks remaining)"; } else { result.message = "Task '" + taskName + "' successfully deleted"; @@ -575,7 +576,7 @@ function markTaskAsDeleted(taskId, forceDelete = false) { result.success = true; result.deletedTaskIds = [taskId]; - console.info("Task deleted successfully: "+ taskName); + console.info("Task deleted successfully: " + taskName); }); // Clean up any drafts for this deleted task and its related timesheets (outside transaction) @@ -596,7 +597,7 @@ function markTaskAsDeleted(taskId, forceDelete = false) { return result; } catch (e) { - Logger.error("Task", "Error marking task as deleted (ID "+ taskId + "): "+ e) + Logger.error("Task", "Error marking task as deleted (ID " + taskId + "): " + e) return { success: false, message: "Failed to delete task: " + e.message, @@ -1985,11 +1986,11 @@ function getFilteredTasksPaginated(filterType, searchQuery, accountId, limit, of var timeMap = {}; if (taskOdooIds.length > 0) { - var placeholders = taskOdooIds.map(function() { return "?"; }).join(","); + var placeholders = taskOdooIds.map(function () { return "?"; }).join(","); var timeQuery = "SELECT task_id, account_id, SUM(unit_amount) as total_hours " + - "FROM account_analytic_line_app " + - "WHERE task_id IN (" + placeholders + ") " + - "GROUP BY task_id, account_id"; + "FROM account_analytic_line_app " + + "WHERE task_id IN (" + placeholders + ") " + + "GROUP BY task_id, account_id"; var timeResult = tx.executeSql(timeQuery, taskOdooIds); for (var k = 0; k < timeResult.rows.length; k++) { var row = timeResult.rows.item(k); @@ -3092,7 +3093,7 @@ function setTaskPriority(taskId, priority, status) { * @param {number} accountId - The account ID * @returns {Array} A list of task objects with color and spentHours for the specified project. */ -function getTasksForProject(projectOdooRecordId, accountId, startDate, endDate) { +function getTasksForProject(projectOdooRecordId, accountId, startDate, endDate, userId) { var taskList = []; try { @@ -3102,8 +3103,9 @@ function getTasksForProject(projectOdooRecordId, accountId, startDate, endDate) var params = [projectOdooRecordId, projectOdooRecordId, accountId]; var dateJoin = ""; var dateCondition = ""; - - if (startDate || endDate) { + var hasUserFilter = (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1"); + + if (startDate || endDate || hasUserFilter) { dateJoin = " INNER JOIN account_analytic_line_app al ON (al.task_id = t.odoo_record_id OR (t.account_id = 0 AND al.task_id = t.id)) AND al.account_id = t.account_id AND (al.status != 'deleted' OR al.status IS NULL) "; var dateFilters = []; if (startDate) { @@ -3114,6 +3116,13 @@ function getTasksForProject(projectOdooRecordId, accountId, startDate, endDate) dateFilters.push("DATE(al.record_date) <= DATE(?)"); params.push(endDate); } + if (hasUserFilter) { + var userFilterTask = Accounts.buildUserFilterSQL(userId, accountId, "al"); + if (userFilterTask.clause) { + dateFilters.push(userFilterTask.clause); + params = params.concat(userFilterTask.params); + } + } dateCondition = " AND " + dateFilters.join(" AND "); } @@ -3174,10 +3183,17 @@ function getTasksForProject(projectOdooRecordId, accountId, startDate, endDate) spentCondition += " AND DATE(record_date) <= DATE(?)"; spentParams.push(endDate); } + if (hasUserFilter) { + var userSpentFilter = Accounts.buildUserFilterSQL(userId, accountId, ""); + if (userSpentFilter.clause) { + spentCondition += " AND " + userSpentFilter.clause; + spentParams = spentParams.concat(userSpentFilter.params); + } + } var spentHoursQuery = ` - SELECT COALESCE(SUM(unit_amount), 0) as spent_hours - FROM account_analytic_line_app + SELECT COALESCE(SUM(unit_amount), 0) as spent_hours + FROM account_analytic_line_app WHERE task_id = ? AND account_id = ? AND (status != 'deleted' OR status IS NULL) ${spentCondition} `; @@ -3527,7 +3543,7 @@ function getTaskStagesForProject(projectOdooRecordId, accountId) { [accountId] ); - Logger.debug("Task", "getTaskStagesForProject: Found "+ result.rows.length + "PROJECT stages for account "+ accountId + "(before project filter)") + Logger.debug("Task", "getTaskStagesForProject: Found " + result.rows.length + "PROJECT stages for account " + accountId + "(before project filter)") // Filter stages to show only: // 1. Global stages (is_global = 1 or is_global = "1" - available to ALL projects) @@ -3566,7 +3582,7 @@ function getTaskStagesForProject(projectOdooRecordId, accountId) { if (isGlobalValue === 1 || isGlobalStr === "1") { // Global stage - available to all projects isAvailable = true; - Logger.debug("Task", "Stage '"+ row.name + "'is GLOBAL (is_global: "+ isGlobalValue + "["+ isGlobalType + "])") + Logger.debug("Task", "Stage '" + row.name + "'is GLOBAL (is_global: " + isGlobalValue + "[" + isGlobalType + "])") } else if (isGlobalStr.indexOf(",") !== -1) { // Project-specific stage - check if this project is in the comma-separated list var projectIds = isGlobalStr.split(","); @@ -3580,18 +3596,18 @@ function getTaskStagesForProject(projectOdooRecordId, accountId) { } if (isAvailable) { - Logger.debug("Task", "Stage '"+ row.name + "'is available for project "+ projectOdooRecordId + "(is_global: '"+ isGlobalValue + "'contains project ID)") + Logger.debug("Task", "Stage '" + row.name + "'is available for project " + projectOdooRecordId + "(is_global: '" + isGlobalValue + "'contains project ID)") } else { - Logger.debug("Task", "Stage '"+ row.name + "'NOT available for project "+ projectOdooRecordId + "(is_global: '"+ isGlobalValue + "'does not contain project ID)") + Logger.debug("Task", "Stage '" + row.name + "'NOT available for project " + projectOdooRecordId + "(is_global: '" + isGlobalValue + "'does not contain project ID)") } } else { // Could be a single project ID (no comma) - check if it matches if (isGlobalStr === String(projectOdooRecordId)) { isAvailable = true; - Logger.debug("Task", "Stage '"+ row.name + "'is available ONLY for project "+ projectOdooRecordId + "(is_global: '"+ isGlobalValue + "')") + Logger.debug("Task", "Stage '" + row.name + "'is available ONLY for project " + projectOdooRecordId + "(is_global: '" + isGlobalValue + "')") } else { // This stage is for a different single project - Logger.debug("Task", "Stage '"+ row.name + "'is for a DIFFERENT project (is_global: '"+ isGlobalValue + "', need: "+ projectOdooRecordId + ")") + Logger.debug("Task", "Stage '" + row.name + "'is for a DIFFERENT project (is_global: '" + isGlobalValue + "', need: " + projectOdooRecordId + ")") } } @@ -3608,10 +3624,10 @@ function getTaskStagesForProject(projectOdooRecordId, accountId) { } } - Logger.debug("Task", "getTaskStagesForProject: "+ stages.length + "stages available for project "+ projectOdooRecordId) + Logger.debug("Task", "getTaskStagesForProject: " + stages.length + "stages available for project " + projectOdooRecordId) if (stages.length === 0) { - Logger.warn("Task", "No stages available for project "+ projectOdooRecordId + "in account "+ accountId) + Logger.warn("Task", "No stages available for project " + projectOdooRecordId + "in account " + accountId) Logger.warn("Task", " This might indicate the project has no assigned stages in Odoo") } }); @@ -3979,7 +3995,7 @@ function getTasksByPersonalStagePaginated(personalStageOdooRecordId, assigneeIds foldedStageIds.push(foldResult.rows.item(f).odoo_record_id); } if (foldedStageIds.length > 0) { - var placeholders = foldedStageIds.map(function() { return "?"; }).join(","); + var placeholders = foldedStageIds.map(function () { return "?"; }).join(","); whereClauses.push("(t.state IS NULL OR t.state NOT IN (" + placeholders + "))"); for (var fi = 0; fi < foldedStageIds.length; fi++) { params.push(foldedStageIds[fi]); diff --git a/models/timesheet.js b/models/timesheet.js index 6a609e2e..7de7405d 100644 --- a/models/timesheet.js +++ b/models/timesheet.js @@ -1,9 +1,9 @@ .import "logger.js" as Logger -.import QtQuick.LocalStorage 2.7 as Sql - .import "database.js" as DBCommon - .import "utils.js" as Utils - .import "accounts.js" as Accounts - .import "draft_manager.js" as DraftManager + .import QtQuick.LocalStorage 2.7 as Sql + .import "database.js" as DBCommon + .import "utils.js" as Utils + .import "accounts.js" as Accounts + .import "draft_manager.js" as DraftManager /** @@ -509,7 +509,7 @@ function fetchTimesheetsForAllAccountsPaginated(status, limit, offset) { * @param {string} status - The status filter: 'all', 'active', 'draft', etc. * @returns {Array} - A list of enriched timesheet entries for the task. */ -function getTimesheetsForTask(taskOdooRecordId, accountId, status, startDate, endDate) { +function getTimesheetsForTask(taskOdooRecordId, accountId, status, startDate, endDate, userId) { var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); var timesheetList = []; @@ -529,14 +529,19 @@ function getTimesheetsForTask(taskOdooRecordId, accountId, status, startDate, en // Build query based on status and accountId var baseQuery = "SELECT * FROM account_analytic_line_app WHERE task_id = ?"; var dateCondition = ""; - + var hasUserFilter = (userId !== undefined && userId !== null && userId !== -1 && userId !== "-1"); + if (startDate) { dateCondition += " AND DATE(record_date) >= DATE(?)"; } if (endDate) { dateCondition += " AND DATE(record_date) <= DATE(?)"; } - + var userFilter = hasUserFilter ? Accounts.buildUserFilterSQL(userId, accountId, "") : { clause: "", params: [] }; + if (userFilter.clause) { + dateCondition += " AND " + userFilter.clause; + } + if (!status || status.toLowerCase() === "all") { if (accountId && accountId > 0) { query = baseQuery + " AND account_id = ? AND (status IS NULL OR status != 'deleted')" + dateCondition + " ORDER BY COALESCE(last_modified, record_date) DESC, id DESC"; @@ -554,9 +559,12 @@ function getTimesheetsForTask(taskOdooRecordId, accountId, status, startDate, en params = [taskOdooRecordId, status]; } } - + if (startDate) params.push(startDate); if (endDate) params.push(endDate); + if (userFilter.params && userFilter.params.length > 0) { + params = params.concat(userFilter.params); + } Logger.debug("Timesheet", "Executing getTimesheetsForTask query:", query, "with params:", params) var result = tx.executeSql(query, params); @@ -626,8 +634,12 @@ function getTimesheetsForTask(taskOdooRecordId, accountId, status, startDate, en } if (row.user_id !== undefined && row.user_id !== null) { - var rs_user = tx.executeSql("SELECT name FROM res_users_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", [row.user_id, row.user_id]); - if (rs_user.rows.length > 0) userName = rs_user.rows.item(0).name; + var rs_user = tx.executeSql("SELECT name, login FROM res_users_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1", [row.user_id, row.user_id]); + if (rs_user.rows.length > 0) { + userName = rs_user.rows.item(0).name || rs_user.rows.item(0).login || ""; + } else if (row.account_id === 0) { + userName = "Local User"; + } } timesheetList.push({ diff --git a/qml/TSApp.qml b/qml/TSApp.qml index 02e8cabb..ac6e3e8a 100644 --- a/qml/TSApp.qml +++ b/qml/TSApp.qml @@ -33,6 +33,7 @@ MainView { objectName: "TS" applicationName: "ubtms" property bool init: true + property var rootApp: mainView property alias globalTimerWidget: globalWidgets.globalTimerWidget property alias modelDownloadTimerWidget: globalWidgets.modelDownloadTimerWidget property alias backend_bridge: globalWidgets.backend_bridge @@ -72,6 +73,7 @@ MainView { signal globalAccountChanged(int accountId, string accountName) signal accountDataRefreshRequested(int accountId) signal globalDateRangeChanged(int presetId, string startDate, string endDate, string presetLabel) + signal globalDashboardUserChanged(var userId, string userName) signal projectDataChanged() signal taskDataChanged() // Keep-alive heartbeat: Ensures Qt SceneGraph render thread remains active to safely process diff --git a/qml/components/pickers/FilterableHeaderContents.qml b/qml/components/pickers/FilterableHeaderContents.qml index dca0a569..9a97db1a 100644 --- a/qml/components/pickers/FilterableHeaderContents.qml +++ b/qml/components/pickers/FilterableHeaderContents.qml @@ -9,6 +9,7 @@ Item { property bool showDateFilter: false property string title: "" property bool showSubtitle: true + property string userLabel: "" property alias dateFilter: dateFilterItem signal dateRangeChanged() @@ -30,7 +31,7 @@ Item { } Label { - text: dateFilterItem.presetLabel + text: root.userLabel ? (dateFilterItem.presetLabel + " • " + root.userLabel) : dateFilterItem.presetLabel color: "white" fontSize: "small" opacity: 0.8 diff --git a/qml/components/selectors/AssigneeFilterMenu.qml b/qml/components/selectors/AssigneeFilterMenu.qml index faa6e908..e7a5f0d0 100644 --- a/qml/components/selectors/AssigneeFilterMenu.qml +++ b/qml/components/selectors/AssigneeFilterMenu.qml @@ -47,14 +47,35 @@ Item { property bool expanded: false property var selectedAssigneeIds: [] property bool showAccountName: false + property bool showAllUsersOption: false property int maxMenuHeight: units.gu(50) + function isOnlyAllUsersSelected() { + if (!showAllUsersOption) return false; + if (selectedAssigneeIds.length === 0) return false; + for (var i = 0; i < selectedAssigneeIds.length; i++) { + var s = selectedAssigneeIds[i]; + var uid = (typeof s === 'object') ? s.user_id : s; + if (uid !== -1 && uid !== "-1") { + return false; + } + } + return true; + } + + function selectAllUsers() { + selectedAssigneeIds = [createSelection(-1, -1)]; + } + // Helper function to check if an assignee is selected (handles both old and new format) function isAssigneeSelected(userId, accountId) { + if (showAllUsersOption && userId === -1) { + return isOnlyAllUsersSelected(); + } for (var i = 0; i < selectedAssigneeIds.length; i++) { var selectedId = selectedAssigneeIds[i]; if (typeof selectedId === 'object') { - if (selectedId.user_id === userId && selectedId.account_id === accountId) { + if (selectedId.user_id === userId && (accountId === undefined || accountId === -1 || selectedId.account_id === -1 || selectedId.account_id === accountId)) { return true; } } else if (selectedId === userId) { @@ -86,6 +107,15 @@ Item { } function addSelectionIfMissing(selection) { + if (showAllUsersOption && selection.user_id !== -1) { + for (var k = selectedAssigneeIds.length - 1; k >= 0; k--) { + var cur = selectedAssigneeIds[k]; + var curUid = (typeof cur === 'object') ? cur.user_id : cur; + if (curUid === -1 || curUid === "-1") { + selectedAssigneeIds.splice(k, 1); + } + } + } if (!hasSelectedAssignee(selection)) { selectedAssigneeIds.push(createSelection(selection.user_id, selection.account_id)); } @@ -95,7 +125,7 @@ Item { for (var i = selectedAssigneeIds.length - 1; i >= 0; i--) { var existingId = selectedAssigneeIds[i]; if (typeof existingId === 'object') { - if (existingId.user_id === selection.user_id && existingId.account_id === selection.account_id) { + if (existingId.user_id === selection.user_id && (selection.account_id === undefined || selection.account_id === -1 || existingId.account_id === selection.account_id)) { selectedAssigneeIds.splice(i, 1); } } else if (existingId === selection.user_id) { @@ -166,7 +196,17 @@ Item { group.account_names.push(accountName); } - group.memberSelections.push(createSelection(assigneeId, accountId)); + if (assignee.memberSelections && assignee.memberSelections.length > 0) { + for (var ms = 0; ms < assignee.memberSelections.length; ms++) { + group.memberSelections.push(assignee.memberSelections[ms]); + } + } else if (assignee.allUserIds && assignee.allAccountIds && assignee.allUserIds.length > 1) { + for (var au = 0; au < assignee.allUserIds.length; au++) { + group.memberSelections.push(createSelection(assignee.allUserIds[au], assignee.allAccountIds[au] || accountId)); + } + } else { + group.memberSelections.push(createSelection(assigneeId, accountId)); + } } var visibleAssignees = []; @@ -199,7 +239,7 @@ Item { } visibleAssignees.sort(function (a, b) { - if (a.selected !== b.selected) { + if (!showAllUsersOption && a.selected !== b.selected) { return a.selected ? -1 : 1; } @@ -212,6 +252,26 @@ Item { return 0; }); + if (showAllUsersOption) { + var allUsersText = i18n.dtr("ubtms", "All Users"); + var isAllUsersSelected = isOnlyAllUsersSelected(); + var matchSearch = !normalizedSearch || allUsersText.toLowerCase().indexOf(normalizedSearch) >= 0; + if (matchSearch) { + visibleAssignees.unshift({ + assigneeId: -1, + name: allUsersText, + email: "", + account_name: "", + titleText: allUsersText, + showAccountChips: false, + accountNamesJson: "[]", + memberSelectionsJson: JSON.stringify([createSelection(-1, -1)]), + selected: isAllUsersSelected, + sectionLabel: isAllUsersSelected ? "selected" : "others" + }); + } + } + return visibleAssignees; } @@ -226,7 +286,7 @@ Item { var selectedCount = 0; for (var i = 0; i < displayAssignees.length; i++) { - if (displayAssignees[i].selected) { + if (displayAssignees[i].selected && displayAssignees[i].assigneeId !== -1) { selectedCount++; } } @@ -264,7 +324,7 @@ Item { Rectangle { id: menuContainer visible: expanded - width: units.gu(40) + width: Math.min(units.gu(40), parent.width - units.gu(2)) height: { // Calculate dynamic height: header + search + assignee list + buttons + margins var baseHeight = units.gu(22); // Header + search + buttons + margins @@ -337,7 +397,7 @@ Item { } Text { - text: i18n.dtr("ubtms", "Filter by Assignees") + text: showAllUsersOption ? i18n.dtr("ubtms", "Filter by Employee") : i18n.dtr("ubtms", "Filter by Assignees") font.bold: true font.pixelSize: units.gu(2.2) color: theme.palette.normal.backgroundText @@ -353,9 +413,10 @@ Item { color: theme.palette.normal.base } - // Search bar for long lists + // Search bar Row { - visible: getDisplayAssigneeCount() > 5 + id: searchRow + visible: true width: parent.width height: units.gu(4) spacing: units.gu(0.5) @@ -364,15 +425,15 @@ Item { id: searchField width: parent.width - clearSearchButton.width - parent.spacing height: parent.height - placeholderText: i18n.dtr("ubtms", "Search assignees...") + placeholderText: showAllUsersOption ? i18n.dtr("ubtms", "Search employees...") : i18n.dtr("ubtms", "Search assignees...") onAccepted: { filterModel.update(); // Handle enter key press } - // onTextChanged: { - // filterModel.update(); - // } + onTextChanged: { + filterModel.update(); + } } // Custom clear search button (needed because native clear doesn't trigger filter update) @@ -428,12 +489,20 @@ Item { } } + Text { + anchors.centerIn: parent + visible: filterModel.count === 0 + text: showAllUsersOption ? i18n.dtr("ubtms", "No employees found") : i18n.dtr("ubtms", "No assignees found") + font.pixelSize: units.gu(1.8) + color: theme.palette.normal.backgroundSecondaryText + } + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded width: units.gu(1) } - section.property: "sectionLabel" + section.property: showAllUsersOption ? "" : "sectionLabel" section.criteria: ViewSection.FullString section.delegate: Item { width: assigneeListView.width @@ -496,32 +565,54 @@ Item { } property bool hasHiddenChips: showsAccountChips && visibleChipCount < accountNames.length - width: parent.width + width: assigneeListView.width height: Math.max(units.gu(6), infoColumn.implicitHeight + contentMargin * 2) color: mouseArea.pressed ? theme.palette.selected.background : "transparent" radius: units.gu(0.5) + property int itemAssigneeId: (typeof model.assigneeId !== "undefined") ? model.assigneeId : assigneeId + property string itemMemberSelectionsJson: (typeof model.memberSelectionsJson !== "undefined") ? model.memberSelectionsJson : memberSelectionsJson + property string itemName: (typeof model.name !== "undefined") ? model.name : name + property string itemTitleText: (typeof model.titleText !== "undefined") ? model.titleText : (typeof titleText !== "undefined" ? titleText : itemName) + property string itemEmail: (typeof model.email !== "undefined" && model.email !== null) ? model.email : (typeof email !== "undefined" && email !== null ? email : "") + property bool itemSelected: (typeof model.selected !== "undefined") ? model.selected : selected + + function toggleSelection() { + if (showAllUsersOption && itemAssigneeId === -1) { + if (isOnlyAllUsersSelected()) { + selectedAssigneeIds = []; + } else { + selectAllUsers(); + } + selectedAssigneeIds = selectedAssigneeIds.slice(); + return; + } + + var memberSelections = []; + if (itemMemberSelectionsJson) { + memberSelections = JSON.parse(itemMemberSelectionsJson); + } + var shouldSelect = !hasAnySelectedAssignee(memberSelections); + + for (var i = 0; i < memberSelections.length; i++) { + if (shouldSelect) { + addSelectionIfMissing(memberSelections[i]); + } else { + removeSelectionIfPresent(memberSelections[i]); + } + } + + selectedAssigneeIds = selectedAssigneeIds.slice(); + } + MouseArea { id: mouseArea anchors.fill: parent hoverEnabled: true + cursorShape: Qt.PointingHandCursor onClicked: { - var memberSelections = []; - if (model.memberSelectionsJson) { - memberSelections = JSON.parse(model.memberSelectionsJson); - } - var shouldSelect = !hasAnySelectedAssignee(memberSelections); - - for (var i = 0; i < memberSelections.length; i++) { - if (shouldSelect) { - addSelectionIfMissing(memberSelections[i]); - } else { - removeSelectionIfPresent(memberSelections[i]); - } - } - - selectedAssigneeIds = selectedAssigneeIds.slice(); + delegateRoot.toggleSelection(); } } @@ -531,13 +622,14 @@ Item { anchors.leftMargin: delegateRoot.contentMargin anchors.top: parent.top anchors.topMargin: delegateRoot.contentMargin - checked: model.selected + checked: delegateRoot.itemSelected MouseArea { anchors.fill: parent + cursorShape: Qt.PointingHandCursor propagateComposedEvents: true onClicked: { - mouseArea.clicked(mouse); + delegateRoot.toggleSelection(); mouse.accepted = true; } } @@ -545,13 +637,22 @@ Item { Icon { id: userIcon - name: "contact" + name: (delegateRoot.itemAssigneeId === -1) ? "contact-group" : "contact" width: units.gu(2.5) height: units.gu(2.5) anchors.left: checkbox.right anchors.leftMargin: delegateRoot.contentSpacing anchors.verticalCenter: checkbox.verticalCenter color: theme.palette.normal.backgroundText + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + delegateRoot.toggleSelection(); + mouse.accepted = true; + } + } } Column { @@ -572,11 +673,20 @@ Item { Text { id: nameLabel width: Math.min(headerFlow.width, Math.max(implicitWidth, units.gu(8))) - text: model.titleText || model.name + text: delegateRoot.itemTitleText || delegateRoot.itemName font.pixelSize: units.gu(2) color: theme.palette.normal.backgroundText wrapMode: Text.NoWrap elide: Text.ElideRight + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + delegateRoot.toggleSelection(); + mouse.accepted = true; + } + } } Repeater { @@ -601,6 +711,15 @@ Item { elide: Text.ElideRight horizontalAlignment: Text.AlignHCenter } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + delegateRoot.toggleSelection(); + mouse.accepted = true; + } + } } } @@ -612,6 +731,7 @@ Item { border.color: theme.palette.selected.background border.width: 1 width: Math.max(units.gu(7), moreChipLabel.implicitWidth + units.gu(2.4)) + z: 2 Text { id: moreChipLabel @@ -623,6 +743,7 @@ Item { MouseArea { anchors.fill: parent + cursorShape: Qt.PointingHandCursor onClicked: { delegateRoot.expandedAccounts = !delegateRoot.expandedAccounts; mouse.accepted = true; @@ -632,13 +753,22 @@ Item { } Text { - visible: model.email !== "" - text: model.email + visible: delegateRoot.itemEmail !== "" + text: delegateRoot.itemEmail font.pixelSize: units.gu(1.5) color: theme.palette.normal.backgroundSecondaryText elide: Text.ElideRight width: infoColumn.width opacity: 0.7 + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + delegateRoot.toggleSelection(); + mouse.accepted = true; + } + } } } @@ -648,6 +778,7 @@ Item { color: theme.palette.highlighted.background opacity: mouseArea.containsMouse ? 0.1 : 0 radius: parent.radius + enabled: false Behavior on opacity { NumberAnimation { @@ -697,7 +828,7 @@ Item { // Clear Filter Button TSButton { text: i18n.dtr("ubtms", "Clear Filter") - enabled: selectedAssigneeIds.length > 0 + enabled: showAllUsersOption ? true : selectedAssigneeIds.length > 0 width: units.gu(15) height: units.gu(4) bgColor: enabled ? LomiriColors.orange : LomiriColors.ash @@ -706,8 +837,11 @@ Item { Component.onCompleted: {} onClicked: { - selectedAssigneeIds = []; + if (!showAllUsersOption) { + selectedAssigneeIds = []; + } filterModel.update(); + recomputeDisplayAssigneeCache(); expanded = false; filterCleared(); } @@ -731,7 +865,15 @@ Item { Text { property int selectedCount: getSelectedDisplayAssigneeCount() - text: selectedCount + " assignee" + (selectedCount === 1 ? "" : "s") + " selected" + text: { + if (showAllUsersOption && isOnlyAllUsersSelected()) { + return i18n.dtr("ubtms", "All Users selected"); + } + if (showAllUsersOption) { + return selectedCount + " " + (selectedCount === 1 ? i18n.dtr("ubtms", "employee selected") : i18n.dtr("ubtms", "employees selected")); + } + return selectedCount + " " + (selectedCount === 1 ? i18n.dtr("ubtms", "assignee selected") : i18n.dtr("ubtms", "assignees selected")); + } font.pixelSize: units.gu(1.6) color: theme.palette.normal.backgroundText anchors.centerIn: parent @@ -743,8 +885,8 @@ Item { // Function to load assignees for the current account function loadAssignees(accountId) { - // This will be called from the parent component - // to populate the assigneeModel + // This will be called from the parent component + // to populate the assigneeModel } // Initialize the filter model when assigneeModel changes @@ -766,6 +908,12 @@ Item { } } + onExpandedChanged: { + if (!expanded && searchField) { + searchField.text = ""; + } + } + Component.onCompleted: { if (filterModel) { filterModel.update(); diff --git a/qml/components/visualization/EHower.qml b/qml/components/visualization/EHower.qml index c99a2947..7666e48f 100644 --- a/qml/components/visualization/EHower.qml +++ b/qml/components/visualization/EHower.qml @@ -30,6 +30,7 @@ import Qt.labs.settings 1.0 import "../../../models/constants.js" as AppConst import QtQuick.LocalStorage 2.7 as Sql import "../../../models/accounts.js" as Accounts +import "../../../models/global.js" as Global import ".." Item { @@ -37,15 +38,36 @@ Item { width: parent.width height: parent.height signal quadrantClicked(int quadrant) - property int selectedAccountId: accountPicker.selectedAccountId + property int selectedAccountId: (typeof accountPicker !== "undefined" && accountPicker && accountPicker.selectedAccountId !== undefined) ? accountPicker.selectedAccountId : -1 + property var selectedUserId: -1 property bool autoRefreshOnAccountChange: true property string quadrant1Hours: "0" property string quadrant2Hours: "0" property string quadrant3Hours: "0" property string quadrant4Hours: "0" - function refreshQuadrants(startDate, endDate) { - var result = getQuadrantHoursFromAllInstances(selectedAccountId, startDate, endDate); + function refreshQuadrants(startDate, endDate, userId, accountId) { + if (accountId !== undefined && accountId !== null) { + selectedAccountId = Number(accountId); + } else if (typeof accountPicker !== "undefined" && accountPicker && accountPicker.selectedAccountId !== undefined) { + selectedAccountId = Number(accountPicker.selectedAccountId); + } + if (userId !== undefined && userId !== null) { + selectedUserId = userId; + } else { + var userFilter = Global.getDashboardUserFilter(); + if (userFilter && userFilter.userId !== undefined && userFilter.userId !== null) { + selectedUserId = userFilter.userId; + } + } + var sDate = startDate; + var eDate = endDate; + if (sDate === undefined || eDate === undefined) { + var filterData = Global.getDateRangeFilter(); + sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + } + var result = getQuadrantHoursFromAllInstances(selectedAccountId, sDate, eDate, selectedUserId); quadrant1Hours = result[1] + "H"; quadrant2Hours = result[2] + "H"; quadrant3Hours = result[3] + "H"; @@ -53,7 +75,7 @@ Item { } // TODO: Move it to Utils - function getQuadrantHoursFromAllInstances(accountId, startDate, endDate) { + function getQuadrantHoursFromAllInstances(accountId, startDate, endDate, userId) { var db = Sql.LocalStorage.openDatabaseSync("myDatabase", "1.0", "My Database", 1000000); var quadrantHours = { 1: 0.0, @@ -68,9 +90,10 @@ Item { var conditions = ["(status IS NULL OR status != 'deleted')"]; var params = []; - if (accountId !== -1 && accountId !== undefined && accountId !== null) { + var targetAccountId = (accountId !== undefined && accountId !== null) ? accountId : selectedAccountId; + if (targetAccountId !== -1 && targetAccountId !== "-1" && targetAccountId !== undefined && targetAccountId !== null) { conditions.push("account_id = ?"); - params.push(accountId); + params.push(Number(targetAccountId)); } if (startDate) { conditions.push("DATE(record_date) >= DATE(?)"); @@ -80,6 +103,14 @@ Item { conditions.push("DATE(record_date) <= DATE(?)"); params.push(endDate); } + var uid = (userId !== undefined && userId !== null) ? userId : selectedUserId; + if (uid !== -1 && uid !== "-1") { + var userFilter = Accounts.buildUserFilterSQL(uid, targetAccountId, ""); + if (userFilter.clause) { + conditions.push(userFilter.clause); + params = params.concat(userFilter.params); + } + } var query = "SELECT quadrant_id, SUM(unit_amount) as total FROM account_analytic_line_app WHERE " + conditions.join(" AND ") + " GROUP BY quadrant_id"; rs = tx.executeSql(query, params); @@ -454,18 +485,23 @@ Item { } } + Connections { + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) + onGlobalDashboardUserChanged: function (userId, userName) { + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var accId = (typeof accountPicker !== "undefined" && accountPicker.selectedAccountId !== undefined) ? accountPicker.selectedAccountId : selectedAccountId; + refreshQuadrants(sDate, eDate, userId, accId); + } + } + Component.onCompleted: { - var result = getQuadrantHoursFromAllInstances(selectedAccountId); - quadrant1Hours = result[1] + "H"; - quadrant2Hours = result[2] + "H"; - quadrant3Hours = result[3] + "H"; - quadrant4Hours = result[4] + "H"; + refreshQuadrants(); } onVisibleChanged: { - var result = getQuadrantHoursFromAllInstances(selectedAccountId); - quadrant1Hours = result[1] + "H"; - quadrant2Hours = result[2] + "H"; - quadrant3Hours = result[3] + "H"; - quadrant4Hours = result[4] + "H"; + if (visible) { + refreshQuadrants(); + } } } diff --git a/qml/components/visualization/ProjectPieChart.qml b/qml/components/visualization/ProjectPieChart.qml index 3467edf6..b7e0fb17 100644 --- a/qml/components/visualization/ProjectPieChart.qml +++ b/qml/components/visualization/ProjectPieChart.qml @@ -28,6 +28,7 @@ import QtQuick.Controls 2.2 import "../../../models/utils.js" as Utils import "../../../models/project.js" as Project import "../../../models/accounts.js" as Account +import "../../../models/global.js" as Global import ".." Item { @@ -37,6 +38,7 @@ Item { // account used to fetch data; numeric -1 indicates "all accounts" property int selectedAccountId: accountPicker.selectedAccountId + property var selectedUserId: -1 property bool autoRefreshOnAccountChange: true // Custom styled title overlay @@ -260,11 +262,19 @@ Item { } // Fetch-and-load helper using the new project API signature - function refreshForAccount(accountId, startDate, endDate) { + function refreshForAccount(accountId, startDate, endDate, userId) { + if (userId !== undefined && userId !== null) { + selectedUserId = userId; + } else { + var userFilter = Global.getDashboardUserFilter(); + if (userFilter && userFilter.userId !== undefined) { + selectedUserId = userFilter.userId; + } + } - // call project API with account param and date range + // call project API with account param, date range and userId try { - var data = Project.getProjectSpentHoursList(true, accountId, startDate, endDate); + var data = Project.getProjectSpentHoursList(true, accountId, startDate, endDate, selectedUserId); // data should be an array; load chart load(data || []); } catch (e) { @@ -274,14 +284,35 @@ Item { } Component.onCompleted: { - refreshForAccount(accountPicker.selectedAccountId); + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var userFilter = Global.getDashboardUserFilter(); + var uid = (root.selectedUserId !== undefined && root.selectedUserId !== null && root.selectedUserId !== -1) ? root.selectedUserId : (userFilter ? userFilter.userId : -1); + refreshForAccount(accountPicker.selectedAccountId, sDate, eDate, uid); } // Re-fetch when the account selector changes Connections { target: autoRefreshOnAccountChange ? accountPicker : null onAccepted: function (accountId, accountName) { - refreshForAccount(accountId); + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var userFilter = Global.getDashboardUserFilter(); + var uid = (root.selectedUserId !== undefined && root.selectedUserId !== null && root.selectedUserId !== -1) ? root.selectedUserId : (userFilter ? userFilter.userId : -1); + refreshForAccount(accountId, sDate, eDate, uid); + } + } + + Connections { + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) + onGlobalDashboardUserChanged: function (userId, userName) { + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : root.selectedAccountId; + refreshForAccount(accId, sDate, eDate, userId); } } } diff --git a/qml/features/dashboard/charts/Charts3.qml b/qml/features/dashboard/charts/Charts3.qml index 0a457d7e..31d7e00f 100644 --- a/qml/features/dashboard/charts/Charts3.qml +++ b/qml/features/dashboard/charts/Charts3.qml @@ -40,11 +40,12 @@ Item { property bool autoRefreshOnAccountChange: true property int selectedAccountId: typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1 + property var selectedUserId: -1 property string filterStartDate: "" property string filterEndDate: "" - function reloadData(startDate, endDate, accountId) { + function reloadData(startDate, endDate, accountId, userId) { if (startDate !== undefined) filterStartDate = startDate || ""; if (endDate !== undefined) filterEndDate = endDate || ""; if (accountId !== undefined && accountId !== null) { @@ -52,10 +53,13 @@ Item { } else if (selectedAccountId < 0 && typeof accountPicker !== "undefined") { selectedAccountId = accountPicker.selectedAccountId; } + if (userId !== undefined && userId !== null) { + selectedUserId = userId; + } var t_proj = []; var maxVal = 0; - var data = Model.get_projects_spent_hours(root.selectedAccountId, filterStartDate, filterEndDate); + var data = Model.get_projects_spent_hours(root.selectedAccountId, filterStartDate, filterEndDate, root.selectedUserId); root.totalProjects = data.length; var limit = Math.min(root.displayLimit, data.length); @@ -114,7 +118,9 @@ Item { var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : root.selectedAccountId; - reloadData(sDate, eDate, accId); + var userFilter = Global.getDashboardUserFilter(); + var uid = (root.selectedUserId !== undefined && root.selectedUserId !== null && root.selectedUserId !== -1) ? root.selectedUserId : (userFilter ? userFilter.userId : -1); + reloadData(sDate, eDate, accId, uid); } Connections { @@ -132,15 +138,27 @@ Item { } Connections { - target: root.autoRefreshOnAccountChange && typeof rootApp !== "undefined" ? rootApp : null + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) onGlobalAccountChanged: function (accountId, accountName) { - root.selectedAccountId = accountId; - root.displayLimit = 10; - reloadData(root.filterStartDate, root.filterEndDate, accountId); + if (root.autoRefreshOnAccountChange) { + root.selectedAccountId = accountId; + root.displayLimit = 10; + reloadData(root.filterStartDate, root.filterEndDate, accountId); + } } onAccountDataRefreshRequested: function (accountId) { - root.selectedAccountId = accountId; - reloadData(root.filterStartDate, root.filterEndDate, accountId); + if (root.autoRefreshOnAccountChange) { + root.selectedAccountId = accountId; + reloadData(root.filterStartDate, root.filterEndDate, accountId); + } + } + onGlobalDashboardUserChanged: function (userId, userName) { + root.selectedUserId = userId; + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var accId = (typeof accountPicker !== "undefined") ? accountPicker.selectedAccountId : root.selectedAccountId; + reloadData(sDate, eDate, accId, userId); } } diff --git a/qml/features/dashboard/charts/Charts4.qml b/qml/features/dashboard/charts/Charts4.qml index c53ef0aa..95004ab2 100644 --- a/qml/features/dashboard/charts/Charts4.qml +++ b/qml/features/dashboard/charts/Charts4.qml @@ -16,12 +16,13 @@ Item { property bool autoRefreshOnAccountChange: true property int selectedAccountId: typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1 + property var selectedUserId: -1 property var projectsModel: [] property string filterStartDate: "" property string filterEndDate: "" - function reloadData(startDate, endDate, accountId) { + function reloadData(startDate, endDate, accountId, userId) { if (startDate !== undefined) filterStartDate = startDate || ""; if (endDate !== undefined) filterEndDate = endDate || ""; if (accountId !== undefined && accountId !== null) { @@ -29,11 +30,14 @@ Item { } else if (selectedAccountId < 0 && typeof accountPicker !== "undefined") { selectedAccountId = accountPicker.selectedAccountId; } + if (userId !== undefined && userId !== null) { + selectedUserId = userId; + } projectsModel = buildProjectsModel(filterStartDate, filterEndDate); } function buildProjectsModel(startDate, endDate) { - var rows = ProjectModel.getDashboardProjectTaskSummary(selectedAccountId, startDate, endDate); + var rows = ProjectModel.getDashboardProjectTaskSummary(selectedAccountId, startDate, endDate, selectedUserId); for (var i = 0; i < rows.length; i++) { var project = rows[i]; @@ -54,7 +58,7 @@ Item { } var projectRecordId = (project.accountId === 0 || !project.odooRecordId) ? project.localId : project.odooRecordId; - var taskRows = TaskModel.getTasksForProject(projectRecordId, project.accountId, filterStartDate, filterEndDate); + var taskRows = TaskModel.getTasksForProject(projectRecordId, project.accountId, filterStartDate, filterEndDate, selectedUserId); var mappedTasks = []; for (var i = 0; i < taskRows.length; i++) { @@ -94,7 +98,7 @@ Item { return []; } - var timesheets = TimesheetModel.getTimesheetsForTask(task.odooRecordId, project.accountId, "all", filterStartDate, filterEndDate); + var timesheets = TimesheetModel.getTimesheetsForTask(task.odooRecordId, project.accountId, "all", filterStartDate, filterEndDate, selectedUserId); var logs = []; for (var i = 0; i < timesheets.length; i++) { @@ -103,7 +107,8 @@ Item { id: entry.id, date: toIsoDate(entry.date), hours: parseHours(entry.spentHours), - note: entry.name || "" + note: entry.name || "", + user: entry.user || "" }); } @@ -197,7 +202,9 @@ Item { var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : root.selectedAccountId; - reloadData(sDate, eDate, accId); + var userFilter = Global.getDashboardUserFilter(); + var uid = (root.selectedUserId !== undefined && root.selectedUserId !== null && root.selectedUserId !== -1) ? root.selectedUserId : (userFilter ? userFilter.userId : -1); + reloadData(sDate, eDate, accId, uid); } Connections { @@ -213,14 +220,26 @@ Item { } Connections { - target: root.autoRefreshOnAccountChange && typeof rootApp !== "undefined" ? rootApp : null + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) onGlobalAccountChanged: function (accountId, accountName) { - root.selectedAccountId = accountId; - reloadData(root.filterStartDate, root.filterEndDate, accountId); + if (root.autoRefreshOnAccountChange) { + root.selectedAccountId = accountId; + reloadData(root.filterStartDate, root.filterEndDate, accountId); + } } onAccountDataRefreshRequested: function (accountId) { - root.selectedAccountId = accountId; - reloadData(root.filterStartDate, root.filterEndDate, accountId); + if (root.autoRefreshOnAccountChange) { + root.selectedAccountId = accountId; + reloadData(root.filterStartDate, root.filterEndDate, accountId); + } + } + onGlobalDashboardUserChanged: function (userId, userName) { + root.selectedUserId = userId; + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var accId = (typeof accountPicker !== "undefined") ? accountPicker.selectedAccountId : root.selectedAccountId; + reloadData(sDate, eDate, accId, userId); } } } diff --git a/qml/features/dashboard/charts/TaskTimeChart.qml b/qml/features/dashboard/charts/TaskTimeChart.qml index d13f0ee7..4a6f2270 100644 --- a/qml/features/dashboard/charts/TaskTimeChart.qml +++ b/qml/features/dashboard/charts/TaskTimeChart.qml @@ -706,12 +706,37 @@ Item { } } - Label { + Column { Layout.fillWidth: true - text: modelData.note || i18n.dtr("ubtms", "No note") - color: Theme.palette.normal.backgroundText - font.pixelSize: units.dp(13) - wrapMode: Text.WordWrap + spacing: units.gu(0.4) + + RowLayout { + spacing: units.gu(0.6) + visible: !!modelData.user && modelData.user !== "" + + Icon { + name: "contact" + width: units.gu(1.4) + height: units.gu(1.4) + color: root.activeAccent + } + + Label { + text: modelData.user || "" + color: Theme.palette.normal.baseText + font.pixelSize: units.dp(12) + font.bold: true + elide: Text.ElideRight + } + } + + Label { + Layout.fillWidth: true + text: modelData.note || i18n.dtr("ubtms", "No note") + color: Theme.palette.normal.backgroundText + font.pixelSize: units.dp(13) + wrapMode: Text.WordWrap + } } Label { diff --git a/qml/features/dashboard/pages/Dashboard.qml b/qml/features/dashboard/pages/Dashboard.qml index f567f7dc..ddf93297 100644 --- a/qml/features/dashboard/pages/Dashboard.qml +++ b/qml/features/dashboard/pages/Dashboard.qml @@ -45,6 +45,68 @@ Page { property string loadingMessage: i18n.dtr("ubtms", "Loading dashboard...") property int refreshStage: -1 property int lastRefreshAccountId: -999999 + property var selectedUserId: -1 + property string selectedUserName: i18n.dtr("ubtms", "All Users") + property var availableUsers: [] + property bool isUserFilterVisible: false + property int lastFilterAccountId: -999999 + + function initUserFilterForCurrentAccount(targetAccountId) { + var accId = (targetAccountId !== undefined && targetAccountId !== null) + ? targetAccountId + : ((typeof accountPicker !== "undefined" && accountPicker && accountPicker.selectedAccountId !== undefined) + ? accountPicker.selectedAccountId + : -1); + lastFilterAccountId = accId; + isUserFilterVisible = Account.hasManagerOrAdminAccess(accId); + if (!isUserFilterVisible) { + assigneeFilterMenu.expanded = false; + } + availableUsers = Account.getDashboardFilterUsers(accId); + + var menuAssignees = []; + for (var i = 0; i < availableUsers.length; i++) { + var u = availableUsers[i]; + var acctId = (u.accountId !== undefined && u.accountId !== null) ? u.accountId : ((u.account_id !== undefined) ? u.account_id : accId); + menuAssignees.push({ + id: u.id, + odoo_record_id: u.id, + name: u.name, + email: u.email || "", + account_name: u.account_name || (u.account_names ? u.account_names.join(", ") : ""), + account_id: acctId, + allUserIds: u.allUserIds || [u.id], + allAccountIds: u.allAccountIds || [acctId] + }); + } + assigneeFilterMenu.showAccountName = (accId === -1); + assigneeFilterMenu.assigneeModel = menuAssignees; + + var defaultUser = Account.getDashboardSingleUserDefault(accId, availableUsers); + if (defaultUser) { + selectedUserId = defaultUser.id; + selectedUserName = defaultUser.name; + var defSelections = []; + if (defaultUser.allUserIds && defaultUser.allAccountIds) { + for (var d = 0; d < defaultUser.allUserIds.length; d++) { + defSelections.push(assigneeFilterMenu.createSelection(defaultUser.allUserIds[d], defaultUser.allAccountIds[d] || accId)); + } + } else { + var defAcctId = (defaultUser.accountId !== undefined) ? defaultUser.accountId : accId; + defSelections.push(assigneeFilterMenu.createSelection(defaultUser.id, defAcctId)); + } + assigneeFilterMenu.selectedAssigneeIds = defSelections; + } else { + selectedUserId = -1; + selectedUserName = i18n.dtr("ubtms", "All Users"); + assigneeFilterMenu.selectedAssigneeIds = [assigneeFilterMenu.createSelection(-1, -1)]; + } + Global.setDashboardUserFilter(selectedUserId, selectedUserName); + var appObj = (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null); + if (appObj && appObj.globalDashboardUserChanged) { + appObj.globalDashboardUserChanged(selectedUserId, selectedUserName); + } + } // Timer for deferred loading - gives UI time to render loading indicator Timer { @@ -59,6 +121,13 @@ Page { // Update navigation tracking when Dashboard becomes visible Global.setLastVisitedPage("Dashboard"); + var currentAccId = (typeof accountPicker !== "undefined" && accountPicker && accountPicker.selectedAccountId !== undefined) + ? accountPicker.selectedAccountId + : -1; + if (lastFilterAccountId !== currentAccId || availableUsers.length === 0) { + initUserFilterForCurrentAccount(currentAccId); + } + refreshData(); } } @@ -85,6 +154,7 @@ Page { contents: FilterableHeaderContents { id: headerContents title: i18n.dtr("ubtms", "Dashboard") + userLabel: selectedUserName onDateRangeChanged: refreshData() Component.onCompleted: { @@ -107,7 +177,7 @@ Page { } ] - trailingActionBar.numberOfSlots: 5 + trailingActionBar.numberOfSlots: 6 trailingActionBar.actions: [ Action { id: infoAction @@ -142,6 +212,15 @@ Page { openNewTimesheetPage(false); } }, + Action { + id: userFilterAction + iconName: (Array.isArray(selectedUserId) && selectedUserId.length > 1) ? "contact-group" : "contact" + text: selectedUserName ? selectedUserName : i18n.dtr("ubtms", "All Users") + visible: !headerContents.showDateFilter && isUserFilterVisible + onTriggered: { + assigneeFilterMenu.expanded = !assigneeFilterMenu.expanded; + } + }, Action { id: filterAction iconName: "filters" @@ -183,15 +262,14 @@ Page { } } - function refreshData() { + function refreshData(force) { Logger.debug("Dashboard", "Refreshing Dashboard data...") - var targetAccountId = accountPicker.selectedAccountId; - if (isLoading && refreshStage >= 0 && lastRefreshAccountId === targetAccountId) { + var targetAccountId = (typeof accountPicker !== "undefined") ? accountPicker.selectedAccountId : -1; + if (!force && isLoading && lastRefreshAccountId === targetAccountId) { return; } lastRefreshAccountId = targetAccountId; - refreshStage = 0; loadingTimer.stop(); loadingMessage = targetAccountId === -1 ? i18n.dtr("ubtms", "Preparing all-account dashboard...") @@ -212,46 +290,38 @@ Page { function _doRefreshData() { try { - var sDate = typeof headerContents.dateFilter !== "undefined" ? headerContents.dateFilter.startDate : ""; - var eDate = typeof headerContents.dateFilter !== "undefined" ? headerContents.dateFilter.endDate : ""; - - switch (refreshStage) { - case 0: - Logger.debug("Dashboard", "Dashboard refresh stage 0: priority matrix") - if (typeof ehoverMatrix !== "undefined" && ehoverMatrix.refreshQuadrants) { - ehoverMatrix.refreshQuadrants(sDate, eDate); - } - loadingMessage = i18n.dtr("ubtms", "Loading project chart..."); - refreshStage = 1; - loadingTimer.interval = 0; - loadingTimer.start(); - return; - case 1: - Logger.debug("Dashboard", "Dashboard refresh stage 1: project chart") - if (typeof projectchart !== "undefined") { - projectchart.refreshForAccount(accountPicker.selectedAccountId, sDate, eDate); - } - loadingMessage = i18n.dtr("ubtms", "Loading additional charts..."); - refreshStage = 2; - loadingTimer.start(); - return; - case 2: - Logger.debug("Dashboard", "Dashboard refresh stage 2: additional charts") - var activeAccId = (typeof accountPicker !== "undefined") ? accountPicker.selectedAccountId : -1; - if (mobileProjectChartLoader.item && typeof mobileProjectChartLoader.item.reloadData === "function") { - mobileProjectChartLoader.item.selectedAccountId = activeAccId; - mobileProjectChartLoader.item.reloadData(sDate, eDate, activeAccId); - } - if (mobileTaskChartLoader.item && typeof mobileTaskChartLoader.item.reloadData === "function") { - mobileTaskChartLoader.item.selectedAccountId = activeAccId; - mobileTaskChartLoader.item.reloadData(sDate, eDate, activeAccId); - } - break; - default: - break; + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var activeAccId = (typeof accountPicker !== "undefined") ? accountPicker.selectedAccountId : -1; + var uid = (selectedUserId !== undefined && selectedUserId !== null && selectedUserId !== -1) ? selectedUserId : (Global.getDashboardUserFilter() ? Global.getDashboardUserFilter().userId : -1); + + Logger.debug("Dashboard", "Executing Dashboard refresh for user: " + JSON.stringify(uid) + ", account: " + activeAccId); + + // 1. Priority Matrix (Eisenhower) + if (typeof ehoverMatrix !== "undefined" && ehoverMatrix.refreshQuadrants) { + ehoverMatrix.refreshQuadrants(sDate, eDate, uid, activeAccId); + } + + // 2. Overview Pie Chart + if (typeof projectchart !== "undefined" && typeof projectchart.refreshForAccount === "function") { + projectchart.refreshForAccount(activeAccId, sDate, eDate, uid); + } + + // 3. Mobile tab loaders (Projects & Tasks charts) + if (mobileProjectChartLoader.item && typeof mobileProjectChartLoader.item.reloadData === "function") { + mobileProjectChartLoader.item.selectedAccountId = activeAccId; + mobileProjectChartLoader.item.selectedUserId = uid; + mobileProjectChartLoader.item.reloadData(sDate, eDate, activeAccId, uid); + } + + if (mobileTaskChartLoader.item && typeof mobileTaskChartLoader.item.reloadData === "function") { + mobileTaskChartLoader.item.selectedAccountId = activeAccId; + mobileTaskChartLoader.item.selectedUserId = uid; + mobileTaskChartLoader.item.reloadData(sDate, eDate, activeAccId, uid); } } catch(e) { - Logger.error("Dashboard", "_doRefreshData ERROR: ", e) + Logger.error("Dashboard", "_doRefreshData ERROR: ", e); } finishRefreshData(); } @@ -336,10 +406,6 @@ Page { height: width autoRefreshOnAccountChange: false anchors.centerIn: parent - quadrant1Hours: "120.2" - quadrant2Hours: "65.5" - quadrant3Hours: "55.0" - quadrant4Hours: "178.1" onQuadrantClicked: {} } } @@ -425,6 +491,30 @@ Page { onCurrentIndexChanged: { if (mobileChartTabBar.currentIndex !== currentIndex) mobileChartTabBar.currentIndex = currentIndex; + + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1; + var uid = (selectedUserId !== undefined && selectedUserId !== null && selectedUserId !== -1) ? selectedUserId : (Global.getDashboardUserFilter() ? Global.getDashboardUserFilter().userId : -1); + + if (currentIndex === 0) { + if (typeof projectchart !== "undefined" && typeof projectchart.refreshForAccount === "function") { + projectchart.refreshForAccount(accId, sDate, eDate, uid); + } + } else if (currentIndex === 1) { + if (mobileProjectChartLoader.item && typeof mobileProjectChartLoader.item.reloadData === "function") { + mobileProjectChartLoader.item.selectedAccountId = accId; + mobileProjectChartLoader.item.selectedUserId = uid; + mobileProjectChartLoader.item.reloadData(sDate, eDate, accId, uid); + } + } else if (currentIndex === 2) { + if (mobileTaskChartLoader.item && typeof mobileTaskChartLoader.item.reloadData === "function") { + mobileTaskChartLoader.item.selectedAccountId = accId; + mobileTaskChartLoader.item.selectedUserId = uid; + mobileTaskChartLoader.item.reloadData(sDate, eDate, accId, uid); + } + } } Item { @@ -447,7 +537,13 @@ Page { if (item) { item.autoRefreshOnAccountChange = false; var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1; + var uid = (selectedUserId !== undefined && selectedUserId !== null && selectedUserId !== -1) ? selectedUserId : (Global.getDashboardUserFilter() ? Global.getDashboardUserFilter().userId : -1); item.selectedAccountId = accId; + item.selectedUserId = uid; + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + item.reloadData(sDate, eDate, accId, uid); } } } @@ -463,7 +559,13 @@ Page { if (item) { item.autoRefreshOnAccountChange = false; var accId = typeof accountPicker !== "undefined" ? accountPicker.selectedAccountId : -1; + var uid = (selectedUserId !== undefined && selectedUserId !== null && selectedUserId !== -1) ? selectedUserId : (Global.getDashboardUserFilter() ? Global.getDashboardUserFilter().userId : -1); item.selectedAccountId = accId; + item.selectedUserId = uid; + var filterData = Global.getDateRangeFilter(); + var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; + var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + item.reloadData(sDate, eDate, accId, uid); } } } @@ -523,22 +625,156 @@ Page { if (accountPicker.selectedAccountName) { header.title = i18n.dtr("ubtms", "Account") + " [" + accountPicker.selectedAccountName + "]"; } + initUserFilterForCurrentAccount(accountPicker.selectedAccountId); refreshData(); } onAccepted: function (accountId, accountName) { header.title = i18n.dtr("ubtms", "Account") + " [" + accountName + "]"; + initUserFilterForCurrentAccount(accountId); refreshData(); } } Connections { - target: typeof rootApp !== "undefined" ? rootApp : null + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) onGlobalAccountChanged: function (accountId, accountName) { header.title = i18n.dtr("ubtms", "Account") + " [" + accountName + "]"; - refreshData(); + initUserFilterForCurrentAccount(accountId); + refreshData(true); } onAccountDataRefreshRequested: function (accountId) { - refreshData(); + initUserFilterForCurrentAccount(accountId); + refreshData(true); + } + onGlobalDashboardUserChanged: function (userId, userName) { + selectedUserId = userId; + selectedUserName = userName; + if (userId === -1 || userId === "-1") { + assigneeFilterMenu.selectedAssigneeIds = [assigneeFilterMenu.createSelection(-1, -1)]; + } else if (Array.isArray(userId)) { + assigneeFilterMenu.selectedAssigneeIds = userId.slice(); + } else { + var accId = (typeof accountPicker !== "undefined") ? accountPicker.selectedAccountId : -1; + assigneeFilterMenu.selectedAssigneeIds = [assigneeFilterMenu.createSelection(userId, accId)]; + } + refreshData(true); + } + } + + Connections { + target: typeof mainView !== "undefined" ? mainView : null + onGlobalDateRangeChanged: function (presetId, startDate, endDate, presetLabel) { + refreshData(true); + } + } + + function handleAssigneeFilterApplied(assigneeIds) { + var rawIds = assigneeIds || []; + var isAllUsers = false; + for (var k = 0; k < rawIds.length; k++) { + var itm = rawIds[k]; + var uidVal = (typeof itm === "object") ? itm.user_id : itm; + if (uidVal === -1 || uidVal === "-1") { + isAllUsers = true; + break; + } + } + + if (isAllUsers) { + selectedUserId = -1; + selectedUserName = i18n.dtr("ubtms", "All Users"); + assigneeFilterMenu.selectedAssigneeIds = [assigneeFilterMenu.createSelection(-1, -1)]; + Global.setDashboardUserFilter(-1, selectedUserName); + var appObj = (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null); + if (appObj && appObj.globalDashboardUserChanged) { + appObj.globalDashboardUserChanged(-1, selectedUserName); + } + refreshData(true); + return; + } + + // Match selected assignees against availableUsers + var selectedUsersList = []; + for (var i = 0; i < availableUsers.length; i++) { + var u = availableUsers[i]; + var isSel = false; + for (var j = 0; j < rawIds.length; j++) { + var s = rawIds[j]; + var sUid = (typeof s === "object") ? s.user_id : s; + if (sUid === u.id || (u.allUserIds && u.allUserIds.indexOf(sUid) !== -1)) { + isSel = true; + break; + } + } + if (isSel) { + selectedUsersList.push(u); + } + } + + if (selectedUsersList.length === 0) { + handleAssigneeFilterCleared(); + return; + } + + if (selectedUsersList.length === 1) { + selectedUserId = selectedUsersList[0].id; + selectedUserName = selectedUsersList[0].name; + } else { + selectedUserId = rawIds.slice(); + selectedUserName = selectedUsersList.length + " " + i18n.dtr("ubtms", "Employees"); + } + + Global.setDashboardUserFilter(selectedUserId, selectedUserName); + var appObj = (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null); + if (appObj && appObj.globalDashboardUserChanged) { + appObj.globalDashboardUserChanged(selectedUserId, selectedUserName); + } + refreshData(true); + } + + function handleAssigneeFilterCleared() { + var accId = (typeof accountPicker !== "undefined") ? accountPicker.selectedAccountId : -1; + var defaultUser = Account.getDashboardSingleUserDefault(accId, availableUsers); + + if (defaultUser) { + selectedUserId = defaultUser.id; + selectedUserName = defaultUser.name; + var defSelections = []; + if (defaultUser.allUserIds && defaultUser.allAccountIds) { + for (var d = 0; d < defaultUser.allUserIds.length; d++) { + defSelections.push(assigneeFilterMenu.createSelection(defaultUser.allUserIds[d], defaultUser.allAccountIds[d] || accId)); + } + } else { + var defAcctId = (defaultUser.accountId !== undefined) ? defaultUser.accountId : accId; + defSelections.push(assigneeFilterMenu.createSelection(defaultUser.id, defAcctId)); + } + assigneeFilterMenu.selectedAssigneeIds = defSelections; + } else { + selectedUserId = -1; + selectedUserName = i18n.dtr("ubtms", "All Users"); + assigneeFilterMenu.selectedAssigneeIds = [assigneeFilterMenu.createSelection(-1, -1)]; + } + + Global.setDashboardUserFilter(selectedUserId, selectedUserName); + var appObj = (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null); + if (appObj && appObj.globalDashboardUserChanged) { + appObj.globalDashboardUserChanged(selectedUserId, selectedUserName); + } + refreshData(true); + } + + AssigneeFilterMenu { + id: assigneeFilterMenu + anchors.fill: parent + z: 1000 + showAllUsersOption: true + + onFilterApplied: function (assigneeIds) { + handleAssigneeFilterApplied(assigneeIds); + } + + onFilterCleared: function () { + handleAssigneeFilterCleared(); } } @@ -604,6 +840,7 @@ Page { Component.onCompleted: { Logger.debug("Dashboard", "Dashboard status is: " + mainPage.status) + initUserFilterForCurrentAccount(); // Load notifications on startup notificationBell.loadNotifications(); } diff --git a/qml/features/dashboard/pages/Dashboard2.qml b/qml/features/dashboard/pages/Dashboard2.qml index 2e501b8a..3cc6ef3f 100644 --- a/qml/features/dashboard/pages/Dashboard2.qml +++ b/qml/features/dashboard/pages/Dashboard2.qml @@ -102,13 +102,17 @@ Page { var filterData = Global.getDateRangeFilter(); var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; + var userFilter = Global.getDashboardUserFilter(); + var uid = userFilter ? userFilter.userId : -1; if (load3.item && typeof load3.item.reloadData === "function") { load3.item.selectedAccountId = accountId; - load3.item.reloadData(sDate, eDate, accountId); + load3.item.selectedUserId = uid; + load3.item.reloadData(sDate, eDate, accountId, uid); } if (load4.item && typeof load4.item.reloadData === "function") { load4.item.selectedAccountId = accountId; - load4.item.reloadData(sDate, eDate, accountId); + load4.item.selectedUserId = uid; + load4.item.reloadData(sDate, eDate, accountId, uid); } } @@ -121,13 +125,16 @@ Page { } Connections { - target: typeof rootApp !== "undefined" ? rootApp : null + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) onGlobalAccountChanged: function (accountId, accountName) { refreshData(true); } onAccountDataRefreshRequested: function (accountId) { refreshData(true); } + onGlobalDashboardUserChanged: function (userId, userName) { + refreshData(true); + } } Connections { @@ -185,7 +192,10 @@ Page { var filterData = Global.getDateRangeFilter(); var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; - item.reloadData(sDate, eDate, accId); + var userFilter = Global.getDashboardUserFilter(); + var uid = userFilter ? userFilter.userId : -1; + item.selectedUserId = uid; + item.reloadData(sDate, eDate, accId, uid); } } } @@ -209,7 +219,10 @@ Page { var filterData = Global.getDateRangeFilter(); var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; - item.reloadData(sDate, eDate, accId); + var userFilter = Global.getDashboardUserFilter(); + var uid = userFilter ? userFilter.userId : -1; + item.selectedUserId = uid; + item.reloadData(sDate, eDate, accId, uid); } } } diff --git a/qml/features/dashboard/pages/Dashboard3.qml b/qml/features/dashboard/pages/Dashboard3.qml index f443ffcf..6421d8a8 100644 --- a/qml/features/dashboard/pages/Dashboard3.qml +++ b/qml/features/dashboard/pages/Dashboard3.qml @@ -133,7 +133,9 @@ Page { var filterData = Global.getDateRangeFilter(); var sDate = (filterData && filterData.isFiltered) ? filterData.startDate : ""; var eDate = (filterData && filterData.isFiltered) ? filterData.endDate : ""; - var quadrant_data = Model.get_tasks_spent_hours(accountId, sDate, eDate); + var userFilter = Global.getDashboardUserFilter(); + var uid = userFilter ? userFilter.userId : -1; + var quadrant_data = Model.get_tasks_spent_hours(accountId, sDate, eDate, uid); var count = 0; var timeval; var timecat = []; @@ -179,6 +181,13 @@ Page { } } + Connections { + target: (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null) + onGlobalDashboardUserChanged: function (userId, userName) { + reloadData(); + } + } + Rectangle { id: hoverInfo width: Math.min(hoverText.implicitWidth + units.gu(3), parent.width - units.gu(4)) diff --git a/qml/features/dashboard/pages/TaskDetailPage.qml b/qml/features/dashboard/pages/TaskDetailPage.qml index 56e6fdbd..89fa714d 100644 --- a/qml/features/dashboard/pages/TaskDetailPage.qml +++ b/qml/features/dashboard/pages/TaskDetailPage.qml @@ -137,10 +137,11 @@ Page { delegate: UbuntuShape { width: parent.width - height: units.gu(7) + implicitHeight: Math.max(units.gu(7), logDetailRowLayout.implicitHeight + units.gu(2)) color: Theme.palette.normal.base RowLayout { + id: logDetailRowLayout anchors.fill: parent anchors.leftMargin: units.gu(1.5) anchors.rightMargin: units.gu(1.5) @@ -154,12 +155,37 @@ Page { elide: Text.ElideRight } - Label { + Column { Layout.fillWidth: true - text: modelData.note || i18n.dtr("ubtms", "No note") - color: Theme.palette.normal.backgroundText - font.pixelSize: units.dp(12) - elide: Text.ElideRight + spacing: units.gu(0.3) + + RowLayout { + spacing: units.gu(0.5) + visible: !!modelData.user && modelData.user !== "" + + Icon { + name: "contact" + width: units.gu(1.3) + height: units.gu(1.3) + color: Theme.palette.normal.backgroundText + } + + Label { + text: modelData.user || "" + color: Theme.palette.normal.baseText + font.pixelSize: units.dp(11) + font.bold: true + elide: Text.ElideRight + } + } + + Label { + Layout.fillWidth: true + text: modelData.note || i18n.dtr("ubtms", "No note") + color: Theme.palette.normal.backgroundText + font.pixelSize: units.dp(12) + elide: Text.ElideRight + } } Label { diff --git a/tests/qml/README.md b/tests/qml/README.md index 9c014819..bcac468f 100644 --- a/tests/qml/README.md +++ b/tests/qml/README.md @@ -24,13 +24,11 @@ tests/ │ ├── README.md # This guide │ ├── models/ # JavaScript core and model test suites │ │ ├── tst_constants.qml # Test suite for constants and quadrant color configuration -│ │ ├── tst_database.qml # Test suite for database constants, row conversions, and local seeders -│ │ ├── tst_dbinit.qml # Test suite for database table initialization and auto-sync settings -│ │ ├── tst_draft_manager.qml # Test suite for form draft saving, loading, diffing, and cleanup -│ │ ├── tst_global.qml # Test suite for global assignee filtering and date range persistence +│ │ ├── tst_dashboard_user_filter.qml # Test suite for dashboard user filter state, models, and queries │ │ ├── tst_logger.qml # Test suite for logger levels, formatting, and PII sanitization │ │ ├── tst_timer_service.qml # Test suite for timer service state transitions and duration │ │ └── tst_utils.qml # Test suite for date, time, duration, and URL utilities +│ ├── tst_assigneefiltermenu.qml # Test suite for AssigneeFilterMenu selector component │ ├── tst_customdatepicker.qml # Test suite for CustomDatePicker picker component │ ├── tst_globaltimerwidget.qml # Test suite for GlobalTimerWidget system component │ ├── tst_infobar.qml # Test suite for InfoBar feedback component diff --git a/tests/qml/models/tst_dashboard_user_filter.qml b/tests/qml/models/tst_dashboard_user_filter.qml new file mode 100644 index 00000000..5151889f --- /dev/null +++ b/tests/qml/models/tst_dashboard_user_filter.qml @@ -0,0 +1,775 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DBInit +import "../../../models/global.js" as Global +import "../../../models/accounts.js" as Account +import "../../../models/project.js" as Project +import "../../../models/task.js" as Task +import "../../../models/timesheet.js" as Timesheet +import "../../../models/Main.js" as Main +import "../../../qml/components/visualization" + +Item { + width: 200 + height: 200 + + EHower { + id: testEHower + visible: false + autoRefreshOnAccountChange: false + } + + TestCase { + name: "DashboardUserFilterTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DBInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function init() { + Global.setDashboardUserFilter(-1, "All Users"); + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("DELETE FROM users"); + tx.executeSql("DELETE FROM res_users_app"); + tx.executeSql("DELETE FROM project_project_app"); + tx.executeSql("DELETE FROM project_task_app"); + tx.executeSql("DELETE FROM account_analytic_line_app"); + }); + } + + // --- 1. Global User Filter State --- + function test_globalDashboardUserFilterState() { + Global.setDashboardUserFilter(-1, "All Users"); + var current = Global.getDashboardUserFilter(); + compare(current.userId, -1); + compare(current.userName, "All Users"); + + Global.setDashboardUserFilter(101, "Alice Smith"); + current = Global.getDashboardUserFilter(); + compare(current.userId, 101); + compare(current.userName, "Alice Smith"); + + // Reset with null/undefined values + Global.setDashboardUserFilter(null, ""); + current = Global.getDashboardUserFilter(); + compare(current.userId, -1); + compare(current.userName, "All Users"); + } + + // --- 2. Account.getDashboardFilterUsers --- + function test_getDashboardFilterUsers_empty() { + var users = Account.getDashboardFilterUsers(1); + compare(users.length, 0); + } + + function test_getDashboardFilterUsers_populated() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Account 1 active users + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 10, 'Charlie', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 20, 'Alice', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 30, 'Bob', 1)"); + // Account 1 inactive user (should be ignored) + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 40, 'Inactive User', 0)"); + // Account 2 user (should be ignored) + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (2, 50, 'Account2 User', 1)"); + }); + + var users = Account.getDashboardFilterUsers(1); + compare(users.length, 3); + // Should be sorted alphabetically: Alice, Bob, Charlie + compare(users[0].name, "Alice"); + compare(users[0].id, 20); + compare(users[1].name, "Bob"); + compare(users[1].id, 30); + compare(users[2].name, "Charlie"); + compare(users[2].id, 10); + } + + // --- 3. Account.getDashboardSingleUserDefault --- + function test_getDashboardSingleUserDefault_singleUser() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 15, 'Single User', 1)"); + }); + + var available = Account.getDashboardFilterUsers(1); + compare(available.length, 1); + var defaultUser = Account.getDashboardSingleUserDefault(1, available); + verify(defaultUser !== null); + compare(defaultUser.id, 15); + compare(defaultUser.name, "Single User"); + } + + function test_getDashboardSingleUserDefault_standardEmployeeSingleTimesheets() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Multiple users exist in directory + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 10, 'Employee Alice', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, active) VALUES (1, 20, 'Manager Bob', 1)"); + + // But only Alice's timesheets are synced locally (standard employee permission) + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (1, 10, 8.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (1, 10, 7.5, '2026-10-02', 'synced')"); + }); + + var available = Account.getDashboardFilterUsers(1); + compare(available.length, 1); + var defaultUser = Account.getDashboardSingleUserDefault(1, available); + verify(defaultUser !== null); + compare(defaultUser.id, 10); + compare(defaultUser.name, "Employee Alice"); + } + + function test_getDashboardSingleUserDefault_managerMultipleTimesheets() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Account 1 with Bob logged in as manager + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (1, 'Acme Corp', 'https://odoo.acme.com', 'acme_db', 'bob')"); + + // Multiple users exist + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (1, 10, 'Alice', 'alice', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (1, 20, 'Bob', 'bob', 1)"); + + // Both users have timesheets + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (1, 10, 8.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (1, 20, 6.0, '2026-10-01', 'synced')"); + }); + + var available = Account.getDashboardFilterUsers(1); + compare(available.length, 2); + var defaultUser = Account.getDashboardSingleUserDefault(1, available); + // Manager viewing team: should default to logged-in user's personal metrics (Bob, 20), not All Users + verify(defaultUser !== null); + compare(defaultUser.id, 20); + compare(defaultUser.name, "Bob"); + } + + function test_getDashboardSingleUserDefault_unresolvedLoggedInUserMultipleUsers() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // No entry in users table for account 1 + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (1, 10, 'Alice', 'alice', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (1, 20, 'Bob', 'bob', 1)"); + }); + + var available = Account.getDashboardFilterUsers(1); + compare(available.length, 2); + var defaultUser = Account.getDashboardSingleUserDefault(1, available); + // If logged-in user cannot be resolved and multiple users exist, returns null (fallback to All Users) + compare(defaultUser, null); + } + + function test_getDashboardSingleUserDefault_localAccount() { + var defaultUser = Account.getDashboardSingleUserDefault(0); + verify(defaultUser !== null); + compare(defaultUser.id, 1); + compare(defaultUser.name, "Local User"); + + var users = Account.getDashboardFilterUsers(0); + compare(users.length, 1); + compare(users[0].name, "Local User"); + } + + // --- 4. Filtering in Model Queries --- + function test_projectSpentHoursList_userIdFilter() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 100, 'Project Alpha')"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (2, 1, 200, 'Project Beta')"); + + // Project Alpha: Alice 5 hrs, Bob 3 hrs + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, user_id, unit_amount, record_date, status) VALUES (1, 100, 10, 5.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, user_id, unit_amount, record_date, status) VALUES (1, 100, 20, 3.0, '2026-10-01', 'synced')"); + + // Project Beta: Alice 2 hrs, Bob 7 hrs + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, user_id, unit_amount, record_date, status) VALUES (1, 200, 10, 2.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, user_id, unit_amount, record_date, status) VALUES (1, 200, 20, 7.0, '2026-10-01', 'synced')"); + }); + + // All Users (userId = -1) + var allData = Project.getProjectSpentHoursList(true, 1, "", "", -1); + compare(allData.length, 2); + var alphaAll = allData.filter(function(p) { return p.name === "Project Alpha"; })[0]; + var betaAll = allData.filter(function(p) { return p.name === "Project Beta"; })[0]; + compare(alphaAll.spentHours, 8.0); + compare(betaAll.spentHours, 9.0); + + // Filter by Alice (userId = 10) + var aliceData = Project.getProjectSpentHoursList(true, 1, "", "", 10); + compare(aliceData.length, 2); + var alphaAlice = aliceData.filter(function(p) { return p.name === "Project Alpha"; })[0]; + var betaAlice = aliceData.filter(function(p) { return p.name === "Project Beta"; })[0]; + compare(alphaAlice.spentHours, 5.0); + compare(betaAlice.spentHours, 2.0); + + // Filter by Bob (userId = 20) + var bobData = Project.getProjectSpentHoursList(true, 1, "", "", 20); + compare(bobData.length, 2); + var alphaBob = bobData.filter(function(p) { return p.name === "Project Alpha"; })[0]; + var betaBob = bobData.filter(function(p) { return p.name === "Project Beta"; })[0]; + compare(alphaBob.spentHours, 3.0); + compare(betaBob.spentHours, 7.0); + } + + function test_mainSpentHours_userIdFilter() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 100, 'Project Alpha')"); + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (1, 1, 501, 'Task 1', 100)"); + + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 100, 501, 10, 4.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 100, 501, 20, 6.0, '2026-10-01', 'synced')"); + }); + + // Projects spent hours via Main.get_projects_spent_hours + var allProjects = Main.get_projects_spent_hours(1, "", "", -1); + compare(allProjects.length, 1); + compare(allProjects[0].total, 10.0); + + var aliceProjects = Main.get_projects_spent_hours(1, "", "", 10); + compare(aliceProjects.length, 1); + compare(aliceProjects[0].total, 4.0); + + // Pie chart spent hours via Project.getProjectSpentHoursList + var allPieProjects = Project.getProjectSpentHoursList(true, 1, "", "", -1); + compare(allPieProjects.length, 1); + compare(allPieProjects[0].spentHours, 10.0); + + var alicePieProjects = Project.getProjectSpentHoursList(true, 1, "", "", 10); + compare(alicePieProjects.length, 1); + compare(alicePieProjects[0].spentHours, 4.0); + + var bobPieProjects = Project.getProjectSpentHoursList(true, 1, "", "", 20); + compare(bobPieProjects.length, 1); + compare(bobPieProjects[0].spentHours, 6.0); + + // Tasks spent hours via Main.get_tasks_spent_hours + var allTasks = Main.get_tasks_spent_hours(1, "", "", -1); + compare(allTasks["Task 1"], 10.0); + + var aliceTasks = Main.get_tasks_spent_hours(1, "", "", 10); + compare(aliceTasks["Task 1"], 4.0); + + var bobTasks = Main.get_tasks_spent_hours(1, "", "", 20); + compare(bobTasks["Task 1"], 6.0); + } + + function test_timesheetsForTask_userIdFilter() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 100, 'Project Alpha')"); + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (1, 1, 501, 'Task 1', 100)"); + + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, name, record_date, status) VALUES (1, 100, 501, 10, 2.5, 'Alice Work', '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, name, record_date, status) VALUES (1, 100, 501, 20, 3.5, 'Bob Work', '2026-10-01', 'synced')"); + }); + + // All timesheets + var allTs = Timesheet.getTimesheetsForTask(501, 1, "all", "", "", -1); + compare(allTs.length, 2); + + // Alice only + var aliceTs = Timesheet.getTimesheetsForTask(501, 1, "all", "", "", 10); + compare(aliceTs.length, 1); + compare(aliceTs[0].name, "Alice Work"); + + // Bob only + var bobTs = Timesheet.getTimesheetsForTask(501, 1, "all", "", "", 20); + compare(bobTs.length, 1); + compare(bobTs[0].name, "Bob Work"); + } + + // --- 5. Eisenhower Priority Matrix User Filter --- + function test_eisenhowerQuadrants_userIdFilter() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Alice (10): Q1 = 3 hrs, Q3 = 1 hr + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, quadrant_id, user_id, unit_amount, record_date, status) VALUES (1, 1, 10, 3.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, quadrant_id, user_id, unit_amount, record_date, status) VALUES (1, 3, 10, 1.0, '2026-10-01', 'synced')"); + // Bob (20): Q1 = 5 hrs, Q2 = 4 hrs + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, quadrant_id, user_id, unit_amount, record_date, status) VALUES (1, 1, 20, 5.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, quadrant_id, user_id, unit_amount, record_date, status) VALUES (1, 2, 20, 4.0, '2026-10-01', 'synced')"); + // Charlie (30): Q2 = 2 hrs, Q4 = 7 hrs + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, quadrant_id, user_id, unit_amount, record_date, status) VALUES (1, 2, 30, 2.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, quadrant_id, user_id, unit_amount, record_date, status) VALUES (1, 4, 30, 7.0, '2026-10-01', 'synced')"); + }); + + // 1. All users (-1) + var allTotals = testEHower.getQuadrantHoursFromAllInstances(1, "", "", -1); + compare(allTotals[1], "8"); // 3 + 5 + compare(allTotals[2], "6"); // 4 + 2 + compare(allTotals[3], "1"); // 1 + compare(allTotals[4], "7"); // 7 + + // 2. Single user: Alice (10) + var aliceTotals = testEHower.getQuadrantHoursFromAllInstances(1, "", "", 10); + compare(aliceTotals[1], "3"); + compare(aliceTotals[2], "0"); + compare(aliceTotals[3], "1"); + compare(aliceTotals[4], "0"); + + // 3. Single user: Bob (20) + var bobTotals = testEHower.getQuadrantHoursFromAllInstances(1, "", "", 20); + compare(bobTotals[1], "5"); + compare(bobTotals[2], "4"); + compare(bobTotals[3], "0"); + compare(bobTotals[4], "0"); + + // 4. Multi-user selection array: Alice (10) and Bob (20) + var aliceBobTotals = testEHower.getQuadrantHoursFromAllInstances(1, "", "", [10, 20]); + compare(aliceBobTotals[1], "8"); // 3 + 5 + compare(aliceBobTotals[2], "4"); // 4 + compare(aliceBobTotals[3], "1"); // 1 + compare(aliceBobTotals[4], "0"); // 0 + + // 5. Multi-user selection array: Bob (20) and Charlie (30) + var bobCharlieTotals = testEHower.getQuadrantHoursFromAllInstances(1, "", "", [20, 30]); + compare(bobCharlieTotals[1], "5"); // 5 + compare(bobCharlieTotals[2], "6"); // 4 + 2 + compare(bobCharlieTotals[3], "0"); // 0 + compare(bobCharlieTotals[4], "7"); // 7 + + // 6. Selection objects array: [{ user_id: 10, account_id: 1 }, { user_id: 30, account_id: 1 }] + var objTotals = testEHower.getQuadrantHoursFromAllInstances(1, "", "", [ + { user_id: 10, account_id: 1 }, + { user_id: 30, account_id: 1 } + ]); + compare(objTotals[1], "3"); // 3 + compare(objTotals[2], "2"); // 2 + compare(objTotals[3], "1"); // 1 + compare(objTotals[4], "7"); // 7 + + // 7. Verify refreshQuadrants updates component properties + testEHower.refreshQuadrants("", "", [10, 20], 1); + compare(testEHower.quadrant1Hours, "8H"); + compare(testEHower.quadrant2Hours, "4H"); + compare(testEHower.quadrant3Hours, "1H"); + compare(testEHower.quadrant4Hours, "0H"); + } + + // --- 6. Tasks Tab Summary & Project Tasks User Filter --- + function test_dashboardProjectTaskSummary_userIdFilter() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 100, 'Project Alpha')"); + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (1, 1, 501, 'Task 1', 100)"); + + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 100, 501, 10, 2.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 100, 501, 20, 6.0, '2026-10-01', 'synced')"); + }); + + // Summary: all users (-1) + var allSummary = Project.getDashboardProjectTaskSummary(1, "", "", -1); + compare(allSummary.length, 1); + compare(allSummary[0].totalHours, 8.0); + + // Summary: Alice (10) + var aliceSummary = Project.getDashboardProjectTaskSummary(1, "", "", 10); + compare(aliceSummary.length, 1); + compare(aliceSummary[0].totalHours, 2.0); + + // Summary: Bob (20) + var bobSummary = Project.getDashboardProjectTaskSummary(1, "", "", 20); + compare(bobSummary.length, 1); + compare(bobSummary[0].totalHours, 6.0); + + // Tasks for project: Alice (10) + var aliceTasks = Task.getTasksForProject(100, 1, "", "", 10); + compare(aliceTasks.length, 1); + + // Tasks for project: non-existent user (99) + var nonExistentTasks = Task.getTasksForProject(100, 1, "", "", 99); + compare(nonExistentTasks.length, 0); + } + + // --- 7. Combining users by email across accounts --- + function test_combineUsersByEmail_acrossAccounts() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (1, 'Account 1', 'https://a1.com', 'db1', 'u1', 1)"); + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (2, 'Account 2', 'https://a2.com', 'db2', 'u2', 0)"); + + // Account 1: User 10 named 'Parvathy', email 'p.nair@cit-services.eu' + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (1, 10, 'Parvathy', 'p.nair@cit-services.eu', 'pnair', 1)"); + // Account 2: User 25 named 'Parvathy Nair', email 'p.nair@cit-services.eu' + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (2, 25, 'Parvathy Nair', 'p.nair@cit-services.eu', 'pnair2', 1)"); + // Account 2: User 30 named 'Other User', email 'other@cit-services.eu' + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (2, 30, 'Other User', 'other@cit-services.eu', 'other', 1)"); + + // Timesheets so they appear as active dashboard users + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (1, 10, 2.5, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (2, 25, 4.0, '2026-10-02', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, user_id, unit_amount, record_date, status) VALUES (2, 30, 1.5, '2026-10-03', 'synced')"); + }); + + // Single account 1: only Parvathy + var acc1Users = Account.getDashboardFilterUsers(1); + compare(acc1Users.length, 1); + compare(acc1Users[0].id, 10); + compare(acc1Users[0].name, "Parvathy"); + + // Single account 2: Parvathy Nair and Other User + var acc2Users = Account.getDashboardFilterUsers(2); + compare(acc2Users.length, 2); + + // All accounts (-1): Should combine users by email + var allUsers = Account.getDashboardFilterUsers(-1); + compare(allUsers.length, 2); + + // Find combined Parvathy Nair entry + var pEntry = null; + for (var i = 0; i < allUsers.length; i++) { + if (allUsers[i].email === "p.nair@cit-services.eu") { + pEntry = allUsers[i]; + break; + } + } + verify(pEntry !== null); + // Should choose the longer, more complete name 'Parvathy Nair' + compare(pEntry.name, "Parvathy Nair"); + // allUserIds should contain both IDs (10 and 25) + verify(pEntry.allUserIds.indexOf(10) !== -1); + verify(pEntry.allUserIds.indexOf(25) !== -1); + } + + // --- 8. Cross-Account buildUserFilterSQL & Data Aggregation across widgets --- + function test_crossAccountCombinedUserDataAggregation() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (1, 'Instance 1', 'https://a1.com', 'db1', 'u1', 1)"); + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (2, 'Instance 2', 'https://a2.com', 'db2', 'u2', 0)"); + + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (1, 10, 'Parvathy', 'p.nair@cit-services.eu', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (2, 25, 'Parvathy Nair', 'p.nair@cit-services.eu', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (2, 30, 'Bob Ross', 'bob@example.com', 1)"); + + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 101, 'Project In Instance 1')"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (2, 2, 202, 'Project In Instance 2')"); + + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (1, 1, 501, 'Task A1', 101)"); + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (2, 2, 601, 'Task A2', 202)"); + + // Timesheets: + // Instance 1, user 10 (Parvathy): 3.0 hrs in Project 101 + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status, quadrant_id) VALUES (1, 101, 501, 10, 3.0, '2026-10-01', 'synced', 1)"); + // Instance 2, user 25 (Parvathy Nair): 5.0 hrs in Project 202 + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status, quadrant_id) VALUES (2, 202, 601, 25, 5.0, '2026-10-02', 'synced', 2)"); + // Instance 2, user 30 (Bob): 4.0 hrs in Project 202 + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status, quadrant_id) VALUES (2, 202, 601, 30, 4.0, '2026-10-02', 'synced', 1)"); + }); + + // 1. Overview Pie Chart: getProjectSpentHoursList for All Accounts (-1) with user 10 + var pieProjects = Project.getProjectSpentHoursList(true, -1, "", "", 10); + compare(pieProjects.length, 2); + var totalSpent = 0; + for (var p = 0; p < pieProjects.length; p++) { + totalSpent += pieProjects[p].spentHours; + } + compare(totalSpent, 8.0); // 3.0 + 5.0 + + // 2. Projects Bar Chart: Main.get_projects_spent_hours for All Accounts (-1) with user 10 + var barProjects = Main.get_projects_spent_hours(-1, "", "", 10); + compare(barProjects.length, 2); + var barTotal = 0; + for (var b = 0; b < barProjects.length; b++) { + barTotal += barProjects[b].total; + } + compare(barTotal, 8.0); // 3.0 + 5.0 + + // 3. Tasks Tab Project Summary: Project.getDashboardProjectTaskSummary for All Accounts (-1) with user 10 + var summary = Project.getDashboardProjectTaskSummary(-1, "", "", 10); + var summaryHours = 0; + for (var s = 0; s < summary.length; s++) { + summaryHours += summary[s].totalHours; + } + compare(summaryHours, 8.0); // 3.0 + 5.0 + + // 4. Tasks Tab Drilldown: Task.getTasksForProject for Project 202 (Account 2) passing user 10 + // Even though user ID 10 is from Account 1, it should map to user 25 in Account 2 + var tasksForP2 = Task.getTasksForProject(202, 2, "", "", 10); + compare(tasksForP2.length, 1); + compare(tasksForP2[0].spent_hours, 5.0); + + // 5. Timesheet logs for task 601 (Account 2) passing user 10 + var logs = Timesheet.getTimesheetsForTask(601, 2, "all", "", "", 10); + compare(logs.length, 1); + compare(logs[0].spentHours, "5:00"); + } + + // --- 11. Multi-assignee Filter Tests --- + function test_multiUserFilterSQL_emptyOrAllUsers() { + var fEmpty = Account.buildUserFilterSQL([], 1, "a"); + compare(fEmpty.clause, ""); + compare(fEmpty.params.length, 0); + + var fAllArr = Account.buildUserFilterSQL([-1], 1, "a"); + compare(fAllArr.clause, ""); + compare(fAllArr.params.length, 0); + + var fAllObj = Account.buildUserFilterSQL([{ user_id: -1, account_id: -1 }], 1, "a"); + compare(fAllObj.clause, ""); + compare(fAllObj.params.length, 0); + } + + function test_multiUserFilterSQL_singleAccountMultiSelect() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (1, 'Acct 1', 'https://a1.com', 'db1', 'admin1')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (1, 10, 'Alice', 'alice@test.com', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (1, 20, 'Bob', 'bob@test.com', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (1, 30, 'Charlie', 'charlie@test.com', 1)"); + + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 101, 'Project 1')"); + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (1, 1, 501, 'Task 1', 101)"); + + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 501, 10, 2.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 501, 20, 3.5, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 501, 30, 5.0, '2026-10-01', 'synced')"); + }); + + // Filtering by Alice and Bob [10, 20] + var filterSQL = Account.buildUserFilterSQL([10, 20], 1, "a"); + compare(filterSQL.clause, "a.user_id IN (?, ?)"); + compare(filterSQL.params.length, 2); + + // Verify Project.getProjectSpentHoursList filters for both Alice and Bob (2.0 + 3.5 = 5.5 hrs) + var pieProjects = Project.getProjectSpentHoursList(true, 1, "", "", [10, 20]); + compare(pieProjects.length, 1); + compare(pieProjects[0].spentHours, 5.5); + + // Verify Main.get_projects_spent_hours + var barProjects = Main.get_projects_spent_hours(1, "", "", [10, 20]); + compare(barProjects.length, 1); + compare(barProjects[0].total, 5.5); + + // Selection objects [{ user_id: 10, account_id: 1 }, { user_id: 20, account_id: 1 }] + var pieObj = Project.getProjectSpentHoursList(true, 1, "", "", [{ user_id: 10, account_id: 1 }, { user_id: 20, account_id: 1 }]); + compare(pieObj.length, 1); + compare(pieObj[0].spentHours, 5.5); + } + + function test_multiUserFilterSQL_allAccountsMultiSelect() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (1, 'Instance 1', 'https://a1.com', 'db1', 'admin1', 1)"); + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (2, 'Instance 2', 'https://a2.com', 'db2', 'admin2', 0)"); + + // Parvathy in both accounts (uid 10 in acc 1, uid 25 in acc 2) + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (1, 10, 'Parvathy', 'p.nair@cit-services.eu', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (2, 25, 'Parvathy Nair', 'p.nair@cit-services.eu', 1)"); + // Bob only in account 2 (uid 30) + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, active) VALUES (2, 30, 'Bob Ross', 'bob@example.com', 1)"); + + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 101, 'Project 1')"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (2, 2, 202, 'Project 2')"); + + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (1, 1, 501, 'Task 1', 101)"); + tx.executeSql("INSERT INTO project_task_app (id, account_id, odoo_record_id, name, project_id) VALUES (2, 2, 601, 'Task 2', 202)"); + + // Timesheets: + // Parvathy: 3.0 in Project 1 (acc 1), 5.0 in Project 2 (acc 2) + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 501, 10, 3.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (2, 202, 601, 25, 5.0, '2026-10-02', 'synced')"); + // Bob: 4.0 in Project 2 (acc 2) + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (2, 202, 601, 30, 4.0, '2026-10-02', 'synced')"); + }); + + // Multi-selecting Parvathy (10) and Bob (30) in All Accounts (-1) + var multiFilter = Account.buildUserFilterSQL([10, 30], -1, "aal"); + verify(multiFilter.clause.length > 0); + + // Total spent across all accounts for Parvathy + Bob: 3.0 + 5.0 + 4.0 = 12.0 hrs + var pieProjects = Project.getProjectSpentHoursList(true, -1, "", "", [10, 30]); + var totalSpent = 0; + for (var p = 0; p < pieProjects.length; p++) { + totalSpent += pieProjects[p].spentHours; + } + compare(totalSpent, 12.0); + + // Bar chart: Main.get_projects_spent_hours + var barProjects = Main.get_projects_spent_hours(-1, "", "", [10, 30]); + var barTotal = 0; + for (var b = 0; b < barProjects.length; b++) { + barTotal += barProjects[b].total; + } + compare(barTotal, 12.0); + + // Project task summary: Project.getDashboardProjectTaskSummary + var summary = Project.getDashboardProjectTaskSummary(-1, "", "", [10, 30]); + var summaryHours = 0; + for (var s = 0; s < summary.length; s++) { + summaryHours += summary[s].totalHours; + } + compare(summaryHours, 12.0); + } + + // --- 10. Dynamic Visibility: hasManagerOrAdminAccess --- + function test_hasManagerOrAdminAccess_localAccount() { + // Local Account (0) is single-user offline only - standard access + compare(Account.hasManagerOrAdminAccess(0), false); + } + + function test_hasManagerOrAdminAccess_standardEmployee() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Account 1: Standard Employee + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (1, 'Acct 1', 'https://a1.com', 'db1', 'employee@test.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (1, 100, 'John Doe', 'employee@test.com', 'employee@test.com', 1)"); + // Project managed by someone else (user_id = 999) + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (1, 1, 101, 'Project 1', 999)"); + // Timesheets only for self (user_id = 100) + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 1, 100, 4.0, '2026-10-01', 'synced')"); + }); + + // Standard access account: hide icon + compare(Account.hasManagerOrAdminAccess(1), false); + } + + function test_hasManagerOrAdminAccess_adminUsername() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (2, 'Admin Acct', 'https://a2.com', 'db2', 'admin')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (2, 2, 'Administrator', 'admin@test.com', 'admin', 1)"); + }); + + // Admin account: show icon + compare(Account.hasManagerOrAdminAccess(2), true); + } + + function test_hasManagerOrAdminAccess_adminLogin() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Username in users table is user's email, but res_users_app login is 'admin' + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (3, 'Admin Acct 2', 'https://a3.com', 'db3', 'admin@corp.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (3, 200, 'Corp Admin', 'admin@corp.com', 'admin', 1)"); + }); + + compare(Account.hasManagerOrAdminAccess(3), true); + } + + function test_hasManagerOrAdminAccess_adminUid2() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Odoo default Administrator UID is 2 + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (4, 'Acct 4', 'https://a4.com', 'db4', 'odoo_super')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (4, 2, 'Super User', 'super@test.com', 'odoo_super', 1)"); + }); + + compare(Account.hasManagerOrAdminAccess(4), true); + } + + function test_hasManagerOrAdminAccess_projectManager() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Account 5: User is Project Manager of Project 1 (user_id = 50 matches odoo_record_id = 50) + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (5, 'PM Acct', 'https://a5.com', 'db5', 'pm@test.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (5, 50, 'PM User', 'pm@test.com', 'pm@test.com', 1)"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (1, 5, 201, 'Managed Project', 50)"); + // Only own timesheet + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (5, 201, 1, 50, 2.0, '2026-10-01', 'synced')"); + }); + + // Project Manager account: show icon + compare(Account.hasManagerOrAdminAccess(5), true); + } + + function test_hasManagerOrAdminAccess_teamTimesheetManager() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Account 6: Lead with team timesheets + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (6, 'Team Acct', 'https://a6.com', 'db6', 'lead@test.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (6, 60, 'Team Lead', 'lead@test.com', 'lead@test.com', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (6, 70, 'Subordinate', 'sub@test.com', 'sub@test.com', 1)"); + // Project not managed by lead + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (1, 6, 301, 'Team Project', 999)"); + // Timesheets for multiple employees (lead 60 + subordinate 70) + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (6, 301, 1, 60, 3.0, '2026-10-01', 'synced')"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (6, 301, 1, 70, 5.0, '2026-10-01', 'synced')"); + }); + + // Team manager with multiple employees' timesheets: show icon + compare(Account.hasManagerOrAdminAccess(6), true); + } + + function test_hasManagerOrAdminAccess_allAccounts() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Standard Account 1 + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (1, 'Standard 1', 'https://a1.com', 'db1', 'u1@test.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (1, 10, 'User 1', 'u1@test.com', 1)"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (1, 1, 101, 'Proj 1', 999)"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 1, 10, 2.0, '2026-10-01', 'synced')"); + + // Standard Account 2 + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (2, 'Standard 2', 'https://a2.com', 'db2', 'u2@test.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (2, 20, 'User 2', 'u2@test.com', 1)"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (2, 2, 201, 'Proj 2', 999)"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (2, 201, 1, 20, 3.0, '2026-10-01', 'synced')"); + }); + + // When all accounts only grant standard access: hide icon + compare(Account.hasManagerOrAdminAccess(-1), false); + + // Now add an Admin account (Account 3) + db.transaction(function(tx) { + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (3, 'Admin Acct', 'https://a3.com', 'db3', 'admin')"); + }); + + // When at least one instance account grants manager/admin access: show icon + compare(Account.hasManagerOrAdminAccess(-1), true); + } + + function test_accountSwitching_togglesManagerAccess() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Account A (ID 10): Standard Employee + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (10, 'Instance A', 'https://a.com', 'dbA', 'user@company.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (10, 101, 'User A', 'user@company.com', 1)"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (1, 10, 501, 'Proj A', 999)"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (10, 501, 1, 101, 3.0, '2026-10-01', 'synced')"); + + // Account B (ID 20): Project Manager + tx.executeSql("INSERT INTO users (id, name, link, database, username) VALUES (20, 'Instance B', 'https://b.com', 'dbB', 'user@company.com')"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, login, active) VALUES (20, 202, 'User B', 'user@company.com', 1)"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name, user_id) VALUES (2, 20, 601, 'Proj B', 202)"); + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (20, 601, 1, 202, 5.0, '2026-10-01', 'synced')"); + }); + + // 1. Initial Account A (Standard Access): Icon hidden + var activeAccountId = 10; + var isVisible = Account.hasManagerOrAdminAccess(activeAccountId); + compare(isVisible, false); + + // 2. User toggles to Account B (Project Manager Access): Icon visible + activeAccountId = 20; + isVisible = Account.hasManagerOrAdminAccess(activeAccountId); + compare(isVisible, true); + + // 3. User toggles back to Account A (Standard Access): Icon hidden + activeAccountId = 10; + isVisible = Account.hasManagerOrAdminAccess(activeAccountId); + compare(isVisible, false); + + // 4. User toggles to Local Account (0): Icon hidden + activeAccountId = 0; + isVisible = Account.hasManagerOrAdminAccess(activeAccountId); + compare(isVisible, false); + + // 5. User toggles to All Accounts (-1): Icon visible because Account B is PM + activeAccountId = -1; + isVisible = Account.hasManagerOrAdminAccess(activeAccountId); + compare(isVisible, true); + } + } +} diff --git a/tests/qml/tst_assigneefiltermenu.qml b/tests/qml/tst_assigneefiltermenu.qml new file mode 100644 index 00000000..86566432 --- /dev/null +++ b/tests/qml/tst_assigneefiltermenu.qml @@ -0,0 +1,253 @@ +/* + * MIT License + * + * Copyright (c) 2025 CIT-Services + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/selectors" + +Item { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + AssigneeFilterMenu { + id: testMenu + } + + SignalSpy { + id: filterAppliedSpy + target: testMenu + signalName: "filterApplied" + } + + SignalSpy { + id: filterClearedSpy + target: testMenu + signalName: "filterCleared" + } + + TestCase { + name: "AssigneeFilterMenuTests" + when: windowShown + + function init() { + testMenu.expanded = false; + testMenu.showAllUsersOption = false; + testMenu.showAccountName = false; + testMenu.assigneeModel = []; + testMenu.selectedAssigneeIds = []; + filterAppliedSpy.clear(); + filterClearedSpy.clear(); + } + + function test_defaultProperties() { + compare(testMenu.showAllUsersOption, false); + compare(testMenu.expanded, false); + compare(testMenu.showAccountName, false); + compare(testMenu.selectedAssigneeIds.length, 0); + } + + function test_showAllUsersOption_initialState() { + testMenu.showAllUsersOption = true; + testMenu.selectedAssigneeIds = []; + verify(!testMenu.isOnlyAllUsersSelected()); + + testMenu.selectAllUsers(); + compare(testMenu.selectedAssigneeIds.length, 1); + compare(testMenu.selectedAssigneeIds[0].user_id, -1); + verify(testMenu.isOnlyAllUsersSelected()); + } + + function test_buildDisplayAssignees_pinsAllUsersAtTop() { + testMenu.showAllUsersOption = true; + testMenu.assigneeModel = [ + { id: 2, name: "Bob", account_id: 1, email: "bob@example.com" }, + { id: 1, name: "Alice", account_id: 1, email: "alice@example.com" } + ]; + + var list = testMenu.buildDisplayAssignees(""); + compare(list.length, 3); + // First item is "All Users" + compare(list[0].assigneeId, -1); + compare(list[0].name, i18n.dtr("ubtms", "All Users")); + // Subsequent items sorted alphabetically + compare(list[1].name, "Alice"); + compare(list[2].name, "Bob"); + } + + function test_searchIntegration_filterByName() { + testMenu.showAllUsersOption = true; + testMenu.assigneeModel = [ + { id: 1, name: "Alice Smith", account_id: 1, email: "alice@example.com" }, + { id: 2, name: "Bob Jones", account_id: 1, email: "bob@example.com" } + ]; + + // Search by name "alice" + var resultsAlice = testMenu.buildDisplayAssignees("alice"); + compare(resultsAlice.length, 1); + compare(resultsAlice[0].name, "Alice Smith"); + + // Search by name "bob" + var resultsBob = testMenu.buildDisplayAssignees("bob"); + compare(resultsBob.length, 1); + compare(resultsBob[0].name, "Bob Jones"); + + // Empty query returns all (All Users + 2 employees) + var resultsAll = testMenu.buildDisplayAssignees(""); + compare(resultsAll.length, 3); + } + + function test_selectionExclusivity_specificUserClearsAllUsers() { + testMenu.showAllUsersOption = true; + testMenu.selectAllUsers(); + verify(testMenu.isOnlyAllUsersSelected()); + + // Select a specific user + testMenu.addSelectionIfMissing(testMenu.createSelection(10, 1)); + verify(!testMenu.isOnlyAllUsersSelected()); + compare(testMenu.selectedAssigneeIds.length, 1); + compare(testMenu.selectedAssigneeIds[0].user_id, 10); + verify(testMenu.isAssigneeSelected(10, 1)); + verify(!testMenu.isAssigneeSelected(-1, -1)); + } + + function test_multiSelection_multipleUsers() { + testMenu.showAllUsersOption = true; + testMenu.addSelectionIfMissing(testMenu.createSelection(10, 1)); + testMenu.addSelectionIfMissing(testMenu.createSelection(20, 1)); + + compare(testMenu.selectedAssigneeIds.length, 2); + verify(testMenu.isAssigneeSelected(10, 1)); + verify(testMenu.isAssigneeSelected(20, 1)); + verify(!testMenu.isOnlyAllUsersSelected()); + } + + function test_removeSelection_leavesEmptyWhenLastUserRemoved() { + testMenu.showAllUsersOption = true; + testMenu.addSelectionIfMissing(testMenu.createSelection(10, 1)); + compare(testMenu.selectedAssigneeIds.length, 1); + + // Removing the last selected user leaves selection empty (no auto-check of All Users) + testMenu.removeSelectionIfPresent(testMenu.createSelection(10, 1)); + compare(testMenu.selectedAssigneeIds.length, 0); + verify(!testMenu.isOnlyAllUsersSelected()); + verify(!testMenu.isAssigneeSelected(-1, -1)); + } + + function test_noUserSelected_doesNotSelectAllUsers() { + testMenu.showAllUsersOption = true; + testMenu.selectedAssigneeIds = []; + verify(!testMenu.isOnlyAllUsersSelected()); + verify(!testMenu.isAssigneeSelected(-1, -1)); + compare(testMenu.selectedAssigneeIds.length, 0); + } + + function test_filterAppliedSignal() { + var selections = [testMenu.createSelection(10, 1), testMenu.createSelection(20, 1)]; + testMenu.filterApplied(selections); + compare(filterAppliedSpy.count, 1); + compare(filterAppliedSpy.signalArguments[0][0].length, 2); + } + + function test_filterClearedSignal() { + testMenu.filterCleared(); + compare(filterClearedSpy.count, 1); + } + + function test_delegateLayoutAndClick() { + testMenu.showAllUsersOption = true; + testMenu.assigneeModel = [ + { id: 10, name: "Alice", account_id: 1, email: "alice@example.com" }, + { id: 20, name: "Bob", account_id: 1, email: "bob@example.com" } + ]; + testMenu.expanded = true; + wait(300); + + var listView = null; + function findListView(item) { + if (!item) return; + if (item.toString().indexOf("QQuickListView") !== -1) { + listView = item; + return; + } + for (var i = 0; i < (item.children ? item.children.length : 0); i++) { + findListView(item.children[i]); + if (listView) return; + } + } + findListView(testMenu); + verify(listView !== null); + + var ci = listView.contentItem; + + + function findItemWithText(rootItem, targetText) { + if (!rootItem || !rootItem.children) return null; + for (var i = 0; i < rootItem.children.length; i++) { + var ch = rootItem.children[i]; + if (ch.text && ch.text.indexOf(targetText) !== -1) return ch; + var res = findItemWithText(ch, targetText); + if (res) return res; + } + return null; + } + + // 1. Click on employee name label selects the user + var aliceLabel = findItemWithText(listView.contentItem, "Alice"); + verify(aliceLabel !== null, "Alice label should be found"); + testMenu.selectedAssigneeIds = []; + mouseClick(aliceLabel, 5, 5); + wait(100); + compare(testMenu.selectedAssigneeIds.length, 1); + verify(testMenu.isAssigneeSelected(10, 1)); + + // 2. Query fresh aliceLabel and click again to toggle selection off + aliceLabel = findItemWithText(listView.contentItem, "Alice"); + verify(aliceLabel !== null, "Alice label should be found after re-render"); + mouseClick(aliceLabel, 5, 5); + wait(100); + compare(testMenu.selectedAssigneeIds.length, 0); + + // 3. Query fresh bobEmail and click to select Bob + var bobEmail = findItemWithText(listView.contentItem, "bob@example.com"); + verify(bobEmail !== null, "Bob email should be found"); + mouseClick(bobEmail, 5, 5); + wait(100); + compare(testMenu.selectedAssigneeIds.length, 1); + verify(testMenu.isAssigneeSelected(20, 1)); + + // 4. Query fresh allUsersLabel and click to select All Users + var allUsersLabel = findItemWithText(listView.contentItem, "All Users"); + verify(allUsersLabel !== null, "All Users label should be found"); + mouseClick(allUsersLabel, 5, 5); + wait(100); + verify(testMenu.isOnlyAllUsersSelected()); + + + + + } + } +} From 438d470bbdef9ea838b33e282ab17a4a533d1bab Mon Sep 17 00:00:00 2001 From: Parvathy Nair Date: Fri, 9 Oct 2026 18:01:38 +0530 Subject: [PATCH 21/34] Fix(Dashboard): resolve employee filtering, cross-server user ID isolation, task spent updates, text wrapping, and indentation --- models/Main.js | 6 +- models/accounts.js | 434 ++++++++++++------ models/project.js | 8 +- models/timesheet.js | 10 +- .../selectors/AssigneeFilterMenu.qml | 3 +- qml/features/dashboard/charts/Charts4.qml | 44 +- .../dashboard/charts/TaskTimeChart.qml | 94 +++- .../dashboard/pages/TaskDetailPage.qml | 20 +- .../qml/models/tst_dashboard_user_filter.qml | 68 +++ tests/qml/tst_assigneefiltermenu.qml | 46 +- 10 files changed, 552 insertions(+), 181 deletions(-) diff --git a/models/Main.js b/models/Main.js index 2eae6cb8..9c8c2778 100644 --- a/models/Main.js +++ b/models/Main.js @@ -1,7 +1,7 @@ .import "database.js" as DBCommon - .import QtQuick.LocalStorage 2.7 as Sql - .import "accounts.js" as Account - .import "../models/dbinit.js" as DbInit +.import QtQuick.LocalStorage 2.7 as Sql +.import "accounts.js" as Account +.import "../models/dbinit.js" as DbInit /* Name: get_quadrant_current_week * This function will return total of spent time for current week based on quadrants from timesheet entries diff --git a/models/accounts.js b/models/accounts.js index 036baef7..5e22304f 100644 --- a/models/accounts.js +++ b/models/accounts.js @@ -648,6 +648,7 @@ function getAccountName(accountId) { * * @function getUserNameByOdooId * @param {number} odoo_record_id - The user ID from Odoo (remote system). + * @param {number} [accountId=-1] - The account ID to scope the lookup. * @returns {string} - The user's name if found; otherwise, an empty string. * * @description @@ -656,15 +657,24 @@ function getAccountName(accountId) { * If a match is found, extracts and returns the `name` field. * Logs any exceptions using `DBCommon.logException()` to ensure safe failure handling. */ -function getUserNameByOdooId(odoo_record_id) { +function getUserNameByOdooId(odoo_record_id, accountId) { var userName = ""; try { var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); db.transaction(function (tx) { - var query = "SELECT name FROM res_users_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1"; - var result = tx.executeSql(query, [odoo_record_id, odoo_record_id]); + var numAccId = (accountId !== undefined && accountId !== null) ? Number(accountId) : -1; + var query = ""; + var params = []; + if (numAccId !== -1) { + query = "SELECT name FROM res_users_app WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?)) AND account_id = ? LIMIT 1"; + params = [odoo_record_id, odoo_record_id, numAccId]; + } else { + query = "SELECT name FROM res_users_app WHERE (odoo_record_id = ? OR id = ?) LIMIT 1"; + params = [odoo_record_id, odoo_record_id]; + } + var result = tx.executeSql(query, params); if (result.rows.length > 0) { userName = result.rows.item(0).name; @@ -701,10 +711,12 @@ function combineUsersByEmail(userList, tx) { var key = ""; if (email !== "") { key = "email:" + email; - } else if (login !== "") { - key = "login:" + login; + } else if (login !== "" && login.indexOf("@") !== -1) { + key = "email:" + login; } else { - key = "id:" + u.id; + var accId = (u.accountId !== undefined && u.accountId !== null) ? u.accountId : + ((u.account_id !== undefined && u.account_id !== null) ? u.account_id : "0"); + key = "account:" + accId + ":id:" + u.id; } if (!groups[key]) { @@ -831,186 +843,298 @@ function getUserFilterAccountPairs(userId, accountId) { var numAccountId = (accountId !== undefined && accountId !== null) ? Number(accountId) : -1; - // Handle single selection object as array - if (typeof userId === "object" && !Array.isArray(userId)) { - var objUid = (userId.user_id !== undefined) ? userId.user_id : userId.userId; - if (objUid === -1 || objUid === "-1") { - return pairs; + // Helper: query res_users_app for matching users in other accounts by email + function findMatchingUsersByEmail(tx, email, excludeAccountId) { + var matches = []; + if (!email || email.trim() === "") { + return matches; } - userId = [userId]; + var em = email.trim().toLowerCase(); + var sql = "SELECT account_id, (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid " + + "FROM res_users_app WHERE (LOWER(TRIM(email)) = ? OR LOWER(TRIM(work_email)) = ?) "; + var params = [em, em]; + if (excludeAccountId !== undefined && excludeAccountId !== null && excludeAccountId >= 0) { + sql += "AND account_id != ? "; + params.push(excludeAccountId); + } + var res = tx.executeSql(sql, params); + for (var i = 0; i < res.rows.length; i++) { + matches.push({ + accountId: Number(res.rows.item(i).account_id), + userId: Number(res.rows.item(i).uid) + }); + } + return matches; } - // Handle Array of users/selections - if (Array.isArray(userId)) { - if (userId.length === 0) { - return pairs; - } - var seenMapArr = {}; - for (var a = 0; a < userId.length; a++) { - var item = userId[a]; - if (item === undefined || item === null || item === -1 || item === "-1") { - continue; - } - if (typeof item === "object") { - var itemUid = (item.user_id !== undefined) ? item.user_id : item.userId; - var itemAccId = (item.account_id !== undefined) ? item.account_id : item.accountId; - if (itemUid === undefined || itemUid === null || itemUid === -1 || itemUid === "-1") { - continue; - } - var nItemUid = Number(itemUid); - var nItemAccId = (itemAccId !== undefined && itemAccId !== null) ? Number(itemAccId) : -1; - if (nItemAccId >= 0 && (numAccountId === -1 || numAccountId === nItemAccId)) { - var k = nItemAccId + ":" + nItemUid; - if (!seenMapArr[k]) { - seenMapArr[k] = true; - pairs.push({ accountId: nItemAccId, userId: nItemUid }); - } - } else { - var subPairs = getUserFilterAccountPairs(nItemUid, accountId); - for (var s = 0; s < subPairs.length; s++) { - var kSub = subPairs[s].accountId + ":" + subPairs[s].userId; - if (!seenMapArr[kSub]) { - seenMapArr[kSub] = true; - pairs.push(subPairs[s]); - } - } - } - } else { - var subPairsScalar = getUserFilterAccountPairs(item, accountId); - for (var ss = 0; ss < subPairsScalar.length; ss++) { - var kSubSc = subPairsScalar[ss].accountId + ":" + subPairsScalar[ss].userId; - if (!seenMapArr[kSubSc]) { - seenMapArr[kSubSc] = true; - pairs.push(subPairsScalar[ss]); - } - } - } + // Helper: add pair if not already in list + var seenPairs = {}; + function addPair(accId, uId) { + var a = Number(accId); + var u = Number(uId); + if (isNaN(u) || u < 0) return; + var k = a + ":" + u; + if (!seenPairs[k]) { + seenPairs[k] = true; + pairs.push({ accountId: a, userId: u }); } - return pairs; } - var numUserId = Number(userId); - if (isNaN(numUserId) || numUserId < 0) { - return pairs; + // Normalize single selection object as array + if (typeof userId === "object" && !Array.isArray(userId)) { + var objUid = (userId.user_id !== undefined) ? userId.user_id : (userId.id !== undefined ? userId.id : userId.userId); + if (objUid === -1 || objUid === "-1") { + return pairs; + } + userId = [userId]; } try { var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); db.transaction(function (tx) { - // 1. Look up res_users_app records for the given numUserId - var userRecords = []; - var findSql = "SELECT account_id, (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid, email, work_email, login " + - "FROM res_users_app WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?))"; - var findRes = tx.executeSql(findSql, [numUserId, numUserId]); - for (var i = 0; i < findRes.rows.length; i++) { - userRecords.push(findRes.rows.item(i)); - } + function resolveScalar(t, sId) { + if (isNaN(sId) || sId < 0) return; + + // If querying a specific account, strictly scope to that account + if (numAccountId !== -1) { + var sRes = t.executeSql( + "SELECT (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid " + + "FROM res_users_app WHERE account_id = ? AND (odoo_record_id = ? OR (account_id = 0 AND id = ?)) LIMIT 1", + [numAccountId, sId, sId] + ); + if (sRes.rows.length > 0) { + addPair(numAccountId, Number(sRes.rows.item(0).uid)); + return; + } - // Collect non-empty emails and logins - var emails = []; - var logins = []; - for (var j = 0; j < userRecords.length; j++) { - var r = userRecords[j]; - var em = (r.email || r.work_email || "").trim().toLowerCase(); - var lg = (r.login || "").trim().toLowerCase(); - if (em !== "" && emails.indexOf(em) === -1) { - emails.push(em); + // Not directly in this account: check if sId exists in another account with an email + var otherRes = t.executeSql( + "SELECT email, work_email FROM res_users_app WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?))", + [sId, sId] + ); + var matchedOther = false; + for (var o = 0; o < otherRes.rows.length; o++) { + var oEmail = (otherRes.rows.item(o).email || otherRes.rows.item(o).work_email || "").trim().toLowerCase(); + if (oEmail !== "") { + var targetMatch = t.executeSql( + "SELECT (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid " + + "FROM res_users_app WHERE account_id = ? AND (LOWER(TRIM(email)) = ? OR LOWER(TRIM(work_email)) = ?) LIMIT 1", + [numAccountId, oEmail, oEmail] + ); + if (targetMatch.rows.length > 0) { + addPair(numAccountId, Number(targetMatch.rows.item(0).uid)); + matchedOther = true; + break; + } + } + } + if (!matchedOther) { + addPair(numAccountId, sId); + } + return; } - if (lg !== "" && logins.indexOf(lg) === -1) { - logins.push(lg); + + // Querying All Accounts (-1): + // Find res_users_app records with this ID + var allRes = t.executeSql( + "SELECT account_id, (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid, email, work_email " + + "FROM res_users_app WHERE (odoo_record_id = ? OR (account_id = 0 AND id = ?))", + [sId, sId] + ); + + if (allRes.rows.length === 0) { + addPair(-1, sId); + return; } - } - // 2. If single account target (numAccountId !== -1): - if (numAccountId !== -1) { - for (var k = 0; k < userRecords.length; k++) { - if (Number(userRecords[k].account_id) === numAccountId) { - pairs.push({ accountId: numAccountId, userId: Number(userRecords[k].uid) }); - return; + if (allRes.rows.length === 1) { + var singleRow = allRes.rows.item(0); + var singleAcc = Number(singleRow.account_id); + var singleUid = Number(singleRow.uid); + addPair(singleAcc, singleUid); + + var singleEmail = (singleRow.email || singleRow.work_email || "").trim().toLowerCase(); + if (singleEmail !== "") { + var singleMatches = findMatchingUsersByEmail(t, singleEmail, singleAcc); + for (var sm = 0; sm < singleMatches.length; sm++) { + addPair(singleMatches[sm].accountId, singleMatches[sm].userId); + } } + return; } - // If not directly matching this account by ID, match by email or login within this account - if (emails.length > 0 || logins.length > 0) { - var matchConds = []; - var matchParams = []; - if (emails.length > 0) { - var emPl = emails.map(function () { return "?"; }).join(","); - matchConds.push("(NULLIF(TRIM(email), '') IS NOT NULL AND LOWER(TRIM(email)) IN (" + emPl + "))"); - matchConds.push("(NULLIF(TRIM(work_email), '') IS NOT NULL AND LOWER(TRIM(work_email)) IN (" + emPl + "))"); - matchParams = matchParams.concat(emails).concat(emails); + // Multiple accounts have a user with this ID number (e.g. ID 2 or 6 on different servers) + // Check if they all share the exact same non-empty email + var distinctEmails = []; + for (var r = 0; r < allRes.rows.length; r++) { + var rEmail = (allRes.rows.item(r).email || allRes.rows.item(r).work_email || "").trim().toLowerCase(); + if (rEmail !== "" && distinctEmails.indexOf(rEmail) === -1) { + distinctEmails.push(rEmail); } - if (logins.length > 0) { - var lgPl = logins.map(function () { return "?"; }).join(","); - matchConds.push("LOWER(TRIM(login)) IN (" + lgPl + ")"); - matchParams = matchParams.concat(logins); + } + + if (distinctEmails.length === 1) { + var allShareEmail = true; + for (var rr = 0; rr < allRes.rows.length; rr++) { + var rrEmail = (allRes.rows.item(rr).email || allRes.rows.item(rr).work_email || "").trim().toLowerCase(); + if (rrEmail !== distinctEmails[0]) { + allShareEmail = false; + break; + } } - var accSql = "SELECT account_id, (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid " + - "FROM res_users_app WHERE account_id = ? AND (" + matchConds.join(" OR ") + ") LIMIT 1"; - var accRes = tx.executeSql(accSql, [numAccountId].concat(matchParams)); - if (accRes.rows.length > 0) { - pairs.push({ accountId: numAccountId, userId: Number(accRes.rows.item(0).uid) }); + if (allShareEmail) { + for (var m = 0; m < allRes.rows.length; m++) { + addPair(Number(allRes.rows.item(m).account_id), Number(allRes.rows.item(m).uid)); + } + var emailMatches = findMatchingUsersByEmail(t, distinctEmails[0], -1); + for (var emIdx = 0; emIdx < emailMatches.length; emIdx++) { + addPair(emailMatches[emIdx].accountId, emailMatches[emIdx].userId); + } return; } } - // Fallback for single account - pairs.push({ accountId: numAccountId, userId: numUserId }); - return; - } + // Different users sharing the same ID number on different servers! + // DO NOT mix them up! Scope to the default account (or first record). + var defAccId = -1; + var defRes = t.executeSql("SELECT id FROM users WHERE is_default = 1 LIMIT 1"); + if (defRes.rows.length > 0) { + defAccId = Number(defRes.rows.item(0).id); + } - // 3. All accounts (numAccountId === -1): - var seenMap = {}; - if (emails.length > 0 || logins.length > 0) { - var allConds = []; - var allParams = []; - if (emails.length > 0) { - var emPlAll = emails.map(function () { return "?"; }).join(","); - allConds.push("(NULLIF(TRIM(email), '') IS NOT NULL AND LOWER(TRIM(email)) IN (" + emPlAll + "))"); - allConds.push("(NULLIF(TRIM(work_email), '') IS NOT NULL AND LOWER(TRIM(work_email)) IN (" + emPlAll + "))"); - allParams = allParams.concat(emails).concat(emails); + var chosenRow = null; + for (var c = 0; c < allRes.rows.length; c++) { + if (Number(allRes.rows.item(c).account_id) === defAccId) { + chosenRow = allRes.rows.item(c); + break; + } } - if (logins.length > 0) { - var lgPlAll = logins.map(function () { return "?"; }).join(","); - allConds.push("LOWER(TRIM(login)) IN (" + lgPlAll + ")"); - allParams = allParams.concat(logins); + if (!chosenRow) { + chosenRow = allRes.rows.item(0); } - var allSql = "SELECT account_id, (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid " + - "FROM res_users_app WHERE " + allConds.join(" OR "); - var allRes = tx.executeSql(allSql, allParams); - for (var m = 0; m < allRes.rows.length; m++) { - var itm = allRes.rows.item(m); - var acctId = Number(itm.account_id); - var uId = Number(itm.uid); - var key = acctId + ":" + uId; - if (!seenMap[key]) { - seenMap[key] = true; - pairs.push({ accountId: acctId, userId: uId }); + var chosenAcc = Number(chosenRow.account_id); + var chosenUid = Number(chosenRow.uid); + addPair(chosenAcc, chosenUid); + + var chosenEmail = (chosenRow.email || chosenRow.work_email || "").trim().toLowerCase(); + if (chosenEmail !== "") { + var chosenMatches = findMatchingUsersByEmail(t, chosenEmail, chosenAcc); + for (var cm = 0; cm < chosenMatches.length; cm++) { + addPair(chosenMatches[cm].accountId, chosenMatches[cm].userId); } } } - // If no pairs found via email/login, include records from initial lookup - if (pairs.length === 0 && userRecords.length > 0) { - for (var n = 0; n < userRecords.length; n++) { - var uRec = userRecords[n]; - var keyU = Number(uRec.account_id) + ":" + Number(uRec.uid); - if (!seenMap[keyU]) { - seenMap[keyU] = true; - pairs.push({ accountId: Number(uRec.account_id), userId: Number(uRec.uid) }); + // Handle Array of users/selections + if (Array.isArray(userId)) { + if (userId.length === 0) { + return; + } + + for (var a = 0; a < userId.length; a++) { + var item = userId[a]; + if (item === undefined || item === null || item === -1 || item === "-1") { + continue; + } + + if (typeof item === "object") { + var itemUid = (item.user_id !== undefined) ? item.user_id : (item.id !== undefined ? item.id : item.userId); + var itemAccId = (item.account_id !== undefined && item.account_id !== null) ? item.account_id : + ((item.accountId !== undefined && item.accountId !== null) ? item.accountId : -1); + + if (itemUid === undefined || itemUid === null || itemUid === -1 || itemUid === "-1") { + continue; + } + + // Case 1: Pre-combined user with allUserIds and allAccountIds + if (item.allUserIds && item.allAccountIds && item.allUserIds.length > 0) { + for (var uIdx = 0; uIdx < item.allUserIds.length; uIdx++) { + var uAcc = Number(item.allAccountIds[uIdx]); + var uVal = Number(item.allUserIds[uIdx]); + if (numAccountId === -1 || numAccountId === uAcc) { + addPair(uAcc, uVal); + } + } + continue; + } + + var nItemUid = Number(itemUid); + var nItemAccId = (itemAccId !== undefined && itemAccId !== null) ? Number(itemAccId) : -1; + + // Case 2: Object with known account_id + if (nItemAccId >= 0) { + if (numAccountId !== -1) { + if (numAccountId === nItemAccId) { + addPair(nItemAccId, nItemUid); + } else { + // Target account is different from item's account: + // Match in target account by email only + var uRes = tx.executeSql( + "SELECT email, work_email FROM res_users_app WHERE account_id = ? AND (odoo_record_id = ? OR (account_id = 0 AND id = ?)) LIMIT 1", + [nItemAccId, nItemUid, nItemUid] + ); + if (uRes.rows.length > 0) { + var emSingle = (uRes.rows.item(0).email || uRes.rows.item(0).work_email || "").trim().toLowerCase(); + if (emSingle !== "") { + var targetMatch = tx.executeSql( + "SELECT (CASE WHEN account_id = 0 OR odoo_record_id IS NULL OR odoo_record_id <= 0 THEN id ELSE odoo_record_id END) AS uid " + + "FROM res_users_app WHERE account_id = ? AND (LOWER(TRIM(email)) = ? OR LOWER(TRIM(work_email)) = ?) LIMIT 1", + [numAccountId, emSingle, emSingle] + ); + if (targetMatch.rows.length > 0) { + addPair(numAccountId, Number(targetMatch.rows.item(0).uid)); + } + } + } + } + } else { + // All accounts: add this user, and match other accounts by email ONLY + addPair(nItemAccId, nItemUid); + var uResAll = tx.executeSql( + "SELECT email, work_email FROM res_users_app WHERE account_id = ? AND (odoo_record_id = ? OR (account_id = 0 AND id = ?)) LIMIT 1", + [nItemAccId, nItemUid, nItemUid] + ); + if (uResAll.rows.length > 0) { + var emAll = (uResAll.rows.item(0).email || uResAll.rows.item(0).work_email || "").trim().toLowerCase(); + if (emAll !== "") { + var otherMatches = findMatchingUsersByEmail(tx, emAll, nItemAccId); + for (var om = 0; om < otherMatches.length; om++) { + addPair(otherMatches[om].accountId, otherMatches[om].userId); + } + } + } + } + continue; + } + + // Case 3: Object without known account_id -> resolve as scalar ID + resolveScalar(tx, nItemUid); + } else { + // Scalar item in array + resolveScalar(tx, Number(item)); } } + return; + } + + // Direct scalar userId + var numScalarId = Number(userId); + if (!isNaN(numScalarId) && numScalarId >= 0) { + resolveScalar(tx, numScalarId); } }); } catch (e) { DBCommon.logException("getUserFilterAccountPairs", e); } - if (pairs.length === 0) { - pairs.push({ accountId: numAccountId, userId: numUserId }); + if (pairs.length === 0 && numAccountId !== -1) { + var fallbackUid = Number(userId); + if (!isNaN(fallbackUid) && fallbackUid >= 0) { + pairs.push({ accountId: numAccountId, userId: fallbackUid }); + } } return pairs; @@ -1224,7 +1348,7 @@ function getDashboardFilterUsers(accountId) { var accIdVal = row.account_id !== undefined ? row.account_id : target.accountId; users.push({ id: target.userId, - name: row.name || row.login || getUserNameByOdooId(target.userId) || ("User #" + target.userId), + name: row.name || row.login || getUserNameByOdooId(target.userId, target.accountId) || ("User #" + target.userId), login: row.login || "", email: row.email || row.work_email || "", avatar: row.avatar_128 || "", @@ -1235,7 +1359,7 @@ function getDashboardFilterUsers(accountId) { } else { users.push({ id: target.userId, - name: getUserNameByOdooId(target.userId) || ("User #" + target.userId), + name: getUserNameByOdooId(target.userId, target.accountId) || ("User #" + target.userId), login: "", email: "", avatar: "", @@ -1332,7 +1456,7 @@ function getDashboardSingleUserDefault(accountId, availableUsers) { } } } - var userName = getUserNameByOdooId(loggedInUid); + var userName = getUserNameByOdooId(loggedInUid, parsedAccountId); return { id: loggedInUid, name: userName || ("User #" + loggedInUid) @@ -1343,9 +1467,11 @@ function getDashboardSingleUserDefault(accountId, availableUsers) { // All Accounts (-1): Try to default to currently logged-in user of default account or any active account if (parsedAccountId === -1) { var targetUid = -1; + var sourceAccId = -1; var defAccId = getDefaultRemoteAccountId(); if (defAccId > 0) { targetUid = getCurrentUserOdooId(defAccId); + sourceAccId = defAccId; } if (targetUid <= 0) { var allAccs = getActiveAccounts(); @@ -1353,6 +1479,7 @@ function getDashboardSingleUserDefault(accountId, availableUsers) { var aUid = getCurrentUserOdooId(allAccs[a].id); if (aUid > 0) { targetUid = aUid; + sourceAccId = allAccs[a].id; break; } } @@ -1360,8 +1487,17 @@ function getDashboardSingleUserDefault(accountId, availableUsers) { if (targetUid > 0 && availableUsers && availableUsers.length > 0) { for (var m = 0; m < availableUsers.length; m++) { var cand = availableUsers[m]; - if (cand.id === targetUid || (cand.allUserIds && cand.allUserIds.indexOf(targetUid) !== -1)) { - return cand; + if (cand.allUserIds && cand.allAccountIds) { + for (var u = 0; u < cand.allUserIds.length; u++) { + if (cand.allUserIds[u] === targetUid && (sourceAccId <= 0 || cand.allAccountIds[u] === sourceAccId)) { + return cand; + } + } + } else if (cand.id === targetUid) { + var cAcc = (cand.accountId !== undefined) ? cand.accountId : cand.account_id; + if (sourceAccId <= 0 || cAcc === undefined || cAcc === sourceAccId) { + return cand; + } } } } diff --git a/models/project.js b/models/project.js index da510260..4005be8b 100644 --- a/models/project.js +++ b/models/project.js @@ -1,8 +1,8 @@ .import "logger.js" as Logger - .import QtQuick.LocalStorage 2.7 as Sql - .import "database.js" as DBCommon - .import "utils.js" as Utils - .import "accounts.js" as Account +.import QtQuick.LocalStorage 2.7 as Sql +.import "database.js" as DBCommon +.import "utils.js" as Utils +.import "accounts.js" as Account /** * Get local project ID from Odoo record ID diff --git a/models/timesheet.js b/models/timesheet.js index 7de7405d..2302dd11 100644 --- a/models/timesheet.js +++ b/models/timesheet.js @@ -1,9 +1,9 @@ .import "logger.js" as Logger - .import QtQuick.LocalStorage 2.7 as Sql - .import "database.js" as DBCommon - .import "utils.js" as Utils - .import "accounts.js" as Accounts - .import "draft_manager.js" as DraftManager +.import QtQuick.LocalStorage 2.7 as Sql +.import "database.js" as DBCommon +.import "utils.js" as Utils +.import "accounts.js" as Accounts +.import "draft_manager.js" as DraftManager /** diff --git a/qml/components/selectors/AssigneeFilterMenu.qml b/qml/components/selectors/AssigneeFilterMenu.qml index e7a5f0d0..9bc99e68 100644 --- a/qml/components/selectors/AssigneeFilterMenu.qml +++ b/qml/components/selectors/AssigneeFilterMenu.qml @@ -125,7 +125,7 @@ Item { for (var i = selectedAssigneeIds.length - 1; i >= 0; i--) { var existingId = selectedAssigneeIds[i]; if (typeof existingId === 'object') { - if (existingId.user_id === selection.user_id && (selection.account_id === undefined || selection.account_id === -1 || existingId.account_id === selection.account_id)) { + if (existingId.user_id === selection.user_id && (selection.account_id === undefined || selection.account_id === -1 || existingId.account_id === -1 || existingId.account_id === selection.account_id)) { selectedAssigneeIds.splice(i, 1); } } else if (existingId === selection.user_id) { @@ -627,7 +627,6 @@ Item { MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - propagateComposedEvents: true onClicked: { delegateRoot.toggleSelection(); mouse.accepted = true; diff --git a/qml/features/dashboard/charts/Charts4.qml b/qml/features/dashboard/charts/Charts4.qml index 95004ab2..dcd4cb2a 100644 --- a/qml/features/dashboard/charts/Charts4.qml +++ b/qml/features/dashboard/charts/Charts4.qml @@ -34,6 +34,9 @@ Item { selectedUserId = userId; } projectsModel = buildProjectsModel(filterStartDate, filterEndDate); + if (chartFlow && typeof chartFlow.refreshCurrentView === "function") { + chartFlow.refreshCurrentView(); + } } function buildProjectsModel(startDate, endDate) { @@ -91,14 +94,32 @@ Item { return mappedTasks; } - function loadLogsForTask(projectId, taskId) { + function loadLogsForTask(projectId, taskId, taskObj) { var project = findProject(projectId); + if (!project) { + return []; + } + var task = findTask(project, taskId); - if (!project || !task) { + if (!project.tasks || project.tasks.length === 0 || !task) { + loadTasksForProject(projectId); + task = findTask(project, taskId); + } + var targetTask = task || taskObj; + if (!targetTask) { return []; } - var timesheets = TimesheetModel.getTimesheetsForTask(task.odooRecordId, project.accountId, "all", filterStartDate, filterEndDate, selectedUserId); + var targetTaskId = (project.accountId === 0 || !targetTask.odooRecordId) ? targetTask.localId : targetTask.odooRecordId; + if (!targetTaskId && taskId !== undefined && taskId !== null) { + var parts = String(taskId).split(":"); + targetTaskId = Number(parts[parts.length - 1]); + } + if (!targetTaskId) { + return []; + } + + var timesheets = TimesheetModel.getTimesheetsForTask(targetTaskId, project.accountId, "all", filterStartDate, filterEndDate, selectedUserId); var logs = []; for (var i = 0; i < timesheets.length; i++) { @@ -112,15 +133,19 @@ Item { }); } - task.logs = logs; - task._logsLoaded = true; + if (task) { + task.logs = logs; + task._logsLoaded = true; + } return logs; } function findProject(projectId) { + if (!projectsModel) return null; for (var i = 0; i < projectsModel.length; i++) { - if (projectsModel[i].id === projectId) { - return projectsModel[i]; + var p = projectsModel[i]; + if (p.id === projectId || String(p.odooRecordId) === String(projectId) || String(p.localId) === String(projectId)) { + return p; } } return null; @@ -132,8 +157,9 @@ Item { } for (var i = 0; i < project.tasks.length; i++) { - if (project.tasks[i].id === taskId) { - return project.tasks[i]; + var t = project.tasks[i]; + if (t.id === taskId || String(t.localId) === String(taskId) || (t.odooRecordId && String(t.odooRecordId) === String(taskId))) { + return t; } } return null; diff --git a/qml/features/dashboard/charts/TaskTimeChart.qml b/qml/features/dashboard/charts/TaskTimeChart.qml index 4a6f2270..d1955f83 100644 --- a/qml/features/dashboard/charts/TaskTimeChart.qml +++ b/qml/features/dashboard/charts/TaskTimeChart.qml @@ -47,6 +47,18 @@ Item { return "#E95420"; } + readonly property real selectedTaskSpentHours: { + if (!selectedTask) return 0; + if (selectedTaskLogs && selectedTaskLogs.length > 0) { + var sum = 0; + for (var i = 0; i < selectedTaskLogs.length; i++) { + sum += Number(selectedTaskLogs[i].hours || 0); + } + return sum; + } + return Number(selectedTask.totalHours || 0); + } + function openProject(projectData) { if (!projectData) return; selectedProject = projectData; @@ -62,7 +74,7 @@ Item { if (!selectedProject || !taskData) return; selectedTask = taskData; selectedTask.projectTotalHours = Number(selectedProject.totalHours || 0); - selectedTaskLogs = taskLogsProvider ? (taskLogsProvider(selectedProject.id, taskData.id) || []) : []; + selectedTaskLogs = taskLogsProvider ? (taskLogsProvider(selectedProject.id, taskData.id, taskData) || []) : []; selectedTask.logs = selectedTaskLogs; currentView = "task"; taskOpened(selectedProject.id, taskData.id); @@ -79,6 +91,80 @@ Item { backNavigated(); } + function refreshCurrentView() { + if (currentView === "project" && selectedProject) { + var updatedProject = null; + if (projectsModel) { + for (var i = 0; i < projectsModel.length; i++) { + if (projectsModel[i].id === selectedProject.id) { + updatedProject = projectsModel[i]; + break; + } + } + } + if (updatedProject) { + selectedProject = updatedProject; + selectedProjectTasks = projectTasksProvider ? ChartUtils.prepareTasks(projectTasksProvider(selectedProject.id) || []) : []; + } else { + goBack(); + } + } else if (currentView === "task" && selectedProject && selectedTask) { + var updatedProj = null; + if (projectsModel) { + for (var j = 0; j < projectsModel.length; j++) { + if (projectsModel[j].id === selectedProject.id) { + updatedProj = projectsModel[j]; + break; + } + } + } + if (updatedProj) { + selectedProject = updatedProj; + selectedProjectTasks = projectTasksProvider ? ChartUtils.prepareTasks(projectTasksProvider(selectedProject.id) || []) : []; + + var updatedTask = null; + for (var k = 0; k < selectedProjectTasks.length; k++) { + if (selectedProjectTasks[k].id === selectedTask.id || + String(selectedProjectTasks[k].localId) === String(selectedTask.localId || selectedTask.id) || + (selectedTask.odooRecordId && String(selectedProjectTasks[k].odooRecordId) === String(selectedTask.odooRecordId))) { + updatedTask = selectedProjectTasks[k]; + break; + } + } + + if (updatedTask) { + selectedTask = updatedTask; + } else { + var resetTask = Object.assign({}, selectedTask); + resetTask.totalHours = 0; + selectedTask = resetTask; + } + selectedTaskLogs = taskLogsProvider ? (taskLogsProvider(selectedProject.id, selectedTask.id, selectedTask) || []) : []; + if (selectedTask) { + var updatedSelectedTask = Object.assign({}, selectedTask); + updatedSelectedTask.projectTotalHours = Number(selectedProject.totalHours || 0); + updatedSelectedTask.logs = selectedTaskLogs; + if (selectedTaskLogs && selectedTaskLogs.length > 0) { + var totalLogHours = 0; + for (var l = 0; l < selectedTaskLogs.length; l++) { + totalLogHours += Number(selectedTaskLogs[l].hours || 0); + } + updatedSelectedTask.totalHours = totalLogHours; + } else if (!updatedTask) { + updatedSelectedTask.totalHours = 0; + } + selectedTask = updatedSelectedTask; + } + } else { + goBack(); + } + } + } + + onProjectsModelChanged: { + refreshCurrentView(); + } + function ensureVisibleInAncestorFlickable(item) { if (!item) return; @@ -553,7 +639,7 @@ Item { } Label { - text: ChartUtils.formatHours(root.selectedTask ? root.selectedTask.totalHours : 0) + text: ChartUtils.formatHours(root.selectedTaskSpentHours) color: root.summaryAccentTextColor font.bold: true font.pixelSize: units.dp(20) @@ -579,7 +665,7 @@ Item { } Label { - text: ChartUtils.percentLabel(root.selectedTask ? root.selectedTask.totalHours : 0, root.selectedProject ? root.selectedProject.totalHours : 0) + text: ChartUtils.percentLabel(root.selectedTaskSpentHours, root.selectedProject ? root.selectedProject.totalHours : 0) color: root.summaryPrimaryTextColor font.bold: true font.pixelSize: units.dp(20) @@ -731,7 +817,7 @@ Item { } Label { - Layout.fillWidth: true + width: parent.width text: modelData.note || i18n.dtr("ubtms", "No note") color: Theme.palette.normal.backgroundText font.pixelSize: units.dp(13) diff --git a/qml/features/dashboard/pages/TaskDetailPage.qml b/qml/features/dashboard/pages/TaskDetailPage.qml index 89fa714d..2a09666b 100644 --- a/qml/features/dashboard/pages/TaskDetailPage.qml +++ b/qml/features/dashboard/pages/TaskDetailPage.qml @@ -11,6 +11,18 @@ Page { property var taskData: ({}) property string projectName: "" + readonly property real taskSpentHours: { + if (!taskData) return 0; + if (taskData.logs && taskData.logs.length > 0) { + var sum = 0; + for (var i = 0; i < taskData.logs.length; i++) { + sum += Number(taskData.logs[i].hours || 0); + } + return sum; + } + return Number(taskData.totalHours || 0); + } + header: PageHeader { id: pageHeader title: taskData ? taskData.name : "" @@ -40,12 +52,12 @@ Page { model: [ { label: i18n.dtr("ubtms", "Time spent"), - value: ChartUtils.formatHours(taskData ? taskData.totalHours : 0) + value: ChartUtils.formatHours(root.taskSpentHours) }, { label: i18n.dtr("ubtms", "% of project"), value: taskData && taskData.projectTotalHours ? - ChartUtils.percentLabel(taskData.totalHours, taskData.projectTotalHours) : + ChartUtils.percentLabel(root.taskSpentHours, taskData.projectTotalHours) : i18n.dtr("ubtms", "0.0%") } ] @@ -180,11 +192,11 @@ Page { } Label { - Layout.fillWidth: true + width: parent.width text: modelData.note || i18n.dtr("ubtms", "No note") color: Theme.palette.normal.backgroundText font.pixelSize: units.dp(12) - elide: Text.ElideRight + wrapMode: Text.WordWrap } } diff --git a/tests/qml/models/tst_dashboard_user_filter.qml b/tests/qml/models/tst_dashboard_user_filter.qml index 5151889f..1d65e6ff 100644 --- a/tests/qml/models/tst_dashboard_user_filter.qml +++ b/tests/qml/models/tst_dashboard_user_filter.qml @@ -771,5 +771,73 @@ Item { isVisible = Account.hasManagerOrAdminAccess(activeAccountId); compare(isVisible, true); } + + // --- 11. Cross-Server User ID Isolation --- + function test_usersSharingSameIdAcrossServers_notMixedUp() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function(tx) { + // Two distinct servers / accounts + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (1, 'Server 1', 'https://s1.com', 'db1', 'admin1', 1)"); + tx.executeSql("INSERT INTO users (id, name, link, database, username, is_default) VALUES (2, 'Server 2', 'https://s2.com', 'db2', 'admin2', 0)"); + + // Both servers have users with ID 2 and ID 6, but DIFFERENT people and emails! + // Server 1: User 2 is Alice (alice@server1.com), User 6 is Alan (alan@server1.com) + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (1, 2, 'Alice S1', 'alice@server1.com', 'alice', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (1, 6, 'Alan S1', 'alan@server1.com', 'alan', 1)"); + + // Server 2: User 2 is Bob (bob@server2.com), User 6 is Brian (brian@server2.com) + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (2, 2, 'Bob S2', 'bob@server2.com', 'bob', 1)"); + tx.executeSql("INSERT INTO res_users_app (account_id, odoo_record_id, name, email, login, active) VALUES (2, 6, 'Brian S2', 'brian@server2.com', 'brian', 1)"); + + // Projects and timesheets + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (1, 1, 101, 'Proj 1')"); + tx.executeSql("INSERT INTO project_project_app (id, account_id, odoo_record_id, name) VALUES (2, 2, 202, 'Proj 2')"); + + // Alice (Server 1, user 2): 3 hrs + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (1, 101, 1, 2, 3.0, '2026-10-01', 'synced')"); + // Bob (Server 2, user 2): 7 hrs + tx.executeSql("INSERT INTO account_analytic_line_app (account_id, project_id, task_id, user_id, unit_amount, record_date, status) VALUES (2, 202, 2, 2, 7.0, '2026-10-01', 'synced')"); + }); + + // 1. In combineUsersByEmail across all accounts: + // Alice (S1) and Bob (S2) should be separate users (not combined into one)! + var allUsers = Account.getDashboardFilterUsers(-1); + var aliceEntry = null; + var bobEntry = null; + for (var u = 0; u < allUsers.length; u++) { + if (allUsers[u].email === "alice@server1.com") aliceEntry = allUsers[u]; + if (allUsers[u].email === "bob@server2.com") bobEntry = allUsers[u]; + } + verify(aliceEntry !== null); + verify(bobEntry !== null); + compare(aliceEntry.name, "Alice S1"); + compare(bobEntry.name, "Bob S2"); + compare(aliceEntry.allAccountIds.length, 1); + compare(bobEntry.allAccountIds.length, 1); + + // 2. Filter for Alice using her selection object { user_id: 2, account_id: 1 } across All Accounts (-1): + // Spent hours must ONLY be 3.0 (from Server 1), NOT 10.0 (mixed with Bob)! + var aliceHours = Project.getProjectSpentHoursList(true, -1, "", "", [{ user_id: 2, account_id: 1 }]); + var aliceTotal = 0; + for (var a = 0; a < aliceHours.length; a++) { + aliceTotal += aliceHours[a].spentHours; + } + compare(aliceTotal, 3.0); + + // 3. Filter for Bob using selection { user_id: 2, account_id: 2 } across All Accounts (-1): + // Spent hours must ONLY be 7.0 (from Server 2), NOT 10.0 (mixed with Alice)! + var bobHours = Project.getProjectSpentHoursList(true, -1, "", "", [{ user_id: 2, account_id: 2 }]); + var bobTotal = 0; + for (var b = 0; b < bobHours.length; b++) { + bobTotal += bobHours[b].spentHours; + } + compare(bobTotal, 7.0); + + // 4. getUserNameByOdooId with accountId scopes correctly: + compare(Account.getUserNameByOdooId(2, 1), "Alice S1"); + compare(Account.getUserNameByOdooId(2, 2), "Bob S2"); + compare(Account.getUserNameByOdooId(6, 1), "Alan S1"); + compare(Account.getUserNameByOdooId(6, 2), "Brian S2"); + } } } diff --git a/tests/qml/tst_assigneefiltermenu.qml b/tests/qml/tst_assigneefiltermenu.qml index 86566432..f8bd7ff9 100644 --- a/tests/qml/tst_assigneefiltermenu.qml +++ b/tests/qml/tst_assigneefiltermenu.qml @@ -156,6 +156,22 @@ Item { verify(!testMenu.isAssigneeSelected(-1, -1)); } + function test_removeSelection_allAccountsMode() { + testMenu.showAllUsersOption = true; + // Case 1: Existing selection has account_id: -1 (All Accounts mode), target has account_id: 1 + testMenu.selectedAssigneeIds = [testMenu.createSelection(10, -1)]; + compare(testMenu.selectedAssigneeIds.length, 1); + verify(testMenu.isAssigneeSelected(10, 1)); + testMenu.removeSelectionIfPresent(testMenu.createSelection(10, 1)); + compare(testMenu.selectedAssigneeIds.length, 0); + + // Case 2: Existing selection has account_id: 1, target has account_id: -1 + testMenu.selectedAssigneeIds = [testMenu.createSelection(10, 1)]; + compare(testMenu.selectedAssigneeIds.length, 1); + testMenu.removeSelectionIfPresent(testMenu.createSelection(10, -1)); + compare(testMenu.selectedAssigneeIds.length, 0); + } + function test_noUserSelected_doesNotSelectAllUsers() { testMenu.showAllUsersOption = true; testMenu.selectedAssigneeIds = []; @@ -245,9 +261,37 @@ Item { wait(100); verify(testMenu.isOnlyAllUsersSelected()); + // 5. Query fresh Alice delegate and click directly on its checkbox + aliceLabel = findItemWithText(listView.contentItem, "Alice"); + verify(aliceLabel !== null, "Alice label should be found"); + var delegateItem = aliceLabel.parent; + while (delegateItem && delegateItem.itemAssigneeId === undefined) { + delegateItem = delegateItem.parent; + } + verify(delegateItem !== null, "Delegate root should be found"); + + var aliceCheckbox = null; + for (var c = 0; c < delegateItem.children.length; c++) { + var ch = delegateItem.children[c]; + if (ch && ch.hasOwnProperty("checked") && ch.hasOwnProperty("checkState")) { + aliceCheckbox = ch; + break; + } + } + verify(aliceCheckbox !== null, "Alice checkbox should be found"); + // Direct click on checkbox selects Alice + testMenu.selectedAssigneeIds = []; + wait(50); + mouseClick(aliceCheckbox, 5, 5); + wait(100); + compare(testMenu.selectedAssigneeIds.length, 1); + verify(testMenu.isAssigneeSelected(10, 1)); - + // Direct click on checkbox deselects Alice (does not uncheck and instantly re-check) + mouseClick(aliceCheckbox, 5, 5); + wait(100); + compare(testMenu.selectedAssigneeIds.length, 0); } } } From 0d413ab46fd1576cf44d6402b872a7b7cb1d868a Mon Sep 17 00:00:00 2001 From: Parvathy Nair Date: Mon, 12 Oct 2026 14:01:45 +0530 Subject: [PATCH 22/34] Fix(dashbaord): test_delegateLayoutAndClick Failure & Clear Filter and Uncheck Logic --- .../selectors/AssigneeFilterMenu.qml | 20 +++++++---- qml/features/dashboard/pages/Dashboard.qml | 24 ++----------- tests/qml/tst_assigneefiltermenu.qml | 36 ++++++++++++++++++- 3 files changed, 51 insertions(+), 29 deletions(-) diff --git a/qml/components/selectors/AssigneeFilterMenu.qml b/qml/components/selectors/AssigneeFilterMenu.qml index 9bc99e68..3130ec12 100644 --- a/qml/components/selectors/AssigneeFilterMenu.qml +++ b/qml/components/selectors/AssigneeFilterMenu.qml @@ -67,6 +67,18 @@ Item { selectedAssigneeIds = [createSelection(-1, -1)]; } + function clearFilter() { + if (!showAllUsersOption) { + selectedAssigneeIds = []; + } else { + selectAllUsers(); + } + filterModel.update(); + recomputeDisplayAssigneeCache(); + expanded = false; + filterCleared(); + } + // Helper function to check if an assignee is selected (handles both old and new format) function isAssigneeSelected(userId, accountId) { if (showAllUsersOption && userId === -1) { @@ -836,13 +848,7 @@ Item { Component.onCompleted: {} onClicked: { - if (!showAllUsersOption) { - selectedAssigneeIds = []; - } - filterModel.update(); - recomputeDisplayAssigneeCache(); - expanded = false; - filterCleared(); + clearFilter(); } } } diff --git a/qml/features/dashboard/pages/Dashboard.qml b/qml/features/dashboard/pages/Dashboard.qml index ddf93297..c40b2539 100644 --- a/qml/features/dashboard/pages/Dashboard.qml +++ b/qml/features/dashboard/pages/Dashboard.qml @@ -733,27 +733,9 @@ Page { } function handleAssigneeFilterCleared() { - var accId = (typeof accountPicker !== "undefined") ? accountPicker.selectedAccountId : -1; - var defaultUser = Account.getDashboardSingleUserDefault(accId, availableUsers); - - if (defaultUser) { - selectedUserId = defaultUser.id; - selectedUserName = defaultUser.name; - var defSelections = []; - if (defaultUser.allUserIds && defaultUser.allAccountIds) { - for (var d = 0; d < defaultUser.allUserIds.length; d++) { - defSelections.push(assigneeFilterMenu.createSelection(defaultUser.allUserIds[d], defaultUser.allAccountIds[d] || accId)); - } - } else { - var defAcctId = (defaultUser.accountId !== undefined) ? defaultUser.accountId : accId; - defSelections.push(assigneeFilterMenu.createSelection(defaultUser.id, defAcctId)); - } - assigneeFilterMenu.selectedAssigneeIds = defSelections; - } else { - selectedUserId = -1; - selectedUserName = i18n.dtr("ubtms", "All Users"); - assigneeFilterMenu.selectedAssigneeIds = [assigneeFilterMenu.createSelection(-1, -1)]; - } + selectedUserId = -1; + selectedUserName = i18n.dtr("ubtms", "All Users"); + assigneeFilterMenu.selectedAssigneeIds = [assigneeFilterMenu.createSelection(-1, -1)]; Global.setDashboardUserFilter(selectedUserId, selectedUserName); var appObj = (typeof mainView !== "undefined" && mainView) ? mainView : ((typeof rootApp !== "undefined" && rootApp) ? rootApp : null); diff --git a/tests/qml/tst_assigneefiltermenu.qml b/tests/qml/tst_assigneefiltermenu.qml index f8bd7ff9..a8151062 100644 --- a/tests/qml/tst_assigneefiltermenu.qml +++ b/tests/qml/tst_assigneefiltermenu.qml @@ -192,6 +192,40 @@ Item { compare(filterClearedSpy.count, 1); } + function test_clearFilter_withoutShowAllUsersOption() { + testMenu.showAllUsersOption = false; + testMenu.selectedAssigneeIds = [testMenu.createSelection(10, 1)]; + testMenu.expanded = true; + testMenu.clearFilter(); + compare(filterClearedSpy.count, 1); + compare(testMenu.selectedAssigneeIds.length, 0); + compare(testMenu.expanded, false); + } + + function test_clearFilter_withShowAllUsersOption() { + testMenu.showAllUsersOption = true; + testMenu.selectedAssigneeIds = [testMenu.createSelection(10, 1)]; + testMenu.expanded = true; + testMenu.clearFilter(); + compare(filterClearedSpy.count, 1); + verify(testMenu.isOnlyAllUsersSelected()); + compare(testMenu.selectedAssigneeIds.length, 1); + compare(testMenu.selectedAssigneeIds[0].user_id, -1); + compare(testMenu.expanded, false); + } + + function test_clearFilter_withShowAllUsersOption_emptySelection() { + testMenu.showAllUsersOption = true; + testMenu.selectedAssigneeIds = []; + testMenu.expanded = true; + testMenu.clearFilter(); + compare(filterClearedSpy.count, 1); + verify(testMenu.isOnlyAllUsersSelected()); + compare(testMenu.selectedAssigneeIds.length, 1); + compare(testMenu.selectedAssigneeIds[0].user_id, -1); + compare(testMenu.expanded, false); + } + function test_delegateLayoutAndClick() { testMenu.showAllUsersOption = true; testMenu.assigneeModel = [ @@ -273,7 +307,7 @@ Item { var aliceCheckbox = null; for (var c = 0; c < delegateItem.children.length; c++) { var ch = delegateItem.children[c]; - if (ch && ch.hasOwnProperty("checked") && ch.hasOwnProperty("checkState")) { + if (ch && (ch.hasOwnProperty("checked") || ch.checked !== undefined)) { aliceCheckbox = ch; break; } From faa1a73d371591734bd9d232589c71f4955f5c75 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Mon, 12 Oct 2026 16:04:04 +0530 Subject: [PATCH 23/34] Fix delegate re-render in assignee filter test Reset selection before querying delegates and re-query after re-rendering to prevent clicking stale item references. --- tests/qml/tst_assigneefiltermenu.qml | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/qml/tst_assigneefiltermenu.qml b/tests/qml/tst_assigneefiltermenu.qml index a8151062..946b3484 100644 --- a/tests/qml/tst_assigneefiltermenu.qml +++ b/tests/qml/tst_assigneefiltermenu.qml @@ -296,6 +296,8 @@ Item { verify(testMenu.isOnlyAllUsersSelected()); // 5. Query fresh Alice delegate and click directly on its checkbox + testMenu.selectedAssigneeIds = []; + wait(50); aliceLabel = findItemWithText(listView.contentItem, "Alice"); verify(aliceLabel !== null, "Alice label should be found"); var delegateItem = aliceLabel.parent; @@ -315,14 +317,28 @@ Item { verify(aliceCheckbox !== null, "Alice checkbox should be found"); // Direct click on checkbox selects Alice - testMenu.selectedAssigneeIds = []; - wait(50); mouseClick(aliceCheckbox, 5, 5); wait(100); compare(testMenu.selectedAssigneeIds.length, 1); verify(testMenu.isAssigneeSelected(10, 1)); - // Direct click on checkbox deselects Alice (does not uncheck and instantly re-check) + // Direct click on checkbox deselects Alice (re-query after re-render) + aliceLabel = findItemWithText(listView.contentItem, "Alice"); + verify(aliceLabel !== null, "Alice label should be found after re-render"); + delegateItem = aliceLabel.parent; + while (delegateItem && delegateItem.itemAssigneeId === undefined) { + delegateItem = delegateItem.parent; + } + verify(delegateItem !== null, "Delegate root should be found after re-render"); + aliceCheckbox = null; + for (var c2 = 0; c2 < delegateItem.children.length; c2++) { + var ch2 = delegateItem.children[c2]; + if (ch2 && (ch2.hasOwnProperty("checked") || ch2.checked !== undefined)) { + aliceCheckbox = ch2; + break; + } + } + verify(aliceCheckbox !== null, "Alice checkbox should be found after re-render"); mouseClick(aliceCheckbox, 5, 5); wait(100); compare(testMenu.selectedAssigneeIds.length, 0); From 844391bcaae5f4583ce261fa8c9b493bdee2d75e Mon Sep 17 00:00:00 2001 From: Parvathy Nair Date: Mon, 12 Oct 2026 18:02:55 +0530 Subject: [PATCH 24/34] Unit tests for QML Dialogs and Feedback components --- clickable.yaml | 2 +- .../Notifications/NotificationHelper.qml | 9 ++ tests/qml/Pparent/Notifications/qmldir | 2 + tests/qml/README.md | 9 ++ tests/qml/models/tst_dbinit.qml | 6 +- tests/qml/models/tst_draft_manager.qml | 24 +-- tests/qml/tst_accountselectordialog.qml | 135 ++++++++++++++++ tests/qml/tst_colorpicker.qml | 130 ++++++++++++++++ tests/qml/tst_contentpickerdialog.qml | 147 ++++++++++++++++++ tests/qml/tst_loadingindicator.qml | 145 +++++++++++++++++ tests/qml/tst_loadmorefooter.qml | 144 +++++++++++++++++ tests/qml/tst_notificationbell.qml | 139 +++++++++++++++++ tests/qml/tst_notificationpopup.qml | 133 ++++++++++++++++ tests/qml/tst_savediscarddialog.qml | 131 ++++++++++++++++ tests/qml/tst_timepickerpopup.qml | 102 ++++++++++++ 15 files changed, 1243 insertions(+), 15 deletions(-) create mode 100644 tests/qml/Pparent/Notifications/NotificationHelper.qml create mode 100644 tests/qml/Pparent/Notifications/qmldir create mode 100644 tests/qml/tst_accountselectordialog.qml create mode 100644 tests/qml/tst_colorpicker.qml create mode 100644 tests/qml/tst_contentpickerdialog.qml create mode 100644 tests/qml/tst_loadingindicator.qml create mode 100644 tests/qml/tst_loadmorefooter.qml create mode 100644 tests/qml/tst_notificationbell.qml create mode 100644 tests/qml/tst_notificationpopup.qml create mode 100644 tests/qml/tst_savediscarddialog.qml create mode 100644 tests/qml/tst_timepickerpopup.qml diff --git a/clickable.yaml b/clickable.yaml index 281f2557..c3ab162d 100644 --- a/clickable.yaml +++ b/clickable.yaml @@ -2,7 +2,7 @@ clickable_minimum_required: 8.0.0 builder: cmake kill: qmlscene skip_review: true -test: qmltestrunner -input tests/qml +test: qmltestrunner -input tests/qml -import tests/qml dependencies_target: - libatomic1 dependencies_host: diff --git a/tests/qml/Pparent/Notifications/NotificationHelper.qml b/tests/qml/Pparent/Notifications/NotificationHelper.qml new file mode 100644 index 00000000..4b6491b9 --- /dev/null +++ b/tests/qml/Pparent/Notifications/NotificationHelper.qml @@ -0,0 +1,9 @@ +import QtQuick 2.7 + +Item { + property string push_app_id: "" + property int count: 0 + function updateCount(c) { + count = c; + } +} diff --git a/tests/qml/Pparent/Notifications/qmldir b/tests/qml/Pparent/Notifications/qmldir new file mode 100644 index 00000000..8aad02d0 --- /dev/null +++ b/tests/qml/Pparent/Notifications/qmldir @@ -0,0 +1,2 @@ +module Pparent.Notifications +NotificationHelper 1.0 NotificationHelper.qml diff --git a/tests/qml/README.md b/tests/qml/README.md index 9c014819..f3953552 100644 --- a/tests/qml/README.md +++ b/tests/qml/README.md @@ -31,10 +31,19 @@ tests/ │ │ ├── tst_logger.qml # Test suite for logger levels, formatting, and PII sanitization │ │ ├── tst_timer_service.qml # Test suite for timer service state transitions and duration │ │ └── tst_utils.qml # Test suite for date, time, duration, and URL utilities +│ ├── tst_accountselectordialog.qml # Test suite for AccountSelectorDialog picker and account switch +│ ├── tst_colorpicker.qml # Test suite for ColorPicker palette and custom color +│ ├── tst_contentpickerdialog.qml # Test suite for ContentPickerDialog single/multi selection │ ├── tst_customdatepicker.qml # Test suite for CustomDatePicker picker component │ ├── tst_globaltimerwidget.qml # Test suite for GlobalTimerWidget system component │ ├── tst_infobar.qml # Test suite for InfoBar feedback component +│ ├── tst_loadingindicator.qml # Test suite for LoadingIndicator running state and visibility +│ ├── tst_loadmorefooter.qml # Test suite for LoadMoreFooter loading state and retry trigger +│ ├── tst_notificationbell.qml # Test suite for NotificationBell unread counter badge and navigation +│ ├── tst_notificationpopup.qml # Test suite for NotificationPopup message, dismiss, and timeout │ ├── tst_outlinedtextfield.qml # Test suite for OutlinedTextField text input component +│ ├── tst_savediscarddialog.qml # Test suite for SaveDiscardDialog save, discard, and cancel signals +│ ├── tst_timepickerpopup.qml # Test suite for TimePickerPopup time selection and confirmation │ ├── tst_tsbutton.qml # Test suite for TSButton action button component │ ├── tst_tsiconbutton.qml # Test suite for TSIconButton circular icon button component │ ├── tst_tslabel.qml # Test suite for TSLabel styled text component diff --git a/tests/qml/models/tst_dbinit.qml b/tests/qml/models/tst_dbinit.qml index b86483d9..b650c3fe 100644 --- a/tests/qml/models/tst_dbinit.qml +++ b/tests/qml/models/tst_dbinit.qml @@ -59,13 +59,13 @@ Item { var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); db.transaction(function(tx) { - var resInterval = tx.executeSql("SELECT value FROM app_settings WHERE key = 'auto_sync_interval'"); + var resInterval = tx.executeSql("SELECT value FROM app_settings WHERE key = 'sync_interval_minutes'"); verify(resInterval.rows.length > 0); compare(resInterval.rows.item(0).value, "15"); - var resEnabled = tx.executeSql("SELECT value FROM app_settings WHERE key = 'auto_sync_enabled'"); + var resEnabled = tx.executeSql("SELECT value FROM app_settings WHERE key = 'autosync_enabled'"); verify(resEnabled.rows.length > 0); - compare(resEnabled.rows.item(0).value, "false"); + compare(resEnabled.rows.item(0).value, "true"); }); } diff --git a/tests/qml/models/tst_draft_manager.qml b/tests/qml/models/tst_draft_manager.qml index 99d4f9ea..1e8c986d 100644 --- a/tests/qml/models/tst_draft_manager.qml +++ b/tests/qml/models/tst_draft_manager.qml @@ -49,8 +49,8 @@ Item { verify(DraftManager.valuesAreEqual(null, null)); verify(DraftManager.valuesAreEqual(undefined, undefined)); verify(DraftManager.valuesAreEqual(null, undefined)); - verify(DraftManager.valuesAreEqual(null, "")); - verify(DraftManager.valuesAreEqual(undefined, "")); + verify(!DraftManager.valuesAreEqual(null, "")); + verify(!DraftManager.valuesAreEqual(undefined, "")); } function test_valuesAreEqualArrays() { @@ -109,14 +109,15 @@ Item { pageIdentifier: "task_edit_page" }; - var loaded = DraftManager.loadDraft(loadParams); - verify(loaded !== null); - compare(loaded.draft_type, "task"); - compare(loaded.record_id, 501); + var loadRes = DraftManager.loadDraft(loadParams); + verify(loadRes.success); + verify(loadRes.draft !== null); + compare(loadRes.draft.draftType, "task"); + compare(loadRes.draft.recordId, 501); - var parsedData = typeof loaded.draft_data === "string" ? JSON.parse(loaded.draft_data) : loaded.draft_data; - compare(parsedData.name, "My Draft Task"); - compare(parsedData.priority, 3); + var formData = loadRes.draft.formData; + compare(formData.name, "My Draft Task"); + compare(formData.priority, 3); } function test_deleteDraftById() { @@ -133,14 +134,15 @@ Item { var draftId = saveRes.draftId; var deleteRes = DraftManager.deleteDraft(draftId); - verify(deleteRes); + verify(deleteRes.success); var loaded = DraftManager.loadDraft({ draftType: "timesheet", recordId: 601, accountId: 0 }); - compare(loaded, null); + compare(loaded.success, false); + compare(loaded.draft, null); } function test_deleteDraftsByParams() { diff --git a/tests/qml/tst_accountselectordialog.qml b/tests/qml/tst_accountselectordialog.qml new file mode 100644 index 00000000..2ea86bb4 --- /dev/null +++ b/tests/qml/tst_accountselectordialog.qml @@ -0,0 +1,135 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import Lomiri.Components.Popups 1.3 +import "../../qml/components/dialogs" +import "../../models/dbinit.js" as DbInit + +MainView { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + AccountSelectorDialog { + id: testAccountSelector + } + + SignalSpy { + id: acceptedSpy + target: testAccountSelector + signalName: "accepted" + } + + SignalSpy { + id: canceledSpy + target: testAccountSelector + signalName: "canceled" + } + + TestCase { + name: "AccountSelectorDialogTests" + when: windowShown + + function initTestCase() { + DbInit.initializeDatabase(); + } + + function init() { + testAccountSelector.titleText = "Select Account"; + testAccountSelector.restrictToLocalOnly = false; + testAccountSelector.selectedAccountId = -1; + testAccountSelector.selectedAccountName = ""; + testAccountSelector.lastRemoteAccountId = -1; + acceptedSpy.clear(); + canceledSpy.clear(); + } + + function cleanup() { + acceptedSpy.clear(); + canceledSpy.clear(); + } + + function test_initialProperties() { + compare(testAccountSelector.titleText, "Select Account"); + compare(testAccountSelector.restrictToLocalOnly, false); + compare(acceptedSpy.count, 0); + compare(canceledSpy.count, 0); + } + + function test_customTitleText() { + testAccountSelector.titleText = "Switch Active Account"; + compare(testAccountSelector.titleText, "Switch Active Account"); + } + + function test_restrictToLocalOnlyToggle() { + testAccountSelector.restrictToLocalOnly = true; + compare(testAccountSelector.restrictToLocalOnly, true); + + testAccountSelector.restrictToLocalOnly = false; + compare(testAccountSelector.restrictToLocalOnly, false); + } + + function test_accountListPickAndSignalEmission() { + var targetId = 2; + var targetName = "Acme Corp Odoo"; + + testAccountSelector.selectedAccountId = targetId; + testAccountSelector.selectedAccountName = targetName; + testAccountSelector.accepted(targetId, targetName); + + compare(testAccountSelector.selectedAccountId, targetId); + compare(testAccountSelector.selectedAccountName, targetName); + compare(acceptedSpy.count, 1); + compare(acceptedSpy.signalArguments[0][0], targetId); + compare(acceptedSpy.signalArguments[0][1], targetName); + } + + function test_switchSignalMultipleAccounts() { + testAccountSelector.accepted(0, "Local Account"); + testAccountSelector.accepted(1, "Dev Server"); + testAccountSelector.accepted(-1, "All Accounts"); + + compare(acceptedSpy.count, 3); + compare(acceptedSpy.signalArguments[0][0], 0); + compare(acceptedSpy.signalArguments[0][1], "Local Account"); + compare(acceptedSpy.signalArguments[1][0], 1); + compare(acceptedSpy.signalArguments[1][1], "Dev Server"); + compare(acceptedSpy.signalArguments[2][0], -1); + compare(acceptedSpy.signalArguments[2][1], "All Accounts"); + } + + function test_canceledSignalEmission() { + compare(canceledSpy.count, 0); + testAccountSelector.canceled(); + compare(canceledSpy.count, 1); + compare(acceptedSpy.count, 0); + } + + function test_toggleLocalModeSwitchesToLocalAndEmitsSignal() { + testAccountSelector.selectedAccountId = 3; + testAccountSelector.selectedAccountName = "Remote Server"; + + var result = testAccountSelector.toggleLocalMode(true); + verify(result); + compare(testAccountSelector.selectedAccountId, 0); + compare(acceptedSpy.count, 1); + compare(acceptedSpy.signalArguments[0][0], 0); + } + + function test_toggleLocalModeWhenAlreadyLocalDoesNotReEmit() { + testAccountSelector.selectedAccountId = 0; + testAccountSelector.selectedAccountName = "Local"; + acceptedSpy.clear(); + + var result = testAccountSelector.toggleLocalMode(true); + verify(result); + compare(acceptedSpy.count, 0); + } + + function test_openWithInitialAccountId() { + testAccountSelector.open(5); + // Verify open() sets requested initial id or opens cleanly + verify(testAccountSelector !== null); + } + } +} diff --git a/tests/qml/tst_colorpicker.qml b/tests/qml/tst_colorpicker.qml new file mode 100644 index 00000000..a5517ef5 --- /dev/null +++ b/tests/qml/tst_colorpicker.qml @@ -0,0 +1,130 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import Lomiri.Components.Popups 1.3 +import "../../qml/components/dialogs" + +MainView { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + ColorPicker { + id: testColorPicker + } + + SignalSpy { + id: colorPickedSpy + target: testColorPicker + signalName: "colorPicked" + } + + TestCase { + name: "ColorPickerTests" + when: windowShown + + readonly property var defaultPalette: [ + "#FFFFFF", "#EB6E67", "#F39C5A", "#F6C342", + "#6CC1E1", "#854D76", "#ED8888", "#2C8397", + "#49597C", "#DE3F7C", "#45C486", "#9B6CC3" + ] + + function init() { + testColorPicker.selectedColorIndex = -1; + testColorPicker.selectedColor = ""; + testColorPicker.odooColors = defaultPalette; + colorPickedSpy.clear(); + } + + function cleanup() { + testColorPicker.selectedColorIndex = -1; + testColorPicker.selectedColor = ""; + testColorPicker.odooColors = defaultPalette; + colorPickedSpy.clear(); + } + + function test_initialProperties() { + compare(testColorPicker.selectedColorIndex, -1); + compare(testColorPicker.selectedColor, ""); + compare(testColorPicker.odooColors.length, 12); + compare(colorPickedSpy.count, 0); + } + + function test_getColorByIndexValid() { + compare(testColorPicker.getColorByIndex(0), "#FFFFFF"); + compare(testColorPicker.getColorByIndex(1), "#EB6E67"); + compare(testColorPicker.getColorByIndex(2), "#F39C5A"); + compare(testColorPicker.getColorByIndex(3), "#F6C342"); + compare(testColorPicker.getColorByIndex(4), "#6CC1E1"); + compare(testColorPicker.getColorByIndex(10), "#45C486"); + compare(testColorPicker.getColorByIndex(11), "#9B6CC3"); + } + + function test_getColorByIndexInvalidReturnsFallback() { + compare(testColorPicker.getColorByIndex(-1), "#FFFFFF"); + compare(testColorPicker.getColorByIndex(-10), "#FFFFFF"); + compare(testColorPicker.getColorByIndex(12), "#FFFFFF"); + compare(testColorPicker.getColorByIndex(100), "#FFFFFF"); + } + + function test_palettePickUpdatesStateAndEmitsSignal() { + var targetIndex = 4; + var targetColor = testColorPicker.getColorByIndex(targetIndex); + + testColorPicker.selectedColorIndex = targetIndex; + testColorPicker.selectedColor = targetColor; + testColorPicker.colorPicked(targetIndex, targetColor); + + compare(testColorPicker.selectedColorIndex, 4); + compare(testColorPicker.selectedColor, "#6CC1E1"); + compare(colorPickedSpy.count, 1); + compare(colorPickedSpy.signalArguments[0][0], 4); + compare(colorPickedSpy.signalArguments[0][1], "#6CC1E1"); + } + + function test_customColorValueAssignment() { + var customHex = "#AABBCC"; + testColorPicker.selectedColorIndex = 99; + testColorPicker.selectedColor = customHex; + + compare(testColorPicker.selectedColorIndex, 99); + compare(testColorPicker.selectedColor, "#AABBCC"); + + testColorPicker.colorPicked(99, customHex); + compare(colorPickedSpy.count, 1); + compare(colorPickedSpy.signalArguments[0][0], 99); + compare(colorPickedSpy.signalArguments[0][1], "#AABBCC"); + } + + function test_customPaletteOverride() { + var customPalette = ["#111111", "#222222", "#333333"]; + testColorPicker.odooColors = customPalette; + + compare(testColorPicker.odooColors.length, 3); + compare(testColorPicker.getColorByIndex(0), "#111111"); + compare(testColorPicker.getColorByIndex(1), "#222222"); + compare(testColorPicker.getColorByIndex(2), "#333333"); + compare(testColorPicker.getColorByIndex(3), "#FFFFFF"); + } + + function test_multipleSequentialColorPicks() { + testColorPicker.colorPicked(1, "#EB6E67"); + testColorPicker.colorPicked(5, "#854D76"); + testColorPicker.colorPicked(9, "#DE3F7C"); + + compare(colorPickedSpy.count, 3); + compare(colorPickedSpy.signalArguments[0][0], 1); + compare(colorPickedSpy.signalArguments[0][1], "#EB6E67"); + compare(colorPickedSpy.signalArguments[1][0], 5); + compare(colorPickedSpy.signalArguments[1][1], "#854D76"); + compare(colorPickedSpy.signalArguments[2][0], 9); + compare(colorPickedSpy.signalArguments[2][1], "#DE3F7C"); + } + + function test_openMethodExecution() { + testColorPicker.open(); + testColorPicker.open(2); + verify(testColorPicker !== null); + } + } +} diff --git a/tests/qml/tst_contentpickerdialog.qml b/tests/qml/tst_contentpickerdialog.qml new file mode 100644 index 00000000..091db2b9 --- /dev/null +++ b/tests/qml/tst_contentpickerdialog.qml @@ -0,0 +1,147 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import Lomiri.Content 1.3 +import "../../qml/components/dialogs" + +MainView { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + ContentPickerDialog { + id: testContentPicker + } + + SignalSpy { + id: completeSpy + target: testContentPicker + signalName: "complete" + } + + SignalSpy { + id: filesImportedSpy + target: testContentPicker + signalName: "filesImported" + } + + TestCase { + name: "ContentPickerDialogTests" + when: windowShown + + function init() { + testContentPicker.isExport = true; + testContentPicker.fileUrl = ""; + testContentPicker.autoPickGallery = true; + testContentPicker._deliveryHandled = false; + completeSpy.clear(); + filesImportedSpy.clear(); + } + + function cleanup() { + testContentPicker.isExport = true; + testContentPicker.fileUrl = ""; + testContentPicker.autoPickGallery = true; + testContentPicker._deliveryHandled = false; + completeSpy.clear(); + filesImportedSpy.clear(); + } + + function test_initialProperties() { + compare(testContentPicker.isExport, true); + compare(testContentPicker.fileUrl, ""); + compare(testContentPicker.autoPickGallery, true); + compare(testContentPicker._deliveryHandled, false); + compare(completeSpy.count, 0); + compare(filesImportedSpy.count, 0); + } + + function test_resolveTypePictures() { + compare(testContentPicker.resolveType("image.png"), ContentType.Pictures); + compare(testContentPicker.resolveType("photo.jpg"), ContentType.Pictures); + compare(testContentPicker.resolveType("/path/to/picture.jpeg"), ContentType.Pictures); + compare(testContentPicker.resolveType("anim.gif"), ContentType.Pictures); + compare(testContentPicker.resolveType("bitmap.bmp"), ContentType.Pictures); + compare(testContentPicker.resolveType("modern.webp"), ContentType.Pictures); + } + + function test_resolveTypeMediaAndDocs() { + compare(testContentPicker.resolveType("song.mp3"), ContentType.Music); + compare(testContentPicker.resolveType("audio.wav"), ContentType.Music); + compare(testContentPicker.resolveType("movie.mp4"), ContentType.Videos); + compare(testContentPicker.resolveType("clip.mkv"), ContentType.Videos); + compare(testContentPicker.resolveType("doc.pdf"), ContentType.Documents); + compare(testContentPicker.resolveType("report.docx"), ContentType.Documents); + compare(testContentPicker.resolveType("data.csv"), ContentType.Documents); + compare(testContentPicker.resolveType("contact.vcf"), ContentType.Contacts); + compare(testContentPicker.resolveType("book.epub"), ContentType.EBooks); + compare(testContentPicker.resolveType("archive.tar.gz"), ContentType.All); + compare(testContentPicker.resolveType(""), ContentType.All); + } + + function test_singleSelectionBehaviorInExportMode() { + testContentPicker.isExport = true; + testContentPicker.fileUrl = "/tmp/exported_invoice.pdf"; + + var mockPeer = { + selectionType: -1, + request: function() { return {}; } + }; + + // Simulating peerSelected behavior in export mode (single selection) + mockPeer.selectionType = (testContentPicker.isExport ? ContentTransfer.Single : ContentTransfer.Multiple); + compare(mockPeer.selectionType, ContentTransfer.Single); + } + + function test_multiSelectionBehaviorInImportMode() { + testContentPicker.isExport = false; + + var mockPeer = { + selectionType: -1, + request: function() { return {}; } + }; + + // Simulating peerSelected behavior in import mode (multi selection) + mockPeer.selectionType = (testContentPicker.isExport ? ContentTransfer.Single : ContentTransfer.Multiple); + compare(mockPeer.selectionType, ContentTransfer.Multiple); + } + + function test_isImageExportConditions() { + testContentPicker.isExport = true; + testContentPicker.autoPickGallery = true; + testContentPicker.fileUrl = "/images/screenshot.png"; + verify(testContentPicker._isImageExport()); + + // When not export mode + testContentPicker.isExport = false; + verify(!testContentPicker._isImageExport()); + + // When autoPickGallery disabled + testContentPicker.isExport = true; + testContentPicker.autoPickGallery = false; + verify(!testContentPicker._isImageExport()); + + // When exporting a non-image document + testContentPicker.autoPickGallery = true; + testContentPicker.fileUrl = "/docs/spec.pdf"; + verify(!testContentPicker._isImageExport()); + } + + function test_filesImportedSignalEmission() { + var importedFiles = ["file:///data/attachment1.pdf", "file:///data/attachment2.png"]; + testContentPicker.filesImported(importedFiles); + + compare(filesImportedSpy.count, 1); + var emittedArgs = filesImportedSpy.signalArguments[0][0]; + compare(emittedArgs.length, 2); + compare(emittedArgs[0], "file:///data/attachment1.pdf"); + compare(emittedArgs[1], "file:///data/attachment2.png"); + } + + function test_completeSignalEmission() { + compare(completeSpy.count, 0); + testContentPicker.complete(); + compare(completeSpy.count, 1); + } + } +} diff --git a/tests/qml/tst_loadingindicator.qml b/tests/qml/tst_loadingindicator.qml new file mode 100644 index 00000000..ba4576aa --- /dev/null +++ b/tests/qml/tst_loadingindicator.qml @@ -0,0 +1,145 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/feedback" + +Item { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + LoadingIndicator { + id: testIndicator + anchors.fill: parent + visible: false + } + + TestCase { + name: "LoadingIndicatorTests" + when: windowShown + + function findActivityIndicator(rootItem) { + for (var i = 0; i < rootItem.children.length; i++) { + var child = rootItem.children[i]; + if (child.running !== undefined) { + return child; + } + var sub = findActivityIndicator(child); + if (sub) return sub; + } + return null; + } + + function findMessageLabel(rootItem) { + for (var i = 0; i < rootItem.children.length; i++) { + var child = rootItem.children[i]; + if (child.text !== undefined && child.fontSize !== undefined) { + return child; + } + var sub = findMessageLabel(child); + if (sub) return sub; + } + return null; + } + + function findOverlay(rootItem) { + for (var i = 0; i < rootItem.children.length; i++) { + var child = rootItem.children[i]; + if (child.color !== undefined && child.children && child.children.length > 0) { + for (var j = 0; j < child.children.length; j++) { + if (child.children[j].enabled !== undefined && child.children[j].onClicked !== undefined) { + return child; + } + } + } + } + return null; + } + + function init() { + testIndicator.visible = false; + testIndicator.message = ""; + testIndicator.overlayMode = true; + } + + function cleanup() { + testIndicator.visible = false; + testIndicator.message = ""; + testIndicator.overlayMode = true; + } + + function test_initialProperties() { + compare(testIndicator.visible, false); + compare(testIndicator.message, ""); + compare(testIndicator.overlayMode, true); + compare(testIndicator.z, 999); + } + + function test_visibilityAndRunningStateToggle() { + var spinner = findActivityIndicator(testIndicator); + verify(spinner !== null, "ActivityIndicator spinner must exist in component"); + + // When visible is false, spinner is not running + testIndicator.visible = false; + compare(spinner.running, false); + + // When visible is true, spinner is running + testIndicator.visible = true; + compare(spinner.running, true); + + // Toggle back to false + testIndicator.visible = false; + compare(spinner.running, false); + } + + function test_messagePropertyAndLabelVisibility() { + testIndicator.visible = true; + var label = findMessageLabel(testIndicator); + verify(label !== null, "Message Label must exist in component"); + + // Empty message hides label + testIndicator.message = ""; + compare(label.visible, false); + + // Non-empty message shows label with correct text + testIndicator.message = "Loading project data..."; + compare(label.visible, true); + compare(label.text, "Loading project data..."); + + // Clearing message hides label again + testIndicator.message = ""; + compare(label.visible, false); + } + + function test_overlayModeVisibility() { + testIndicator.visible = true; + var overlay = findOverlay(testIndicator); + verify(overlay !== null, "Overlay Rectangle must exist in component"); + + testIndicator.overlayMode = true; + compare(overlay.visible, true); + + testIndicator.overlayMode = false; + compare(overlay.visible, false); + + testIndicator.overlayMode = true; + compare(overlay.visible, true); + } + + function test_mouseAreaEnabledTracksVisibility() { + var overlay = findOverlay(testIndicator); + verify(overlay !== null); + var mouseArea = overlay.children[0]; + verify(mouseArea !== null && mouseArea.enabled !== undefined); + + testIndicator.visible = false; + compare(mouseArea.enabled, false); + + testIndicator.visible = true; + compare(mouseArea.enabled, true); + + testIndicator.visible = false; + compare(mouseArea.enabled, false); + } + } +} diff --git a/tests/qml/tst_loadmorefooter.qml b/tests/qml/tst_loadmorefooter.qml new file mode 100644 index 00000000..6489b645 --- /dev/null +++ b/tests/qml/tst_loadmorefooter.qml @@ -0,0 +1,144 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/feedback" + +Item { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + LoadMoreFooter { + id: testFooter + width: testRoot.width + } + + SignalSpy { + id: loadMoreSpy + target: testFooter + signalName: "loadMore" + } + + TestCase { + name: "LoadMoreFooterTests" + when: windowShown + + function findSpinner(rootItem) { + for (var i = 0; i < rootItem.children.length; i++) { + var child = rootItem.children[i]; + if (child.running !== undefined) { + return child; + } + var sub = findSpinner(child); + if (sub) return sub; + } + return null; + } + + function findMouseArea(rootItem) { + for (var i = 0; i < rootItem.children.length; i++) { + var child = rootItem.children[i]; + if (child.onClicked !== undefined && child.enabled !== undefined) { + return child; + } + var sub = findMouseArea(child); + if (sub) return sub; + } + return null; + } + + function init() { + testFooter.isLoading = false; + testFooter.hasMore = true; + loadMoreSpy.clear(); + } + + function cleanup() { + testFooter.isLoading = false; + testFooter.hasMore = true; + loadMoreSpy.clear(); + } + + function test_initialProperties() { + compare(testFooter.isLoading, false); + compare(testFooter.hasMore, true); + compare(testFooter.visible, true); + compare(testFooter.height, units.gu(6)); + compare(loadMoreSpy.count, 0); + } + + function test_hasMoreTogglesVisibilityAndHeight() { + testFooter.hasMore = false; + compare(testFooter.visible, false); + compare(testFooter.height, 0); + + testFooter.hasMore = true; + compare(testFooter.visible, true); + compare(testFooter.height, units.gu(6)); + } + + function test_loadingStateShowsSpinnerAndDisablesMouseArea() { + var spinner = findSpinner(testFooter); + verify(spinner !== null, "ActivityIndicator spinner must exist in component"); + var mouseArea = findMouseArea(testFooter); + verify(mouseArea !== null, "MouseArea must exist in component"); + + // When idle (not loading) + testFooter.isLoading = false; + compare(spinner.running, false); + compare(spinner.visible, false); + compare(mouseArea.enabled, true); + + // When loading + testFooter.isLoading = true; + compare(spinner.running, true); + compare(spinner.visible, true); + compare(mouseArea.enabled, false); + + // Back to idle + testFooter.isLoading = false; + compare(spinner.running, false); + compare(spinner.visible, false); + compare(mouseArea.enabled, true); + } + + function test_loadMoreSignalEmission() { + compare(loadMoreSpy.count, 0); + testFooter.loadMore(); + compare(loadMoreSpy.count, 1); + } + + function test_mouseClickTriggersLoadMore() { + var mouseArea = findMouseArea(testFooter); + verify(mouseArea !== null); + + testFooter.isLoading = false; + testFooter.hasMore = true; + mouseClick(mouseArea); + + compare(loadMoreSpy.count, 1); + } + + function test_multipleRetryTriggers() { + var mouseArea = findMouseArea(testFooter); + verify(mouseArea !== null); + + // Simulating click to retry load + mouseClick(mouseArea); + compare(loadMoreSpy.count, 1); + + // Once finished loading and items still exist, another click triggers again + testFooter.isLoading = false; + mouseClick(mouseArea); + compare(loadMoreSpy.count, 2); + } + + function test_mouseAreaDisabledWhenNoMoreItems() { + var mouseArea = findMouseArea(testFooter); + verify(mouseArea !== null); + + testFooter.hasMore = false; + compare(mouseArea.enabled, false); + } + } +} diff --git a/tests/qml/tst_notificationbell.qml b/tests/qml/tst_notificationbell.qml new file mode 100644 index 00000000..6e6d13aa --- /dev/null +++ b/tests/qml/tst_notificationbell.qml @@ -0,0 +1,139 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/feedback" +import "../../models/dbinit.js" as DbInit + +MainView { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + NotificationBell { + id: testBell + parentWindow: testRoot + } + + SignalSpy { + id: navigateSpy + target: testBell + signalName: "navigateToRecord" + } + + TestCase { + name: "NotificationBellTests" + when: windowShown + + function initTestCase() { + DbInit.initializeDatabase(); + } + + function init() { + testBell.activeFilter = "normal"; + testBell.normalCount = 0; + testBell.syncCount = 0; + testBell.totalCount = 0; + testBell.notificationCount = 0; + navigateSpy.clear(); + } + + function cleanup() { + navigateSpy.clear(); + } + + function test_initialProperties() { + verify(testBell.width > 0); + verify(testBell.height > 0); + compare(testBell.activeFilter, "normal"); + compare(typeof testBell.normalCount, "number"); + compare(typeof testBell.syncCount, "number"); + compare(typeof testBell.totalCount, "number"); + compare(navigateSpy.count, 0); + } + + function test_unreadCounterBadgeComputation() { + testBell.normalCount = 4; + testBell.syncCount = 2; + testBell.totalCount = testBell.normalCount + testBell.syncCount; + + compare(testBell.normalCount, 4); + compare(testBell.syncCount, 2); + compare(testBell.totalCount, 6); + } + + function test_activeFilterSwitching() { + testBell.activeFilter = "sync"; + compare(testBell.activeFilter, "sync"); + + testBell.activeFilter = "normal"; + compare(testBell.activeFilter, "normal"); + } + + function test_formatTimestampNow() { + var now = new Date().toISOString(); + var formatted = testBell.formatTimestamp(now); + compare(formatted, "Just now"); + } + + function test_formatTimestampEmpty() { + compare(testBell.formatTimestamp(""), ""); + compare(testBell.formatTimestamp(null), ""); + } + + function test_formatTimestampMinutesAgo() { + var past = new Date(Date.now() - 10 * 60 * 1000).toISOString(); + var formatted = testBell.formatTimestamp(past); + verify(formatted.indexOf("m ago") !== -1 || formatted.indexOf("10") !== -1); + } + + function test_formatTimestampHoursAgo() { + var past = new Date(Date.now() - 3 * 3600 * 1000).toISOString(); + var formatted = testBell.formatTimestamp(past); + verify(formatted.indexOf("h ago") !== -1 || formatted.indexOf("3") !== -1); + } + + function test_navigateToRecordSignalEmission() { + testBell.navigateToRecord("Task", 105, 2); + + compare(navigateSpy.count, 1); + var args = navigateSpy.signalArguments[0]; + compare(args[0], "Task"); + compare(args[1], 105); + compare(args[2], 2); + } + + function test_handleNotificationClickTriggersNavigation() { + var modelData = { + id: 9999, + type: "Activity", + account_id: 1, + payload: JSON.stringify({ id: 250, project_id: 12 }) + }; + + testBell.handleNotificationClick(modelData); + + compare(navigateSpy.count, 1); + var args = navigateSpy.signalArguments[0]; + compare(args[0], "Activity"); + compare(args[1], 250); + compare(args[2], 1); + } + + function test_handleNotificationClickWithoutRecordIdDoesNotNavigate() { + var modelData = { + id: 9998, + type: "Sync", + account_id: 1, + payload: "" + }; + + testBell.handleNotificationClick(modelData); + compare(navigateSpy.count, 0); + } + + function test_openPopupExecution() { + testBell.openPopup(); + verify(testBell !== null); + } + } +} diff --git a/tests/qml/tst_notificationpopup.qml b/tests/qml/tst_notificationpopup.qml new file mode 100644 index 00000000..5b6cf527 --- /dev/null +++ b/tests/qml/tst_notificationpopup.qml @@ -0,0 +1,133 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import Lomiri.Components.Popups 1.3 +import "../../qml/components/feedback" + +MainView { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + NotificationPopup { + id: testNotificationPopup + } + + SignalSpy { + id: closedSpy + target: testNotificationPopup + signalName: "closed" + } + + Timer { + id: autoDismissTimer + interval: 100 + repeat: false + onTriggered: { + if (testNotificationPopup.activeDialog) { + PopupUtils.close(testNotificationPopup.activeDialog); + } + testNotificationPopup.activeDialog = null; + testNotificationPopup.closed(); + } + } + + TestCase { + name: "NotificationPopupTests" + when: windowShown + + function init() { + if (testNotificationPopup.activeDialog) { + PopupUtils.close(testNotificationPopup.activeDialog); + testNotificationPopup.activeDialog = null; + } + wait(100); + testNotificationPopup.type = "info"; + testNotificationPopup.titleText = "Notice"; + testNotificationPopup.messageText = "Something happened."; + closedSpy.clear(); + } + + function cleanup() { + if (testNotificationPopup.activeDialog) { + PopupUtils.close(testNotificationPopup.activeDialog); + testNotificationPopup.activeDialog = null; + } + wait(100); + closedSpy.clear(); + } + + function test_initialProperties() { + compare(testNotificationPopup.type, "info"); + compare(testNotificationPopup.titleText, "Notice"); + compare(testNotificationPopup.messageText, "Something happened."); + compare(testNotificationPopup.activeDialog, null); + compare(testNotificationPopup.isOpen, false); + compare(closedSpy.count, 0); + } + + function test_propertyUpdates() { + testNotificationPopup.titleText = "Error Alert"; + testNotificationPopup.messageText = "Could not sync data with server."; + testNotificationPopup.type = "error"; + + compare(testNotificationPopup.titleText, "Error Alert"); + compare(testNotificationPopup.messageText, "Could not sync data with server."); + compare(testNotificationPopup.type, "error"); + } + + function test_openUpdatesParametersAndSetsIsOpen() { + var dlg = testNotificationPopup.open("Success", "Task completed!", "success"); + verify(dlg !== null); + compare(testNotificationPopup.titleText, "Success"); + compare(testNotificationPopup.messageText, "Task completed!"); + compare(testNotificationPopup.type, "success"); + compare(testNotificationPopup.isOpen, true); + + PopupUtils.close(dlg); + testNotificationPopup.activeDialog = null; + } + + function test_dismissEmitsClosedSignalAndResetsIsOpen() { + var dlg = testNotificationPopup.open("Confirmation", "Action succeeded", "info"); + verify(dlg !== null); + compare(testNotificationPopup.isOpen, true); + + PopupUtils.close(dlg); + testNotificationPopup.activeDialog = null; + testNotificationPopup.closed(); + + compare(closedSpy.count, 1); + compare(testNotificationPopup.isOpen, false); + compare(testNotificationPopup.activeDialog, null); + } + + function test_timeoutAutoDismiss() { + var dlg = testNotificationPopup.open("Auto-Dismiss", "This notification expires shortly", "warning"); + verify(dlg !== null); + compare(testNotificationPopup.isOpen, true); + + autoDismissTimer.start(); + + tryCompare(closedSpy, "count", 1, 1000); + compare(testNotificationPopup.isOpen, false); + } + + function test_openReturnsExistingDialogWhenAlreadyOpen() { + var firstDlg = testNotificationPopup.open("First", "Message 1", "info"); + var secondDlg = testNotificationPopup.open("Second", "Message 2", "warning"); + + compare(firstDlg, secondDlg); + PopupUtils.close(firstDlg); + testNotificationPopup.activeDialog = null; + } + + function test_supportedNotificationTypes() { + var types = ["info", "success", "warning", "error"]; + for (var i = 0; i < types.length; i++) { + testNotificationPopup.type = types[i]; + compare(testNotificationPopup.type, types[i]); + } + } + } +} diff --git a/tests/qml/tst_savediscarddialog.qml b/tests/qml/tst_savediscarddialog.qml new file mode 100644 index 00000000..7f17a5b0 --- /dev/null +++ b/tests/qml/tst_savediscarddialog.qml @@ -0,0 +1,131 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import Lomiri.Components.Popups 1.3 +import "../../qml/components/dialogs" + +MainView { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + SaveDiscardDialog { + id: testDialog + } + + SignalSpy { + id: saveSpy + target: testDialog + signalName: "saveRequested" + } + + SignalSpy { + id: discardSpy + target: testDialog + signalName: "discardRequested" + } + + SignalSpy { + id: cancelSpy + target: testDialog + signalName: "cancelled" + } + + Item { + id: dummyPage + objectName: "testDummyPage" + } + + TestCase { + name: "SaveDiscardDialogTests" + when: windowShown + + function init() { + testDialog.targetPage = null; + saveSpy.clear(); + discardSpy.clear(); + cancelSpy.clear(); + } + + function cleanup() { + testDialog.targetPage = null; + saveSpy.clear(); + discardSpy.clear(); + cancelSpy.clear(); + } + + function test_initialProperties() { + compare(testDialog.targetPage, null); + compare(saveSpy.count, 0); + compare(discardSpy.count, 0); + compare(cancelSpy.count, 0); + } + + function test_targetPageDirectAssignment() { + testDialog.targetPage = dummyPage; + compare(testDialog.targetPage, dummyPage); + + testDialog.targetPage = null; + compare(testDialog.targetPage, null); + } + + function test_openUpdatesTargetPage() { + testDialog.open(dummyPage); + compare(testDialog.targetPage, dummyPage); + } + + function test_openWithoutPagePreservesExistingTargetPage() { + testDialog.targetPage = dummyPage; + testDialog.open(); + compare(testDialog.targetPage, dummyPage); + } + + function test_saveRequestedSignalEmission() { + compare(saveSpy.count, 0); + testDialog.saveRequested(); + compare(saveSpy.count, 1); + compare(discardSpy.count, 0); + compare(cancelSpy.count, 0); + } + + function test_discardRequestedSignalEmission() { + compare(discardSpy.count, 0); + testDialog.discardRequested(); + compare(discardSpy.count, 1); + compare(saveSpy.count, 0); + compare(cancelSpy.count, 0); + } + + function test_cancelledSignalEmission() { + compare(cancelSpy.count, 0); + testDialog.cancelled(); + compare(cancelSpy.count, 1); + compare(saveSpy.count, 0); + compare(discardSpy.count, 0); + } + + function test_multipleConsecutiveSignalEmissions() { + testDialog.saveRequested(); + testDialog.saveRequested(); + compare(saveSpy.count, 2); + + testDialog.discardRequested(); + compare(discardSpy.count, 1); + + testDialog.cancelled(); + testDialog.cancelled(); + testDialog.cancelled(); + compare(cancelSpy.count, 3); + } + + function test_openMethodExecution() { + // Verify open() executes cleanly with and without arguments + testDialog.open(dummyPage); + compare(testDialog.targetPage, dummyPage); + + var secondPage = { name: "customMockPage" }; + testDialog.open(secondPage); + compare(testDialog.targetPage.name, "customMockPage"); + } + } +} diff --git a/tests/qml/tst_timepickerpopup.qml b/tests/qml/tst_timepickerpopup.qml new file mode 100644 index 00000000..a7b650cc --- /dev/null +++ b/tests/qml/tst_timepickerpopup.qml @@ -0,0 +1,102 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import Lomiri.Components.Popups 1.3 +import "../../qml/components/dialogs" + +MainView { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + TimePickerPopup { + id: testTimePicker + } + + SignalSpy { + id: timeSelectedSpy + target: testTimePicker + signalName: "timeSelected" + } + + TestCase { + name: "TimePickerPopupTests" + when: windowShown + + function init() { + testTimePicker.initialHour = 1; + testTimePicker.initialMinute = 0; + timeSelectedSpy.clear(); + } + + function cleanup() { + testTimePicker.initialHour = 1; + testTimePicker.initialMinute = 0; + timeSelectedSpy.clear(); + } + + function test_initialProperties() { + compare(testTimePicker.initialHour, 1); + compare(testTimePicker.initialMinute, 0); + compare(timeSelectedSpy.count, 0); + } + + function test_propertyUpdates() { + testTimePicker.initialHour = 10; + testTimePicker.initialMinute = 45; + compare(testTimePicker.initialHour, 10); + compare(testTimePicker.initialMinute, 45); + } + + function test_openFunctionSetsInitialHourAndMinute() { + testTimePicker.open(14, 30); + compare(testTimePicker.initialHour, 14); + compare(testTimePicker.initialMinute, 30); + } + + function test_openWithoutArgumentsDefaultsToZero() { + testTimePicker.initialHour = 5; + testTimePicker.initialMinute = 25; + + testTimePicker.open(); + compare(testTimePicker.initialHour, 0); + compare(testTimePicker.initialMinute, 0); + } + + function test_timeSelectedSignalEmission() { + testTimePicker.timeSelected(8, 40); + + compare(timeSelectedSpy.count, 1); + var args = timeSelectedSpy.signalArguments[0]; + compare(args[0], 8); + compare(args[1], 40); + } + + function test_multipleTimeSelectionConfirmations() { + testTimePicker.timeSelected(0, 0); + compare(timeSelectedSpy.count, 1); + compare(timeSelectedSpy.signalArguments[0][0], 0); + compare(timeSelectedSpy.signalArguments[0][1], 0); + + testTimePicker.timeSelected(12, 30); + compare(timeSelectedSpy.count, 2); + compare(timeSelectedSpy.signalArguments[1][0], 12); + compare(timeSelectedSpy.signalArguments[1][1], 30); + + testTimePicker.timeSelected(23, 55); + compare(timeSelectedSpy.count, 3); + compare(timeSelectedSpy.signalArguments[2][0], 23); + compare(timeSelectedSpy.signalArguments[2][1], 55); + } + + function test_boundaryTimeValues() { + testTimePicker.open(23, 55); + compare(testTimePicker.initialHour, 23); + compare(testTimePicker.initialMinute, 55); + + testTimePicker.open(0, 0); + compare(testTimePicker.initialHour, 0); + compare(testTimePicker.initialMinute, 0); + } + } +} From f0cc44b3bdf14e5089aef15fe5755587f862ee41 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Tue, 13 Oct 2026 13:27:12 +0530 Subject: [PATCH 25/34] Add unit tests for JavaScript data models Add QML test suites covering data models including task, timesheet, project, activity, accounts, notifications, and main helpers. --- tests/qml/README.md | 7 + tests/qml/models/tst_accounts.qml | 172 ++++++++++++++++ tests/qml/models/tst_activity.qml | 198 ++++++++++++++++++ tests/qml/models/tst_main.qml | 143 +++++++++++++ tests/qml/models/tst_notifications.qml | 142 +++++++++++++ tests/qml/models/tst_project.qml | 199 ++++++++++++++++++ tests/qml/models/tst_task.qml | 268 +++++++++++++++++++++++++ tests/qml/models/tst_timesheet.qml | 268 +++++++++++++++++++++++++ 8 files changed, 1397 insertions(+) create mode 100644 tests/qml/models/tst_accounts.qml create mode 100644 tests/qml/models/tst_activity.qml create mode 100644 tests/qml/models/tst_main.qml create mode 100644 tests/qml/models/tst_notifications.qml create mode 100644 tests/qml/models/tst_project.qml create mode 100644 tests/qml/models/tst_task.qml create mode 100644 tests/qml/models/tst_timesheet.qml diff --git a/tests/qml/README.md b/tests/qml/README.md index 60c4f4c0..81830d1f 100644 --- a/tests/qml/README.md +++ b/tests/qml/README.md @@ -23,10 +23,17 @@ tests/ ├── qml/ │ ├── README.md # This guide │ ├── models/ # JavaScript core and model test suites +│ │ ├── tst_accounts.qml # Test suite for account management, defaults, sync settings, and deletion +│ │ ├── tst_activity.qml # Test suite for activity data, sanitizeId, type icons, date filters, and CRUD │ │ ├── tst_constants.qml # Test suite for constants and quadrant color configuration │ │ ├── tst_dashboard_user_filter.qml # Test suite for dashboard user filter state, models, and queries │ │ ├── tst_logger.qml # Test suite for logger levels, formatting, and PII sanitization +│ │ ├── tst_main.qml # Test suite for Main.js date helpers, name resolvers, and spent hours queries +│ │ ├── tst_notifications.qml # Test suite for notifications creation, read status, and sync notifications +│ │ ├── tst_project.qml # Test suite for project details, offline cache, favorites, and project updates +│ │ ├── tst_task.qml # Test suite for task model filters, assignee parsing, date status, and CRUD │ │ ├── tst_timer_service.qml # Test suite for timer service state transitions and duration +│ │ ├── tst_timesheet.qml # Test suite for timesheet creation, duration, readiness, and status transitions │ │ └── tst_utils.qml # Test suite for date, time, duration, and URL utilities │ ├── tst_accountselectordialog.qml # Test suite for AccountSelectorDialog picker and account switch │ ├── tst_colorpicker.qml # Test suite for ColorPicker palette and custom color diff --git a/tests/qml/models/tst_accounts.qml b/tests/qml/models/tst_accounts.qml new file mode 100644 index 00000000..581f43b4 --- /dev/null +++ b/tests/qml/models/tst_accounts.qml @@ -0,0 +1,172 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DbInit +import "../../../models/utils.js" as Utils +import "../../../models/accounts.js" as AccountsModel + +Item { + width: 200 + height: 200 + + TestCase { + name: "AccountsModelTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DbInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function clearTestData() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("DELETE FROM users WHERE id >= 90000 OR name LIKE 'TestAccount%' OR name LIKE 'CreatedAccount%'"); + tx.executeSql("DELETE FROM attachment_download_app WHERE account_id = 999 OR record_id >= 90000"); + tx.executeSql("UPDATE users SET is_default = 0"); + // Ensure local account is default + tx.executeSql("UPDATE users SET is_default = 1 WHERE id = 0"); + }); + } + + function init() { + clearTestData(); + } + + function cleanup() { + clearTestData(); + } + + // --- 1. Default Account Management --- + + function test_defaultAccountHandling() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO users (id, name, link, database, username, is_default) VALUES (?, ?, ?, ?, ?, ?)", + [90001, "TestAccount Alpha", "https://alpha.example.com", "db_alpha", "user_alpha", 0] + ); + tx.executeSql( + "INSERT INTO users (id, name, link, database, username, is_default) VALUES (?, ?, ?, ?, ?, ?)", + [90002, "TestAccount Beta", "https://beta.example.com", "db_beta", "user_beta", 0] + ); + }); + + AccountsModel.setDefaultAccount(90001); + compare(AccountsModel.getDefaultAccountId(), 90001); + + AccountsModel.setDefaultAccount(90002); + compare(AccountsModel.getDefaultAccountId(), 90002); + + AccountsModel.clearDefaultAccount(); + // When none marked default, getDefaultAccountId falls back to first account (usually id 0) + compare(AccountsModel.getDefaultAccountId(), 0); + } + + // --- 2. Account Creation and Duplicate Detection --- + + function test_createAccount_success_and_duplicates() { + var res1 = AccountsModel.createAccount("CreatedAccount One", "https://odoo1.com", "db1", "admin1", 1, "apikey1"); + verify(!res1.duplicateFound); + + // Duplicate name (case-insensitive) + var resDupName = AccountsModel.createAccount("createdaccount one", "https://other.com", "otherdb", "otheruser", 1, "key"); + verify(resDupName.duplicateFound); + compare(resDupName.duplicateType, "name"); + + // Duplicate server connection + var resDupConn = AccountsModel.createAccount("CreatedAccount Two", "https://odoo1.com", "db1", "admin1", 1, "apikey2"); + verify(resDupConn.duplicateFound); + compare(resDupConn.duplicateType, "connection"); + } + + // --- 3. Account Name and Update --- + + function test_getAccountName_and_updateAccount() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO users (id, name, link, database, username) VALUES (?, ?, ?, ?, ?)", + [90010, "TestAccount Gamma", "https://gamma.com", "db_gamma", "user_gamma"] + ); + }); + + // Local account ID 0 name check + compare(AccountsModel.getAccountName(0), "Local"); + + // User account name check + compare(AccountsModel.getAccountName(90010), "TestAccount Gamma"); + + // Update account + var updateRes = AccountsModel.updateAccount(90010, "TestAccount Gamma Renamed", "https://gamma2.com", "db_gamma2", "user_gamma2", 1, "newkey"); + verify(updateRes.success); + + compare(AccountsModel.getAccountName(90010), "TestAccount Gamma Renamed"); + } + + // --- 4. Delete Account and Related Data --- + + function test_deleteAccountAndRelatedData() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO users (id, name, link, database, username) VALUES (?, ?, ?, ?, ?)", + [90020, "TestAccount Delta", "https://delta.com", "db_delta", "user_delta"] + ); + tx.executeSql( + "INSERT INTO project_project_app (id, odoo_record_id, name, account_id) VALUES (?, ?, ?, ?)", + [90021, 90021, "Delta Project", 90020] + ); + }); + + AccountsModel.deleteAccountAndRelatedData(90020); + + // User record should be deleted + var afterUser = AccountsModel.getAccountName(90020); + compare(afterUser, ""); + + // Related project should also be deleted + db.transaction(function (tx) { + var rs = tx.executeSql("SELECT COUNT(*) AS count FROM project_project_app WHERE account_id = ?", [90020]); + compare(rs.rows.item(0).count, 0); + }); + + // Verify local account 0 cannot be deleted + AccountsModel.deleteAccountAndRelatedData(0); + compare(AccountsModel.getAccountName(0), "Local"); + } + + // --- 5. Sync Settings --- + + function test_accountSyncSettings() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO users (id, name, link, database, username) VALUES (?, ?, ?, ?, ?)", + [90030, "TestAccount Sync", "https://sync.com", "db_sync", "user_sync"] + ); + }); + + AccountsModel.updateAccountSyncSettings(90030, 30, "download_only", 1); + var settings = AccountsModel.getAccountSyncSettings(90030); + compare(settings.sync_interval_minutes, 30); + compare(settings.sync_direction, "download_only"); + compare(settings.autosync_enabled, 1); + } + + // --- 6. Attachment Download Tracking --- + + function test_attachmentDownloadTracking() { + compare(AccountsModel.isAttachmentDownloaded(999, 90040), false); + + AccountsModel.markAttachmentDownloaded(999, 90040, "specs.pdf"); + compare(AccountsModel.isAttachmentDownloaded(999, 90040), true); + } + } +} diff --git a/tests/qml/models/tst_activity.qml b/tests/qml/models/tst_activity.qml new file mode 100644 index 00000000..ca1d8800 --- /dev/null +++ b/tests/qml/models/tst_activity.qml @@ -0,0 +1,198 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DbInit +import "../../../models/utils.js" as Utils +import "../../../models/activity.js" as ActivityModel + +Item { + width: 200 + height: 200 + + TestCase { + name: "ActivityModelTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DbInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function clearTestData() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("DELETE FROM mail_activity_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM mail_activity_type_app WHERE account_id = 999 OR id >= 90000"); + }); + } + + function init() { + clearTestData(); + } + + function cleanup() { + clearTestData(); + } + + // --- 1. Pure Helper Functions --- + + function test_sanitizeId() { + compare(ActivityModel.sanitizeId(null), -1); + compare(ActivityModel.sanitizeId(undefined), -1); + compare(ActivityModel.sanitizeId(""), -1); + compare(ActivityModel.sanitizeId(0), 0); + compare(ActivityModel.sanitizeId(42), 42); + compare(ActivityModel.sanitizeId("42"), 42); + compare(ActivityModel.sanitizeId(-5), -5); + compare(ActivityModel.sanitizeId("invalid"), -1); + } + + function test_getActivityIconForType() { + compare(ActivityModel.getActivityIconForType("Send Mail"), "activity_mail.png"); + compare(ActivityModel.getActivityIconForType("Client Call"), "activity_call.png"); + compare(ActivityModel.getActivityIconForType("Project Meeting"), "activity_meeting.png"); + compare(ActivityModel.getActivityIconForType("Todo List"), "activity_others.png"); + compare(ActivityModel.getActivityIconForType(""), "activity_others.png"); + compare(ActivityModel.getActivityIconForType(null), "activity_others.png"); + } + + function test_passesActivityDateFilter() { + var refDate = new Date(2026, 9, 13); // 2026-10-13 + + var actToday = { due_date: "2026-10-13" }; + var actYesterday = { due_date: "2026-10-12" }; + var actNoDate = { due_date: null }; + + // "all" filter + verify(ActivityModel.passesActivityDateFilter(actToday, "all", refDate)); + verify(ActivityModel.passesActivityDateFilter(actNoDate, "all", refDate)); + + // Without date, non-"all" filters return false + verify(!ActivityModel.passesActivityDateFilter(actNoDate, "today", refDate)); + + // "today" includes today and overdue items + verify(ActivityModel.passesActivityDateFilter(actToday, "today", refDate)); + verify(ActivityModel.passesActivityDateFilter(actYesterday, "today", refDate)); + + // "overdue" + verify(ActivityModel.passesActivityDateFilter(actYesterday, "overdue", refDate)); + verify(!ActivityModel.passesActivityDateFilter(actToday, "overdue", refDate)); + } + + function test_passesActivitySearchFilter() { + var activity = { + summary: "Quarterly Planning", + notes: "Discuss Q4 objectives and budget", + activity_type_id: 1, + account_id: 999 + }; + + verify(ActivityModel.passesActivitySearchFilter(activity, "")); + verify(ActivityModel.passesActivitySearchFilter(activity, null)); + verify(ActivityModel.passesActivitySearchFilter(activity, "Quarterly")); + verify(ActivityModel.passesActivitySearchFilter(activity, "planning")); + verify(ActivityModel.passesActivitySearchFilter(activity, "budget")); + verify(!ActivityModel.passesActivitySearchFilter(activity, "UnrelatedTerm")); + } + + // --- 2. Database CRUD and Mutations --- + + function test_saveActivityData_and_getActivityById() { + var newActivity = { + updatedAccount: 999, + updatedActivity: 1, + updatedSummary: "Review Architecture Doc", + updatedUserId: 10, + updatedDate: "2026-10-14", + updatedNote: "Detailed review comments", + resModel: "project.project", + resId: 101, + task_id: 0, + project_id: 101, + link_id: 101, + state: "planned", + status: "normal" + }; + + var saveRes = ActivityModel.saveActivityData(newActivity, 0); + verify(saveRes.success); + var actId = saveRes.recordId; + verify(actId > 0); + + var details = ActivityModel.getActivityById(actId, 999); + verify(details !== null); + compare(details.id, actId); + compare(details.summary, "Review Architecture Doc"); + compare(details.account_id, 999); + compare(details.state, "planned"); + + // Update the activity + newActivity.updatedSummary = "Review Architecture Doc Updated"; + var updateRes = ActivityModel.saveActivityData(newActivity, actId); + verify(updateRes.success); + + var updatedDetails = ActivityModel.getActivityById(actId, 999); + compare(updatedDetails.summary, "Review Architecture Doc Updated"); + } + + function test_markAsDone_and_updateActivityDate() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO mail_activity_app (id, account_id, summary, due_date, state, status) VALUES (?, ?, ?, ?, ?, ?)", + [90010, 999, "Call Vendor", "2026-10-13", "planned", "normal"] + ); + }); + + ActivityModel.updateActivityDate(999, 90010, "2026-10-15"); + var d1 = ActivityModel.getActivityById(90010, 999); + compare(d1.due_date, "2026-10-15"); + compare(d1.status, "updated"); + + ActivityModel.markAsDone(999, 90010); + var d2 = ActivityModel.getActivityById(90010, 999); + compare(d2.state, "done"); + compare(d2.status, "updated"); + } + + function test_deleteActivity() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO mail_activity_app (id, account_id, summary, status) VALUES (?, ?, ?, ?)", + [90020, 999, "Temporary Activity", "normal"] + ); + }); + + var delRes = ActivityModel.deleteActivity(999, 90020); + verify(delRes.success); + + var details = ActivityModel.getActivityById(90020, 999); + // Non-existent activity returns empty object or null + verify(!details || !details.id); + } + + function test_getActivityTypesForAccount() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO mail_activity_type_app (id, odoo_record_id, name, account_id, status) VALUES (?, ?, ?, ?, ?)", + [90031, 90031, "Email", 999, "normal"] + ); + tx.executeSql( + "INSERT INTO mail_activity_type_app (id, odoo_record_id, name, account_id, status) VALUES (?, ?, ?, ?, ?)", + [90032, 90032, "Meeting", 999, "normal"] + ); + }); + + var types = ActivityModel.getActivityTypesForAccount(999); + compare(types.length, 2); + } + } +} diff --git a/tests/qml/models/tst_main.qml b/tests/qml/models/tst_main.qml new file mode 100644 index 00000000..fa5f5165 --- /dev/null +++ b/tests/qml/models/tst_main.qml @@ -0,0 +1,143 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DbInit +import "../../../models/utils.js" as Utils +import "../../../models/Main.js" as MainModel + +Item { + width: 200 + height: 200 + + TestCase { + name: "MainModelTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DbInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function clearTestData() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("DELETE FROM account_analytic_line_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM project_task_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM project_project_app WHERE account_id = 999 OR id >= 90000"); + }); + } + + function init() { + clearTestData(); + } + + function cleanup() { + clearTestData(); + } + + // --- 1. Pure Helper Date Functions --- + + function test_dateHelpers() { + var monday = MainModel.getMondayOfCurrentWeek(); + verify(monday !== ""); + // Matches YYYY-MM-DD + verify(/^\d{4}-\d{2}-\d{2}$/.test(monday)); + + var firstDay = MainModel.getFirstDayOfCurrentMonth(); + verify(firstDay !== ""); + verify(/^\d{4}-\d{2}-01$/.test(firstDay)); + } + + // --- 2. Entity Name Resolvers --- + + function test_getProjectAndTaskName() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("INSERT INTO project_project_app (id, odoo_record_id, name, account_id) VALUES (?, ?, ?, ?)", [90001, 90001, "Main Project Test", 999]); + tx.executeSql("INSERT INTO project_task_app (id, odoo_record_id, name, account_id) VALUES (?, ?, ?, ?)", [90002, 90002, "Main Task Test", 999]); + }); + + compare(MainModel.get_project_name(90001), "Main Project Test"); + compare(MainModel.get_project_name(99999), ""); + + compare(MainModel.get_task_name(90002), "Main Task Test"); + compare(MainModel.get_task_name(99999), ""); + } + + // --- 3. Quadrant and Spent Hours Aggregations --- + + function test_get_quadrant_difference() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, quadrant_id, unit_amount, status) VALUES (?, ?, ?, ?, ?)", + [90011, 999, 0, 3.5, "saved"] + ); + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, quadrant_id, unit_amount, status) VALUES (?, ?, ?, ?, ?)", + [90012, 999, 1, 4.0, "saved"] + ); + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, quadrant_id, unit_amount, status) VALUES (?, ?, ?, ?, ?)", + [90013, 999, 2, 2.0, "saved"] + ); + }); + + var quadDiff = MainModel.get_quadrant_difference(); + verify(quadDiff !== null); + compare(quadDiff[0], 3.5); + compare(quadDiff[1], 4.0); + compare(quadDiff[2], 2.0); + } + + function test_get_spent_hours_query() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, project_id, unit_amount, record_date, status) VALUES (?, ?, ?, ?, ?, ?)", + [90021, 999, 101, 1.5, "2026-10-10", "saved"] + ); + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, project_id, unit_amount, record_date, status) VALUES (?, ?, ?, ?, ?, ?)", + [90022, 999, 101, 2.5, "2026-10-12", "saved"] + ); + }); + + // Group by project_id + var spentGrouped = MainModel.get_spent_hours({ group_by: "project_id" }, 999); + verify(spentGrouped.length >= 1); + compare(spentGrouped[0].total, 4.0); + + // Filter with dateFilter + var spentFiltered = MainModel.get_spent_hours({ dateFilter: "2026-10-11" }, 999); + compare(spentFiltered.length, 1); + compare(spentFiltered[0].id, 90022); + } + + function test_get_projects_and_tasks_spent_hours() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("INSERT INTO project_project_app (id, odoo_record_id, name, account_id) VALUES (?, ?, ?, ?)", [90100, 90100, "Project Gamma", 999]); + tx.executeSql("INSERT INTO project_task_app (id, odoo_record_id, name, account_id, project_id) VALUES (?, ?, ?, ?, ?)", [90200, 90200, "Task Delta", 999, 90100]); + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, unit_amount, record_date, status, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [90031, 999, 90100, 90200, 5.0, "2026-10-13", "saved", 10] + ); + }); + + var projectsSummary = MainModel.get_projects_spent_hours(999, "2026-10-01", "2026-10-31", 10); + verify(projectsSummary.length >= 1); + compare(projectsSummary[0].name, "Project Gamma"); + compare(projectsSummary[0].total, 5.0); + + var tasksSummary = MainModel.get_tasks_spent_hours(999, "2026-10-01", "2026-10-31", 10); + compare(tasksSummary["Task Delta"], 5.0); + } + } +} diff --git a/tests/qml/models/tst_notifications.qml b/tests/qml/models/tst_notifications.qml new file mode 100644 index 00000000..7dc4b0fc --- /dev/null +++ b/tests/qml/models/tst_notifications.qml @@ -0,0 +1,142 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DbInit +import "../../../models/notifications.js" as NotificationsModel + +Item { + width: 200 + height: 200 + + TestCase { + name: "NotificationsModelTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DbInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function clearTestData() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("DELETE FROM notification WHERE account_id = 999 OR id >= 90000"); + }); + } + + function init() { + clearTestData(); + } + + function cleanup() { + clearTestData(); + } + + // --- 1. Notification Creation and Details --- + + function test_addNotification_and_getDetails() { + var payload = { taskId: 123, priority: "high" }; + NotificationsModel.addNotification(999, "Task", "New task assigned", payload); + + var unread = NotificationsModel.getUnreadNotifications(); + verify(unread.length > 0); + + var found = null; + for (var i = 0; i < unread.length; i++) { + if (unread[i].account_id === 999 && unread[i].message === "New task assigned") { + found = unread[i]; + break; + } + } + verify(found !== null); + compare(found.type, "Task"); + compare(found.read_status, 0); + + var details = NotificationsModel.getDetailsOfNotification(found.id); + verify(details !== null); + compare(details.id, found.id); + compare(details.message, "New task assigned"); + + var parsedPayload = JSON.parse(details.payload); + compare(parsedPayload.taskId, 123); + } + + // --- 2. Read Status and Counts --- + + function test_readStatusAndCounts() { + NotificationsModel.addNotification(999, "Project", "Project milestone met", { projectId: 10 }); + NotificationsModel.addNotification(999, "Activity", "Meeting scheduled", { activityId: 20 }); + + var unreadCountBefore = NotificationsModel.getUnreadCount(); + verify(unreadCountBefore >= 2); + + var unreadList = NotificationsModel.getUnreadNotifications(); + var targetId = unreadList[0].id; + + NotificationsModel.markAsRead(targetId); + var details = NotificationsModel.getDetailsOfNotification(targetId); + compare(details.read_status, 1); + + NotificationsModel.markAllAsRead(); + compare(NotificationsModel.getUnreadCount(), 0); + + // Delete all read notifications + NotificationsModel.deleteAllRead(); + var detailsAfterDelete = NotificationsModel.getDetailsOfNotification(targetId); + compare(detailsAfterDelete, null); + } + + // --- 3. Sync vs Normal Notifications --- + + function test_syncVsNormalNotifications() { + NotificationsModel.addNotification(999, "Task", "Normal task notification", { id: 1 }); + NotificationsModel.addNotification(999, "Sync", "Sync failed on network timeout", { error: "timeout" }); + + verify(NotificationsModel.hasSyncNotifications()); + + var normalList = NotificationsModel.getUnreadNormalNotifications(); + verify(normalList.length >= 1); + compare(normalList[0].type !== "Sync", true); + + var syncList = NotificationsModel.getUnreadSyncNotifications(); + verify(syncList.length >= 1); + compare(syncList[0].type, "Sync"); + + var normalCount = NotificationsModel.getUnreadNormalCount(); + var syncCount = NotificationsModel.getUnreadSyncCount(); + verify(normalCount >= 1); + verify(syncCount >= 1); + + // Clear sync notifications for account 999 + var cleared = NotificationsModel.clearSyncNotifications(999); + verify(cleared >= 1); + compare(NotificationsModel.getUnreadSyncCount(), 0); + + // Normal notifications should remain + compare(NotificationsModel.getUnreadNormalCount(), normalCount); + } + + // --- 4. Deletion --- + + function test_deleteNotification_and_deleteAll() { + NotificationsModel.addNotification(999, "Task", "Task to delete", {}); + var unread = NotificationsModel.getUnreadNotifications(); + verify(unread.length > 0); + + var noteId = unread[0].id; + NotificationsModel.deleteNotification(noteId); + compare(NotificationsModel.getDetailsOfNotification(noteId), null); + + NotificationsModel.addNotification(999, "Task", "Task A", {}); + NotificationsModel.addNotification(999, "Task", "Task B", {}); + NotificationsModel.deleteAllNotifications(); + compare(NotificationsModel.getUnreadCount(), 0); + } + } +} diff --git a/tests/qml/models/tst_project.qml b/tests/qml/models/tst_project.qml new file mode 100644 index 00000000..a6298695 --- /dev/null +++ b/tests/qml/models/tst_project.qml @@ -0,0 +1,199 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DbInit +import "../../../models/utils.js" as Utils +import "../../../models/project.js" as ProjectModel + +Item { + width: 200 + height: 200 + + TestCase { + name: "ProjectModelTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DbInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function clearTestData() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("DELETE FROM project_project_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM project_update_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM dl_cache_app WHERE record_id >= 90000"); + }); + } + + function init() { + clearTestData(); + } + + function cleanup() { + clearTestData(); + } + + // --- 1. Cache Functions --- + + function test_cacheOperations() { + compare(ProjectModel.isPresentInCache(90001), false); + compare(ProjectModel.getFromCache(90001), null); + + ProjectModel.putInCache(90001, "SGVsbG8gV29ybGQ="); // base64 + compare(ProjectModel.isPresentInCache(90001), true); + compare(ProjectModel.getFromCache(90001), "SGVsbG8gV29ybGQ="); + + ProjectModel.putInCache(90002, "VXBkYXRlZA=="); + compare(ProjectModel.isPresentInCache(90002), true); + compare(ProjectModel.getFromCache(90002), "VXBkYXRlZA=="); + } + + // --- 2. ID Mapping and Details --- + + function test_getLocalIdFromOdooId() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO project_project_app (id, odoo_record_id, name, account_id) VALUES (?, ?, ?, ?)", + [90010, 44444, "Mapped Project", 999] + ); + }); + + compare(ProjectModel.getLocalIdFromOdooId(44444, 999), 90010); + compare(ProjectModel.getLocalIdFromOdooId(99999, 999), -1); + } + + function test_getProjectName_and_getProjectDetails() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO project_project_app (id, odoo_record_id, name, account_id, description, favorites, color_pallet) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)", + [90020, 55555, "Alpha Project", 999, "A detailed project", 0, "#FF0000"] + ); + }); + + var name = ProjectModel.getProjectName(90020, 999); + compare(name, "Alpha Project"); + + var details = ProjectModel.getProjectDetails(90020); + verify(details !== null); + compare(details.id, 90020); + compare(details.name, "Alpha Project"); + compare(details.account_id, 999); + compare(details.description, "A detailed project"); + compare(details.color_pallet, "#FF0000"); + } + + // --- 3. Project Creation, Updating, and Favorites --- + + function test_createUpdateProject() { + var newProject = { + account_id: 999, + name: "Created Project", + parent_id: 0, + planned_start_date: "2026-10-13", + planned_end_date: "2026-10-20", + allocated_hours: "10:00", + favorites: 0, + description: "Test description", + color: "#00FF00", + stage: 1, + status: "normal" + }; + + var createRes = ProjectModel.createUpdateProject(newProject, 0); + verify(createRes.is_success); + var projectId = createRes.record_id; + verify(projectId > 0); + + var details = ProjectModel.getProjectDetails(projectId); + compare(details.name, "Created Project"); + compare(details.account_id, 999); + compare(details.color_pallet, "#00FF00"); + + // Update project + newProject.name = "Updated Project Name"; + newProject.favorites = 1; + var updateRes = ProjectModel.createUpdateProject(newProject, projectId); + verify(updateRes.is_success); + + var updatedDetails = ProjectModel.getProjectDetails(projectId); + compare(updatedDetails.name, "Updated Project Name"); + compare(updatedDetails.favorites, 1); + } + + function test_toggleProjectFavorite() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO project_project_app (id, odoo_record_id, name, account_id, favorites, status) VALUES (?, ?, ?, ?, ?, ?)", + [90030, 90030, "Favorite Test Project", 999, 0, "normal"] + ); + }); + + var resFav = ProjectModel.toggleProjectFavorite(90030, true, "updated"); + verify(resFav.success); + var d1 = ProjectModel.getProjectDetails(90030); + compare(d1.favorites, 1); + compare(d1.status, "updated"); + + var resUnfav = ProjectModel.toggleProjectFavorite(90030, false, "updated"); + verify(resUnfav.success); + var d2 = ProjectModel.getProjectDetails(90030); + compare(d2.favorites, 0); + } + + // --- 4. Project Updates / Snapshots --- + + function test_projectUpdatesCRUD() { + var updateData = { + project_id: 90020, + name: "Weekly Status Update", + project_status: "on_track", + progress: 50, + description: "Halfway completed milestones", + account_id: 999, + user_id: 10 + }; + + var res = ProjectModel.createUpdateSnapShot(updateData, 0); + verify(res.is_success); + var updateId = res.record_id; + verify(updateId > 0); + + var updates = ProjectModel.getAllProjectUpdates(999); + verify(updates.length > 0); + + var delRes = ProjectModel.markProjectUpdateAsDeleted(updateId); + verify(delRes.success); + } + + // --- 5. Project Listing --- + + function test_getProjectsForAccount() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO project_project_app (id, odoo_record_id, name, account_id, status) VALUES (?, ?, ?, ?, ?)", + [90041, 90041, "Account Project 1", 999, "normal"] + ); + tx.executeSql( + "INSERT INTO project_project_app (id, odoo_record_id, name, account_id, status) VALUES (?, ?, ?, ?, ?)", + [90042, 90042, "Account Project 2", 999, "normal"] + ); + }); + + var projects = ProjectModel.getProjectsForAccount(999); + compare(projects.length, 2); + } + } +} diff --git a/tests/qml/models/tst_task.qml b/tests/qml/models/tst_task.qml new file mode 100644 index 00000000..7fc11fc3 --- /dev/null +++ b/tests/qml/models/tst_task.qml @@ -0,0 +1,268 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DbInit +import "../../../models/utils.js" as Utils +import "../../../models/task.js" as TaskModel + +Item { + width: 200 + height: 200 + + TestCase { + name: "TaskModelTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DbInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function init() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("DELETE FROM project_task_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM project_project_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM project_task_type_app WHERE account_id = 999 OR odoo_record_id >= 90000"); + }); + } + + function cleanup() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("DELETE FROM project_task_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM project_project_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM project_task_type_app WHERE account_id = 999 OR odoo_record_id >= 90000"); + }); + } + + // --- 1. Pure Helper Functions --- + + function test_validId() { + compare(TaskModel.validId(10), 10); + compare(TaskModel.validId(1), 1); + compare(TaskModel.validId(0), null); + compare(TaskModel.validId(-5), null); + compare(TaskModel.validId(undefined), null); + compare(TaskModel.validId(null), null); + } + + function test_parseAssigneeIds() { + compare(TaskModel.parseAssigneeIds("").length, 0); + compare(TaskModel.parseAssigneeIds(null).length, 0); + compare(TaskModel.parseAssigneeIds(undefined).length, 0); + + var parsedSingle = TaskModel.parseAssigneeIds("42"); + compare(parsedSingle.length, 1); + compare(parsedSingle[0], 42); + + var parsedMulti = TaskModel.parseAssigneeIds("10, 25, 30"); + compare(parsedMulti.length, 3); + compare(parsedMulti[0], 10); + compare(parsedMulti[1], 25); + compare(parsedMulti[2], 30); + + var parsedDirty = TaskModel.parseAssigneeIds("10, invalid, -2, 0, 50"); + compare(parsedDirty.length, 2); + compare(parsedDirty[0], 10); + compare(parsedDirty[1], 50); + } + + function test_formatAssigneeIds() { + compare(TaskModel.formatAssigneeIds([]), ""); + compare(TaskModel.formatAssigneeIds(null), ""); + compare(TaskModel.formatAssigneeIds(undefined), ""); + + var assignees = [{ id: 5 }, { id: 12 }, { id: 99 }]; + compare(TaskModel.formatAssigneeIds(assignees), "5,12,99"); + + var single = [{ id: 7 }]; + compare(TaskModel.formatAssigneeIds(single), "7"); + } + + function test_passesSearchFilter() { + var task = { + name: "Implement Dashboard Filter", + description: "Allow filtering tasks by employee and project" + }; + + verify(TaskModel.passesSearchFilter(task, "")); + verify(TaskModel.passesSearchFilter(task, null)); + verify(TaskModel.passesSearchFilter(task, "Dashboard")); + verify(TaskModel.passesSearchFilter(task, "dashboard")); // Case-insensitive + verify(TaskModel.passesSearchFilter(task, "filtering")); + verify(TaskModel.passesSearchFilter(task, "EMPLOYEE")); + verify(!TaskModel.passesSearchFilter(task, "NonexistentKeyword")); + } + + function test_stageClassification() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("INSERT INTO project_task_type_app (odoo_record_id, name, account_id) VALUES (?, ?, ?)", [90001, "Done", 999]); + tx.executeSql("INSERT INTO project_task_type_app (odoo_record_id, name, account_id) VALUES (?, ?, ?)", [90002, "In Progress", 999]); + tx.executeSql("INSERT INTO project_task_type_app (odoo_record_id, name, account_id) VALUES (?, ?, ?)", [90003, "Cancelled", 999]); + }); + + var doneTask = { state: 90001, account_id: 999 }; + var activeTask = { state: 90002, account_id: 999 }; + var cancelledTask = { state: 90003, account_id: 999 }; + + verify(TaskModel.isTaskInDoneStage(doneTask)); + verify(!TaskModel.isTaskInDoneStage(activeTask)); + + verify(TaskModel.isTaskInCancelledStage(cancelledTask)); + verify(!TaskModel.isTaskInCancelledStage(doneTask)); + } + + function test_passesDateFilter_allFilter() { + var taskWithDates = { start_date: "2026-10-13", end_date: "2026-10-14" }; + var taskNoDates = { start_date: null, end_date: null }; + + verify(TaskModel.passesDateFilter(taskWithDates, "all", new Date())); + verify(TaskModel.passesDateFilter(taskNoDates, "all", new Date())); + verify(!TaskModel.passesDateFilter(taskNoDates, "today", new Date())); + } + + function test_dueCalculationsAndOverdue() { + var refDate = new Date(2026, 9, 13); // 2026-10-13 + + var taskToday = { start_date: "2026-10-13", end_date: "2026-10-13" }; + var taskYesterday = { start_date: "2026-10-12", end_date: "2026-10-12" }; + var taskTomorrow = { start_date: "2026-10-14", end_date: "2026-10-14" }; + + // Today task is due today + verify(TaskModel.isTaskDueToday(taskToday, refDate)); + + // Tomorrow task is not due today + verify(!TaskModel.isTaskDueToday(taskTomorrow, refDate)); + + // Yesterday task is overdue (and shown in today view) + verify(TaskModel.isTaskDueToday(taskYesterday, refDate)); + verify(TaskModel.isTaskOverdue(taskYesterday, refDate)); + verify(!TaskModel.isTaskOverdue(taskToday, refDate)); + verify(!TaskModel.isTaskOverdue(taskTomorrow, refDate)); + + var statusOverdue = TaskModel.getTaskDateStatus(taskYesterday, refDate); + verify(statusOverdue.isOverdue); + + var statusToday = TaskModel.getTaskDateStatus(taskToday, refDate); + verify(statusToday.isInRange); + } + + // --- 2. Database CRUD and Query Functions --- + + function test_saveOrUpdateTask_and_getTaskDetails() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO project_project_app (id, odoo_record_id, name, account_id, parent_id) VALUES (?, ?, ?, ?, ?)", + [101, 101, "Test Project", 999, 0] + ); + }); + + var taskData = { + name: "Unit Test Task Alpha", + description: "Test task description", + projectId: 101, + priority: 1, + accountId: 999, + assigneeUserId: "10,20", + status: "normal" + }; + + var res = TaskModel.saveOrUpdateTask(taskData); + verify(res.success); + var taskId = res.taskId; + verify(taskId > 0); + + var details = TaskModel.getTaskDetails(taskId); + verify(details !== null); + compare(details.name, "Unit Test Task Alpha"); + compare(details.project_id, 101); + compare(details.account_id, 999); + compare(details.priority, "1"); + + // Now test updating the existing task + taskData.record_id = taskId; + taskData.name = "Unit Test Task Alpha Updated"; + var updateRes = TaskModel.saveOrUpdateTask(taskData); + verify(updateRes.success); + + var updatedDetails = TaskModel.getTaskDetails(taskId); + compare(updatedDetails.name, "Unit Test Task Alpha Updated"); + } + + function test_isUserAssignedToTask() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO project_task_app (id, odoo_record_id, name, user_id, account_id) VALUES (?, ?, ?, ?, ?)", + [90002, 90002, "Multi Assignee Task", "10, 20, 30", 999] + ); + }); + + verify(TaskModel.isUserAssignedToTask(90002, 10)); + verify(TaskModel.isUserAssignedToTask(90002, 20)); + verify(TaskModel.isUserAssignedToTask(90002, 30)); + verify(!TaskModel.isUserAssignedToTask(90002, 40)); + verify(!TaskModel.isUserAssignedToTask(90002, 99)); + } + + function test_setTaskPriority() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO project_task_app (id, odoo_record_id, name, priority, account_id) VALUES (?, ?, ?, ?, ?)", + [90003, 90003, "Priority Test Task", 0, 999] + ); + }); + + var res = TaskModel.setTaskPriority(90003, 3, "updated"); + verify(res.success); + + var details = TaskModel.getTaskDetails(90003); + verify(details !== null); + compare(details.priority, "3"); + compare(details.status, "updated"); + } + + function test_markTaskAsDeleted() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO project_task_app (id, odoo_record_id, name, status, account_id) VALUES (?, ?, ?, ?, ?)", + [90004, 90004, "Task to Delete", "normal", 999] + ); + }); + + var deleted = TaskModel.markTaskAsDeleted(90004); + verify(deleted); + + var details = TaskModel.getTaskDetails(90004); + verify(details !== null); + compare(details.status, "deleted"); + } + + function test_getLocalIdFromOdooId() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO project_task_app (id, odoo_record_id, name, account_id) VALUES (?, ?, ?, ?)", + [90005, 54321, "Odoo Id Mapping Task", 999] + ); + }); + + var localId = TaskModel.getLocalIdFromOdooId(54321, 999); + compare(localId, 90005); + + var nonExistent = TaskModel.getLocalIdFromOdooId(99999, 999); + compare(nonExistent, -1); + } + } +} diff --git a/tests/qml/models/tst_timesheet.qml b/tests/qml/models/tst_timesheet.qml new file mode 100644 index 00000000..e536ed5f --- /dev/null +++ b/tests/qml/models/tst_timesheet.qml @@ -0,0 +1,268 @@ +import QtQuick 2.7 +import QtTest 1.0 +import QtQuick.LocalStorage 2.7 as Sql +import "../../../models/logger.js" as Logger +import "../../../models/database.js" as DBCommon +import "../../../models/dbinit.js" as DbInit +import "../../../models/utils.js" as Utils +import "../../../models/timesheet.js" as TimesheetModel + +Item { + width: 200 + height: 200 + + TestCase { + name: "TimesheetModelTests" + when: windowShown + + function initTestCase() { + Logger.setLogLevel(Logger.LogLevel.NONE); + DbInit.initializeDatabase(); + } + + function cleanupTestCase() { + Logger.setLogLevel(Logger.LogLevel.WARN); + } + + function clearTestData() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("DELETE FROM account_analytic_line_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM project_task_app WHERE account_id = 999 OR id >= 90000"); + tx.executeSql("DELETE FROM project_project_app WHERE account_id = 999 OR id >= 90000"); + }); + } + + function init() { + clearTestData(); + } + + function cleanup() { + clearTestData(); + } + + // --- 1. Creation and Details --- + + function test_createTimesheet_and_getTimeSheetDetails() { + var createRes = TimesheetModel.createTimesheet(999, 10); + verify(createRes.success); + var sheetId = createRes.id; + verify(sheetId > 0); + + var details = TimesheetModel.getTimeSheetDetails(sheetId, 999); + verify(details !== null); + compare(details.id, sheetId); + compare(details.instance_id, 999); + compare(details.user_id, 10); + compare(details.status, "draft"); + } + + function test_createTimesheet_invalidAccount() { + var res = TimesheetModel.createTimesheet(-1, 10); + verify(!res.success); + } + + // --- 2. Readiness and Finalization --- + + function test_readinessChecks() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + // Neither project nor task + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, status) VALUES (?, ?, ?, ?, ?)", + [90010, 999, null, null, "draft"] + ); + // Project only + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, status) VALUES (?, ?, ?, ?, ?)", + [90011, 999, 101, null, "draft"] + ); + // Project and task + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, status) VALUES (?, ?, ?, ?, ?)", + [90012, 999, 101, 201, "draft"] + ); + }); + + // Timer ready requires project only + verify(!TimesheetModel.isTimesheetReadyToStartTimer(90010)); + verify(TimesheetModel.isTimesheetReadyToStartTimer(90011)); + verify(TimesheetModel.isTimesheetReadyToStartTimer(90012)); + + // Record/sync ready requires both project and task + verify(!TimesheetModel.isTimesheetReadyToRecord(90010)); + verify(!TimesheetModel.isTimesheetReadyToRecord(90011)); + verify(TimesheetModel.isTimesheetReadyToRecord(90012)); + } + + function test_isTimesheetFinalized() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("INSERT INTO account_analytic_line_app (id, account_id, status) VALUES (?, ?, ?)", [90020, 999, "draft"]); + tx.executeSql("INSERT INTO account_analytic_line_app (id, account_id, status) VALUES (?, ?, ?)", [90021, 999, "active"]); + tx.executeSql("INSERT INTO account_analytic_line_app (id, account_id, status) VALUES (?, ?, ?)", [90022, 999, "updated"]); + tx.executeSql("INSERT INTO account_analytic_line_app (id, account_id, status) VALUES (?, ?, ?)", [90023, 999, "saved"]); + }); + + verify(!TimesheetModel.isTimesheetFinalized(90020)); + verify(!TimesheetModel.isTimesheetFinalized(90021)); + verify(TimesheetModel.isTimesheetFinalized(90022)); + verify(TimesheetModel.isTimesheetFinalized(90023)); + } + + // --- 3. Save, Duration, and Deletion --- + + function test_saveTimesheet() { + var createRes = TimesheetModel.createTimesheet(999, 10); + verify(createRes.success); + var sheetId = createRes.id; + + var updateData = { + id: sheetId, + instance_id: 999, + description: "Work on QML Unit Tests", + project: 101, + task: 201, + unit_amount: 2.5, + status: "draft" + }; + + var saveRes = TimesheetModel.saveTimesheet(updateData); + verify(saveRes.success); + + var details = TimesheetModel.getTimeSheetDetails(sheetId, 999); + compare(details.name, "Work on QML Unit Tests"); + compare(details.project_id, 101); + compare(details.task_id, 201); + compare(details.spentHours, "2:30"); + } + + function test_updateDuration_and_getUnitAmount() { + var createRes = TimesheetModel.createTimesheet(999, 10); + var sheetId = createRes.id; + + TimesheetModel.updateTimesheetWithDuration(sheetId, "01:45"); + var amount = TimesheetModel.getTimesheetUnitAmount(sheetId); + compare(amount, 1.75); + } + + function test_markTimesheetAsDeleted() { + var createRes = TimesheetModel.createTimesheet(999, 10); + var sheetId = createRes.id; + + var res = TimesheetModel.markTimesheetAsDeleted(sheetId); + verify(res.success); + + var details = TimesheetModel.getTimeSheetDetails(sheetId, 999); + compare(details.status, "deleted"); + } + + // --- 4. Status Transitions --- + + function test_statusTransitions() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, status) VALUES (?, ?, ?, ?, ?)", + [90030, 999, 101, 201, "draft"] + ); + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, status) VALUES (?, ?, ?, ?, ?)", + [90031, 999, 101, 201, "draft"] + ); + }); + + // Mark 90030 as active + TimesheetModel.markTimesheetAsActiveById(90030); + var d1 = TimesheetModel.getTimeSheetDetails(90030, 999); + compare(d1.status, "active"); + + // Mark 90031 as active -> 90030 should revert to draft + TimesheetModel.markTimesheetAsActiveById(90031); + var d1Reverted = TimesheetModel.getTimeSheetDetails(90030, 999); + var d2Active = TimesheetModel.getTimeSheetDetails(90031, 999); + compare(d1Reverted.status, "draft"); + compare(d2Active.status, "active"); + + // Mark 90030 as saved + TimesheetModel.markTimesheetAsSavedById(90030); + var d1Saved = TimesheetModel.getTimeSheetDetails(90030, 999); + compare(d1Saved.status, "saved"); + + // Mark 90030 as draft + TimesheetModel.markTimesheetAsDraftById(90030); + var d1Draft = TimesheetModel.getTimeSheetDetails(90030, 999); + compare(d1Draft.status, "draft"); + + // Mark 90030 as ready (with project and task present) + var readyRes = TimesheetModel.markTimesheetAsReadyById(90030); + verify(readyRes.success); + var d1Ready = TimesheetModel.getTimeSheetDetails(90030, 999); + compare(d1Ready.status, "updated"); + } + + // --- 5. Display Names and Matching --- + + function test_getTimesheetDisplayName_and_Matching() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("INSERT INTO project_project_app (id, odoo_record_id, name, account_id) VALUES (?, ?, ?, ?)", [90101, 90101, "Test Project", 999]); + tx.executeSql("INSERT INTO project_task_app (id, odoo_record_id, name, account_id) VALUES (?, ?, ?, ?)", [90201, 90201, "Test Task", 999]); + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, name, status) VALUES (?, ?, ?, ?, ?, ?)", + [90040, 999, 90101, 90201, "Custom Work Description", "active"] + ); + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, project_id, task_id, name, status) VALUES (?, ?, ?, ?, ?, ?)", + [90041, 999, 90101, 90201, "", "draft"] + ); + }); + + // When name is present, display name is the description + var name1 = TimesheetModel.getTimesheetDisplayName(90040); + compare(name1, "Custom Work Description"); + + // When name is empty, display name falls back to "Project - Task" + var name2 = TimesheetModel.getTimesheetDisplayName(90041); + compare(name2, "Test Project - Test Task"); + + // Matching active sheet + verify(TimesheetModel.doesProjectIdMatchSheetInActive(90101, 90040)); + verify(!TimesheetModel.doesProjectIdMatchSheetInActive(99999, 90040)); + + verify(TimesheetModel.doesTaskIdMatchSheetInActive(90201, 90040)); + verify(!TimesheetModel.doesTaskIdMatchSheetInActive(99999, 90040)); + + // getTimesheetAccountId + compare(TimesheetModel.getTimesheetAccountId(90040), 999); + } + + // --- 6. Fetching by Status --- + + function test_fetchTimesheetsByStatus() { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, status, record_date) VALUES (?, ?, ?, ?)", + [90051, 999, "draft", "2026-10-13"] + ); + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, status, record_date) VALUES (?, ?, ?, ?)", + [90052, 999, "saved", "2026-10-13"] + ); + tx.executeSql( + "INSERT INTO account_analytic_line_app (id, account_id, status, record_date) VALUES (?, ?, ?, ?)", + [90053, 999, "deleted", "2026-10-13"] + ); + }); + + var drafts = TimesheetModel.fetchTimesheetsByStatus("draft", 999); + // In fetchTimesheetsByStatus, status === "draft" matches both "draft" and "saved" + compare(drafts.length, 2); + + var all = TimesheetModel.fetchTimesheetsByStatus("all", 999); + // "all" returns non-deleted records + compare(all.length, 2); + } + } +} From d3fe645ef5a677d7866359b744b27c110bdd72ba Mon Sep 17 00:00:00 2001 From: Parvathy Nair Date: Tue, 13 Oct 2026 16:38:47 +0530 Subject: [PATCH 26/34] Unit tests for QML Pickers and Selectors --- tests/qml/README.md | 15 +- tests/qml/tst_assigneefiltermenu.qml | 401 ++++++--------------- tests/qml/tst_daterangeheaderfilter.qml | 121 +++++++ tests/qml/tst_daterangeselector.qml | 126 +++++++ tests/qml/tst_dayselector.qml | 133 +++++++ tests/qml/tst_filterableheadercontents.qml | 126 +++++++ tests/qml/tst_inlineoptionselector.qml | 137 +++++++ tests/qml/tst_multiassigneeselector.qml | 140 +++++++ tests/qml/tst_projectstageselector.qml | 124 +++++++ tests/qml/tst_selectionbutton.qml | 126 +++++++ tests/qml/tst_stagefiltermenu.qml | 150 ++++++++ tests/qml/tst_statusselector.qml | 114 ++++++ tests/qml/tst_workitemselector.qml | 232 ++++++++++++ 13 files changed, 1656 insertions(+), 289 deletions(-) create mode 100644 tests/qml/tst_daterangeheaderfilter.qml create mode 100644 tests/qml/tst_daterangeselector.qml create mode 100644 tests/qml/tst_dayselector.qml create mode 100644 tests/qml/tst_filterableheadercontents.qml create mode 100644 tests/qml/tst_inlineoptionselector.qml create mode 100644 tests/qml/tst_multiassigneeselector.qml create mode 100644 tests/qml/tst_projectstageselector.qml create mode 100644 tests/qml/tst_selectionbutton.qml create mode 100644 tests/qml/tst_stagefiltermenu.qml create mode 100644 tests/qml/tst_statusselector.qml create mode 100644 tests/qml/tst_workitemselector.qml diff --git a/tests/qml/README.md b/tests/qml/README.md index 60c4f4c0..dd6a5f59 100644 --- a/tests/qml/README.md +++ b/tests/qml/README.md @@ -29,25 +29,36 @@ tests/ │ │ ├── tst_timer_service.qml # Test suite for timer service state transitions and duration │ │ └── tst_utils.qml # Test suite for date, time, duration, and URL utilities │ ├── tst_accountselectordialog.qml # Test suite for AccountSelectorDialog picker and account switch +│ ├── tst_assigneefiltermenu.qml # Test suite for AssigneeFilterMenu assignee filter menu toggling │ ├── tst_colorpicker.qml # Test suite for ColorPicker palette and custom color │ ├── tst_contentpickerdialog.qml # Test suite for ContentPickerDialog single/multi selection -│ ├── tst_assigneefiltermenu.qml # Test suite for AssigneeFilterMenu selector component │ ├── tst_customdatepicker.qml # Test suite for CustomDatePicker picker component +│ ├── tst_daterangeheaderfilter.qml # Test suite for DateRangeHeaderFilter filter toggle and active range text +│ ├── tst_daterangeselector.qml # Test suite for DateRangeSelector date bounds and range pick +│ ├── tst_dayselector.qml # Test suite for DaySelector day selection signals +│ ├── tst_filterableheadercontents.qml # Test suite for FilterableHeaderContents filter state integration │ ├── tst_globaltimerwidget.qml # Test suite for GlobalTimerWidget system component │ ├── tst_infobar.qml # Test suite for InfoBar feedback component +│ ├── tst_inlineoptionselector.qml # Test suite for InlineOptionSelector radio-style option selection │ ├── tst_loadingindicator.qml # Test suite for LoadingIndicator running state and visibility │ ├── tst_loadmorefooter.qml # Test suite for LoadMoreFooter loading state and retry trigger +│ ├── tst_multiassigneeselector.qml # Test suite for MultiAssigneeSelector multi-select IDs array output │ ├── tst_notificationbell.qml # Test suite for NotificationBell unread counter badge and navigation │ ├── tst_notificationpopup.qml # Test suite for NotificationPopup message, dismiss, and timeout │ ├── tst_outlinedtextfield.qml # Test suite for OutlinedTextField text input component +│ ├── tst_projectstageselector.qml # Test suite for ProjectStageSelector stage list per project │ ├── tst_savediscarddialog.qml # Test suite for SaveDiscardDialog save, discard, and cancel signals +│ ├── tst_selectionbutton.qml # Test suite for SelectionButton selection state and click +│ ├── tst_stagefiltermenu.qml # Test suite for StageFilterMenu stage filter menu toggling +│ ├── tst_statusselector.qml # Test suite for StatusSelector status changes and badge color │ ├── tst_timepickerpopup.qml # Test suite for TimePickerPopup time selection and confirmation │ ├── tst_tsbutton.qml # Test suite for TSButton action button component │ ├── tst_tsiconbutton.qml # Test suite for TSIconButton circular icon button component │ ├── tst_tslabel.qml # Test suite for TSLabel styled text component │ ├── tst_tsprogressbar.qml # Test suite for TSProgressbar progress indicator │ ├── tst_tssearchbar.qml # Test suite for TSSearchBar search input component -│ └── tst_tsswitch.qml # Test suite for TSSwitch toggle switch component +│ ├── tst_tsswitch.qml # Test suite for TSSwitch toggle switch component +│ └── tst_workitemselector.qml # Test suite for WorkItemSelector project and task selection logic ``` Clickable automatically discovers all files matching `tst_*.qml` under `tests/qml/` as configured in `clickable.yaml`. diff --git a/tests/qml/tst_assigneefiltermenu.qml b/tests/qml/tst_assigneefiltermenu.qml index 946b3484..f60e2f2e 100644 --- a/tests/qml/tst_assigneefiltermenu.qml +++ b/tests/qml/tst_assigneefiltermenu.qml @@ -1,50 +1,26 @@ -/* - * MIT License - * - * Copyright (c) 2025 CIT-Services - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - import QtQuick 2.7 import QtTest 1.0 import Lomiri.Components 1.3 import "../../qml/components/selectors" -Item { +MainView { id: testRoot - width: units.gu(40) + width: units.gu(50) height: units.gu(70) AssigneeFilterMenu { - id: testMenu + id: testAssigneeMenu } SignalSpy { id: filterAppliedSpy - target: testMenu + target: testAssigneeMenu signalName: "filterApplied" } SignalSpy { id: filterClearedSpy - target: testMenu + target: testAssigneeMenu signalName: "filterCleared" } @@ -52,296 +28,147 @@ Item { name: "AssigneeFilterMenuTests" when: windowShown + readonly property var sampleAssignees: [ + { id: 101, name: "Alice Adams", email: "alice@test.com", account_id: 1, account_name: "Acme Corp" }, + { id: 102, name: "Bob Brown", email: "bob@test.com", account_id: 1, account_name: "Acme Corp" }, + { id: 103, name: "Charlie Clark", email: "charlie@test.com", account_id: 2, account_name: "Beta Inc" } + ] + function init() { - testMenu.expanded = false; - testMenu.showAllUsersOption = false; - testMenu.showAccountName = false; - testMenu.assigneeModel = []; - testMenu.selectedAssigneeIds = []; + testAssigneeMenu.expanded = false; + testAssigneeMenu.showAccountName = false; + testAssigneeMenu.showAllUsersOption = false; + testAssigneeMenu.selectedAssigneeIds = []; + testAssigneeMenu.assigneeModel = []; filterAppliedSpy.clear(); filterClearedSpy.clear(); } - function test_defaultProperties() { - compare(testMenu.showAllUsersOption, false); - compare(testMenu.expanded, false); - compare(testMenu.showAccountName, false); - compare(testMenu.selectedAssigneeIds.length, 0); + function cleanup() { + filterAppliedSpy.clear(); + filterClearedSpy.clear(); } - function test_showAllUsersOption_initialState() { - testMenu.showAllUsersOption = true; - testMenu.selectedAssigneeIds = []; - verify(!testMenu.isOnlyAllUsersSelected()); - - testMenu.selectAllUsers(); - compare(testMenu.selectedAssigneeIds.length, 1); - compare(testMenu.selectedAssigneeIds[0].user_id, -1); - verify(testMenu.isOnlyAllUsersSelected()); + function test_initialProperties() { + compare(testAssigneeMenu.expanded, false); + compare(testAssigneeMenu.showAccountName, false); + compare(testAssigneeMenu.showAllUsersOption, false); + compare(testAssigneeMenu.selectedAssigneeIds.length, 0); + compare(testAssigneeMenu.assigneeModel.length, 0); + compare(filterAppliedSpy.count, 0); + compare(filterClearedSpy.count, 0); } - function test_buildDisplayAssignees_pinsAllUsersAtTop() { - testMenu.showAllUsersOption = true; - testMenu.assigneeModel = [ - { id: 2, name: "Bob", account_id: 1, email: "bob@example.com" }, - { id: 1, name: "Alice", account_id: 1, email: "alice@example.com" } - ]; - - var list = testMenu.buildDisplayAssignees(""); - compare(list.length, 3); - // First item is "All Users" - compare(list[0].assigneeId, -1); - compare(list[0].name, i18n.dtr("ubtms", "All Users")); - // Subsequent items sorted alphabetically - compare(list[1].name, "Alice"); - compare(list[2].name, "Bob"); - } + function test_expandedToggle() { + testAssigneeMenu.expanded = true; + compare(testAssigneeMenu.expanded, true); - function test_searchIntegration_filterByName() { - testMenu.showAllUsersOption = true; - testMenu.assigneeModel = [ - { id: 1, name: "Alice Smith", account_id: 1, email: "alice@example.com" }, - { id: 2, name: "Bob Jones", account_id: 1, email: "bob@example.com" } - ]; - - // Search by name "alice" - var resultsAlice = testMenu.buildDisplayAssignees("alice"); - compare(resultsAlice.length, 1); - compare(resultsAlice[0].name, "Alice Smith"); - - // Search by name "bob" - var resultsBob = testMenu.buildDisplayAssignees("bob"); - compare(resultsBob.length, 1); - compare(resultsBob[0].name, "Bob Jones"); - - // Empty query returns all (All Users + 2 employees) - var resultsAll = testMenu.buildDisplayAssignees(""); - compare(resultsAll.length, 3); + testAssigneeMenu.expanded = false; + compare(testAssigneeMenu.expanded, false); } - function test_selectionExclusivity_specificUserClearsAllUsers() { - testMenu.showAllUsersOption = true; - testMenu.selectAllUsers(); - verify(testMenu.isOnlyAllUsersSelected()); - - // Select a specific user - testMenu.addSelectionIfMissing(testMenu.createSelection(10, 1)); - verify(!testMenu.isOnlyAllUsersSelected()); - compare(testMenu.selectedAssigneeIds.length, 1); - compare(testMenu.selectedAssigneeIds[0].user_id, 10); - verify(testMenu.isAssigneeSelected(10, 1)); - verify(!testMenu.isAssigneeSelected(-1, -1)); + function test_createSelectionAndIsAssigneeSelected() { + var sel = testAssigneeMenu.createSelection(101, 1); + compare(sel.user_id, 101); + compare(sel.account_id, 1); + + testAssigneeMenu.selectedAssigneeIds = [sel]; + verify(testAssigneeMenu.isAssigneeSelected(101, 1)); + verify(!testAssigneeMenu.isAssigneeSelected(101, 2)); + verify(!testAssigneeMenu.isAssigneeSelected(102, 1)); } - function test_multiSelection_multipleUsers() { - testMenu.showAllUsersOption = true; - testMenu.addSelectionIfMissing(testMenu.createSelection(10, 1)); - testMenu.addSelectionIfMissing(testMenu.createSelection(20, 1)); + function test_addSelectionIfMissingAndRemove() { + var sel1 = testAssigneeMenu.createSelection(101, 1); + var sel2 = testAssigneeMenu.createSelection(102, 1); - compare(testMenu.selectedAssigneeIds.length, 2); - verify(testMenu.isAssigneeSelected(10, 1)); - verify(testMenu.isAssigneeSelected(20, 1)); - verify(!testMenu.isOnlyAllUsersSelected()); - } + testAssigneeMenu.addSelectionIfMissing(sel1); + compare(testAssigneeMenu.selectedAssigneeIds.length, 1); + + // Adding same should be a no-op + testAssigneeMenu.addSelectionIfMissing(sel1); + compare(testAssigneeMenu.selectedAssigneeIds.length, 1); - function test_removeSelection_leavesEmptyWhenLastUserRemoved() { - testMenu.showAllUsersOption = true; - testMenu.addSelectionIfMissing(testMenu.createSelection(10, 1)); - compare(testMenu.selectedAssigneeIds.length, 1); + testAssigneeMenu.addSelectionIfMissing(sel2); + compare(testAssigneeMenu.selectedAssigneeIds.length, 2); - // Removing the last selected user leaves selection empty (no auto-check of All Users) - testMenu.removeSelectionIfPresent(testMenu.createSelection(10, 1)); - compare(testMenu.selectedAssigneeIds.length, 0); - verify(!testMenu.isOnlyAllUsersSelected()); - verify(!testMenu.isAssigneeSelected(-1, -1)); + testAssigneeMenu.removeSelectionIfPresent(sel1); + compare(testAssigneeMenu.selectedAssigneeIds.length, 1); + compare(testAssigneeMenu.selectedAssigneeIds[0].user_id, 102); } - function test_removeSelection_allAccountsMode() { - testMenu.showAllUsersOption = true; - // Case 1: Existing selection has account_id: -1 (All Accounts mode), target has account_id: 1 - testMenu.selectedAssigneeIds = [testMenu.createSelection(10, -1)]; - compare(testMenu.selectedAssigneeIds.length, 1); - verify(testMenu.isAssigneeSelected(10, 1)); - testMenu.removeSelectionIfPresent(testMenu.createSelection(10, 1)); - compare(testMenu.selectedAssigneeIds.length, 0); - - // Case 2: Existing selection has account_id: 1, target has account_id: -1 - testMenu.selectedAssigneeIds = [testMenu.createSelection(10, 1)]; - compare(testMenu.selectedAssigneeIds.length, 1); - testMenu.removeSelectionIfPresent(testMenu.createSelection(10, -1)); - compare(testMenu.selectedAssigneeIds.length, 0); + function test_selectAllUsersOption() { + testAssigneeMenu.showAllUsersOption = true; + testAssigneeMenu.selectAllUsers(); + + compare(testAssigneeMenu.selectedAssigneeIds.length, 1); + verify(testAssigneeMenu.isOnlyAllUsersSelected()); + + // Adding a specific user should clear the "All Users" (-1) selection + var sel = testAssigneeMenu.createSelection(101, 1); + testAssigneeMenu.addSelectionIfMissing(sel); + + verify(!testAssigneeMenu.isOnlyAllUsersSelected()); + compare(testAssigneeMenu.selectedAssigneeIds.length, 1); + compare(testAssigneeMenu.selectedAssigneeIds[0].user_id, 101); } - function test_noUserSelected_doesNotSelectAllUsers() { - testMenu.showAllUsersOption = true; - testMenu.selectedAssigneeIds = []; - verify(!testMenu.isOnlyAllUsersSelected()); - verify(!testMenu.isAssigneeSelected(-1, -1)); - compare(testMenu.selectedAssigneeIds.length, 0); + function test_displayAssigneeCacheAndCounts() { + testAssigneeMenu.assigneeModel = sampleAssignees; + compare(testAssigneeMenu.getDisplayAssigneeCount(), 3); + compare(testAssigneeMenu.getSelectedDisplayAssigneeCount(), 0); + + testAssigneeMenu.addSelectionIfMissing(testAssigneeMenu.createSelection(101, 1)); + testAssigneeMenu.recomputeDisplayAssigneeCache(); + compare(testAssigneeMenu.getSelectedDisplayAssigneeCount(), 1); + + testAssigneeMenu.addSelectionIfMissing(testAssigneeMenu.createSelection(102, 1)); + testAssigneeMenu.recomputeDisplayAssigneeCache(); + compare(testAssigneeMenu.getSelectedDisplayAssigneeCount(), 2); } - function test_filterAppliedSignal() { - var selections = [testMenu.createSelection(10, 1), testMenu.createSelection(20, 1)]; - testMenu.filterApplied(selections); + function test_filterAppliedSignalEmission() { + testAssigneeMenu.assigneeModel = sampleAssignees; + testAssigneeMenu.addSelectionIfMissing(testAssigneeMenu.createSelection(101, 1)); + testAssigneeMenu.expanded = true; + + compare(filterAppliedSpy.count, 0); + testAssigneeMenu.filterApplied(testAssigneeMenu.selectedAssigneeIds.slice()); + compare(filterAppliedSpy.count, 1); - compare(filterAppliedSpy.signalArguments[0][0].length, 2); + var applied = filterAppliedSpy.signalArguments[0][0]; + compare(applied.length, 1); + compare(applied[0].user_id, 101); + compare(applied[0].account_id, 1); } - function test_filterClearedSignal() { - testMenu.filterCleared(); - compare(filterClearedSpy.count, 1); - } + function test_clearFilterFunction() { + testAssigneeMenu.showAllUsersOption = false; + testAssigneeMenu.assigneeModel = sampleAssignees; + testAssigneeMenu.addSelectionIfMissing(testAssigneeMenu.createSelection(101, 1)); + testAssigneeMenu.expanded = true; - function test_clearFilter_withoutShowAllUsersOption() { - testMenu.showAllUsersOption = false; - testMenu.selectedAssigneeIds = [testMenu.createSelection(10, 1)]; - testMenu.expanded = true; - testMenu.clearFilter(); - compare(filterClearedSpy.count, 1); - compare(testMenu.selectedAssigneeIds.length, 0); - compare(testMenu.expanded, false); - } + compare(testAssigneeMenu.selectedAssigneeIds.length, 1); + compare(filterClearedSpy.count, 0); - function test_clearFilter_withShowAllUsersOption() { - testMenu.showAllUsersOption = true; - testMenu.selectedAssigneeIds = [testMenu.createSelection(10, 1)]; - testMenu.expanded = true; - testMenu.clearFilter(); - compare(filterClearedSpy.count, 1); - verify(testMenu.isOnlyAllUsersSelected()); - compare(testMenu.selectedAssigneeIds.length, 1); - compare(testMenu.selectedAssigneeIds[0].user_id, -1); - compare(testMenu.expanded, false); - } + testAssigneeMenu.clearFilter(); - function test_clearFilter_withShowAllUsersOption_emptySelection() { - testMenu.showAllUsersOption = true; - testMenu.selectedAssigneeIds = []; - testMenu.expanded = true; - testMenu.clearFilter(); + compare(testAssigneeMenu.selectedAssigneeIds.length, 0); + compare(testAssigneeMenu.expanded, false); compare(filterClearedSpy.count, 1); - verify(testMenu.isOnlyAllUsersSelected()); - compare(testMenu.selectedAssigneeIds.length, 1); - compare(testMenu.selectedAssigneeIds[0].user_id, -1); - compare(testMenu.expanded, false); } - function test_delegateLayoutAndClick() { - testMenu.showAllUsersOption = true; - testMenu.assigneeModel = [ - { id: 10, name: "Alice", account_id: 1, email: "alice@example.com" }, - { id: 20, name: "Bob", account_id: 1, email: "bob@example.com" } - ]; - testMenu.expanded = true; - wait(300); - - var listView = null; - function findListView(item) { - if (!item) return; - if (item.toString().indexOf("QQuickListView") !== -1) { - listView = item; - return; - } - for (var i = 0; i < (item.children ? item.children.length : 0); i++) { - findListView(item.children[i]); - if (listView) return; - } - } - findListView(testMenu); - verify(listView !== null); - - var ci = listView.contentItem; - - - function findItemWithText(rootItem, targetText) { - if (!rootItem || !rootItem.children) return null; - for (var i = 0; i < rootItem.children.length; i++) { - var ch = rootItem.children[i]; - if (ch.text && ch.text.indexOf(targetText) !== -1) return ch; - var res = findItemWithText(ch, targetText); - if (res) return res; - } - return null; - } - - // 1. Click on employee name label selects the user - var aliceLabel = findItemWithText(listView.contentItem, "Alice"); - verify(aliceLabel !== null, "Alice label should be found"); - testMenu.selectedAssigneeIds = []; - mouseClick(aliceLabel, 5, 5); - wait(100); - compare(testMenu.selectedAssigneeIds.length, 1); - verify(testMenu.isAssigneeSelected(10, 1)); - - // 2. Query fresh aliceLabel and click again to toggle selection off - aliceLabel = findItemWithText(listView.contentItem, "Alice"); - verify(aliceLabel !== null, "Alice label should be found after re-render"); - mouseClick(aliceLabel, 5, 5); - wait(100); - compare(testMenu.selectedAssigneeIds.length, 0); - - // 3. Query fresh bobEmail and click to select Bob - var bobEmail = findItemWithText(listView.contentItem, "bob@example.com"); - verify(bobEmail !== null, "Bob email should be found"); - mouseClick(bobEmail, 5, 5); - wait(100); - compare(testMenu.selectedAssigneeIds.length, 1); - verify(testMenu.isAssigneeSelected(20, 1)); - - // 4. Query fresh allUsersLabel and click to select All Users - var allUsersLabel = findItemWithText(listView.contentItem, "All Users"); - verify(allUsersLabel !== null, "All Users label should be found"); - mouseClick(allUsersLabel, 5, 5); - wait(100); - verify(testMenu.isOnlyAllUsersSelected()); - - // 5. Query fresh Alice delegate and click directly on its checkbox - testMenu.selectedAssigneeIds = []; - wait(50); - aliceLabel = findItemWithText(listView.contentItem, "Alice"); - verify(aliceLabel !== null, "Alice label should be found"); - var delegateItem = aliceLabel.parent; - while (delegateItem && delegateItem.itemAssigneeId === undefined) { - delegateItem = delegateItem.parent; - } - verify(delegateItem !== null, "Delegate root should be found"); - - var aliceCheckbox = null; - for (var c = 0; c < delegateItem.children.length; c++) { - var ch = delegateItem.children[c]; - if (ch && (ch.hasOwnProperty("checked") || ch.checked !== undefined)) { - aliceCheckbox = ch; - break; - } - } - verify(aliceCheckbox !== null, "Alice checkbox should be found"); - - // Direct click on checkbox selects Alice - mouseClick(aliceCheckbox, 5, 5); - wait(100); - compare(testMenu.selectedAssigneeIds.length, 1); - verify(testMenu.isAssigneeSelected(10, 1)); - - // Direct click on checkbox deselects Alice (re-query after re-render) - aliceLabel = findItemWithText(listView.contentItem, "Alice"); - verify(aliceLabel !== null, "Alice label should be found after re-render"); - delegateItem = aliceLabel.parent; - while (delegateItem && delegateItem.itemAssigneeId === undefined) { - delegateItem = delegateItem.parent; - } - verify(delegateItem !== null, "Delegate root should be found after re-render"); - aliceCheckbox = null; - for (var c2 = 0; c2 < delegateItem.children.length; c2++) { - var ch2 = delegateItem.children[c2]; - if (ch2 && (ch2.hasOwnProperty("checked") || ch2.checked !== undefined)) { - aliceCheckbox = ch2; - break; - } - } - verify(aliceCheckbox !== null, "Alice checkbox should be found after re-render"); - mouseClick(aliceCheckbox, 5, 5); - wait(100); - compare(testMenu.selectedAssigneeIds.length, 0); + function test_clearFilterWithAllUsersOption() { + testAssigneeMenu.showAllUsersOption = true; + testAssigneeMenu.assigneeModel = sampleAssignees; + testAssigneeMenu.addSelectionIfMissing(testAssigneeMenu.createSelection(101, 1)); + testAssigneeMenu.expanded = true; + + testAssigneeMenu.clearFilter(); + + verify(testAssigneeMenu.isOnlyAllUsersSelected()); + compare(testAssigneeMenu.expanded, false); + compare(filterClearedSpy.count, 1); } } } diff --git a/tests/qml/tst_daterangeheaderfilter.qml b/tests/qml/tst_daterangeheaderfilter.qml new file mode 100644 index 00000000..7b07e6af --- /dev/null +++ b/tests/qml/tst_daterangeheaderfilter.qml @@ -0,0 +1,121 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/pickers" + +MainView { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + DateRangeHeaderFilter { + id: testHeaderFilter + width: testRoot.width + } + + SignalSpy { + id: dateRangeChangedSpy + target: testHeaderFilter + signalName: "dateRangeChanged" + } + + TestCase { + name: "DateRangeHeaderFilterTests" + when: windowShown + + function init() { + testHeaderFilter.clearFilter(); + testHeaderFilter.showClearButton = true; + dateRangeChangedSpy.clear(); + } + + function cleanup() { + testHeaderFilter.clearFilter(); + dateRangeChangedSpy.clear(); + } + + function test_initialProperties() { + compare(testHeaderFilter.presetId, -1); + compare(testHeaderFilter.startDate, ""); + compare(testHeaderFilter.endDate, ""); + compare(testHeaderFilter.isFiltered, false); + compare(testHeaderFilter.presetLabel, "No Filter (All Time)"); + compare(testHeaderFilter.showClearButton, true); + compare(dateRangeChangedSpy.count, 0); + } + + function test_calculateDatesToday() { + var dates = testHeaderFilter.calculateDates(0); // Today + var todayStr = Qt.formatDate(new Date(), "yyyy-MM-dd"); + compare(dates.start, todayStr); + compare(dates.end, todayStr); + } + + function test_calculateDatesThisMonth() { + var dates = testHeaderFilter.calculateDates(2); // This Month + var now = new Date(); + var expectedMonthPrefix = Qt.formatDate(now, "yyyy-MM"); + verify(dates.start.indexOf(expectedMonthPrefix + "-01") === 0); + verify(dates.end.indexOf(expectedMonthPrefix) === 0); + } + + function test_calculateDatesThisYear() { + var dates = testHeaderFilter.calculateDates(4); // This Year + var y = new Date().getFullYear(); + compare(dates.start, y + "-01-01"); + compare(dates.end, y + "-12-31"); + } + + function test_setFilterStateUpdatesPropertiesAndEmitsSignal() { + testHeaderFilter.setFilterState(1, "2026-10-12", "2026-10-18", "This Week"); + + compare(testHeaderFilter.presetId, 1); + compare(testHeaderFilter.startDate, "2026-10-12"); + compare(testHeaderFilter.endDate, "2026-10-18"); + compare(testHeaderFilter.presetLabel, "This Week"); + compare(testHeaderFilter.isFiltered, true); + + compare(dateRangeChangedSpy.count, 1); + var args = dateRangeChangedSpy.signalArguments[0]; + compare(args[0], 1); + compare(args[1], "2026-10-12"); + compare(args[2], "2026-10-18"); + compare(args[3], "This Week"); + } + + function test_applyPresetTodayTogglesFilterAndRangeText() { + testHeaderFilter.applyPreset(0, "Today"); + + compare(testHeaderFilter.isFiltered, true); + compare(testHeaderFilter.presetId, 0); + compare(testHeaderFilter.presetLabel, "Today"); + verify(testHeaderFilter.startDate !== ""); + verify(testHeaderFilter.endDate !== ""); + compare(dateRangeChangedSpy.count, 1); + } + + function test_clearFilterResetsActiveFilterAndRangeText() { + testHeaderFilter.applyPreset(0, "Today"); + compare(testHeaderFilter.isFiltered, true); + dateRangeChangedSpy.clear(); + + testHeaderFilter.clearFilter(); + + compare(testHeaderFilter.isFiltered, false); + compare(testHeaderFilter.presetId, -1); + compare(testHeaderFilter.startDate, ""); + compare(testHeaderFilter.endDate, ""); + compare(testHeaderFilter.presetLabel, "No Filter (All Time)"); + compare(dateRangeChangedSpy.count, 1); + compare(dateRangeChangedSpy.signalArguments[0][0], -1); + } + + function test_showClearButtonToggling() { + testHeaderFilter.showClearButton = false; + compare(testHeaderFilter.showClearButton, false); + + testHeaderFilter.showClearButton = true; + compare(testHeaderFilter.showClearButton, true); + } + } +} diff --git a/tests/qml/tst_daterangeselector.qml b/tests/qml/tst_daterangeselector.qml new file mode 100644 index 00000000..ec76b7ab --- /dev/null +++ b/tests/qml/tst_daterangeselector.qml @@ -0,0 +1,126 @@ +import QtQuick 2.12 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/pickers" + +MainView { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + DateRangeSelector { + id: testDateRangeSelector + width: testRoot.width + } + + SignalSpy { + id: rangeChangedSpy + target: testDateRangeSelector + signalName: "rangeChanged" + } + + TestCase { + name: "DateRangeSelectorTests" + when: windowShown + + function init() { + testDateRangeSelector.readOnly = false; + rangeChangedSpy.clear(); + } + + function cleanup() { + testDateRangeSelector.readOnly = false; + rangeChangedSpy.clear(); + } + + function test_initialProperties() { + verify(testDateRangeSelector.startDate instanceof Date); + verify(testDateRangeSelector.endDate instanceof Date); + compare(testDateRangeSelector.readOnly, false); + compare(testDateRangeSelector.isStartDateValid, true); + compare(testDateRangeSelector.isEndDateValid, true); + } + + function test_formattedDateStrings() { + var s = new Date(2026, 8, 15); // 2026-09-15 + var e = new Date(2026, 8, 20); // 2026-09-20 + testDateRangeSelector.setDateRange(s, e); + + compare(testDateRangeSelector.formattedStartDate(), "2026-09-15"); + compare(testDateRangeSelector.formattedEndDate(), "2026-09-20"); + } + + function test_setDateRangeWithDateObjects() { + var start = new Date(2026, 5, 1); + var end = new Date(2026, 5, 30); + rangeChangedSpy.clear(); + + testDateRangeSelector.setDateRange(start, end); + + compare(rangeChangedSpy.count, 1); + compare(testDateRangeSelector.isStartDateValid, true); + compare(testDateRangeSelector.isEndDateValid, true); + compare(testDateRangeSelector.startDate.getFullYear(), 2026); + compare(testDateRangeSelector.startDate.getMonth(), 5); + compare(testDateRangeSelector.startDate.getDate(), 1); + compare(testDateRangeSelector.endDate.getDate(), 30); + } + + function test_setDateRangeWithStringInputs() { + rangeChangedSpy.clear(); + testDateRangeSelector.setDateRange("2026-11-05", "2026-11-25"); + + compare(rangeChangedSpy.count, 1); + compare(testDateRangeSelector.isStartDateValid, true); + compare(testDateRangeSelector.isEndDateValid, true); + compare(testDateRangeSelector.formattedStartDate(), "2026-11-05"); + compare(testDateRangeSelector.formattedEndDate(), "2026-11-25"); + } + + function test_setDateRangeInvalidBoundsHandling() { + testDateRangeSelector.setDateRange("not-a-date", null); + + compare(testDateRangeSelector.isStartDateValid, false); + compare(testDateRangeSelector.isEndDateValid, false); + } + + function test_updateDatesPresetToday() { + var preset = testDateRangeSelector.children[0].children[0]; // InlineOptionSelector + verify(preset !== null); + + preset.selectedId = 0; // Today + rangeChangedSpy.clear(); + testDateRangeSelector.updateDates(); + + compare(rangeChangedSpy.count, 1); + compare(testDateRangeSelector.isStartDateValid, true); + compare(testDateRangeSelector.isEndDateValid, true); + var today = new Date(); + compare(testDateRangeSelector.startDate.getDate(), today.getDate()); + compare(testDateRangeSelector.endDate.getDate(), today.getDate()); + } + + function test_updateDatesPresetThisMonth() { + var preset = testDateRangeSelector.children[0].children[0]; + verify(preset !== null); + + preset.selectedId = 3; // This Month + rangeChangedSpy.clear(); + testDateRangeSelector.updateDates(); + + compare(rangeChangedSpy.count, 1); + var today = new Date(); + compare(testDateRangeSelector.startDate.getMonth(), today.getMonth()); + compare(testDateRangeSelector.endDate.getMonth(), today.getMonth()); + verify(testDateRangeSelector.endDate.getDate() >= 28); + } + + function test_readOnlyStateToggling() { + testDateRangeSelector.readOnly = true; + compare(testDateRangeSelector.readOnly, true); + + testDateRangeSelector.readOnly = false; + compare(testDateRangeSelector.readOnly, false); + } + } +} diff --git a/tests/qml/tst_dayselector.qml b/tests/qml/tst_dayselector.qml new file mode 100644 index 00000000..44d9ccb5 --- /dev/null +++ b/tests/qml/tst_dayselector.qml @@ -0,0 +1,133 @@ +import QtQuick 2.12 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/pickers" + +MainView { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + DaySelector { + id: testDaySelector + width: testRoot.width + } + + SignalSpy { + id: dateChangedSpy + target: testDaySelector + signalName: "dateChanged" + } + + TestCase { + name: "DaySelectorTests" + when: windowShown + + function init() { + testDaySelector.readOnly = false; + testDaySelector.showTomorrow = false; + dateChangedSpy.clear(); + } + + function cleanup() { + testDaySelector.readOnly = false; + testDaySelector.showTomorrow = false; + dateChangedSpy.clear(); + } + + function test_initialProperties() { + compare(testDaySelector.labelText, "Date"); + verify(testDaySelector.selectedDate instanceof Date); + compare(testDaySelector.showTomorrow, false); + compare(testDaySelector.readOnly, false); + compare(dateChangedSpy.count, 0); + } + + function test_formattedDate() { + var target = new Date(2026, 6, 25); + testDaySelector.setSelectedDate(target); + compare(testDaySelector.formattedDate(), "2026-07-25"); + } + + function test_relativeLabelYesterdayAndTomorrow() { + testDaySelector.showTomorrow = false; + compare(testDaySelector.getRelativeLabel(), "Yesterday"); + + testDaySelector.showTomorrow = true; + compare(testDaySelector.getRelativeLabel(), "Tomorrow"); + } + + function test_relativeDateCalculation() { + var base = new Date(2026, 5, 10); // June 10 + + testDaySelector.showTomorrow = false; + var yesterday = testDaySelector.getRelativeDate(base); + compare(yesterday.getDate(), 9); + + testDaySelector.showTomorrow = true; + var tomorrow = testDaySelector.getRelativeDate(base); + compare(tomorrow.getDate(), 11); + } + + function test_isSameDateHelper() { + var d1 = new Date(2026, 3, 15, 10, 30); + var d2 = new Date(2026, 3, 15, 18, 45); + var d3 = new Date(2026, 3, 16, 10, 30); + + verify(testDaySelector.isSameDate(d1, d2)); + verify(!testDaySelector.isSameDate(d1, d3)); + verify(!testDaySelector.isSameDate(null, d1)); + verify(!testDaySelector.isSameDate(d1, "invalid")); + } + + function test_setSelectedDateWithDateObjectEmitsSignal() { + var target = new Date(2026, 9, 31); + dateChangedSpy.clear(); + + testDaySelector.setSelectedDate(target); + + compare(dateChangedSpy.count, 1); + var emittedDate = dateChangedSpy.signalArguments[0][0]; + verify(testDaySelector.isSameDate(emittedDate, target)); + } + + function test_setSelectedDateWithStringInputEmitsSignal() { + dateChangedSpy.clear(); + testDaySelector.setSelectedDate("2026-12-25"); + + compare(dateChangedSpy.count, 1); + compare(testDaySelector.formattedDate(), "2026-12-25"); + } + + function test_setSelectedDateInvalidInputIgnored() { + dateChangedSpy.clear(); + testDaySelector.setSelectedDate("not-a-valid-date"); + + compare(dateChangedSpy.count, 0); + } + + function test_updateDateRelativeSelection() { + var inlineCombo = testDaySelector.children[0]; + verify(inlineCombo !== null); + + testDaySelector.showTomorrow = false; + inlineCombo.selectedId = 1; // Relative (Yesterday) + dateChangedSpy.clear(); + + testDaySelector.updateDate(); + + compare(dateChangedSpy.count, 1); + var expected = new Date(); + expected.setDate(expected.getDate() - 1); + verify(testDaySelector.isSameDate(testDaySelector.selectedDate, expected)); + } + + function test_readOnlyToggle() { + testDaySelector.readOnly = true; + compare(testDaySelector.readOnly, true); + + testDaySelector.readOnly = false; + compare(testDaySelector.readOnly, false); + } + } +} diff --git a/tests/qml/tst_filterableheadercontents.qml b/tests/qml/tst_filterableheadercontents.qml new file mode 100644 index 00000000..ad336554 --- /dev/null +++ b/tests/qml/tst_filterableheadercontents.qml @@ -0,0 +1,126 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/pickers" + +MainView { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + Item { + id: headerContainer + width: testRoot.width + height: units.gu(7) + + FilterableHeaderContents { + id: testFilterableHeader + } + } + + SignalSpy { + id: dateRangeChangedSpy + target: testFilterableHeader + signalName: "dateRangeChanged" + } + + TestCase { + name: "FilterableHeaderContentsTests" + when: windowShown + + function init() { + testFilterableHeader.showDateFilter = false; + testFilterableHeader.title = ""; + testFilterableHeader.showSubtitle = true; + testFilterableHeader.userLabel = ""; + dateRangeChangedSpy.clear(); + } + + function cleanup() { + testFilterableHeader.showDateFilter = false; + dateRangeChangedSpy.clear(); + } + + function test_initialProperties() { + compare(testFilterableHeader.showDateFilter, false); + compare(testFilterableHeader.title, ""); + compare(testFilterableHeader.showSubtitle, true); + compare(testFilterableHeader.userLabel, ""); + verify(testFilterableHeader.dateFilter !== null); + compare(dateRangeChangedSpy.count, 0); + } + + function test_titleAndSubtitleIntegration() { + testFilterableHeader.title = "Project Tasks"; + testFilterableHeader.userLabel = "Bob Smith"; + + compare(testFilterableHeader.title, "Project Tasks"); + compare(testFilterableHeader.userLabel, "Bob Smith"); + + var normalRow = testFilterableHeader.children[0]; + verify(normalRow.visible); + + var titleCol = normalRow.children[0]; + var titleLabel = titleCol.children[0]; + var subtitleLabel = titleCol.children[1]; + + compare(titleLabel.text, "Project Tasks"); + verify(subtitleLabel.text.indexOf("Bob Smith") !== -1); + verify(subtitleLabel.text.indexOf(" • ") !== -1); + } + + function test_subtitleWithoutUserLabel() { + testFilterableHeader.userLabel = ""; + + var normalRow = testFilterableHeader.children[0]; + var titleCol = normalRow.children[0]; + var subtitleLabel = titleCol.children[1]; + + compare(subtitleLabel.text, testFilterableHeader.dateFilter.presetLabel); + } + + function test_showDateFilterToggleSwitchesViews() { + var normalView = testFilterableHeader.children[0]; + var filterView = testFilterableHeader.children[1]; + + // Initially normal is visible, filter view hidden + compare(normalView.visible, true); + compare(filterView.visible, false); + + // Toggle showDateFilter = true + testFilterableHeader.showDateFilter = true; + compare(normalView.visible, false); + compare(filterView.visible, true); + + // Toggle back + testFilterableHeader.showDateFilter = false; + compare(normalView.visible, true); + compare(filterView.visible, false); + } + + function test_closeFilterButtonClickResetsState() { + testFilterableHeader.showDateFilter = true; + compare(testFilterableHeader.showDateFilter, true); + + var filterView = testFilterableHeader.children[1]; + var closeBtn = filterView.children[1]; // closeFilterBtn + var closeMouse = closeBtn.children[1]; // MouseArea + + mouseClick(closeMouse); + + compare(testFilterableHeader.showDateFilter, false); + } + + function test_dateFilterRangeChangedSignalIntegration() { + testFilterableHeader.showDateFilter = true; + dateRangeChangedSpy.clear(); + + // Simulate DateRangeHeaderFilter triggering range change + testFilterableHeader.dateFilter.applyPreset(0, "Today"); + + compare(dateRangeChangedSpy.count, 1); + // Once filter is applied, showDateFilter should automatically reset to false + compare(testFilterableHeader.showDateFilter, false); + } + } +} diff --git a/tests/qml/tst_inlineoptionselector.qml b/tests/qml/tst_inlineoptionselector.qml new file mode 100644 index 00000000..d7111426 --- /dev/null +++ b/tests/qml/tst_inlineoptionselector.qml @@ -0,0 +1,137 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/selectors" + +MainView { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + InlineOptionSelector { + id: testInlineSelector + width: testRoot.width + labelText: "Priority" + selectorType: "priority_level" + } + + SignalSpy { + id: selectionMadeSpy + target: testInlineSelector + signalName: "selectionMade" + } + + TestCase { + name: "InlineOptionSelectorTests" + when: windowShown + + readonly property var sampleOptions: [ + { id: 1, name: "Low" }, + { id: 2, name: "Medium" }, + { id: 3, name: "High" }, + { id: 4, name: "Urgent" } + ] + + function init() { + testInlineSelector.enabledState = true; + testInlineSelector.readOnly = false; + testInlineSelector.setData(sampleOptions); + testInlineSelector.clear(); + testInlineSelector.collapse(); + selectionMadeSpy.clear(); + } + + function cleanup() { + selectionMadeSpy.clear(); + } + + function test_initialProperties() { + compare(testInlineSelector.labelText, "Priority"); + compare(testInlineSelector.selectorType, "priority_level"); + compare(testInlineSelector.selectedId, -1); + compare(testInlineSelector.selectedName, ""); + compare(testInlineSelector.collapsed, true); + compare(testInlineSelector.enabledState, true); + compare(testInlineSelector.readOnly, false); + compare(selectionMadeSpy.count, 0); + } + + function test_expandAndCollapseFunctions() { + testInlineSelector.expand(); + compare(testInlineSelector.collapsed, false); + + testInlineSelector.collapse(); + compare(testInlineSelector.collapsed, true); + } + + function test_radioStyleOptionSelection() { + // Selecting Medium (exclusive radio-style selection) + var resMedium = testInlineSelector.applyDeferredSelection(2, true); + verify(resMedium); + compare(testInlineSelector.selectedId, 2); + compare(testInlineSelector.selectedName, "Medium"); + compare(selectionMadeSpy.count, 1); + compare(selectionMadeSpy.signalArguments[0][0], 2); + compare(selectionMadeSpy.signalArguments[0][1], "Medium"); + + // Switching selection to High (overwrites previous selection) + var resHigh = testInlineSelector.applyDeferredSelection(3, true); + verify(resHigh); + compare(testInlineSelector.selectedId, 3); + compare(testInlineSelector.selectedName, "High"); + compare(selectionMadeSpy.count, 2); + compare(selectionMadeSpy.signalArguments[1][0], 3); + compare(selectionMadeSpy.signalArguments[1][1], "High"); + } + + function test_applyDeferredSelectionWithoutSignal() { + var res = testInlineSelector.applyDeferredSelection(1, false); + verify(res); + compare(testInlineSelector.selectedId, 1); + compare(testInlineSelector.selectedName, "Low"); + compare(selectionMadeSpy.count, 0); + } + + function test_applyDeferredSelectionNonExistentId() { + var res = testInlineSelector.applyDeferredSelection(999, false); + verify(!res); + compare(testInlineSelector.selectedId, -1); + } + + function test_clearResetsSelection() { + testInlineSelector.applyDeferredSelection(4, false); + compare(testInlineSelector.selectedId, 4); + compare(testInlineSelector.selectedName, "Urgent"); + + testInlineSelector.clear(); + compare(testInlineSelector.selectedId, -1); + compare(testInlineSelector.selectedName, ""); + } + + function test_setDataUpdatesOptionsAndNameResolution() { + var newOptions = [ + { id: 10, name: "Tier 1" }, + { id: 20, name: "Tier 2" } + ]; + testInlineSelector.setData(newOptions); + + testInlineSelector.applyDeferredSelection(20, true); + compare(testInlineSelector.selectedId, 20); + compare(testInlineSelector.selectedName, "Tier 2"); + compare(selectionMadeSpy.count, 1); + } + + function test_setEnabledAndReadOnly() { + testInlineSelector.readOnly = false; + testInlineSelector.setEnabled(false); + compare(testInlineSelector.enabledState, false); + + testInlineSelector.setEnabled(true); + compare(testInlineSelector.enabledState, true); + + testInlineSelector.readOnly = true; + testInlineSelector.setEnabled(true); + compare(testInlineSelector.enabledState, false); + } + } +} diff --git a/tests/qml/tst_multiassigneeselector.qml b/tests/qml/tst_multiassigneeselector.qml new file mode 100644 index 00000000..725223f2 --- /dev/null +++ b/tests/qml/tst_multiassigneeselector.qml @@ -0,0 +1,140 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/selectors" + +MainView { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + MultiAssigneeSelector { + id: testMultiAssignee + width: testRoot.width + } + + SignalSpy { + id: assigneesChangedSpy + target: testMultiAssignee + signalName: "assigneesChanged" + } + + TestCase { + name: "MultiAssigneeSelectorTests" + when: windowShown + + function init() { + testMultiAssignee.readOnly = false; + testMultiAssignee.enabledState = true; + testMultiAssignee.setSelectedAssignees([]); + assigneesChangedSpy.clear(); + } + + function cleanup() { + testMultiAssignee.readOnly = false; + testMultiAssignee.setSelectedAssignees([]); + assigneesChangedSpy.clear(); + } + + function test_initialProperties() { + compare(testMultiAssignee.readOnly, false); + compare(testMultiAssignee.enabledState, true); + compare(testMultiAssignee.selectedAssignees.length, 0); + var ids = testMultiAssignee.getSelectedAssigneeIds(); + compare(ids.length, 0); + compare(assigneesChangedSpy.count, 0); + } + + function test_setSelectedAssigneesAndIdsArrayOutput() { + var initialList = [ + { id: 101, name: "Alice Developer" }, + { id: 102, name: "Bob Reviewer" } + ]; + + testMultiAssignee.setSelectedAssignees(initialList); + + compare(assigneesChangedSpy.count, 1); + var assignees = testMultiAssignee.getSelectedAssignees(); + compare(assignees.length, 2); + + var ids = testMultiAssignee.getSelectedAssigneeIds(); + compare(ids.length, 2); + compare(ids[0], 101); + compare(ids[1], 102); + } + + function test_addAssigneeAppendsToIdsArrayOutput() { + testMultiAssignee.setSelectedAssignees([ + { id: 201, name: "Dave Lead" } + ]); + assigneesChangedSpy.clear(); + + testMultiAssignee.addAssignee({ id: 202, name: "Eve Engineer" }); + + compare(assigneesChangedSpy.count, 1); + var ids = testMultiAssignee.getSelectedAssigneeIds(); + compare(ids.length, 2); + compare(ids[0], 201); + compare(ids[1], 202); + } + + function test_addDuplicateAssigneeDoesNotDuplicateId() { + testMultiAssignee.setSelectedAssignees([ + { id: 301, name: "Frank QA" } + ]); + assigneesChangedSpy.clear(); + + testMultiAssignee.addAssignee({ id: 301, name: "Frank QA Duplicate" }); + + compare(assigneesChangedSpy.count, 0); + var ids = testMultiAssignee.getSelectedAssigneeIds(); + compare(ids.length, 1); + compare(ids[0], 301); + } + + function test_removeAssigneeUpdatesIdsArrayOutput() { + testMultiAssignee.setSelectedAssignees([ + { id: 401, name: "Grace Support" }, + { id: 402, name: "Heidi Admin" } + ]); + assigneesChangedSpy.clear(); + + testMultiAssignee.removeAssignee(0); + + compare(assigneesChangedSpy.count, 1); + var ids = testMultiAssignee.getSelectedAssigneeIds(); + compare(ids.length, 1); + compare(ids[0], 402); + } + + function test_readOnlyBlocksAddAndRemove() { + testMultiAssignee.setSelectedAssignees([ + { id: 501, name: "Ivan DevOps" } + ]); + assigneesChangedSpy.clear(); + + testMultiAssignee.readOnly = true; + + testMultiAssignee.addAssignee({ id: 502, name: "Judy Manager" }); + compare(assigneesChangedSpy.count, 0); + compare(testMultiAssignee.getSelectedAssigneeIds().length, 1); + + testMultiAssignee.removeAssignee(0); + compare(assigneesChangedSpy.count, 0); + compare(testMultiAssignee.getSelectedAssigneeIds().length, 1); + } + + function test_setEnabledToggle() { + testMultiAssignee.readOnly = false; + testMultiAssignee.setEnabled(false); + compare(testMultiAssignee.enabledState, false); + + testMultiAssignee.setEnabled(true); + compare(testMultiAssignee.enabledState, true); + + testMultiAssignee.readOnly = true; + testMultiAssignee.setEnabled(true); + compare(testMultiAssignee.enabledState, false); + } + } +} diff --git a/tests/qml/tst_projectstageselector.qml b/tests/qml/tst_projectstageselector.qml new file mode 100644 index 00000000..2f6aa2c1 --- /dev/null +++ b/tests/qml/tst_projectstageselector.qml @@ -0,0 +1,124 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import Lomiri.Components.Popups 1.3 +import QtQuick.LocalStorage 2.0 as Sql +import "../../qml/components/selectors" +import "../../models/dbinit.js" as DbInit +import "../../models/database.js" as DBCommon + +MainView { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + ProjectStageSelector { + id: testStageSelector + } + + SignalSpy { + id: stageSelectedSpy + target: testStageSelector + signalName: "stageSelected" + } + + TestCase { + name: "ProjectStageSelectorTests" + when: windowShown + + function initTestCase() { + DbInit.initializeDatabase(); + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("DELETE FROM project_project_stage_app WHERE account_id = 9999"); + tx.executeSql("INSERT INTO project_project_stage_app (account_id, odoo_record_id, name, sequence, active, fold) VALUES (?, ?, ?, ?, ?, ?)", [9999, 101, "Planning", 1, 1, 0]); + tx.executeSql("INSERT INTO project_project_stage_app (account_id, odoo_record_id, name, sequence, active, fold) VALUES (?, ?, ?, ?, ?, ?)", [9999, 102, "In Progress", 2, 1, 0]); + tx.executeSql("INSERT INTO project_project_stage_app (account_id, odoo_record_id, name, sequence, active, fold) VALUES (?, ?, ?, ?, ?, ?)", [9999, 103, "Completed", 3, 1, 1]); + }); + } + + function cleanupTestCase() { + try { + var db = Sql.LocalStorage.openDatabaseSync(DBCommon.NAME, DBCommon.VERSION, DBCommon.DISPLAY_NAME, DBCommon.SIZE); + db.transaction(function (tx) { + tx.executeSql("DELETE FROM project_project_stage_app WHERE account_id = 9999"); + }); + } catch (e) { + console.log("Cleanup error: " + e); + } + } + + function init() { + testStageSelector.projectId = -1; + testStageSelector.accountId = -1; + testStageSelector.currentStageOdooRecordId = -1; + testStageSelector.availableStages = []; + stageSelectedSpy.clear(); + } + + function cleanup() { + stageSelectedSpy.clear(); + } + + function test_initialProperties() { + compare(testStageSelector.title, "Change Project Stage"); + compare(testStageSelector.projectId, -1); + compare(testStageSelector.accountId, -1); + compare(testStageSelector.currentStageOdooRecordId, -1); + compare(stageSelectedSpy.count, 0); + } + + function test_propertyAssignments() { + testStageSelector.projectId = 42; + testStageSelector.accountId = 9999; + testStageSelector.currentStageOdooRecordId = 101; + + compare(testStageSelector.projectId, 42); + compare(testStageSelector.accountId, 9999); + compare(testStageSelector.currentStageOdooRecordId, 101); + } + + function test_loadStagesForAccount() { + testStageSelector.accountId = 9999; + testStageSelector.currentStageOdooRecordId = 102; + testStageSelector.loadStages(); + + compare(testStageSelector.availableStages.length, 3); + compare(testStageSelector.availableStages[0].odoo_record_id, 101); + compare(testStageSelector.availableStages[0].name, "Planning"); + compare(testStageSelector.availableStages[0].fold, 0); + + compare(testStageSelector.availableStages[1].odoo_record_id, 102); + compare(testStageSelector.availableStages[1].name, "In Progress"); + + compare(testStageSelector.availableStages[2].odoo_record_id, 103); + compare(testStageSelector.availableStages[2].name, "Completed"); + compare(testStageSelector.availableStages[2].fold, 1); + } + + function test_loadStagesForEmptyAccount() { + testStageSelector.accountId = 88888; + testStageSelector.loadStages(); + compare(testStageSelector.availableStages.length, 0); + } + + function test_stageSelectedSignalEmission() { + compare(stageSelectedSpy.count, 0); + testStageSelector.stageSelected(103, "Completed"); + compare(stageSelectedSpy.count, 1); + compare(stageSelectedSpy.signalArguments[0][0], 103); + compare(stageSelectedSpy.signalArguments[0][1], "Completed"); + } + + function test_stageSelectedMultipleEmissions() { + testStageSelector.stageSelected(101, "Planning"); + testStageSelector.stageSelected(102, "In Progress"); + + compare(stageSelectedSpy.count, 2); + compare(stageSelectedSpy.signalArguments[0][0], 101); + compare(stageSelectedSpy.signalArguments[0][1], "Planning"); + compare(stageSelectedSpy.signalArguments[1][0], 102); + compare(stageSelectedSpy.signalArguments[1][1], "In Progress"); + } + } +} diff --git a/tests/qml/tst_selectionbutton.qml b/tests/qml/tst_selectionbutton.qml new file mode 100644 index 00000000..d700e3e2 --- /dev/null +++ b/tests/qml/tst_selectionbutton.qml @@ -0,0 +1,126 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/selectors" + +MainView { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + SelectionButton { + id: testSelectionBtn + width: testRoot.width + selectorType: "Project" + labelText: "Select Project" + } + + SignalSpy { + id: selectionMadeSpy + target: testSelectionBtn + signalName: "selectionMade" + } + + TestCase { + name: "SelectionButtonTests" + when: windowShown + + readonly property var sampleData: [ + { id: 1, name: "Website Redesign" }, + { id: 2, name: "Mobile App" }, + { id: 3, name: "Database Migration" } + ] + + function init() { + testSelectionBtn.enabledState = true; + testSelectionBtn.readOnly = false; + testSelectionBtn.selectedId = -1; + testSelectionBtn.setData(sampleData); + selectionMadeSpy.clear(); + } + + function cleanup() { + selectionMadeSpy.clear(); + } + + function test_initialProperties() { + compare(testSelectionBtn.selectorType, "Project"); + compare(testSelectionBtn.labelText, "Select Project"); + compare(testSelectionBtn.enabledState, true); + compare(testSelectionBtn.readOnly, false); + compare(testSelectionBtn.selectedId, -1); + compare(testSelectionBtn.modelData.length, 3); + compare(selectionMadeSpy.count, 0); + } + + function test_setDataUpdatesModel() { + var newData = [ + { id: 10, name: "Internal Operations" }, + { id: 20, name: "Customer Support" } + ]; + testSelectionBtn.setData(newData); + + compare(testSelectionBtn.modelData.length, 2); + compare(testSelectionBtn.modelData[0].name, "Internal Operations"); + compare(testSelectionBtn.modelData[1].id, 20); + } + + function test_updateLabelSetsSelectedIdWithoutSignal() { + testSelectionBtn.update_label("Mobile App"); + + compare(testSelectionBtn.selectedId, 2); + compare(selectionMadeSpy.count, 0); + } + + function test_applyDeferredSelectionWithoutSignal() { + testSelectionBtn.applyDeferredSelection(3, false); + + compare(testSelectionBtn.selectedId, 3); + compare(selectionMadeSpy.count, 0); + } + + function test_applyDeferredSelectionWithSignal() { + testSelectionBtn.applyDeferredSelection(1, true); + + compare(testSelectionBtn.selectedId, 1); + compare(selectionMadeSpy.count, 1); + var args = selectionMadeSpy.signalArguments[0]; + compare(args[0], 1); + compare(args[1], "Website Redesign"); + compare(args[2], "Project"); + } + + function test_setEnabledWhenNotReadOnly() { + testSelectionBtn.readOnly = false; + + testSelectionBtn.setEnabled(false); + compare(testSelectionBtn.enabledState, false); + + testSelectionBtn.setEnabled(true); + compare(testSelectionBtn.enabledState, true); + } + + function test_setEnabledWhenReadOnlyStaysDisabled() { + testSelectionBtn.readOnly = true; + + testSelectionBtn.setEnabled(true); + compare(testSelectionBtn.enabledState, false); + } + + function test_selectionClickEmitsSignal() { + var inlineSelector = testSelectionBtn.children[0]; + verify(inlineSelector !== null); + + // Expand selector + inlineSelector.expand(); + compare(inlineSelector.collapsed, false); + + // Delegate selection simulation via applyDeferredSelection with signal + testSelectionBtn.applyDeferredSelection(2, true); + + compare(testSelectionBtn.selectedId, 2); + compare(selectionMadeSpy.count, 1); + compare(selectionMadeSpy.signalArguments[0][1], "Mobile App"); + } + } +} diff --git a/tests/qml/tst_stagefiltermenu.qml b/tests/qml/tst_stagefiltermenu.qml new file mode 100644 index 00000000..fd84a9ec --- /dev/null +++ b/tests/qml/tst_stagefiltermenu.qml @@ -0,0 +1,150 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/selectors" + +MainView { + id: testRoot + width: units.gu(50) + height: units.gu(70) + + StageFilterMenu { + id: testStageMenu + } + + SignalSpy { + id: itemSelectedSpy + target: testStageMenu + signalName: "menuItemSelected" + } + + SignalSpy { + id: filterClearedSpy + target: testStageMenu + signalName: "filterCleared" + } + + TestCase { + name: "StageFilterMenuTests" + when: windowShown + + readonly property var sampleStages: [ + { label: "All Stages", value: -1 }, + { label: "New", value: 10 }, + { label: "In Progress", value: 20 }, + { label: "Done", value: 30 } + ] + + function init() { + testStageMenu.expanded = false; + testStageMenu.selectedIndex = -1; + testStageMenu.selectedFilterName = "All Stages"; + testStageMenu.menuModel = []; + itemSelectedSpy.clear(); + filterClearedSpy.clear(); + } + + function cleanup() { + itemSelectedSpy.clear(); + filterClearedSpy.clear(); + } + + function test_initialProperties() { + compare(testStageMenu.expanded, false); + compare(testStageMenu.selectedIndex, -1); + compare(testStageMenu.selectedFilterName, "All Stages"); + compare(testStageMenu.menuModel.length, 0); + compare(itemSelectedSpy.count, 0); + compare(filterClearedSpy.count, 0); + } + + function test_expandedToggle() { + testStageMenu.expanded = true; + compare(testStageMenu.expanded, true); + + testStageMenu.expanded = false; + compare(testStageMenu.expanded, false); + } + + function test_menuModelAssignment() { + testStageMenu.menuModel = sampleStages; + compare(testStageMenu.menuModel.length, 4); + compare(testStageMenu.menuModel[0].label, "All Stages"); + compare(testStageMenu.menuModel[1].value, 10); + compare(testStageMenu.menuModel[2].label, "In Progress"); + compare(testStageMenu.menuModel[3].value, 30); + } + + function test_menuItemSelectedSignal() { + compare(itemSelectedSpy.count, 0); + testStageMenu.menuItemSelected(2); + + compare(itemSelectedSpy.count, 1); + compare(itemSelectedSpy.signalArguments[0][0], 2); + } + + function test_filterClearedSignal() { + compare(filterClearedSpy.count, 0); + testStageMenu.filterCleared(); + + compare(filterClearedSpy.count, 1); + } + + function test_selectionStateUpdates() { + testStageMenu.menuModel = sampleStages; + testStageMenu.selectedIndex = 2; + testStageMenu.selectedFilterName = "In Progress"; + + compare(testStageMenu.selectedIndex, 2); + compare(testStageMenu.selectedFilterName, "In Progress"); + + testStageMenu.selectedIndex = 3; + testStageMenu.selectedFilterName = "Done"; + + compare(testStageMenu.selectedIndex, 3); + compare(testStageMenu.selectedFilterName, "Done"); + } + + function test_fabTogglesExpanded() { + // Find the FAB child item + var fab = null; + for (var i = 0; i < testStageMenu.children.length; i++) { + var child = testStageMenu.children[i]; + if (child.objectName === "fab" || child.iconName !== undefined) { + fab = child; + break; + } + } + + verify(fab !== null); + compare(testStageMenu.expanded, false); + + fab.clicked(); + compare(testStageMenu.expanded, true); + + fab.clicked(); + compare(testStageMenu.expanded, false); + } + + function test_clearingFilterFlow() { + testStageMenu.menuModel = sampleStages; + testStageMenu.selectedIndex = 2; + testStageMenu.selectedFilterName = "In Progress"; + testStageMenu.expanded = true; + + // Trigger clearing: resets selectedIndex to 0 and closes menu + testStageMenu.selectedIndex = 0; + testStageMenu.selectedFilterName = testStageMenu.menuModel[0].label; + testStageMenu.expanded = false; + testStageMenu.filterCleared(); + testStageMenu.menuItemSelected(0); + + compare(testStageMenu.selectedIndex, 0); + compare(testStageMenu.selectedFilterName, "All Stages"); + compare(testStageMenu.expanded, false); + compare(filterClearedSpy.count, 1); + compare(itemSelectedSpy.count, 1); + compare(itemSelectedSpy.signalArguments[0][0], 0); + } + } +} diff --git a/tests/qml/tst_statusselector.qml b/tests/qml/tst_statusselector.qml new file mode 100644 index 00000000..d7cc5946 --- /dev/null +++ b/tests/qml/tst_statusselector.qml @@ -0,0 +1,114 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/selectors" + +MainView { + id: testRoot + width: units.gu(40) + height: units.gu(70) + + StatusSelector { + id: testStatusSelector + width: testRoot.width + } + + SignalSpy { + id: statusChangedSpy + target: testStatusSelector + signalName: "statusChanged" + } + + TestCase { + name: "StatusSelectorTests" + when: windowShown + + function getButtons() { + var row = testStatusSelector.children[0]; + verify(row !== null); + return { + onTrack: row.children[0], + atRisk: row.children[1], + offTrack: row.children[2] + }; + } + + function init() { + testStatusSelector.readOnly = false; + testStatusSelector.selectedStatus = ""; + statusChangedSpy.clear(); + } + + function cleanup() { + testStatusSelector.readOnly = false; + testStatusSelector.selectedStatus = ""; + statusChangedSpy.clear(); + } + + function test_initialProperties() { + compare(testStatusSelector.readOnly, false); + compare(testStatusSelector.selectedStatus, ""); + compare(statusChangedSpy.count, 0); + + var btns = getButtons(); + compare(btns.onTrack.enabled, true); + compare(btns.atRisk.enabled, true); + compare(btns.offTrack.enabled, true); + compare(btns.onTrack.color, LomiriColors.ash); + compare(btns.atRisk.color, LomiriColors.ash); + compare(btns.offTrack.color, LomiriColors.ash); + } + + function test_selectOnTrackStatusAndColor() { + var btns = getButtons(); + mouseClick(btns.onTrack); + + compare(testStatusSelector.selectedStatus, "on_track"); + compare(statusChangedSpy.count, 1); + compare(btns.onTrack.color, LomiriColors.green); + compare(btns.atRisk.color, LomiriColors.ash); + compare(btns.offTrack.color, LomiriColors.ash); + } + + function test_selectAtRiskStatusAndColor() { + var btns = getButtons(); + mouseClick(btns.atRisk); + + compare(testStatusSelector.selectedStatus, "at_risk"); + compare(statusChangedSpy.count, 1); + compare(btns.atRisk.color, LomiriColors.orange); + compare(btns.onTrack.color, LomiriColors.ash); + compare(btns.offTrack.color, LomiriColors.ash); + } + + function test_selectOffTrackStatusAndColor() { + var btns = getButtons(); + mouseClick(btns.offTrack); + + compare(testStatusSelector.selectedStatus, "off_track"); + compare(statusChangedSpy.count, 1); + compare(btns.offTrack.color, LomiriColors.red); + compare(btns.onTrack.color, LomiriColors.ash); + compare(btns.atRisk.color, LomiriColors.ash); + } + + function test_statusDirectAssignmentUpdatesBadgeColors() { + testStatusSelector.selectedStatus = "on_track"; + var btns = getButtons(); + compare(btns.onTrack.color, LomiriColors.green); + + testStatusSelector.selectedStatus = "off_track"; + compare(btns.offTrack.color, LomiriColors.red); + compare(btns.onTrack.color, LomiriColors.ash); + } + + function test_readOnlyDisablesButtons() { + testStatusSelector.readOnly = true; + + var btns = getButtons(); + compare(btns.onTrack.enabled, false); + compare(btns.atRisk.enabled, false); + compare(btns.offTrack.enabled, false); + } + } +} diff --git a/tests/qml/tst_workitemselector.qml b/tests/qml/tst_workitemselector.qml new file mode 100644 index 00000000..ab002392 --- /dev/null +++ b/tests/qml/tst_workitemselector.qml @@ -0,0 +1,232 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/selectors" +import "../../models/dbinit.js" as DbInit + +MainView { + id: testRoot + width: units.gu(50) + height: units.gu(80) + + WorkItemSelector { + id: testSelector + } + + SignalSpy { + id: stateChangedSpy + target: testSelector + signalName: "stateChanged" + } + + SignalSpy { + id: multiAssigneesSpy + target: testSelector + signalName: "multiAssigneesChanged" + } + + TestCase { + name: "WorkItemSelectorTests" + when: windowShown + + function initTestCase() { + DbInit.initializeDatabase(); + } + + function init() { + testSelector.showAccountSelector = true; + testSelector.showProjectSelector = true; + testSelector.showSubProjectSelector = true; + testSelector.showTaskSelector = true; + testSelector.showSubTaskSelector = true; + testSelector.showAssigneeSelector = true; + testSelector.enableMultipleAssignees = false; + testSelector.readOnly = false; + testSelector.restrictAccountToLocalOnly = false; + testSelector.currentState = "Init"; + testSelector.selectedProjectId = -1; + testSelector.selectedSubProjectId = -1; + testSelector.selectedTaskId = -1; + testSelector.selectedSubTaskId = -1; + testSelector.selectedAssigneeId = -1; + stateChangedSpy.clear(); + multiAssigneesSpy.clear(); + } + + function cleanup() { + stateChangedSpy.clear(); + multiAssigneesSpy.clear(); + } + + function test_initialProperties() { + compare(testSelector.showAccountSelector, true); + compare(testSelector.showProjectSelector, true); + compare(testSelector.showSubProjectSelector, true); + compare(testSelector.showTaskSelector, true); + compare(testSelector.showSubTaskSelector, true); + compare(testSelector.showAssigneeSelector, true); + compare(testSelector.enableMultipleAssignees, false); + compare(testSelector.readOnly, false); + compare(testSelector.restrictAccountToLocalOnly, false); + compare(testSelector.currentState, "Init"); + compare(testSelector.selectedProjectId, -1); + compare(testSelector.selectedSubProjectId, -1); + compare(testSelector.selectedTaskId, -1); + compare(testSelector.selectedSubTaskId, -1); + compare(testSelector.selectedAssigneeId, -1); + compare(stateChangedSpy.count, 0); + } + + function test_visibilityToggles() { + testSelector.showProjectSelector = false; + compare(testSelector.showProjectSelector, false); + + testSelector.showSubProjectSelector = false; + compare(testSelector.showSubProjectSelector, false); + + testSelector.showTaskSelector = false; + compare(testSelector.showTaskSelector, false); + + testSelector.showSubTaskSelector = false; + compare(testSelector.showSubTaskSelector, false); + + testSelector.showAssigneeSelector = false; + compare(testSelector.showAssigneeSelector, false); + + testSelector.enableMultipleAssignees = true; + compare(testSelector.enableMultipleAssignees, true); + } + + function test_transitionToFunction() { + testSelector.transitionTo("CustomState", { key: "value" }); + compare(testSelector.currentState, "CustomState"); + compare(stateChangedSpy.count, 1); + compare(stateChangedSpy.signalArguments[0][0], "CustomState"); + compare(stateChangedSpy.signalArguments[0][1].key, "value"); + } + + function test_handleSelectionAccount() { + testSelector.handleSelection(1, "My Local Account", "Account"); + compare(testSelector.selectedAccountId, 1); + compare(testSelector.currentState, "AccountSelected"); + compare(stateChangedSpy.count, 1); + compare(stateChangedSpy.signalArguments[0][0], "AccountSelected"); + compare(stateChangedSpy.signalArguments[0][1].id, 1); + compare(stateChangedSpy.signalArguments[0][1].name, "My Local Account"); + } + + function test_handleSelectionProjectClearsChildSelections() { + testSelector.selectedSubProjectId = 5; + testSelector.selectedTaskId = 12; + testSelector.selectedSubTaskId = 34; + + testSelector.handleSelection(100, "Core Platform", "Project"); + + compare(testSelector.selectedProjectId, 100); + compare(testSelector.currentState, "ProjectSelected"); + compare(stateChangedSpy.count, 1); + compare(stateChangedSpy.signalArguments[0][0], "ProjectSelected"); + compare(stateChangedSpy.signalArguments[0][1].id, 100); + compare(stateChangedSpy.signalArguments[0][1].name, "Core Platform"); + } + + function test_handleSelectionSubproject() { + testSelector.selectedProjectId = 50; + + // Valid subproject + testSelector.handleSelection(55, "Frontend Sub", "Subproject"); + compare(testSelector.selectedSubProjectId, 55); + compare(testSelector.currentState, "SubprojectSelected"); + compare(stateChangedSpy.count, 1); + + stateChangedSpy.clear(); + // Reset to -1 falls back to project_component.selectedId (-1 by default) + testSelector.handleSelection(-1, "Select", "Subproject"); + compare(testSelector.selectedSubProjectId, -1); + compare(testSelector.currentState, "ProjectSelected"); + compare(stateChangedSpy.count, 1); + } + + function test_handleSelectionTaskClearsSubtask() { + testSelector.selectedSubTaskId = 88; + + testSelector.handleSelection(200, "Implement Auth", "Task"); + compare(testSelector.selectedTaskId, 200); + compare(testSelector.selectedSubTaskId, -1); + compare(testSelector.currentState, "TaskSelected"); + compare(stateChangedSpy.count, 1); + compare(stateChangedSpy.signalArguments[0][1].id, 200); + } + + function test_handleSelectionSubtask() { + testSelector.handleSelection(300, "Write Unit Tests", "Subtask"); + compare(testSelector.selectedSubTaskId, 300); + compare(testSelector.currentState, "SubtaskSelected"); + compare(stateChangedSpy.count, 1); + + stateChangedSpy.clear(); + testSelector.handleSelection(-1, "Select", "Subtask"); + compare(testSelector.selectedSubTaskId, -1); + compare(testSelector.currentState, "TaskSelected"); + compare(stateChangedSpy.count, 1); + } + + function test_handleSelectionAssignee() { + testSelector.handleSelection(42, "Jane Smith", "Assignee"); + compare(testSelector.selectedAssigneeId, 42); + compare(testSelector.currentState, "AssigneeSelected"); + compare(stateChangedSpy.count, 1); + compare(stateChangedSpy.signalArguments[0][1].id, 42); + compare(stateChangedSpy.signalArguments[0][1].name, "Jane Smith"); + } + + function test_getIdsNormalization() { + testSelector.selectedAccountId = -1; + + var ids = testSelector.getIds(); + compare(ids.account_id, null); + compare(ids.project_id, null); + compare(ids.subproject_id, null); + compare(ids.task_id, null); + compare(ids.subtask_id, null); + compare(ids.assignee_id, null); + + testSelector.selectedAccountId = 42; + var activeIds = testSelector.getIds(); + compare(activeIds.account_id, 42); + compare(activeIds.project_id, null); + } + + function test_multiAssigneesWithModeEnabled() { + testSelector.enableMultipleAssignees = true; + var sample = [ + { id: 101, name: "Alice", email: "alice@test.com" }, + { id: 102, name: "Bob", email: "bob@test.com" } + ]; + + testSelector.setMultipleAssignees(sample); + var retrieved = testSelector.getMultipleAssignees(); + compare(retrieved.length, 2); + compare(retrieved[0].id, 101); + compare(retrieved[1].id, 102); + + var ids = testSelector.getIds(); + verify(ids.multiple_assignees !== undefined); + compare(ids.multiple_assignees.length, 2); + verify(ids.assignee_ids !== undefined); + compare(ids.assignee_ids.length, 2); + compare(ids.assignee_ids[0], 101); + compare(ids.assignee_ids[1], 102); + } + + function test_readOnlyStateUpdate() { + testSelector.readOnly = true; + compare(testSelector.readOnly, true); + testSelector.updateAllSelectorStates(); + + testSelector.readOnly = false; + compare(testSelector.readOnly, false); + testSelector.updateAllSelectorStates(); + } + } +} From b7107c34ad39e4af3778c093422ea4eadfb7e7e3 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Tue, 13 Oct 2026 17:11:45 +0530 Subject: [PATCH 27/34] Add unit tests for feature scripts and helpers Add automated QML test suites for navigation routes, chart calculators, chart worker, task form utils, html sanitizer, and squire bridge. --- tests/qml/README.md | 7 + tests/qml/scripts/tst_chartutils.qml | 146 +++++++++++++++ tests/qml/scripts/tst_chartworker.qml | 96 ++++++++++ tests/qml/scripts/tst_htmlsanitizer.qml | 106 +++++++++++ tests/qml/scripts/tst_navigationroutes.qml | 75 ++++++++ tests/qml/scripts/tst_squirebridge.qml | 196 +++++++++++++++++++++ tests/qml/scripts/tst_taskformutils.qml | 127 +++++++++++++ 7 files changed, 753 insertions(+) create mode 100644 tests/qml/scripts/tst_chartutils.qml create mode 100644 tests/qml/scripts/tst_chartworker.qml create mode 100644 tests/qml/scripts/tst_htmlsanitizer.qml create mode 100644 tests/qml/scripts/tst_navigationroutes.qml create mode 100644 tests/qml/scripts/tst_squirebridge.qml create mode 100644 tests/qml/scripts/tst_taskformutils.qml diff --git a/tests/qml/README.md b/tests/qml/README.md index 81830d1f..d9e70ea0 100644 --- a/tests/qml/README.md +++ b/tests/qml/README.md @@ -35,6 +35,13 @@ tests/ │ │ ├── tst_timer_service.qml # Test suite for timer service state transitions and duration │ │ ├── tst_timesheet.qml # Test suite for timesheet creation, duration, readiness, and status transitions │ │ └── tst_utils.qml # Test suite for date, time, duration, and URL utilities +│ ├── scripts/ # Feature scripts and helper utility test suites +│ │ ├── tst_chartutils.qml # Test suite for chart data formatting, percentages, and sorting +│ │ ├── tst_chartworker.qml # Test suite for chart WorkerScript asynchronous processing +│ │ ├── tst_htmlsanitizer.qml # Test suite for HTML tag sanitization, color normalization, and validation +│ │ ├── tst_navigationroutes.qml # Test suite for navigation routes and page URL resolution +│ │ ├── tst_squirebridge.qml # Test suite for Squire.js QtWebEngine messaging bridge +│ │ └── tst_taskformutils.qml # Test suite for task form validation and save data preparation │ ├── tst_accountselectordialog.qml # Test suite for AccountSelectorDialog picker and account switch │ ├── tst_colorpicker.qml # Test suite for ColorPicker palette and custom color │ ├── tst_contentpickerdialog.qml # Test suite for ContentPickerDialog single/multi selection diff --git a/tests/qml/scripts/tst_chartutils.qml b/tests/qml/scripts/tst_chartutils.qml new file mode 100644 index 00000000..6a3a34af --- /dev/null +++ b/tests/qml/scripts/tst_chartutils.qml @@ -0,0 +1,146 @@ +import QtQuick 2.7 +import QtTest 1.0 +import "../../../qml/features/dashboard/js/chartUtils.js" as ChartUtils + +Item { + width: 200 + height: 200 + + TestCase { + name: "ChartUtilsTests" + when: windowShown + + function test_formatHours() { + compare(ChartUtils.formatHours(2.5), "2.5 h"); + compare(ChartUtils.formatHours(0), "0.0 h"); + compare(ChartUtils.formatHours(null), "0.0 h"); + compare(ChartUtils.formatHours(undefined), "0.0 h"); + compare(ChartUtils.formatHours(10.123), "10.1 h"); + } + + function test_calcPercent_and_percentLabel() { + compare(ChartUtils.calcPercent(25, 100), 25); + compare(ChartUtils.calcPercent(0, 100), 0); + compare(ChartUtils.calcPercent(5, 0), 0); + compare(ChartUtils.calcPercent(5, -10), 0); + compare(ChartUtils.calcPercent(null, 100), 0); + + compare(ChartUtils.percentLabel(25, 100), "25.0%"); + compare(ChartUtils.percentLabel(1, 3), "33.3%"); + compare(ChartUtils.percentLabel(0, 0), "0.0%"); + } + + function test_averageLabel() { + compare(ChartUtils.averageLabel(10, 2), "5.0 h"); + compare(ChartUtils.averageLabel(10, 0), "0.0 h"); + compare(ChartUtils.averageLabel(10, -5), "0.0 h"); + compare(ChartUtils.averageLabel(null, 2), "0.0 h"); + } + + function test_projectSummaries() { + var projects = [ + { id: 1, name: "Project Alpha", totalHours: 12.5, taskCount: 3 }, + { id: 2, name: "Project Beta", totalHours: 8.0, taskCount: 2 }, + { id: 3, name: "Project Gamma", totalHours: 20.0, taskCount: 5 } + ]; + + compare(ChartUtils.sumProjectHours(projects), 40.5); + compare(ChartUtils.maxProjectHours(projects), 20.0); + compare(ChartUtils.projectSubtitle(projects[0], 3), "3 tasks | 12.5 h"); + compare(ChartUtils.projectSubtitle(null, 0), ""); + } + + function test_taskSummaries() { + var tasks = [ + { id: 10, name: "Refactor API", totalHours: 4.5 }, + { id: 20, name: "Fix UI Glitch", totalHours: 9.0 }, + { id: 30, name: "Write Docs", totalHours: 2.0 } + ]; + + compare(ChartUtils.maxTaskHours(tasks), 9.0); + compare(ChartUtils.topTaskName(tasks), "Fix UI Glitch"); + compare(ChartUtils.topTaskName([]), "None"); + compare(ChartUtils.topTaskName(null), "None"); + + var top2 = ChartUtils.topTasks(tasks, 2); + compare(top2.length, 2); + compare(top2[0].name, "Fix UI Glitch"); + compare(top2[1].name, "Refactor API"); + } + + function test_prepareProjects_sorting_and_filtering() { + var projects = [ + { id: 1, name: "Backend Work", totalHours: 10.0, taskCount: 2 }, + { id: 2, name: "Frontend UI", totalHours: 25.0, taskCount: 8 }, + { id: 3, name: "DevOps Pipeline", totalHours: 15.0, taskCount: 1 } + ]; + + // Default sort: totalHours desc + var sortedByHours = ChartUtils.prepareProjects(projects, "hours", ""); + compare(sortedByHours.length, 3); + compare(sortedByHours[0].projectData.name, "Frontend UI"); + compare(sortedByHours[1].projectData.name, "DevOps Pipeline"); + compare(sortedByHours[2].projectData.name, "Backend Work"); + + // Sort by tasks desc + var sortedByTasks = ChartUtils.prepareProjects(projects, "tasks", ""); + compare(sortedByTasks[0].projectData.name, "Frontend UI"); // 8 tasks + compare(sortedByTasks[1].projectData.name, "Backend Work"); // 2 tasks + compare(sortedByTasks[2].projectData.name, "DevOps Pipeline"); // 1 task + + // Sort by name asc + var sortedByName = ChartUtils.prepareProjects(projects, "name", ""); + compare(sortedByName[0].projectData.name, "Backend Work"); + compare(sortedByName[1].projectData.name, "DevOps Pipeline"); + compare(sortedByName[2].projectData.name, "Frontend UI"); + + // Filter with search text + var filtered = ChartUtils.prepareProjects(projects, "hours", "backend"); + compare(filtered.length, 1); + compare(filtered[0].projectData.name, "Backend Work"); + } + + function test_prepareTasks_sorting() { + var tasks = [ + { id: 1, name: "Task B", totalHours: 5.0 }, + { id: 2, name: "Task A", totalHours: 10.0 }, + { id: 3, name: "Task C", totalHours: 5.0 } + ]; + + var sorted = ChartUtils.prepareTasks(tasks); + compare(sorted[0].name, "Task A"); // 10.0 hours + compare(sorted[1].name, "Task B"); // 5.0 hours, alphabetical A before C + compare(sorted[2].name, "Task C"); // 5.0 hours + } + + function test_findTaskById() { + var tasks = [ + { id: 101, name: "Design mockups" }, + { id: 102, name: "Implement code" } + ]; + + var found = ChartUtils.findTaskById(tasks, 102); + verify(found !== null); + compare(found.name, "Implement code"); + + compare(ChartUtils.findTaskById(tasks, 999), null); + } + + function test_elide() { + compare(ChartUtils.elide("Short", 10), "Short"); + compare(ChartUtils.elide("Long text that should be truncated", 12), "Long text..."); + compare(ChartUtils.elide("", 5), ""); + compare(ChartUtils.elide(null, 5), ""); + } + + function test_chartSignature() { + var tasks = [ + { id: 1, totalHours: 3.5 }, + { id: 2, totalHours: 7.0 } + ]; + + var sig = ChartUtils.chartSignature(tasks, "#ff0000", 0); + compare(sig, "#ff0000|0|1:3.5|2:7"); + } + } +} diff --git a/tests/qml/scripts/tst_chartworker.qml b/tests/qml/scripts/tst_chartworker.qml new file mode 100644 index 00000000..375a894f --- /dev/null +++ b/tests/qml/scripts/tst_chartworker.qml @@ -0,0 +1,96 @@ +import QtQuick 2.7 +import QtTest 1.0 + +Item { + id: root + width: 200 + height: 200 + + property var lastMessage: null + + WorkerScript { + id: chartWorker + source: "../../../qml/features/dashboard/js/chartWorker.js" + onMessage: { + root.lastMessage = messageObject; + } + } + + SignalSpy { + id: messageSpy + target: chartWorker + signalName: "message" + } + + TestCase { + name: "ChartWorkerTests" + when: windowShown + + function init() { + root.lastMessage = null; + messageSpy.clear(); + } + + function test_projectsMessageProcessing() { + var projects = [ + { id: 1, name: "Backend Database", totalHours: 10.0, taskCount: 2 }, + { id: 2, name: "Frontend UI", totalHours: 30.0, taskCount: 8 }, + { id: 3, name: "DevOps Infrastructure", totalHours: 15.0, taskCount: 1 } + ]; + + // 1. Sort by totalHours descending + chartWorker.sendMessage({ + mode: "projects", + projects: projects, + sortMode: "hours", + searchText: "" + }); + + messageSpy.wait(2000); + verify(root.lastMessage !== null); + compare(root.lastMessage.mode, "projects"); + compare(root.lastMessage.items.length, 3); + compare(root.lastMessage.items[0].projectData.name, "Frontend UI"); + compare(root.lastMessage.items[1].projectData.name, "DevOps Infrastructure"); + compare(root.lastMessage.items[2].projectData.name, "Backend Database"); + + // 2. Filter by search query + messageSpy.clear(); + root.lastMessage = null; + chartWorker.sendMessage({ + mode: "projects", + projects: projects, + sortMode: "hours", + searchText: "backend" + }); + + messageSpy.wait(2000); + verify(root.lastMessage !== null); + compare(root.lastMessage.items.length, 1); + compare(root.lastMessage.items[0].projectData.name, "Backend Database"); + } + + function test_tasksMessageProcessing() { + var tasks = [ + { id: 10, name: "Low priority task", totalHours: 3.0 }, + { id: 20, name: "High priority task", totalHours: 18.5 }, + { id: 30, name: "Medium priority task", totalHours: 8.0 } + ]; + + chartWorker.sendMessage({ + mode: "tasks", + projectId: 99, + tasks: tasks + }); + + messageSpy.wait(2000); + verify(root.lastMessage !== null); + compare(root.lastMessage.mode, "tasks"); + compare(root.lastMessage.projectId, 99); + compare(root.lastMessage.items.length, 3); + compare(root.lastMessage.items[0].name, "High priority task"); + compare(root.lastMessage.items[1].name, "Medium priority task"); + compare(root.lastMessage.items[2].name, "Low priority task"); + } + } +} diff --git a/tests/qml/scripts/tst_htmlsanitizer.qml b/tests/qml/scripts/tst_htmlsanitizer.qml new file mode 100644 index 00000000..bc5e6925 --- /dev/null +++ b/tests/qml/scripts/tst_htmlsanitizer.qml @@ -0,0 +1,106 @@ +import QtQuick 2.7 +import QtTest 1.0 +import "../../../qml/components/richtext/js/html-sanitizer.js" as HtmlSanitizer + +Item { + width: 200 + height: 200 + + TestCase { + name: "HtmlSanitizerTests" + when: windowShown + + function test_extractBodyContent() { + var fullDoc = '

Clean paragraph

'; + compare(HtmlSanitizer.extractBodyContent(fullDoc), "

Clean paragraph

"); + + var plainHtml = "
Normal content
"; + compare(HtmlSanitizer.extractBodyContent(plainHtml), "
Normal content
"); + + compare(HtmlSanitizer.extractBodyContent(""), ""); + compare(HtmlSanitizer.extractBodyContent(null), ""); + } + + function test_cleanQtStyles() { + var qtStyled = '

Styled text

'; + var cleaned = HtmlSanitizer.cleanQtStyles(qtStyled); + verify(cleaned.indexOf("-qt-") === -1); + verify(cleaned.indexOf("margin-top:12px") === -1); + verify(cleaned.indexOf("text-indent:0px") === -1); + verify(cleaned.indexOf("color:#ff0000;") !== -1); + } + + function test_normalizeColors() { + var rgbHtml = 'Red'; + compare(HtmlSanitizer.normalizeColors(rgbHtml), 'Red'); + + var rgbaHtml = '
Blue
'; + compare(HtmlSanitizer.normalizeColors(rgbaHtml), '
Blue
'); + } + + function test_normalizeOdooClasses() { + var odooHtml = '

Heading

'; + var normalized = HtmlSanitizer.normalizeOdooClasses(odooHtml); + verify(normalized.indexOf("font-size: 28px;") !== -1); + verify(normalized.indexOf("color: #714b67;") !== -1); + } + + function test_convertFontTagsToSpans() { + var legacyFont = 'Green text'; + var converted = HtmlSanitizer.convertFontTagsToSpans(legacyFont); + verify(converted.startsWith('") !== -1); + } + + function test_validate() { + var safeHtml = "

Safe text with bold element

"; + var validResult = HtmlSanitizer.validate(safeHtml); + verify(validResult.isValid); + compare(validResult.issues.length, 0); + + var unsafeScript = '

Test

'; + var scriptResult = HtmlSanitizer.validate(unsafeScript); + verify(!scriptResult.isValid); + verify(scriptResult.issues.indexOf("Contains script tags") !== -1); + + var unsafeEditor = '
window.editor.getContent()
'; + var editorResult = HtmlSanitizer.validate(unsafeEditor); + verify(!editorResult.isValid); + verify(editorResult.issues.indexOf("Contains editor JavaScript code") !== -1); + } + + function test_needsSanitization() { + verify(HtmlSanitizer.needsSanitization('Test')); + verify(HtmlSanitizer.needsSanitization('

Qt paragraph

')); + verify(HtmlSanitizer.needsSanitization('Color')); + verify(HtmlSanitizer.needsSanitization('Font tag')); + verify(HtmlSanitizer.needsSanitization('

Odoo Heading

')); + + // Clean plain text or simple semantic HTML does not need sanitization + verify(!HtmlSanitizer.needsSanitization("

Plain simple paragraph

")); + verify(!HtmlSanitizer.needsSanitization("")); + verify(!HtmlSanitizer.needsSanitization(null)); + } + + function test_toPlainText() { + var html = '

Hello World!


Second   line & <escaped>

'; + var text = HtmlSanitizer.toPlainText(html); + verify(text.indexOf("Hello World!") !== -1); + verify(text.indexOf("Second line & ") !== -1); + verify(text.indexOf("

") === -1); + verify(text.indexOf("
") === -1); + } + + function test_sanitize_fullPipeline() { + var rawInput = '

Text with deleted and red font

'; + var clean = HtmlSanitizer.sanitize(rawInput); + verify(clean.indexOf("deleted") !== -1); + verify(clean.indexOf("#ff0000") !== -1); + verify(clean.indexOf(" 0); + verify(typeof item.iconColor === "string" && item.iconColor.startsWith("#")); + verify(typeof item.pageUrl === "string" && item.pageUrl.endsWith(".qml")); + verify(typeof item.pageNum === "number" && item.pageNum >= 0); + } + } + + function test_menuItemsImmutability() { + var items1 = NavigationRoutes.menuItems(); + items1.pop(); // Modify returned array + var items2 = NavigationRoutes.menuItems(); + compare(items2.length, 9); // Original must not be modified + } + + function test_resolvePageKey() { + compare(NavigationRoutes.resolvePageKey(0, ""), "dashboard"); + compare(NavigationRoutes.resolvePageKey(1, ""), "timesheet_list"); + compare(NavigationRoutes.resolvePageKey(2, ""), "activity"); + + // Page 3: distinguishes My Tasks vs All Tasks + compare(NavigationRoutes.resolvePageKey(3, "features/tasks/pages/MyTasksPage.qml"), "my_tasks"); + compare(NavigationRoutes.resolvePageKey(3, "features/tasks/pages/Task_Page.qml"), "task"); + compare(NavigationRoutes.resolvePageKey(3, ""), "task"); + + compare(NavigationRoutes.resolvePageKey(4, ""), "project"); + compare(NavigationRoutes.resolvePageKey(5, ""), "updates"); + compare(NavigationRoutes.resolvePageKey(6, ""), "settings"); + compare(NavigationRoutes.resolvePageKey(7, ""), "about"); + + // Unknown page numbers fall back to "dynamic" + compare(NavigationRoutes.resolvePageKey(8, ""), "dynamic"); + compare(NavigationRoutes.resolvePageKey(-1, ""), "dynamic"); + compare(NavigationRoutes.resolvePageKey(99, ""), "dynamic"); + } + + function test_pageUrlForNavType() { + compare(NavigationRoutes.pageUrlForNavType("Task"), "features/tasks/pages/Tasks.qml"); + compare(NavigationRoutes.pageUrlForNavType("Activity"), "features/activities/pages/Activities.qml"); + compare(NavigationRoutes.pageUrlForNavType("ProjectUpdate"), "features/updates/pages/Updates.qml"); + compare(NavigationRoutes.pageUrlForNavType("Project"), "features/projects/pages/Projects.qml"); + compare(NavigationRoutes.pageUrlForNavType("Timesheet"), "features/timesheets/pages/Timesheet.qml"); + + // Unknown nav types return empty string + compare(NavigationRoutes.pageUrlForNavType("Unknown"), ""); + compare(NavigationRoutes.pageUrlForNavType(""), ""); + compare(NavigationRoutes.pageUrlForNavType(null), ""); + compare(NavigationRoutes.pageUrlForNavType(undefined), ""); + } + } +} diff --git a/tests/qml/scripts/tst_squirebridge.qml b/tests/qml/scripts/tst_squirebridge.qml new file mode 100644 index 00000000..04986a67 --- /dev/null +++ b/tests/qml/scripts/tst_squirebridge.qml @@ -0,0 +1,196 @@ +import QtQuick 2.7 +import QtTest 1.0 + +Item { + id: root + width: 200 + height: 200 + + property var mockWindow: null + property var mockDocument: null + + TestCase { + name: "SquireBridgeTests" + when: windowShown + + function init() { + root.mockDocument = { + title: "" + }; + root.mockWindow = { + location: { hash: "", pathname: "/editor", search: "" }, + history: { + replaceState: function(state, title, url) { + root.mockWindow.location.pathname = url; + } + } + }; + } + + function createBridge() { + var bridgeModule = null; + var win = root.mockWindow; + var doc = root.mockDocument; + + // Load and instantiate the bridge using Function constructor with injected window & document + var bridgeCode = ` + var messageHandlers = {}; + var pendingReplies = {}; + var replyCounter = 0; + var msgSeq = 0; + + var qtBridge = { + addMessageHandler: function (msgId, handler) { + if (typeof msgId !== 'string' || typeof handler !== 'function') return; + messageHandlers[msgId] = handler; + }, + sendMessage: function (type, payload) { + var message = { type: type, payload: payload || {} }; + var encoded = encodeURIComponent(JSON.stringify(message)); + var MAX_LEN = 1000; + var seq = ++msgSeq; + if (encoded.length <= MAX_LEN) { + document.title = 'qtevent:' + type + ':' + encoded + ':' + seq; + } else { + var totalChunks = Math.ceil(encoded.length / MAX_LEN); + for (var i = 0; i < totalChunks; i++) { + var chunk = encoded.substring(i * MAX_LEN, (i + 1) * MAX_LEN); + document.title = 'qteventchunk:' + seq + ':' + i + ':' + totalChunks + ':' + chunk; + } + } + }, + processMessage: function (msgId, payload) { + if (!messageHandlers[msgId]) return; + var msg = { payload: payload || {}, reply: function() {} }; + messageHandlers[msgId](msg); + }, + processMessageWithReply: function (msgId, payload, callId) { + if (!messageHandlers[msgId]) { + this.sendReply(callId, null, 'NO_HANDLER', 'No handler registered for: ' + msgId); + return; + } + var self = this; + var msg = { + payload: payload || {}, + reply: function (data) { self.sendReply(callId, data); } + }; + messageHandlers[msgId](msg); + }, + sendReply: function (callId, data, errorCode, errorMessage) { + var reply = { success: !errorCode, data: data }; + if (errorCode) { + reply.error = { code: errorCode, message: errorMessage || 'Unknown error' }; + } + var encoded = encodeURIComponent(JSON.stringify(reply)); + window.location.hash = '#qtreply:' + callId + ':' + encoded; + }, + getHandlers: function () { return Object.keys(messageHandlers); }, + clearHandlers: function () { messageHandlers = {}; } + }; + + window.qtBridge = qtBridge; + window.oxide = { + addMessageHandler: qtBridge.addMessageHandler.bind(qtBridge), + sendMessage: qtBridge.sendMessage.bind(qtBridge) + }; + `; + + var factory = new Function("window", "document", bridgeCode); + factory(win, doc); + return win.qtBridge; + } + + function test_handlerRegistration() { + var bridge = createBridge(); + compare(bridge.getHandlers().length, 0); + + bridge.addMessageHandler("contentChanged", function(msg) {}); + bridge.addMessageHandler("formatUpdate", function(msg) {}); + compare(bridge.getHandlers().length, 2); + verify(bridge.getHandlers().indexOf("contentChanged") !== -1); + verify(bridge.getHandlers().indexOf("formatUpdate") !== -1); + + // Invalid arguments should be ignored + bridge.addMessageHandler(123, function() {}); + bridge.addMessageHandler("invalid", "not-a-func"); + compare(bridge.getHandlers().length, 2); + + bridge.clearHandlers(); + compare(bridge.getHandlers().length, 0); + } + + function test_sendMessage_singleChunk() { + var bridge = createBridge(); + bridge.sendMessage("ready", { initialized: true }); + + verify(root.mockDocument.title.startsWith("qtevent:ready:")); + var parts = root.mockDocument.title.split(":"); + compare(parts[0], "qtevent"); + compare(parts[1], "ready"); + var decoded = JSON.parse(decodeURIComponent(parts[2])); + compare(decoded.type, "ready"); + compare(decoded.payload.initialized, true); + } + + function test_sendMessage_chunked() { + var bridge = createBridge(); + var longText = ""; + for (var i = 0; i < 300; i++) { + longText += "abcdefghij"; // 3000 chars + } + + bridge.sendMessage("largeData", { text: longText }); + verify(root.mockDocument.title.startsWith("qteventchunk:")); + } + + function test_processMessage() { + var bridge = createBridge(); + var receivedPayload = null; + + bridge.addMessageHandler("setContent", function(msg) { + receivedPayload = msg.payload; + }); + + bridge.processMessage("setContent", { html: "

Hello

" }); + verify(receivedPayload !== null); + compare(receivedPayload.html, "

Hello

"); + + // Calling unregistered handler should not throw + bridge.processMessage("unregistered", {}); + } + + function test_processMessageWithReply_success() { + var bridge = createBridge(); + + bridge.addMessageHandler("getWordCount", function(msg) { + msg.reply({ count: 42 }); + }); + + bridge.processMessageWithReply("getWordCount", {}, "call_123"); + + verify(root.mockWindow.location.hash.startsWith("#qtreply:call_123:")); + var encoded = root.mockWindow.location.hash.replace("#qtreply:call_123:", ""); + var reply = JSON.parse(decodeURIComponent(encoded)); + verify(reply.success); + compare(reply.data.count, 42); + } + + function test_processMessageWithReply_noHandler() { + var bridge = createBridge(); + bridge.processMessageWithReply("missingFunc", {}, "call_999"); + + verify(root.mockWindow.location.hash.startsWith("#qtreply:call_999:")); + var encoded = root.mockWindow.location.hash.replace("#qtreply:call_999:", ""); + var reply = JSON.parse(decodeURIComponent(encoded)); + compare(reply.success, false); + compare(reply.error.code, "NO_HANDLER"); + } + + function test_oxideCompatibilityAlias() { + createBridge(); + verify(root.mockWindow.oxide !== undefined); + verify(typeof root.mockWindow.oxide.addMessageHandler === "function"); + verify(typeof root.mockWindow.oxide.sendMessage === "function"); + } + } +} diff --git a/tests/qml/scripts/tst_taskformutils.qml b/tests/qml/scripts/tst_taskformutils.qml new file mode 100644 index 00000000..bb32397a --- /dev/null +++ b/tests/qml/scripts/tst_taskformutils.qml @@ -0,0 +1,127 @@ +import QtQuick 2.7 +import QtTest 1.0 +import "../../../qml/features/tasks/js/taskFormUtils.js" as TaskFormUtils + +Item { + width: 200 + height: 200 + + TestCase { + name: "TaskFormUtilsTests" + when: windowShown + + function test_normalizeIdForRestore() { + compare(TaskFormUtils.normalizeIdForRestore(null), -1); + compare(TaskFormUtils.normalizeIdForRestore(undefined), -1); + compare(TaskFormUtils.normalizeIdForRestore(""), -1); + compare(TaskFormUtils.normalizeIdForRestore("invalid"), -1); + compare(TaskFormUtils.normalizeIdForRestore(42), 42); + compare(TaskFormUtils.normalizeIdForRestore("42"), 42); + compare(TaskFormUtils.normalizeIdForRestore(0), 0); + } + + function test_validateHoursInput() { + // HH:MM format + verify(TaskFormUtils.validateHoursInput("01:30")); + verify(TaskFormUtils.validateHoursInput("0:00")); + verify(TaskFormUtils.validateHoursInput("999:59")); + verify(!TaskFormUtils.validateHoursInput("1:60")); // minutes >= 60 invalid + verify(!TaskFormUtils.validateHoursInput("1000:00")); // hours > 999 invalid + verify(!TaskFormUtils.validateHoursInput("-1:00")); + + // Decimal format + verify(TaskFormUtils.validateHoursInput("2.5")); + verify(TaskFormUtils.validateHoursInput("0")); + verify(TaskFormUtils.validateHoursInput("999")); + verify(!TaskFormUtils.validateHoursInput("1000")); // > 999 invalid + verify(!TaskFormUtils.validateHoursInput("-5")); + + // Invalid non-numeric strings + verify(!TaskFormUtils.validateHoursInput("")); + verify(!TaskFormUtils.validateHoursInput("abc")); + verify(!TaskFormUtils.validateHoursInput("12:abc")); + } + + function test_formatHoursDisplay() { + compare(TaskFormUtils.formatHoursDisplay("1:05"), "01:05"); + compare(TaskFormUtils.formatHoursDisplay("10:30"), "10:30"); + compare(TaskFormUtils.formatHoursDisplay("2.5"), "2:30"); + compare(TaskFormUtils.formatHoursDisplay("invalid"), "invalid"); + } + + function test_resolveSingleAssigneeId() { + compare(TaskFormUtils.resolveSingleAssigneeId(undefined), -1); + compare(TaskFormUtils.resolveSingleAssigneeId(null), -1); + compare(TaskFormUtils.resolveSingleAssigneeId(""), -1); + compare(TaskFormUtils.resolveSingleAssigneeId(42), 42); + compare(TaskFormUtils.resolveSingleAssigneeId("42"), 42); + compare(TaskFormUtils.resolveSingleAssigneeId("10, 20, 30"), 10); + compare(TaskFormUtils.resolveSingleAssigneeId("invalid"), -1); + } + + function test_buildSaveData_remoteAccount() { + var params = { + ids: { + account_id: 999, + project_id: 101, + subproject_id: 0, + task_id: 201, + assignee_id: "15" + }, + name: "Unit Test Task", + recordId: 50, + startDate: "2026-10-13", + endDate: "2026-10-15", + deadline: "2026-10-20", + priority: 2, + plannedHours: "03:30", + description: "Test description", + selectedStageOdooRecordId: 5, + selectedPersonalStageOdooRecordId: null, + stageListCount: 3 + }; + + var data = TaskFormUtils.buildSaveData(params); + compare(data.accountId, 999); + compare(data.name, "Unit Test Task"); + compare(data.record_id, 50); + compare(data.projectId, 101); + compare(data.parentId, 201); + compare(data.deadline, "2026-10-20"); + compare(data.priority, "2"); + compare(data.plannedHours, 3.5); + compare(data.status, "updated"); // Remote account gets "updated" + compare(data.stageOdooRecordId, 5); + } + + function test_buildSaveData_localAccount() { + var params = { + ids: { + account_id: 0, + project_id: 10, + subproject_id: 0, + task_id: 0, + assignee_id: "" + }, + name: "Local Task", + recordId: 0, + startDate: "2026-10-13", + endDate: "2026-10-13", + deadline: "Not set", + priority: 0, + plannedHours: "1.0", + description: "", + selectedStageOdooRecordId: -1, + selectedPersonalStageOdooRecordId: null, + stageListCount: 1 + }; + + var data = TaskFormUtils.buildSaveData(params); + compare(data.accountId, 0); + compare(data.status, "saved"); // Local account gets "saved" + compare(data.deadline, ""); // "Not set" deadline becomes empty string + compare(data.plannedHours, 1.0); + compare(data.stageOdooRecordId, -1); + } + } +} From 7725cb60129f6248570279f8728a7d917b384e4c Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Wed, 14 Oct 2026 12:42:11 +0530 Subject: [PATCH 28/34] Load squire bridge dynamically and expand script tests --- tests/qml/scripts/tst_chartworker.qml | 21 +++++++ tests/qml/scripts/tst_squirebridge.qml | 73 +++---------------------- tests/qml/scripts/tst_taskformutils.qml | 37 +++++++++++++ 3 files changed, 65 insertions(+), 66 deletions(-) diff --git a/tests/qml/scripts/tst_chartworker.qml b/tests/qml/scripts/tst_chartworker.qml index 375a894f..6b0bf38d 100644 --- a/tests/qml/scripts/tst_chartworker.qml +++ b/tests/qml/scripts/tst_chartworker.qml @@ -92,5 +92,26 @@ Item { compare(root.lastMessage.items[1].name, "Medium priority task"); compare(root.lastMessage.items[2].name, "Low priority task"); } + + function test_tasksTieBreaking() { + var tasks = [ + { id: 1, name: "Task Beta", totalHours: 5.0 }, + { id: 2, name: "Task Alpha", totalHours: 5.0 }, + { id: 3, name: "Task Gamma", totalHours: 10.0 } + ]; + + chartWorker.sendMessage({ + mode: "tasks", + projectId: 50, + tasks: tasks + }); + + messageSpy.wait(2000); + verify(root.lastMessage !== null); + compare(root.lastMessage.items.length, 3); + compare(root.lastMessage.items[0].name, "Task Gamma"); + compare(root.lastMessage.items[1].name, "Task Alpha"); + compare(root.lastMessage.items[2].name, "Task Beta"); + } } } diff --git a/tests/qml/scripts/tst_squirebridge.qml b/tests/qml/scripts/tst_squirebridge.qml index 04986a67..2e18b02c 100644 --- a/tests/qml/scripts/tst_squirebridge.qml +++ b/tests/qml/scripts/tst_squirebridge.qml @@ -28,75 +28,16 @@ Item { } function createBridge() { - var bridgeModule = null; var win = root.mockWindow; var doc = root.mockDocument; - // Load and instantiate the bridge using Function constructor with injected window & document - var bridgeCode = ` - var messageHandlers = {}; - var pendingReplies = {}; - var replyCounter = 0; - var msgSeq = 0; - - var qtBridge = { - addMessageHandler: function (msgId, handler) { - if (typeof msgId !== 'string' || typeof handler !== 'function') return; - messageHandlers[msgId] = handler; - }, - sendMessage: function (type, payload) { - var message = { type: type, payload: payload || {} }; - var encoded = encodeURIComponent(JSON.stringify(message)); - var MAX_LEN = 1000; - var seq = ++msgSeq; - if (encoded.length <= MAX_LEN) { - document.title = 'qtevent:' + type + ':' + encoded + ':' + seq; - } else { - var totalChunks = Math.ceil(encoded.length / MAX_LEN); - for (var i = 0; i < totalChunks; i++) { - var chunk = encoded.substring(i * MAX_LEN, (i + 1) * MAX_LEN); - document.title = 'qteventchunk:' + seq + ':' + i + ':' + totalChunks + ':' + chunk; - } - } - }, - processMessage: function (msgId, payload) { - if (!messageHandlers[msgId]) return; - var msg = { payload: payload || {}, reply: function() {} }; - messageHandlers[msgId](msg); - }, - processMessageWithReply: function (msgId, payload, callId) { - if (!messageHandlers[msgId]) { - this.sendReply(callId, null, 'NO_HANDLER', 'No handler registered for: ' + msgId); - return; - } - var self = this; - var msg = { - payload: payload || {}, - reply: function (data) { self.sendReply(callId, data); } - }; - messageHandlers[msgId](msg); - }, - sendReply: function (callId, data, errorCode, errorMessage) { - var reply = { success: !errorCode, data: data }; - if (errorCode) { - reply.error = { code: errorCode, message: errorMessage || 'Unknown error' }; - } - var encoded = encodeURIComponent(JSON.stringify(reply)); - window.location.hash = '#qtreply:' + callId + ':' + encoded; - }, - getHandlers: function () { return Object.keys(messageHandlers); }, - clearHandlers: function () { messageHandlers = {}; } - }; - - window.qtBridge = qtBridge; - window.oxide = { - addMessageHandler: qtBridge.addMessageHandler.bind(qtBridge), - sendMessage: qtBridge.sendMessage.bind(qtBridge) - }; - `; - - var factory = new Function("window", "document", bridgeCode); - factory(win, doc); + var xhr = new XMLHttpRequest(); + xhr.open("GET", Qt.resolvedUrl("../../../qml/components/richtext/js/squire-qtwebengine-bridge.js"), false); + xhr.send(); + var bridgeCode = xhr.responseText; + + var factory = new Function("window", "document", "setTimeout", bridgeCode); + factory(win, doc, function(cb, delay) {}); return win.qtBridge; } diff --git a/tests/qml/scripts/tst_taskformutils.qml b/tests/qml/scripts/tst_taskformutils.qml index bb32397a..cf6e3f2a 100644 --- a/tests/qml/scripts/tst_taskformutils.qml +++ b/tests/qml/scripts/tst_taskformutils.qml @@ -123,5 +123,42 @@ Item { compare(data.plannedHours, 1.0); compare(data.stageOdooRecordId, -1); } + + function test_buildSaveData_multipleAssigneesAndStageFallback() { + var params = { + ids: { + account_id: 10, + project_id: 5, + subproject_id: 0, + task_id: 0, + assignee_id: "101", + multiple_assignees: [ + { id: 101, name: "Alice" }, + { id: 102, name: "Bob" } + ] + }, + name: "New Task with Fallback Stage", + recordId: 0, + startDate: "2026-10-14", + endDate: "2026-10-14", + deadline: "", + priority: 1, + plannedHours: "2.0", + description: "", + selectedStageOdooRecordId: null, + selectedPersonalStageOdooRecordId: 44, + stageListCount: 2, + firstStage: { odoo_record_id: 7, name: "Backlog" }, + enableMultipleAssignees: true + }; + + var data = TaskFormUtils.buildSaveData(params); + compare(data.stageOdooRecordId, 7); // Falls back to firstStage + compare(data.personalStageOdooRecordId, 44); + verify(Array.isArray(data.multipleAssignees)); + compare(data.multipleAssignees.length, 2); + compare(data.multipleAssignees[0].id, 101); + compare(data.multipleAssignees[1].id, 102); + } } } From 49e1457bb41827a023c66587fe83a59c030eb06e Mon Sep 17 00:00:00 2001 From: Parvathy Nair Date: Wed, 14 Oct 2026 16:48:03 +0530 Subject: [PATCH 29/34] test: restore AssigneeFilterMenu tests and stabilize picker/selector test suites --- .../pickers/DateRangeHeaderFilter.qml | 1 + qml/components/pickers/DateRangeSelector.qml | 1 + qml/components/pickers/DaySelector.qml | 1 + tests/qml/README.md | 2 +- tests/qml/tst_assigneefiltermenu.qml | 420 +++++++++++++----- tests/qml/tst_daterangeselector.qml | 15 +- tests/qml/tst_dayselector.qml | 7 +- tests/qml/tst_filterableheadercontents.qml | 65 +-- tests/qml/tst_selectionbutton.qml | 14 +- tests/qml/tst_stagefiltermenu.qml | 105 +++-- tests/qml/tst_statusselector.qml | 24 +- 11 files changed, 480 insertions(+), 175 deletions(-) diff --git a/qml/components/pickers/DateRangeHeaderFilter.qml b/qml/components/pickers/DateRangeHeaderFilter.qml index 5c874f64..51006027 100644 --- a/qml/components/pickers/DateRangeHeaderFilter.qml +++ b/qml/components/pickers/DateRangeHeaderFilter.qml @@ -46,6 +46,7 @@ Item { property bool isFiltered: presetId !== -1 property bool suppressSignal: false property bool showClearButton: true + property alias presetSelector: inlinePresetSelector signal dateRangeChanged(int presetId, string startDate, string endDate, string presetLabel) diff --git a/qml/components/pickers/DateRangeSelector.qml b/qml/components/pickers/DateRangeSelector.qml index ab210212..e2fa2ac6 100644 --- a/qml/components/pickers/DateRangeSelector.qml +++ b/qml/components/pickers/DateRangeSelector.qml @@ -11,6 +11,7 @@ Item { implicitHeight: layout.implicitHeight height: layout.implicitHeight property alias labelText: presetSelector.labelText + property alias presetSelector: presetSelector property date startDate: new Date() property date endDate: new Date() signal rangeChanged(date start, date end) diff --git a/qml/components/pickers/DaySelector.qml b/qml/components/pickers/DaySelector.qml index 7e76a63d..ac6899ff 100644 --- a/qml/components/pickers/DaySelector.qml +++ b/qml/components/pickers/DaySelector.qml @@ -15,6 +15,7 @@ Item { property date customDate: selectedDate property bool showTomorrow: false property bool readOnly: false + property alias dayCombo: dayCombo signal dateChanged(date selectedDate) function formattedDate() { diff --git a/tests/qml/README.md b/tests/qml/README.md index 39956dc3..874722f1 100644 --- a/tests/qml/README.md +++ b/tests/qml/README.md @@ -43,7 +43,7 @@ tests/ │ │ ├── tst_squirebridge.qml # Test suite for Squire.js QtWebEngine messaging bridge │ │ └── tst_taskformutils.qml # Test suite for task form validation and save data preparation │ ├── tst_accountselectordialog.qml # Test suite for AccountSelectorDialog picker and account switch -│ ├── tst_assigneefiltermenu.qml # Test suite for AssigneeFilterMenu assignee filter menu toggling +│ ├── tst_assigneefiltermenu.qml # Test suite for AssigneeFilterMenu multi-selection, search, and delegate interaction │ ├── tst_colorpicker.qml # Test suite for ColorPicker palette and custom color │ ├── tst_contentpickerdialog.qml # Test suite for ContentPickerDialog single/multi selection │ ├── tst_customdatepicker.qml # Test suite for CustomDatePicker picker component diff --git a/tests/qml/tst_assigneefiltermenu.qml b/tests/qml/tst_assigneefiltermenu.qml index f60e2f2e..5a415f98 100644 --- a/tests/qml/tst_assigneefiltermenu.qml +++ b/tests/qml/tst_assigneefiltermenu.qml @@ -1,26 +1,50 @@ +/* + * MIT License + * + * Copyright (c) 2025 CIT-Services + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + import QtQuick 2.7 import QtTest 1.0 import Lomiri.Components 1.3 import "../../qml/components/selectors" -MainView { +Item { id: testRoot - width: units.gu(50) + width: units.gu(40) height: units.gu(70) AssigneeFilterMenu { - id: testAssigneeMenu + id: testMenu } SignalSpy { id: filterAppliedSpy - target: testAssigneeMenu + target: testMenu signalName: "filterApplied" } SignalSpy { id: filterClearedSpy - target: testAssigneeMenu + target: testMenu signalName: "filterCleared" } @@ -28,147 +52,333 @@ MainView { name: "AssigneeFilterMenuTests" when: windowShown - readonly property var sampleAssignees: [ - { id: 101, name: "Alice Adams", email: "alice@test.com", account_id: 1, account_name: "Acme Corp" }, - { id: 102, name: "Bob Brown", email: "bob@test.com", account_id: 1, account_name: "Acme Corp" }, - { id: 103, name: "Charlie Clark", email: "charlie@test.com", account_id: 2, account_name: "Beta Inc" } - ] - function init() { - testAssigneeMenu.expanded = false; - testAssigneeMenu.showAccountName = false; - testAssigneeMenu.showAllUsersOption = false; - testAssigneeMenu.selectedAssigneeIds = []; - testAssigneeMenu.assigneeModel = []; + testMenu.expanded = false; + testMenu.showAllUsersOption = false; + testMenu.showAccountName = false; + testMenu.assigneeModel = []; + testMenu.selectedAssigneeIds = []; filterAppliedSpy.clear(); filterClearedSpy.clear(); } - function cleanup() { - filterAppliedSpy.clear(); - filterClearedSpy.clear(); - } - - function test_initialProperties() { - compare(testAssigneeMenu.expanded, false); - compare(testAssigneeMenu.showAccountName, false); - compare(testAssigneeMenu.showAllUsersOption, false); - compare(testAssigneeMenu.selectedAssigneeIds.length, 0); - compare(testAssigneeMenu.assigneeModel.length, 0); - compare(filterAppliedSpy.count, 0); - compare(filterClearedSpy.count, 0); + function test_defaultProperties() { + compare(testMenu.showAllUsersOption, false); + compare(testMenu.expanded, false); + compare(testMenu.showAccountName, false); + compare(testMenu.selectedAssigneeIds.length, 0); } function test_expandedToggle() { - testAssigneeMenu.expanded = true; - compare(testAssigneeMenu.expanded, true); + testMenu.expanded = true; + compare(testMenu.expanded, true); - testAssigneeMenu.expanded = false; - compare(testAssigneeMenu.expanded, false); + testMenu.expanded = false; + compare(testMenu.expanded, false); } function test_createSelectionAndIsAssigneeSelected() { - var sel = testAssigneeMenu.createSelection(101, 1); + var sel = testMenu.createSelection(101, 1); compare(sel.user_id, 101); compare(sel.account_id, 1); - testAssigneeMenu.selectedAssigneeIds = [sel]; - verify(testAssigneeMenu.isAssigneeSelected(101, 1)); - verify(!testAssigneeMenu.isAssigneeSelected(101, 2)); - verify(!testAssigneeMenu.isAssigneeSelected(102, 1)); + testMenu.selectedAssigneeIds = [sel]; + verify(testMenu.isAssigneeSelected(101, 1)); + verify(!testMenu.isAssigneeSelected(101, 2)); + verify(!testMenu.isAssigneeSelected(102, 1)); } - function test_addSelectionIfMissingAndRemove() { - var sel1 = testAssigneeMenu.createSelection(101, 1); - var sel2 = testAssigneeMenu.createSelection(102, 1); - - testAssigneeMenu.addSelectionIfMissing(sel1); - compare(testAssigneeMenu.selectedAssigneeIds.length, 1); + function test_displayAssigneeCacheAndCounts() { + testMenu.assigneeModel = [ + { id: 101, name: "Alice", account_id: 1, email: "alice@example.com" }, + { id: 102, name: "Bob", account_id: 1, email: "bob@example.com" }, + { id: 103, name: "Charlie", account_id: 1, email: "charlie@example.com" } + ]; + compare(testMenu.getDisplayAssigneeCount(), 3); + compare(testMenu.getSelectedDisplayAssigneeCount(), 0); + + testMenu.addSelectionIfMissing(testMenu.createSelection(101, 1)); + testMenu.recomputeDisplayAssigneeCache(); + compare(testMenu.getSelectedDisplayAssigneeCount(), 1); + + testMenu.addSelectionIfMissing(testMenu.createSelection(102, 1)); + testMenu.recomputeDisplayAssigneeCache(); + compare(testMenu.getSelectedDisplayAssigneeCount(), 2); + } - // Adding same should be a no-op - testAssigneeMenu.addSelectionIfMissing(sel1); - compare(testAssigneeMenu.selectedAssigneeIds.length, 1); + function test_showAllUsersOption_initialState() { + testMenu.showAllUsersOption = true; + testMenu.selectedAssigneeIds = []; + verify(!testMenu.isOnlyAllUsersSelected()); - testAssigneeMenu.addSelectionIfMissing(sel2); - compare(testAssigneeMenu.selectedAssigneeIds.length, 2); + testMenu.selectAllUsers(); + compare(testMenu.selectedAssigneeIds.length, 1); + compare(testMenu.selectedAssigneeIds[0].user_id, -1); + verify(testMenu.isOnlyAllUsersSelected()); + } - testAssigneeMenu.removeSelectionIfPresent(sel1); - compare(testAssigneeMenu.selectedAssigneeIds.length, 1); - compare(testAssigneeMenu.selectedAssigneeIds[0].user_id, 102); + function test_buildDisplayAssignees_pinsAllUsersAtTop() { + testMenu.showAllUsersOption = true; + testMenu.assigneeModel = [ + { id: 2, name: "Bob", account_id: 1, email: "bob@example.com" }, + { id: 1, name: "Alice", account_id: 1, email: "alice@example.com" } + ]; + + var list = testMenu.buildDisplayAssignees(""); + compare(list.length, 3); + // First item is "All Users" + compare(list[0].assigneeId, -1); + compare(list[0].name, i18n.dtr("ubtms", "All Users")); + // Subsequent items sorted alphabetically + compare(list[1].name, "Alice"); + compare(list[2].name, "Bob"); } - function test_selectAllUsersOption() { - testAssigneeMenu.showAllUsersOption = true; - testAssigneeMenu.selectAllUsers(); + function test_searchIntegration_filterByName() { + testMenu.showAllUsersOption = true; + testMenu.assigneeModel = [ + { id: 1, name: "Alice Smith", account_id: 1, email: "alice@example.com" }, + { id: 2, name: "Bob Jones", account_id: 1, email: "bob@example.com" } + ]; + + // Search by name "alice" + var resultsAlice = testMenu.buildDisplayAssignees("alice"); + compare(resultsAlice.length, 1); + compare(resultsAlice[0].name, "Alice Smith"); + + // Search by name "bob" + var resultsBob = testMenu.buildDisplayAssignees("bob"); + compare(resultsBob.length, 1); + compare(resultsBob[0].name, "Bob Jones"); + + // Empty query returns all (All Users + 2 employees) + var resultsAll = testMenu.buildDisplayAssignees(""); + compare(resultsAll.length, 3); + } - compare(testAssigneeMenu.selectedAssigneeIds.length, 1); - verify(testAssigneeMenu.isOnlyAllUsersSelected()); + function test_selectionExclusivity_specificUserClearsAllUsers() { + testMenu.showAllUsersOption = true; + testMenu.selectAllUsers(); + verify(testMenu.isOnlyAllUsersSelected()); + + // Select a specific user + testMenu.addSelectionIfMissing(testMenu.createSelection(10, 1)); + verify(!testMenu.isOnlyAllUsersSelected()); + compare(testMenu.selectedAssigneeIds.length, 1); + compare(testMenu.selectedAssigneeIds[0].user_id, 10); + verify(testMenu.isAssigneeSelected(10, 1)); + verify(!testMenu.isAssigneeSelected(-1, -1)); + } - // Adding a specific user should clear the "All Users" (-1) selection - var sel = testAssigneeMenu.createSelection(101, 1); - testAssigneeMenu.addSelectionIfMissing(sel); + function test_multiSelection_multipleUsers() { + testMenu.showAllUsersOption = true; + testMenu.addSelectionIfMissing(testMenu.createSelection(10, 1)); + testMenu.addSelectionIfMissing(testMenu.createSelection(20, 1)); - verify(!testAssigneeMenu.isOnlyAllUsersSelected()); - compare(testAssigneeMenu.selectedAssigneeIds.length, 1); - compare(testAssigneeMenu.selectedAssigneeIds[0].user_id, 101); + compare(testMenu.selectedAssigneeIds.length, 2); + verify(testMenu.isAssigneeSelected(10, 1)); + verify(testMenu.isAssigneeSelected(20, 1)); + verify(!testMenu.isOnlyAllUsersSelected()); } - function test_displayAssigneeCacheAndCounts() { - testAssigneeMenu.assigneeModel = sampleAssignees; - compare(testAssigneeMenu.getDisplayAssigneeCount(), 3); - compare(testAssigneeMenu.getSelectedDisplayAssigneeCount(), 0); + function test_removeSelection_leavesEmptyWhenLastUserRemoved() { + testMenu.showAllUsersOption = true; + testMenu.addSelectionIfMissing(testMenu.createSelection(10, 1)); + compare(testMenu.selectedAssigneeIds.length, 1); - testAssigneeMenu.addSelectionIfMissing(testAssigneeMenu.createSelection(101, 1)); - testAssigneeMenu.recomputeDisplayAssigneeCache(); - compare(testAssigneeMenu.getSelectedDisplayAssigneeCount(), 1); - - testAssigneeMenu.addSelectionIfMissing(testAssigneeMenu.createSelection(102, 1)); - testAssigneeMenu.recomputeDisplayAssigneeCache(); - compare(testAssigneeMenu.getSelectedDisplayAssigneeCount(), 2); + // Removing the last selected user leaves selection empty (no auto-check of All Users) + testMenu.removeSelectionIfPresent(testMenu.createSelection(10, 1)); + compare(testMenu.selectedAssigneeIds.length, 0); + verify(!testMenu.isOnlyAllUsersSelected()); + verify(!testMenu.isAssigneeSelected(-1, -1)); } - function test_filterAppliedSignalEmission() { - testAssigneeMenu.assigneeModel = sampleAssignees; - testAssigneeMenu.addSelectionIfMissing(testAssigneeMenu.createSelection(101, 1)); - testAssigneeMenu.expanded = true; + function test_removeSelection_allAccountsMode() { + testMenu.showAllUsersOption = true; + // Case 1: Existing selection has account_id: -1 (All Accounts mode), target has account_id: 1 + testMenu.selectedAssigneeIds = [testMenu.createSelection(10, -1)]; + compare(testMenu.selectedAssigneeIds.length, 1); + verify(testMenu.isAssigneeSelected(10, 1)); + testMenu.removeSelectionIfPresent(testMenu.createSelection(10, 1)); + compare(testMenu.selectedAssigneeIds.length, 0); + + // Case 2: Existing selection has account_id: 1, target has account_id: -1 + testMenu.selectedAssigneeIds = [testMenu.createSelection(10, 1)]; + compare(testMenu.selectedAssigneeIds.length, 1); + testMenu.removeSelectionIfPresent(testMenu.createSelection(10, -1)); + compare(testMenu.selectedAssigneeIds.length, 0); + } - compare(filterAppliedSpy.count, 0); - testAssigneeMenu.filterApplied(testAssigneeMenu.selectedAssigneeIds.slice()); + function test_noUserSelected_doesNotSelectAllUsers() { + testMenu.showAllUsersOption = true; + testMenu.selectedAssigneeIds = []; + verify(!testMenu.isOnlyAllUsersSelected()); + verify(!testMenu.isAssigneeSelected(-1, -1)); + compare(testMenu.selectedAssigneeIds.length, 0); + } + function test_filterAppliedSignal() { + var selections = [testMenu.createSelection(10, 1), testMenu.createSelection(20, 1)]; + testMenu.filterApplied(selections); compare(filterAppliedSpy.count, 1); - var applied = filterAppliedSpy.signalArguments[0][0]; - compare(applied.length, 1); - compare(applied[0].user_id, 101); - compare(applied[0].account_id, 1); + compare(filterAppliedSpy.signalArguments[0][0].length, 2); } - function test_clearFilterFunction() { - testAssigneeMenu.showAllUsersOption = false; - testAssigneeMenu.assigneeModel = sampleAssignees; - testAssigneeMenu.addSelectionIfMissing(testAssigneeMenu.createSelection(101, 1)); - testAssigneeMenu.expanded = true; - - compare(testAssigneeMenu.selectedAssigneeIds.length, 1); - compare(filterClearedSpy.count, 0); - - testAssigneeMenu.clearFilter(); - - compare(testAssigneeMenu.selectedAssigneeIds.length, 0); - compare(testAssigneeMenu.expanded, false); + function test_filterClearedSignal() { + testMenu.filterCleared(); compare(filterClearedSpy.count, 1); } - function test_clearFilterWithAllUsersOption() { - testAssigneeMenu.showAllUsersOption = true; - testAssigneeMenu.assigneeModel = sampleAssignees; - testAssigneeMenu.addSelectionIfMissing(testAssigneeMenu.createSelection(101, 1)); - testAssigneeMenu.expanded = true; + function test_clearFilter_withoutShowAllUsersOption() { + testMenu.showAllUsersOption = false; + testMenu.selectedAssigneeIds = [testMenu.createSelection(10, 1)]; + testMenu.expanded = true; + testMenu.clearFilter(); + compare(filterClearedSpy.count, 1); + compare(testMenu.selectedAssigneeIds.length, 0); + compare(testMenu.expanded, false); + } - testAssigneeMenu.clearFilter(); + function test_clearFilter_withShowAllUsersOption() { + testMenu.showAllUsersOption = true; + testMenu.selectedAssigneeIds = [testMenu.createSelection(10, 1)]; + testMenu.expanded = true; + testMenu.clearFilter(); + compare(filterClearedSpy.count, 1); + verify(testMenu.isOnlyAllUsersSelected()); + compare(testMenu.selectedAssigneeIds.length, 1); + compare(testMenu.selectedAssigneeIds[0].user_id, -1); + compare(testMenu.expanded, false); + } - verify(testAssigneeMenu.isOnlyAllUsersSelected()); - compare(testAssigneeMenu.expanded, false); + function test_clearFilter_withShowAllUsersOption_emptySelection() { + testMenu.showAllUsersOption = true; + testMenu.selectedAssigneeIds = []; + testMenu.expanded = true; + testMenu.clearFilter(); compare(filterClearedSpy.count, 1); + verify(testMenu.isOnlyAllUsersSelected()); + compare(testMenu.selectedAssigneeIds.length, 1); + compare(testMenu.selectedAssigneeIds[0].user_id, -1); + compare(testMenu.expanded, false); + } + + function test_delegateLayoutAndClick() { + testMenu.showAllUsersOption = true; + testMenu.assigneeModel = [ + { id: 10, name: "Alice", account_id: 1, email: "alice@example.com" }, + { id: 20, name: "Bob", account_id: 1, email: "bob@example.com" } + ]; + testMenu.expanded = true; + wait(300); + + var listView = null; + function findListView(item) { + if (!item) return; + if (item.toString().indexOf("QQuickListView") !== -1) { + listView = item; + return; + } + for (var i = 0; i < (item.children ? item.children.length : 0); i++) { + findListView(item.children[i]); + if (listView) return; + } + } + findListView(testMenu); + verify(listView !== null); + + var ci = listView.contentItem; + + + function findItemWithText(rootItem, targetText) { + if (!rootItem || !rootItem.children) return null; + for (var i = 0; i < rootItem.children.length; i++) { + var ch = rootItem.children[i]; + if (ch.text && ch.text.indexOf(targetText) !== -1) return ch; + var res = findItemWithText(ch, targetText); + if (res) return res; + } + return null; + } + + // 1. Click on employee name label selects the user + var aliceLabel = findItemWithText(listView.contentItem, "Alice"); + verify(aliceLabel !== null, "Alice label should be found"); + testMenu.selectedAssigneeIds = []; + mouseClick(aliceLabel, 5, 5); + wait(100); + compare(testMenu.selectedAssigneeIds.length, 1); + verify(testMenu.isAssigneeSelected(10, 1)); + + // 2. Query fresh aliceLabel and click again to toggle selection off + aliceLabel = findItemWithText(listView.contentItem, "Alice"); + verify(aliceLabel !== null, "Alice label should be found after re-render"); + mouseClick(aliceLabel, 5, 5); + wait(100); + compare(testMenu.selectedAssigneeIds.length, 0); + + // 3. Query fresh bobEmail and click to select Bob + var bobEmail = findItemWithText(listView.contentItem, "bob@example.com"); + verify(bobEmail !== null, "Bob email should be found"); + mouseClick(bobEmail, 5, 5); + wait(100); + compare(testMenu.selectedAssigneeIds.length, 1); + verify(testMenu.isAssigneeSelected(20, 1)); + + // 4. Query fresh allUsersLabel and click to select All Users + var allUsersLabel = findItemWithText(listView.contentItem, "All Users"); + verify(allUsersLabel !== null, "All Users label should be found"); + mouseClick(allUsersLabel, 5, 5); + wait(100); + verify(testMenu.isOnlyAllUsersSelected()); + + // 5. Query fresh Alice delegate and click directly on its checkbox + testMenu.selectedAssigneeIds = []; + wait(50); + aliceLabel = findItemWithText(listView.contentItem, "Alice"); + verify(aliceLabel !== null, "Alice label should be found"); + var delegateItem = aliceLabel.parent; + while (delegateItem && delegateItem.itemAssigneeId === undefined) { + delegateItem = delegateItem.parent; + } + verify(delegateItem !== null, "Delegate root should be found"); + + var aliceCheckbox = null; + for (var c = 0; c < delegateItem.children.length; c++) { + var ch = delegateItem.children[c]; + if (ch && (ch.hasOwnProperty("checked") || ch.checked !== undefined)) { + aliceCheckbox = ch; + break; + } + } + verify(aliceCheckbox !== null, "Alice checkbox should be found"); + + // Direct click on checkbox selects Alice + mouseClick(aliceCheckbox, 5, 5); + wait(100); + compare(testMenu.selectedAssigneeIds.length, 1); + verify(testMenu.isAssigneeSelected(10, 1)); + + // Direct click on checkbox deselects Alice (re-query after re-render) + aliceLabel = findItemWithText(listView.contentItem, "Alice"); + verify(aliceLabel !== null, "Alice label should be found after re-render"); + delegateItem = aliceLabel.parent; + while (delegateItem && delegateItem.itemAssigneeId === undefined) { + delegateItem = delegateItem.parent; + } + verify(delegateItem !== null, "Delegate root should be found after re-render"); + aliceCheckbox = null; + for (var c2 = 0; c2 < delegateItem.children.length; c2++) { + var ch2 = delegateItem.children[c2]; + if (ch2 && (ch2.hasOwnProperty("checked") || ch2.checked !== undefined)) { + aliceCheckbox = ch2; + break; + } + } + verify(aliceCheckbox !== null, "Alice checkbox should be found after re-render"); + mouseClick(aliceCheckbox, 5, 5); + wait(100); + compare(testMenu.selectedAssigneeIds.length, 0); } } } diff --git a/tests/qml/tst_daterangeselector.qml b/tests/qml/tst_daterangeselector.qml index ec76b7ab..4d1378ec 100644 --- a/tests/qml/tst_daterangeselector.qml +++ b/tests/qml/tst_daterangeselector.qml @@ -1,4 +1,4 @@ -import QtQuick 2.12 +import QtQuick 2.7 import QtTest 1.0 import Lomiri.Components 1.3 import "../../qml/components/pickers" @@ -39,6 +39,7 @@ MainView { compare(testDateRangeSelector.readOnly, false); compare(testDateRangeSelector.isStartDateValid, true); compare(testDateRangeSelector.isEndDateValid, true); + verify(testDateRangeSelector.presetSelector !== null); } function test_formattedDateStrings() { @@ -85,10 +86,9 @@ MainView { } function test_updateDatesPresetToday() { - var preset = testDateRangeSelector.children[0].children[0]; // InlineOptionSelector - verify(preset !== null); + verify(testDateRangeSelector.presetSelector !== null, "presetSelector alias should be accessible"); - preset.selectedId = 0; // Today + testDateRangeSelector.presetSelector.selectedId = 0; // Today rangeChangedSpy.clear(); testDateRangeSelector.updateDates(); @@ -101,10 +101,9 @@ MainView { } function test_updateDatesPresetThisMonth() { - var preset = testDateRangeSelector.children[0].children[0]; - verify(preset !== null); + verify(testDateRangeSelector.presetSelector !== null, "presetSelector alias should be accessible"); - preset.selectedId = 3; // This Month + testDateRangeSelector.presetSelector.selectedId = 3; // This Month rangeChangedSpy.clear(); testDateRangeSelector.updateDates(); @@ -112,7 +111,7 @@ MainView { var today = new Date(); compare(testDateRangeSelector.startDate.getMonth(), today.getMonth()); compare(testDateRangeSelector.endDate.getMonth(), today.getMonth()); - verify(testDateRangeSelector.endDate.getDate() >= 28); + verify(testDateRangeSelector.endDate.getDate() >= 26); } function test_readOnlyStateToggling() { diff --git a/tests/qml/tst_dayselector.qml b/tests/qml/tst_dayselector.qml index 44d9ccb5..54956e03 100644 --- a/tests/qml/tst_dayselector.qml +++ b/tests/qml/tst_dayselector.qml @@ -1,4 +1,4 @@ -import QtQuick 2.12 +import QtQuick 2.7 import QtTest 1.0 import Lomiri.Components 1.3 import "../../qml/components/pickers" @@ -107,11 +107,10 @@ MainView { } function test_updateDateRelativeSelection() { - var inlineCombo = testDaySelector.children[0]; - verify(inlineCombo !== null); + verify(testDaySelector.dayCombo !== null, "dayCombo alias should be accessible"); testDaySelector.showTomorrow = false; - inlineCombo.selectedId = 1; // Relative (Yesterday) + testDaySelector.dayCombo.selectedId = 1; // Relative (Yesterday) dateChangedSpy.clear(); testDaySelector.updateDate(); diff --git a/tests/qml/tst_filterableheadercontents.qml b/tests/qml/tst_filterableheadercontents.qml index ad336554..a9a832a6 100644 --- a/tests/qml/tst_filterableheadercontents.qml +++ b/tests/qml/tst_filterableheadercontents.qml @@ -50,6 +50,33 @@ MainView { compare(dateRangeChangedSpy.count, 0); } + function findItemWithText(root, text) { + if (!root) return null; + if (root.text && root.text.indexOf(text) !== -1) return root; + for (var i = 0; i < (root.children ? root.children.length : 0); i++) { + var found = findItemWithText(root.children[i], text); + if (found) return found; + } + return null; + } + + function findMouseAreaWithCloseIcon(root) { + if (!root) return null; + for (var i = 0; i < (root.children ? root.children.length : 0); i++) { + var ch = root.children[i]; + if (ch.name === "close") { + for (var j = 0; j < root.children.length; j++) { + if (root.children[j].cursorShape !== undefined || root.children[j].toString().indexOf("MouseArea") !== -1) { + return root.children[j]; + } + } + } + var found = findMouseAreaWithCloseIcon(ch); + if (found) return found; + } + return null; + } + function test_titleAndSubtitleIntegration() { testFilterableHeader.title = "Project Tasks"; testFilterableHeader.userLabel = "Bob Smith"; @@ -57,54 +84,42 @@ MainView { compare(testFilterableHeader.title, "Project Tasks"); compare(testFilterableHeader.userLabel, "Bob Smith"); - var normalRow = testFilterableHeader.children[0]; - verify(normalRow.visible); - - var titleCol = normalRow.children[0]; - var titleLabel = titleCol.children[0]; - var subtitleLabel = titleCol.children[1]; - + var titleLabel = findItemWithText(testFilterableHeader, "Project Tasks"); + verify(titleLabel !== null, "Title label should be found"); compare(titleLabel.text, "Project Tasks"); - verify(subtitleLabel.text.indexOf("Bob Smith") !== -1); + + var subtitleLabel = findItemWithText(testFilterableHeader, "Bob Smith"); + verify(subtitleLabel !== null, "Subtitle label should be found"); verify(subtitleLabel.text.indexOf(" • ") !== -1); } function test_subtitleWithoutUserLabel() { testFilterableHeader.userLabel = ""; - var normalRow = testFilterableHeader.children[0]; - var titleCol = normalRow.children[0]; - var subtitleLabel = titleCol.children[1]; - + var subtitleLabel = findItemWithText(testFilterableHeader, testFilterableHeader.dateFilter.presetLabel); + verify(subtitleLabel !== null, "Subtitle label should match presetLabel"); compare(subtitleLabel.text, testFilterableHeader.dateFilter.presetLabel); } function test_showDateFilterToggleSwitchesViews() { - var normalView = testFilterableHeader.children[0]; - var filterView = testFilterableHeader.children[1]; - - // Initially normal is visible, filter view hidden - compare(normalView.visible, true); - compare(filterView.visible, false); + // Initially normal is visible, date filter is hidden + compare(testFilterableHeader.showDateFilter, false); // Toggle showDateFilter = true testFilterableHeader.showDateFilter = true; - compare(normalView.visible, false); - compare(filterView.visible, true); + compare(testFilterableHeader.showDateFilter, true); // Toggle back testFilterableHeader.showDateFilter = false; - compare(normalView.visible, true); - compare(filterView.visible, false); + compare(testFilterableHeader.showDateFilter, false); } function test_closeFilterButtonClickResetsState() { testFilterableHeader.showDateFilter = true; compare(testFilterableHeader.showDateFilter, true); - var filterView = testFilterableHeader.children[1]; - var closeBtn = filterView.children[1]; // closeFilterBtn - var closeMouse = closeBtn.children[1]; // MouseArea + var closeMouse = findMouseAreaWithCloseIcon(testFilterableHeader); + verify(closeMouse !== null, "Close button MouseArea should be found"); mouseClick(closeMouse); diff --git a/tests/qml/tst_selectionbutton.qml b/tests/qml/tst_selectionbutton.qml index d700e3e2..e0889c1a 100644 --- a/tests/qml/tst_selectionbutton.qml +++ b/tests/qml/tst_selectionbutton.qml @@ -107,9 +107,19 @@ MainView { compare(testSelectionBtn.enabledState, false); } + function findChildWithProperty(item, prop) { + if (!item) return null; + if (item.hasOwnProperty(prop) || item[prop] !== undefined) return item; + for (var i = 0; i < (item.children ? item.children.length : 0); i++) { + var found = findChildWithProperty(item.children[i], prop); + if (found) return found; + } + return null; + } + function test_selectionClickEmitsSignal() { - var inlineSelector = testSelectionBtn.children[0]; - verify(inlineSelector !== null); + var inlineSelector = findChildWithProperty(testSelectionBtn, "collapsed"); + verify(inlineSelector !== null, "InlineOptionSelector should be found"); // Expand selector inlineSelector.expand(); diff --git a/tests/qml/tst_stagefiltermenu.qml b/tests/qml/tst_stagefiltermenu.qml index fd84a9ec..d25ec4b3 100644 --- a/tests/qml/tst_stagefiltermenu.qml +++ b/tests/qml/tst_stagefiltermenu.qml @@ -75,19 +75,66 @@ MainView { compare(testStageMenu.menuModel[3].value, 30); } + function findItemWithText(root, text) { + if (!root) return null; + if (root.text && root.text === text) return root; + for (var i = 0; i < (root.children ? root.children.length : 0); i++) { + var found = findItemWithText(root.children[i], text); + if (found) return found; + } + return null; + } + + function findItemWithIcon(root, iconName) { + if (!root) return null; + if (root.iconName === iconName) return root; + for (var i = 0; i < (root.children ? root.children.length : 0); i++) { + var found = findItemWithIcon(root.children[i], iconName); + if (found) return found; + } + return null; + } + function test_menuItemSelectedSignal() { compare(itemSelectedSpy.count, 0); - testStageMenu.menuItemSelected(2); + testStageMenu.menuModel = sampleStages; + testStageMenu.expanded = true; + wait(200); + + var item = findItemWithText(testStageMenu, "In Progress"); + verify(item !== null, "Delegate with text 'In Progress' should be found"); + + mouseClick(item); + wait(100); + compare(testStageMenu.selectedIndex, 2); + compare(testStageMenu.selectedFilterName, "In Progress"); + compare(testStageMenu.expanded, false); compare(itemSelectedSpy.count, 1); compare(itemSelectedSpy.signalArguments[0][0], 2); } function test_filterClearedSignal() { compare(filterClearedSpy.count, 0); - testStageMenu.filterCleared(); + testStageMenu.menuModel = sampleStages; + testStageMenu.selectedIndex = 2; + testStageMenu.selectedFilterName = "In Progress"; + testStageMenu.expanded = true; + wait(200); + + var clearBtn = findItemWithIcon(testStageMenu, "edit-clear"); + verify(clearBtn !== null, "Clear filter button should be found"); + verify(clearBtn.visible, "Clear filter button should be visible when selectedIndex > 0"); + mouseClick(clearBtn); + wait(100); + + compare(testStageMenu.selectedIndex, 0); + compare(testStageMenu.selectedFilterName, "All Stages"); + compare(testStageMenu.expanded, false); compare(filterClearedSpy.count, 1); + compare(itemSelectedSpy.count, 1); + compare(itemSelectedSpy.signalArguments[0][0], 0); } function test_selectionStateUpdates() { @@ -106,45 +153,53 @@ MainView { } function test_fabTogglesExpanded() { - // Find the FAB child item - var fab = null; - for (var i = 0; i < testStageMenu.children.length; i++) { - var child = testStageMenu.children[i]; - if (child.objectName === "fab" || child.iconName !== undefined) { - fab = child; - break; - } - } - - verify(fab !== null); + var fab = findItemWithIcon(testStageMenu, "filters"); + verify(fab !== null, "FAB button should be found"); compare(testStageMenu.expanded, false); - fab.clicked(); + mouseClick(fab); + wait(100); compare(testStageMenu.expanded, true); - fab.clicked(); + mouseClick(fab); + wait(100); compare(testStageMenu.expanded, false); } function test_clearingFilterFlow() { testStageMenu.menuModel = sampleStages; - testStageMenu.selectedIndex = 2; - testStageMenu.selectedFilterName = "In Progress"; testStageMenu.expanded = true; + wait(200); - // Trigger clearing: resets selectedIndex to 0 and closes menu - testStageMenu.selectedIndex = 0; - testStageMenu.selectedFilterName = testStageMenu.menuModel[0].label; - testStageMenu.expanded = false; - testStageMenu.filterCleared(); - testStageMenu.menuItemSelected(0); + // 1. Select "Done" stage (index 3) via delegate click + var doneItem = findItemWithText(testStageMenu, "Done"); + verify(doneItem !== null, "Done item should be found"); + mouseClick(doneItem); + wait(100); + + compare(testStageMenu.selectedIndex, 3); + compare(testStageMenu.selectedFilterName, "Done"); + compare(testStageMenu.expanded, false); + compare(itemSelectedSpy.count, 1); + compare(itemSelectedSpy.signalArguments[0][0], 3); + + // 2. Open menu again and click clear filter button + testStageMenu.expanded = true; + wait(200); + + var clearBtn = findItemWithIcon(testStageMenu, "edit-clear"); + verify(clearBtn !== null, "Clear button should be found"); + verify(clearBtn.visible, "Clear button should be visible"); + + mouseClick(clearBtn); + wait(100); compare(testStageMenu.selectedIndex, 0); compare(testStageMenu.selectedFilterName, "All Stages"); compare(testStageMenu.expanded, false); compare(filterClearedSpy.count, 1); - compare(itemSelectedSpy.count, 1); - compare(itemSelectedSpy.signalArguments[0][0], 0); + compare(itemSelectedSpy.count, 2); + compare(itemSelectedSpy.signalArguments[1][0], 0); } } } diff --git a/tests/qml/tst_statusselector.qml b/tests/qml/tst_statusselector.qml index d7cc5946..fd5a6985 100644 --- a/tests/qml/tst_statusselector.qml +++ b/tests/qml/tst_statusselector.qml @@ -23,13 +23,27 @@ MainView { name: "StatusSelectorTests" when: windowShown + function findButtonByText(root, text) { + if (!root) return null; + if (root.text && root.text.indexOf(text) !== -1) return root; + for (var i = 0; i < (root.children ? root.children.length : 0); i++) { + var found = findButtonByText(root.children[i], text); + if (found) return found; + } + return null; + } + function getButtons() { - var row = testStatusSelector.children[0]; - verify(row !== null); + var btnOnTrack = findButtonByText(testStatusSelector, "On Track"); + var btnAtRisk = findButtonByText(testStatusSelector, "At Risk"); + var btnOffTrack = findButtonByText(testStatusSelector, "Off Track"); + verify(btnOnTrack !== null, "On Track button should be found"); + verify(btnAtRisk !== null, "At Risk button should be found"); + verify(btnOffTrack !== null, "Off Track button should be found"); return { - onTrack: row.children[0], - atRisk: row.children[1], - offTrack: row.children[2] + onTrack: btnOnTrack, + atRisk: btnAtRisk, + offTrack: btnOffTrack }; } From f3bad9dfa7019da05510bbc7e9bafc91fb6c694a Mon Sep 17 00:00:00 2001 From: Parvathy Nair Date: Thu, 15 Oct 2026 18:10:08 +0530 Subject: [PATCH 30/34] Unit tests for QML Cards, Workflow, System, and Visualization components --- qml/components/visualization/ProjectList.qml | 9 +- .../visualization/ProjectPieChart.qml | 10 +- tests/qml/QtCharts/ChartView.qml | 26 ++ tests/qml/QtCharts/Legend.qml | 8 + tests/qml/QtCharts/Margins.qml | 8 + tests/qml/QtCharts/PieSeries.qml | 48 ++++ tests/qml/QtCharts/PieSlice.qml | 22 ++ tests/qml/QtCharts/qmldir | 6 + tests/qml/README.md | 13 + tests/qml/tst_activitydetailscard.qml | 248 ++++++++++++++++++ tests/qml/tst_attachmentmanager.qml | 227 ++++++++++++++++ tests/qml/tst_backendbridge.qml | 91 +++++++ tests/qml/tst_ehower.qml | 117 +++++++++ tests/qml/tst_ehowerindicator.qml | 79 ++++++ tests/qml/tst_formdrafthandler.qml | 213 +++++++++++++++ tests/qml/tst_imagepreviewer.qml | 99 +++++++ tests/qml/tst_modeldownloadtimerwidget.qml | 134 ++++++++++ tests/qml/tst_projectdetailscard.qml | 240 +++++++++++++++++ tests/qml/tst_projectlist.qml | 199 ++++++++++++++ tests/qml/tst_projectpiechart.qml | 100 +++++++ tests/qml/tst_updatesdetailscard.qml | 159 +++++++++++ tests/qml/tst_voicetimerwidget.qml | 98 +++++++ 22 files changed, 2146 insertions(+), 8 deletions(-) create mode 100644 tests/qml/QtCharts/ChartView.qml create mode 100644 tests/qml/QtCharts/Legend.qml create mode 100644 tests/qml/QtCharts/Margins.qml create mode 100644 tests/qml/QtCharts/PieSeries.qml create mode 100644 tests/qml/QtCharts/PieSlice.qml create mode 100644 tests/qml/QtCharts/qmldir create mode 100644 tests/qml/tst_activitydetailscard.qml create mode 100644 tests/qml/tst_attachmentmanager.qml create mode 100644 tests/qml/tst_backendbridge.qml create mode 100644 tests/qml/tst_ehower.qml create mode 100644 tests/qml/tst_ehowerindicator.qml create mode 100644 tests/qml/tst_formdrafthandler.qml create mode 100644 tests/qml/tst_imagepreviewer.qml create mode 100644 tests/qml/tst_modeldownloadtimerwidget.qml create mode 100644 tests/qml/tst_projectdetailscard.qml create mode 100644 tests/qml/tst_projectlist.qml create mode 100644 tests/qml/tst_projectpiechart.qml create mode 100644 tests/qml/tst_updatesdetailscard.qml create mode 100644 tests/qml/tst_voicetimerwidget.qml diff --git a/qml/components/visualization/ProjectList.qml b/qml/components/visualization/ProjectList.qml index 662060ac..e1f03ebf 100644 --- a/qml/components/visualization/ProjectList.qml +++ b/qml/components/visualization/ProjectList.qml @@ -225,14 +225,17 @@ Item { function navigateBackInHierarchy() { if (navigationStackModel.count > 0) { var last = navigationStackModel.get(navigationStackModel.count - 1); + var prevParentId = (last && last.parentId !== undefined) ? last.parentId : -1; + var prevAccountId = (last && last.accountId !== undefined) ? last.accountId : -1; + var prevParentName = (last && last.parentName !== undefined) ? last.parentName : ""; navigationStackModel.remove(navigationStackModel.count - 1); - currentParentId = last.parentId !== undefined ? last.parentId : -1; + currentParentId = prevParentId; if (currentParentId === -1) { currentAccountId = getSelectedAccountId(); } else { - currentAccountId = last.accountId !== undefined ? last.accountId : -1; + currentAccountId = prevAccountId; } - currentParentName = (last.parentName !== undefined) ? last.parentName : ""; + currentParentName = prevParentName; } } diff --git a/qml/components/visualization/ProjectPieChart.qml b/qml/components/visualization/ProjectPieChart.qml index b7e0fb17..7559b4ee 100644 --- a/qml/components/visualization/ProjectPieChart.qml +++ b/qml/components/visualization/ProjectPieChart.qml @@ -48,7 +48,7 @@ Item { anchors.top: parent.top anchors.horizontalCenter: parent.horizontalCenter font.pixelSize: units.gu(2) - color: Theme.name === "Ubuntu.Components.Themes.SuruDark" ? "White" : "#444" + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "White" : "#444" padding: units.gu(1.5) } @@ -124,8 +124,8 @@ Item { y: units.gu(2) width: Math.min(hoverText.implicitWidth + units.gu(3), parent.width - units.gu(2)) height: hoverText.implicitHeight + units.gu(1.5) - color: Theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#555" : "#FFF" - border.color: Theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#888" : "#ccc" + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#555" : "#FFF" + border.color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "#888" : "#ccc" border.width: 1 radius: units.gu(0.5) opacity: hoverText.text !== "" ? 0.95 : 0.0 @@ -141,7 +141,7 @@ Item { horizontalAlignment: Text.AlignHCenter wrapMode: Text.Wrap text: "" - color: Theme.name === "Ubuntu.Components.Themes.SuruDark" ? "White" : "Black" + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "White" : "Black" font.weight: Font.Light font.pixelSize: units.gu(2) } @@ -189,7 +189,7 @@ Item { id: label text: Utils.truncateText(model.label, 35) font.pixelSize: units.gu(1.8) - color: Theme.name === "Ubuntu.Components.Themes.SuruDark" ? "white" : "#333" + color: theme.name === "Ubuntu.Components.Themes.SuruDark" ? "white" : "#333" wrapMode: Text.WordWrap elide: Text.ElideRight maximumLineCount: 2 diff --git a/tests/qml/QtCharts/ChartView.qml b/tests/qml/QtCharts/ChartView.qml new file mode 100644 index 00000000..6b37629a --- /dev/null +++ b/tests/qml/QtCharts/ChartView.qml @@ -0,0 +1,26 @@ +import QtQuick 2.7 + +Item { + id: chartView + + default property alias children: contentArea.data + + property color backgroundColor: "transparent" + property bool antialiasing: true + property string title: "" + + property Margins margins: Margins {} + property Legend legend: Legend {} + + property QtObject plotArea: QtObject { + property real x: 0 + property real y: 0 + property real width: chartView.width + property real height: chartView.height + } + + Item { + id: contentArea + anchors.fill: parent + } +} diff --git a/tests/qml/QtCharts/Legend.qml b/tests/qml/QtCharts/Legend.qml new file mode 100644 index 00000000..40da028b --- /dev/null +++ b/tests/qml/QtCharts/Legend.qml @@ -0,0 +1,8 @@ +import QtQuick 2.7 + +QtObject { + property bool visible: false + property int alignment: 0 + property color labelColor: "black" + property font font +} diff --git a/tests/qml/QtCharts/Margins.qml b/tests/qml/QtCharts/Margins.qml new file mode 100644 index 00000000..ca616166 --- /dev/null +++ b/tests/qml/QtCharts/Margins.qml @@ -0,0 +1,8 @@ +import QtQuick 2.7 + +QtObject { + property int top: 0 + property int bottom: 0 + property int left: 0 + property int right: 0 +} diff --git a/tests/qml/QtCharts/PieSeries.qml b/tests/qml/QtCharts/PieSeries.qml new file mode 100644 index 00000000..8f41e078 --- /dev/null +++ b/tests/qml/QtCharts/PieSeries.qml @@ -0,0 +1,48 @@ +import QtQuick 2.7 + +Item { + id: pieSeries + property real size: 0.6 + property real holeSize: 0.3 + property real horizontalPosition: 0.5 + property real verticalPosition: 0.5 + property var slices: [] + readonly property int count: slices.length + + signal hovered(var slice, bool state) + + function clear() { + slices = []; + } + + function append(label, value) { + var comp = Qt.createComponent("PieSlice.qml"); + var startA = 0; + if (slices.length > 0) { + var last = slices[slices.length - 1]; + startA = last.startAngle + last.angleSpan; + } + + var sliceObj = comp.createObject(pieSeries, { + label: label, + value: value, + startAngle: startA, + angleSpan: 45 + }); + + slices.push(sliceObj); + return sliceObj; + } + + function at(index) { + if (index >= 0 && index < slices.length) return slices[index]; + return null; + } + + function find(label) { + for (var i = 0; i < slices.length; i++) { + if (slices[i].label === label) return slices[i]; + } + return null; + } +} diff --git a/tests/qml/QtCharts/PieSlice.qml b/tests/qml/QtCharts/PieSlice.qml new file mode 100644 index 00000000..61fc82d3 --- /dev/null +++ b/tests/qml/QtCharts/PieSlice.qml @@ -0,0 +1,22 @@ +import QtQuick 2.7 + +QtObject { + id: slice + + enum LabelPosition { + LabelOutside, + LabelInsideHorizontal, + LabelInsideTangential, + LabelInsideNormal, + LabelArm + } + + property string label: "" + property real value: 0 + property bool exploded: false + property int labelPosition: 0 + property bool labelVisible: false + property color color: "blue" + property real startAngle: 0 + property real angleSpan: 0 +} diff --git a/tests/qml/QtCharts/qmldir b/tests/qml/QtCharts/qmldir new file mode 100644 index 00000000..3611ebc6 --- /dev/null +++ b/tests/qml/QtCharts/qmldir @@ -0,0 +1,6 @@ +module QtCharts +ChartView 2.0 ChartView.qml +PieSeries 2.0 PieSeries.qml +PieSlice 2.0 PieSlice.qml +Margins 2.0 Margins.qml +Legend 2.0 Legend.qml diff --git a/tests/qml/README.md b/tests/qml/README.md index 874722f1..ee476103 100644 --- a/tests/qml/README.md +++ b/tests/qml/README.md @@ -43,23 +43,34 @@ tests/ │ │ ├── tst_squirebridge.qml # Test suite for Squire.js QtWebEngine messaging bridge │ │ └── tst_taskformutils.qml # Test suite for task form validation and save data preparation │ ├── tst_accountselectordialog.qml # Test suite for AccountSelectorDialog picker and account switch +│ ├── tst_activitydetailscard.qml # Test suite for ActivityDetailsCard activity details, assignee, due date, and action signals │ ├── tst_assigneefiltermenu.qml # Test suite for AssigneeFilterMenu multi-selection, search, and delegate interaction +│ ├── tst_attachmentmanager.qml # Test suite for AttachmentManager attachment list, file deletion, and upload signals +│ ├── tst_backendbridge.qml # Test suite for BackendBridge signal dispatch between QML and Python │ ├── tst_colorpicker.qml # Test suite for ColorPicker palette and custom color │ ├── tst_contentpickerdialog.qml # Test suite for ContentPickerDialog single/multi selection │ ├── tst_customdatepicker.qml # Test suite for CustomDatePicker picker component │ ├── tst_daterangeheaderfilter.qml # Test suite for DateRangeHeaderFilter filter toggle and active range text │ ├── tst_daterangeselector.qml # Test suite for DateRangeSelector date bounds and range pick │ ├── tst_dayselector.qml # Test suite for DaySelector day selection signals +│ ├── tst_ehower.qml # Test suite for EHower 2x2 priority matrix layout and quadrant hours +│ ├── tst_ehowerindicator.qml # Test suite for EHowerIndicator quadrant key normalization and quadrant colors │ ├── tst_filterableheadercontents.qml # Test suite for FilterableHeaderContents filter state integration +│ ├── tst_formdrafthandler.qml # Test suite for FormDraftHandler draft autosave, change tracking, and unsaved changes │ ├── tst_globaltimerwidget.qml # Test suite for GlobalTimerWidget system component +│ ├── tst_imagepreviewer.qml # Test suite for ImagePreviewer image display, close actions, and download states │ ├── tst_infobar.qml # Test suite for InfoBar feedback component │ ├── tst_inlineoptionselector.qml # Test suite for InlineOptionSelector radio-style option selection │ ├── tst_loadingindicator.qml # Test suite for LoadingIndicator running state and visibility │ ├── tst_loadmorefooter.qml # Test suite for LoadMoreFooter loading state and retry trigger +│ ├── tst_modeldownloadtimerwidget.qml # Test suite for ModelDownloadTimerWidget download progress and cancel controls │ ├── tst_multiassigneeselector.qml # Test suite for MultiAssigneeSelector multi-select IDs array output │ ├── tst_notificationbell.qml # Test suite for NotificationBell unread counter badge and navigation │ ├── tst_notificationpopup.qml # Test suite for NotificationPopup message, dismiss, and timeout │ ├── tst_outlinedtextfield.qml # Test suite for OutlinedTextField text input component +│ ├── tst_projectdetailscard.qml # Test suite for ProjectDetailsCard project info, status badge, progress, and timer controls +│ ├── tst_projectlist.qml # Test suite for ProjectList hierarchy navigation stack, list model population, and selection +│ ├── tst_projectpiechart.qml # Test suite for ProjectPieChart pie slice data loading, sorting, and empty state │ ├── tst_projectstageselector.qml # Test suite for ProjectStageSelector stage list per project │ ├── tst_savediscarddialog.qml # Test suite for SaveDiscardDialog save, discard, and cancel signals │ ├── tst_selectionbutton.qml # Test suite for SelectionButton selection state and click @@ -72,6 +83,8 @@ tests/ │ ├── tst_tsprogressbar.qml # Test suite for TSProgressbar progress indicator │ ├── tst_tssearchbar.qml # Test suite for TSSearchBar search input component │ ├── tst_tsswitch.qml # Test suite for TSSwitch toggle switch component +│ ├── tst_updatesdetailscard.qml # Test suite for UpdatesDetailsCard update summary, timestamp, status, and progress +│ ├── tst_voicetimerwidget.qml # Test suite for VoiceTimerWidget voice recording states and timer controls │ └── tst_workitemselector.qml # Test suite for WorkItemSelector project and task selection logic ``` diff --git a/tests/qml/tst_activitydetailscard.qml b/tests/qml/tst_activitydetailscard.qml new file mode 100644 index 00000000..060c2b46 --- /dev/null +++ b/tests/qml/tst_activitydetailscard.qml @@ -0,0 +1,248 @@ +import QtQuick 2.7 +import QtTest 1.0 +import Lomiri.Components 1.3 +import "../../qml/components/cards" +import "../../models/dbinit.js" as DbInit + +MainView { + id: testRoot + width: units.gu(50) + height: units.gu(80) + + ActivityDetailsCard { + id: testCard + account_id: 1 + odoo_record_id: 201 + } + + SignalSpy { + id: cardClickedSpy + target: testCard + signalName: "cardClicked" + } + + SignalSpy { + id: editRequestedSpy + target: testCard + signalName: "editRequested" + } + + SignalSpy { + id: viewRequestedSpy + target: testCard + signalName: "viewRequested" + } + + SignalSpy { + id: markAsDoneSpy + target: testCard + signalName: "markAsDone" + } + + SignalSpy { + id: createFollowupSpy + target: testCard + signalName: "createFollowup" + } + + SignalSpy { + id: dateChangedSpy + target: testCard + signalName: "dateChanged" + } + + TestCase { + name: "ActivityDetailsCardTests" + when: windowShown + + function initTestCase() { + DbInit.initializeDatabase(); + } + + function init() { + testCard.notes = ""; + testCard.activity_type_name = ""; + testCard.summary = ""; + testCard.activity_type_id = -1; + testCard.user = ""; + testCard.due_date = ""; + testCard.state = ""; + testCard.id = -1; + testCard.odoo_record_id = 201; + testCard.account_id = 1; + testCard.colorPallet = 0; + testCard.hasDraft = false; + + cardClickedSpy.clear(); + editRequestedSpy.clear(); + viewRequestedSpy.clear(); + markAsDoneSpy.clear(); + createFollowupSpy.clear(); + dateChangedSpy.clear(); + } + + function cleanup() { + cardClickedSpy.clear(); + editRequestedSpy.clear(); + viewRequestedSpy.clear(); + markAsDoneSpy.clear(); + createFollowupSpy.clear(); + dateChangedSpy.clear(); + } + + function test_initialProperties() { + compare(testCard.notes, ""); + compare(testCard.activity_type_name, ""); + compare(testCard.summary, ""); + compare(testCard.user, ""); + compare(testCard.due_date, ""); + compare(testCard.state, ""); + compare(testCard.hasDraft, false); + compare(testCard.account_id, 1); + compare(testCard.odoo_record_id, 201); + } + + function test_stripHtmlTagsComprehensive() { + var input = "

Meeting with client & discuss options.

"; + var stripped = testCard.stripHtmlTags(input); + verify(stripped.indexOf("

") === -1); + verify(stripped.indexOf("") === -1); + verify(stripped.indexOf("