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 new file mode 100644 index 00000000..4ddfaf80 --- /dev/null +++ b/tests/ocl/ocl-display-language.test.js @@ -0,0 +1,371 @@ +/** + * 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 +// --------------------------------------------------------------------------- + +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 = getLangDefs(); + if (!code) { + return new Languages(defs); + } + return Languages.fromAcceptLanguage(code, defs, false); +} + +/** Build a fresh Designations collection backed by loaded LanguageDefinitions. */ +function makeDesignations() { + return new Designations(getLangDefs()); +} + +// --------------------------------------------------------------------------- +// 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 langs = makeLangs(langCode); + const displays = makeDesignations(); + + 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/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 () => { 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/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 7a4cf538..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,45 +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) { this._validateSearchParams(searchParams); - const params = Object.fromEntries(searchParams.map(({ name, value }) => [name, String(value).toLowerCase()])); - const oclParams = {}; + 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; - if (params.source) { - oclParams.from_source_url = params.source; + // 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 []; } - if (params.target) { - oclParams.to_source_url = params.target; + } + + 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 mappings = await this.#searchMappings(oclParams, this.maxSearchPages); - const results = []; + 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 cm = this.#toConceptMap(mapping); - if (!cm) { + 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; } - this.#indexConceptMap(cm); - if (this.#matches(cm.jsonObj, params)) { - results.push(cm); + + 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); @@ -135,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 + } } } @@ -311,43 +468,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/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) diff --git a/tx/workers/translate.js b/tx/workers/translate.js index f20b2490..9b839889 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); @@ -169,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); } @@ -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,