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..6b0bf38d --- /dev/null +++ b/tests/qml/scripts/tst_chartworker.qml @@ -0,0 +1,117 @@ +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"); + } + + 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_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 = "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 = '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 = 'Qt paragraph
')); + verify(HtmlSanitizer.needsSanitization('Color')); + verify(HtmlSanitizer.needsSanitization('Font tag')); + 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);
+ }
+
+ function test_sanitize_fullPipeline() {
+ var rawInput = '
Text with deleted and red font
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..cf6e3f2a --- /dev/null +++ b/tests/qml/scripts/tst_taskformutils.qml @@ -0,0 +1,164 @@ +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); + } + + 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); + } + } +}