Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -38,27 +38,75 @@ function prepareContextForFhirpathV4(ctx: Record<string, any>): Record<string, a
jest.mock('../utils/createFhirPathContext', () => {
const actual = jest.requireActual('../utils/createFhirPathContext');
return {
...actual,
...(actual as object),
createFhirPathContext: jest.fn() // only createReferenceContextTuple is mocked
};
});

// 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'
};

// 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
Expand Down Expand Up @@ -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);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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'
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -635,7 +636,7 @@ async function constructRepeatGroupInstances(
try {
const fhirPathResult = fhirpath.evaluate(
{},
initialExpression.expression,
childInitialExpression.expression,
fhirPathContextWithItemPopulationContext,
fhirpath_r4_model,
{
Expand Down Expand Up @@ -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
);
}
Expand Down
Loading
Loading