From b7107c34ad39e4af3778c093422ea4eadfb7e7e3 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Tue, 13 Oct 2026 17:11:45 +0530 Subject: [PATCH 1/2] 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 2/2] 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); + } } }