@the-cascade-protocol/cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/.dockerignore +7 -0
  2. package/.eslintrc.json +23 -0
  3. package/.prettierrc +7 -0
  4. package/DOCKER.md +36 -0
  5. package/Dockerfile +18 -0
  6. package/README.md +69 -0
  7. package/dist/commands/capabilities.d.ts +9 -0
  8. package/dist/commands/capabilities.d.ts.map +1 -0
  9. package/dist/commands/capabilities.js +194 -0
  10. package/dist/commands/capabilities.js.map +1 -0
  11. package/dist/commands/conformance.d.ts +15 -0
  12. package/dist/commands/conformance.d.ts.map +1 -0
  13. package/dist/commands/conformance.js +348 -0
  14. package/dist/commands/conformance.js.map +1 -0
  15. package/dist/commands/convert.d.ts +21 -0
  16. package/dist/commands/convert.d.ts.map +1 -0
  17. package/dist/commands/convert.js +134 -0
  18. package/dist/commands/convert.js.map +1 -0
  19. package/dist/commands/pod/export.d.ts +8 -0
  20. package/dist/commands/pod/export.d.ts.map +1 -0
  21. package/dist/commands/pod/export.js +72 -0
  22. package/dist/commands/pod/export.js.map +1 -0
  23. package/dist/commands/pod/helpers.d.ts +79 -0
  24. package/dist/commands/pod/helpers.d.ts.map +1 -0
  25. package/dist/commands/pod/helpers.js +369 -0
  26. package/dist/commands/pod/helpers.js.map +1 -0
  27. package/dist/commands/pod/index.d.ts +20 -0
  28. package/dist/commands/pod/index.d.ts.map +1 -0
  29. package/dist/commands/pod/index.js +29 -0
  30. package/dist/commands/pod/index.js.map +1 -0
  31. package/dist/commands/pod/info.d.ts +9 -0
  32. package/dist/commands/pod/info.d.ts.map +1 -0
  33. package/dist/commands/pod/info.js +196 -0
  34. package/dist/commands/pod/info.js.map +1 -0
  35. package/dist/commands/pod/init.d.ts +9 -0
  36. package/dist/commands/pod/init.d.ts.map +1 -0
  37. package/dist/commands/pod/init.js +251 -0
  38. package/dist/commands/pod/init.js.map +1 -0
  39. package/dist/commands/pod/query.d.ts +9 -0
  40. package/dist/commands/pod/query.d.ts.map +1 -0
  41. package/dist/commands/pod/query.js +169 -0
  42. package/dist/commands/pod/query.js.map +1 -0
  43. package/dist/commands/pod 2.js +1017 -0
  44. package/dist/commands/pod.d.ts +28 -0
  45. package/dist/commands/pod.d.ts 2.map +1 -0
  46. package/dist/commands/pod.d.ts.map +1 -0
  47. package/dist/commands/pod.js +1031 -0
  48. package/dist/commands/pod.js 2.map +1 -0
  49. package/dist/commands/pod.js.map +1 -0
  50. package/dist/commands/serve.d.ts +33 -0
  51. package/dist/commands/serve.d.ts.map +1 -0
  52. package/dist/commands/serve.js +74 -0
  53. package/dist/commands/serve.js.map +1 -0
  54. package/dist/commands/validate.d.ts +18 -0
  55. package/dist/commands/validate.d.ts.map +1 -0
  56. package/dist/commands/validate.js +275 -0
  57. package/dist/commands/validate.js.map +1 -0
  58. package/dist/index.d.ts +19 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +49 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/lib/fhir-converter/cascade-to-fhir.d.ts +17 -0
  63. package/dist/lib/fhir-converter/cascade-to-fhir.d.ts.map +1 -0
  64. package/dist/lib/fhir-converter/cascade-to-fhir.js +358 -0
  65. package/dist/lib/fhir-converter/cascade-to-fhir.js.map +1 -0
  66. package/dist/lib/fhir-converter/converters-clinical.d.ts +29 -0
  67. package/dist/lib/fhir-converter/converters-clinical.d.ts.map +1 -0
  68. package/dist/lib/fhir-converter/converters-clinical.js +391 -0
  69. package/dist/lib/fhir-converter/converters-clinical.js.map +1 -0
  70. package/dist/lib/fhir-converter/converters-demographics.d.ts +20 -0
  71. package/dist/lib/fhir-converter/converters-demographics.d.ts.map +1 -0
  72. package/dist/lib/fhir-converter/converters-demographics.js +242 -0
  73. package/dist/lib/fhir-converter/converters-demographics.js.map +1 -0
  74. package/dist/lib/fhir-converter/fhir-to-cascade.d.ts +17 -0
  75. package/dist/lib/fhir-converter/fhir-to-cascade.d.ts.map +1 -0
  76. package/dist/lib/fhir-converter/fhir-to-cascade.js +63 -0
  77. package/dist/lib/fhir-converter/fhir-to-cascade.js.map +1 -0
  78. package/dist/lib/fhir-converter/index.d.ts +36 -0
  79. package/dist/lib/fhir-converter/index.d.ts.map +1 -0
  80. package/dist/lib/fhir-converter/index.js +187 -0
  81. package/dist/lib/fhir-converter/index.js.map +1 -0
  82. package/dist/lib/fhir-converter/types.d.ts +77 -0
  83. package/dist/lib/fhir-converter/types.d.ts.map +1 -0
  84. package/dist/lib/fhir-converter/types.js +236 -0
  85. package/dist/lib/fhir-converter/types.js.map +1 -0
  86. package/dist/lib/fhir-converter.d.ts +62 -0
  87. package/dist/lib/fhir-converter.d.ts.map +1 -0
  88. package/dist/lib/fhir-converter.js +1474 -0
  89. package/dist/lib/fhir-converter.js.map +1 -0
  90. package/dist/lib/mcp/audit.d.ts +24 -0
  91. package/dist/lib/mcp/audit.d.ts.map +1 -0
  92. package/dist/lib/mcp/audit.js +85 -0
  93. package/dist/lib/mcp/audit.js.map +1 -0
  94. package/dist/lib/mcp/server.d.ts +38 -0
  95. package/dist/lib/mcp/server.d.ts.map +1 -0
  96. package/dist/lib/mcp/server.js +172 -0
  97. package/dist/lib/mcp/server.js.map +1 -0
  98. package/dist/lib/mcp/tools.d.ts +47 -0
  99. package/dist/lib/mcp/tools.d.ts.map +1 -0
  100. package/dist/lib/mcp/tools.js +547 -0
  101. package/dist/lib/mcp/tools.js.map +1 -0
  102. package/dist/lib/output.d.ts +26 -0
  103. package/dist/lib/output.d.ts.map +1 -0
  104. package/dist/lib/output.js +64 -0
  105. package/dist/lib/output.js.map +1 -0
  106. package/dist/lib/shacl-validator.d.ts +53 -0
  107. package/dist/lib/shacl-validator.d.ts.map +1 -0
  108. package/dist/lib/shacl-validator.js +245 -0
  109. package/dist/lib/shacl-validator.js.map +1 -0
  110. package/dist/lib/turtle-parser.d.ts +64 -0
  111. package/dist/lib/turtle-parser.d.ts.map +1 -0
  112. package/dist/lib/turtle-parser.js +236 -0
  113. package/dist/lib/turtle-parser.js.map +1 -0
  114. package/dist/shapes/checkup.shapes.ttl +1459 -0
  115. package/dist/shapes/clinical.shapes.ttl +1350 -0
  116. package/dist/shapes/clinical.ttl +1369 -0
  117. package/dist/shapes/core.shapes.ttl +450 -0
  118. package/dist/shapes/core.ttl +603 -0
  119. package/dist/shapes/coverage.shapes.ttl +214 -0
  120. package/dist/shapes/coverage.ttl +182 -0
  121. package/dist/shapes/health.shapes.ttl +697 -0
  122. package/dist/shapes/health.ttl +859 -0
  123. package/dist/shapes/pots.shapes.ttl +481 -0
  124. package/package.json +54 -0
  125. package/src/commands/capabilities.ts +235 -0
  126. package/src/commands/conformance.ts +447 -0
  127. package/src/commands/convert.ts +164 -0
  128. package/src/commands/pod/export.ts +85 -0
  129. package/src/commands/pod/helpers.ts +449 -0
  130. package/src/commands/pod/index.ts +32 -0
  131. package/src/commands/pod/info.ts +239 -0
  132. package/src/commands/pod/init.ts +273 -0
  133. package/src/commands/pod/query.ts +224 -0
  134. package/src/commands/serve.ts +92 -0
  135. package/src/commands/validate.ts +303 -0
  136. package/src/index.ts +58 -0
  137. package/src/lib/fhir-converter/cascade-to-fhir.ts +369 -0
  138. package/src/lib/fhir-converter/converters-clinical.ts +446 -0
  139. package/src/lib/fhir-converter/converters-demographics.ts +270 -0
  140. package/src/lib/fhir-converter/fhir-to-cascade.ts +82 -0
  141. package/src/lib/fhir-converter/index.ts +215 -0
  142. package/src/lib/fhir-converter/types.ts +318 -0
  143. package/src/lib/mcp/audit.ts +107 -0
  144. package/src/lib/mcp/server.ts +192 -0
  145. package/src/lib/mcp/tools.ts +668 -0
  146. package/src/lib/output.ts +76 -0
  147. package/src/lib/shacl-validator.ts +314 -0
  148. package/src/lib/turtle-parser.ts +277 -0
  149. package/src/shapes/checkup.shapes.ttl +1459 -0
  150. package/src/shapes/clinical.shapes.ttl +1350 -0
  151. package/src/shapes/clinical.ttl +1369 -0
  152. package/src/shapes/core.shapes.ttl +450 -0
  153. package/src/shapes/core.ttl +603 -0
  154. package/src/shapes/coverage.shapes.ttl +214 -0
  155. package/src/shapes/coverage.ttl +182 -0
  156. package/src/shapes/health.shapes.ttl +697 -0
  157. package/src/shapes/health.ttl +859 -0
  158. package/src/shapes/pots.shapes.ttl +481 -0
  159. package/test-fixtures/fhir-bundle-example.json +216 -0
  160. package/test-fixtures/fhir-medication-example.json +18 -0
  161. package/tests/cli.test.ts +126 -0
  162. package/tests/fhir-converter.test.ts +874 -0
  163. package/tests/mcp-server.test.ts +396 -0
  164. package/tests/pod.test.ts +400 -0
  165. package/tsconfig.json +24 -0
