-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathtest_protocols.py
More file actions
593 lines (505 loc) · 25.2 KB
/
test_protocols.py
File metadata and controls
593 lines (505 loc) · 25.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
"""
Unit tests for protocol handlers (M4).
Reference: specs/011-madagascar-analyzer-integration, tasks T091–T095.
"""
import json
import os
import unittest
import tempfile
import threading
import http.client
from copy import deepcopy
from http.server import HTTPServer
from protocols.astm_handler import ASTMHandler
from protocols.hl7_handler import HL7Handler
from protocols.serial_handler import SerialHandler
from protocols.file_handler import FileHandler
from api import MockAPIHandler as SimulateAPIHandler
def _load_template(name: str):
# Use the production loader so these tests exercise the real template path:
# transport/fixtures from templates/<name>.json + assay fields derived from the
# canonical profile it references. (A raw json.load would bypass the profile
# derivation and test a stub.)
from server import _load_template as _load_production_template
return _load_production_template(name)
class TestASTMHandler(unittest.TestCase):
def test_generate_mindray_bc5380(self):
t = _load_template("mindray_bc5380")
msg = ASTMHandler().generate(t, patient_id="P001", sample_id="DEV01264000000000001")
self.assertIn("H|", msg)
self.assertIn("P|", msg)
self.assertIn("O|", msg)
self.assertIn("R|", msg)
self.assertIn("L|", msg)
self.assertIn("P001", msg)
self.assertIn("DEV01264000000000001", msg)
def test_generate_rejects_invalid_explicit_sample_id(self):
t = _load_template("mindray_bc5380")
with self.assertRaisesRegex(ValueError, "valid SiteYearNum accession"):
ASTMHandler().generate(t, sample_id="S001-BAD")
def test_generate_horiba_pentra60(self):
t = _load_template("horiba_pentra60")
msg = ASTMHandler().generate(t)
self.assertIn("H|", msg)
self.assertIn("PENTRA", msg.upper())
class TestASTMGeneXpert(unittest.TestCase):
"""Tests for GeneXpert ASTM template features (PR #13 review comments)."""
@classmethod
def setUpClass(cls):
cls.template = _load_template("genexpert_astm")
cls.handler = ASTMHandler()
def _generate(self, **kwargs):
return self.handler.generate(self.template, use_seed=True, **kwargs)
def _generate_with_qc(self, **kwargs):
# Template sets enable_qc=false for the Madagascar harness demo flow.
# QC generation code path is still covered here via a per-test override.
t = deepcopy(self.template)
t.setdefault("astm_config", {})["enable_qc"] = True
return self.handler.generate(t, use_seed=True, **kwargs)
def _segments(self, msg, prefix):
return [l for l in msg.strip().split("\n") if l.startswith(prefix)]
# --- H-record ---
def test_h_record_has_message_id(self):
msg = self._generate()
h = self._segments(msg, "H")[0]
fields = h.split("|")
# H.3 should be a non-empty message ID (MSG-...)
self.assertTrue(fields[2].startswith("MSG-"), f"H.3 should be MSG-..., got: {fields[2]}")
def test_h_record_receiver_id(self):
msg = self._generate()
h = self._segments(msg, "H")[0]
fields = h.split("|")
# H.10 = Receiver ID = "LIS"
self.assertEqual(fields[9], "LIS")
def test_h_record_processing_id(self):
msg = self._generate()
h = self._segments(msg, "H")[0]
fields = h.split("|")
# H.12 = Processing ID = "P"
self.assertEqual(fields[11], "P")
def test_h_record_version_number(self):
msg = self._generate()
h = self._segments(msg, "H")[0]
fields = h.split("|")
# H.13 = Version = "1394-97" (not "LIS2-A2")
self.assertEqual(fields[12], "1394-97")
def test_h_record_analyzer_name(self):
msg = self._generate()
h = self._segments(msg, "H")[0]
self.assertIn("GENEXPERT^GeneXpert^4.6.0", h)
# --- O-record ---
def test_o_record_action_code_empty_for_patient(self):
msg = self._generate()
o = self._segments(msg, "O")[0]
fields = o.split("|")
# O.12 (index 11) should be empty for patient samples
self.assertEqual(fields[11], "")
def test_o_record_specimen_descriptor(self):
msg = self._generate()
o = self._segments(msg, "O")[0]
fields = o.split("|")
# O.16 (index 15) = "ORH"
self.assertEqual(fields[15], "ORH")
def test_o_record_report_type(self):
msg = self._generate()
o = self._segments(msg, "O")[0]
fields = o.split("|")
# O.26 (index 25) = "F"
self.assertEqual(fields[25], "F")
def test_o_record_universal_test_id(self):
msg = self._generate()
o = self._segments(msg, "O")[0]
fields = o.split("|")
# O.5 (index 4) = ^^^<first ordered assay code> (assays come from the profile)
first_code = self.template["fields"][0]["code"]
self.assertEqual(fields[4], f"^^^{first_code}")
def test_o_record_has_26_fields(self):
msg = self._generate()
o = self._segments(msg, "O")[0]
fields = o.split("|")
self.assertEqual(len(fields), 26)
def test_default_template_generates_valid_siteyearnum_accession(self):
msg = self._generate()
o = self._segments(msg, "O")[0]
sample_id = o.split("|")[2].split("^")[0]
self.assertRegex(sample_id, r"^DEV01\d{15}$")
self.assertEqual(len(sample_id), 20)
def test_invalid_template_sample_seed_fails_loudly(self):
invalid = deepcopy(self.template)
invalid["testSample"]["id"] = "SPECIMEN-GX-001"
with self.assertRaisesRegex(ValueError, "2-digit lane code"):
self.handler.generate(invalid, use_seed=True)
# --- R-record: 7-component test ID ---
def test_r_record_8_component_test_id(self):
msg = self._generate()
r_lines = self._segments(msg, "R")
# The MTB-RIF R-record carries the 8-component GeneXpert test ID (it declares
# a cartridge version via fieldOverrides). Find it among the derived assays.
mtb_rif = [l for l in r_lines if l.split("|")[2].split("^")[3:4] == ["MTB-RIF"]]
self.assertTrue(mtb_rif, "expected an MTB-RIF R-record")
r_fields = mtb_rif[0].split("|")
test_id = r_fields[2]
components = test_id.split("^")
# ^^^MTB-RIF^Xpert MTB/RIF^2.1^^ = 8 components
# (3 empty + code + name + version + analyte(empty) + complementary(empty))
self.assertEqual(len(components), 8, f"Expected 8 components, got {len(components)}: {components}")
self.assertEqual(components[3], "MTB-RIF")
self.assertEqual(components[4], "Xpert MTB/RIF")
self.assertEqual(components[5], "2.1")
self.assertEqual(components[6], "") # analyte name (empty for main result)
self.assertEqual(components[7], "") # complementary name (empty for main result)
# --- R-record: complementary results (8 components) ---
def test_complementary_result_8_component_test_id(self):
msg = self._generate()
r_lines = self._segments(msg, "R")
# Find the Conc/LOG complementary result (HIV-VL has one)
comp_lines = [r for r in r_lines if "Conc/LOG" in r]
self.assertTrue(len(comp_lines) > 0, "Should have a Conc/LOG complementary result")
r_fields = comp_lines[0].split("|")
test_id = r_fields[2]
components = test_id.split("^")
# ^^^HIV-VL^...^...^^Conc/LOG = 8 components
self.assertEqual(len(components), 8, f"Expected 8 components, got {len(components)}: {components}")
self.assertEqual(components[7], "Conc/LOG")
def test_complementary_result_seed_value(self):
msg = self._generate()
r_lines = self._segments(msg, "R")
comp_lines = [r for r in r_lines if "Conc/LOG" in r]
self.assertTrue(len(comp_lines) > 0)
r_fields = comp_lines[0].split("|")
# Value field (R.4) should contain ^3.1 (seed value)
self.assertIn("3.1", r_fields[3])
# --- R-record: qualitative values ---
def test_qualitative_seed_value(self):
# DATA test: depends on the canonical GeneXpert profile declaring its assays
# qualitative (result_type). The mock reads projects/analyzer-profiles from
# its OE2 parent (monorepo layout) — single source of truth. When that
# profile predates the result_type enrichment (e.g. a standalone CI checkout
# of an older OE2 ref), there's nothing qualitative to assert, so skip rather
# than false-fail on un-merged cross-repo state. The adapter's qualitative
# LOGIC is gated hermetically by TestProfileAdapterQualitativeLogic below, and
# the real round-trip value is gated by the integrated (OE2-monorepo) harness E2E.
first_field = self.template["fields"][0]
if first_field.get("type") != "QUALITATIVE":
self.skipTest(
"GeneXpert profile not enriched with result_type (first assay "
f"'{first_field.get('code')}' is {first_field.get('type')}); qualitative "
"value is gated by the integrated monorepo CI against PR-version profiles"
)
msg = self._generate()
r_lines = self._segments(msg, "R")
# First R-record seed should be "NOT DETECTED" — aligns with Cepheid LIS
# spec 302-2261 vocabulary.
r_fields = r_lines[0].split("|")
self.assertIn("NOT DETECTED", r_fields[3])
# --- QC message generation ---
# Template's runtime astm_config.enable_qc is false for the harness demo
# flow (interleaved QC trips up parts of the demo harness). These tests
# still cover the QC generation code path by overriding enable_qc=true
# on a per-test copy of the template.
def test_qc_message_generated(self):
msg = self._generate_with_qc()
h_records = self._segments(msg, "H")
# Should have 2 H-records: one for patient, one for QC
self.assertEqual(len(h_records), 2, "Should generate patient + QC messages")
def test_qc_action_code(self):
msg = self._generate_with_qc()
o_records = self._segments(msg, "O")
# Second O-record (QC) should have Action Code "Q" at O.12
self.assertTrue(len(o_records) >= 2, "Should have at least 2 O-records")
qc_o_fields = o_records[1].split("|")
self.assertEqual(qc_o_fields[11], "Q")
def test_qc_specimen_id(self):
msg = self._generate_with_qc()
o_records = self._segments(msg, "O")
qc_o_fields = o_records[1].split("|")
# O.3 should contain QC specimen ID
self.assertIn("DEV01261000000000999", qc_o_fields[2])
def test_qc_includes_all_template_fields(self):
msg = self._generate_with_qc()
# Split into patient msg and QC msg (second H starts QC)
lines = msg.strip().split("\n")
h_indices = [i for i, l in enumerate(lines) if l.startswith("H")]
self.assertEqual(len(h_indices), 2)
qc_lines = lines[h_indices[1]:]
qc_r_lines = [l for l in qc_lines if l.startswith("R")]
# Template has 4 fields + 1 complementary = 5 R-records minimum
self.assertGreaterEqual(len(qc_r_lines), 4,
f"QC should include all template fields, got {len(qc_r_lines)} R-records")
def test_enable_qc_disabled_by_default_for_harness(self):
"""Template must ship with enable_qc=false so harness demo flow
doesn't get interleaved QC messages it can't route."""
self.assertFalse(
self.template.get("astm_config", {}).get("enable_qc", False),
"genexpert_astm.json must keep enable_qc=false for the Madagascar harness demo flow",
)
# --- proactive_enq config ---
def test_proactive_enq_config_present(self):
"""GeneXpert template should have proactive_enq enabled."""
self.assertTrue(
self.template.get('astm_config', {}).get('proactive_enq'),
"GeneXpert template should have proactive_enq: true in astm_config"
)
def test_proactive_enq_not_in_other_templates(self):
"""Non-GeneXpert templates should NOT have proactive_enq."""
mindray = _load_template("mindray_bc5380")
self.assertFalse(
mindray.get('astm_config', {}).get('proactive_enq'),
"Mindray template should not have proactive_enq"
)
def test_generate_qc_controls_emits_valid_accession_and_o12_q(self):
"""qc_controls path must emit SiteYearNum on O.3 and O.12=Q (GenericASTM index 11)."""
t = _load_template("genexpert_astm")
msg = ASTMHandler().generate_qc(t)
lines = [ln.strip() for ln in msg.strip().split("\n") if ln.startswith("O|")]
self.assertTrue(lines, "expected at least one O-record")
o_fields = lines[0].split("|")
self.assertGreaterEqual(len(o_fields), 12, "O-record must pad to O.12 for QC action code")
self.assertEqual(o_fields[11], "Q", "O.12 must be Q for QC (0-based index 11)")
acc = o_fields[2].split("^")[0]
self.assertRegex(acc, r"^DEV01\d{15}$")
self.assertEqual(len(acc), 20)
# --- use_seed determinism ---
def test_seed_produces_deterministic_output(self):
msg1 = self._generate()
msg2 = self._generate()
# With use_seed=True, qualitative values should be identical
r1 = [l for l in msg1.strip().split("\n") if l.startswith("R")]
r2 = [l for l in msg2.strip().split("\n") if l.startswith("R")]
# Compare R.4 (value field) for qualitative results
for line1, line2 in zip(r1, r2):
val1 = line1.split("|")[3]
val2 = line2.split("|")[3]
self.assertEqual(val1, val2, f"Seed values should be deterministic: {val1} != {val2}")
class TestHL7Handler(unittest.TestCase):
def test_generate_mindray_bc5380(self):
t = _load_template("mindray_bc5380")
msg = HL7Handler().generate(t, patient_id="P001", sample_id="DEV01264000000000001")
self.assertIn("MSH|", msg)
self.assertIn("ORU^R01", msg)
self.assertIn("PID|", msg)
self.assertIn("OBR|", msg)
self.assertIn("OBX|", msg)
self.assertIn("MINDRAY", msg)
self.assertIn("P001", msg)
self.assertIn("DEV01264000000000001", msg)
def test_invalid_hl7_template_sample_seed_fails_loudly(self):
t = _load_template("mindray_bc5380")
invalid = deepcopy(t)
invalid["testSample"]["id"] = "PLACER-INVALID"
with self.assertRaisesRegex(ValueError, "2-digit lane code"):
HL7Handler().generate(invalid)
def test_invalid_hl7_sample_override_fails_loudly(self):
t = _load_template("mindray_bc5380")
with self.assertRaisesRegex(ValueError, "valid SiteYearNum accession"):
HL7Handler().generate(t, sample_id="S001")
def test_hl7_mints_valid_accession_when_test_sample_missing(self):
"""If testSample is absent, default lane 00 must produce a valid SiteYearNum."""
minimal = {
"protocol": {"type": "HL7", "version": "2.5.1"},
"identification": {"hl7_sending_app": "TEST", "hl7_sending_facility": "LAB"},
"fields": [{"code": "GLU", "name": "Glucose", "type": "NUMERIC", "seedValue": 5.0, "unit": "mmol/L"}],
}
msg = HL7Handler().generate(minimal)
self.assertIn("ORC|", msg)
self.assertIn("OBR|", msg)
for line in msg.split("\r"):
if line.startswith("OBR|"):
obr = line.split("|")
filler = obr[3].split("^")[0] if len(obr) > 3 else ""
self.assertRegex(filler, r"^DEV01\d{15}$")
self.assertEqual(len(filler), 20)
break
else:
self.fail("expected OBR segment")
def test_generate_sysmex_xn(self):
t = _load_template("sysmex_xn")
msg = HL7Handler().generate(t)
self.assertIn("MSH|", msg)
self.assertIn("SYSMEX", msg)
def test_generate_genexpert_hl7_template(self):
t = _load_template("genexpert")
msg = HL7Handler().generate(t)
self.assertIn("GENEXPERT", msg)
self.assertRegex(msg, r"DEV01\d{15}")
class TestSerialHandler(unittest.TestCase):
def test_generate_horiba_pentra60(self):
t = _load_template("horiba_pentra60")
msg = SerialHandler().generate(t)
self.assertIn("H|", msg)
self.assertIn("PENTRA", msg.upper())
class TestFileHandler(unittest.TestCase):
def test_generate_quantstudio7(self):
t = _load_template("quantstudio7")
csv = FileHandler().generate(t, sample_id="DEV01262000000000001")
self.assertIn("Sample Name", csv)
self.assertIn("Target Name", csv)
self.assertIn("Quantity Mean", csv)
self.assertIn("DEV01262000000000001", csv)
def test_generate_hain_fluorocycler(self):
t = _load_template("hain_fluorocycler")
csv = FileHandler().generate(t)
self.assertIn("SampleID", csv)
self.assertIn("TargetName", csv)
self.assertIn("CP", csv)
self.assertRegex(csv, r"DEV01\d{15}")
def test_invalid_file_sample_override_fails_loudly(self):
t = _load_template("quantstudio7")
with self.assertRaisesRegex(ValueError, "valid SiteYearNum accession"):
FileHandler().generate(t, sample_id="S001")
class TestFileSimulateAPI(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.httpd = HTTPServer(("127.0.0.1", 0), SimulateAPIHandler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
@classmethod
def tearDownClass(cls):
cls.httpd.shutdown()
cls.httpd.server_close()
cls.thread.join(timeout=10)
def test_get_simulate_file_quantstudio7(self):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
conn.request("GET", "/simulate/file/quantstudio7")
resp = conn.getresponse()
body = json.loads(resp.read().decode("utf-8"))
conn.close()
self.assertEqual(resp.status, 200)
self.assertEqual(body.get("status"), "generated")
# Fixture-backed templates return metadata; synthetic return content
if "metadata" in body:
self.assertIn("results", body["metadata"])
self.assertGreater(len(body["metadata"]["results"]), 0)
else:
self.assertIn("Sample Name", body.get("content", ""))
# ------------------------------------------------------------------
# Fixture drift guards: every FILE template whose fixture we own must
# still parse to non-empty metadata results. Catches mismatches between
# fixture column headers / delimiter / skipRows and the template's
# fixture.column_mapping — the exact class of bug that broke the
# Madagascar harness demo flow in CI.
# ------------------------------------------------------------------
def _assert_fixture_parses(self, template_name: str):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
conn.request("GET", f"/simulate/file/{template_name}")
resp = conn.getresponse()
body = json.loads(resp.read().decode("utf-8"))
conn.close()
self.assertEqual(resp.status, 200, f"{template_name}: HTTP {resp.status}")
self.assertEqual(body.get("status"), "generated")
self.assertIn("metadata", body, f"{template_name}: no metadata in response")
results = body["metadata"].get("results") or []
self.assertGreater(
len(results), 0,
f"{template_name}: parse_fixture returned 0 results — fixture/profile drift",
)
# Every returned result must carry sampleId + result at minimum
for r in results:
self.assertIn("sampleId", r, f"{template_name}: result missing sampleId: {r}")
self.assertIn("result", r, f"{template_name}: result missing value: {r}")
def test_fixture_parses_hain_fluorocycler(self):
self._assert_fixture_parses("hain_fluorocycler")
def test_fixture_parses_wondfo_finecare(self):
self._assert_fixture_parses("wondfo_finecare")
def test_fixture_parses_tecan_f50(self):
self._assert_fixture_parses("tecan_f50")
def test_fixture_parses_multiskan_fc(self):
self._assert_fixture_parses("multiskan_fc")
def test_post_simulate_file_write_target_dir(self):
with tempfile.TemporaryDirectory() as tmpdir:
payload = json.dumps({"target_dir": tmpdir, "filename": "sim.csv"})
headers = {"Content-Type": "application/json"}
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
conn.request("POST", "/simulate/file/quantstudio7", body=payload, headers=headers)
resp = conn.getresponse()
body = json.loads(resp.read().decode("utf-8"))
conn.close()
self.assertEqual(resp.status, 200)
self.assertEqual(body.get("status"), "completed")
written_path = body.get("written_path")
self.assertTrue(written_path)
self.assertTrue(os.path.exists(written_path))
def test_post_simulate_file_sanitizes_path_traversal_filename(self):
"""Path traversal in filename is stripped to basename (safe write)."""
with tempfile.TemporaryDirectory() as tmpdir:
payload = json.dumps({"target_dir": tmpdir, "filename": "../evil.csv"})
headers = {"Content-Type": "application/json"}
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
conn.request("POST", "/simulate/file/quantstudio7", body=payload, headers=headers)
resp = conn.getresponse()
body = json.loads(resp.read())
conn.close()
# Server strips path components — writes "evil.csv" in target_dir (safe)
self.assertEqual(resp.status, 200)
self.assertTrue(os.path.exists(os.path.join(tmpdir, "evil.csv")))
# Verify no file escaped to parent
self.assertFalse(os.path.exists(os.path.join(os.path.dirname(tmpdir), "evil.csv")))
def test_post_simulate_file_rejects_invalid_json_body(self):
headers = {"Content-Type": "application/json"}
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
conn.request("POST", "/simulate/file/quantstudio7", body="not json", headers=headers)
resp = conn.getresponse()
resp.read()
conn.close()
self.assertGreaterEqual(resp.status, 400)
self.assertLess(resp.status, 500)
def test_simulate_file_rejects_path_traversal_template(self):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
conn.request("GET", "/simulate/file/../../etc/passwd")
resp = conn.getresponse()
resp.read()
conn.close()
self.assertGreaterEqual(resp.status, 400)
self.assertLess(resp.status, 500)
class TestProfileAdapterQualitativeLogic(unittest.TestCase):
"""Hermetic gate for the adapter's result_type→seed mapping.
Pins the LOGIC against a SYNTHETIC profile (built in a temp dir and pointed at
via ANALYZER_PROFILES_DIR) with no dependency on the canonical analyzer-profiles
tree. This is the mock-OWNED unit gate that runs in standalone CI regardless of
which OE2 ref is present; the REAL GeneXpert profile values are gated by the
integrated (OE2-monorepo) harness E2E. Keeps qualitative coverage even when
test_qualitative_seed_value skips on an un-enriched profile checkout.
"""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
astm_dir = os.path.join(self._tmp.name, "astm")
os.makedirs(astm_dir)
with open(os.path.join(astm_dir, "synth.json"), "w") as fh:
json.dump(
{
"default_test_mappings": [
{
"test_code": "QUAL1",
"loinc": "111-1",
"result_type": "qualitative",
"values": ["DETECTED", "NOT DETECTED"],
},
{"test_code": "QUANT1", "loinc": "222-2", "result_type": "quantitative"},
]
},
fh,
)
self._prev = os.environ.get("ANALYZER_PROFILES_DIR")
os.environ["ANALYZER_PROFILES_DIR"] = self._tmp.name
self.addCleanup(self._restore_env)
def _restore_env(self):
if self._prev is None:
os.environ.pop("ANALYZER_PROFILES_DIR", None)
else:
os.environ["ANALYZER_PROFILES_DIR"] = self._prev
def test_qualitative_seeds_negative_vocab_quantitative_seeds_zero(self):
from profile_adapter import load_profile_backed_template
merged = load_profile_backed_template(
"synth", {"profile": "astm/synth", "protocol": {"type": "ASTM"}}
)
fields = {f["code"]: f for f in merged["fields"]}
# qualitative result_type -> QUALITATIVE field seeded to the negative vocab
self.assertEqual(fields["QUAL1"]["type"], "QUALITATIVE")
self.assertEqual(fields["QUAL1"]["seedQualitative"], "NOT DETECTED")
# quantitative -> NUMERIC field, deterministic 0 seed
self.assertEqual(fields["QUANT1"]["type"], "NUMERIC")
self.assertEqual(fields["QUANT1"]["seedValue"], 0)
if __name__ == "__main__":
unittest.main()