PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
test_multimodal_workflow.cpp
Go to the documentation of this file.
1
18#include "test_fixtures.h"
19
20#include <catch2/catch_test_macros.hpp>
21#include <catch2/matchers/catch_matchers_string.hpp>
22
33
34#include <atomic>
35#include <future>
36#include <memory>
37#include <mutex>
38#include <set>
39#include <thread>
40#include <vector>
41
42using namespace kcenon::pacs::integration_test;
43using namespace kcenon::pacs::network;
44using namespace kcenon::pacs::network::dimse;
45using namespace kcenon::pacs::services;
46using namespace kcenon::pacs::storage;
47using namespace kcenon::pacs::core;
48using namespace kcenon::pacs::encoding;
49
50namespace {
51
53
54// =============================================================================
55// Workflow Verification Helper
56// =============================================================================
57
64class workflow_verification {
65public:
66 explicit workflow_verification(index_database& db) : db_(db) {}
67
73 bool verify_patient_exists(const std::string& patient_id) {
74 auto patient = db_.find_patient(patient_id);
75 return patient.has_value();
76 }
77
84 bool verify_study_count(const std::string& patient_id, size_t expected) {
85 auto studies_result = db_.list_studies(patient_id);
86 if (studies_result.is_err()) return false;
87 return studies_result.value().size() == expected;
88 }
89
96 bool verify_modalities_in_study(
97 const std::string& study_uid,
98 const std::vector<std::string>& expected_modalities) {
99
100 auto series_list_result = db_.list_series(study_uid);
101 if (series_list_result.is_err()) return false;
102
103 std::set<std::string> found_modalities;
104 for (const auto& series : series_list_result.value()) {
105 if (!series.modality.empty()) {
106 found_modalities.insert(series.modality);
107 }
108 }
109
110 for (const auto& mod : expected_modalities) {
111 if (found_modalities.find(mod) == found_modalities.end()) {
112 return false;
113 }
114 }
115 return true;
116 }
117
124 bool verify_series_count(const std::string& study_uid, size_t expected) {
125 auto count_result = db_.series_count(study_uid);
126 if (count_result.is_err()) return false;
127 return count_result.value() == expected;
128 }
129
136 bool verify_image_count(const std::string& series_uid, size_t expected) {
137 auto count_result = db_.instance_count(series_uid);
138 if (count_result.is_err()) return false;
139 return count_result.value() == expected;
140 }
141
147 bool verify_unique_uids(const std::string& study_uid) {
148 auto series_list_result = db_.list_series(study_uid);
149 if (series_list_result.is_err()) return false;
150
151 std::set<std::string> uids;
152 for (const auto& series : series_list_result.value()) {
153 auto instances_result = db_.list_instances(series.series_uid);
154 if (instances_result.is_err()) return false;
155
156 for (const auto& instance : instances_result.value()) {
157 if (uids.find(instance.sop_uid) != uids.end()) {
158 return false; // Duplicate found
159 }
160 uids.insert(instance.sop_uid);
161 }
162 }
163 return true;
164 }
165
171 size_t get_instance_count(const std::string& study_uid) {
172 size_t count = 0;
173 auto series_list_result = db_.list_series(study_uid);
174 if (series_list_result.is_err()) return 0;
175
176 for (const auto& series : series_list_result.value()) {
177 auto count_result = db_.instance_count(series.series_uid);
178 if (count_result.is_ok()) {
179 count += count_result.value();
180 }
181 }
182 return count;
183 }
184
185private:
186 index_database& db_;
187};
188
189// =============================================================================
190// Multi-Modal PACS Server
191// =============================================================================
192
202class multimodal_pacs_server {
203public:
204 explicit multimodal_pacs_server(uint16_t port, const std::string& ae_title = "MM_PACS")
205 : port_(port)
206 , ae_title_(ae_title)
207 , test_dir_("multimodal_pacs_test_")
208 , storage_dir_(test_dir_.path() / "archive")
209 , db_path_(test_dir_.path() / "index.db") {
210
211 std::filesystem::create_directories(storage_dir_);
212
213 server_config config;
214 config.ae_title = ae_title_;
215 config.port = port_;
216 config.max_associations = 50; // Support concurrent modalities
217 config.idle_timeout = std::chrono::seconds{120};
218 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.138";
219 config.implementation_version_name = "MM_PACS";
220
221 server_ = std::make_unique<dicom_server>(config);
222
223 file_storage_config fs_config;
224 fs_config.root_path = storage_dir_;
225 file_storage_ = std::make_unique<file_storage>(fs_config);
226
227 auto db_result = index_database::open(db_path_.string());
228 if (db_result.is_err()) {
229 throw std::runtime_error("Failed to open database: " + db_result.error().message);
230 }
231 database_ = std::move(db_result.value());
232 }
233
234 bool initialize() {
235 // Verification SCP
236 server_->register_service(std::make_shared<verification_scp>());
237
238 // Storage SCP
239 auto storage_scp_ptr = std::make_shared<storage_scp>();
240 storage_scp_ptr->set_handler([this](
241 const dicom_dataset& dataset,
242 const std::string& calling_ae,
243 const std::string& sop_class_uid,
244 const std::string& sop_instance_uid) -> storage_status {
245
246 return handle_store(dataset, calling_ae, sop_class_uid, sop_instance_uid);
247 });
248 server_->register_service(storage_scp_ptr);
249
250 // Query SCP
251 auto query_scp_ptr = std::make_shared<query_scp>();
252 query_scp_ptr->set_handler([this](
253 query_level level,
254 const dicom_dataset& query_keys,
255 const std::string& /* calling_ae */) -> std::vector<dicom_dataset> {
256
257 return handle_query(level, query_keys);
258 });
259 server_->register_service(query_scp_ptr);
260
261 // Worklist SCP
262 auto worklist_scp_ptr = std::make_shared<worklist_scp>();
263 worklist_scp_ptr->set_handler([this](
264 const dicom_dataset& query,
265 const std::string& /* calling_ae */) -> std::vector<dicom_dataset> {
266
267 return query_worklist(query);
268 });
269 server_->register_service(worklist_scp_ptr);
270
271 // MPPS SCP
272 auto mpps_scp_ptr = std::make_shared<mpps_scp>();
273 mpps_scp_ptr->set_create_handler([this](const mpps_instance& instance)
275 return create_mpps(instance);
276 });
277 mpps_scp_ptr->set_set_handler([this](
278 const std::string& uid,
279 const dicom_dataset& modifications,
282 return update_mpps(uid, modifications, status);
283 });
284 server_->register_service(mpps_scp_ptr);
285
286 return true;
287 }
288
289 bool start() {
290 auto result = server_->start();
291 if (result.is_ok()) {
292 std::this_thread::sleep_for(std::chrono::milliseconds{100});
293 return true;
294 }
295 return false;
296 }
297
298 void stop() {
299 server_->stop();
300 }
301
302 void add_worklist_item(const dicom_dataset& item) {
303 std::lock_guard<std::mutex> lock(mutex_);
304 worklist_items_.push_back(item);
305 }
306
307 std::optional<mpps_instance> get_mpps(const std::string& uid) const {
308 std::lock_guard<std::mutex> lock(mutex_);
309 for (const auto& mpps : mpps_instances_) {
310 if (mpps.sop_instance_uid == uid) {
311 return mpps;
312 }
313 }
314 return std::nullopt;
315 }
316
317 size_t mpps_count() const {
318 std::lock_guard<std::mutex> lock(mutex_);
319 return mpps_instances_.size();
320 }
321
322 size_t stored_count() const { return stored_count_.load(); }
323 size_t error_count() const { return error_count_.load(); }
324
325 uint16_t port() const { return port_; }
326 const std::string& ae_title() const { return ae_title_; }
327
328 index_database& database() { return *database_; }
329
330 workflow_verification get_verifier() {
331 return workflow_verification(*database_);
332 }
333
334private:
335 storage_status handle_store(
336 const dicom_dataset& dataset,
337 const std::string& /* calling_ae */,
338 const std::string& /* sop_class_uid */,
339 const std::string& /* sop_instance_uid */) {
340
341 // Store to filesystem
342 auto store_result = file_storage_->store(dataset);
343 if (store_result.is_err()) {
344 ++error_count_;
345 return storage_status::storage_error;
346 }
347
348 // Index in database (following test_store_query.cpp pattern)
349 // 1. Patient
350 auto pat_id = dataset.get_string(tags::patient_id);
351 auto pat_name = dataset.get_string(tags::patient_name);
352 auto pat_birth = dataset.get_string(tags::patient_birth_date);
353 auto pat_sex = dataset.get_string(tags::patient_sex);
354
355 auto pat_res = database_->upsert_patient(pat_id, pat_name, pat_birth, pat_sex);
356 if (pat_res.is_err()) {
357 ++error_count_;
358 return storage_status::storage_error;
359 }
360 auto pat_pk = pat_res.value();
361
362 // 2. Study
363 auto study_uid = dataset.get_string(tags::study_instance_uid);
364 auto study_res = database_->upsert_study(pat_pk, study_uid);
365 if (study_res.is_err()) {
366 ++error_count_;
367 return storage_status::storage_error;
368 }
369 auto study_pk = study_res.value();
370
371 // 3. Series
372 auto series_uid = dataset.get_string(tags::series_instance_uid);
373 auto modality = dataset.get_string(tags::modality);
374 auto series_res = database_->upsert_series(study_pk, series_uid, modality);
375 if (series_res.is_err()) {
376 ++error_count_;
377 return storage_status::storage_error;
378 }
379 auto series_pk = series_res.value();
380
381 // 4. Instance
382 auto sop_uid = dataset.get_string(tags::sop_instance_uid);
383 auto sop_class = dataset.get_string(tags::sop_class_uid);
384 auto file_path = file_storage_->get_file_path(sop_uid).string();
385
386 std::error_code ec;
387 auto file_size = std::filesystem::file_size(file_path, ec);
388 if (ec) file_size = 0;
389
390 auto inst_res = database_->upsert_instance(
391 series_pk, sop_uid, sop_class, file_path, static_cast<int64_t>(file_size));
392 if (inst_res.is_err()) {
393 ++error_count_;
394 return storage_status::storage_error;
395 }
396
397 // Update modalities in study
398 (void)database_->update_modalities_in_study(study_pk);
399
400 ++stored_count_;
401 return storage_status::success;
402 }
403
404 std::vector<dicom_dataset> handle_query(
405 query_level level,
406 const dicom_dataset& query_keys) {
407
408 std::vector<dicom_dataset> results;
409
410 if (level == query_level::study) {
412 auto study_uid_val = query_keys.get_string(tags::study_instance_uid);
413 if (!study_uid_val.empty()) query.study_uid = std::string(study_uid_val);
414
415 auto pat_id_val = query_keys.get_string(tags::patient_id);
416 if (!pat_id_val.empty()) query.patient_id = std::string(pat_id_val);
417
418 auto pat_name_val = query_keys.get_string(tags::patient_name);
419 if (!pat_name_val.empty()) query.patient_name = std::string(pat_name_val);
420
421 auto studies_result = database_->search_studies(query);
422 if (studies_result.is_ok()) {
423 for (const auto& study : studies_result.value()) {
424 dicom_dataset ds;
425 ds.set_string(tags::study_instance_uid, vr_type::UI, study.study_uid);
426 ds.set_string(tags::study_id, vr_type::SH, study.study_id);
427 ds.set_string(tags::study_date, vr_type::DA, study.study_date);
428 ds.set_string(tags::study_time, vr_type::TM, study.study_time);
429 ds.set_string(tags::accession_number, vr_type::SH, study.accession_number);
430 ds.set_string(tags::study_description, vr_type::LO, study.study_description);
431 ds.set_string(tags::query_retrieve_level, vr_type::CS, "STUDY");
432
433 auto patient = database_->find_patient_by_pk(study.patient_pk);
434 if (patient) {
435 ds.set_string(tags::patient_name, vr_type::PN, patient->patient_name);
436 ds.set_string(tags::patient_id, vr_type::LO, patient->patient_id);
437 ds.set_string(tags::patient_birth_date, vr_type::DA, patient->birth_date);
438 ds.set_string(tags::patient_sex, vr_type::CS, patient->sex);
439 }
440
441 results.push_back(std::move(ds));
442 }
443 }
444 }
445 return results;
446 }
447
448 std::vector<dicom_dataset> query_worklist(const dicom_dataset& /* query */) {
449 std::lock_guard<std::mutex> lock(mutex_);
450 return worklist_items_;
451 }
452
454 std::lock_guard<std::mutex> lock(mutex_);
455 mpps_instances_.push_back(instance);
457 }
458
460 const std::string& uid,
461 const dicom_dataset& /* modifications */,
463
464 std::lock_guard<std::mutex> lock(mutex_);
465 for (auto& mpps : mpps_instances_) {
466 if (mpps.sop_instance_uid == uid) {
467 mpps.status = status;
469 }
470 }
473 "MPPS not found"
474 });
475 }
476
477 uint16_t port_;
478 std::string ae_title_;
479 test_directory test_dir_;
480 std::filesystem::path storage_dir_;
481 std::filesystem::path db_path_;
482
483 std::unique_ptr<dicom_server> server_;
484 std::unique_ptr<file_storage> file_storage_;
485 std::unique_ptr<index_database> database_;
486
487 mutable std::mutex mutex_;
488 std::vector<dicom_dataset> worklist_items_;
489 std::vector<mpps_instance> mpps_instances_;
490
491 std::atomic<size_t> stored_count_{0};
492 std::atomic<size_t> error_count_{0};
493};
494
495// =============================================================================
496// Storage Helper Functions
497// =============================================================================
498
503bool store_to_pacs(
504 const dicom_dataset& dataset,
505 const std::string& host,
506 uint16_t port,
507 const std::string& called_ae,
508 const std::string& calling_ae = "MODALITY") {
509
510 auto sop_class = dataset.get_string(tags::sop_class_uid);
511 if (sop_class.empty()) {
512 return false;
513 }
514
515 auto assoc_result = test_association::connect(
516 host, port, called_ae, calling_ae, {std::string(sop_class)});
517
518 if (assoc_result.is_err()) {
519 return false;
520 }
521
522 auto& assoc = assoc_result.value();
523 storage_scu scu;
524 auto store_result = scu.store(assoc, dataset);
525
526 (void)assoc.release();
527 return store_result.is_ok();
528}
529
535size_t parallel_store(
536 multimodal_pacs_server& server,
537 const std::vector<dicom_dataset>& datasets,
538 const std::string& calling_ae = "MODALITY",
539 size_t batch_size = 20) {
540
541 std::atomic<size_t> success_count{0};
542
543 // Process in batches to avoid resource exhaustion
544 for (size_t i = 0; i < datasets.size(); i += batch_size) {
545 std::vector<std::future<bool>> futures;
546 size_t end = (std::min)(i + batch_size, datasets.size());
547
548 for (size_t j = i; j < end; ++j) {
549 const auto& dataset = datasets[j];
550 futures.push_back(std::async(std::launch::async, [&, calling_ae]() {
551 return store_to_pacs(
552 dataset, "127.0.0.1", server.port(),
553 server.ae_title(), calling_ae);
554 }));
555 }
556
557 for (auto& future : futures) {
558 if (future.get()) {
559 ++success_count;
560 }
561 }
562 }
563
564 return success_count.load();
565}
566
567} // anonymous namespace
568
569// =============================================================================
570// Test Cases
571// =============================================================================
572
573TEST_CASE("Multi-modal workflow tests", "[workflow][multimodal][integration]") {
574 auto port = find_available_port();
575 multimodal_pacs_server server(port);
576 REQUIRE(server.initialize());
577 REQUIRE(server.start());
578
579 SECTION("Scenario 1: Complete patient journey - CT and MR") {
580 // This scenario simulates a patient arriving for scheduled CT and MR exams
581 // Workflow: Worklist query → CT scan → MPPS → MR scan → MPPS → Query verification
582
583 const std::string patient_id = "JOURNEY001";
584 const std::string patient_name = "JOURNEY^PATIENT^COMPLETE";
585 auto study_uid = generate_uid();
586
587 // Step 1: Add scheduled procedures to worklist
588 auto ct_worklist = test_data_generator::worklist(patient_id, "CT");
589 ct_worklist.set_string(tags::study_instance_uid, vr_type::UI, study_uid);
590 server.add_worklist_item(ct_worklist);
591
592 auto mr_worklist = test_data_generator::worklist(patient_id, "MR");
593 mr_worklist.set_string(tags::study_instance_uid, vr_type::UI, study_uid);
594 server.add_worklist_item(mr_worklist);
595
596 // Step 2: Perform CT examination
597 // Generate CT series (3 images)
598 auto ct_series_uid = generate_uid();
599 std::vector<dicom_dataset> ct_images;
600 for (int i = 0; i < 3; ++i) {
601 auto ct = test_data_generator::ct(study_uid);
602 ct.set_string(tags::patient_id, vr_type::LO, patient_id);
603 ct.set_string(tags::patient_name, vr_type::PN, patient_name);
604 ct.set_string(tags::series_instance_uid, vr_type::UI, ct_series_uid);
605 ct.set_string(tags::sop_instance_uid, vr_type::UI, generate_uid());
606 ct.set_string(tags::instance_number, vr_type::IS, std::to_string(i + 1));
607 ct_images.push_back(std::move(ct));
608 }
609
610 // Store CT images
611 for (const auto& image : ct_images) {
612 REQUIRE(store_to_pacs(image, "127.0.0.1", port, server.ae_title(), "CT_SCANNER"));
613 }
614
615 // Step 3: Perform MR examination
616 // Generate MR series (2 images)
617 auto mr_series_uid = generate_uid();
618 std::vector<dicom_dataset> mr_images;
619 for (int i = 0; i < 2; ++i) {
620 auto mr = test_data_generator::mr(study_uid);
621 mr.set_string(tags::patient_id, vr_type::LO, patient_id);
622 mr.set_string(tags::patient_name, vr_type::PN, patient_name);
623 mr.set_string(tags::series_instance_uid, vr_type::UI, mr_series_uid);
624 mr.set_string(tags::sop_instance_uid, vr_type::UI, generate_uid());
625 mr.set_string(tags::instance_number, vr_type::IS, std::to_string(i + 1));
626 mr_images.push_back(std::move(mr));
627 }
628
629 // Store MR images
630 for (const auto& image : mr_images) {
631 REQUIRE(store_to_pacs(image, "127.0.0.1", port, server.ae_title(), "MR_SCANNER"));
632 }
633
634 // Step 4: Verify data consistency
635 auto verifier = server.get_verifier();
636
637 // Verify patient exists
638 REQUIRE(verifier.verify_patient_exists(patient_id));
639
640 // Verify study contains both modalities
641 REQUIRE(verifier.verify_modalities_in_study(study_uid, {"CT", "MR"}));
642
643 // Verify series count (1 CT + 1 MR = 2 series)
644 REQUIRE(verifier.verify_series_count(study_uid, 2));
645
646 // Verify image counts
647 REQUIRE(verifier.verify_image_count(ct_series_uid, 3));
648 REQUIRE(verifier.verify_image_count(mr_series_uid, 2));
649
650 // Verify no duplicate UIDs
651 REQUIRE(verifier.verify_unique_uids(study_uid));
652
653 // Verify total stored count
654 REQUIRE(server.stored_count() == 5);
655 REQUIRE(server.error_count() == 0);
656 }
657
658 SECTION("Scenario 2: Interventional workflow - XA cine acquisition") {
659 // This scenario simulates an interventional radiology procedure
660 // Workflow: XA cine acquisition → Store multi-frame → Query verification
661
662 const std::string patient_id = "INTERVENT001";
663 const std::string patient_name = "INTERVENTIONAL^PATIENT";
664 auto study_uid = generate_uid();
665
666 // Generate XA cine (multi-frame)
667 auto xa_cine = test_data_generator::xa_cine(10, study_uid); // 10 frames
668 xa_cine.set_string(tags::patient_id, vr_type::LO, patient_id);
669 xa_cine.set_string(tags::patient_name, vr_type::PN, patient_name);
670 xa_cine.set_string(tags::series_description, vr_type::LO, "Coronary Angiography Run 1");
671
672 auto xa_series_uid_opt = xa_cine.get_string(tags::series_instance_uid);
673 REQUIRE(!xa_series_uid_opt.empty());
674 auto xa_series_uid = std::string(xa_series_uid_opt);
675
676 // Store XA cine
677 REQUIRE(store_to_pacs(xa_cine, "127.0.0.1", port, server.ae_title(), "XA_CATH_LAB"));
678
679 // Generate second XA run
680 auto xa_cine_2 = test_data_generator::xa_cine(15, study_uid); // 15 frames
681 xa_cine_2.set_string(tags::patient_id, vr_type::LO, patient_id);
682 xa_cine_2.set_string(tags::patient_name, vr_type::PN, patient_name);
683 xa_cine_2.set_string(tags::series_description, vr_type::LO, "Coronary Angiography Run 2");
684
685 // Store second run
686 REQUIRE(store_to_pacs(xa_cine_2, "127.0.0.1", port, server.ae_title(), "XA_CATH_LAB"));
687
688 // Verify data consistency
689 auto verifier = server.get_verifier();
690
691 REQUIRE(verifier.verify_patient_exists(patient_id));
692 REQUIRE(verifier.verify_modalities_in_study(study_uid, {"XA"}));
693 REQUIRE(verifier.verify_series_count(study_uid, 2)); // 2 XA runs
694 REQUIRE(verifier.verify_unique_uids(study_uid));
695 }
696
697 SECTION("Scenario 3: Emergency multi-modality - Trauma workflow") {
698 // This scenario simulates emergency trauma imaging
699 // Workflow: Rapid CT → XA intervention → Follow-up CT (all in same study)
700
701 const std::string patient_id = "TRAUMA001";
702 const std::string patient_name = "TRAUMA^PATIENT^EMERGENCY";
703 auto study_uid = generate_uid();
704
705 // Step 1: Initial trauma CT
706 auto initial_ct_series = generate_uid();
707 for (int i = 0; i < 5; ++i) {
708 auto ct = test_data_generator::ct(study_uid);
709 ct.set_string(tags::patient_id, vr_type::LO, patient_id);
710 ct.set_string(tags::patient_name, vr_type::PN, patient_name);
711 ct.set_string(tags::series_instance_uid, vr_type::UI, initial_ct_series);
712 ct.set_string(tags::series_description, vr_type::LO, "Initial Trauma CT");
713 ct.set_string(tags::sop_instance_uid, vr_type::UI, generate_uid());
714 REQUIRE(store_to_pacs(ct, "127.0.0.1", port, server.ae_title(), "CT_EMERGENCY"));
715 }
716
717 // Step 2: XA intervention
718 auto xa_intervention = test_data_generator::xa_cine(20, study_uid);
719 xa_intervention.set_string(tags::patient_id, vr_type::LO, patient_id);
720 xa_intervention.set_string(tags::patient_name, vr_type::PN, patient_name);
721 xa_intervention.set_string(tags::series_description, vr_type::LO, "Emergency Embolization");
722 REQUIRE(store_to_pacs(xa_intervention, "127.0.0.1", port, server.ae_title(), "XA_EMERGENCY"));
723
724 // Step 3: Follow-up CT
725 auto followup_ct_series = generate_uid();
726 for (int i = 0; i < 3; ++i) {
727 auto ct = test_data_generator::ct(study_uid);
728 ct.set_string(tags::patient_id, vr_type::LO, patient_id);
729 ct.set_string(tags::patient_name, vr_type::PN, patient_name);
730 ct.set_string(tags::series_instance_uid, vr_type::UI, followup_ct_series);
731 ct.set_string(tags::series_description, vr_type::LO, "Follow-up CT");
732 ct.set_string(tags::sop_instance_uid, vr_type::UI, generate_uid());
733 REQUIRE(store_to_pacs(ct, "127.0.0.1", port, server.ae_title(), "CT_EMERGENCY"));
734 }
735
736 // Verify complete trauma workflow
737 auto verifier = server.get_verifier();
738
739 REQUIRE(verifier.verify_patient_exists(patient_id));
740 REQUIRE(verifier.verify_modalities_in_study(study_uid, {"CT", "XA"}));
741 REQUIRE(verifier.verify_series_count(study_uid, 3)); // 2 CT series + 1 XA
742 REQUIRE(verifier.verify_image_count(initial_ct_series, 5));
743 REQUIRE(verifier.verify_image_count(followup_ct_series, 3));
744 REQUIRE(verifier.verify_unique_uids(study_uid));
745
746 // Verify total instance count
747 size_t total_instances = verifier.get_instance_count(study_uid);
748 REQUIRE(total_instances == 9); // 5 + 1 + 3
749 }
750
751 SECTION("Scenario 4: Concurrent modality operations") {
752 // This scenario tests multiple scanners storing simultaneously
753 // Tests thread safety and data integrity under concurrent load
754
755 const std::string study_uid = generate_uid();
756 const std::string patient_id = "CONCURRENT001";
757 const std::string patient_name = "CONCURRENT^PATIENT";
758
759 std::vector<dicom_dataset> all_datasets;
760 std::vector<std::string> series_uids;
761
762 // Generate datasets for 4 different modalities
763 const std::vector<std::pair<std::string, int>> modality_counts = {
764 {"CT", 5},
765 {"MR", 4},
766 {"XA", 2},
767 {"US", 3}
768 };
769
770 for (const auto& [modality, count] : modality_counts) {
771 auto series_uid = generate_uid();
772 series_uids.push_back(series_uid);
773
774 for (int i = 0; i < count; ++i) {
775 dicom_dataset ds;
776 if (modality == "CT") {
777 ds = test_data_generator::ct(study_uid);
778 } else if (modality == "MR") {
779 ds = test_data_generator::mr(study_uid);
780 } else if (modality == "XA") {
781 ds = test_data_generator::xa(study_uid);
782 } else if (modality == "US") {
783 ds = test_data_generator::us(study_uid);
784 }
785
786 ds.set_string(tags::patient_id, vr_type::LO, patient_id);
787 ds.set_string(tags::patient_name, vr_type::PN, patient_name);
788 ds.set_string(tags::series_instance_uid, vr_type::UI, series_uid);
789 ds.set_string(tags::sop_instance_uid, vr_type::UI, generate_uid());
790 all_datasets.push_back(std::move(ds));
791 }
792 }
793
794 // Store all datasets in parallel
795 size_t success = parallel_store(server, all_datasets);
796
797 // Allow time for indexing
798 std::this_thread::sleep_for(std::chrono::milliseconds{100});
799
800 // Verify all stores succeeded
801 REQUIRE(success == all_datasets.size());
802
803 // Verify data consistency
804 auto verifier = server.get_verifier();
805
806 REQUIRE(verifier.verify_patient_exists(patient_id));
807 REQUIRE(verifier.verify_modalities_in_study(study_uid, {"CT", "MR", "XA", "US"}));
808 REQUIRE(verifier.verify_series_count(study_uid, 4));
809 REQUIRE(verifier.verify_unique_uids(study_uid));
810
811 // Verify no errors
812 REQUIRE(server.error_count() == 0);
813
814 // Verify total stored
815 size_t expected_total = 5 + 4 + 2 + 3; // 14 images
816 REQUIRE(verifier.get_instance_count(study_uid) == expected_total);
817 }
818
819 server.stop();
820}
821
822TEST_CASE("Multi-modal workflow with MPPS tracking", "[workflow][mpps][integration]") {
823 auto port = find_available_port();
824 multimodal_pacs_server server(port);
825 REQUIRE(server.initialize());
826 REQUIRE(server.start());
827
828 SECTION("MPPS lifecycle for multi-modality study") {
829 const std::string patient_id = "MPPS001";
830 const std::string patient_name = "MPPS^TRACKING^PATIENT";
831 auto study_uid = generate_uid();
832
833 // Add worklist items for CT and MR
834 auto ct_worklist = test_data_generator::worklist(patient_id, "CT");
835 ct_worklist.set_string(tags::study_instance_uid, vr_type::UI, study_uid);
836 server.add_worklist_item(ct_worklist);
837
838 auto mr_worklist = test_data_generator::worklist(patient_id, "MR");
839 mr_worklist.set_string(tags::study_instance_uid, vr_type::UI, study_uid);
840 server.add_worklist_item(mr_worklist);
841
842 // Perform CT and store images
843 auto ct = test_data_generator::ct(study_uid);
844 ct.set_string(tags::patient_id, vr_type::LO, patient_id);
845 ct.set_string(tags::patient_name, vr_type::PN, patient_name);
846 REQUIRE(store_to_pacs(ct, "127.0.0.1", port, server.ae_title(), "CT_SCANNER"));
847
848 // Perform MR and store images
849 auto mr = test_data_generator::mr(study_uid);
850 mr.set_string(tags::patient_id, vr_type::LO, patient_id);
851 mr.set_string(tags::patient_name, vr_type::PN, patient_name);
852 REQUIRE(store_to_pacs(mr, "127.0.0.1", port, server.ae_title(), "MR_SCANNER"));
853
854 // Verify data consistency
855 auto verifier = server.get_verifier();
856 REQUIRE(verifier.verify_modalities_in_study(study_uid, {"CT", "MR"}));
857 REQUIRE(verifier.verify_unique_uids(study_uid));
858 REQUIRE(server.stored_count() == 2);
859 }
860
861 server.stop();
862}
863
864TEST_CASE("Stress test: High-volume multi-modal storage", "[workflow][.stress][integration]") {
865 auto port = find_available_port();
866 multimodal_pacs_server server(port);
867 REQUIRE(server.initialize());
868 REQUIRE(server.start());
869
870 SECTION("Store 8 images from multiple modalities sequentially") {
871 const std::string study_uid = generate_uid();
872 const std::string patient_id = "STRESS001";
873 const std::string patient_name = "STRESS^TEST^PATIENT";
874
875 // Store 8 images sequentially across 4 modalities (2 each)
876 const std::vector<std::string> modalities = {"CT", "MR", "XA", "US"};
877 size_t success_count = 0;
878
879 auto start = std::chrono::steady_clock::now();
880
881 for (int i = 0; i < 8; ++i) {
882 const auto& modality = modalities[i % modalities.size()];
883 dicom_dataset ds;
884
885 if (modality == "CT") {
886 ds = test_data_generator::ct(study_uid);
887 } else if (modality == "MR") {
888 ds = test_data_generator::mr(study_uid);
889 } else if (modality == "XA") {
890 ds = test_data_generator::xa(study_uid);
891 } else {
892 ds = test_data_generator::us(study_uid);
893 }
894
895 ds.set_string(tags::patient_id, vr_type::LO, patient_id);
896 ds.set_string(tags::patient_name, vr_type::PN, patient_name);
897 ds.set_string(tags::sop_instance_uid, vr_type::UI, generate_uid());
898
899 if (store_to_pacs(ds, "127.0.0.1", port, server.ae_title(), "STRESS_SCU")) {
900 ++success_count;
901 }
902 }
903
904 auto duration = std::chrono::steady_clock::now() - start;
905
906 INFO("Stored " << success_count << " images in "
907 << std::chrono::duration_cast<std::chrono::milliseconds>(duration).count() << "ms");
908
909 // Verify all stores succeeded
910 REQUIRE(success_count == 8);
911
912 // Verify no data corruption
913 auto verifier = server.get_verifier();
914 REQUIRE(verifier.verify_unique_uids(study_uid));
915 REQUIRE(verifier.verify_modalities_in_study(study_uid, {"CT", "MR", "XA", "US"}));
916 }
917
918 server.stop();
919}
void set_string(dicom_tag tag, encoding::vr_type vr, std::string_view value)
Set a string value for the given tag.
auto get_string(dicom_tag tag, std::string_view default_value="") const -> std::string
Get the string value of an element.
static network::Result< network::association > connect(const std::string &host, uint16_t port, const std::string &called_ae, const std::string &calling_ae=test_scu_ae_title, const std::vector< std::string > &sop_classes={"1.2.840.10008.1.1"})
Connect to a test server.
static core::dicom_dataset worklist(const std::string &patient_id="", const std::string &modality="CT")
Generate a worklist item dataset.
static core::dicom_dataset mr(const std::string &study_uid="")
Generate an MR Image dataset.
static core::dicom_dataset ct(const std::string &study_uid="")
Generate a CT Image dataset.
static core::dicom_dataset us(const std::string &study_uid="")
Generate a single-frame US Image dataset.
static core::dicom_dataset xa(const std::string &study_uid="")
Generate a single-frame XA Image dataset.
static core::dicom_dataset xa_cine(uint32_t frames=30, const std::string &study_uid="")
Generate a multi-frame XA cine dataset.
RAII wrapper for temporary test directory.
network::Result< store_result > store(network::association &assoc, const core::dicom_dataset &dataset)
Store a single DICOM dataset.
DIMSE message encoding and decoding.
Filesystem-based DICOM storage with hierarchical organization.
PACS index database for metadata storage and retrieval.
DICOM MPPS (Modality Performed Procedure Step) SCP service.
constexpr dicom_tag status
Status.
constexpr dicom_tag modality
Modality.
uint16_t find_available_port(uint16_t start=default_test_port, int max_attempts=200)
Find an available port for testing.
TEST_CASE("test_data_generator::ct generates valid CT dataset", "[data_generator][ct]")
std::string generate_uid(const std::string &root="1.2.826.0.1.3680043.9.9999")
Generate a unique UID for testing.
const atna_coded_value query
Query (110112)
storage_status
Storage operation status codes.
mpps_status
MPPS status enumeration.
Definition mpps_scp.h:48
query_level
DICOM Query/Retrieve level enumeration.
Definition query_scp.h:63
@ study
Study level - query study information.
@ image
Image (Instance) level - query instance information.
@ patient
Patient level - query patient demographics.
@ series
Series level - query series information.
@ mpps
Modality Performed Procedure Step.
DICOM Query SCP service (C-FIND handler)
DICOM Retrieve SCP service (C-MOVE/C-GET handler)
DICOM Storage SCP service (C-STORE handler)
DICOM Storage SCU service (C-STORE sender)
size_t max_associations
Maximum concurrent associations (0 = unlimited)
std::chrono::seconds idle_timeout
Idle timeout for associations (0 = no timeout)
std::string implementation_version_name
Implementation Version Name.
uint16_t port
Port to listen on (default: 11112, standard alternate DICOM port)
std::string ae_title
Application Entity Title for this server (16 chars max)
std::string implementation_class_uid
Implementation Class UID.
MPPS instance data structure.
Definition mpps_scp.h:98
Result of a C-STORE operation.
Definition storage_scu.h:43
Configuration for file_storage.
std::filesystem::path root_path
Root directory for storage.
Common test fixtures and utilities for integration tests.
std::string_view uid
DICOM Verification SCP service (C-ECHO handler)
DICOM Modality Worklist SCP service (MWL C-FIND handler)