From 7d545499ca226fb2016414c8281699179c1af20f Mon Sep 17 00:00:00 2001 From: Alex Lipovka Date: Thu, 11 Jun 2026 17:23:01 +0400 Subject: [PATCH 1/5] resolve x-fhir-query variables on root questionnaire level --- .../useQuestionnaireContext.test.tsx | 96 +++++++++++++++ src/hooks.ts | 110 +++++++++++++++++- src/index.ts | 3 +- src/utils.ts | 39 +++++-- tests/utils.test.ts | 73 +++++++++++- 5 files changed, 302 insertions(+), 19 deletions(-) create mode 100644 src/__tests__/useQuestionnaireContext.test.tsx diff --git a/src/__tests__/useQuestionnaireContext.test.tsx b/src/__tests__/useQuestionnaireContext.test.tsx new file mode 100644 index 0000000..ffc1c63 --- /dev/null +++ b/src/__tests__/useQuestionnaireContext.test.tsx @@ -0,0 +1,96 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { isSuccess, success } from '@beda.software/remote-data'; +import { describe, expect, test, vi } from 'vitest'; + +import { useQuestionnaireContext } from '../hooks'; +import type { QuestionnaireResponseFormData } from '../types'; +import type { FCEQuestionnaire } from '../fce.types'; + +function buildFHIRServiceMock(mapping: Record) { + return vi.fn(async (config: { url?: string }) => { + if (config.url && mapping[config.url]) { + return success(mapping[config.url]); + } + + return success({}); + }); +} + +function buildContext(variable: FCEQuestionnaire['variable']): QuestionnaireResponseFormData['context'] { + const fceQuestionnaire: FCEQuestionnaire = { + resourceType: 'Questionnaire', + status: 'active', + item: [], + variable, + }; + + return { + questionnaireResponse: { resourceType: 'QuestionnaireResponse', status: 'in-progress' }, + questionnaire: fceQuestionnaire as any, + fceQuestionnaire, + launchContextParameters: [{ name: 'patient', resource: { resourceType: 'Patient', id: 'p1' } }], + }; +} + +describe('useQuestionnaireContext', () => { + test('fetches x-fhir-query variables and feeds dependent fhirpath variables', async () => { + const qrfDataContext = buildContext([ + { + name: 'PatientBundle', + language: 'application/x-fhir-query', + expression: 'Patient?_id={{ %patient.id }}', + }, + { + name: 'PatientFamily', + language: 'text/fhirpath', + expression: '%PatientBundle.entry.resource.name.family', + }, + ]); + + const fhirService = buildFHIRServiceMock({ + 'Patient?_id=p1': { + entry: [{ resource: { resourceType: 'Patient', name: [{ family: 'Smith' }] } }], + }, + }); + + const { result } = renderHook(() => useQuestionnaireContext({ qrfDataContext, values: {}, fhirService })); + + // Before the fetch resolves, the query variable is empty (no crash). + expect(result.current.context.PatientBundle).toStrictEqual([]); + expect(result.current.context.PatientFamily).toStrictEqual([]); + + await waitFor(() => { + expect(result.current.context.PatientFamily?.[0]).toEqual('Smith'); + }); + + expect(isSuccess(result.current.evaluationResponse)).toBe(true); + expect(fhirService).toHaveBeenCalledWith(expect.objectContaining({ url: 'Patient?_id=p1' })); + }); + + test('does not re-issue the request when the resolved URL is unchanged', async () => { + const qrfDataContext = buildContext([ + { + name: 'PatientBundle', + language: 'application/x-fhir-query', + expression: 'Patient?_id={{ %patient.id }}', + }, + ]); + + const fhirService = buildFHIRServiceMock({ + 'Patient?_id=p1': { entry: [{ resource: { resourceType: 'Patient' } }] }, + }); + + const { result, rerender } = renderHook(() => + useQuestionnaireContext({ qrfDataContext, values: {}, fhirService }), + ); + + await waitFor(() => { + expect(isSuccess(result.current.evaluationResponse)).toBe(true); + }); + + rerender(); + rerender(); + + expect(fhirService).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks.ts b/src/hooks.ts index a60d7d0..71a5eac 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -4,8 +4,8 @@ import { FCEQuestionnaireItem } from './fce.types'; import { type RemoteData, isSuccess, loading, success, mapSuccess, sequenceArray } from '@beda.software/remote-data'; import { QRFContext } from './context'; -import { EvaluateFhirpath, ItemContext } from './types'; -import { resolveTemplateExpr, evaluateFHIRPathExpression, getBranchItems } from './utils'; +import { EvaluateFhirpath, FormItems, ItemContext, QuestionnaireResponseFormData } from './types'; +import { calcInitialContext, resolveTemplateExpr, evaluateFHIRPathExpression, getBranchItems } from './utils'; export function useQuestionnaireResponseFormContext() { return useContext(QRFContext); @@ -181,3 +181,109 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): { evaluationResponse, }; } + +export type UseQuestionnaireContextArgs = { + qrfDataContext: QuestionnaireResponseFormData['context']; + values: FormItems; + fhirService: (config: AxiosRequestConfig) => Promise>; + evaluateFhirpath?: EvaluateFhirpath; +}; + +type QuestionnaireAsyncState = Record< + string, + { + key: string; + remoteData: RemoteData; + } +>; + +export function useQuestionnaireContext(props: UseQuestionnaireContextArgs): { + context: ItemContext; + evaluationResponse: RemoteData; +} { + const { qrfDataContext, values, fhirService, evaluateFhirpath } = props; + const variables = useMemo( + () => qrfDataContext.fceQuestionnaire.variable ?? [], + [qrfDataContext.fceQuestionnaire.variable], + ); + const [asyncState, setAsyncState] = useState({}); + + const resolutionContext = useMemo( + () => calcInitialContext(qrfDataContext, values, evaluateFhirpath), + [qrfDataContext, values, evaluateFhirpath], + ); + + useEffect(() => { + variables.forEach((variable) => { + if (!variable?.name || !variable.expression || variable.language !== 'application/x-fhir-query') { + return; + } + + const { name, expression } = variable; + const url = resolveTemplateExpr(expression, resolutionContext, `questionnaire.variable.${name}`, true); + + if (!url) { + return; + } + + const current = asyncState[name]; + if (current && current.key === url) { + // Already fetching/fetched this exact URL. + return; + } + + setAsyncState((prev) => ({ + ...prev, + [name]: { key: url, remoteData: loading }, + })); + + void (async () => { + const remoteData = await fhirService({ url, method: 'GET' }); + + setAsyncState((prev) => { + const prevVar = prev[name]; + + // Ignore outdated responses (url mismatch). + if (prevVar && prevVar.key !== url) { + return prev; + } + + return { + ...prev, + [name]: { key: url, remoteData }, + }; + }); + })(); + }); + }, [asyncState, resolutionContext, fhirService, variables]); + + const resolvedQueryVariables = useMemo(() => { + const resolved: Record = {}; + Object.entries(asyncState).forEach(([name, { remoteData }]) => { + if (isSuccess(remoteData)) { + resolved[name] = remoteData.data; + } + }); + return resolved; + }, [asyncState]); + + const context = useMemo( + () => calcInitialContext(qrfDataContext, values, evaluateFhirpath, resolvedQueryVariables), + [qrfDataContext, values, evaluateFhirpath, resolvedQueryVariables], + ); + + const evaluationResponse: RemoteData = useMemo(() => { + const remoteList = Object.values(asyncState).map(({ remoteData }) => remoteData); + + if (!remoteList.length) { + return success(context); + } + + return mapSuccess(sequenceArray(remoteList), () => context); + }, [asyncState, context]); + + return { + context, + evaluationResponse, + }; +} diff --git a/src/index.ts b/src/index.ts index f19aea0..263429c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,8 @@ export { removeItemKey, getItemKey, } from './utils'; -export { useQuestionnaireResponseFormContext } from './hooks'; +export { useQuestionnaireResponseFormContext, useQuestionnaireContext } from './hooks'; +export type { UseQuestionnaireContextArgs } from './hooks'; export { QuestionItems, QuestionItem, QuestionnaireResponseFormProvider } from './components'; export * from './converter'; export type * from './fce.types'; diff --git a/src/utils.ts b/src/utils.ts index 6fd6e54..baebe61 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -730,6 +730,7 @@ export function calcInitialContext( qrfDataContext: QuestionnaireResponseFormData['context'], values: FormItems, evaluateFhirpath?: EvaluateFhirpath, + resolvedQueryVariables?: Record, ): ItemContext { const questionnaireResponse = { ...qrfDataContext.questionnaireResponse, @@ -756,21 +757,35 @@ export function calcInitialContext( QuestionnaireResponse: questionnaireResponse, }; - // Evaluate questionnaire-level FHIRPath variables in declaration order + // Evaluate questionnaire-level variables in declaration order. Each variable is defined (as an + // empty collection at worst) so that dependent FHIRPath expressions referencing it resolve to + // empty instead of throwing "undefined environment variable" and crashing the whole form. return (qrfDataContext.fceQuestionnaire.variable ?? []).reduce((ctx: ItemContext, variable) => { - if (!variable?.name || !variable.expression || variable.language !== 'text/fhirpath') { - console.warn(`Only fhirpath variables are supported. Variable ${variable.name} is not supported.`); + if (!variable?.name) { return ctx; } - return { - ...ctx, - [variable.name]: evaluateFHIRPathExpression( - variable, - ctx, - `questionnaire.variable.${variable.name}`, - evaluateFhirpath, - ), - }; + + // x-fhir-query variables are resolved asynchronously elsewhere (useQuestionnaireContext) and + // passed in via `resolvedQueryVariables`. Anything not (yet) resolved is defined as empty. + if (variable.language !== 'text/fhirpath' || !variable.expression) { + return { ...ctx, [variable.name]: resolvedQueryVariables?.[variable.name] ?? [] }; + } + + try { + return { + ...ctx, + [variable.name]: evaluateFHIRPathExpression( + variable, + ctx, + `questionnaire.variable.${variable.name}`, + evaluateFhirpath, + ), + }; + } catch (err) { + // A single failing variable must not crash the whole form. + console.warn(`Failed to evaluate questionnaire variable ${variable.name}, defined as empty. ${err}`); + return { ...ctx, [variable.name]: [] }; + } }, baseContext); } diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 0528740..17dae65 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -2599,14 +2599,14 @@ describe('calcInitialContext', () => { expect(result.PatientType).toStrictEqual(['Patient']); }); - test('skips variables that are not text/fhirpath or are missing name/expression', () => { + test('defines unresolved/incomplete named variables as empty', () => { const result = calcInitialContext( { ...qrfDataContext, fceQuestionnaire: { ...questionnaire, variable: [ - { name: 'Query', language: 'application/x-fhir-query', expression: '/Patient' } as any, + { name: 'Query', language: 'application/x-fhir-query', expression: 'Patient' } as any, { language: 'text/fhirpath', expression: "'no-name'" } as any, { name: 'NoExpression', language: 'text/fhirpath' } as any, ], @@ -2615,8 +2615,73 @@ describe('calcInitialContext', () => { formValues, ); - expect(result).not.toHaveProperty('Query'); - expect(result).not.toHaveProperty('NoExpression'); + expect(result.Query).toStrictEqual([]); + expect(result.NoExpression).toStrictEqual([]); + expect(result).not.toHaveProperty('no-name'); + }); + + test('does not crash when a fhirpath variable references an unresolved x-fhir-query variable', () => { + const result = calcInitialContext( + { + ...qrfDataContext, + fceQuestionnaire: { + ...questionnaire, + variable: [ + { + name: 'PractitionerRoleLocation', + language: 'application/x-fhir-query', + expression: 'PractitionerRole', + } as any, + { + name: 'ClinicLocation', + language: 'text/fhirpath', + expression: '%PractitionerRoleLocation.entry.resource', + }, + ], + }, + }, + formValues, + ); + + expect(result.PractitionerRoleLocation).toStrictEqual([]); + expect(result.ClinicLocation).toStrictEqual([]); + }); + + test('injects resolved x-fhir-query variables so dependent fhirpath variables can use them', () => { + const result = calcInitialContext( + { + ...qrfDataContext, + fceQuestionnaire: { + ...questionnaire, + variable: [ + { + name: 'PractitionerRoleLocation', + language: 'application/x-fhir-query', + expression: 'PractitionerRole', + } as any, + { + name: 'ClinicLocation', + language: 'text/fhirpath', + expression: '%PractitionerRoleLocation.entry.resource.ofType(Location)', + }, + ], + }, + }, + formValues, + undefined, + { + PractitionerRoleLocation: { + resourceType: 'Bundle', + entry: [{ resource: { resourceType: 'Location', id: 'loc1' } }], + }, + }, + ); + + expect(result.PractitionerRoleLocation).toEqual({ + resourceType: 'Bundle', + entry: [{ resource: { resourceType: 'Location', id: 'loc1' } }], + }); + expect(result.ClinicLocation).toEqual([{ resourceType: 'Location', id: 'loc1' }]); }); }); From 8ef7ec47a1aa5c6aa3f1f1af6ae8832d638f3549 Mon Sep 17 00:00:00 2001 From: Alex Lipovka Date: Thu, 11 Jun 2026 17:34:56 +0400 Subject: [PATCH 2/5] evaluate sdc-questionnaire-itemPopulationContext --- src/hooks.ts | 18 +++++++++++++++--- src/utils.ts | 37 ++++++++++++++++++++++++++++++++++++- tests/utils.test.ts | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/hooks.ts b/src/hooks.ts index 71a5eac..6b3b4d6 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -5,7 +5,13 @@ import { type RemoteData, isSuccess, loading, success, mapSuccess, sequenceArray import { QRFContext } from './context'; import { EvaluateFhirpath, FormItems, ItemContext, QuestionnaireResponseFormData } from './types'; -import { calcInitialContext, resolveTemplateExpr, evaluateFHIRPathExpression, getBranchItems } from './utils'; +import { + calcInitialContext, + resolveItemPopulationContext, + resolveTemplateExpr, + evaluateFHIRPathExpression, + getBranchItems, +} from './utils'; export function useQuestionnaireResponseFormContext() { return useContext(QRFContext); @@ -41,11 +47,14 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): { useEffect(() => { branchItems.qrItems.forEach((qrItem, branchIndex) => { - const workingContext: ItemContext = { + let workingContext: ItemContext = { ...initialContext, context: qrItem, qitem: branchItems.qItem, }; + // Bind the item's itemPopulationContext so the item's variables/expressions and its + // descendants can reference it (e.g. `%PostalAddressArray`). + workingContext = resolveItemPopulationContext(workingContext, questionItem, evaluateFhirpath); variables.forEach((variable) => { if (!variable?.name || !variable.expression) { @@ -125,11 +134,14 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): { const contexts = useMemo(() => { return branchItems.qrItems.map((qrItem, branchIndex) => { - const workingContext: ItemContext = { + let workingContext: ItemContext = { ...initialContext, context: qrItem, qitem: branchItems.qItem, }; + // Bind the item's itemPopulationContext so the item's expressions and its descendants + // (rendered with this context) can reference it (e.g. `%PostalAddressArray`). + workingContext = resolveItemPopulationContext(workingContext, questionItem, evaluateFhirpath); variables.forEach((variable) => { if (!variable?.name || !variable.expression) { diff --git a/src/utils.ts b/src/utils.ts index baebe61..44aba35 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -532,6 +532,38 @@ export function getChecker( return _.constant(true); } +/** + * Evaluates an item's `sdc-questionnaire-itemPopulationContext` (a named text/fhirpath expression) and + * returns a context with that named variable bound, so expressions on the item or its descendants + * (e.g. enableWhenExpression) can reference it. Unsupported/failing contexts are bound to an empty + * collection rather than crashing the form. + */ +export function resolveItemPopulationContext( + context: ItemContext, + qItem: FCEQuestionnaireItem, + evaluateFhirpath?: EvaluateFhirpath, +): ItemContext { + const ipc = qItem.itemPopulationContext; + if (!ipc?.name || !ipc.expression || ipc.language !== 'text/fhirpath') { + return context; + } + + try { + return { + ...context, + [ipc.name]: evaluateFHIRPathExpression( + ipc, + context, + `${qItem.linkId}.itemPopulationContext`, + evaluateFhirpath, + ), + }; + } catch (err) { + console.warn(`Failed to evaluate itemPopulationContext ${ipc.name}, defined as empty. ${err}`); + return { ...context, [ipc.name]: [] }; + } +} + interface IsQuestionEnabledArgs { qItem: FCEQuestionnaireItem; parentPath: string[]; @@ -556,10 +588,13 @@ function isQuestionEnabled(args: IsQuestionEnabledArgs) { } if (enableWhenExpression && enableWhenExpression.language === 'text/fhirpath') { + // Bind the item's own itemPopulationContext (if any) so the expression can reference it, + // e.g. enableWhenExpression `%PostalAddressArray.exists()`. + const context = resolveItemPopulationContext(args.context, args.qItem, args.evaluateFhirpath); try { const expressionResult = evaluateFHIRPathExpression( enableWhenExpression, - args.context, + context, `${linkId}.enableWhenExpression`, args.evaluateFhirpath, )[0]; diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 17dae65..56caa8c 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -23,6 +23,7 @@ import { findAnswersForQuestion, ITEM_KEY, stripNonEnumerable, + resolveItemPopulationContext, } from '../src/utils'; import { ParametersParameter, @@ -2685,6 +2686,40 @@ describe('calcInitialContext', () => { }); }); +describe('resolveItemPopulationContext', () => { + const baseContext: ItemContext = { + questionnaire: { resourceType: 'Questionnaire', status: 'active' }, + resource: { resourceType: 'QuestionnaireResponse', status: 'in-progress' }, + context: { resourceType: 'QuestionnaireResponse', status: 'in-progress' }, + patient: { + resourceType: 'Patient', + address: [ + { use: 'home', line: ['1 Home St'] }, + { type: 'postal', line: ['PO Box 5'] }, + ], + }, + } as any; + + test('binds the itemPopulationContext variable so dependent expressions can use it', () => { + const result = resolveItemPopulationContext(baseContext, { + linkId: 'postal', + type: 'group', + itemPopulationContext: { + name: 'PostalAddressArray', + language: 'text/fhirpath', + expression: "%patient.address.where(type='postal')", + }, + } as any); + + expect(result.PostalAddressArray).toEqual([{ type: 'postal', line: ['PO Box 5'] }]); + }); + + test('returns the context unchanged when there is no itemPopulationContext', () => { + const result = resolveItemPopulationContext(baseContext, { linkId: 'x', type: 'string' } as any); + expect(result).toBe(baseContext); + }); +}); + describe('parseFhirQueryExpression', () => { const patient: Patient = { resourceType: 'Patient', From ef366a79f42d522dfdaa4a3587969e8e56c0dd1d Mon Sep 17 00:00:00 2001 From: Alex Lipovka Date: Fri, 12 Jun 2026 13:08:27 +0400 Subject: [PATCH 3/5] remove comments --- src/hooks.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/hooks.ts b/src/hooks.ts index 6b3b4d6..433b477 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -52,8 +52,6 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): { context: qrItem, qitem: branchItems.qItem, }; - // Bind the item's itemPopulationContext so the item's variables/expressions and its - // descendants can reference it (e.g. `%PostalAddressArray`). workingContext = resolveItemPopulationContext(workingContext, questionItem, evaluateFhirpath); variables.forEach((variable) => { @@ -139,8 +137,6 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): { context: qrItem, qitem: branchItems.qItem, }; - // Bind the item's itemPopulationContext so the item's expressions and its descendants - // (rendered with this context) can reference it (e.g. `%PostalAddressArray`). workingContext = resolveItemPopulationContext(workingContext, questionItem, evaluateFhirpath); variables.forEach((variable) => { From 729a179d789a4313bc6812d27d206c921594337d Mon Sep 17 00:00:00 2001 From: Alex Lipovka Date: Fri, 12 Jun 2026 16:29:37 +0400 Subject: [PATCH 4/5] refactor hooks to reuse logic from existing useQuestionItemContext --- src/__tests__/useQuestionItemContext.test.tsx | 30 ++-- .../useQuestionnaireContext.test.tsx | 96 ------------ src/components.tsx | 8 +- src/hooks.ts | 144 +++--------------- src/index.ts | 4 +- src/utils.ts | 64 ++------ tests/utils.test.ts | 134 ---------------- 7 files changed, 56 insertions(+), 424 deletions(-) delete mode 100644 src/__tests__/useQuestionnaireContext.test.tsx diff --git a/src/__tests__/useQuestionItemContext.test.tsx b/src/__tests__/useQuestionItemContext.test.tsx index 84859af..3755904 100644 --- a/src/__tests__/useQuestionItemContext.test.tsx +++ b/src/__tests__/useQuestionItemContext.test.tsx @@ -3,7 +3,7 @@ import type { Questionnaire, QuestionnaireResponse, QuestionnaireResponseItem } import { isSuccess, success } from '@beda.software/remote-data'; import { describe, expect, test, vi } from 'vitest'; -import { useQuestionItemContext } from '../hooks'; +import { useVariablesResolver } from '../hooks'; import type { ItemContext } from '../types'; import type { FCEQuestionnaireItem } from '../fce.types'; import { getBranchItems } from '../utils.js'; @@ -29,7 +29,7 @@ function buildFHIRServiceMock(mapping: Record) { }); } -describe('useQuestionItemContext', () => { +describe('useVariablesResolver', () => { test('returns single context for non-group question', () => { const questionnaire: Questionnaire = { resourceType: 'Questionnaire', @@ -63,10 +63,11 @@ describe('useQuestionItemContext', () => { }; const { result } = renderHook(() => - useQuestionItemContext({ + useVariablesResolver({ initialContext, branchItems, - questionItem, + variable: questionItem.variable, + prefix: questionItem.linkId, fhirService: vi.fn(), }), ); @@ -152,10 +153,11 @@ describe('useQuestionItemContext', () => { }; const { result } = renderHook(() => - useQuestionItemContext({ + useVariablesResolver({ initialContext, branchItems, - questionItem, + variable: questionItem.variable, + prefix: questionItem.linkId, fhirService: vi.fn(), }), ); @@ -175,7 +177,7 @@ describe('useQuestionItemContext', () => { }); }); -describe('useQuestionItemContext with x-fhir-query', () => { +describe('useVariablesResolver with x-fhir-query', () => { test('evaluates patient/org variable chain', async () => { const questionnaire: Questionnaire = { resourceType: 'Questionnaire', @@ -269,13 +271,18 @@ describe('useQuestionItemContext with x-fhir-query', () => { const initialBranchItems = getBranchItems([orgItem.linkId], questionnaire, initialQuestionnaireResponse); const { result, rerender } = renderHook( - (props: { + ({ + questionItem, + ...props + }: { initialContext: ItemContext; branchItems: ReturnType; questionItem: FCEQuestionnaireItem; }) => - useQuestionItemContext({ + useVariablesResolver({ ...props, + variable: questionItem.variable, + prefix: questionItem.linkId, fhirService, }), { @@ -452,10 +459,11 @@ describe('useQuestionItemContext with x-fhir-query', () => { const branchItems = getBranchItems(['row'], questionnaire, questionnaireResponse); const { result } = renderHook(() => - useQuestionItemContext({ + useVariablesResolver({ initialContext, branchItems, - questionItem, + variable: questionItem.variable, + prefix: questionItem.linkId, fhirService, }), ); diff --git a/src/__tests__/useQuestionnaireContext.test.tsx b/src/__tests__/useQuestionnaireContext.test.tsx deleted file mode 100644 index ffc1c63..0000000 --- a/src/__tests__/useQuestionnaireContext.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { renderHook, waitFor } from '@testing-library/react'; -import { isSuccess, success } from '@beda.software/remote-data'; -import { describe, expect, test, vi } from 'vitest'; - -import { useQuestionnaireContext } from '../hooks'; -import type { QuestionnaireResponseFormData } from '../types'; -import type { FCEQuestionnaire } from '../fce.types'; - -function buildFHIRServiceMock(mapping: Record) { - return vi.fn(async (config: { url?: string }) => { - if (config.url && mapping[config.url]) { - return success(mapping[config.url]); - } - - return success({}); - }); -} - -function buildContext(variable: FCEQuestionnaire['variable']): QuestionnaireResponseFormData['context'] { - const fceQuestionnaire: FCEQuestionnaire = { - resourceType: 'Questionnaire', - status: 'active', - item: [], - variable, - }; - - return { - questionnaireResponse: { resourceType: 'QuestionnaireResponse', status: 'in-progress' }, - questionnaire: fceQuestionnaire as any, - fceQuestionnaire, - launchContextParameters: [{ name: 'patient', resource: { resourceType: 'Patient', id: 'p1' } }], - }; -} - -describe('useQuestionnaireContext', () => { - test('fetches x-fhir-query variables and feeds dependent fhirpath variables', async () => { - const qrfDataContext = buildContext([ - { - name: 'PatientBundle', - language: 'application/x-fhir-query', - expression: 'Patient?_id={{ %patient.id }}', - }, - { - name: 'PatientFamily', - language: 'text/fhirpath', - expression: '%PatientBundle.entry.resource.name.family', - }, - ]); - - const fhirService = buildFHIRServiceMock({ - 'Patient?_id=p1': { - entry: [{ resource: { resourceType: 'Patient', name: [{ family: 'Smith' }] } }], - }, - }); - - const { result } = renderHook(() => useQuestionnaireContext({ qrfDataContext, values: {}, fhirService })); - - // Before the fetch resolves, the query variable is empty (no crash). - expect(result.current.context.PatientBundle).toStrictEqual([]); - expect(result.current.context.PatientFamily).toStrictEqual([]); - - await waitFor(() => { - expect(result.current.context.PatientFamily?.[0]).toEqual('Smith'); - }); - - expect(isSuccess(result.current.evaluationResponse)).toBe(true); - expect(fhirService).toHaveBeenCalledWith(expect.objectContaining({ url: 'Patient?_id=p1' })); - }); - - test('does not re-issue the request when the resolved URL is unchanged', async () => { - const qrfDataContext = buildContext([ - { - name: 'PatientBundle', - language: 'application/x-fhir-query', - expression: 'Patient?_id={{ %patient.id }}', - }, - ]); - - const fhirService = buildFHIRServiceMock({ - 'Patient?_id=p1': { entry: [{ resource: { resourceType: 'Patient' } }] }, - }); - - const { result, rerender } = renderHook(() => - useQuestionnaireContext({ qrfDataContext, values: {}, fhirService }), - ); - - await waitFor(() => { - expect(isSuccess(result.current.evaluationResponse)).toBe(true); - }); - - rerender(); - rerender(); - - expect(fhirService).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/components.tsx b/src/components.tsx index 81a8cca..cc8d1e6 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -14,7 +14,7 @@ import { stripNonEnumerable, wrapAnswerValue, } from './utils.js'; -import { useQuestionItemContext } from './hooks'; +import { useVariablesResolver } from './hooks'; function usePreviousValue(value: T) { const prevValue = useRef(value); @@ -65,11 +65,13 @@ export function QuestionItem(props: QuestionItemProps) { () => getBranchItems(fieldPath, initialContext.questionnaire, initialContext.resource), [fieldPath, initialContext.questionnaire, initialContext.resource], ); - const { contexts } = useQuestionItemContext({ + const { contexts } = useVariablesResolver({ initialContext, branchItems, fhirService, - questionItem, + evaluateFhirpath, + variable: questionItem.variable, + prefix: linkId, }); const context = type === 'group' ? contexts : contexts[0]!; const prevAnswers: FormAnswerItems[] | undefined = usePreviousValue(_.get(formValues, fieldPath)); diff --git a/src/hooks.ts b/src/hooks.ts index 433b477..4237ce4 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,28 +1,23 @@ import { useContext, useEffect, useMemo, useState } from 'react'; import type { AxiosRequestConfig } from 'axios'; -import { FCEQuestionnaireItem } from './fce.types'; import { type RemoteData, isSuccess, loading, success, mapSuccess, sequenceArray } from '@beda.software/remote-data'; import { QRFContext } from './context'; -import { EvaluateFhirpath, FormItems, ItemContext, QuestionnaireResponseFormData } from './types'; -import { - calcInitialContext, - resolveItemPopulationContext, - resolveTemplateExpr, - evaluateFHIRPathExpression, - getBranchItems, -} from './utils'; +import { EvaluateFhirpath, ItemContext } from './types'; +import { resolveItemPopulationContext, resolveTemplateExpr, evaluateFHIRPathExpression } from './utils'; +import { Expression, QuestionnaireItem, QuestionnaireResponse, QuestionnaireResponseItem } from 'fhir/r4b'; export function useQuestionnaireResponseFormContext() { return useContext(QRFContext); } -export type UseQuestionItemContextArgs = { +export type UseVariablesResolverArgs = { initialContext: ItemContext; - branchItems: ReturnType; + branchItems: { qItem?: QuestionnaireItem; qrItems: Array }; fhirService: (config: AxiosRequestConfig) => Promise>; - questionItem: FCEQuestionnaireItem; evaluateFhirpath?: EvaluateFhirpath; + variable?: Expression[]; + prefix?: string; }; type AsyncState = Record< @@ -36,12 +31,11 @@ type AsyncState = Record< > >; -export function useQuestionItemContext(props: UseQuestionItemContextArgs): { +export function useVariablesResolver(props: UseVariablesResolverArgs): { contexts: ItemContext[]; evaluationResponse: RemoteData; } { - const { initialContext, branchItems, fhirService, questionItem, evaluateFhirpath } = props; - const { variable, linkId } = questionItem; + const { initialContext, branchItems, fhirService, evaluateFhirpath, variable, prefix } = props; const variables = useMemo(() => variable ?? [], [variable]); const [asyncState, setAsyncState] = useState({}); @@ -52,7 +46,9 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): { context: qrItem, qitem: branchItems.qItem, }; - workingContext = resolveItemPopulationContext(workingContext, questionItem, evaluateFhirpath); + workingContext = branchItems.qItem + ? resolveItemPopulationContext(workingContext, branchItems.qItem, evaluateFhirpath) + : workingContext; variables.forEach((variable) => { if (!variable?.name || !variable.expression) { @@ -62,7 +58,7 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): { const { name, expression, language } = variable; if (language === 'application/x-fhir-query') { - const url = resolveTemplateExpr(expression!, workingContext, `${linkId}.variable.${name}`, true); + const url = resolveTemplateExpr(expression!, workingContext, `${prefix}.variable.${name}`, true); if (!url) { workingContext[name] = null; @@ -122,7 +118,7 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): { workingContext[name] = evaluateFHIRPathExpression( variable, workingContext, - `${linkId}.variable.${name}`, + `${prefix}.variable.${name}`, evaluateFhirpath, ); } @@ -137,7 +133,9 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): { context: qrItem, qitem: branchItems.qItem, }; - workingContext = resolveItemPopulationContext(workingContext, questionItem, evaluateFhirpath); + workingContext = branchItems.qItem + ? resolveItemPopulationContext(workingContext, branchItems.qItem, evaluateFhirpath) + : workingContext; variables.forEach((variable) => { if (!variable?.name || !variable.expression) { @@ -159,7 +157,7 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): { workingContext[name] = evaluateFHIRPathExpression( variable, workingContext, - `${linkId}.variable.${name}`, + `${prefix}.variable.${name}`, evaluateFhirpath, ); } @@ -189,109 +187,3 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): { evaluationResponse, }; } - -export type UseQuestionnaireContextArgs = { - qrfDataContext: QuestionnaireResponseFormData['context']; - values: FormItems; - fhirService: (config: AxiosRequestConfig) => Promise>; - evaluateFhirpath?: EvaluateFhirpath; -}; - -type QuestionnaireAsyncState = Record< - string, - { - key: string; - remoteData: RemoteData; - } ->; - -export function useQuestionnaireContext(props: UseQuestionnaireContextArgs): { - context: ItemContext; - evaluationResponse: RemoteData; -} { - const { qrfDataContext, values, fhirService, evaluateFhirpath } = props; - const variables = useMemo( - () => qrfDataContext.fceQuestionnaire.variable ?? [], - [qrfDataContext.fceQuestionnaire.variable], - ); - const [asyncState, setAsyncState] = useState({}); - - const resolutionContext = useMemo( - () => calcInitialContext(qrfDataContext, values, evaluateFhirpath), - [qrfDataContext, values, evaluateFhirpath], - ); - - useEffect(() => { - variables.forEach((variable) => { - if (!variable?.name || !variable.expression || variable.language !== 'application/x-fhir-query') { - return; - } - - const { name, expression } = variable; - const url = resolveTemplateExpr(expression, resolutionContext, `questionnaire.variable.${name}`, true); - - if (!url) { - return; - } - - const current = asyncState[name]; - if (current && current.key === url) { - // Already fetching/fetched this exact URL. - return; - } - - setAsyncState((prev) => ({ - ...prev, - [name]: { key: url, remoteData: loading }, - })); - - void (async () => { - const remoteData = await fhirService({ url, method: 'GET' }); - - setAsyncState((prev) => { - const prevVar = prev[name]; - - // Ignore outdated responses (url mismatch). - if (prevVar && prevVar.key !== url) { - return prev; - } - - return { - ...prev, - [name]: { key: url, remoteData }, - }; - }); - })(); - }); - }, [asyncState, resolutionContext, fhirService, variables]); - - const resolvedQueryVariables = useMemo(() => { - const resolved: Record = {}; - Object.entries(asyncState).forEach(([name, { remoteData }]) => { - if (isSuccess(remoteData)) { - resolved[name] = remoteData.data; - } - }); - return resolved; - }, [asyncState]); - - const context = useMemo( - () => calcInitialContext(qrfDataContext, values, evaluateFhirpath, resolvedQueryVariables), - [qrfDataContext, values, evaluateFhirpath, resolvedQueryVariables], - ); - - const evaluationResponse: RemoteData = useMemo(() => { - const remoteList = Object.values(asyncState).map(({ remoteData }) => remoteData); - - if (!remoteList.length) { - return success(context); - } - - return mapSuccess(sequenceArray(remoteList), () => context); - }, [asyncState, context]); - - return { - context, - evaluationResponse, - }; -} diff --git a/src/index.ts b/src/index.ts index 263429c..d37dc6b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,8 +17,8 @@ export { removeItemKey, getItemKey, } from './utils'; -export { useQuestionnaireResponseFormContext, useQuestionnaireContext } from './hooks'; -export type { UseQuestionnaireContextArgs } from './hooks'; +export { useQuestionnaireResponseFormContext, useVariablesResolver } from './hooks'; +export type { UseVariablesResolverArgs } from './hooks'; export { QuestionItems, QuestionItem, QuestionnaireResponseFormProvider } from './components'; export * from './converter'; export type * from './fce.types'; diff --git a/src/utils.ts b/src/utils.ts index 44aba35..0c981d5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -540,28 +540,23 @@ export function getChecker( */ export function resolveItemPopulationContext( context: ItemContext, - qItem: FCEQuestionnaireItem, + qItem: FCEQuestionnaireItem | undefined, evaluateFhirpath?: EvaluateFhirpath, ): ItemContext { - const ipc = qItem.itemPopulationContext; + const ipc = qItem?.itemPopulationContext; if (!ipc?.name || !ipc.expression || ipc.language !== 'text/fhirpath') { return context; } - try { - return { - ...context, - [ipc.name]: evaluateFHIRPathExpression( - ipc, - context, - `${qItem.linkId}.itemPopulationContext`, - evaluateFhirpath, - ), - }; - } catch (err) { - console.warn(`Failed to evaluate itemPopulationContext ${ipc.name}, defined as empty. ${err}`); - return { ...context, [ipc.name]: [] }; - } + return { + ...context, + [ipc.name]: evaluateFHIRPathExpression( + ipc, + context, + `${qItem?.linkId}.itemPopulationContext`, + evaluateFhirpath, + ), + }; } interface IsQuestionEnabledArgs { @@ -588,8 +583,6 @@ function isQuestionEnabled(args: IsQuestionEnabledArgs) { } if (enableWhenExpression && enableWhenExpression.language === 'text/fhirpath') { - // Bind the item's own itemPopulationContext (if any) so the expression can reference it, - // e.g. enableWhenExpression `%PostalAddressArray.exists()`. const context = resolveItemPopulationContext(args.context, args.qItem, args.evaluateFhirpath); try { const expressionResult = evaluateFHIRPathExpression( @@ -764,15 +757,13 @@ export function getEnabledQuestions( export function calcInitialContext( qrfDataContext: QuestionnaireResponseFormData['context'], values: FormItems, - evaluateFhirpath?: EvaluateFhirpath, - resolvedQueryVariables?: Record, ): ItemContext { const questionnaireResponse = { ...qrfDataContext.questionnaireResponse, ...mapFormToResponse(values, qrfDataContext.questionnaire), }; - const baseContext: ItemContext = { + return { ...qrfDataContext.launchContextParameters.reduce((acc, { name, resource, ...param }) => { const value = getChoiceTypeValue(param, 'value'); @@ -791,37 +782,6 @@ export function calcInitialContext( Questionnaire: qrfDataContext.questionnaire, QuestionnaireResponse: questionnaireResponse, }; - - // Evaluate questionnaire-level variables in declaration order. Each variable is defined (as an - // empty collection at worst) so that dependent FHIRPath expressions referencing it resolve to - // empty instead of throwing "undefined environment variable" and crashing the whole form. - return (qrfDataContext.fceQuestionnaire.variable ?? []).reduce((ctx: ItemContext, variable) => { - if (!variable?.name) { - return ctx; - } - - // x-fhir-query variables are resolved asynchronously elsewhere (useQuestionnaireContext) and - // passed in via `resolvedQueryVariables`. Anything not (yet) resolved is defined as empty. - if (variable.language !== 'text/fhirpath' || !variable.expression) { - return { ...ctx, [variable.name]: resolvedQueryVariables?.[variable.name] ?? [] }; - } - - try { - return { - ...ctx, - [variable.name]: evaluateFHIRPathExpression( - variable, - ctx, - `questionnaire.variable.${variable.name}`, - evaluateFhirpath, - ), - }; - } catch (err) { - // A single failing variable must not crash the whole form. - console.warn(`Failed to evaluate questionnaire variable ${variable.name}, defined as empty. ${err}`); - return { ...ctx, [variable.name]: [] }; - } - }, baseContext); } export function resolveTemplateExpr( diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 56caa8c..699441a 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -2550,140 +2550,6 @@ describe('calcInitialContext', () => { StringValue: 'string', }); }); - - test('evaluates questionnaire-level variables and adds them to the context', () => { - const result = calcInitialContext( - { - ...qrfDataContext, - fceQuestionnaire: { - ...questionnaire, - variable: [{ name: 'Greeting', language: 'text/fhirpath', expression: "'Hello'" }], - }, - }, - formValues, - ); - - expect(result.Greeting).toStrictEqual(['Hello']); - }); - - test('evaluates variables in declaration order so later ones can reference earlier ones', () => { - const result = calcInitialContext( - { - ...qrfDataContext, - fceQuestionnaire: { - ...questionnaire, - variable: [ - { name: 'First', language: 'text/fhirpath', expression: "'First'" }, - { name: 'Second', language: 'text/fhirpath', expression: "%First + 'Second'" }, - ], - }, - }, - formValues, - ); - - expect(result.First).toStrictEqual(['First']); - expect(result.Second).toStrictEqual(['FirstSecond']); - }); - - test('variables can reference launch context and questionnaire response values', () => { - const result = calcInitialContext( - { - ...qrfDataContext, - fceQuestionnaire: { - ...questionnaire, - variable: [{ name: 'PatientType', language: 'text/fhirpath', expression: '%Patient.resourceType' }], - }, - }, - formValues, - ); - - expect(result.PatientType).toStrictEqual(['Patient']); - }); - - test('defines unresolved/incomplete named variables as empty', () => { - const result = calcInitialContext( - { - ...qrfDataContext, - fceQuestionnaire: { - ...questionnaire, - variable: [ - { name: 'Query', language: 'application/x-fhir-query', expression: 'Patient' } as any, - { language: 'text/fhirpath', expression: "'no-name'" } as any, - { name: 'NoExpression', language: 'text/fhirpath' } as any, - ], - }, - }, - formValues, - ); - - expect(result.Query).toStrictEqual([]); - expect(result.NoExpression).toStrictEqual([]); - expect(result).not.toHaveProperty('no-name'); - }); - - test('does not crash when a fhirpath variable references an unresolved x-fhir-query variable', () => { - const result = calcInitialContext( - { - ...qrfDataContext, - fceQuestionnaire: { - ...questionnaire, - variable: [ - { - name: 'PractitionerRoleLocation', - language: 'application/x-fhir-query', - expression: 'PractitionerRole', - } as any, - { - name: 'ClinicLocation', - language: 'text/fhirpath', - expression: '%PractitionerRoleLocation.entry.resource', - }, - ], - }, - }, - formValues, - ); - - expect(result.PractitionerRoleLocation).toStrictEqual([]); - expect(result.ClinicLocation).toStrictEqual([]); - }); - - test('injects resolved x-fhir-query variables so dependent fhirpath variables can use them', () => { - const result = calcInitialContext( - { - ...qrfDataContext, - fceQuestionnaire: { - ...questionnaire, - variable: [ - { - name: 'PractitionerRoleLocation', - language: 'application/x-fhir-query', - expression: 'PractitionerRole', - } as any, - { - name: 'ClinicLocation', - language: 'text/fhirpath', - expression: '%PractitionerRoleLocation.entry.resource.ofType(Location)', - }, - ], - }, - }, - formValues, - undefined, - { - PractitionerRoleLocation: { - resourceType: 'Bundle', - entry: [{ resource: { resourceType: 'Location', id: 'loc1' } }], - }, - }, - ); - - expect(result.PractitionerRoleLocation).toEqual({ - resourceType: 'Bundle', - entry: [{ resource: { resourceType: 'Location', id: 'loc1' } }], - }); - expect(result.ClinicLocation).toEqual([{ resourceType: 'Location', id: 'loc1' }]); - }); }); describe('resolveItemPopulationContext', () => { From cf1ef3a287b3b06a36ac1e2ebe6b767bb7256534 Mon Sep 17 00:00:00 2001 From: Alex Lipovka Date: Fri, 12 Jun 2026 19:59:22 +0400 Subject: [PATCH 5/5] add tests to cover itemPopulationContext --- src/__tests__/useQuestionItemContext.test.tsx | 138 +++++++++++++++++- 1 file changed, 137 insertions(+), 1 deletion(-) diff --git a/src/__tests__/useQuestionItemContext.test.tsx b/src/__tests__/useQuestionItemContext.test.tsx index 3755904..7fadbf9 100644 --- a/src/__tests__/useQuestionItemContext.test.tsx +++ b/src/__tests__/useQuestionItemContext.test.tsx @@ -6,7 +6,7 @@ import { describe, expect, test, vi } from 'vitest'; import { useVariablesResolver } from '../hooks'; import type { ItemContext } from '../types'; import type { FCEQuestionnaireItem } from '../fce.types'; -import { getBranchItems } from '../utils.js'; +import { getBranchItems, getEnabledQuestions } from '../utils.js'; function createInitialContext(questionnaire: Questionnaire, questionnaireResponse: QuestionnaireResponse): ItemContext { return { @@ -497,3 +497,139 @@ describe('useVariablesResolver with x-fhir-query', () => { ); }); }); + +describe('useVariablesResolver with itemPopulationContext', () => { + const patient = { + resourceType: 'Patient', + address: [ + { use: 'home', line: ['1 Home St'] }, + { type: 'postal', line: ['PO Box 5'] }, + ], + }; + + function buildPostalContext(patientResource: any): ItemContext { + const questionnaire: Questionnaire = { resourceType: 'Questionnaire', status: 'active', item: [] }; + const questionnaireResponse: QuestionnaireResponse = { + resourceType: 'QuestionnaireResponse', + status: 'in-progress', + item: [{ linkId: 'postal', item: [{ linkId: 'city' }] }], + }; + + return { + ...createInitialContext(questionnaire, questionnaireResponse), + patient: patientResource, + } as ItemContext; + } + + const postalQuestionnaire: Questionnaire = { + resourceType: 'Questionnaire', + status: 'active', + item: [ + { + linkId: 'postal', + type: 'group', + itemPopulationContext: { + name: 'PostalAddressArray', + language: 'text/fhirpath', + expression: "%patient.address.where(type='postal')", + }, + item: [{ linkId: 'city', type: 'string' }], + } as any, + ], + }; + + const postalQuestionnaireResponse: QuestionnaireResponse = { + resourceType: 'QuestionnaireResponse', + status: 'in-progress', + item: [{ linkId: 'postal', item: [{ linkId: 'city' }] }], + }; + + test('binds the itemPopulationContext variable into the resolved context', () => { + const initialContext = buildPostalContext(patient); + const branchItems = getBranchItems(['postal'], postalQuestionnaire, postalQuestionnaireResponse); + const questionItem = postalQuestionnaire.item![0] as FCEQuestionnaireItem; + + const { result } = renderHook(() => + useVariablesResolver({ + initialContext, + branchItems, + variable: questionItem.variable, + prefix: questionItem.linkId, + fhirService: vi.fn(), + }), + ); + + expect(result.current.contexts).toHaveLength(1); + expect(result.current.contexts[0].PostalAddressArray).toEqual([{ type: 'postal', line: ['PO Box 5'] }]); + }); + + test('a variable can reference the bound itemPopulationContext', () => { + const questionnaire: Questionnaire = { + resourceType: 'Questionnaire', + status: 'active', + item: [ + { + linkId: 'postal', + type: 'group', + itemPopulationContext: { + name: 'PostalAddressArray', + language: 'text/fhirpath', + expression: "%patient.address.where(type='postal')", + }, + variable: [ + { + name: 'PostalLine', + language: 'text/fhirpath', + expression: '%PostalAddressArray.line', + }, + ], + item: [{ linkId: 'city', type: 'string' }], + } as any, + ], + }; + + const initialContext = buildPostalContext(patient); + const branchItems = getBranchItems(['postal'], questionnaire, postalQuestionnaireResponse); + const questionItem = questionnaire.item![0] as FCEQuestionnaireItem; + + const { result } = renderHook(() => + useVariablesResolver({ + initialContext, + branchItems, + variable: questionItem.variable, + prefix: questionItem.linkId, + fhirService: vi.fn(), + }), + ); + + expect((result.current.contexts[0].PostalLine as string[])[0]).toEqual('PO Box 5'); + }); + + test('enableWhenExpression can reference an item itemPopulationContext variable', () => { + const item = { + linkId: 'postal', + type: 'group', + itemPopulationContext: { + name: 'PostalAddressArray', + language: 'text/fhirpath', + expression: "%patient.address.where(type='postal')", + }, + enableWhenExpression: { + language: 'text/fhirpath', + expression: '%PostalAddressArray.exists()', + }, + item: [{ linkId: 'city', type: 'string' }], + } as unknown as FCEQuestionnaireItem; + + const enabled = getEnabledQuestions([item], [], {}, buildPostalContext(patient)); + expect(enabled).toHaveLength(1); + + const disabled = getEnabledQuestions( + [item], + [], + {}, + buildPostalContext({ resourceType: 'Patient', address: [{ use: 'home', line: ['1 Home St'] }] }), + ); + expect(disabled).toHaveLength(0); + }); +});