@@ -0,0 +1,874 @@
1
+ /**
2
+ * Unit tests for the FHIR converter modules.
3
+ *
4
+ * Tests FHIR -> Cascade conversion (all 9 resource types),
5
+ * Cascade -> FHIR reverse conversion, batch Bundle conversion,
6
+ * and edge cases.
7
+ */
8
+
9
+ import { describe, it, expect } from 'vitest';
10
+ import {
11
+ convertFhirToCascade,
12
+ convertFhirResourceToQuads,
13
+ convert,
14
+ } from '../src/lib/fhir-converter/index.js';
15
+ import { convertCascadeToFhir } from '../src/lib/fhir-converter/cascade-to-fhir.js';
16
+ import {
17
+ convertMedicationStatement,
18
+ convertCondition,
19
+ convertAllergyIntolerance,
20
+ convertObservationLab,
21
+ convertObservationVital,
22
+ isVitalSignObservation,
23
+ } from '../src/lib/fhir-converter/converters-clinical.js';
24
+ import {
25
+ convertPatient,
26
+ convertImmunization,
27
+ convertCoverage,
28
+ } from '../src/lib/fhir-converter/converters-demographics.js';
29
+ import {
30
+ NS,
31
+ ensureDateTimeWithTz,
32
+ extractCodings,
33
+ codeableConceptText,
34
+ } from '../src/lib/fhir-converter/types.js';
35
+
36
+ // =============================================================================
37
+ // Sample FHIR resources for testing
38
+ // =============================================================================
39
+
40
+ const samplePatient = {
41
+ resourceType: 'Patient',
42
+ id: 'patient-1',
43
+ birthDate: '1985-06-15',
44
+ gender: 'female',
45
+ address: [
46
+ {
47
+ city: 'Portland',
48
+ state: 'OR',
49
+ postalCode: '97201',
50
+ country: 'US',
51
+ line: ['123 Main St'],
52
+ },
53
+ ],
54
+ maritalStatus: {
55
+ coding: [{ code: 'M' }],
56
+ text: 'Married',
57
+ },
58
+ };
59
+
60
+ const sampleCondition = {
61
+ resourceType: 'Condition',
62
+ id: 'condition-1',
63
+ code: {
64
+ coding: [
65
+ { system: 'http://snomed.info/sct', code: '73211009', display: 'Diabetes Mellitus' },
66
+ { system: 'http://hl7.org/fhir/sid/icd-10-cm', code: 'E11.9', display: 'Type 2 diabetes mellitus without complications' },
67
+ ],
68
+ text: 'Diabetes Mellitus',
69
+ },
70
+ clinicalStatus: {
71
+ coding: [{ code: 'active' }],
72
+ },
73
+ onsetDateTime: '2020-03-15',
74
+ note: [{ text: 'Patient diagnosed during routine screening' }],
75
+ };
76
+
77
+ const sampleAllergy = {
78
+ resourceType: 'AllergyIntolerance',
79
+ id: 'allergy-1',
80
+ code: {
81
+ coding: [{ display: 'Penicillin' }],
82
+ text: 'Penicillin',
83
+ },
84
+ category: ['medication'],
85
+ reaction: [
86
+ {
87
+ manifestation: [{ text: 'Hives' }, { text: 'Rash' }],
88
+ severity: 'moderate',
89
+ },
90
+ ],
91
+ onsetDateTime: '2015-01-01',
92
+ };
93
+
94
+ const sampleLabObservation = {
95
+ resourceType: 'Observation',
96
+ id: 'lab-1',
97
+ code: {
98
+ coding: [{ system: 'http://loinc.org', code: '2345-7', display: 'Glucose' }],
99
+ text: 'Glucose',
100
+ },
101
+ category: [{ coding: [{ code: 'laboratory' }] }],
102
+ valueQuantity: { value: 105, unit: 'mg/dL' },
103
+ interpretation: [{ coding: [{ code: 'H' }] }],
104
+ effectiveDateTime: '2024-01-15T10:30:00Z',
105
+ referenceRange: [{ low: { value: 70, unit: 'mg/dL' }, high: { value: 100, unit: 'mg/dL' } }],
106
+ };
107
+
108
+ const sampleVitalObservation = {
109
+ resourceType: 'Observation',
110
+ id: 'vital-1',
111
+ code: {
112
+ coding: [{ system: 'http://loinc.org', code: '8480-6', display: 'Systolic Blood Pressure' }],
113
+ },
114
+ category: [{ coding: [{ code: 'vital-signs' }] }],
115
+ valueQuantity: { value: 128, unit: 'mmHg' },
116
+ effectiveDateTime: '2024-01-15T10:30:00Z',
117
+ referenceRange: [{ low: { value: 90 }, high: { value: 120 } }],
118
+ interpretation: [{ coding: [{ code: 'H' }] }],
119
+ };
120
+
121
+ const sampleMedicationStatement = {
122
+ resourceType: 'MedicationStatement',
123
+ id: 'med-1',
124
+ status: 'active',
125
+ medicationCodeableConcept: {
126
+ coding: [
127
+ { system: 'http://www.nlm.nih.gov/research/umls/rxnorm', code: '1049502', display: 'Metformin 500mg' },
128
+ ],
129
+ text: 'Metformin 500mg',
130
+ },
131
+ dosage: [
132
+ {
133
+ text: '500mg twice daily',
134
+ route: { text: 'oral' },
135
+ timing: { repeat: { frequency: 2, periodUnit: 'd' } },
136
+ },
137
+ ],
138
+ effectivePeriod: {
139
+ start: '2020-03-15',
140
+ },
141
+ note: [{ text: 'Take with meals' }],
142
+ };
143
+
144
+ const sampleMedicationRequest = {
145
+ resourceType: 'MedicationRequest',
146
+ id: 'med-req-1',
147
+ status: 'active',
148
+ medicationCodeableConcept: {
149
+ coding: [
150
+ { system: 'http://www.nlm.nih.gov/research/umls/rxnorm', code: '860975', display: 'Lisinopril 10mg' },
151
+ ],
152
+ text: 'Lisinopril 10mg',
153
+ },
154
+ dosage: [{ text: '10mg once daily' }],
155
+ };
156
+
157
+ const sampleImmunization = {
158
+ resourceType: 'Immunization',
159
+ id: 'imm-1',
160
+ status: 'completed',
161
+ vaccineCode: {
162
+ coding: [
163
+ { system: 'http://hl7.org/fhir/sid/cvx', code: '208', display: 'COVID-19 mRNA' },
164
+ ],
165
+ text: 'COVID-19 mRNA Vaccine',
166
+ },
167
+ occurrenceDateTime: '2021-04-15T09:00:00Z',
168
+ manufacturer: { display: 'Pfizer' },
169
+ lotNumber: 'EL9264',
170
+ performer: [{ actor: { display: 'Dr. Smith' } }],
171
+ location: { display: 'City Health Clinic' },
172
+ note: [{ text: 'First dose' }],
173
+ };
174
+
175
+ const sampleCoverage = {
176
+ resourceType: 'Coverage',
177
+ id: 'cov-1',
178
+ status: 'active',
179
+ subscriberId: 'XYZ123456',
180
+ payor: [{ display: 'Blue Cross Blue Shield' }],
181
+ type: { coding: [{ code: 'HIP' }] },
182
+ class: [
183
+ { type: { coding: [{ code: 'group' }] }, value: 'GRP-001', name: 'Premium Plan' },
184
+ { type: { coding: [{ code: 'rxbin' }] }, value: '015432' },
185
+ { type: { coding: [{ code: 'rxpcn' }] }, value: 'PCN99' },
186
+ { type: { coding: [{ code: 'rxgroup' }] }, value: 'RXGRP01' },
187
+ ],
188
+ period: {
189
+ start: '2024-01-01',
190
+ end: '2024-12-31',
191
+ },
192
+ relationship: { coding: [{ code: 'self' }] },
193
+ };
194
+
195
+ // =============================================================================
196
+ // Helper: find quad value by predicate
197
+ // =============================================================================
198
+
199
+ function findQuadValue(quads: any[], predicateIri: string): string | undefined {
200
+ const q = quads.find((q: any) => q.predicate.value === predicateIri);
201
+ return q?.object?.value;
202
+ }
203
+
204
+ function findAllQuadValues(quads: any[], predicateIri: string): string[] {
205
+ return quads
206
+ .filter((q: any) => q.predicate.value === predicateIri)
207
+ .map((q: any) => q.object.value);
208
+ }
209
+
210
+ // =============================================================================
211
+ // Tests: Helper functions from types.ts
212
+ // =============================================================================
213
+
214
+ describe('FHIR converter helpers', () => {
215
+ describe('ensureDateTimeWithTz', () => {
216
+ it('should return empty string for empty input', () => {
217
+ expect(ensureDateTimeWithTz('')).toBe('');
218
+ });
219
+
220
+ it('should append T00:00:00Z to date-only strings', () => {
221
+ expect(ensureDateTimeWithTz('2024-01-15')).toBe('2024-01-15T00:00:00Z');
222
+ });
223
+
224
+ it('should pass through strings with timezone', () => {
225
+ expect(ensureDateTimeWithTz('2024-01-15T10:30:00Z')).toBe('2024-01-15T10:30:00Z');
226
+ });
227
+
228
+ it('should pass through strings with offset timezone', () => {
229
+ expect(ensureDateTimeWithTz('2024-01-15T10:30:00+05:00')).toBe('2024-01-15T10:30:00+05:00');
230
+ });
231
+
232
+ it('should append Z to time without timezone', () => {
233
+ expect(ensureDateTimeWithTz('2024-01-15T10:30:00')).toBe('2024-01-15T10:30:00Z');
234
+ });
235
+ });
236
+
237
+ describe('extractCodings', () => {
238
+ it('should return empty array for null input', () => {
239
+ expect(extractCodings(null)).toEqual([]);
240
+ });
241
+
242
+ it('should return empty array for codeable concept without codings', () => {
243
+ expect(extractCodings({ text: 'Something' })).toEqual([]);
244
+ });
245
+
246
+ it('should extract codings with system, code, and display', () => {
247
+ const cc = {
248
+ coding: [
249
+ { system: 'http://loinc.org', code: '2345-7', display: 'Glucose' },
250
+ { system: 'http://snomed.info/sct', code: '33747003' },
251
+ ],
252
+ };
253
+ const result = extractCodings(cc);
254
+ expect(result).toHaveLength(2);
255
+ expect(result[0]).toEqual({ system: 'http://loinc.org', code: '2345-7', display: 'Glucose' });
256
+ expect(result[1]).toEqual({ system: 'http://snomed.info/sct', code: '33747003', display: undefined });
257
+ });
258
+
259
+ it('should skip codings without system or code', () => {
260
+ const cc = { coding: [{ display: 'Only display' }] };
261
+ expect(extractCodings(cc)).toEqual([]);
262
+ });
263
+ });
264
+
265
+ describe('codeableConceptText', () => {
266
+ it('should return undefined for null input', () => {
267
+ expect(codeableConceptText(null)).toBeUndefined();
268
+ });
269
+
270
+ it('should prefer .text property', () => {
271
+ const cc = { text: 'My Text', coding: [{ display: 'Coded Display' }] };
272
+ expect(codeableConceptText(cc)).toBe('My Text');
273
+ });
274
+
275
+ it('should fall back to coding[0].display', () => {
276
+ const cc = { coding: [{ display: 'Coded Display' }] };
277
+ expect(codeableConceptText(cc)).toBe('Coded Display');
278
+ });
279
+ });
280
+ });
281
+
282
+ // =============================================================================
283
+ // Tests: FHIR -> Cascade (per resource type)
284
+ // =============================================================================
285
+
286
+ describe('FHIR -> Cascade converters', () => {
287
+ describe('Patient -> PatientProfile', () => {
288
+ it('should convert Patient to cascade:PatientProfile', () => {
289
+ const result = convertPatient(samplePatient);
290
+ expect(result.resourceType).toBe('Patient');
291
+ expect(result.cascadeType).toBe('cascade:PatientProfile');
292
+
293
+ const quads = result._quads;
294
+ const typeValue = findQuadValue(quads, NS.rdf + 'type');
295
+ expect(typeValue).toBe(NS.cascade + 'PatientProfile');
296
+
297
+ // Gender -> biologicalSex
298
+ expect(findQuadValue(quads, NS.cascade + 'biologicalSex')).toBe('female');
299
+
300
+ // Address fields
301
+ expect(findQuadValue(quads, NS.cascade + 'addressCity')).toBe('Portland');
302
+ expect(findQuadValue(quads, NS.cascade + 'addressState')).toBe('OR');
303
+ expect(findQuadValue(quads, NS.cascade + 'addressPostalCode')).toBe('97201');
304
+
305
+ // Marital status
306
+ expect(findQuadValue(quads, NS.cascade + 'maritalStatus')).toBe('married');
307
+
308
+ // Profile ID
309
+ expect(findQuadValue(quads, NS.cascade + 'profileId')).toBe('patient-1');
310
+ });
311
+
312
+ it('should compute age and age group', () => {
313
+ const result = convertPatient(samplePatient);
314
+ const quads = result._quads;
315
+ const computedAge = findQuadValue(quads, NS.cascade + 'computedAge');
316
+ expect(computedAge).toBeDefined();
317
+ expect(parseInt(computedAge!, 10)).toBeGreaterThan(30);
318
+
319
+ const ageGroup = findQuadValue(quads, NS.cascade + 'ageGroup');
320
+ expect(ageGroup).toBe('adult');
321
+ });
322
+
323
+ it('should warn when birthDate is missing', () => {
324
+ const result = convertPatient({ resourceType: 'Patient' });
325
+ expect(result.warnings).toContain('No birthDate found in Patient resource');
326
+ });
327
+ });
328
+
329
+ describe('Condition -> ConditionRecord', () => {
330
+ it('should convert Condition to health:ConditionRecord', () => {
331
+ const result = convertCondition(sampleCondition);
332
+ expect(result.cascadeType).toBe('health:ConditionRecord');
333
+
334
+ const quads = result._quads;
335
+ expect(findQuadValue(quads, NS.rdf + 'type')).toBe(NS.health + 'ConditionRecord');
336
+ expect(findQuadValue(quads, NS.health + 'conditionName')).toBe('Diabetes Mellitus');
337
+ expect(findQuadValue(quads, NS.health + 'status')).toBe('active');
338
+ expect(findQuadValue(quads, NS.health + 'sourceRecordId')).toBe('condition-1');
339
+ });
340
+
341
+ it('should map ICD-10 and SNOMED codes', () => {
342
+ const result = convertCondition(sampleCondition);
343
+ const quads = result._quads;
344
+
345
+ const snomedCodes = findAllQuadValues(quads, NS.health + 'snomedCode');
346
+ expect(snomedCodes).toContain(NS.sct + '73211009');
347
+
348
+ const icd10Codes = findAllQuadValues(quads, NS.health + 'icd10Code');
349
+ expect(icd10Codes).toContain(NS.icd10 + 'E11.9');
350
+ });
351
+
352
+ it('should include notes', () => {
353
+ const result = convertCondition(sampleCondition);
354
+ const quads = result._quads;
355
+ expect(findQuadValue(quads, NS.health + 'notes')).toBe('Patient diagnosed during routine screening');
356
+ });
357
+ });
358
+
359
+ describe('AllergyIntolerance -> AllergyRecord', () => {
360
+ it('should convert AllergyIntolerance to health:AllergyRecord', () => {
361
+ const result = convertAllergyIntolerance(sampleAllergy);
362
+ expect(result.cascadeType).toBe('health:AllergyRecord');
363
+
364
+ const quads = result._quads;
365
+ expect(findQuadValue(quads, NS.health + 'allergen')).toBe('Penicillin');
366
+ expect(findQuadValue(quads, NS.health + 'allergyCategory')).toBe('medication');
367
+ expect(findQuadValue(quads, NS.health + 'reaction')).toBe('Hives, Rash');
368
+ expect(findQuadValue(quads, NS.health + 'allergySeverity')).toBe('moderate');
369
+ });
370
+
371
+ it('should fall back to criticality when reaction severity is missing', () => {
372
+ const allergy = {
373
+ resourceType: 'AllergyIntolerance',
374
+ code: { text: 'Peanuts' },
375
+ criticality: 'high',
376
+ };
377
+ const result = convertAllergyIntolerance(allergy);
378
+ expect(findQuadValue(result._quads, NS.health + 'allergySeverity')).toBe('severe');
379
+ });
380
+ });
381
+
382
+ describe('Observation (lab) -> LabResultRecord', () => {
383
+ it('should convert lab Observation to health:LabResultRecord', () => {
384
+ const result = convertObservationLab(sampleLabObservation);
385
+ expect(result.cascadeType).toBe('health:LabResultRecord');
386
+
387
+ const quads = result._quads;
388
+ expect(findQuadValue(quads, NS.health + 'testName')).toBe('Glucose');
389
+ expect(findQuadValue(quads, NS.health + 'resultValue')).toBe('105');
390
+ expect(findQuadValue(quads, NS.health + 'resultUnit')).toBe('mg/dL');
391
+ expect(findQuadValue(quads, NS.health + 'interpretation')).toBe('abnormal');
392
+ });
393
+
394
+ it('should handle referenceRange', () => {
395
+ const result = convertObservationLab(sampleLabObservation);
396
+ expect(findQuadValue(result._quads, NS.health + 'referenceRange')).toBe('70-100 mg/dL');
397
+ });
398
+
399
+ it('should handle valueString', () => {
400
+ const obs = {
401
+ resourceType: 'Observation',
402
+ code: { text: 'Blood Type' },
403
+ valueString: 'A+',
404
+ effectiveDateTime: '2024-01-15',
405
+ };
406
+ const result = convertObservationLab(obs);
407
+ expect(findQuadValue(result._quads, NS.health + 'resultValue')).toBe('A+');
408
+ });
409
+
410
+ it('should warn when result value is missing', () => {
411
+ const obs = {
412
+ resourceType: 'Observation',
413
+ code: { text: 'Empty' },
414
+ effectiveDateTime: '2024-01-15',
415
+ };
416
+ const result = convertObservationLab(obs);
417
+ expect(result.warnings).toContain('No result value found in Observation resource');
418
+ });
419
+
420
+ it('should map LOINC test codes', () => {
421
+ const result = convertObservationLab(sampleLabObservation);
422
+ const testCodes = findAllQuadValues(result._quads, NS.health + 'testCode');
423
+ expect(testCodes).toContain(NS.loinc + '2345-7');
424
+ });
425
+ });
426
+
427
+ describe('Observation (vital) -> VitalSign', () => {
428
+ it('should convert vital Observation to clinical:VitalSign', () => {
429
+ const result = convertObservationVital(sampleVitalObservation);
430
+ expect(result.cascadeType).toBe('clinical:VitalSign');
431
+
432
+ const quads = result._quads;
433
+ expect(findQuadValue(quads, NS.rdf + 'type')).toBe(NS.clinical + 'VitalSign');
434
+ expect(findQuadValue(quads, NS.clinical + 'vitalType')).toBe('bloodPressureSystolic');
435
+ expect(findQuadValue(quads, NS.clinical + 'vitalTypeName')).toBe('Systolic Blood Pressure');
436
+ });
437
+
438
+ it('should map LOINC code and SNOMED code', () => {
439
+ const result = convertObservationVital(sampleVitalObservation);
440
+ const quads = result._quads;
441
+ expect(findQuadValue(quads, NS.clinical + 'loincCode')).toBe(NS.loinc + '8480-6');
442
+ expect(findQuadValue(quads, NS.clinical + 'snomedCode')).toBe(NS.sct + '271649006');
443
+ });
444
+
445
+ it('should include value and unit', () => {
446
+ const result = convertObservationVital(sampleVitalObservation);
447
+ const quads = result._quads;
448
+ expect(findQuadValue(quads, NS.clinical + 'value')).toBe('128');
449
+ expect(findQuadValue(quads, NS.clinical + 'unit')).toBe('mmHg');
450
+ });
451
+
452
+ it('should include reference range', () => {
453
+ const result = convertObservationVital(sampleVitalObservation);
454
+ const quads = result._quads;
455
+ expect(findQuadValue(quads, NS.clinical + 'referenceRangeLow')).toBe('90');
456
+ expect(findQuadValue(quads, NS.clinical + 'referenceRangeHigh')).toBe('120');
457
+ });
458
+
459
+ it('should include interpretation', () => {
460
+ const result = convertObservationVital(sampleVitalObservation);
461
+ expect(findQuadValue(result._quads, NS.clinical + 'interpretation')).toBe('high');
462
+ });
463
+ });
464
+
465
+ describe('isVitalSignObservation', () => {
466
+ it('should detect vital sign by category', () => {
467
+ expect(isVitalSignObservation(sampleVitalObservation)).toBe(true);
468
+ });
469
+
470
+ it('should detect vital sign by LOINC code alone', () => {
471
+ const obs = {
472
+ resourceType: 'Observation',
473
+ code: { coding: [{ system: 'http://loinc.org', code: '8867-4' }] },
474
+ };
475
+ expect(isVitalSignObservation(obs)).toBe(true);
476
+ });
477
+
478
+ it('should return false for lab observation', () => {
479
+ expect(isVitalSignObservation(sampleLabObservation)).toBe(false);
480
+ });
481
+ });
482
+
483
+ describe('MedicationStatement -> MedicationRecord', () => {
484
+ it('should convert MedicationStatement with full details', () => {
485
+ const result = convertMedicationStatement(sampleMedicationStatement);
486
+ expect(result.cascadeType).toBe('health:MedicationRecord');
487
+
488
+ const quads = result._quads;
489
+ expect(findQuadValue(quads, NS.health + 'medicationName')).toBe('Metformin 500mg');
490
+ expect(findQuadValue(quads, NS.health + 'isActive')).toBe('true');
491
+ expect(findQuadValue(quads, NS.health + 'dose')).toBe('500mg twice daily');
492
+ expect(findQuadValue(quads, NS.health + 'route')).toBe('oral');
493
+ expect(findQuadValue(quads, NS.health + 'frequency')).toBe('2 times daily');
494
+ expect(findQuadValue(quads, NS.clinical + 'sourceFhirResourceType')).toBe('MedicationStatement');
495
+ expect(findQuadValue(quads, NS.clinical + 'clinicalIntent')).toBe('reportedUse');
496
+ });
497
+
498
+ it('should map RxNorm drug codes', () => {
499
+ const result = convertMedicationStatement(sampleMedicationStatement);
500
+ const quads = result._quads;
501
+ const rxCodes = findAllQuadValues(quads, NS.health + 'rxNormCode');
502
+ expect(rxCodes).toContain(NS.rxnorm + '1049502');
503
+ });
504
+
505
+ it('should include notes', () => {
506
+ const result = convertMedicationStatement(sampleMedicationStatement);
507
+ expect(findQuadValue(result._quads, NS.health + 'notes')).toBe('Take with meals');
508
+ });
509
+ });
510
+
511
+ describe('MedicationRequest -> MedicationRecord', () => {
512
+ it('should convert MedicationRequest with prescribed intent', () => {
513
+ const result = convertMedicationStatement(sampleMedicationRequest);
514
+ expect(result.cascadeType).toBe('health:MedicationRecord');
515
+
516
+ const quads = result._quads;
517
+ expect(findQuadValue(quads, NS.health + 'medicationName')).toBe('Lisinopril 10mg');
518
+ expect(findQuadValue(quads, NS.clinical + 'sourceFhirResourceType')).toBe('MedicationRequest');
519
+ expect(findQuadValue(quads, NS.clinical + 'clinicalIntent')).toBe('prescribed');
520
+ });
521
+ });
522
+
523
+ describe('Immunization -> ImmunizationRecord', () => {
524
+ it('should convert Immunization to health:ImmunizationRecord', () => {
525
+ const result = convertImmunization(sampleImmunization);
526
+ expect(result.cascadeType).toBe('health:ImmunizationRecord');
527
+
528
+ const quads = result._quads;
529
+ expect(findQuadValue(quads, NS.health + 'vaccineName')).toBe('COVID-19 mRNA Vaccine');
530
+ expect(findQuadValue(quads, NS.health + 'status')).toBe('completed');
531
+ expect(findQuadValue(quads, NS.health + 'vaccineCode')).toBe('CVX-208');
532
+ expect(findQuadValue(quads, NS.health + 'manufacturer')).toBe('Pfizer');
533
+ expect(findQuadValue(quads, NS.health + 'lotNumber')).toBe('EL9264');
534
+ expect(findQuadValue(quads, NS.health + 'administeringProvider')).toBe('Dr. Smith');
535
+ expect(findQuadValue(quads, NS.health + 'administeringLocation')).toBe('City Health Clinic');
536
+ });
537
+
538
+ it('should include notes', () => {
539
+ const result = convertImmunization(sampleImmunization);
540
+ expect(findQuadValue(result._quads, NS.health + 'notes')).toBe('First dose');
541
+ });
542
+
543
+ it('should warn for occurrenceString', () => {
544
+ const imm = {
545
+ resourceType: 'Immunization',
546
+ vaccineCode: { text: 'Flu Shot' },
547
+ occurrenceString: 'sometime in 2020',
548
+ };
549
+ const result = convertImmunization(imm);
550
+ expect(result.warnings.some(w => w.includes('string'))).toBe(true);
551
+ });
552
+ });
553
+
554
+ describe('Coverage -> InsurancePlan', () => {
555
+ it('should convert Coverage to coverage:InsurancePlan', () => {
556
+ const result = convertCoverage(sampleCoverage);
557
+ expect(result.cascadeType).toBe('coverage:InsurancePlan');
558
+
559
+ const quads = result._quads;
560
+ expect(findQuadValue(quads, NS.coverage + 'providerName')).toBe('Blue Cross Blue Shield');
561
+ expect(findQuadValue(quads, NS.coverage + 'memberId')).toBe('XYZ123456');
562
+ expect(findQuadValue(quads, NS.coverage + 'subscriberId')).toBe('XYZ123456');
563
+ expect(findQuadValue(quads, NS.coverage + 'coverageType')).toBe('HIP');
564
+ expect(findQuadValue(quads, NS.coverage + 'groupNumber')).toBe('GRP-001');
565
+ expect(findQuadValue(quads, NS.coverage + 'planName')).toBe('Premium Plan');
566
+ expect(findQuadValue(quads, NS.coverage + 'rxBin')).toBe('015432');
567
+ expect(findQuadValue(quads, NS.coverage + 'rxPcn')).toBe('PCN99');
568
+ expect(findQuadValue(quads, NS.coverage + 'rxGroup')).toBe('RXGRP01');
569
+ expect(findQuadValue(quads, NS.coverage + 'subscriberRelationship')).toBe('self');
570
+ });
571
+
572
+ it('should warn when payor is missing', () => {
573
+ const cov = { resourceType: 'Coverage' };
574
+ const result = convertCoverage(cov);
575
+ expect(result.warnings).toContain('No payor information found in Coverage resource');
576
+ expect(findQuadValue(result._quads, NS.coverage + 'providerName')).toBe('Unknown Insurance');
577
+ });
578
+
579
+ it('should fall back to identifier when subscriberId is missing', () => {
580
+ const cov = {
581
+ resourceType: 'Coverage',
582
+ identifier: [{ value: 'ID-999' }],
583
+ };
584
+ const result = convertCoverage(cov);
585
+ expect(findQuadValue(result._quads, NS.coverage + 'memberId')).toBe('ID-999');
586
+ });
587
+ });
588
+ });
589
+
590
+ // =============================================================================
591
+ // Tests: Cascade -> FHIR (reverse conversion)
592
+ // =============================================================================
593
+
594
+ describe('Cascade -> FHIR converters', () => {
595
+ describe('MedicationRecord -> MedicationStatement', () => {
596
+ it('should round-trip MedicationStatement', async () => {
597
+ const fhirResult = await convertFhirToCascade(sampleMedicationStatement);
598
+ expect(fhirResult.turtle).toBeTruthy();
599
+
600
+ const reverseResult = await convertCascadeToFhir(fhirResult.turtle);
601
+ expect(reverseResult.resources).toHaveLength(1);
602
+
603
+ const fhir = reverseResult.resources[0];
604
+ expect(fhir.resourceType).toBe('MedicationStatement');
605
+ expect(fhir.medicationCodeableConcept.text).toBe('Metformin 500mg');
606
+ expect(fhir.status).toBe('active');
607
+ expect(fhir.id).toBe('med-1');
608
+ });
609
+ });
610
+
611
+ describe('ConditionRecord -> Condition', () => {
612
+ it('should round-trip Condition', async () => {
613
+ const fhirResult = await convertFhirToCascade(sampleCondition);
614
+ const reverseResult = await convertCascadeToFhir(fhirResult.turtle);
615
+
616
+ expect(reverseResult.resources).toHaveLength(1);
617
+ const fhir = reverseResult.resources[0];
618
+ expect(fhir.resourceType).toBe('Condition');
619
+ expect(fhir.code.text).toBe('Diabetes Mellitus');
620
+ expect(fhir.clinicalStatus.coding[0].code).toBe('active');
621
+ expect(fhir.id).toBe('condition-1');
622
+ });
623
+ });
624
+
625
+ describe('AllergyRecord -> AllergyIntolerance', () => {
626
+ it('should round-trip AllergyIntolerance', async () => {
627
+ const fhirResult = await convertFhirToCascade(sampleAllergy);
628
+ const reverseResult = await convertCascadeToFhir(fhirResult.turtle);
629
+
630
+ expect(reverseResult.resources).toHaveLength(1);
631
+ const fhir = reverseResult.resources[0];
632
+ expect(fhir.resourceType).toBe('AllergyIntolerance');
633
+ expect(fhir.code.text).toBe('Penicillin');
634
+ expect(fhir.category).toEqual(['medication']);
635
+ expect(fhir.reaction[0].severity).toBe('moderate');
636
+ });
637
+ });
638
+
639
+ describe('LabResultRecord -> Observation', () => {
640
+ it('should round-trip lab Observation', async () => {
641
+ const fhirResult = await convertFhirToCascade(sampleLabObservation);
642
+ const reverseResult = await convertCascadeToFhir(fhirResult.turtle);
643
+
644
+ expect(reverseResult.resources).toHaveLength(1);
645
+ const fhir = reverseResult.resources[0];
646
+ expect(fhir.resourceType).toBe('Observation');
647
+ expect(fhir.code.text).toBe('Glucose');
648
+ expect(fhir.valueQuantity.value).toBe(105);
649
+ expect(fhir.valueQuantity.unit).toBe('mg/dL');
650
+ });
651
+ });
652
+
653
+ describe('VitalSign -> Observation', () => {
654
+ it('should round-trip vital sign Observation', async () => {
655
+ const fhirResult = await convertFhirToCascade(sampleVitalObservation);
656
+ const reverseResult = await convertCascadeToFhir(fhirResult.turtle);
657
+
658
+ expect(reverseResult.resources).toHaveLength(1);
659
+ const fhir = reverseResult.resources[0];
660
+ expect(fhir.resourceType).toBe('Observation');
661
+ expect(fhir.category[0].coding[0].code).toBe('vital-signs');
662
+ expect(fhir.valueQuantity.value).toBe(128);
663
+ expect(fhir.code.text).toBe('Systolic Blood Pressure');
664
+ });
665
+ });
666
+
667
+ describe('PatientProfile -> Patient', () => {
668
+ it('should round-trip Patient', async () => {
669
+ const fhirResult = await convertFhirToCascade(samplePatient);
670
+ const reverseResult = await convertCascadeToFhir(fhirResult.turtle);
671
+
672
+ expect(reverseResult.resources).toHaveLength(1);
673
+ const fhir = reverseResult.resources[0];
674
+ expect(fhir.resourceType).toBe('Patient');
675
+ expect(fhir.gender).toBe('female');
676
+ expect(fhir.id).toBe('patient-1');
677
+ });
678
+
679
+ it('should warn about Cascade-only fields', async () => {
680
+ const fhirResult = await convertFhirToCascade(samplePatient);
681
+ const reverseResult = await convertCascadeToFhir(fhirResult.turtle);
682
+
683
+ // computedAge and ageGroup have no FHIR equivalent
684
+ expect(reverseResult.warnings.some(w => w.includes('computedAge'))).toBe(true);
685
+ expect(reverseResult.warnings.some(w => w.includes('ageGroup'))).toBe(true);
686
+ });
687
+ });
688
+
689
+ describe('ImmunizationRecord -> Immunization', () => {
690
+ it('should round-trip Immunization', async () => {
691
+ const fhirResult = await convertFhirToCascade(sampleImmunization);
692
+ const reverseResult = await convertCascadeToFhir(fhirResult.turtle);
693
+
694
+ expect(reverseResult.resources).toHaveLength(1);
695
+ const fhir = reverseResult.resources[0];
696
+ expect(fhir.resourceType).toBe('Immunization');
697
+ expect(fhir.vaccineCode.text).toBe('COVID-19 mRNA Vaccine');
698
+ expect(fhir.status).toBe('completed');
699
+ expect(fhir.manufacturer.display).toBe('Pfizer');
700
+ expect(fhir.lotNumber).toBe('EL9264');
701
+ });
702
+ });
703
+
704
+ describe('InsurancePlan -> Coverage', () => {
705
+ it('should round-trip Coverage', async () => {
706
+ const fhirResult = await convertFhirToCascade(sampleCoverage);
707
+ const reverseResult = await convertCascadeToFhir(fhirResult.turtle);
708
+
709
+ expect(reverseResult.resources).toHaveLength(1);
710
+ const fhir = reverseResult.resources[0];
711
+ expect(fhir.resourceType).toBe('Coverage');
712
+ expect(fhir.payor[0].display).toBe('Blue Cross Blue Shield');
713
+ expect(fhir.subscriberId).toBe('XYZ123456');
714
+ });
715
+ });
716
+ });
717
+
718
+ // =============================================================================
719
+ // Tests: Batch conversion (FHIR Bundle)
720
+ // =============================================================================
721
+
722
+ describe('Batch conversion', () => {
723
+ it('should convert a FHIR Bundle with multiple resources', async () => {
724
+ const bundle = {
725
+ resourceType: 'Bundle',
726
+ type: 'collection',
727
+ entry: [
728
+ { resource: sampleCondition },
729
+ { resource: sampleAllergy },
730
+ { resource: sampleMedicationStatement },
731
+ ],
732
+ };
733
+
734
+ const result = await convert(JSON.stringify(bundle), 'fhir', 'turtle');
735
+ expect(result.success).toBe(true);
736
+ expect(result.resourceCount).toBe(3);
737
+ expect(result.output).toContain('MedicationRecord');
738
+ expect(result.output).toContain('ConditionRecord');
739
+ expect(result.output).toContain('AllergyRecord');
740
+ });
741
+
742
+ it('should convert a single resource (not Bundle)', async () => {
743
+ const result = await convert(JSON.stringify(sampleCondition), 'fhir', 'turtle');
744
+ expect(result.success).toBe(true);
745
+ expect(result.resourceCount).toBe(1);
746
+ expect(result.output).toContain('ConditionRecord');
747
+ });
748
+
749
+ it('should skip unsupported resource types in a Bundle', async () => {
750
+ const bundle = {
751
+ resourceType: 'Bundle',
752
+ type: 'collection',
753
+ entry: [
754
+ { resource: sampleCondition },
755
+ { resource: { resourceType: 'Practitioner', id: 'prac-1' } },
756
+ ],
757
+ };
758
+
759
+ const result = await convert(JSON.stringify(bundle), 'fhir', 'turtle');
760
+ expect(result.success).toBe(true);
761
+ expect(result.resourceCount).toBe(1);
762
+ expect(result.warnings.some(w => w.includes('Practitioner'))).toBe(true);
763
+ });
764
+
765
+ it('should convert to JSON-LD when requested', async () => {
766
+ const result = await convert(JSON.stringify(sampleCondition), 'fhir', 'jsonld', 'jsonld');
767
+ expect(result.success).toBe(true);
768
+ expect(result.output).toBeTruthy();
769
+ const parsed = JSON.parse(result.output);
770
+ expect(parsed['@context']).toBeDefined();
771
+ });
772
+
773
+ it('should convert Cascade Turtle back to FHIR Bundle', async () => {
774
+ // First convert FHIR Bundle to Cascade
775
+ const bundle = {
776
+ resourceType: 'Bundle',
777
+ type: 'collection',
778
+ entry: [
779
+ { resource: sampleCondition },
780
+ { resource: sampleAllergy },
781
+ ],
782
+ };
783
+ const cascadeResult = await convert(JSON.stringify(bundle), 'fhir', 'turtle');
784
+
785
+ // Then convert back to FHIR
786
+ const fhirResult = await convert(cascadeResult.output, 'cascade', 'fhir');
787
+ expect(fhirResult.success).toBe(true);
788
+ expect(fhirResult.resourceCount).toBe(2);
789
+ const parsed = JSON.parse(fhirResult.output);
790
+ expect(parsed.resourceType).toBe('Bundle');
791
+ expect(parsed.entry).toHaveLength(2);
792
+ });
793
+ });
794
+
795
+ // =============================================================================
796
+ // Tests: Edge cases
797
+ // =============================================================================
798
+
799
+ describe('Edge cases', () => {
800
+ it('should return null for unknown resource types', () => {
801
+ const result = convertFhirResourceToQuads({ resourceType: 'Practitioner' });
802
+ expect(result).toBeNull();
803
+ });
804
+
805
+ it('should return null for resources without resourceType', () => {
806
+ const result = convertFhirResourceToQuads({ id: 'something' });
807
+ expect(result).toBeNull();
808
+ });
809
+
810
+ it('should handle empty/null input gracefully via convertFhirToCascade', async () => {
811
+ const result = await convertFhirToCascade({ resourceType: 'Encounter' });
812
+ expect(result.turtle).toBe('');
813
+ expect(result.warnings.some(w => w.includes('Unsupported'))).toBe(true);
814
+ expect(result.cascadeType).toBe('unknown');
815
+ });
816
+
817
+ it('should handle invalid JSON in batch convert', async () => {
818
+ const result = await convert('not valid json', 'fhir', 'turtle');
819
+ expect(result.success).toBe(false);
820
+ expect(result.errors).toContain('Invalid JSON input');
821
+ });
822
+
823
+ it('should handle invalid Turtle in Cascade -> FHIR', async () => {
824
+ const result = await convertCascadeToFhir('@@@ not valid turtle @@@');
825
+ expect(result.resources).toHaveLength(0);
826
+ expect(result.warnings.some(w => w.includes('parse error'))).toBe(true);
827
+ });
828
+
829
+ it('should handle unknown Cascade RDF type', async () => {
830
+ const turtle = `
831
+ @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
832
+ @prefix custom: <http://example.org/custom#> .
833
+ <urn:uuid:test> rdf:type custom:UnknownType .
834
+ `;
835
+ const result = await convertCascadeToFhir(turtle);
836
+ expect(result.resources).toHaveLength(0);
837
+ expect(result.warnings.some(w => w.includes('Unknown Cascade RDF type'))).toBe(true);
838
+ });
839
+
840
+ it('should reject unsupported conversion direction', async () => {
841
+ const result = await convert('<xml/>', 'c-cda', 'turtle');
842
+ expect(result.success).toBe(false);
843
+ expect(result.errors.some(e => e.includes('C-CDA'))).toBe(true);
844
+ });
845
+
846
+ it('should handle Condition with missing optional fields', () => {
847
+ const minimal = {
848
+ resourceType: 'Condition',
849
+ code: { text: 'Unknown' },
850
+ };
851
+ const result = convertCondition(minimal);
852
+ expect(result.cascadeType).toBe('health:ConditionRecord');
853
+ expect(findQuadValue(result._quads, NS.health + 'conditionName')).toBe('Unknown');
854
+ // No onset, no codes, no notes -- should not throw
855
+ expect(result.warnings).toEqual([]);
856
+ });
857
+
858
+ it('should handle medication with stopped status', () => {
859
+ const stopped = {
860
+ resourceType: 'MedicationStatement',
861
+ status: 'stopped',
862
+ medicationCodeableConcept: { text: 'OldMed' },
863
+ };
864
+ const result = convertMedicationStatement(stopped);
865
+ expect(findQuadValue(result._quads, NS.health + 'isActive')).toBe('false');
866
+ });
867
+
868
+ it('should include common triples on every converted resource', () => {
869
+ const result = convertCondition({ resourceType: 'Condition', code: { text: 'Test' } });
870
+ const quads = result._quads;
871
+ expect(findQuadValue(quads, NS.cascade + 'dataProvenance')).toBe(NS.cascade + 'ClinicalGenerated');
872
+ expect(findQuadValue(quads, NS.cascade + 'schemaVersion')).toBe('1.3');
873
+ });
874
+ });