diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/common/DateUtil.java b/api/src/main/java/org/openmrs/module/patientdocuments/common/DateUtil.java new file mode 100644 index 0000000..db538ab --- /dev/null +++ b/api/src/main/java/org/openmrs/module/patientdocuments/common/DateUtil.java @@ -0,0 +1,37 @@ +/** + * 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.common; + +import java.text.SimpleDateFormat; +import java.util.Date; + +public final class DateUtil { + + public static final String DATE_PATTERN = "yyyy-MM-dd"; + + public static final String TIME_PATTERN = "HH:mm"; + + public static final String DATE_TIME_PATTERN = DATE_PATTERN + " " + TIME_PATTERN; + + private DateUtil() { + } + + public static String formatDate(Date date) { + return date == null ? null : new SimpleDateFormat(DATE_PATTERN).format(date); + } + + public static String formatTime(Date date) { + return date == null ? null : new SimpleDateFormat(TIME_PATTERN).format(date); + } + + public static String formatDateTime(Date date) { + return date == null ? null : new SimpleDateFormat(DATE_TIME_PATTERN).format(date); + } +} diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterPdfReportRenderer.java b/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterPdfReportRenderer.java index 3fb17dc..2b761d9 100644 --- a/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterPdfReportRenderer.java +++ b/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterPdfReportRenderer.java @@ -13,7 +13,10 @@ import org.apache.fop.apps.FOUserAgent; import org.apache.fop.apps.Fop; import org.apache.fop.apps.FopFactory; +import org.apache.fop.apps.FopFactoryBuilder; import org.apache.fop.apps.MimeConstants; +import org.apache.fop.configuration.Configuration; +import org.apache.fop.configuration.DefaultConfigurationBuilder; import org.openmrs.Encounter; import org.openmrs.annotation.Handler; import org.openmrs.api.EncounterService; @@ -26,6 +29,7 @@ import org.openmrs.module.reporting.report.ReportRequest; import org.openmrs.module.reporting.report.renderer.RenderingException; import org.openmrs.module.reporting.report.renderer.ReportDesignRenderer; +import org.openmrs.util.OpenmrsClassLoader; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; @@ -43,7 +47,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.io.StringReader; -import java.net.URI; +import java.net.URL; import java.nio.file.Files; import java.util.ArrayList; import java.util.List; @@ -55,6 +59,10 @@ public class EncounterPdfReportRenderer extends ReportDesignRenderer { private static final Logger log = LoggerFactory.getLogger(EncounterPdfReportRenderer.class); + private static final String FOP_CONFIG_PATH = "conf/fop.xconf.xml"; + + private static final String FONT_BASE_PATH = "fonts/"; + @Override public String getFilename(ReportRequest request) { return "EncountersReport.pdf"; @@ -101,8 +109,8 @@ private List collectEncounters(String encounterUuids) { return encounters; } - private void transformXmlToPdf(String xmlData, OutputStream outStream) throws Exception { - FopFactory fopFactory = FopFactory.newInstance(new URI(".")); + void transformXmlToPdf(String xmlData, OutputStream outStream) throws Exception { + FopFactory fopFactory = buildFopFactory(); FOUserAgent foUserAgent = fopFactory.newFOUserAgent(); Fop fop = fopFactory.newFop(MimeConstants.MIME_PDF, foUserAgent, outStream); @@ -123,6 +131,20 @@ private void transformXmlToPdf(String xmlData, OutputStream outStream) throws Ex } } + private FopFactory buildFopFactory() throws Exception { + URL fontBaseUrl = OpenmrsClassLoader.getInstance().getResource(FONT_BASE_PATH); + if (fontBaseUrl == null) { + throw new IllegalStateException("Bundled font directory not found on classpath: " + FONT_BASE_PATH); + } + try (InputStream fopConfigStream = Helper.getInputStreamByResource(FOP_CONFIG_PATH)) { + if (fopConfigStream == null) { + throw new IllegalStateException("Bundled FOP configuration not found on classpath: " + FOP_CONFIG_PATH); + } + Configuration cfg = new DefaultConfigurationBuilder().build(fopConfigStream); + return new FopFactoryBuilder(fontBaseUrl.toURI()).setConfiguration(cfg).build(); + } + } + InputStream getStylesheetStream() throws IOException { return getStylesheetStream(getConfiguredStylesheetPath()); } diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterXmlBuilder.java b/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterXmlBuilder.java index 54c61bc..70e7d8d 100644 --- a/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterXmlBuilder.java +++ b/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterXmlBuilder.java @@ -12,6 +12,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.commons.text.StringEscapeUtils; import org.openmrs.Concept; +import org.openmrs.ConceptDatatype; import org.openmrs.ConceptName; import org.openmrs.Encounter; import org.openmrs.Obs; @@ -26,6 +27,7 @@ import org.openmrs.messagesource.MessageSourceService; import org.openmrs.module.initializer.api.InitializerService; import org.openmrs.module.o3forms.api.O3FormsService; +import org.openmrs.module.patientdocuments.common.DateUtil; import org.openmrs.module.patientdocuments.common.Helper; import org.openmrs.module.patientdocuments.common.PatientDocumentsConstants; import org.openmrs.module.webservices.rest.SimpleObject; @@ -41,9 +43,11 @@ import java.util.Collections; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; @@ -53,14 +57,29 @@ public class EncounterXmlBuilder { private static final String QUESTIONS_SECTION = "questions"; + private static final String SECTIONS_SECTION = "sections"; + private static final String ANSWERS_FIELD = "answers"; private static final String CONCEPT_FIELD = "concept"; private static final String LABEL_FIELD = "label"; + private static final String ID_FIELD = "id"; + + /** + * Prefix the O3 React form engine writes into Obs.formFieldPath, e.g. a field with id = skinColor_1 produces + * the path rfe-forms-skinColor_1. We use this to map each rendered question occurrence to the specific observation + * it captured, so questions that reuse the same concept across repeated sections each show their own single value. + */ + private static final String FORM_FIELD_PATH_PREFIX = "rfe-forms-"; + private InitializerService initializerService; + private final Set allFieldIds = new HashSet<>(); + + private final Map conceptRefUsage = new HashMap<>(); + private static final Logger log = LoggerFactory.getLogger(EncounterXmlBuilder.class); private InitializerService getInitializerService() { @@ -115,15 +134,15 @@ private String renderSingleEncounter(Encounter encounter, Locale locale, O3Forms StringBuilder xml = new StringBuilder(); xml.append(""); - xml.append(buildHeaderContent(encounter, locale)); + xml.append(buildHeaderContent(encounter)); xml.append(buildMainContent(encounter, locale, o3FormsService)); - xml.append(buildFooterContent(user, locale)); + xml.append(buildFooterContent(user)); xml.append(""); return xml.toString(); } - private String buildHeaderContent(Encounter encounter, Locale locale) { + private String buildHeaderContent(Encounter encounter) { StringBuilder xml = new StringBuilder(); Visit visit = encounter.getVisit(); Patient patient = encounter.getPatient(); @@ -153,13 +172,13 @@ private String buildHeaderContent(Encounter encounter, Locale locale) { if (isHeaderFieldEnabled("encounterDate")) { xml.append("") - .append(escape(OpenmrsUtil.getDateFormat(locale).format(encounter.getEncounterDatetime()))) + .append(escape(DateUtil.formatDate(encounter.getEncounterDatetime()))) .append(""); } if (isHeaderFieldEnabled("visitStartDate")) { - String visitStartDate = (visit != null && visit.getStartDatetime() != null) - ? OpenmrsUtil.getDateFormat(locale).format(visit.getStartDatetime()) + String visitStartDate = (visit != null && visit.getStartDatetime() != null) + ? DateUtil.formatDate(visit.getStartDatetime()) : PatientDocumentsConstants.MISSING_VALUE_PLACEHOLDER; xml.append("") .append(escape(visitStartDate)) @@ -168,7 +187,7 @@ private String buildHeaderContent(Encounter encounter, Locale locale) { if (isHeaderFieldEnabled("visitEndDate")) { String visitEndDate = (visit != null && visit.getStopDatetime() != null) - ? OpenmrsUtil.getDateFormat(locale).format(visit.getStopDatetime()) + ? DateUtil.formatDate(visit.getStopDatetime()) : PatientDocumentsConstants.MISSING_VALUE_PLACEHOLDER; xml.append("") .append(escape(visitEndDate)) @@ -199,16 +218,12 @@ private String buildHeaderContent(Encounter encounter, Locale locale) { return xml.toString(); } - private String buildFooterContent(User user, Locale locale) { + private String buildFooterContent(User user) { StringBuilder xml = new StringBuilder(); String userName = (user != null && user.getPersonName() != null) ? user.getPersonName().getFullName() : "System"; String systemId = (user != null && user.getSystemId() != null) ? user.getSystemId() : "Unknown"; - // JDK 20+ locale-aware date formats use U+202F (narrow no-break space) before AM/PM. - // The font embedded by Apache FOP lacks that glyph and renders it as "#" in the PDF, - // so we normalize it (and U+00A0) to a regular ASCII space. - String printTimestamp = OpenmrsUtil.getDateTimeFormat(locale).format(new Date()) - .replace('\u202F', ' ').replace('\u00A0', ' '); + String printTimestamp = DateUtil.formatDateTime(new Date()); String printedBy = String.format("Printed by %s (%s) at %s", userName, systemId, printTimestamp); xml.append("").append(escape(printedBy)).append(""); @@ -237,6 +252,7 @@ private String buildMainContent(Encounter encounter, Locale locale, O3FormsServi xml.append(""); List> pages = schema.get("pages"); + collectFieldIds(pages); if (pages != null) { for (Map page : pages) { xml.append(renderPage(page, obsMap, locale)); @@ -268,7 +284,7 @@ private String renderPage(Map page, Map> obsMa xml.append(""); - List> sections = (List>) page.get("sections"); + List> sections = (List>) page.get(SECTIONS_SECTION); if (sections != null) { for (Map section : sections) { xml.append(renderSection(section, obsMap, locale)); @@ -402,7 +418,12 @@ private String findObsValue(Map question, Map> return PatientDocumentsConstants.NO_DATA_RECORDED_PLACEHOLDER; } - List observations = obsMap.get(concept.getUuid()); + List conceptObs = obsMap.get(concept.getUuid()); + if (conceptObs.isEmpty()) { + return PatientDocumentsConstants.NO_DATA_RECORDED_PLACEHOLDER; + } + + List observations = selectObsForQuestion(question, conceptObs); if (observations.isEmpty()) { return PatientDocumentsConstants.NO_DATA_RECORDED_PLACEHOLDER; } @@ -412,6 +433,99 @@ private String findObsValue(Map question, Map> .collect(Collectors.joining(", ")); } + List selectObsForQuestion(Map question, List conceptObs) { + String fieldId = (String) question.get(ID_FIELD); + if (StringUtils.isBlank(fieldId)) { + return conceptObs; + } + + boolean anyFormFieldPath = conceptObs.stream() + .anyMatch(obs -> StringUtils.isNotBlank(obs.getFormFieldPath())); + if (!anyFormFieldPath) { + return conceptObs; + } + + List matched = new ArrayList<>(); + for (Obs obs : conceptObs) { + if (matchesField(obs, fieldId)) { + matched.add(obs); + } + } + + if (!matched.isEmpty()) { + return matched; + } + + return isConceptSharedByMultipleQuestions(question) ? Collections.emptyList() : conceptObs; + } + + private boolean isConceptSharedByMultipleQuestions(Map question) { + String conceptRef = extractConceptRef(question); + return conceptRef != null && conceptRefUsage.getOrDefault(conceptRef, 0) > 1; + } + + private boolean matchesField(Obs obs, String fieldId) { + String formFieldPath = obs.getFormFieldPath(); + if (StringUtils.isBlank(formFieldPath) || !formFieldPath.startsWith(FORM_FIELD_PATH_PREFIX)) { + return false; + } + String core = formFieldPath.substring(FORM_FIELD_PATH_PREFIX.length()); + if (core.equals(fieldId)) { + return true; + } + + if (core.startsWith(fieldId + "_") && !allFieldIds.contains(core)) { + return isAllDigits(core.substring(fieldId.length() + 1)); + } + return false; + } + + private boolean isAllDigits(String value) { + if (value.isEmpty()) { + return false; + } + for (int i = 0; i < value.length(); i++) { + if (!Character.isDigit(value.charAt(i))) { + return false; + } + } + return true; + } + + void collectFieldIds(List> pages) { + allFieldIds.clear(); + conceptRefUsage.clear(); + if (pages == null) { + return; + } + for (Map page : pages) { + List> sections = (List>) page.get(SECTIONS_SECTION); + if (sections == null) { + continue; + } + for (Map section : sections) { + collectQuestionIds((List>) section.get(QUESTIONS_SECTION)); + } + } + } + + private void collectQuestionIds(List> questions) { + if (questions == null) { + return; + } + for (Map question : questions) { + Object id = question.get(ID_FIELD); + if (id instanceof String) { + allFieldIds.add((String) id); + } + String conceptRef = extractConceptRef(question); + if (conceptRef != null) { + conceptRefUsage.merge(conceptRef, 1, Integer::sum); + } + collectQuestionIds((List>) question.get(QUESTIONS_SECTION)); + } + } + private String escape(String input) { return StringEscapeUtils.escapeXml10(StringUtils.defaultString(input)); } @@ -430,9 +544,34 @@ private String getLocalizedObsValue(Map question, Obs obs, Local return obs.getValueCoded().getDisplayString(); } } + + String formattedDate = formatDateObsValue(obs); + if (formattedDate != null) { + return formattedDate; + } + return obs.getValueAsString(locale); } + String formatDateObsValue(Obs obs) { + Date value = obs.getValueDatetime(); + if (value == null || obs.getConcept() == null || obs.getConcept().getDatatype() == null) { + return null; + } + + ConceptDatatype datatype = obs.getConcept().getDatatype(); + if (datatype.isDate()) { + return DateUtil.formatDate(value); + } + if (datatype.isTime()) { + return DateUtil.formatTime(value); + } + if (datatype.isDateTime()) { + return DateUtil.formatDateTime(value); + } + return null; + } + private String findAnswerLabel(Map question, String obsConceptUuid, Locale locale) { for (Map answer : extractAnswers(question)) { String answerConceptRef = (String) answer.get(CONCEPT_FIELD); diff --git a/api/src/main/resources/conf/fop.xconf.xml b/api/src/main/resources/conf/fop.xconf.xml index 6b67fbd..65e2afd 100644 --- a/api/src/main/resources/conf/fop.xconf.xml +++ b/api/src/main/resources/conf/fop.xconf.xml @@ -13,14 +13,31 @@ - + + + + + + + + + + + + + + + + + + - + diff --git a/api/src/main/resources/defaultEncounterFormFopStylesheet.xsl b/api/src/main/resources/defaultEncounterFormFopStylesheet.xsl index 8903604..cf69dd7 100644 --- a/api/src/main/resources/defaultEncounterFormFopStylesheet.xsl +++ b/api/src/main/resources/defaultEncounterFormFopStylesheet.xsl @@ -4,7 +4,7 @@ xmlns:fo="http://www.w3.org/1999/XSL/Format"> - + diff --git a/api/src/test/java/org/openmrs/module/patientdocuments/common/DateUtilTest.java b/api/src/test/java/org/openmrs/module/patientdocuments/common/DateUtilTest.java new file mode 100644 index 0000000..329cace --- /dev/null +++ b/api/src/test/java/org/openmrs/module/patientdocuments/common/DateUtilTest.java @@ -0,0 +1,45 @@ +/** + * 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.common; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.text.SimpleDateFormat; +import java.util.Date; + +public class DateUtilTest { + + private Date at(String value) throws Exception { + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(value); + } + + @Test + public void formatDate_shouldFormatAsIsoDate() throws Exception { + Assertions.assertEquals("2026-07-04", DateUtil.formatDate(at("2026-07-04 09:25:00"))); + } + + @Test + public void formatTime_shouldFormatAs24HourTime() throws Exception { + Assertions.assertEquals("09:25", DateUtil.formatTime(at("2026-07-04 09:25:00"))); + } + + @Test + public void formatDateTime_shouldFormatAsIsoDateAndTime() throws Exception { + Assertions.assertEquals("2026-07-04 09:25", DateUtil.formatDateTime(at("2026-07-04 09:25:00"))); + } + + @Test + public void formatters_shouldReturnNullForNullDate() { + Assertions.assertNull(DateUtil.formatDate(null)); + Assertions.assertNull(DateUtil.formatTime(null)); + Assertions.assertNull(DateUtil.formatDateTime(null)); + } +} diff --git a/api/src/test/java/org/openmrs/module/patientdocuments/renderer/EncounterXmlBuilderDateFormatTest.java b/api/src/test/java/org/openmrs/module/patientdocuments/renderer/EncounterXmlBuilderDateFormatTest.java new file mode 100644 index 0000000..8bf0117 --- /dev/null +++ b/api/src/test/java/org/openmrs/module/patientdocuments/renderer/EncounterXmlBuilderDateFormatTest.java @@ -0,0 +1,74 @@ +/** + * 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.renderer; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.openmrs.Concept; +import org.openmrs.ConceptDatatype; +import org.openmrs.Obs; +import org.openmrs.test.jupiter.BaseModuleContextSensitiveTest; + +import java.text.SimpleDateFormat; +import java.util.Date; + +public class EncounterXmlBuilderDateFormatTest extends BaseModuleContextSensitiveTest { + + private final EncounterXmlBuilder builder = new EncounterXmlBuilder(); + + private Date at(String yyyyMmDdHhMmSs) throws Exception { + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(yyyyMmDdHhMmSs); + } + + private Obs obsWithDatatype(String datatypeUuid, Date value) { + ConceptDatatype datatype = new ConceptDatatype(); + datatype.setUuid(datatypeUuid); + Concept concept = new Concept(); + concept.setDatatype(datatype); + Obs obs = new Obs(); + obs.setConcept(concept); + obs.setValueDatetime(value); + return obs; + } + + @Test + public void formatDateObsValue_shouldFormatDatetimeWithoutLongStyleArtifacts() throws Exception { + Obs obs = obsWithDatatype(ConceptDatatype.DATETIME_UUID, at("2026-07-04 09:25:00")); + + String result = builder.formatDateObsValue(obs); + + Assertions.assertEquals("2026-07-04 09:25", result); + } + + @Test + public void formatDateObsValue_shouldFormatDateAsIsoWithoutTime() throws Exception { + Obs obs = obsWithDatatype(ConceptDatatype.DATE_UUID, at("2026-07-01 00:00:00")); + + String result = builder.formatDateObsValue(obs); + + Assertions.assertEquals("2026-07-01", result); + } + + @Test + public void formatDateObsValue_shouldFormatTimeAs24Hour() throws Exception { + Obs obs = obsWithDatatype(ConceptDatatype.TIME_UUID, at("2026-07-01 14:05:00")); + + String result = builder.formatDateObsValue(obs); + + Assertions.assertEquals("14:05", result); + } + + @Test + public void formatDateObsValue_shouldReturnNullForNonDateConcept() throws Exception { + Obs obs = obsWithDatatype(ConceptDatatype.NUMERIC_UUID, null); + + Assertions.assertNull(builder.formatDateObsValue(obs)); + } +} diff --git a/api/src/test/java/org/openmrs/module/patientdocuments/renderer/EncounterXmlBuilderObsSelectionTest.java b/api/src/test/java/org/openmrs/module/patientdocuments/renderer/EncounterXmlBuilderObsSelectionTest.java new file mode 100644 index 0000000..48c259e --- /dev/null +++ b/api/src/test/java/org/openmrs/module/patientdocuments/renderer/EncounterXmlBuilderObsSelectionTest.java @@ -0,0 +1,130 @@ +/** + * 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.renderer; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.openmrs.Obs; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class EncounterXmlBuilderObsSelectionTest { + + private static final String NS = "rfe-forms"; + + private Obs obsWithPath(String formFieldPath) { + Obs obs = new Obs(); + obs.setFormField(NS, formFieldPath); + return obs; + } + + private Map question(String id) { + Map q = new HashMap<>(); + q.put("id", id); + return q; + } + + private Map question(String id, String conceptRef) { + Map q = question(id); + Map options = new HashMap<>(); + options.put("concept", conceptRef); + q.put("questionOptions", options); + return q; + } + + /** Builds a one-page, one-section schema declaring the given question ids. */ + private List> schemaWith(String... questionIds) { + List> questions = new ArrayList<>(); + for (String id : questionIds) { + questions.add(question(id)); + } + Map section = new HashMap<>(); + section.put("questions", questions); + Map page = new HashMap<>(); + page.put("sections", new ArrayList<>(Collections.singletonList(section))); + return new ArrayList<>(Collections.singletonList(page)); + } + + @Test + public void selectObsForQuestion_shouldReturnOnlyTheObsForThisFieldWhenConceptRepeatsAcrossSections() { + EncounterXmlBuilder builder = new EncounterXmlBuilder(); + builder.collectFieldIds(schemaWith("skinColor", "skinColor_1", "skinColor_2")); + + Obs first = obsWithPath("rfe-forms-skinColor"); + Obs second = obsWithPath("rfe-forms-skinColor_1"); + Obs third = obsWithPath("rfe-forms-skinColor_2"); + List conceptObs = Arrays.asList(first, second, third); + + Assertions.assertEquals(Collections.singletonList(first), + builder.selectObsForQuestion(question("skinColor"), conceptObs)); + Assertions.assertEquals(Collections.singletonList(second), + builder.selectObsForQuestion(question("skinColor_1"), conceptObs)); + Assertions.assertEquals(Collections.singletonList(third), + builder.selectObsForQuestion(question("skinColor_2"), conceptObs)); + } + + @Test + public void selectObsForQuestion_shouldKeepMultipleObsSharingOneFieldPath() { + EncounterXmlBuilder builder = new EncounterXmlBuilder(); + builder.collectFieldIds(schemaWith("symptoms")); + + Obs a = obsWithPath("rfe-forms-symptoms"); + Obs b = obsWithPath("rfe-forms-symptoms"); + List conceptObs = Arrays.asList(a, b); + + Assertions.assertEquals(conceptObs, builder.selectObsForQuestion(question("symptoms"), conceptObs)); + } + + @Test + public void selectObsForQuestion_shouldFallBackToAllObsWhenNoFormFieldPathPresent() { + EncounterXmlBuilder builder = new EncounterXmlBuilder(); + builder.collectFieldIds(schemaWith("apgarScore")); + + Obs a = new Obs(); + Obs b = new Obs(); + List conceptObs = Arrays.asList(a, b); + + Assertions.assertEquals(conceptObs, builder.selectObsForQuestion(question("apgarScore"), conceptObs)); + } + + @Test + public void selectObsForQuestion_shouldReturnEmptyWhenFieldHasNoMatchingObsButOthersDo() { + Map skinColor = question("skinColor", "apgarSkin"); + Map skinColor1 = question("skinColor_1", "apgarSkin"); + Map section = new HashMap<>(); + section.put("questions", Arrays.asList(skinColor, skinColor1)); + Map page = new HashMap<>(); + page.put("sections", Collections.singletonList(section)); + + EncounterXmlBuilder builder = new EncounterXmlBuilder(); + builder.collectFieldIds(Collections.singletonList(page)); + + List conceptObs = Collections.singletonList(obsWithPath("rfe-forms-skinColor_1")); + + Assertions.assertTrue(builder.selectObsForQuestion(skinColor, conceptObs).isEmpty()); + } + + @Test + public void selectObsForQuestion_shouldMatchRepeatingWidgetInstances() { + EncounterXmlBuilder builder = new EncounterXmlBuilder(); + builder.collectFieldIds(schemaWith("goal")); + + Obs g0 = obsWithPath("rfe-forms-goal_0"); + Obs g1 = obsWithPath("rfe-forms-goal_1"); + List conceptObs = Arrays.asList(g0, g1); + + Assertions.assertEquals(conceptObs, builder.selectObsForQuestion(question("goal"), conceptObs)); + } +}