From c92c71a3c5f493ac9bc61c814d73b0e6fca77f3e Mon Sep 17 00:00:00 2001 From: Amrita P Date: Fri, 17 Jul 2026 19:00:03 +0530 Subject: [PATCH] (feat) O3-5674: add visit notes section to visit summary PDF --- .../api/model/VisitNoteEntry.java | 22 ++ .../api/section/VisitNotesSection.java | 219 ++++++++++++ api/src/main/resources/messages.properties | 4 + api/src/main/resources/messages_ar.properties | 4 + api/src/main/resources/messages_fr.properties | 4 + .../resources/visitSummaryFopStylesheet.xsl | 17 +- .../api/section/VisitNotesSectionTest.java | 323 ++++++++++++++++++ .../include/visitNotesSectionTestDataset.xml | 217 ++++++++++++ 8 files changed, 808 insertions(+), 2 deletions(-) create mode 100644 api/src/main/java/org/openmrs/module/patientdocuments/api/model/VisitNoteEntry.java create mode 100644 api/src/main/java/org/openmrs/module/patientdocuments/api/section/VisitNotesSection.java create mode 100644 api/src/test/java/org/openmrs/module/patientdocuments/api/section/VisitNotesSectionTest.java create mode 100644 api/src/test/resources/org/openmrs/module/patientdocuments/include/visitNotesSectionTestDataset.xml diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/api/model/VisitNoteEntry.java b/api/src/main/java/org/openmrs/module/patientdocuments/api/model/VisitNoteEntry.java new file mode 100644 index 0000000..dc06e58 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/patientdocuments/api/model/VisitNoteEntry.java @@ -0,0 +1,22 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.patientdocuments.api.model; + +import lombok.Value; + +@Value +public class VisitNoteEntry { + + String text; + + String provider; + + String dateTime; +} diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/api/section/VisitNotesSection.java b/api/src/main/java/org/openmrs/module/patientdocuments/api/section/VisitNotesSection.java new file mode 100644 index 0000000..9a8e524 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/patientdocuments/api/section/VisitNotesSection.java @@ -0,0 +1,219 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.patientdocuments.api.section; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +import lombok.extern.slf4j.Slf4j; + +import org.apache.commons.lang3.StringUtils; +import org.openmrs.Concept; +import org.openmrs.Encounter; +import org.openmrs.EncounterProvider; +import org.openmrs.EncounterRole; +import org.openmrs.Obs; +import org.openmrs.Provider; +import org.openmrs.Visit; +import org.openmrs.api.context.Context; +import org.openmrs.module.patientdocuments.api.model.VisitNoteEntry; +import org.openmrs.util.ConfigUtil; +import org.springframework.stereotype.Component; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +@Component +@Slf4j +public class VisitNotesSection extends TypedSection> { + + /** + * Default concept mapping (source:code) identifying visit-note narrative obs when + * report.visitSummary.visitNotes.concept is not set. CIEL:162169 is "Text of + * encounter note", the concept the O3 visit-note form stores its narrative under. + * Package-private for test access in VisitNotesSectionTest @BeforeEach cache reset. + */ + static final String DEFAULT_NOTE_CONCEPT = "CIEL:162169"; + + static final String NOTE_CONCEPT_PROPERTY = "report.visitSummary.visitNotes.concept"; + + /** + * Default encounter-role name used to attribute each note to its clinician when + * report.visitSummary.visitNotes.clinicianEncounterRole is not set. Resolved by + * name (not UUID) so deployments with their own role metadata can re-point it. + */ + static final String DEFAULT_CLINICIAN_ROLE = "Clinician"; + + static final String CLINICIAN_ROLE_PROPERTY = "report.visitSummary.visitNotes.clinicianEncounterRole"; + + private static final int DEFAULT_ORDER = 600; + + private static final String KEY_PREFIX = "patientdocuments.visitSummary.section.visitNotes."; + + @Override + public String getSectionKey() { + return "visitNotes"; + } + + @Override + protected int getDefaultOrder() { + return DEFAULT_ORDER; + } + + @Override + protected List gatherData(Visit visit) { + return gatherData(visit, new ArrayList<>()); + } + + @Override + protected List gatherData(Visit visit, List notices) { + List notes = new ArrayList<>(); + List encounters = getNonVoidedEncounters(visit); + if (encounters.isEmpty()) { + return notes; + } + + Concept noteConcept = resolveNoteConcept(); + EncounterRole clinicianRole = resolveClinicianRole(notices); + + // Every note shares the same concept, so notes are never deduplicated per + // concept — all of them render, oldest first. + List obsList = Context.getObsService().getObservations( + null, encounters, Collections.singletonList(noteConcept), null, null, null, + Collections.singletonList("obsDatetime asc"), + null, null, null, null, false + ); + + for (Obs obs : obsList) { + notes.add(buildEntry(obs, clinicianRole)); + } + return notes; + } + + private VisitNoteEntry buildEntry(Obs obs, EncounterRole clinicianRole) { + String text = obs.getValueText(); + if (StringUtils.isBlank(text)) { + log.warn("Visit note obs {} has no recorded text; rendering placeholder", obs.getUuid()); + text = "—"; + } + Encounter encounter = obs.getEncounter(); + return new VisitNoteEntry(text, resolveProvider(encounter, clinicianRole), + formatDateTime(encounter.getEncounterDatetime(), obs)); + } + + /** + * Providers under the configured clinician role first; if none, any non-voided + * provider on the encounter (many deployments record providers under other roles, + * and O3's visit-notes display ignores role); "Unknown" only when the encounter + * has no named provider at all. + */ + private String resolveProvider(Encounter encounter, EncounterRole clinicianRole) { + List names = new ArrayList<>(); + if (clinicianRole != null) { + addProviderNames(encounter.getProvidersByRole(clinicianRole), names); + } + if (names.isEmpty()) { + List anyRole = new ArrayList<>(); + for (EncounterProvider encounterProvider : encounter.getActiveEncounterProviders()) { + anyRole.add(encounterProvider.getProvider()); + } + addProviderNames(anyRole, names); + } + if (names.isEmpty()) { + log.warn("Visit note encounter {} has no named provider; rendering 'Unknown'", encounter.getUuid()); + return msg(KEY_PREFIX + "unknown", "Unknown"); + } + // Sorted so multi-provider output is deterministic (the underlying sets have no order). + Collections.sort(names); + return String.join(", ", names); + } + + private void addProviderNames(Collection providers, List names) { + for (Provider provider : providers) { + if (provider != null && StringUtils.isNotBlank(provider.getName())) { + names.add(provider.getName()); + } + } + } + + private String formatDateTime(Date dateTime, Obs obs) { + if (dateTime == null) { + log.warn("Visit note obs {} has an encounter with no datetime; rendering placeholder", obs.getUuid()); + return "—"; + } + return new SimpleDateFormat("yyyy-MM-dd HH:mm").format(dateTime); + } + + /** + * Resolves the visit-note concept from report.visitSummary.visitNotes.concept + * (defaults to DEFAULT_NOTE_CONCEPT). The single entry must be in "source:code" + * format. A malformed or unresolvable value throws — every note hangs off this + * one concept, so the failure is total and must surface as a section error, never + * as a section that quietly looks like "no notes". + */ + private Concept resolveNoteConcept() { + String raw = ConfigUtil.getProperty(NOTE_CONCEPT_PROPERTY, DEFAULT_NOTE_CONCEPT).trim(); + String[] parts = raw.split(":"); + if (parts.length != 2) { + throw new IllegalStateException("Visit-note concept '" + raw + + "' is not in source:code format; check " + NOTE_CONCEPT_PROPERTY); + } + Concept concept = Context.getConceptService().getConceptByMapping(parts[1].trim(), parts[0].trim()); + if (concept == null) { + throw new IllegalStateException("Visit-note concept mapping '" + raw + + "' could not be resolved; check " + NOTE_CONCEPT_PROPERTY); + } + return concept; + } + + /** + * Resolves the clinician encounter role by name from + * report.visitSummary.visitNotes.clinicianEncounterRole (defaults to + * DEFAULT_CLINICIAN_ROLE). An unresolvable role only degrades attribution to the + * any-role fallback, so only an explicitly configured role that fails to resolve + * adds a section notice — the default name not existing in a deployment is not a + * config error, and a notice on every PDF there would just be ignored noise. + */ + private EncounterRole resolveClinicianRole(List notices) { + String configured = ConfigUtil.getProperty(CLINICIAN_ROLE_PROPERTY); + boolean explicit = StringUtils.isNotBlank(configured); + String roleName = explicit ? configured.trim() : DEFAULT_CLINICIAN_ROLE; + EncounterRole role = Context.getEncounterService().getEncounterRoleByName(roleName); + if (role == null) { + if (explicit) { + log.warn("Clinician encounter role '{}' could not be resolved; notes fall back to any-role attribution", + roleName); + notices.add(buildSectionNotice(Collections.singletonList(roleName))); + } else { + log.debug("Default clinician encounter role '{}' does not exist; notes use any-role attribution", + roleName); + } + } + return role; + } + + @Override + protected void renderXml(Document doc, Element root, List data) { + Element section = doc.createElement("visitNotes"); + section.setAttribute("heading", msg(KEY_PREFIX + "heading", "Visit Notes")); + root.appendChild(section); + + for (VisitNoteEntry note : data) { + Element noteEl = doc.createElement("note"); + noteEl.setAttribute("provider", StringUtils.defaultString(note.getProvider())); + noteEl.setAttribute("datetime", StringUtils.defaultString(note.getDateTime())); + noteEl.setTextContent(StringUtils.defaultString(note.getText())); + section.appendChild(noteEl); + } + } +} \ No newline at end of file diff --git a/api/src/main/resources/messages.properties b/api/src/main/resources/messages.properties index 9665a8d..6594a85 100644 --- a/api/src/main/resources/messages.properties +++ b/api/src/main/resources/messages.properties @@ -90,6 +90,10 @@ ${project.parent.artifactId}.visitSummary.section.labResults.flag.abnormal=Abnor ${project.parent.artifactId}.visitSummary.section.labResults.flag.critically_abnormal=Critically Abnormal ${project.parent.artifactId}.visitSummary.section.labResults.unknown=Unknown +# Visit summary - Visit Notes section +${project.parent.artifactId}.visitSummary.section.visitNotes.heading=Visit Notes +${project.parent.artifactId}.visitSummary.section.visitNotes.unknown=Unknown + # Visit summary - Footer section ${project.parent.artifactId}.visitSummary.section.footer.lbl.printedBy=Printed by: ${project.parent.artifactId}.visitSummary.section.footer.lbl.systemId=System ID: diff --git a/api/src/main/resources/messages_ar.properties b/api/src/main/resources/messages_ar.properties index 48e80b6..1b3d90c 100644 --- a/api/src/main/resources/messages_ar.properties +++ b/api/src/main/resources/messages_ar.properties @@ -95,6 +95,10 @@ ${project.parent.artifactId}.visitSummary.section.labResults.flag.abnormal=Abnor ${project.parent.artifactId}.visitSummary.section.labResults.flag.critically_abnormal=Critically Abnormal ${project.parent.artifactId}.visitSummary.section.labResults.unknown=Unknown +# Visit summary - Visit Notes section (English placeholders pending native-speaker translation) +${project.parent.artifactId}.visitSummary.section.visitNotes.heading=Visit Notes +${project.parent.artifactId}.visitSummary.section.visitNotes.unknown=Unknown + # Visit summary - Footer section (English placeholders pending native-speaker translation) ${project.parent.artifactId}.visitSummary.section.footer.lbl.printedBy=Printed by: ${project.parent.artifactId}.visitSummary.section.footer.lbl.systemId=System ID: diff --git a/api/src/main/resources/messages_fr.properties b/api/src/main/resources/messages_fr.properties index a700d84..aa5954e 100644 --- a/api/src/main/resources/messages_fr.properties +++ b/api/src/main/resources/messages_fr.properties @@ -88,6 +88,10 @@ ${project.parent.artifactId}.visitSummary.section.labResults.flag.abnormal=Abnor ${project.parent.artifactId}.visitSummary.section.labResults.flag.critically_abnormal=Critically Abnormal ${project.parent.artifactId}.visitSummary.section.labResults.unknown=Unknown +# Visit summary - Visit Notes section (English placeholders pending native-speaker translation) +${project.parent.artifactId}.visitSummary.section.visitNotes.heading=Visit Notes +${project.parent.artifactId}.visitSummary.section.visitNotes.unknown=Unknown + # Visit summary - Footer section (English placeholders pending native-speaker translation) ${project.parent.artifactId}.visitSummary.section.footer.lbl.printedBy=Printed by: ${project.parent.artifactId}.visitSummary.section.footer.lbl.systemId=System ID: diff --git a/api/src/main/resources/visitSummaryFopStylesheet.xsl b/api/src/main/resources/visitSummaryFopStylesheet.xsl index 8081982..8a23278 100644 --- a/api/src/main/resources/visitSummaryFopStylesheet.xsl +++ b/api/src/main/resources/visitSummaryFopStylesheet.xsl @@ -83,6 +83,7 @@ + @@ -670,7 +671,8 @@ @@ -681,7 +683,18 @@ - + + + + + + + + + + + + None recorded diff --git a/api/src/test/java/org/openmrs/module/patientdocuments/api/section/VisitNotesSectionTest.java b/api/src/test/java/org/openmrs/module/patientdocuments/api/section/VisitNotesSectionTest.java new file mode 100644 index 0000000..6f49754 --- /dev/null +++ b/api/src/test/java/org/openmrs/module/patientdocuments/api/section/VisitNotesSectionTest.java @@ -0,0 +1,323 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.patientdocuments.api.section; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openmrs.EncounterRole; +import org.openmrs.GlobalProperty; +import org.openmrs.Visit; +import org.openmrs.api.context.Context; +import org.openmrs.module.patientdocuments.api.model.VisitNoteEntry; +import org.openmrs.test.jupiter.BaseModuleContextSensitiveTest; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import javax.xml.parsers.DocumentBuilderFactory; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Integration tests for VisitNotesSection. + * + * Obs are pre-loaded from visitNotesSectionTestDataset.xml rather than created via + * saveObs(), which triggers a buggy ConceptReferenceRange validator on OpenMRS 2.7.0. + * + * Dataset layout: + * Visit 601 — patient 2, two encounters with one note each (10:00 and 14:00) for + * chronological ordering and per-note provenance, plus a non-note obs, + * a voided note obs, and a voided encounter with a note obs (all excluded). + * Visit 602 — patient 7, encounter with no obs (None recorded). + * Visit 603 — patient 2, no encounters (None recorded). + * Visit 604 — patient 2, provider edge cases: no provider at all (renders "Unknown"), + * and a provider under a non-clinician role (falls back to that provider). + * Visit 605 — patient 2, a note with no text (placeholder) and a note with two + * clinician-role providers (joined, sorted). + */ +public class VisitNotesSectionTest extends BaseModuleContextSensitiveTest { + + private static final String DATASET = + "org/openmrs/module/patientdocuments/include/visitNotesSectionTestDataset.xml"; + + private VisitNotesSection section; + + @BeforeEach + public void setUp() throws Exception { + executeDataSet(DATASET); + // ConfigUtil caches GP values in-memory outside the test transaction; reset the + // defaults before each test so a prior test's override does not leak. + Context.getAdministrationService().saveGlobalProperty(new GlobalProperty( + VisitNotesSection.NOTE_CONCEPT_PROPERTY, VisitNotesSection.DEFAULT_NOTE_CONCEPT)); + Context.getAdministrationService().saveGlobalProperty(new GlobalProperty( + VisitNotesSection.CLINICIAN_ROLE_PROPERTY, VisitNotesSection.DEFAULT_CLINICIAN_ROLE)); + section = new VisitNotesSection(); + } + + // ── gatherData tests ────────────────────────────────────────────────────── + + @Test + public void gatherData_multipleNotesAcrossEncounters_returnsChronologicalOrderWithProvenance() { + Visit visit = Context.getVisitService().getVisit(601); + + List notes = section.gatherData(visit); + + Assertions.assertEquals(2, notes.size(), "Expected both notes, oldest first, nothing else"); + Assertions.assertEquals("Morning assessment note", notes.get(0).getText()); + Assertions.assertEquals("Hippocrates of Cos", notes.get(0).getProvider()); + Assertions.assertEquals("2025-06-01 10:00", notes.get(0).getDateTime()); + Assertions.assertEquals("Afternoon follow-up note", notes.get(1).getText()); + Assertions.assertEquals("Bruno Otterbourg", notes.get(1).getProvider()); + Assertions.assertEquals("2025-06-01 14:00", notes.get(1).getDateTime()); + } + + @Test + public void gatherData_voidedNoteObs_isExcluded() { + Visit visit = Context.getVisitService().getVisit(601); + + List notes = section.gatherData(visit); + + Assertions.assertTrue(notes.stream().noneMatch(n -> "Voided note".equals(n.getText())), + "Voided note obs must be excluded"); + } + + @Test + public void gatherData_noteInVoidedEncounter_isExcluded() { + Visit visit = Context.getVisitService().getVisit(601); + + List notes = section.gatherData(visit); + + Assertions.assertTrue(notes.stream().noneMatch(n -> "Note in voided encounter".equals(n.getText())), + "Notes hanging off a voided encounter must be excluded"); + } + + @Test + public void gatherData_encounterWithNoNoteObs_returnsEmptyList() { + // Visit 602's encounter has no obs + Visit visit = Context.getVisitService().getVisit(602); + + Assertions.assertTrue(section.gatherData(visit).isEmpty()); + } + + @Test + public void gatherData_visitWithNoEncounters_returnsEmptyList() { + // Visit 603 has no encounters at all (the encounters.isEmpty() early return) + Visit visit = Context.getVisitService().getVisit(603); + + Assertions.assertTrue(section.gatherData(visit).isEmpty()); + } + + @Test + public void gatherData_noteWithNoText_rendersPlaceholderAndKeepsProvenance() { + Visit visit = Context.getVisitService().getVisit(605); + + List notes = section.gatherData(visit); + + Assertions.assertEquals(2, notes.size(), "The textless note must not be dropped"); + VisitNoteEntry placeholder = notes.get(0); + Assertions.assertEquals("—", placeholder.getText()); + Assertions.assertEquals("Hippocrates of Cos", placeholder.getProvider()); + Assertions.assertEquals("2025-06-05 09:00", placeholder.getDateTime()); + } + + @Test + public void gatherData_encounterWithNoProvider_rendersUnknownProviderAndKeepsText() { + Visit visit = Context.getVisitService().getVisit(604); + + List notes = section.gatherData(visit); + + VisitNoteEntry note = notes.stream() + .filter(n -> "Note without any provider".equals(n.getText())).findFirst().orElse(null); + Assertions.assertNotNull(note, "The note must render even without a provider"); + Assertions.assertEquals("Unknown", note.getProvider()); + } + + @Test + public void gatherData_providerUnderNonClinicianRole_fallsBackToThatProvider() { + // Many deployments attach the provider under the stock "Unknown" role; the + // encounter's real provider must be shown, not "Unknown" (mirrors O3, which + // reads the encounter's providers regardless of role). + Visit visit = Context.getVisitService().getVisit(604); + + List notes = section.gatherData(visit); + + VisitNoteEntry note = notes.stream() + .filter(n -> "Note with non-clinician provider".equals(n.getText())).findFirst().orElse(null); + Assertions.assertNotNull(note, "The note must render even when no clinician-role provider exists"); + Assertions.assertEquals("Hippocrates of Cos", note.getProvider()); + } + + @Test + public void gatherData_multipleClinicianProviders_joinsNamesSorted() { + Visit visit = Context.getVisitService().getVisit(605); + + List notes = section.gatherData(visit); + + VisitNoteEntry note = notes.stream() + .filter(n -> "Jointly written team note".equals(n.getText())).findFirst().orElse(null); + Assertions.assertNotNull(note, "Expected the two-provider note"); + Assertions.assertEquals("Bruno Otterbourg, Hippocrates of Cos", note.getProvider()); + } + + @Test + public void gatherData_unresolvableConceptMapping_throws() { + Context.getAdministrationService().saveGlobalProperty(new GlobalProperty( + VisitNotesSection.NOTE_CONCEPT_PROPERTY, "CIEL:999999")); + Visit visit = Context.getVisitService().getVisit(601); + + Assertions.assertThrows(IllegalStateException.class, () -> section.gatherData(visit), + "Expected IllegalStateException when the note concept mapping does not resolve"); + } + + @Test + public void gatherData_malformedConceptProperty_throws() { + Context.getAdministrationService().saveGlobalProperty(new GlobalProperty( + VisitNotesSection.NOTE_CONCEPT_PROPERTY, "notamapping")); + Visit visit = Context.getVisitService().getVisit(601); + + Assertions.assertThrows(IllegalStateException.class, () -> section.gatherData(visit), + "Expected IllegalStateException when the note concept property is not source:code"); + } + + @Test + public void gatherData_unresolvableClinicianRole_addsNoticeAndKeepsNotes() { + Context.getAdministrationService().saveGlobalProperty(new GlobalProperty( + VisitNotesSection.CLINICIAN_ROLE_PROPERTY, "NoSuchRole")); + Visit visit = Context.getVisitService().getVisit(601); + List notices = new ArrayList<>(); + + List notes = section.gatherData(visit, notices); + + Assertions.assertEquals(2, notes.size(), "Notes must still render when the role is unresolvable"); + Assertions.assertEquals("Hippocrates of Cos", notes.get(0).getProvider(), + "Providers must fall back to any-role attribution, not degrade to 'Unknown'"); + Assertions.assertEquals(1, notices.size(), "Expected one notice for the unresolved role"); + Assertions.assertTrue(notices.get(0).contains("NoSuchRole"), + "Notice should name the unresolved encounter role"); + } + + @Test + public void gatherData_defaultRoleMissingInDeployment_fallsBackWithoutNotice() { + // A deployment without a "Clinician" role has not misconfigured anything, so a + // notice on every PDF would be noise; the notice is reserved for explicit overrides. + Context.getAdministrationService().saveGlobalProperty(new GlobalProperty( + VisitNotesSection.CLINICIAN_ROLE_PROPERTY, "")); + EncounterRole clinician = Context.getEncounterService().getEncounterRoleByName("Clinician"); + clinician.setName("Attending"); + Context.getEncounterService().saveEncounterRole(clinician); + Visit visit = Context.getVisitService().getVisit(601); + List notices = new ArrayList<>(); + + List notes = section.gatherData(visit, notices); + + Assertions.assertEquals(2, notes.size()); + Assertions.assertEquals("Hippocrates of Cos", notes.get(0).getProvider(), + "Attribution must fall back to the encounter's providers"); + Assertions.assertTrue(notices.isEmpty(), "The default role not existing must not add a notice"); + } + + // ── renderXml tests ─────────────────────────────────────────────────────── + + @Test + public void renderXml_withEntries_producesVisitNotesElementMatchingXsltContract() throws Exception { + Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument(); + Element root = doc.createElement("root"); + doc.appendChild(root); + List data = Arrays.asList( + new VisitNoteEntry("Morning assessment note", "Hippocrates of Cos", "2025-06-01 10:00"), + new VisitNoteEntry("Afternoon follow-up note", "Bruno Otterbourg", "2025-06-01 14:00")); + + section.renderXml(doc, root, data); + + Assertions.assertEquals(1, root.getChildNodes().getLength()); + Element notesEl = (Element) root.getChildNodes().item(0); + Assertions.assertEquals("visitNotes", notesEl.getNodeName()); + Assertions.assertEquals("Visit Notes", notesEl.getAttribute("heading")); + Assertions.assertEquals(2, notesEl.getChildNodes().getLength()); + + Element firstNote = (Element) notesEl.getChildNodes().item(0); + Assertions.assertEquals("note", firstNote.getNodeName()); + Assertions.assertEquals("Hippocrates of Cos", firstNote.getAttribute("provider")); + Assertions.assertEquals("2025-06-01 10:00", firstNote.getAttribute("datetime")); + Assertions.assertEquals("Morning assessment note", firstNote.getTextContent()); + + Element secondNote = (Element) notesEl.getChildNodes().item(1); + Assertions.assertEquals("Afternoon follow-up note", secondNote.getTextContent()); + } + + @Test + public void renderXml_withEmptyData_producesVisitNotesElementWithNoChildren() throws Exception { + Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument(); + Element root = doc.createElement("root"); + doc.appendChild(root); + + section.renderXml(doc, root, Collections.emptyList()); + + Assertions.assertEquals(1, root.getChildNodes().getLength()); + Element notesEl = (Element) root.getChildNodes().item(0); + Assertions.assertEquals("visitNotes", notesEl.getNodeName()); + Assertions.assertEquals(0, notesEl.getChildNodes().getLength()); + } + + @Test + public void renderXml_whenConfigInvalid_emitsSectionErrorForVisitNotes() throws Exception { + // End-to-end: a gatherData failure must surface as the visitNotes section-error banner, + // never as an empty (silently "no notes") section. + Context.getAdministrationService().saveGlobalProperty(new GlobalProperty( + VisitNotesSection.NOTE_CONCEPT_PROPERTY, "CIEL:999999")); + Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument(); + Element root = doc.createElement("root"); + doc.appendChild(root); + Visit visit = Context.getVisitService().getVisit(601); + + section.renderXml(doc, root, visit); + + Assertions.assertEquals(1, root.getChildNodes().getLength()); + Element errorEl = (Element) root.getChildNodes().item(0); + Assertions.assertEquals("section-error", errorEl.getNodeName()); + Assertions.assertEquals("visitNotes", errorEl.getAttribute("key")); + } + + @Test + public void renderXml_unresolvableClinicianRole_emitsNoticeAlongsideData() throws Exception { + // The notice must be a sibling of the data element — notes render AND the gap is visible. + Context.getAdministrationService().saveGlobalProperty(new GlobalProperty( + VisitNotesSection.CLINICIAN_ROLE_PROPERTY, "NoSuchRole")); + Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument(); + Element root = doc.createElement("root"); + doc.appendChild(root); + Visit visit = Context.getVisitService().getVisit(601); + + section.renderXml(doc, root, visit); + + Assertions.assertEquals(2, root.getChildNodes().getLength()); + Element dataEl = (Element) root.getChildNodes().item(0); + Assertions.assertEquals("visitNotes", dataEl.getNodeName()); + Assertions.assertEquals(2, dataEl.getElementsByTagName("note").getLength(), + "Notes should still render when the role config is broken"); + Element noticeEl = (Element) root.getChildNodes().item(1); + Assertions.assertEquals("section-notice", noticeEl.getNodeName()); + Assertions.assertEquals("visitNotes", noticeEl.getAttribute("key")); + Assertions.assertTrue(noticeEl.getAttribute("message").contains("NoSuchRole"), + "Notice message should name the unresolved encounter role"); + } + + // ── isEnabled tests ─────────────────────────────────────────────────────── + + @Test + public void isEnabled_whenGlobalPropertySetToFalse_returnsFalse() { + Context.getAdministrationService().saveGlobalProperty(new GlobalProperty( + "report.visitSummary.section.visitNotes.enabled", "false")); + + Assertions.assertFalse(section.isEnabled()); + } +} diff --git a/api/src/test/resources/org/openmrs/module/patientdocuments/include/visitNotesSectionTestDataset.xml b/api/src/test/resources/org/openmrs/module/patientdocuments/include/visitNotesSectionTestDataset.xml new file mode 100644 index 0000000..751a574 --- /dev/null +++ b/api/src/test/resources/org/openmrs/module/patientdocuments/include/visitNotesSectionTestDataset.xml @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +