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 = "
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..2e18b02c --- /dev/null +++ b/tests/qml/scripts/tst_squirebridge.qml @@ -0,0 +1,137 @@ +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 win = root.mockWindow; + var doc = root.mockDocument; + + 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; + } + + 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..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); + } + } +}