From 4f3daed23dad8175791bb90011546327d7b85998 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Tue, 16 Jun 2026 09:01:58 -0600 Subject: [PATCH 1/2] Add test coverage for the clinical observations bulk edit Observation/Score editor (#706) ## Rationale Adds Selenium coverage for the new category-dependent Observation/Score editor in the clinical observations bulk edit dialog, introduced in the related ehrModules PR. ## Related Pull Requests - https://github.com/LabKey/ehrModules/pull/1145 ## Changes - New `testObservationBulkEdit` in `NIRC_EHRTest` that bulk-edits observation rows and verifies the Observation/Score editor is rebuilt per the selected Category (lookup combo for Appetite, free text for Mass), stays enabled across category changes, clears stale values, and applies the submitted values to every selected row. --- .../tests.nirc_ehr/NIRC_EHRTest.java | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/nirc_ehr/test/src/org.labkey.test/tests.nirc_ehr/NIRC_EHRTest.java b/nirc_ehr/test/src/org.labkey.test/tests.nirc_ehr/NIRC_EHRTest.java index 85f30273..469a6d26 100644 --- a/nirc_ehr/test/src/org.labkey.test/tests.nirc_ehr/NIRC_EHRTest.java +++ b/nirc_ehr/test/src/org.labkey.test/tests.nirc_ehr/NIRC_EHRTest.java @@ -56,6 +56,7 @@ import org.labkey.test.util.PortalHelper; import org.labkey.test.util.PostgresOnlyTest; import org.labkey.test.util.ehr.EHRClientAPIHelper; +import org.labkey.test.util.ext4cmp.Ext4ComboRef; import org.labkey.test.util.ext4cmp.Ext4FieldRef; import org.labkey.test.util.ext4cmp.Ext4GridRef; import org.openqa.selenium.Keys; @@ -764,6 +765,79 @@ public void testBulkClinicalEntry() Assert.assertEquals("Status is not updated ", "Completed", table.getDataAsText(0, "observationStatus")); } + @Test + public void testObservationBulkEdit() + { + log("Verifying the bulk edit Observation/Score editor follows the selected Category"); + gotoEnterData(); + waitAndClickAndWait(Locator.linkWithText("Bulk Clinical Entry")); + + Ext4GridRef observations = _helper.getExt4GridForFormSection("Observations"); + _helper.addRecordToGrid(observations); + _helper.addRecordToGrid(observations); + int rowCount = observations.getRowCount(); + + observations.clickTbarButton("Select All"); + observations.waitForSelected(rowCount); + + Locator.XPathLocator bulkEditWindow = _helper.openBulkEdit(observations); + + log("Selecting a Category whose observation editor is a lookup combo"); + _helper.toggleBulkEditExactField("Category"); + Ext4ComboRef categoryField = _ext4Helper.queryOne("window field[name=category]", Ext4ComboRef.class); + Assert.assertNotNull("Category field not found in Bulk Edit window", categoryField); + categoryField.waitForStoreLoad(); + categoryField.setComboByDisplayValue("Appetite"); + waitForObservationBulkEditFieldXtype("ehr-simplecombo", "Appetite"); + + log("Enabling the rebuilt Observation/Score field and selecting an Appetite-specific value"); + _helper.toggleBulkEditExactField("Observation/Score"); + Ext4ComboRef observationCombo = new Ext4ComboRef(getObservationBulkEditField(), this); + observationCombo.waitForStoreLoad(); + observationCombo.setComboByDisplayValue("Normal to low"); + Assert.assertEquals("Observation/Score value was not set from the Appetite lookup", "Normal to low", observationCombo.getValue()); + + log("Switching to a Category whose observation editor is free text"); + categoryField.setComboByDisplayValue("Mass"); + waitForObservationBulkEditFieldXtype("textfield", "Mass"); + + Ext4FieldRef observationField = getObservationBulkEditField(); + Assert.assertFalse("Observation/Score field should remain enabled across Category changes", observationField.isDisabled()); + Object staleValue = observationField.getValue(); + Assert.assertTrue("Observation/Score value should be cleared when the Category changes, but was: " + staleValue, + staleValue == null || "".equals(staleValue)); + + String observationText = "3 cm mass on left arm"; + observationField.setValue(observationText); + + waitAndClick(bulkEditWindow.append(Ext4Helper.Locators.ext4Button("Submit"))); + Window msgWindow = new Window.WindowFinder(getDriver()).withTitle("Set Values").waitFor(); + msgWindow.clickButton("Yes", 0); + waitForElementToDisappear(bulkEditWindow); + + log("Verifying the bulk edit values were applied to every selected row"); + for (int row = 1; row <= rowCount; row++) + { + Assert.assertEquals("Category was not bulk-set on row " + row, "Mass", observations.getFieldValue(row, "category")); + Assert.assertEquals("Observation was not bulk-set on row " + row, observationText, observations.getFieldValue(row, "observation")); + } + + _helper.discardForm(); + } + + private Ext4FieldRef getObservationBulkEditField() + { + return _ext4Helper.queryOne("window field[name=observation]", Ext4FieldRef.class); + } + + private void waitForObservationBulkEditFieldXtype(String xtype, String category) + { + waitFor(() -> { + Ext4FieldRef field = getObservationBulkEditField(); + return field != null && xtype.equals(field.getEval("xtype")); + }, "Observation/Score editor was not rebuilt as '" + xtype + "' for the '" + category + "' category", WAIT_FOR_JAVASCRIPT); + } + @Override @Test public void testQuickSearch() From d98c54d6b2bf57f5c6d69509b9fde10ba76b3b65 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Thu, 18 Jun 2026 14:49:26 -0600 Subject: [PATCH 2/2] Group scheduled observations by order with a new task per group (#707) #### Rationale When a clinical observation is entered from the observation schedule and multiple orders match on category and scheduled date/time, an entry is created in clinical_observations for each matching order. Previously the duplicate entries reused the originating order's existing schedule task id, so the entered observations were tied back to the schedule order tasks instead of a task representing the recording session. This change groups the entries by their originating order's taskid and assigns one task per group. #### Related Pull Requests * N/A #### Changes * NIRC_EHRTriggerHelper.handleScheduledObservations now groups scheduled observation entries by their originating order's taskid: the first order group reuses the form's own task and each additional distinct order group gets a freshly created task cloned from the form task, so no order's existing schedule task id is reused and the form task is never left empty. * Added resolveGroupTaskId and createTaskFromForm helpers plus a per-batch grouping map that is cleared from the clinical_observations onInit trigger. * clinical_observations.js sets the triggering row's taskid to the resolved group task. * Added the caseId/problemCategory column to the clinical_observations default view. --- .../queries/study/clinical_observations.js | 2 + .../study/clinical_observations/.qview.xml | 1 + .../nirc_ehr/query/NIRC_EHRTriggerHelper.java | 94 ++++++++- .../tests.nirc_ehr/NIRC_EHRTest.java | 181 ++++++++++++++++++ 4 files changed, 277 insertions(+), 1 deletion(-) diff --git a/nirc_ehr/resources/queries/study/clinical_observations.js b/nirc_ehr/resources/queries/study/clinical_observations.js index 38f07036..6c50146b 100644 --- a/nirc_ehr/resources/queries/study/clinical_observations.js +++ b/nirc_ehr/resources/queries/study/clinical_observations.js @@ -12,6 +12,7 @@ var triggerHelper = new org.labkey.nirc_ehr.query.NIRC_EHRTriggerHelper(LABKEY.S function onInit(event, helper) { helper.decodeExtraContextProperty('orderTasksInTransaction'); + triggerHelper.clearScheduledObsTaskMap(); } function onUpsert(helper, scriptErrors, row, oldRow) { @@ -67,6 +68,7 @@ function onUpsert(helper, scriptErrors, row, oldRow) { row.orderid = orderData.orderId; row.area = orderData.area; row.type = orderData.type; + row.taskid = orderData.taskId; } } } diff --git a/nirc_ehr/resources/queries/study/clinical_observations/.qview.xml b/nirc_ehr/resources/queries/study/clinical_observations/.qview.xml index a48d5a51..daaa82db 100644 --- a/nirc_ehr/resources/queries/study/clinical_observations/.qview.xml +++ b/nirc_ehr/resources/queries/study/clinical_observations/.qview.xml @@ -14,5 +14,6 @@ + \ No newline at end of file diff --git a/nirc_ehr/src/org/labkey/nirc_ehr/query/NIRC_EHRTriggerHelper.java b/nirc_ehr/src/org/labkey/nirc_ehr/query/NIRC_EHRTriggerHelper.java index 22112ed5..a4b08fd2 100644 --- a/nirc_ehr/src/org/labkey/nirc_ehr/query/NIRC_EHRTriggerHelper.java +++ b/nirc_ehr/src/org/labkey/nirc_ehr/query/NIRC_EHRTriggerHelper.java @@ -40,6 +40,7 @@ import org.labkey.api.util.PageFlowUtil; import org.labkey.nirc_ehr.NIRCOrchardFileGenerator; import org.labkey.nirc_ehr.NIRC_EHRManager; +import org.labkey.nirc_ehr.dataentry.form.NIRCClinicalObservationsFormType; import org.labkey.nirc_ehr.notification.NIRCClinicalMoveNotification; import org.labkey.nirc_ehr.notification.NIRCDeathNotification; import org.labkey.nirc_ehr.notification.NIRCPregnancyOutcomeNotification; @@ -66,6 +67,10 @@ public class NIRC_EHRTriggerHelper private static final Logger _log = LogManager.getLogger(NIRC_EHRTriggerHelper.class); private final Map _cachedDrugFormulary = new HashMap<>(); + // Maps an originating observation order's taskid to the task its scheduled observations are grouped under, + // for the duration of a single save batch (the same helper instance is reused across rows in the batch). + private final Map _scheduledObsTaskMap = new HashMap<>(); + private final SimpleDateFormat _dateFormat; public NIRC_EHRTriggerHelper(int userId, String containerId) @@ -798,6 +803,10 @@ public Map handleScheduledObservations(Map row, { Map order = orders[i]; + // Group every entry by the originating order's taskid into a new task per group. + String orderTaskId = ConvertHelper.convert(order.get("taskid"), String.class); + String groupTaskId = resolveGroupTaskId(orderTaskId, taskid, qcstate); + // First order we find will fill out the information in the row passing through the trigger if (i == 0) { @@ -806,6 +815,7 @@ public Map handleScheduledObservations(Map row, triggerOrder.put("area", order.get("area")); triggerOrder.put("orderId", order.get("objectid")); triggerOrder.put("type", order.get("type")); + triggerOrder.put("taskId", groupTaskId); continue; } @@ -822,7 +832,7 @@ public Map handleScheduledObservations(Map row, obsRow.put("performedBy", performedBy); obsRow.put("orderId", order.get("objectid")); obsRow.put("type", order.get("type")); - obsRow.put("taskid", order.get("taskid")); + obsRow.put("taskid", groupTaskId); List> rows = new ArrayList<>(); rows.add(obsRow); @@ -837,6 +847,88 @@ public Map handleScheduledObservations(Map row, return triggerOrder; } + /** + * Resets the per-batch grouping map. Called from the clinical_observations onInit trigger so no + * grouping state can leak between save batches. + */ + public void clearScheduledObsTaskMap() + { + _scheduledObsTaskMap.clear(); + } + + /** + * Resolves the task that a scheduled observation entry should be grouped under, keyed by the + * originating observation order's taskid. The first distinct order taskid seen in a save batch + * reuses the form's own task ({@code formTaskId}); each subsequent distinct order taskid gets a + * freshly created task that clones the form task. This groups all observations that came from the + * same order under one task, with a new task per additional group, while reusing the form's task + * for the first group so it is not left empty. + */ + private String resolveGroupTaskId(String orderTaskId, String formTaskId, String qcstate) throws SQLException, BatchValidationException, QueryUpdateServiceException, DuplicateKeyException + { + // Defensive: an order with no taskid can't be grouped, so fall back to the form's task. + if (orderTaskId == null) + return formTaskId; + + if (_scheduledObsTaskMap.containsKey(orderTaskId)) + return _scheduledObsTaskMap.get(orderTaskId); + + // Reuse the form's already-created task for the first group; create new tasks for the rest. + String groupTaskId = _scheduledObsTaskMap.isEmpty() ? formTaskId : createTaskFromForm(formTaskId, qcstate); + _scheduledObsTaskMap.put(orderTaskId, groupTaskId); + return groupTaskId; + } + + /** + * Creates a new ehr.tasks record for a group of scheduled observations, cloning the form's task + * ({@code formTaskId}) so the new task carries the same title/form/category/etc. Returns the new taskid. + */ + private String createTaskFromForm(String formTaskId, String qcstate) throws SQLException, BatchValidationException, QueryUpdateServiceException, DuplicateKeyException + { + String newTaskId = new GUID().toString(); + TableInfo tasksTi = getTableInfo("ehr", "tasks"); + + Map formTask = null; + if (formTaskId != null) + { + SimpleFilter filter = new SimpleFilter(FieldKey.fromString("taskid"), formTaskId); + formTask = new TableSelector(tasksTi, PageFlowUtil.set("title", "formtype", "category", "qcstate", "assignedto", "duedate", "caseid", "description"), filter, null).getMap(); + } + + Map taskRow = new CaseInsensitiveHashMap<>(); + taskRow.put("taskid", newTaskId); + if (formTask != null) + { + taskRow.put("title", formTask.get("title")); + taskRow.put("formtype", formTask.get("formtype")); + taskRow.put("category", formTask.get("category")); + taskRow.put("qcstate", formTask.get("qcstate")); + taskRow.put("assignedto", formTask.get("assignedto")); + taskRow.put("duedate", formTask.get("duedate")); + taskRow.put("caseid", formTask.get("caseid")); + taskRow.put("description", formTask.get("description")); + } + else + { + // Fallback if the form task is not visible yet: populate the required non-null columns. + taskRow.put("title", "Clinical Observations"); + taskRow.put("category", "task"); + taskRow.put("formtype", NIRCClinicalObservationsFormType.NAME); + taskRow.put("qcstate", qcstate); + taskRow.put("assignedto", _user.getUserId()); + } + + List> rows = new ArrayList<>(); + rows.add(taskRow); + + BatchValidationException errors = new BatchValidationException(); + tasksTi.getUpdateService().insertRows(_user, _container, rows, errors, null, getExtraContext()); + if (errors.hasErrors()) + throw errors; + + return newTaskId; + } + public boolean validateHousing(String id, String cage, Date date) { if (id == null || cage == null || date == null) diff --git a/nirc_ehr/test/src/org.labkey.test/tests.nirc_ehr/NIRC_EHRTest.java b/nirc_ehr/test/src/org.labkey.test/tests.nirc_ehr/NIRC_EHRTest.java index 469a6d26..8d99ca6b 100644 --- a/nirc_ehr/test/src/org.labkey.test/tests.nirc_ehr/NIRC_EHRTest.java +++ b/nirc_ehr/test/src/org.labkey.test/tests.nirc_ehr/NIRC_EHRTest.java @@ -29,6 +29,7 @@ import org.labkey.remoteapi.CommandException; import org.labkey.remoteapi.SimplePostCommand; import org.labkey.remoteapi.core.SaveModulePropertiesCommand; +import org.labkey.remoteapi.query.ContainerFilter; import org.labkey.remoteapi.query.Filter; import org.labkey.remoteapi.query.ImportDataCommand; import org.labkey.remoteapi.query.InsertRowsCommand; @@ -79,8 +80,10 @@ import java.util.Collections; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import static org.junit.Assert.assertEquals; @@ -102,6 +105,9 @@ public class NIRC_EHRTest extends AbstractGenericEHRTest implements PostgresOnly private static final String deadAnimalId = "D5454"; private static final String departedAnimalId = "H6767"; private static final String aliveAnimalId = "A4545"; + // Dedicated animal for testScheduledObservationTaskGrouping; provisioned (alive, housed, assigned) in + // createTestSubjects so the clinical case form raises no warnings that would keep the validation banner up. + private static final String taskGroupAnimalId = "TESTGRP9090"; private final String[] weightFields = {"Id", "date", "enddate", "project", "weight", FIELD_QCSTATELABEL, FIELD_OBJECTID, FIELD_LSID, "_recordid", "performedby"}; private final Object[] weightData1 = {getExpectedAnimalIDCasing("TESTSUBJECT1"), EHRClientAPIHelper.DATE_SUBSTITUTION, null, null, "12", EHRQCState.IN_PROGRESS.label, null, null, "_recordID", 1004}; @@ -472,6 +478,33 @@ protected void createTestSubjects() throws Exception getApiHelper().deleteAllRecords("study", "Assignment", new Filter("Id", StringUtils.join(SUBJECTS, ";"), Filter.Operator.IN)); getApiHelper().doSaveRows(DATA_ADMIN.getEmail(), insertCommand, getExtraContext()); + // Fully provision the task-grouping test animal (alive demographics, current housing, active assignment) so + // the clinical case form has no unknown-animal warnings to keep the validation banner from clearing. + log("Creating task grouping test subject"); + fields = new String[]{"Id", "Species", "Birth", "Gender", "date", "calculated_status", "objectid", "performedby"}; + data = new Object[][]{ + {taskGroupAnimalId, "Rhesus", (new Date()).toString(), getMale(), new Date(), "Alive", UUID.randomUUID().toString(), 1004} + }; + insertCommand = getApiHelper().prepareInsertCommand("study", "demographics", "lsid", fields, data); + getApiHelper().deleteAllRecords("study", "demographics", new Filter("Id", taskGroupAnimalId)); + getApiHelper().doSaveRows(DATA_ADMIN.getEmail(), insertCommand, getExtraContext()); + + fields = new String[]{"Id", "date", "enddate", "room", "cage", "performedby"}; + data = new Object[][]{ + {taskGroupAnimalId, pastDate1, null, getRooms()[0], CAGES[0], 1004} + }; + insertCommand = getApiHelper().prepareInsertCommand("study", "Housing", "lsid", fields, data); + getApiHelper().deleteAllRecords("study", "Housing", new Filter("Id", taskGroupAnimalId)); + getApiHelper().doSaveRows(DATA_ADMIN.getEmail(), insertCommand, getExtraContext()); + + fields = new String[]{"Id", "date", "enddate", "project", "performedby"}; + data = new Object[][]{ + {taskGroupAnimalId, pastDate1, null, PROJECTS[0], 1004} + }; + insertCommand = getApiHelper().prepareInsertCommand("study", "Assignment", "lsid", fields, data); + getApiHelper().deleteAllRecords("study", "Assignment", new Filter("Id", taskGroupAnimalId)); + getApiHelper().doSaveRows(DATA_ADMIN.getEmail(), insertCommand, getExtraContext()); + primeCaches(); } @@ -665,6 +698,10 @@ public void testClinicalObservation() Assert.assertEquals("Incorrect rows in Today's Observation Schedule", 4, table.getDataRowCount()); Assert.assertEquals("Incorrect observation title", "Daily Clinical Observations; Lameness", table.getDataAsText(0, "observationList")); Assert.assertEquals("Status is not updated", "", table.getDataAsText(0, "observationStatus")); + + // Capture existing observation-form tasks so we can confirm that entering scheduled + // observations groups them onto the form's task without leaving an empty task behind. + Set obsTasksBefore = getObservationFormTaskIds(); table.link(0, "observationRecord").click(); switchToWindow(1); @@ -695,6 +732,10 @@ public void testClinicalObservation() table = new AnimalHistoryPage<>(getDriver()).getActiveReportDataRegion(); Assert.assertEquals("Status is not updated", "Completed", table.getDataAsText(0, "observationStatus")); + // This animal has a single clinical case, so every scheduled observation belongs to that one + // order group and stays on the form's task: one task group, and no empty task created. + verifyScheduledObservationTaskGrouping(animalId, obsTasksBefore, 1); + log("Closing the case"); goToEHRFolder(); waitAndClickAndWait(Locator.linkWithText("Active Clinical Cases")); @@ -765,6 +806,146 @@ public void testBulkClinicalEntry() Assert.assertEquals("Status is not updated ", "Completed", table.getDataAsText(0, "observationStatus")); } + // The ehr.tasks formtype for the clinical observations data entry form (NIRCClinicalObservationsFormType.NAME). + private static final String NIRC_OBSERVATIONS_FORM_TYPE = "Observations"; + + // Valid Observation/Score values keyed by daily clinical observation category. The Observation/Score + // field is category-dependent, so each value must be legal for its category. + private static final Map NIRC_DAILY_OBS_VALUES = Map.of( + "Activity", "0-1 Extremely Lethargic", + "Appetite", "Normal to low", + "BCS", "2.5", + "Hydration", "10%", + "Stool", "M/F", + "Verified Id?", "No"); + + @Test + public void testScheduledObservationTaskGrouping() + { + String animalId = taskGroupAnimalId; + + // Two concurrent clinical cases for the same animal each generate their own set of daily + // observation orders at the same scheduled slot (today at 8:00 AM). A single schedule entry + // therefore matches two orders per category, each carrying a distinct order taskid. The entered + // observations must be grouped by that order taskid -- the first group reuses the form's own + // task and the second gets a freshly created task -- so the entries end up under exactly two + // tasks with no empty task left behind. + // The first case finalizes through the normal "Finalize Form" confirmation. The second case is for + // the same animal and problem area, so its submission instead raises the "Similar Case Exists" + // confirmation -- acknowledge that one to finalize it. + createClinicalCase(animalId, "Finalize"); + createClinicalCase(animalId, "Similar Case Exists"); + + goToEHRFolder(); + waitAndClickAndWait(Locator.linkWithText("Today's Observation Schedule")); + DataRegionTable table = new AnimalHistoryPage<>(getDriver()).getActiveReportDataRegion(); + table.setFilter("Id", "Equals", animalId); + Assert.assertEquals("Both cases' orders should collapse to a single schedule row for " + animalId, 1, table.getDataRowCount()); + + Set obsTasksBefore = getObservationFormTaskIds(); + table.link(0, "observationRecord").click(); + switchToWindow(1); + waitForText(animalId); + enterScheduledObservations(); + + // Each of the six daily categories matched two orders, so two entries per category were created, + // grouped into exactly two tasks (one per originating order taskid) with no empty task. + verifyScheduledObservationTaskGrouping(animalId, obsTasksBefore, 2); + + Map entriesPerCategory = new HashMap<>(); + for (Map row : getClinicalObservations(animalId)) + entriesPerCategory.merge(String.valueOf(row.get("category")), 1, Integer::sum); + Assert.assertEquals("Expected the six daily observation categories", NIRC_DAILY_OBS_VALUES.size(), entriesPerCategory.size()); + entriesPerCategory.forEach((category, count) -> + Assert.assertEquals("Expected two entries (one per matching order) for category " + category, Integer.valueOf(2), count)); + } + + // Creates and finalizes a minimal clinical case for the animal. The case's open date is set to + // yesterday so the auto-generated daily observation orders land on today's observation schedule. + // confirmWindowTitle is the finalize-confirmation dialog expected on submit: "Finalize" for a brand + // new case, or "Similar Case Exists" when the animal already has an active case for the same problem. + private void createClinicalCase(String animalId, String confirmWindowTitle) + { + gotoEnterData(); + waitAndClickAndWait(Locator.linkWithText("Clinical Cases")); + Ext4FieldRef problem = _helper.getExt4FieldForFormSection("Clinical Case", "Problem Area"); + problem.clickTrigger(); + problem.setValue("General abnormality"); + _helper.setDataEntryField("openRemark", "Clinical Case for " + animalId); + _helper.setDataEntryField("plan", "Case plan for " + animalId); + _helper.getExt4FieldForFormSection("Clinical Case", "Open Date").setValue(LocalDateTime.now().minusDays(1).format(_dateFormat)); + setFormElement(Locator.name("Id"), animalId); + _helper.setDataEntryField("s", "Subjective for " + animalId); + _helper.setDataEntryField("remark", "Remarks for " + animalId); + submitForm("Submit Final", confirmWindowTitle); + } + + // Fills in the Observations grid opened from the schedule, setting a valid value and remark for each + // category row regardless of the grid's row order, then submits. + private void enterScheduledObservations() + { + Ext4GridRef observation = _helper.getExt4GridForFormSection("Observations"); + int rowCount = observation.getRowCount(); + for (int row = 1; row <= rowCount; row++) + { + String category = String.valueOf(observation.getFieldValue(row, "category")); + String value = NIRC_DAILY_OBS_VALUES.get(category); + if (value != null) + observation.setGridCell(row, "observation", value); + observation.setGridCellJS(row, "remark", "remark for " + category); + } + submitForm("Submit Final", "Finalize"); + } + + // Asserts that the animal's scheduled observations are grouped under the expected number of distinct + // tasks and that no observation-form task created while entering them was left empty. + private void verifyScheduledObservationTaskGrouping(String animalId, Set obsTasksBefore, int expectedTaskGroups) + { + List> obsRows = getClinicalObservations(animalId); + Assert.assertFalse("Expected scheduled clinical observations for " + animalId, obsRows.isEmpty()); + + Set taskIds = new HashSet<>(); + for (Map row : obsRows) + { + Object taskId = row.get("taskid"); + Assert.assertNotNull("A scheduled observation is missing its taskid", taskId); + taskIds.add(String.valueOf(taskId)); + } + Assert.assertEquals("Scheduled observations should be grouped under " + expectedTaskGroups + " task(s)", expectedTaskGroups, taskIds.size()); + + // No empty task: every observation-form task created while entering these observations must carry + // at least one observation. The old behavior abandoned the form's task (leaving it empty) when its + // entries were moved onto freshly created group tasks. + Set newObsTasks = new HashSet<>(getObservationFormTaskIds()); + newObsTasks.removeAll(obsTasksBefore); + Assert.assertFalse("Entering scheduled observations should have created at least one observation task", newObsTasks.isEmpty()); + for (String taskId : newObsTasks) + Assert.assertTrue("An empty observation task was created: " + taskId, countObservationsForTask(taskId) > 0); + } + + private List> getClinicalObservations(String animalId) + { + // study datasets and ehr.tasks are defined in the EHR study folder, not the project root, so query + // that container explicitly rather than relying on the default project-scoped overload. + return executeSelectRowCommand("study", "clinical_observations", ContainerFilter.Current, "/" + getContainerPath(), List.of(new Filter("Id", animalId))).getRows(); + } + + private Set getObservationFormTaskIds() + { + Set taskIds = new HashSet<>(); + for (Map row : executeSelectRowCommand("ehr", "tasks", ContainerFilter.Current, "/" + getContainerPath(), List.of(new Filter("formtype", NIRC_OBSERVATIONS_FORM_TYPE))).getRows()) + { + if (row.get("taskid") != null) + taskIds.add(String.valueOf(row.get("taskid"))); + } + return taskIds; + } + + private int countObservationsForTask(String taskId) + { + return executeSelectRowCommand("study", "clinical_observations", ContainerFilter.Current, "/" + getContainerPath(), List.of(new Filter("taskid", taskId))).getRowCount().intValue(); + } + @Test public void testObservationBulkEdit() {