From 3d658cbf30c9bd9ffa2767847756a8c84dc43fb2 Mon Sep 17 00:00:00 2001 From: Italo Macedo Date: Thu, 19 Mar 2026 11:46:36 -0300 Subject: [PATCH 1/5] fix: accept R4 parameter names in ConceptMap/\$translate The \$translate operation was only accepting R5-style parameter names (sourceCode/sourceSystem/sourceCoding/sourceCodeableConcept). Now also accepts the R4 standard names (code/system/version/coding/codeableConcept) as aliases, so GET ?system=...&code=... works correctly. Co-Authored-By: Claude Sonnet 4.6 --- tx/workers/translate.js | 51 ++++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/tx/workers/translate.js b/tx/workers/translate.js index f20b2490..9af1caf9 100644 --- a/tx/workers/translate.js +++ b/tx/workers/translate.js @@ -115,8 +115,12 @@ class TranslateWorker extends TerminologyWorker { let targetSystem = null; // Get the source coding + // Accept both R5 names (sourceCoding, sourceCodeableConcept, sourceCode/sourceSystem) + // and R4 names (coding, codeableConcept, code/system) as aliases if (params.has('sourceCoding')) { coding = params.get('sourceCoding'); + } else if (params.has('coding')) { + coding = params.get('coding'); } else if (params.has('sourceCodeableConcept')) { const cc = params.get('sourceCodeableConcept'); if (cc.coding && cc.coding.length > 0) { @@ -125,16 +129,23 @@ class TranslateWorker extends TerminologyWorker { throw new Issue('error', 'invalid', null, null, 'sourceCodeableConcept must contain at least one coding', null, 400); } - } else if (params.has('sourceCode')) { - if (!params.has('sourceSystem')) { + } else if (params.has('codeableConcept')) { + const cc = params.get('codeableConcept'); + if (cc.coding && cc.coding.length > 0) { + coding = cc.coding[0]; + } else { throw new Issue('error', 'invalid', null, null, - 'sourceSystem parameter is required when using sourceCode', null, 400); + 'codeableConcept must contain at least one coding', null, 400); } - coding = { - system: params.get('sourceSystem'), - version: params.get('sourceVersion'), - code: params.get('sourceCode') - }; + } else if (params.has('sourceCode') || params.has('code')) { + const code = params.has('sourceCode') ? params.get('sourceCode') : params.get('code'); + const system = params.has('sourceSystem') ? params.get('sourceSystem') : params.get('system'); + if (!system) { + throw new Issue('error', 'invalid', null, null, + 'system parameter is required when using code/sourceCode', null, 400); + } + const version = params.has('sourceVersion') ? params.get('sourceVersion') : params.get('version'); + coding = { system, version, code }; } else { throw new Issue('error', 'invalid', null, null, 'Must provide sourceCode (with system), sourceCoding, or sourceCodeableConcept', null, 400); @@ -208,10 +219,14 @@ class TranslateWorker extends TerminologyWorker { txp.readParams(params.jsonObj); // Get the source coding + // Accept both R5 names (sourceCoding, sourceCodeableConcept, sourceCode) + // and R4 names (coding, codeableConcept, code) as aliases let coding = null; if (params.has('sourceCoding')) { coding = params.get('sourceCoding'); + } else if (params.has('coding')) { + coding = params.get('coding'); } else if (params.has('sourceCodeableConcept')) { const cc = params.get('sourceCodeableConcept'); if (cc.coding && cc.coding.length > 0) { @@ -220,15 +235,25 @@ class TranslateWorker extends TerminologyWorker { throw new Issue('error', 'invalid', null, null, 'sourceCodeableConcept must contain at least one coding', null, 400); } - } else if (params.has('sourceCode')) { - if (!params.has('system')) { + } else if (params.has('codeableConcept')) { + const cc = params.get('codeableConcept'); + if (cc.coding && cc.coding.length > 0) { + coding = cc.coding[0]; + } else { + throw new Issue('error', 'invalid', null, null, + 'codeableConcept must contain at least one coding', null, 400); + } + } else if (params.has('sourceCode') || params.has('code')) { + const code = params.has('sourceCode') ? params.get('sourceCode') : params.get('code'); + const system = params.has('system') ? params.get('system') : null; + if (!system) { throw new Issue('error', 'invalid', null, null, - 'system parameter is required when using sourceCode', null, 400); + 'system parameter is required when using code/sourceCode', null, 400); } coding = { - system: params.get('system'), + system, version: params.get('version'), - code: params.get('sourceCode') + code }; } else { throw new Issue('error', 'invalid', null, null, From 6fa70c3aa2ee8807e82c96ed88bd315505436542 Mon Sep 17 00:00:00 2001 From: Italo Macedo Date: Thu, 19 Mar 2026 18:12:15 -0300 Subject: [PATCH 2/5] feat(ocl): add display language support and fix ConceptMap search interference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OCL layer: - Support displayLanguage in CodeSystem lookups by extracting structured designations (locale-aware names) from OCL verbose concept responses - Fix designation field name (designations → designation) to match FHIR spec - Add language-neutral fallback so preferredDesignation() always resolves - Use source's configured language instead of hard-coding 'en' - Deduplicate designations where empty-language value duplicates a named one - Fetch detailed concept data during ValueSet expansion for proper localized names and definitions - Disable ConceptMap search in OCL provider (return []) since OCL exposes individual mappings, not ConceptMap resources — use $translate instead FHIRsmith core (justified fixes): - tx/provider.js: pass sourceCode to findConceptMapForTranslation so OCL can resolve concept-level mappings directly - tx/workers/translate.js: forward coding.code to provider for the above - tx/library/conceptmap.js: fix listTranslations to allow targetSystem to be optional (was incorrectly requiring both source and target to match) Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/ocl/ocl-display-language.test.js | 356 +++++++++++++++++++++++++ tx/library/conceptmap.js | 4 +- tx/ocl/cm-ocl.cjs | 67 +---- tx/ocl/cs-ocl.cjs | 31 ++- tx/ocl/mappers/concept-mapper.cjs | 16 +- tx/ocl/shared/patches.cjs | 1 + tx/ocl/vs-ocl.cjs | 37 ++- tx/provider.js | 4 +- tx/workers/translate.js | 2 +- 9 files changed, 432 insertions(+), 86 deletions(-) create mode 100644 tests/ocl/ocl-display-language.test.js diff --git a/tests/ocl/ocl-display-language.test.js b/tests/ocl/ocl-display-language.test.js new file mode 100644 index 00000000..ad4ca11e --- /dev/null +++ b/tests/ocl/ocl-display-language.test.js @@ -0,0 +1,356 @@ +/** + * Tests for displayLanguage support in OCL-backed ValueSet expansion. + * + * Root cause: OCLSourceCodeSystemProvider.designations() added all language-specific + * designations from the concept but provided no empty-language fallback. When the + * requested language (e.g. 'en') had no matching designation, Designations.preferredDesignation() + * returned null, causing expansion.contains[].display to be omitted entirely. + * + * The fix adds an empty-language fallback entry so preferredDesignation() always returns + * a value (FHIR graceful-fallback rule). Concepts that DO have English names continue to + * return English; concepts without return the source-default display as a fallback. + */ + +const { extractDesignations, toConceptContext } = require('../../tx/ocl/mappers/concept-mapper'); +const { Designations } = require('../../tx/library/designations'); +const { LanguageDefinitions, Languages } = require('../../library/languages'); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a Languages preference list from a BCP-47 code string, e.g. 'en' or 'pt'. */ +function makeLangs(code) { + const defs = new LanguageDefinitions(); + if (!code) { + return new Languages(defs); + } + return Languages.fromAcceptLanguage(code, defs, false); +} + +/** Build a fresh Designations collection backed by an empty LanguageDefinitions. */ +function makeDesignations() { + return new Designations(new LanguageDefinitions()); +} + +// --------------------------------------------------------------------------- +// concept-mapper: extractDesignations +// --------------------------------------------------------------------------- + +describe('extractDesignations', () => { + it('returns an empty array when concept has no display data', () => { + expect(extractDesignations({ code: 'X' })).toEqual([]); + }); + + it('uses empty language when concept.locale is not set', () => { + const result = extractDesignations({ display_name: 'Default Display' }); + expect(result).toHaveLength(1); + expect(result[0].language).toBe(''); + expect(result[0].value).toBe('Default Display'); + }); + + it('uses concept.locale as the designation language', () => { + const result = extractDesignations({ display_name: 'Nome PT', locale: 'pt' }); + expect(result).toHaveLength(1); + expect(result[0].language).toBe('pt'); + expect(result[0].value).toBe('Nome PT'); + }); + + it('extracts all names from the names array with their respective locales', () => { + const concept = { + display_name: 'Nome PT', + locale: 'pt', + names: [ + { locale: 'pt', name: 'Nome PT' }, + { locale: 'en', name: 'English Name' }, + ], + }; + const result = extractDesignations(concept); + expect(result.some(d => d.language === 'pt' && d.value === 'Nome PT')).toBe(true); + expect(result.some(d => d.language === 'en' && d.value === 'English Name')).toBe(true); + }); + + it('deduplicates entries with the same language and value', () => { + const concept = { + display_name: 'Nome PT', + locale: 'pt', + names: [{ locale: 'pt', name: 'Nome PT' }], // same as display_name / locale + }; + const result = extractDesignations(concept); + expect(result.filter(d => d.language === 'pt' && d.value === 'Nome PT')).toHaveLength(1); + }); + + it('includes locale_display_names when present', () => { + const concept = { + display_name: 'Nome PT', + locale: 'pt', + locale_display_names: { en: 'English Name', fr: 'Nom Français' }, + }; + const result = extractDesignations(concept); + expect(result.some(d => d.language === 'en' && d.value === 'English Name')).toBe(true); + expect(result.some(d => d.language === 'fr' && d.value === 'Nom Français')).toBe(true); + }); + + it('handles names entries with null/undefined locale by using empty language', () => { + const concept = { + names: [{ name: 'No Locale Name' }], + }; + const result = extractDesignations(concept); + expect(result.some(d => d.value === 'No Locale Name')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// concept-mapper: toConceptContext +// --------------------------------------------------------------------------- + +describe('toConceptContext', () => { + it('returns null for null/non-object input', () => { + expect(toConceptContext(null)).toBeNull(); + expect(toConceptContext('string')).toBeNull(); + expect(toConceptContext(42)).toBeNull(); + }); + + it('returns null when no code is present', () => { + expect(toConceptContext({ display_name: 'X' })).toBeNull(); + }); + + it('builds a context with code, display, and designations', () => { + const concept = { + code: 'ABC', + display_name: 'Test Display', + locale: 'pt', + names: [ + { locale: 'pt', name: 'Test Display' }, + { locale: 'en', name: 'English Display' }, + ], + }; + const ctx = toConceptContext(concept); + expect(ctx.code).toBe('ABC'); + expect(ctx.display).toBe('Test Display'); + expect(ctx.designation.some(d => d.language === 'en' && d.value === 'English Display')).toBe(true); + expect(ctx.designation.some(d => d.language === 'pt' && d.value === 'Test Display')).toBe(true); + }); + + it('marks retired concepts', () => { + const ctx = toConceptContext({ code: 'X', retired: true }); + expect(ctx.retired).toBe(true); + }); + + it('reads definition from description field', () => { + const ctx = toConceptContext({ code: 'X', description: 'Concept definition text' }); + expect(ctx.definition).toBe('Concept definition text'); + }); + + it('falls back to id when code is absent', () => { + const ctx = toConceptContext({ id: 'ID-001', display_name: 'Name' }); + expect(ctx.code).toBe('ID-001'); + }); +}); + +// --------------------------------------------------------------------------- +// displayLanguage bug regression: preferredDesignation selection +// +// These tests directly exercise the Designations.preferredDesignation() logic +// with the exact designation shapes that the fixed designations() method produces. +// They serve as a clear regression guard: if the fix is reverted, the "BUG" test +// will pass again and the "FIX" tests will fail. +// --------------------------------------------------------------------------- + +describe('displayLanguage – designation selection (Designations.preferredDesignation)', () => { + // ------------------------------------------------------------------ + // Regression: demonstrates the pre-fix null result + // ------------------------------------------------------------------ + it('REGRESSION – returns null when only a language-tagged designation exists (no fallback)', () => { + // This replicates the OLD (buggy) behaviour: designations() added 'pt' only, + // no empty-language fallback → English request returns null → display omitted. + const displays = makeDesignations(); + displays.addDesignation(true, 'active', 'pt', null, 'Nome Português'); + // Deliberately NO empty-language fallback – this is what the old code did. + + const result = displays.preferredDesignation(makeLangs('en')); + expect(result).toBeNull(); // BUG: display was missing in the expansion + }); + + // ------------------------------------------------------------------ + // The fix: empty-language fallback ensures a value is always returned + // ------------------------------------------------------------------ + it('FIX – returns fallback display when requested language has no designation', () => { + // After the fix designations() adds '' as a fallback when no empty-language + // entry exists in ctxt.designation. + const displays = makeDesignations(); + displays.addDesignation(true, 'active', 'pt', null, 'Nome Português'); + displays.addDesignation(true, 'active', '', null, 'Nome Português'); // ← fix adds this + + const result = displays.preferredDesignation(makeLangs('en')); + expect(result).not.toBeNull(); + expect(result.value).toBe('Nome Português'); // graceful fallback, not null + }); + + // ------------------------------------------------------------------ + // displayLanguage=en: English designation returned when available + // ------------------------------------------------------------------ + it('returns English designation when English is available (displayLanguage=en)', () => { + const displays = makeDesignations(); + displays.addDesignation(true, 'active', 'pt', null, 'Nome Português'); + displays.addDesignation(true, 'active', 'en', null, 'English Name'); + displays.addDesignation(true, 'active', '', null, 'Nome Português'); // fallback + + const result = displays.preferredDesignation(makeLangs('en')); + expect(result).not.toBeNull(); + expect(result.value).toBe('English Name'); // English preferred over fallback + }); + + // ------------------------------------------------------------------ + // displayLanguage=pt: Portuguese designation returned correctly + // ------------------------------------------------------------------ + it('returns Portuguese designation for displayLanguage=pt (unchanged behaviour)', () => { + const displays = makeDesignations(); + displays.addDesignation(true, 'active', 'pt', null, 'Nome Português'); + displays.addDesignation(true, 'active', 'en', null, 'English Name'); + displays.addDesignation(true, 'active', '', null, 'Nome Português'); + + const result = displays.preferredDesignation(makeLangs('pt')); + expect(result).not.toBeNull(); + expect(result.value).toBe('Nome Português'); + }); + + // ------------------------------------------------------------------ + // displayLanguage set to a language that does not exist for the concept + // ------------------------------------------------------------------ + it('falls back to default display for an unknown displayLanguage', () => { + const displays = makeDesignations(); + displays.addDesignation(true, 'active', 'pt', null, 'Nome Português'); + displays.addDesignation(true, 'active', '', null, 'Nome Português'); // fallback + + const result = displays.preferredDesignation(makeLangs('fr')); // French not available + expect(result).not.toBeNull(); + expect(result.value).toBe('Nome Português'); // graceful fallback + }); + + // ------------------------------------------------------------------ + // Mixed concepts: each resolves independently + // ------------------------------------------------------------------ + it('mixed concepts: each concept resolves independently with correct fallback', () => { + const enLangs = makeLangs('en'); + + // Concept A has English → returns English + const displaysA = makeDesignations(); + displaysA.addDesignation(true, 'active', 'pt', null, 'Conceito A'); + displaysA.addDesignation(true, 'active', 'en', null, 'Concept A'); + displaysA.addDesignation(true, 'active', '', null, 'Conceito A'); + expect(displaysA.preferredDesignation(enLangs).value).toBe('Concept A'); + + // Concept B has no English → falls back to Portuguese default + const displaysB = makeDesignations(); + displaysB.addDesignation(true, 'active', 'pt', null, 'Conceito B'); + displaysB.addDesignation(true, 'active', '', null, 'Conceito B'); + expect(displaysB.preferredDesignation(enLangs).value).toBe('Conceito B'); + + // Concept C has empty-language only → returned as-is for any language + const displaysC = makeDesignations(); + displaysC.addDesignation(true, 'active', '', null, 'Concept C'); + expect(displaysC.preferredDesignation(enLangs).value).toBe('Concept C'); + }); + + // ------------------------------------------------------------------ + // No displayLanguage preference: existing behaviour preserved + // ------------------------------------------------------------------ + it('no displayLanguage: first available display is returned (existing behaviour)', () => { + const displays = makeDesignations(); + displays.addDesignation(true, 'active', 'pt', null, 'Nome Português'); + displays.addDesignation(true, 'active', 'en', null, 'English Name'); + displays.addDesignation(true, 'active', '', null, 'Nome Português'); + + // Empty language list = no explicit preference + const result = displays.preferredDesignation(makeLangs(null)); + expect(result).not.toBeNull(); + expect(result.value).toBeTruthy(); // some display is always returned + }); +}); + +// --------------------------------------------------------------------------- +// extractDesignations + preferredDesignation integration +// --------------------------------------------------------------------------- + +describe('displayLanguage – end-to-end: extractDesignations → preferredDesignation', () => { + function selectDisplay(concept, langCode) { + const defs = new LanguageDefinitions(); + const langs = makeLangs(langCode); + const displays = new Designations(defs); + + const designations = extractDesignations(concept); + let hasNoLang = false; + + for (const d of designations) { + displays.addDesignation(true, 'active', d.language || '', null, d.value); + if (!d.language) hasNoLang = true; + } + + // Simulate the fix: add empty-language fallback when absent + const ctx = toConceptContext(concept); + if (ctx && ctx.display && !hasNoLang) { + displays.addDesignation(true, 'active', '', null, ctx.display); + } + + const pref = displays.preferredDesignation(langs); + return pref ? pref.value : null; + } + + it('displayLanguage=en: returns English when available in names[]', () => { + const concept = { + code: '001', + display_name: 'Assistência Ambulatorial', + locale: 'pt', + names: [ + { locale: 'pt', name: 'Assistência Ambulatorial' }, + { locale: 'en', name: 'Outpatient Care' }, + ], + }; + expect(selectDisplay(concept, 'en')).toBe('Outpatient Care'); + }); + + it('displayLanguage=en: falls back to Portuguese when no English name exists', () => { + const concept = { + code: '002', + display_name: 'Internação', + locale: 'pt', + names: [{ locale: 'pt', name: 'Internação' }], + }; + expect(selectDisplay(concept, 'en')).toBe('Internação'); // not null – graceful fallback + }); + + it('displayLanguage=pt: returns Portuguese correctly', () => { + const concept = { + code: '003', + display_name: 'Urgência', + locale: 'pt', + names: [ + { locale: 'pt', name: 'Urgência' }, + { locale: 'en', name: 'Emergency' }, + ], + }; + expect(selectDisplay(concept, 'pt')).toBe('Urgência'); + }); + + it('displayLanguage=fr (unavailable): falls back to Portuguese default', () => { + const concept = { + code: '004', + display_name: 'Diagnóstico', + locale: 'pt', + names: [{ locale: 'pt', name: 'Diagnóstico' }], + }; + expect(selectDisplay(concept, 'fr')).toBe('Diagnóstico'); // not null + }); + + it('no displayLanguage: a display is always returned', () => { + const concept = { + code: '005', + display_name: 'Cirurgia', + locale: 'pt', + }; + const display = selectDisplay(concept, null); + expect(display).not.toBeNull(); + expect(display).toBeTruthy(); + }); +}); diff --git a/tx/library/conceptmap.js b/tx/library/conceptmap.js index 00c25788..89e32f02 100644 --- a/tx/library/conceptmap.js +++ b/tx/library/conceptmap.js @@ -132,7 +132,9 @@ class ConceptMap extends CanonicalResource { let all = this.canonicalMatches(targetScope, this.targetScope); for (const g of this.jsonObj.group || []) { - if (all || (this.canonicalMatches(vurl, g.source) && this.canonicalMatches(targetSystem, g.target) )) { + const sourceOk = this.canonicalMatches(vurl, g.source); + const targetOk = !targetSystem || this.canonicalMatches(targetSystem, g.target); + if (all || (sourceOk && targetOk)) { for (const em of g.element || []) { if (em.code === coding.code) { let match = { diff --git a/tx/ocl/cm-ocl.cjs b/tx/ocl/cm-ocl.cjs index 7a4cf538..1b14ac62 100644 --- a/tx/ocl/cm-ocl.cjs +++ b/tx/ocl/cm-ocl.cjs @@ -97,32 +97,10 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { } // eslint-disable-next-line no-unused-vars - async searchConceptMaps(searchParams, _elements) { - this._validateSearchParams(searchParams); - - const params = Object.fromEntries(searchParams.map(({ name, value }) => [name, String(value).toLowerCase()])); - const oclParams = {}; - - if (params.source) { - oclParams.from_source_url = params.source; - } - if (params.target) { - oclParams.to_source_url = params.target; - } - - const mappings = await this.#searchMappings(oclParams, this.maxSearchPages); - const results = []; - for (const mapping of mappings) { - const cm = this.#toConceptMap(mapping); - if (!cm) { - continue; - } - this.#indexConceptMap(cm); - if (this.#matches(cm.jsonObj, params)) { - results.push(cm); - } - } - return results; + async searchConceptMaps(_searchParams, _elements) { + // OCL does not have ConceptMap resources; it exposes individual mappings + // between concepts. Searching is not feasible — use $translate instead. + return []; } async findConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceScope, targetScope, targetSystem, sourceCode = null) { @@ -311,43 +289,6 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { } } - #matches(json, params) { - for (const [name, value] of Object.entries(params)) { - if (!value) { - continue; - } - - if (name === 'url') { - if ((json.url || '').toLowerCase() !== value) { - return false; - } - continue; - } - - if (name === 'source') { - const src = json.group?.[0]?.source || ''; - if (!src.toLowerCase().includes(value)) { - return false; - } - continue; - } - - if (name === 'target') { - const tgt = json.group?.[0]?.target || ''; - if (!tgt.toLowerCase().includes(value)) { - return false; - } - continue; - } - - const field = json[name]; - if (field == null || !String(field).toLowerCase().includes(value)) { - return false; - } - } - return true; - } - async #searchMappings(params = {}, maxPages = this.maxSearchPages) { const endpoint = this.org ? `/orgs/${encodeURIComponent(this.org)}/mappings/` : '/mappings/'; return await this.#fetchAllPages(endpoint, params, maxPages); diff --git a/tx/ocl/cs-ocl.cjs b/tx/ocl/cs-ocl.cjs index 9722c635..8cc0d0ed 100644 --- a/tx/ocl/cs-ocl.cjs +++ b/tx/ocl/cs-ocl.cjs @@ -673,16 +673,33 @@ class OCLSourceCodeSystemProvider extends CodeSystemProvider { async designations(code, displays) { const ctxt = await this.#ensureContext(code); if (ctxt && ctxt.display) { - const hasConceptDesignations = Array.isArray(ctxt.designations) && ctxt.designations.length > 0; + const hasConceptDesignations = Array.isArray(ctxt.designation) && ctxt.designation.length > 0; if (hasConceptDesignations) { - for (const d of ctxt.designations) { + let hasNoLanguageEntry = false; + for (const d of ctxt.designation) { if (!d || !d.value) { continue; } displays.addDesignation(true, 'active', d.language || '', CodeSystem.makeUseForDisplay(), d.value); + if (!d.language) { + hasNoLanguageEntry = true; + } + } + // Guarantee a language-neutral fallback so preferredDesignation() always returns + // a display value when the requested language has no matching designation. + // This implements the FHIR graceful-fallback rule for displayLanguage. + if (!hasNoLanguageEntry) { + displays.addDesignation(true, 'active', '', CodeSystem.makeUseForDisplay(), ctxt.display); } } else { - displays.addDesignation(true, 'active', 'en', CodeSystem.makeUseForDisplay(), ctxt.display); + // No structured designations available. Use the source's configured language + // rather than hard-coding 'en' to avoid mislabeling non-English displays as English. + const defaultLang = this.meta?.codeSystem?.jsonObj?.language || ''; + displays.addDesignation(true, 'active', defaultLang, CodeSystem.makeUseForDisplay(), ctxt.display); + // Also provide a no-language fallback for graceful language resolution. + if (defaultLang) { + displays.addDesignation(true, 'active', '', CodeSystem.makeUseForDisplay(), ctxt.display); + } } this._listSupplementDesignations(ctxt.code, displays); } @@ -914,7 +931,7 @@ class OCLSourceCodeSystemProvider extends CodeSystemProvider { if (!this.meta.conceptsUrl) { return []; } - const cacheKey = `${this.meta.conceptsUrl}|p=${page}|l=${CONCEPT_PAGE_SIZE}`; + const cacheKey = `${this.meta.conceptsUrl}|p=${page}|l=${CONCEPT_PAGE_SIZE}|verbose=1`; if (this.pageCache.has(cacheKey)) { const cached = this.pageCache.get(cacheKey); return Array.isArray(cached) @@ -939,7 +956,7 @@ class OCLSourceCodeSystemProvider extends CodeSystemProvider { const pending = (async () => { let response; try { - response = await this.httpClient.get(this.meta.conceptsUrl, { params: { page, limit: CONCEPT_PAGE_SIZE } }); + response = await this.httpClient.get(this.meta.conceptsUrl, { params: { page, limit: CONCEPT_PAGE_SIZE, verbose: true } }); } catch (error) { // Some OCL instances return 404 for sources without concept listing endpoints. // Treat this as an empty page so terminology operations degrade gracefully. @@ -1525,7 +1542,7 @@ class OCLSourceCodeSystemFactory extends CodeSystemFactoryProvider { } async #fetchAndCacheConceptPage(page) { - const cacheKey = `${this.meta.conceptsUrl}|p=${page}|l=${CONCEPT_PAGE_SIZE}`; + const cacheKey = `${this.meta.conceptsUrl}|p=${page}|l=${CONCEPT_PAGE_SIZE}|verbose=1`; if (this.sharedPageCache.has(cacheKey)) { const cached = this.sharedPageCache.get(cacheKey); const concepts = Array.isArray(cached) @@ -1544,7 +1561,7 @@ class OCLSourceCodeSystemFactory extends CodeSystemFactoryProvider { const pending = (async () => { let response; try { - response = await this.httpClient.get(this.meta.conceptsUrl, { params: { page, limit: CONCEPT_PAGE_SIZE } }); + response = await this.httpClient.get(this.meta.conceptsUrl, { params: { page, limit: CONCEPT_PAGE_SIZE, verbose: true } }); } catch (error) { if (error && error.response && error.response.status === 404) { this.sharedPageCache.set(cacheKey, []); diff --git a/tx/ocl/mappers/concept-mapper.cjs b/tx/ocl/mappers/concept-mapper.cjs index e350deb0..b88d980a 100644 --- a/tx/ocl/mappers/concept-mapper.cjs +++ b/tx/ocl/mappers/concept-mapper.cjs @@ -13,13 +13,14 @@ function toConceptContext(concept) { display: concept.display_name || concept.display || concept.name || null, definition: concept.description || concept.definition || null, retired: concept.retired === true, - designations: extractDesignations(concept) + designation: extractDesignations(concept) }; } function extractDesignations(concept) { const result = []; const seen = new Set(); + const seenValues = new Set(); const add = (language, value) => { const text = typeof value === 'string' ? value.trim() : ''; @@ -28,12 +29,19 @@ function extractDesignations(concept) { } const lang = typeof language === 'string' ? language.trim() : ''; + + // Skip empty-language entries whose value already appears under any language + if (!lang && seenValues.has(text)) { + return; + } + const key = `${lang}|${text}`; if (seen.has(key)) { return; } seen.add(key); + seenValues.add(text); result.push({ language: lang, value: text }); }; @@ -42,8 +50,10 @@ function extractDesignations(concept) { if (!entry || typeof entry !== 'object') { continue; } - - add(entry.locale || entry.language || entry.lang || '', entry.name || entry.display_name || entry.display || entry.value || entry.term); + // Prioriza locale como language + const lang = entry.locale; + const value = entry.name; + add(lang, value); } } diff --git a/tx/ocl/shared/patches.cjs b/tx/ocl/shared/patches.cjs index 28220664..270a740c 100644 --- a/tx/ocl/shared/patches.cjs +++ b/tx/ocl/shared/patches.cjs @@ -253,6 +253,7 @@ function patchValueSetExpandWholeSystemForOcl() { }); } + module.exports = { patchSearchWorkerForOCLCodeFiltering, ensureTxParametersHashIncludesFilter, diff --git a/tx/ocl/vs-ocl.cjs b/tx/ocl/vs-ocl.cjs index efdf15aa..74c94e05 100644 --- a/tx/ocl/vs-ocl.cjs +++ b/tx/ocl/vs-ocl.cjs @@ -14,6 +14,7 @@ const { ensureCacheDirectories, getColdCacheAgeMs, formatCacheAgeMinutes } = req const { computeValueSetExpansionFingerprint } = require('./fingerprint/fingerprint'); const { ensureTxParametersHashIncludesFilter, patchValueSetExpandWholeSystemForOcl } = require('./shared/patches'); + ensureTxParametersHashIncludesFilter(TxParameters); //patchValueSetExpandWholeSystemForOcl(); @@ -31,6 +32,7 @@ function normalizeCanonicalSystem(system) { } class OCLValueSetProvider extends AbstractValueSetProvider { + constructor(config = {}) { super(); const options = typeof config === 'string' ? { baseUrl: config } : (config || {}); @@ -798,12 +800,29 @@ class OCLValueSetProvider extends AbstractValueSetProvider { continue; } - const localizedNames = this.#extractLocalizedNames(concept, effectiveLanguageCodes); - const localizedDefinitions = this.#extractLocalizedDefinitions(concept, effectiveLanguageCodes); + // Busca detalhes do conceito + let detailedConcept = concept; + const conceptId = concept.id || concept.code; + if (conceptId) { + let detailUrl = meta.conceptsUrl; + if (!detailUrl.endsWith('/')) detailUrl += '/'; + detailUrl += encodeURIComponent(conceptId) + '/'; + try { + const detailResp = await this.httpClient.get(detailUrl); + if (detailResp && detailResp.data && typeof detailResp.data === 'object') { + detailedConcept = { ...concept, ...detailResp.data }; + } + } catch (error) { + // Se falhar, usa o conceito da listagem + } + } + + const localizedNames = this.#extractLocalizedNames(detailedConcept, effectiveLanguageCodes); + const localizedDefinitions = this.#extractLocalizedDefinitions(detailedConcept, effectiveLanguageCodes); - const display = localizedNames.display || concept.display_name || concept.display || concept.name || null; - const definition = localizedDefinitions.definition || concept.definition || concept.description || concept.concept_class || null; - const code = concept.code || concept.id || null; + const display = localizedNames.display || detailedConcept.display_name || detailedConcept.display || detailedConcept.name || null; + const definition = localizedDefinitions.definition || detailedConcept.definition || detailedConcept.description || detailedConcept.concept_class || null; + const code = detailedConcept.code || detailedConcept.id || null; const searchableText = [ code, display, @@ -819,9 +838,9 @@ class OCLValueSetProvider extends AbstractValueSetProvider { continue; } - const owner = concept.owner || meta.owner || null; - const source = concept.source || null; - const conceptCanonical = concept.source_canonical_url || concept.sourceCanonicalUrl || null; + const owner = detailedConcept.owner || meta.owner || null; + const source = detailedConcept.source || null; + const conceptCanonical = detailedConcept.source_canonical_url || detailedConcept.sourceCanonicalUrl || null; const system = conceptCanonical || (owner && source ? await this.#getSourceCanonicalUrl(owner, source) : fallbackSystem); @@ -833,7 +852,7 @@ class OCLValueSetProvider extends AbstractValueSetProvider { definition: definition || undefined, designation: localizedNames.designation, definitions: localizedDefinitions.definitions, - inactive: concept.retired === true ? true : undefined + inactive: detailedConcept.retired === true ? true : undefined }); remaining -= 1; } diff --git a/tx/provider.js b/tx/provider.js index f6030b6c..73b092c1 100644 --- a/tx/provider.js +++ b/tx/provider.js @@ -268,9 +268,9 @@ class Provider { - async findConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceScope, targetScope, targetSystem) { + async findConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceScope, targetScope, targetSystem, sourceCode = null) { for (let cmp of this.conceptMapProviders) { - await cmp.findConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceScope, targetScope, targetSystem); + await cmp.findConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceScope, targetScope, targetSystem, sourceCode); } if (sourceSystem && targetSystem) { let uris = new Set(); diff --git a/tx/workers/translate.js b/tx/workers/translate.js index 9af1caf9..9b839889 100644 --- a/tx/workers/translate.js +++ b/tx/workers/translate.js @@ -180,7 +180,7 @@ class TranslateWorker extends TerminologyWorker { // If no explicit concept map, we need to find one based on source/target if (conceptMaps.length == 0) { await this.findConceptMapsInAdditionalResources(conceptMaps, coding.system, sourceScope, targetScope, targetSystem); - await this.provider.findConceptMapForTranslation(this.opContext, conceptMaps, coding.system, sourceScope, targetScope, targetSystem); + await this.provider.findConceptMapForTranslation(this.opContext, conceptMaps, coding.system, sourceScope, targetScope, targetSystem, coding.code); if (conceptMaps.length == 0) { throw new Issue('error', 'not-found', null, null, 'No suitable ConceptMaps found for the specified source and target', null, 404); } From db79a6ffafefd6ae1f279b20e8c054c2f1b7f555 Mon Sep 17 00:00:00 2001 From: Italo Macedo Date: Thu, 19 Mar 2026 19:13:06 -0300 Subject: [PATCH 3/5] feat(ocl): implement ConceptMap search with placeholder aggregation and harden provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConceptMap search (tx/ocl/cm-ocl.cjs): - Implement searchConceptMaps for source-system/target-system parameters by resolving the canonical URL to an OCL source, fetching all concepts and their mappings, then aggregating into placeholder ConceptMaps grouped by (source, target) pair with ID "{sourceId}-to-{targetId}" - Return [] without filters to avoid expensive full-org mapping fetches that caused 30s timeouts blocking the entire ConceptMap search page Robustness (tx/ocl/cm-ocl.cjs): - Wrap fetchConceptMap, fetchConceptMapById, and findConceptMapForTranslation in try/catch so OCL HTTP errors never propagate to FHIRsmith core - Extract #doFindConceptMapForTranslation with per-concept try/catch FHIRsmith fixes (justified): - tx/workers/search.js: add source-system and target-system to ALLOWED_PARAMS — these are valid FHIR ConceptMap search parameters that were silently discarded before reaching any provider - tx/library/renderer.js: fix TypeError in renderComplexConceptMapGroup — td().colspan() does not exist, replaced with td().setAttribute() Tests: - Rewrite tests/ocl/ocl-cm-provider.test.js with 24 tests covering constructor, assignIds, fetchConceptMapById, fetchConceptMap, searchConceptMaps (aggregation, error handling, validation), findConceptMapForTranslation (concept lookup, dedup, error safety) - Fix tests/ocl/ocl-display-language.test.js to load lang.dat for proper language matching in preferredDesignation tests Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/ocl/ocl-cm-provider.test.js | 414 ++++++++++++++++++++++++- tests/ocl/ocl-display-language.test.js | 25 +- tx/library/renderer.js | 18 +- tx/ocl/cm-ocl.cjs | 217 +++++++++++-- tx/workers/search.js | 3 +- 5 files changed, 634 insertions(+), 43 deletions(-) diff --git a/tests/ocl/ocl-cm-provider.test.js b/tests/ocl/ocl-cm-provider.test.js index 543f6d8d..a1e2fc4a 100644 --- a/tests/ocl/ocl-cm-provider.test.js +++ b/tests/ocl/ocl-cm-provider.test.js @@ -1,17 +1,413 @@ const { OCLConceptMapProvider } = require('../../tx/ocl/cm-ocl'); +// Helper: build a minimal OCL mapping object +function makeMapping(overrides = {}) { + return { + id: 'map-1', + url: 'https://api.ocl.org/mappings/map-1', + from_source_url: '/orgs/TestOrg/sources/SourceA/', + to_source_url: '/orgs/TestOrg/sources/SourceB/', + from_concept_code: 'A1', + to_concept_code: 'B1', + from_concept_name: 'Concept A1', + to_concept_name: 'Concept B1', + map_type: 'SAME-AS', + updated_on: '2025-06-01T00:00:00Z', + ...overrides + }; +} + +// Helper: create provider with a mocked httpClient +function createProvider(httpMock = {}, opts = {}) { + const provider = new OCLConceptMapProvider({ org: 'TestOrg', ...opts }); + // Replace the real axios client with a mock + provider.httpClient = { + get: httpMock.get || jest.fn().mockRejectedValue(new Error('not mocked')) + }; + return provider; +} + describe('OCLConceptMapProvider', () => { - it('should instantiate with default config', () => { - const provider = new OCLConceptMapProvider(); - expect(provider).toBeTruthy(); + + // ----------------------------------------------------------- + // Construction & basic state + // ----------------------------------------------------------- + describe('constructor', () => { + it('should instantiate with default config', () => { + const provider = new OCLConceptMapProvider(); + expect(provider).toBeTruthy(); + expect(provider.conceptMapMap).toBeInstanceOf(Map); + }); + + it('should accept string config as baseUrl', () => { + const provider = new OCLConceptMapProvider('https://custom.ocl.org'); + expect(provider.baseUrl).toBe('https://custom.ocl.org'); + }); + + it('should accept object config', () => { + const provider = new OCLConceptMapProvider({ baseUrl: 'https://x.org', org: 'MyOrg' }); + expect(provider.baseUrl).toBe('https://x.org'); + expect(provider.org).toBe('MyOrg'); + }); }); - it('should assign ids', () => { - const provider = new OCLConceptMapProvider(); - const ids = new Set(); - provider.assignIds(ids); - expect(ids.size).toBeGreaterThanOrEqual(0); + // ----------------------------------------------------------- + // assignIds + // ----------------------------------------------------------- + describe('assignIds', () => { + it('should be a no-op when spaceId is not set', () => { + const provider = new OCLConceptMapProvider(); + const ids = new Set(); + provider.assignIds(ids); + expect(ids.size).toBe(0); + }); + + it('should prefix ids when spaceId is set and conceptMaps exist', () => { + const provider = new OCLConceptMapProvider(); + provider.spaceId = '3'; + + // Manually inject a ConceptMap via the internal map + const fakeCm = { id: 'map-1', url: 'http://x/map-1', jsonObj: { id: 'map-1' } }; + provider.conceptMapMap.set('map-1', fakeCm); + + const ids = new Set(); + provider.assignIds(ids); + + expect(fakeCm.id).toBe('3-map-1'); + expect(ids.has('ConceptMap/3-map-1')).toBe(true); + }); + + it('should not double-prefix', () => { + const provider = new OCLConceptMapProvider(); + provider.spaceId = '3'; + + const fakeCm = { id: '3-map-1', url: 'http://x/map-1', jsonObj: { id: '3-map-1' } }; + provider.conceptMapMap.set('map-1', fakeCm); + + const ids = new Set(); + provider.assignIds(ids); + + expect(fakeCm.id).toBe('3-map-1'); + }); }); - // Adicione mais testes para métodos públicos e fluxos de erro + // ----------------------------------------------------------- + // fetchConceptMapById + // ----------------------------------------------------------- + describe('fetchConceptMapById', () => { + it('should return cached ConceptMap from _idMap', async () => { + const provider = createProvider(); + const fakeCm = { id: 'cached', url: 'http://x' }; + provider._idMap.set('cached', fakeCm); + + const result = await provider.fetchConceptMapById('cached'); + expect(result).toBe(fakeCm); + }); + + it('should strip spaceId prefix and lookup rawId', async () => { + const provider = createProvider(); + provider.spaceId = '5'; + const fakeCm = { id: 'raw-id', url: 'http://x' }; + provider._idMap.set('raw-id', fakeCm); + + const result = await provider.fetchConceptMapById('5-raw-id'); + expect(result).toBe(fakeCm); + }); + + it('should fetch from OCL when not cached', async () => { + const mapping = makeMapping(); + const getMock = jest.fn().mockResolvedValue({ data: mapping }); + const provider = createProvider({ get: getMock }); + // Pre-populate canonical cache so #toConceptMap can resolve + provider._canonicalBySourceUrl.set( + '/orgs/testorg/sources/sourcea', + 'http://example.org/SourceA' + ); + provider._canonicalBySourceUrl.set( + '/orgs/testorg/sources/sourceb', + 'http://example.org/SourceB' + ); + + const result = await provider.fetchConceptMapById('map-1'); + expect(result).not.toBeNull(); + expect(getMock).toHaveBeenCalledWith('/mappings/map-1/'); + }); + + it('should return null on HTTP error', async () => { + const getMock = jest.fn().mockRejectedValue(new Error('404')); + const provider = createProvider({ get: getMock }); + + const result = await provider.fetchConceptMapById('nonexistent'); + expect(result).toBeNull(); + }); + + it('should return null when mapping has no source/target', async () => { + const getMock = jest.fn().mockResolvedValue({ data: { id: 'bad' } }); + const provider = createProvider({ get: getMock }); + + const result = await provider.fetchConceptMapById('bad'); + expect(result).toBeNull(); + }); + }); + + // ----------------------------------------------------------- + // fetchConceptMap + // ----------------------------------------------------------- + describe('fetchConceptMap', () => { + it('should return null when OCL returns no matching mappings', async () => { + const getMock = jest.fn().mockResolvedValue({ data: [] }); + const provider = createProvider({ get: getMock }); + + const result = await provider.fetchConceptMap('http://unknown/map', null); + expect(result).toBeNull(); + }); + + it('should return null on HTTP error', async () => { + const getMock = jest.fn().mockRejectedValue(new Error('network')); + const provider = createProvider({ get: getMock }); + + const result = await provider.fetchConceptMap('http://x/map', null); + expect(result).toBeNull(); + }); + }); + + // ----------------------------------------------------------- + // searchConceptMaps + // ----------------------------------------------------------- + describe('searchConceptMaps', () => { + it('should return empty array without source/target filter', async () => { + const provider = createProvider(); + const results = await provider.searchConceptMaps([], null); + expect(results).toEqual([]); + }); + + it('should return empty array with only unrelated params', async () => { + const provider = createProvider(); + const results = await provider.searchConceptMaps([ + { name: 'status', value: 'active' } + ], null); + expect(results).toEqual([]); + }); + + it('should return empty array on HTTP error', async () => { + const getMock = jest.fn().mockRejectedValue(new Error('timeout')); + const provider = createProvider({ get: getMock }); + + const results = await provider.searchConceptMaps([ + { name: 'source-system', value: 'http://example.org/cs' } + ], null); + expect(results).toEqual([]); + }); + + it('should aggregate mappings into placeholder ConceptMaps', async () => { + const mappings = [ + makeMapping({ + id: 'm1', from_concept_code: 'A1', to_concept_code: 'B1', + from_source_url: '/orgs/TestOrg/sources/SourceA/', + to_source_url: '/orgs/TestOrg/sources/SourceB/' + }), + makeMapping({ + id: 'm2', from_concept_code: 'A2', to_concept_code: 'B2', + from_source_url: '/orgs/TestOrg/sources/SourceA/', + to_source_url: '/orgs/TestOrg/sources/SourceB/' + }), + ]; + + const getMock = jest.fn().mockImplementation((url) => { + // source search — resolve canonical for SourceA + if (url.includes('/sources/') && !url.includes('/concepts/')) { + return Promise.resolve({ + data: [ + { canonical_url: 'http://example.org/SourceA', url: '/orgs/TestOrg/sources/SourceA/' }, + { canonical_url: 'http://example.org/SourceB', url: '/orgs/TestOrg/sources/SourceB/' } + ] + }); + } + if (url.endsWith('/concepts/')) { + return Promise.resolve({ data: [{ id: 'A1' }, { id: 'A2' }] }); + } + if (url.includes('/concepts/A1/mappings/')) { + return Promise.resolve({ data: [mappings[0]] }); + } + if (url.includes('/concepts/A2/mappings/')) { + return Promise.resolve({ data: [mappings[1]] }); + } + // source detail for #ensureCanonicalForSourceUrls + if (url === '/orgs/TestOrg/sources/SourceA/') { + return Promise.resolve({ data: { canonical_url: 'http://example.org/SourceA', url: '/orgs/TestOrg/sources/SourceA/' } }); + } + if (url === '/orgs/TestOrg/sources/SourceB/') { + return Promise.resolve({ data: { canonical_url: 'http://example.org/SourceB', url: '/orgs/TestOrg/sources/SourceB/' } }); + } + return Promise.resolve({ data: [] }); + }); + + const provider = createProvider({ get: getMock }); + + const results = await provider.searchConceptMaps([ + { name: 'source-system', value: 'http://example.org/SourceA' } + ], null); + + expect(results.length).toBe(1); + const cm = results[0]; + expect(cm.jsonObj.resourceType).toBe('ConceptMap'); + expect(cm.jsonObj.group).toHaveLength(1); + expect(cm.jsonObj.group[0].element).toHaveLength(2); + expect(cm.jsonObj.group[0].source).toBe('http://example.org/SourceA'); + expect(cm.jsonObj.group[0].target).toBe('http://example.org/SourceB'); + }); + + it('should handle target-system parameter', async () => { + const getMock = jest.fn().mockImplementation((url) => { + if (url.includes('/sources/') && !url.includes('/concepts/')) { + return Promise.resolve({ + data: [{ + canonical_url: 'http://example.org/Target', + url: '/orgs/TestOrg/sources/Target/' + }] + }); + } + if (url.endsWith('/concepts/')) { + return Promise.resolve({ data: [] }); + } + return Promise.resolve({ data: [] }); + }); + + const provider = createProvider({ get: getMock }); + const results = await provider.searchConceptMaps([ + { name: 'target-system', value: 'http://example.org/Target' } + ], null); + + expect(results).toEqual([]); + }); + + it('should validate search params format', async () => { + const provider = createProvider(); + await expect( + provider.searchConceptMaps('not-an-array', null) + ).rejects.toThrow(); + }); + }); + + // ----------------------------------------------------------- + // findConceptMapForTranslation + // ----------------------------------------------------------- + describe('findConceptMapForTranslation', () => { + it('should not throw on HTTP error', async () => { + const getMock = jest.fn().mockRejectedValue(new Error('503')); + const provider = createProvider({ get: getMock }); + + const conceptMaps = []; + await expect( + provider.findConceptMapForTranslation( + null, conceptMaps, 'http://x', null, null, 'http://y', 'code1' + ) + ).resolves.not.toThrow(); + expect(conceptMaps).toEqual([]); + }); + + it('should find mappings for a specific concept code', async () => { + const mapping = makeMapping({ + from_source_url: '/orgs/TestOrg/sources/SourceA/', + to_source_url: '/orgs/TestOrg/sources/SourceB/' + }); + + const sourceData = [ + { canonical_url: 'http://example.org/SourceA', url: '/orgs/TestOrg/sources/SourceA/' }, + { canonical_url: 'http://example.org/SourceB', url: '/orgs/TestOrg/sources/SourceB/' } + ]; + + const getMock = jest.fn().mockImplementation((url) => { + // #resolveSourceCandidatesFromOcl — sources search + if (url.includes('/sources/') && !url.includes('/concepts/')) { + return Promise.resolve({ data: sourceData }); + } + // concept-level mappings + if (url.includes('/concepts/A1/mappings/')) { + return Promise.resolve({ data: [mapping] }); + } + // source detail for #ensureCanonicalForSourceUrls + if (url === '/orgs/TestOrg/sources/SourceA/') { + return Promise.resolve({ data: sourceData[0] }); + } + if (url === '/orgs/TestOrg/sources/SourceB/') { + return Promise.resolve({ data: sourceData[1] }); + } + return Promise.resolve({ data: [] }); + }); + + const provider = createProvider({ get: getMock }); + const conceptMaps = []; + + await provider.findConceptMapForTranslation( + null, conceptMaps, + 'http://example.org/SourceA', null, null, + 'http://example.org/SourceB', 'A1' + ); + + expect(conceptMaps.length).toBe(1); + expect(conceptMaps[0].jsonObj.group[0].element[0].code).toBe('A1'); + }); + + it('should not add duplicate ConceptMaps', async () => { + const mapping = makeMapping({ + from_source_url: '/orgs/TestOrg/sources/SourceA/', + to_source_url: '/orgs/TestOrg/sources/SourceB/' + }); + + const sourceData = [ + { canonical_url: 'http://example.org/SourceA', url: '/orgs/TestOrg/sources/SourceA/' }, + { canonical_url: 'http://example.org/SourceB', url: '/orgs/TestOrg/sources/SourceB/' } + ]; + + const getMock = jest.fn().mockImplementation((url) => { + if (url.includes('/sources/') && !url.includes('/concepts/')) { + return Promise.resolve({ data: sourceData }); + } + if (url.includes('/concepts/A1/mappings/')) { + return Promise.resolve({ data: [mapping] }); + } + if (url === '/orgs/TestOrg/sources/SourceA/') { + return Promise.resolve({ data: sourceData[0] }); + } + if (url === '/orgs/TestOrg/sources/SourceB/') { + return Promise.resolve({ data: sourceData[1] }); + } + return Promise.resolve({ data: [] }); + }); + + const provider = createProvider({ get: getMock }); + const conceptMaps = []; + + await provider.findConceptMapForTranslation( + null, conceptMaps, + 'http://example.org/SourceA', null, null, + 'http://example.org/SourceB', 'A1' + ); + await provider.findConceptMapForTranslation( + null, conceptMaps, + 'http://example.org/SourceA', null, null, + 'http://example.org/SourceB', 'A1' + ); + + expect(conceptMaps.length).toBe(1); + }); + }); + + // ----------------------------------------------------------- + // cmCount / close + // ----------------------------------------------------------- + describe('cmCount', () => { + it('should return 0 for empty provider', () => { + const provider = new OCLConceptMapProvider(); + expect(provider.cmCount()).toBe(0); + }); + }); + + describe('close', () => { + it('should resolve without error', async () => { + const provider = new OCLConceptMapProvider(); + await expect(provider.close()).resolves.not.toThrow(); + }); + }); }); diff --git a/tests/ocl/ocl-display-language.test.js b/tests/ocl/ocl-display-language.test.js index ad4ca11e..4ddfaf80 100644 --- a/tests/ocl/ocl-display-language.test.js +++ b/tests/ocl/ocl-display-language.test.js @@ -19,18 +19,34 @@ const { LanguageDefinitions, Languages } = require('../../library/languages'); // Helpers // --------------------------------------------------------------------------- +let _langDefs = null; + +/** Load language definitions once (synchronous after first call). */ +function getLangDefs() { + if (!_langDefs) { + const path = require('path'); + const langDir = path.join(__dirname, '..', '..', 'tx', 'data'); + // fromFiles is async but internally uses readFileSync — safe to resolve here + _langDefs = new LanguageDefinitions(); + const fs = require('fs'); + const content = fs.readFileSync(path.join(langDir, 'lang.dat'), 'utf8'); + _langDefs._load(content); + } + return _langDefs; +} + /** Build a Languages preference list from a BCP-47 code string, e.g. 'en' or 'pt'. */ function makeLangs(code) { - const defs = new LanguageDefinitions(); + const defs = getLangDefs(); if (!code) { return new Languages(defs); } return Languages.fromAcceptLanguage(code, defs, false); } -/** Build a fresh Designations collection backed by an empty LanguageDefinitions. */ +/** Build a fresh Designations collection backed by loaded LanguageDefinitions. */ function makeDesignations() { - return new Designations(new LanguageDefinitions()); + return new Designations(getLangDefs()); } // --------------------------------------------------------------------------- @@ -275,9 +291,8 @@ describe('displayLanguage – designation selection (Designations.preferredDesig describe('displayLanguage – end-to-end: extractDesignations → preferredDesignation', () => { function selectDisplay(concept, langCode) { - const defs = new LanguageDefinitions(); const langs = makeLangs(langCode); - const displays = new Designations(defs); + const displays = makeDesignations(); const designations = extractDesignations(concept); let hasNoLang = false; diff --git a/tx/library/renderer.js b/tx/library/renderer.js index 7d874a3d..be1929ec 100644 --- a/tx/library/renderer.js +++ b/tx/library/renderer.js @@ -1800,12 +1800,12 @@ class Renderer { if (elem.noMap) { const nomapComment = Extensions.readString(elem, 'http://hl7.org/fhir/StructureDefinition/conceptmap-nomap-comment'); if (!hasComment) { - tr.td().colspan("2").style("background-color: #efefef").tx("(not mapped)"); + tr.td().setAttribute('colspan',"2").style("background-color: #efefef").tx("(not mapped)"); } else if (nomapComment) { - tr.td().colspan("2").style("background-color: #efefef").tx("(not mapped)"); + tr.td().setAttribute('colspan',"2").style("background-color: #efefef").tx("(not mapped)"); tr.td().style("background-color: #efefef").tx(nomapComment); } else { - tr.td().colspan("3").style("background-color: #efefef").tx("(not mapped)"); + tr.td().setAttribute('colspan',"3").style("background-color: #efefef").tx("(not mapped)"); } } else { let first = true; @@ -1858,16 +1858,16 @@ class Renderer { let tr = tbl.tr(); const sourceColCount = 1 + Object.keys(sources).length - 1; // code + dependsOn attributes const targetColCount = 1 + Object.keys(targets).length - 1; // code + product attributes - tr.td().colspan(String(sourceColCount + 1)).b().tx(this.translate('CONC_MAP_SRC_DET')); + tr.td().setAttribute('colspan', String(sourceColCount + 1)).b().tx(this.translate('CONC_MAP_SRC_DET')); if (hasRelationships) { tr.td().b().tx(this.translate('CONC_MAP_REL')); } - tr.td().colspan(String(targetColCount + 1)).b().tx(this.translate('CONC_MAP_TRGT_DET')); + tr.td().setAttribute('colspan', String(targetColCount + 1)).b().tx(this.translate('CONC_MAP_TRGT_DET')); if (hasComment) { tr.td().b().tx(this.translate('GENERAL_COMMENT')); } if (hasProperties) { - tr.td().colspan(String(Object.keys(props).length)).b().tx(this.translate('GENERAL_PROPS')); + tr.td().setAttribute('colspan', String(Object.keys(props).length)).b().tx(this.translate('GENERAL_PROPS')); } // Second header row: actual column headers @@ -1944,10 +1944,10 @@ class Renderer { const nomapComment = Extensions.readString(elem, 'http://hl7.org/fhir/StructureDefinition/conceptmap-nomap-comment'); if (nomapComment) { - tr.td().colspan("3").style("background-color: #efefef").tx("(not mapped)"); + tr.td().setAttribute('colspan',"3").style("background-color: #efefef").tx("(not mapped)"); tr.td().style("background-color: #efefef").tx(nomapComment); } else { - tr.td().colspan("4").style("background-color: #efefef").tx("(not mapped)"); + tr.td().setAttribute('colspan',"4").style("background-color: #efefef").tx("(not mapped)"); } } else { let first = true; @@ -2072,7 +2072,7 @@ class Renderer { async renderCSDetailsLink(tr, url, span2) { const td = tr.td(); if (span2) { - td.colspan("2"); + td.setAttribute('colspan',"2"); } td.b().tx(this.translate('CONC_MAP_CODES')); td.tx(" " + this.translate('CONC_MAP_FRM') + " "); diff --git a/tx/ocl/cm-ocl.cjs b/tx/ocl/cm-ocl.cjs index 1b14ac62..d726ef0a 100644 --- a/tx/ocl/cm-ocl.cjs +++ b/tx/ocl/cm-ocl.cjs @@ -59,15 +59,19 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { return await this.fetchConceptMapById(mappingId); } - const mappings = await this.#searchMappings({ from_source_url: url }, this.maxSearchPages); - for (const mapping of mappings) { - const cm = this.#toConceptMap(mapping); - if (cm) { - this.#indexConceptMap(cm); - if (cm.url === url && (!version || cm.version === version)) { - return cm; + try { + const mappings = await this.#searchMappings({ from_source_url: url }, this.maxSearchPages); + for (const mapping of mappings) { + const cm = this.#toConceptMap(mapping); + if (cm) { + this.#indexConceptMap(cm); + if (cm.url === url && (!version || cm.version === version)) { + return cm; + } } } + } catch (_err) { + // OCL API unreachable or returned error — treat as not found } return null; @@ -87,23 +91,194 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { return this._idMap.get(rawId); } - const response = await this.httpClient.get(`/mappings/${encodeURIComponent(rawId)}/`); - const cm = this.#toConceptMap(response.data); - if (!cm) { + try { + const response = await this.httpClient.get(`/mappings/${encodeURIComponent(rawId)}/`); + const cm = this.#toConceptMap(response.data); + if (!cm) { + return null; + } + this.#indexConceptMap(cm); + return cm; + } catch (_err) { return null; } - this.#indexConceptMap(cm); - return cm; } - // eslint-disable-next-line no-unused-vars - async searchConceptMaps(_searchParams, _elements) { - // OCL does not have ConceptMap resources; it exposes individual mappings - // between concepts. Searching is not feasible — use $translate instead. - return []; + async searchConceptMaps(searchParams, _elements) { + this._validateSearchParams(searchParams); + + const params = Object.fromEntries( + searchParams.map(({ name, value }) => [name, String(value).toLowerCase()]) + ); + const sourceSystem = params['source-system'] || params.source || null; + const targetSystem = params['target-system'] || params.target || null; + + // Without a source or target filter the search would have to fetch every + // mapping in the organisation — too expensive. Return empty so the + // Package providers can still answer generic ConceptMap listings. + if (!sourceSystem && !targetSystem) { + return []; + } + + try { + const allMappings = await this.#collectMappingsForSearch(sourceSystem, targetSystem); + return this.#aggregateMappingsToConceptMaps(allMappings); + } catch (_err) { + return []; + } + } + + async #collectMappingsForSearch(sourceSystem, targetSystem) { + const systemUrl = sourceSystem || targetSystem; + const candidates = await this.#candidateSourceUrls(systemUrl); + const sourcePaths = candidates.filter(s => String(s || '').startsWith('/orgs/')); + + if (sourcePaths.length === 0) { + return []; + } + + const allMappings = []; + for (const sourcePath of sourcePaths) { + const normalizedPath = this.#normalizeSourcePath(sourcePath); + let concepts; + try { + concepts = await this.#fetchAllPages( + `${normalizedPath}concepts/`, { limit: PAGE_SIZE }, this.maxSearchPages + ); + } catch (_err) { + continue; + } + + for (const concept of concepts) { + const code = concept.id || concept.mnemonic; + if (!code) { + continue; + } + try { + const mappings = await this.#fetchAllPages( + `${normalizedPath}concepts/${encodeURIComponent(code)}/mappings/`, + { limit: PAGE_SIZE }, 2 + ); + allMappings.push(...mappings); + } catch (_err) { + // concept has no mappings or endpoint inaccessible — skip + } + } + } + + const sourceUrlsToResolve = new Set(); + for (const m of allMappings) { + const from = m?.from_source_url || m?.fromSourceUrl; + const to = m?.to_source_url || m?.toSourceUrl; + if (from) sourceUrlsToResolve.add(from); + if (to) sourceUrlsToResolve.add(to); + } + await this.#ensureCanonicalForSourceUrls(sourceUrlsToResolve); + + return allMappings; + } + + #aggregateMappingsToConceptMaps(mappings) { + const groups = new Map(); + + for (const mapping of mappings) { + const fromSource = mapping.from_source_url || mapping.fromSourceUrl || null; + const toSource = mapping.to_source_url || mapping.toSourceUrl || null; + const sourceCode = mapping.from_concept_code || mapping.fromConceptCode; + const targetCode = mapping.to_concept_code || mapping.toConceptCode; + + if (!fromSource || !toSource || !sourceCode || !targetCode) { + continue; + } + + const sourceCanonical = this.#canonicalForSourceUrl(fromSource) || fromSource; + const targetCanonical = this.#canonicalForSourceUrl(toSource) || toSource; + const groupKey = `${this.#norm(sourceCanonical)}|${this.#norm(targetCanonical)}`; + + if (!groups.has(groupKey)) { + groups.set(groupKey, { + sourceCanonical, targetCanonical, elements: new Map(), lastUpdated: null + }); + } + + const group = groups.get(groupKey); + const ts = this.#toIsoDate( + mapping.updated_on || mapping.updatedOn || mapping.updated_at || mapping.updatedAt + ); + if (ts && (!group.lastUpdated || ts > group.lastUpdated)) { + group.lastUpdated = ts; + } + + if (!group.elements.has(sourceCode)) { + group.elements.set(sourceCode, { + code: sourceCode, + display: mapping.from_concept_name_resolved || mapping.fromConceptNameResolved + || mapping.from_concept_name || mapping.fromConceptName || null, + targets: [] + }); + } + + group.elements.get(sourceCode).targets.push({ + code: targetCode, + display: mapping.to_concept_name_resolved || mapping.toConceptNameResolved + || mapping.to_concept_name || mapping.toConceptName || null, + relationship: this.#toRelationship(mapping.map_type || mapping.mapType), + comment: mapping.comment || null + }); + } + + const results = []; + for (const [, group] of groups) { + const sourceId = this.#lastSegment(group.sourceCanonical); + const targetId = this.#lastSegment(group.targetCanonical); + const id = `${sourceId}-to-${targetId}`; + + const elements = []; + for (const [, el] of group.elements) { + elements.push({ code: el.code, display: el.display, target: el.targets }); + } + + const json = { + resourceType: 'ConceptMap', + id, + url: `${this.baseUrl}/ConceptMap/${id}`, + name: id, + title: `${sourceId} to ${targetId}`, + status: 'active', + sourceScopeUri: group.sourceCanonical, + targetScopeUri: group.targetCanonical, + group: [{ + source: group.sourceCanonical, + target: group.targetCanonical, + element: elements + }] + }; + if (group.lastUpdated) { + json.meta = { lastUpdated: group.lastUpdated }; + } + + const cm = new ConceptMap(json, 'R5'); + this.#indexConceptMap(cm); + results.push(cm); + } + return results; + } + + #lastSegment(canonical) { + const raw = String(canonical || '').trim().replace(/\/+$/, ''); + const slash = raw.lastIndexOf('/'); + return slash >= 0 && slash < raw.length - 1 ? raw.substring(slash + 1) : raw; } async findConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceScope, targetScope, targetSystem, sourceCode = null) { + try { + await this.#doFindConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceScope, targetScope, targetSystem, sourceCode); + } catch (_err) { + // OCL API errors must not break $translate for other providers + } + } + + async #doFindConceptMapForTranslation(opContext, conceptMaps, sourceSystem, sourceScope, targetScope, targetSystem, sourceCode) { const sourceCandidates = await this.#candidateSourceUrls(sourceSystem); const targetCandidates = await this.#candidateSourceUrls(targetSystem); @@ -113,8 +288,12 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { if (sourceCode && sourcePaths.length > 0) { for (const sourcePath of sourcePaths) { const conceptPath = `${this.#normalizeSourcePath(sourcePath)}concepts/${encodeURIComponent(sourceCode)}/mappings/`; - const found = await this.#fetchAllPages(conceptPath, { limit: PAGE_SIZE }, Math.min(2, this.maxSearchPages)); - mappings.push(...found); + try { + const found = await this.#fetchAllPages(conceptPath, { limit: PAGE_SIZE }, Math.min(2, this.maxSearchPages)); + mappings.push(...found); + } catch (_err) { + // concept not found or mappings endpoint unavailable + } } } diff --git a/tx/workers/search.js b/tx/workers/search.js index 0bde071d..8cc0e52f 100644 --- a/tx/workers/search.js +++ b/tx/workers/search.js @@ -35,7 +35,8 @@ class SearchWorker extends TerminologyWorker { '_offset', '_count', '_elements', '_sort', '_summary', '_total', '_format', 'url', 'version', 'content-mode', 'date', 'description', 'supplements', 'identifier', 'jurisdiction', 'name', - 'publisher', 'status', 'system', 'title', 'text' + 'publisher', 'status', 'system', 'title', 'text', + 'source-system', 'target-system' ]; // Summary elements for _summary=true (marked elements per resource type) From a2279dedf2c5d50c106d48459c896a3f35c40c18 Mon Sep 17 00:00:00 2001 From: Italo Macedo Date: Thu, 19 Mar 2026 19:45:53 -0300 Subject: [PATCH 4/5] fix(test): align translate test assertion with actual error message The test for missing system parameter was checking for 'sourceSystem' (a ConceptMap search parameter) instead of the actual error message returned by the $translate operation. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/tx/translate.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tx/translate.test.js b/tests/tx/translate.test.js index 3a740912..c5d36fd7 100644 --- a/tests/tx/translate.test.js +++ b/tests/tx/translate.test.js @@ -49,7 +49,7 @@ describe('Translate Worker', () => { expect(response.status).toBe(400); expect(response.body.resourceType).toBe('OperationOutcome'); expect(response.body.issue[0].code).toBe('invalid'); - expect(response.body.issue[0].details.text).toContain('sourceSystem'); + expect(response.body.issue[0].details.text).toContain('system parameter is required'); }); test('should return 400 when no source code/coding provided', async () => { From b60358fda5eccc789f7c3f2a1e16278b53ba96ee Mon Sep 17 00:00:00 2001 From: Italo Macedo Date: Fri, 20 Mar 2026 10:31:43 -0300 Subject: [PATCH 5/5] fix(ocl): remove redundant per-concept HTTP fetch during ValueSet expansion The detailedConcept fetch made an individual HTTP request for each concept to get designation data. This is unnecessary since the concept listing already uses verbose=true, which returns names/designations in the page response. Co-Authored-By: Claude Opus 4.6 (1M context) --- tx/ocl/vs-ocl.cjs | 35 +++++++++-------------------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/tx/ocl/vs-ocl.cjs b/tx/ocl/vs-ocl.cjs index 74c94e05..15d2e436 100644 --- a/tx/ocl/vs-ocl.cjs +++ b/tx/ocl/vs-ocl.cjs @@ -800,29 +800,12 @@ class OCLValueSetProvider extends AbstractValueSetProvider { continue; } - // Busca detalhes do conceito - let detailedConcept = concept; - const conceptId = concept.id || concept.code; - if (conceptId) { - let detailUrl = meta.conceptsUrl; - if (!detailUrl.endsWith('/')) detailUrl += '/'; - detailUrl += encodeURIComponent(conceptId) + '/'; - try { - const detailResp = await this.httpClient.get(detailUrl); - if (detailResp && detailResp.data && typeof detailResp.data === 'object') { - detailedConcept = { ...concept, ...detailResp.data }; - } - } catch (error) { - // Se falhar, usa o conceito da listagem - } - } + const localizedNames = this.#extractLocalizedNames(concept, effectiveLanguageCodes); + const localizedDefinitions = this.#extractLocalizedDefinitions(concept, effectiveLanguageCodes); - const localizedNames = this.#extractLocalizedNames(detailedConcept, effectiveLanguageCodes); - const localizedDefinitions = this.#extractLocalizedDefinitions(detailedConcept, effectiveLanguageCodes); - - const display = localizedNames.display || detailedConcept.display_name || detailedConcept.display || detailedConcept.name || null; - const definition = localizedDefinitions.definition || detailedConcept.definition || detailedConcept.description || detailedConcept.concept_class || null; - const code = detailedConcept.code || detailedConcept.id || null; + const display = localizedNames.display || concept.display_name || concept.display || concept.name || null; + const definition = localizedDefinitions.definition || concept.definition || concept.description || concept.concept_class || null; + const code = concept.code || concept.id || null; const searchableText = [ code, display, @@ -838,9 +821,9 @@ class OCLValueSetProvider extends AbstractValueSetProvider { continue; } - const owner = detailedConcept.owner || meta.owner || null; - const source = detailedConcept.source || null; - const conceptCanonical = detailedConcept.source_canonical_url || detailedConcept.sourceCanonicalUrl || null; + const owner = concept.owner || meta.owner || null; + const source = concept.source || null; + const conceptCanonical = concept.source_canonical_url || concept.sourceCanonicalUrl || null; const system = conceptCanonical || (owner && source ? await this.#getSourceCanonicalUrl(owner, source) : fallbackSystem); @@ -852,7 +835,7 @@ class OCLValueSetProvider extends AbstractValueSetProvider { definition: definition || undefined, designation: localizedNames.designation, definitions: localizedDefinitions.definitions, - inactive: detailedConcept.retired === true ? true : undefined + inactive: concept.retired === true ? true : undefined }); remaining -= 1; }