diff --git a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/addDisplayToCodings.test.ts b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/addDisplayToCodings.test.ts index b4d32b48c..d272eb1c2 100644 --- a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/addDisplayToCodings.test.ts +++ b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/addDisplayToCodings.test.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import { getCodeSystemLookupPromise } from '../api/lookupCodeSystem'; import { resolveLookupPromises } from '../utils/resolveLookupPromises'; import { addDisplayToQuestionnaireResponseCodings } from '../utils/addDisplayToCodings'; diff --git a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/answerOption.test.ts b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/answerOption.test.ts index f07ee277c..dc26a431e 100644 --- a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/answerOption.test.ts +++ b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/answerOption.test.ts @@ -31,6 +31,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import { describe, expect, it } from '@jest/globals'; import { findInAnswerOptions } from '../utils/answerOption'; describe('findInAnswerOptions', () => { diff --git a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/createQuestionnaireReference.test.ts b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/createQuestionnaireReference.test.ts index 2506c8347..a19eb6f79 100644 --- a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/createQuestionnaireReference.test.ts +++ b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/createQuestionnaireReference.test.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import { describe, expect, it } from '@jest/globals'; import type { Questionnaire } from 'fhir/r4'; import { createQuestionnaireReference } from '../utils/createQuestionnaireReference'; diff --git a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/expandValueSet.test.ts b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/expandValueSet.test.ts index f3fce0c75..8f0a783bb 100644 --- a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/expandValueSet.test.ts +++ b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/expandValueSet.test.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import type { QuestionnaireItem } from 'fhir/r4'; import { defaultTerminologyRequest } from '../api/defaultTerminologyRequest'; import { getValueSetPromise } from '../api/expandValueSet'; diff --git a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/fetchQuestionnaire.test.ts b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/fetchQuestionnaire.test.ts index a4616c1a5..82e95f27f 100644 --- a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/fetchQuestionnaire.test.ts +++ b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/fetchQuestionnaire.test.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import { describe, expect, it, jest, test } from '@jest/globals'; import type { Bundle, OperationOutcome, Questionnaire } from 'fhir/r4'; import { fetchQuestionnaire, safeReplaceCanonicalVersion } from '../api/fetchQuestionnaire'; import type { InputParameters } from '../interfaces'; // Adjust FHIR version if needed diff --git a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/lookupCodeSystem.test.ts b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/lookupCodeSystem.test.ts index c607ddcf7..c1b5273b0 100644 --- a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/lookupCodeSystem.test.ts +++ b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/lookupCodeSystem.test.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import { defaultTerminologyRequest } from '../api/defaultTerminologyRequest'; import type { Coding } from 'fhir/r4'; import { getCodeSystemLookupPromise, lookupResponseIsValid } from '../api/lookupCodeSystem'; diff --git a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/parse.test.ts b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/parse.test.ts index ddbcc7d2f..8f7376048 100644 --- a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/parse.test.ts +++ b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/parse.test.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import { describe, expect, it } from '@jest/globals'; import type { QuestionnaireItem, QuestionnaireItemInitial } from 'fhir/r4'; import { parseItemInitialToAnswer, parseValueToAnswer } from '../utils/parse'; import { checkIsDateTime, checkIsTime, convertDateTimeToDate } from '../utils/constructResponse'; diff --git a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/populate.test.ts b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/populate.test.ts index 2824f7c8e..f0f3c0f7d 100644 --- a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/populate.test.ts +++ b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/populate.test.ts @@ -1,4 +1,4 @@ -import { describe } from '@jest/globals'; +import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals'; import type { Bundle, Patient, QuestionnaireResponse } from 'fhir/r4'; import { patRepop } from '../../test-data-shared/patRepop'; import { bundleObsBodyHeight } from '../../test-data-shared/CalculatedExpressionBMICalculatorPrepop/bundleObsBodyHeight'; @@ -38,7 +38,7 @@ function prepareContextForFhirpathV4(ctx: Record): Record { const actual = jest.requireActual('../utils/createFhirPathContext'); return { - ...actual, + ...(actual as object), createFhirPathContext: jest.fn() // only createReferenceContextTuple is mocked }; }); @@ -46,12 +46,14 @@ jest.mock('../utils/createFhirPathContext', () => { // Mock resolveLookupPromises function jest.mock('../utils/resolveLookupPromises'); -const mockFetchResourceCallback = jest.fn(); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockFetchResourceCallback = jest.fn() as any; const mockFetchResourceCallbackConfig = { sourceServerUrl: 'https://example.com/fhir' }; -const mockTerminologyCallback = jest.fn(); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockTerminologyCallback = jest.fn() as any; const mockTerminologyCallbackConfig = { terminologyServerUrl: 'https://example.com/terminology/fhir' }; @@ -59,6 +61,52 @@ const mockTerminologyCallbackConfig = { // Launch context resources const mockPatient: Patient = patRepop; +const SDC_INITIAL_EXPR = + 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression'; +const SDC_ITEM_POP_CTX = + 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-itemPopulationContext'; + +// Minimal questionnaire with a repeat group whose children use toString() on a context variable. +// This pattern previously caused false-positive issues when 2+ resources were in the collection. +const qRepeatGroupWithToString = { + resourceType: 'Questionnaire', + id: 'repeat-group-tostring-test', + status: 'active', + item: [ + { + linkId: 'conditions-group', + type: 'group', + repeats: true, + extension: [ + { + url: SDC_ITEM_POP_CTX, + valueExpression: { + name: 'ConditionRepeat', + language: 'text/fhirpath', + expression: '%ConditionInput.entry.resource' + } + } + ], + item: [ + { + linkId: 'condition-onset', + type: 'date', + extension: [ + { + url: SDC_INITIAL_EXPR, + valueExpression: { + language: 'text/fhirpath', + expression: + '%ConditionRepeat.onset.ofType(dateTime).toString().substring(0,10).toDate()' + } + } + ] + } + ] + } + ] +}; + describe('populate', () => { beforeAll(() => { jest.useFakeTimers(); // or 'legacy' depending on your Jest version @@ -161,8 +209,67 @@ describe('populate', () => { const resultContext = resultAsOutputParameters.parameter.find( (param) => param.name === 'contextResult-custom' - ).valueAttachment.data as string; + )?.valueAttachment?.data as string; const decodedResultContext = JSON.parse(Base64.decode(resultContext)); expect(decodedResultContext).toStrictEqual(mockContext); }); }); + +describe('populate - repeat group with toString()', () => { + it('should populate 2 repeat instances without issues when 2 resources are in the collection', async () => { + const mockContext = { + resource: { resourceType: 'QuestionnaireResponse', status: 'in-progress' }, + rootResource: { resourceType: 'QuestionnaireResponse', status: 'in-progress' }, + patient: { resourceType: 'Patient', id: 'test-patient' }, + ConditionInput: { + resourceType: 'Bundle', + type: 'searchset', + entry: [ + { resource: { resourceType: 'Condition', id: 'cond-1', onsetDateTime: '2023-06-15' } }, + { resource: { resourceType: 'Condition', id: 'cond-2', onsetDateTime: '2022-03-20' } } + ] + } + }; + + (createFhirPathContext as jest.Mock).mockImplementation(async () => mockContext); + (resolveLookupPromises as jest.Mock).mockImplementation(async () => ({})); + + const inputParameters: InputParameters = { + resourceType: 'Parameters', + parameter: [ + { name: 'questionnaire', resource: qRepeatGroupWithToString as any }, + { name: 'subject', valueReference: { type: 'Patient', reference: 'Patient/test-patient' } } + ] + }; + + const result = await populate( + inputParameters, + mockFetchResourceCallback, + mockFetchResourceCallbackConfig, + mockTerminologyCallback, + mockTerminologyCallbackConfig + ); + + const resultAsOutputParameters = result as OutputParameters; + expect(resultAsOutputParameters.resourceType).toBe('Parameters'); + + // No issues should be reported — before the fix, toString() on a multi-item collection + // triggered a false-positive OperationOutcomeIssue warning + const issuesParam = resultAsOutputParameters.parameter.find((p) => p.name === 'issues'); + expect(issuesParam).toBeUndefined(); + + // Both conditions should appear as separate repeat group instances + const response = resultAsOutputParameters.parameter.find((p) => p.name === 'response') + ?.resource as QuestionnaireResponse; + expect(response).toBeDefined(); + + const repeatInstances = response.item?.filter((item) => item.linkId === 'conditions-group'); + expect(repeatInstances).toHaveLength(2); + + for (const instance of repeatInstances!) { + const onsetItem = instance.item?.find((item) => item.linkId === 'condition-onset'); + expect(onsetItem).toBeDefined(); + expect(onsetItem?.answer?.length).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/readPopulationExpressions.test.ts b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/readPopulationExpressions.test.ts index c51c50e38..7dd2e044b 100644 --- a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/readPopulationExpressions.test.ts +++ b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/readPopulationExpressions.test.ts @@ -15,10 +15,16 @@ * limitations under the License. */ +import { describe, expect, it } from '@jest/globals'; import type { Questionnaire } from 'fhir/r4'; import { readPopulationExpressions } from '../utils/readPopulationExpressions'; import { QTestFhirContext } from './resources/QTestFhirContext'; +const SDC_INITIAL_EXPR = + 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression'; +const SDC_ITEM_POP_CTX = + 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-itemPopulationContext'; + describe('readPopulationExpressions', () => { const questionnaire = QTestFhirContext as Questionnaire; const { initialExpressions } = readPopulationExpressions(questionnaire); @@ -27,3 +33,85 @@ describe('readPopulationExpressions', () => { expect(initialExpressions.q1.expression).toEqual('%ObsBodyHeight.toString()'); }); }); + +describe('readPopulationExpressions - itemPopulationContext', () => { + const questionnaire: Questionnaire = { + resourceType: 'Questionnaire', + status: 'active', + item: [ + { + linkId: 'non-repeat-field', + type: 'string', + extension: [ + { + url: SDC_INITIAL_EXPR, + valueExpression: { language: 'text/fhirpath', expression: '%patient.name.family' } + } + ] + }, + { + linkId: 'repeat-group', + type: 'group', + repeats: true, + extension: [ + { + url: SDC_ITEM_POP_CTX, + valueExpression: { + name: 'ConditionRepeat', + language: 'text/fhirpath', + expression: '%Condition.entry.resource' + } + } + ], + item: [ + { + linkId: 'condition-onset', + type: 'date', + extension: [ + { + url: SDC_INITIAL_EXPR, + valueExpression: { + language: 'text/fhirpath', + expression: + '%ConditionRepeat.onset.ofType(dateTime).toString().substring(0,10).toDate()' + } + } + ] + }, + { + linkId: 'condition-code', + type: 'open-choice', + extension: [ + { + url: SDC_INITIAL_EXPR, + valueExpression: { + language: 'text/fhirpath', + expression: '%ConditionRepeat.code.coding' + } + } + ] + } + ] + } + ] + }; + + const { initialExpressions, itemPopulationContexts } = readPopulationExpressions(questionnaire); + + it('should include initialExpression for non-repeat items', () => { + expect(initialExpressions['non-repeat-field']).toBeDefined(); + expect(initialExpressions['non-repeat-field'].expression).toEqual('%patient.name.family'); + }); + + it('should not include initialExpressions for children of an itemPopulationContext group', () => { + expect(initialExpressions['condition-onset']).toBeUndefined(); + expect(initialExpressions['condition-code']).toBeUndefined(); + }); + + it('should still register the itemPopulationContext', () => { + expect(itemPopulationContexts['ConditionRepeat']).toBeDefined(); + expect(itemPopulationContexts['ConditionRepeat'].expression).toEqual( + '%Condition.entry.resource' + ); + }); +}); diff --git a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/resolveLookupPromises.test.ts b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/resolveLookupPromises.test.ts index 93d033491..cd77838f1 100644 --- a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/resolveLookupPromises.test.ts +++ b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/test/resolveLookupPromises.test.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import { lookupResponseIsValid } from '../api/lookupCodeSystem'; import { resolveLookupPromises } from '../utils/resolveLookupPromises'; // Mock getCodeSystemLookupPromise function diff --git a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/constructResponse.ts b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/constructResponse.ts index 92898fd36..fcc93e49a 100644 --- a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/constructResponse.ts +++ b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/constructResponse.ts @@ -38,7 +38,7 @@ import dayjs from 'dayjs'; import fhirpath from 'fhirpath'; // Need to specifically import from 'index.js' to get it working with ts import fhirpath_r4_model from 'fhirpath/fhir-context/r4/index.js'; -import { getItemPopulationContextName } from './readPopulationExpressions'; +import { getInitialExpression, getItemPopulationContextName } from './readPopulationExpressions'; import { createQuestionnaireReference } from './createQuestionnaireReference'; import { parseItemInitialToAnswer, parseValueToAnswer } from './parse'; import { getValueSetPromise } from '../api/expandValueSet'; @@ -571,11 +571,11 @@ async function constructRepeatGroupInstances( const terminologyServerUrl = fetchTerminologyRequestConfig?.terminologyServerUrl ?? null; const fhirServerUrl = fetchResourceRequestConfig?.sourceServerUrl ?? null; - // Look in initialExpressions of each of the child items to relate back to the itemPopulationContext its using + // Look in child items to relate back to the itemPopulationContext being used let itemPopulationContextExpression: string | undefined; let itemPopulationContext: ItemPopulationContext | undefined; for (const childItem of qRepeatGroupParent.item) { - const expression = initialExpressions[childItem.linkId]?.expression; + const expression = getInitialExpression(childItem)?.expression; if (!expression) continue; const contextName = getItemPopulationContextName(expression); @@ -623,9 +623,10 @@ async function constructRepeatGroupInstances( }; for (const childItem of qRepeatGroupParent.item) { - // Populate answers from initialExpressions - const initialExpression = initialExpressions[childItem.linkId]; - if (initialExpression) { + // Populate answers from initialExpression — read directly from the questionnaire item + // so that expressions are evaluated per-item against the scoped context, not the global one + const childInitialExpression = getInitialExpression(childItem); + if (childInitialExpression?.expression) { // Allow child items consuming itemPopulationContext to access renderer-wide variables via fhirPathContext const fhirPathContextWithItemPopulationContext = { ...fhirPathContext, @@ -635,7 +636,7 @@ async function constructRepeatGroupInstances( try { const fhirPathResult = fhirpath.evaluate( {}, - initialExpression.expression, + childInitialExpression.expression, fhirPathContextWithItemPopulationContext, fhirpath_r4_model, { @@ -670,7 +671,7 @@ async function constructRepeatGroupInstances( } catch (e) { // e is not thrown as an Error type in fhirpath.js, so we can't use `if (e instanceof Error)` here console.warn( - `SDC-Populate Error: fhirpath evaluation for ItemPopulationContext child for expression ${initialExpression.expression} failed. Details below:` + + `SDC-Populate Error: fhirpath evaluation for ItemPopulationContext child for expression ${childInitialExpression.expression} failed. Details below:` + e ); } diff --git a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/readPopulationExpressions.ts b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/readPopulationExpressions.ts index ccee0fdb0..027978c3e 100644 --- a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/readPopulationExpressions.ts +++ b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/readPopulationExpressions.ts @@ -38,13 +38,16 @@ export function readPopulationExpressions(questionnaire: Questionnaire): Populat } /** - * Recursively read a single questionnaire item/group and save its initialExpression into the object if present + * Recursively read a single questionnaire item/group and save its initialExpression into the object if present. + * initialExpressions belonging to children of an itemPopulationContext group are excluded from the global map — + * they are evaluated per-item in constructRepeatGroupInstances and must not be pre-evaluated against the full collection. * * @author Sean Fong */ function readQuestionnaireItemRecursive( item: QuestionnaireItem, - populationExpressions: PopulationExpressions + populationExpressions: PopulationExpressions, + underItemPopulationContext = false ): PopulationExpressions { const items = item.item; if (items && items.length > 0) { @@ -59,30 +62,35 @@ function readQuestionnaireItemRecursive( }; } - // Read initial expression of group item - const initialExpression = getInitialExpression(item); - if (initialExpression && initialExpression.expression) { - populationExpressions.initialExpressions[item.linkId] = { - expression: initialExpression.expression, - value: undefined - }; + // Read initial expression of group item — skipped when under an itemPopulationContext + if (!underItemPopulationContext) { + const initialExpression = getInitialExpression(item); + if (initialExpression && initialExpression.expression) { + populationExpressions.initialExpressions[item.linkId] = { + expression: initialExpression.expression, + value: undefined + }; + } } - // iterate through items of item recursively + // Children of an itemPopulationContext group are evaluated per-item, not globally + const childrenUnderContext = underItemPopulationContext || !!itemPopulationContext; items.forEach((item) => { - readQuestionnaireItemRecursive(item, populationExpressions); + readQuestionnaireItemRecursive(item, populationExpressions, childrenUnderContext); }); return populationExpressions; } - // Read initial expression of qItem - const initialExpression = getInitialExpression(item); - if (initialExpression && initialExpression.expression) { - populationExpressions.initialExpressions[item.linkId] = { - expression: initialExpression.expression, - value: undefined - }; + // Read initial expression of qItem — skipped when under an itemPopulationContext + if (!underItemPopulationContext) { + const initialExpression = getInitialExpression(item); + if (initialExpression && initialExpression.expression) { + populationExpressions.initialExpressions[item.linkId] = { + expression: initialExpression.expression, + value: undefined + }; + } } return populationExpressions; diff --git a/packages/sdc-populate/src/inAppPopulation/test/createInputParameters.test.ts b/packages/sdc-populate/src/inAppPopulation/test/createInputParameters.test.ts index 6037b17f2..cbf67f6a5 100644 --- a/packages/sdc-populate/src/inAppPopulation/test/createInputParameters.test.ts +++ b/packages/sdc-populate/src/inAppPopulation/test/createInputParameters.test.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import { constructPopulateInputParameters } from '../utils/inputParameters'; import { resolveFhirContextReferences } from '../utils/resolveFhirContexts'; import type { Encounter, Endpoint, Patient, Practitioner, Questionnaire } from 'fhir/r4'; diff --git a/packages/sdc-populate/src/inAppPopulation/test/populateQuestionnaire.test.ts b/packages/sdc-populate/src/inAppPopulation/test/populateQuestionnaire.test.ts index d1f9cb179..8627a93fd 100644 --- a/packages/sdc-populate/src/inAppPopulation/test/populateQuestionnaire.test.ts +++ b/packages/sdc-populate/src/inAppPopulation/test/populateQuestionnaire.test.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import { describe, expect, it, jest } from '@jest/globals'; import type { Bundle, Encounter, diff --git a/packages/sdc-populate/src/inAppPopulation/test/resolveFhirContexts.test.ts b/packages/sdc-populate/src/inAppPopulation/test/resolveFhirContexts.test.ts index 2c00631b2..ae625b343 100644 --- a/packages/sdc-populate/src/inAppPopulation/test/resolveFhirContexts.test.ts +++ b/packages/sdc-populate/src/inAppPopulation/test/resolveFhirContexts.test.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import { resolveFhirContextReferences } from '../utils/resolveFhirContexts'; const mockFetchResource = jest.fn();