13#include <catch2/catch_test_macros.hpp>
48class test_query_database {
51 std::lock_guard<std::mutex> lock(mutex_);
52 studies_.push_back(ds);
55 std::vector<dicom_dataset> find_studies(
const dicom_dataset& query_keys)
const {
56 std::lock_guard<std::mutex> lock(mutex_);
57 std::vector<dicom_dataset> results;
59 auto query_patient_id = query_keys.
get_string(tags::patient_id);
60 auto query_patient_name = query_keys.
get_string(tags::patient_name);
61 auto query_study_date = query_keys.
get_string(tags::study_date);
62 auto query_modality = query_keys.
get_string(tags::modalities_in_study);
64 for (
const auto&
study : studies_) {
68 if (!query_patient_id.empty()) {
69 auto study_patient_id =
study.get_string(tags::patient_id);
70 if (!matches_wildcard(study_patient_id, query_patient_id)) {
76 if (match && !query_patient_name.empty()) {
77 auto study_patient_name =
study.get_string(tags::patient_name);
78 if (!matches_wildcard(study_patient_name, query_patient_name)) {
84 if (match && !query_study_date.empty()) {
86 if (!matches_date_range(study_date, query_study_date)) {
92 if (match && !query_modality.empty()) {
93 auto study_modality =
study.get_string(tags::modalities_in_study);
94 if (study_modality.find(query_modality) == std::string::npos) {
100 results.push_back(
study);
107 std::vector<dicom_dataset> find_patients(
const dicom_dataset& query_keys)
const {
108 std::lock_guard<std::mutex> lock(mutex_);
109 std::vector<dicom_dataset> results;
110 std::set<std::string> seen_patients;
112 auto query_patient_id = query_keys.
get_string(tags::patient_id);
113 auto query_patient_name = query_keys.
get_string(tags::patient_name);
115 for (
const auto&
study : studies_) {
119 if (seen_patients.count(patient_id) > 0)
continue;
123 if (!query_patient_id.empty()) {
124 if (!matches_wildcard(patient_id, query_patient_id)) {
129 if (match && !query_patient_name.empty()) {
131 if (!matches_wildcard(patient_name, query_patient_name)) {
138 patient_ds.
set_string(tags::patient_id, vr_type::LO,
139 study.get_string(tags::patient_id));
140 patient_ds.
set_string(tags::patient_name, vr_type::PN,
141 study.get_string(tags::patient_name));
142 patient_ds.
set_string(tags::patient_birth_date, vr_type::DA,
143 study.get_string(tags::patient_birth_date));
144 patient_ds.
set_string(tags::patient_sex, vr_type::CS,
145 study.get_string(tags::patient_sex));
146 patient_ds.
set_string(tags::query_retrieve_level, vr_type::CS,
"PATIENT");
148 results.push_back(std::move(patient_ds));
149 seen_patients.insert(patient_id);
157 std::lock_guard<std::mutex> lock(mutex_);
161 size_t size()
const {
162 std::lock_guard<std::mutex> lock(mutex_);
163 return studies_.size();
167 static bool matches_wildcard(
const std::string& value,
const std::string& pattern) {
168 if (pattern.empty())
return true;
169 if (pattern ==
"*")
return true;
172 if (pattern.back() ==
'*') {
173 auto prefix = pattern.substr(0, pattern.size() - 1);
174 return value.substr(0, prefix.size()) == prefix;
178 if (pattern.find(
'?') != std::string::npos) {
179 if (value.size() != pattern.size())
return false;
180 for (
size_t i = 0; i < pattern.size(); ++i) {
181 if (pattern[i] !=
'?' && pattern[i] != value[i]) {
189 return value == pattern;
192 static bool matches_date_range(
const std::string& value,
const std::string& range) {
193 if (range.empty())
return true;
196 auto dash_pos = range.find(
'-');
197 if (dash_pos == std::string::npos) {
199 return value == range;
202 auto start_date = range.substr(0, dash_pos);
203 auto end_date = range.substr(dash_pos + 1);
205 if (!start_date.empty() && value < start_date)
return false;
206 if (!end_date.empty() && value > end_date)
return false;
211 mutable std::mutex mutex_;
212 std::vector<dicom_dataset> studies_;
219 const std::string& patient_id,
220 const std::string& patient_name,
221 const std::string& study_date,
222 const std::string& modality) {
227 ds.
set_string(tags::patient_id, vr_type::LO, patient_id);
228 ds.
set_string(tags::patient_name, vr_type::PN, patient_name);
229 ds.
set_string(tags::patient_birth_date, vr_type::DA,
"19700101");
230 ds.
set_string(tags::patient_sex, vr_type::CS,
"M");
234 ds.
set_string(tags::study_date, vr_type::DA, study_date);
235 ds.
set_string(tags::study_time, vr_type::TM,
"120000");
236 ds.
set_string(tags::accession_number, vr_type::SH,
"ACC" + patient_id);
237 ds.
set_string(tags::study_id, vr_type::SH,
"STUDY001");
238 ds.
set_string(tags::study_description, vr_type::LO,
"Test Study");
239 ds.
set_string(tags::modalities_in_study, vr_type::CS, modality);
240 ds.
set_string(tags::query_retrieve_level, vr_type::CS,
"STUDY");
251TEST_CASE(
"C-FIND: pacs_system SCP with DCMTK findscu",
"[dcmtk][interop][find]") {
253 SKIP(
"DCMTK not installed - skipping interoperability test");
258 SKIP(
"pacs_system does not support real TCP DICOM connections yet");
263 const std::string ae_title =
"PACS_FIND_SCP";
265 test_query_database db;
268 db.add_study(create_test_study(
"PAT001",
"SMITH^JOHN",
"20231201",
"CT"));
269 db.add_study(create_test_study(
"PAT002",
"SMITH^JANE",
"20231215",
"MR"));
270 db.add_study(create_test_study(
"PAT003",
"JONES^WILLIAM",
"20240101",
"CT"));
271 db.add_study(create_test_study(
"PAT004",
"BROWN^ALICE",
"20240115",
"XA"));
276 auto query_scp_ptr = std::make_shared<query_scp>();
277 query_scp_ptr->set_handler([&db](
280 const std::string& ) -> std::vector<dicom_dataset> {
282 if (level == query_level::patient) {
283 return db.find_patients(query_keys);
285 return db.find_studies(query_keys);
290 REQUIRE(server.
start());
297 SECTION(
"Basic study-level query succeeds") {
298 std::vector<std::pair<std::string, std::string>> keys = {
302 {
"StudyInstanceUID",
""}
306 "localhost", port, ae_title,
"STUDY", keys);
308 INFO(
"stdout: " << result.stdout_output);
309 INFO(
"stderr: " << result.stderr_output);
311 REQUIRE(result.success());
314 SECTION(
"Query with PatientID filter") {
315 std::vector<std::pair<std::string, std::string>> keys = {
316 {
"PatientID",
"PAT001"},
318 {
"StudyInstanceUID",
""}
322 "localhost", port, ae_title,
"STUDY", keys);
324 INFO(
"stdout: " << result.stdout_output);
325 INFO(
"stderr: " << result.stderr_output);
327 REQUIRE(result.success());
330 SECTION(
"Query with wildcard PatientName") {
331 std::vector<std::pair<std::string, std::string>> keys = {
332 {
"PatientName",
"SMITH*"},
334 {
"StudyInstanceUID",
""}
338 "localhost", port, ae_title,
"STUDY", keys);
340 INFO(
"stdout: " << result.stdout_output);
341 INFO(
"stderr: " << result.stderr_output);
343 REQUIRE(result.success());
346 SECTION(
"Query with date range") {
347 std::vector<std::pair<std::string, std::string>> keys = {
348 {
"StudyDate",
"20231201-20231231"},
351 {
"StudyInstanceUID",
""}
355 "localhost", port, ae_title,
"STUDY", keys);
357 INFO(
"stdout: " << result.stdout_output);
358 INFO(
"stderr: " << result.stderr_output);
360 REQUIRE(result.success());
363 SECTION(
"Query with no matching results") {
364 std::vector<std::pair<std::string, std::string>> keys = {
365 {
"PatientID",
"NONEXISTENT"},
366 {
"StudyInstanceUID",
""}
370 "localhost", port, ae_title,
"STUDY", keys);
372 INFO(
"stdout: " << result.stdout_output);
373 INFO(
"stderr: " << result.stderr_output);
376 REQUIRE(result.success());
379 SECTION(
"Multiple consecutive queries") {
380 for (
int i = 0; i < 3; ++i) {
381 std::vector<std::pair<std::string, std::string>> keys = {
383 {
"StudyInstanceUID",
""}
387 "localhost", port, ae_title,
"STUDY", keys,
388 "FINDSCU_" + std::to_string(i));
390 INFO(
"Iteration: " << i);
391 INFO(
"stdout: " << result.stdout_output);
392 INFO(
"stderr: " << result.stderr_output);
394 REQUIRE(result.success());
398 SECTION(
"Patient-level query") {
399 std::vector<std::pair<std::string, std::string>> keys = {
402 {
"PatientBirthDate",
""}
406 "localhost", port, ae_title,
"PATIENT", keys);
408 INFO(
"stdout: " << result.stdout_output);
409 INFO(
"stderr: " << result.stderr_output);
411 REQUIRE(result.success());
419TEST_CASE(
"C-FIND: pacs_system SCU query operations",
"[dcmtk][interop][find]") {
421 SKIP(
"DCMTK not installed - skipping interoperability test");
426 SKIP(
"pacs_system does not support real TCP DICOM connections yet");
431 const std::string ae_title =
"QUERY_SCP";
433 test_query_database db;
434 db.add_study(create_test_study(
"PAT001",
"DOE^JOHN",
"20240101",
"CT"));
435 db.add_study(create_test_study(
"PAT002",
"DOE^JANE",
"20240115",
"MR"));
439 auto query_scp_ptr = std::make_shared<query_scp>();
440 query_scp_ptr->set_handler([&db](
443 const std::string& ) -> std::vector<dicom_dataset> {
445 if (level == query_level::patient) {
446 return db.find_patients(query_keys);
448 return db.find_studies(query_keys);
451 REQUIRE(server.
start());
457 SECTION(
"pacs_system SCU sends C-FIND successfully") {
459 "localhost", port, ae_title,
"PACS_SCU",
462 REQUIRE(connect_result.is_ok());
463 auto& assoc = connect_result.value();
467 REQUIRE(context_id.has_value());
471 query_keys.
set_string(tags::query_retrieve_level, vr_type::CS,
"STUDY");
472 query_keys.
set_string(tags::patient_id, vr_type::LO,
"");
473 query_keys.
set_string(tags::patient_name, vr_type::PN,
"");
474 query_keys.
set_string(tags::study_instance_uid, vr_type::UI,
"");
478 find_rq.set_dataset(std::move(query_keys));
479 auto send_result = assoc.send_dimse(*context_id, find_rq);
480 REQUIRE(send_result.is_ok());
483 std::vector<dicom_dataset> results;
485 auto recv_result = assoc.receive_dimse();
486 REQUIRE(recv_result.is_ok());
488 auto& [recv_ctx, rsp] = recv_result.value();
489 if (rsp.status() == status_success) {
491 }
else if (rsp.status() == status_pending) {
492 if (rsp.has_dataset()) {
493 auto ds_result = rsp.dataset();
494 if (ds_result.is_ok()) {
495 results.push_back(ds_result.value().get());
499 FAIL(
"Unexpected C-FIND response status");
504 REQUIRE(results.size() == 2);
507 SECTION(
"Query with specific PatientName filter") {
509 "localhost", port, ae_title,
"PACS_SCU",
512 REQUIRE(connect_result.is_ok());
513 auto& assoc = connect_result.value();
516 REQUIRE(context_id.has_value());
519 query_keys.
set_string(tags::query_retrieve_level, vr_type::CS,
"STUDY");
520 query_keys.
set_string(tags::patient_name, vr_type::PN,
"DOE^JOHN");
521 query_keys.
set_string(tags::study_instance_uid, vr_type::UI,
"");
524 find_rq.set_dataset(std::move(query_keys));
525 auto send_result = assoc.send_dimse(*context_id, find_rq);
526 REQUIRE(send_result.is_ok());
528 std::vector<dicom_dataset> results;
530 auto recv_result = assoc.receive_dimse();
531 REQUIRE(recv_result.is_ok());
533 auto& [recv_ctx, rsp] = recv_result.value();
534 if (rsp.status() == status_success)
break;
535 if (rsp.status() == status_pending && rsp.has_dataset()) {
536 auto ds_result = rsp.dataset();
537 if (ds_result.is_ok()) {
538 results.push_back(ds_result.value().get());
544 REQUIRE(results.size() == 1);
545 REQUIRE(results[0].get_string(tags::patient_name) ==
"DOE^JOHN");
548 SECTION(
"Query with wildcard pattern") {
550 "localhost", port, ae_title,
"PACS_SCU",
553 REQUIRE(connect_result.is_ok());
554 auto& assoc = connect_result.value();
557 REQUIRE(context_id.has_value());
560 query_keys.
set_string(tags::query_retrieve_level, vr_type::CS,
"STUDY");
561 query_keys.
set_string(tags::patient_name, vr_type::PN,
"DOE*");
562 query_keys.
set_string(tags::study_instance_uid, vr_type::UI,
"");
565 find_rq.set_dataset(std::move(query_keys));
566 (void)assoc.send_dimse(*context_id, find_rq);
568 std::vector<dicom_dataset> results;
570 auto recv_result = assoc.receive_dimse();
571 if (recv_result.is_err())
break;
573 auto& [recv_ctx, rsp] = recv_result.value();
574 if (rsp.status() == status_success)
break;
575 if (rsp.status() == status_pending && rsp.has_dataset()) {
576 auto ds_result = rsp.dataset();
577 if (ds_result.is_ok()) {
578 results.push_back(ds_result.value().get());
584 REQUIRE(results.size() == 2);
592TEST_CASE(
"C-FIND: Concurrent query operations",
"[dcmtk][interop][find][stress]") {
594 SKIP(
"DCMTK not installed");
599 SKIP(
"pacs_system does not support real TCP DICOM connections yet");
603 const std::string ae_title =
"STRESS_FIND_SCP";
605 test_query_database db;
606 for (
int i = 0; i < 10; ++i) {
607 db.add_study(create_test_study(
608 "PAT" + std::to_string(i),
609 "PATIENT^" + std::to_string(i),
610 "20240" + std::to_string(100 + i),
616 auto query_scp_ptr = std::make_shared<query_scp>();
617 query_scp_ptr->set_handler([&db](
620 const std::string& ) -> std::vector<dicom_dataset> {
621 return db.find_studies(query_keys);
624 REQUIRE(server.
start());
630 SECTION(
"3 concurrent DCMTK findscu clients") {
631 constexpr int num_clients = 3;
632 std::vector<std::future<dcmtk_result>> futures;
633 futures.reserve(num_clients);
635 for (
int i = 0; i < num_clients; ++i) {
636 futures.push_back(std::async(std::launch::async, [&, i]() {
637 std::vector<std::pair<std::string, std::string>> keys = {
639 {
"StudyInstanceUID",
""}
642 "localhost", port, ae_title,
"STUDY", keys,
643 "CLIENT_" + std::to_string(i));
648 for (
size_t i = 0; i < futures.size(); ++i) {
649 auto result = futures[i].get();
651 INFO(
"Client " << i <<
" stdout: " << result.stdout_output);
652 INFO(
"Client " << i <<
" stderr: " << result.stderr_output);
654 REQUIRE(result.success());
658 SECTION(
"3 concurrent pacs_system SCU clients") {
659 constexpr int num_clients = 3;
660 std::vector<std::future<bool>> futures;
661 futures.reserve(num_clients);
663 for (
int i = 0; i < num_clients; ++i) {
664 futures.push_back(std::async(std::launch::async, [&, i]() {
666 "localhost", port, ae_title,
667 "PACS_CLIENT_" + std::to_string(i),
670 if (!connect_result.is_ok())
return false;
672 auto& assoc = connect_result.value();
674 if (!context_id.has_value())
return false;
677 query_keys.
set_string(tags::query_retrieve_level, vr_type::CS,
"STUDY");
678 query_keys.
set_string(tags::patient_id, vr_type::LO,
"");
679 query_keys.
set_string(tags::study_instance_uid, vr_type::UI,
"");
682 find_rq.set_dataset(std::move(query_keys));
683 auto send_result = assoc.send_dimse(*context_id, find_rq);
684 if (!send_result.is_ok())
return false;
688 auto recv_result = assoc.receive_dimse();
689 if (!recv_result.is_ok())
return false;
691 auto& [recv_ctx, rsp] = recv_result.value();
692 if (rsp.status() == status_success)
break;
693 if (rsp.status() != status_pending)
return false;
701 for (
size_t i = 0; i < futures.size(); ++i) {
702 bool success = futures[i].get();
703 INFO(
"Client " << i);
713TEST_CASE(
"C-FIND: Connection error handling",
"[dcmtk][interop][find][error]") {
715 SKIP(
"DCMTK not installed");
720 SKIP(
"pacs_system does not support real TCP DICOM connections yet");
723 SECTION(
"findscu to non-existent server fails gracefully") {
729 std::vector<std::pair<std::string, std::string>> keys = {
731 {
"StudyInstanceUID",
""}
735 "localhost", port,
"NONEXISTENT",
"STUDY", keys,
736 "FINDSCU", std::chrono::seconds{5});
739 REQUIRE_FALSE(result.success());
742 SECTION(
"pacs_system SCU to non-existent server fails gracefully") {
747 std::this_thread::sleep_for(std::chrono::milliseconds{100});
751 SKIP(
"Port " + std::to_string(port) +
" is unexpectedly in use");
755 "localhost", port,
"NONEXISTENT",
"PACS_SCU",
759 REQUIRE_FALSE(connect_result.is_ok());
767TEST_CASE(
"C-FIND: Query level variations",
"[dcmtk][interop][find][levels]") {
769 SKIP(
"DCMTK not installed");
774 SKIP(
"pacs_system does not support real TCP DICOM connections yet");
778 const std::string ae_title =
"LEVEL_TEST_SCP";
780 test_query_database db;
781 db.add_study(create_test_study(
"PAT001",
"TEST^PATIENT",
"20240101",
"CT"));
785 auto query_scp_ptr = std::make_shared<query_scp>();
786 query_scp_ptr->set_handler([&db](
789 const std::string& ) -> std::vector<dicom_dataset> {
791 if (level == query_level::patient) {
792 return db.find_patients(query_keys);
794 return db.find_studies(query_keys);
797 REQUIRE(server.
start());
803 SECTION(
"STUDY level query") {
804 std::vector<std::pair<std::string, std::string>> keys = {
805 {
"PatientID",
"PAT001"},
806 {
"StudyInstanceUID",
""}
810 "localhost", port, ae_title,
"STUDY", keys);
812 REQUIRE(result.success());
815 SECTION(
"PATIENT level query") {
816 std::vector<std::pair<std::string, std::string>> keys = {
822 "localhost", port, ae_title,
"PATIENT", keys);
824 REQUIRE(result.success());
832TEST_CASE(
"C-FIND: Special character handling",
"[dcmtk][interop][find][special]") {
834 SKIP(
"DCMTK not installed");
839 SKIP(
"pacs_system does not support real TCP DICOM connections yet");
843 const std::string ae_title =
"SPECIAL_CHAR_SCP";
845 test_query_database db;
847 db.add_study(create_test_study(
"PAT001",
"O'BRIEN^MARY",
"20240101",
"CT"));
848 db.add_study(create_test_study(
"PAT002",
"MÜLLER^HANS",
"20240101",
"MR"));
852 auto query_scp_ptr = std::make_shared<query_scp>();
853 query_scp_ptr->set_handler([&db](
856 const std::string& ) -> std::vector<dicom_dataset> {
857 return db.find_studies(query_keys);
860 REQUIRE(server.
start());
866 SECTION(
"Query with special characters in response") {
867 std::vector<std::pair<std::string, std::string>> keys = {
868 {
"PatientID",
"PAT001"},
870 {
"StudyInstanceUID",
""}
874 "localhost", port, ae_title,
"STUDY", keys);
876 INFO(
"stdout: " << result.stdout_output);
877 INFO(
"stderr: " << result.stderr_output);
880 REQUIRE(result.success());
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 bool is_port_listening(uint16_t port, const std::string &host="127.0.0.1")
Check if a port is currently listening.
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.
RAII wrapper for a test DICOM server.
bool start()
Start the server and wait for it to be ready.
void register_service(std::shared_ptr< Service > service)
Register a service provider.
DICOM Dataset - ordered collection of Data Elements.
Compile-time constants for commonly used DICOM tags.
DIMSE message encoding and decoding.
bool supports_real_tcp_dicom()
Check if pacs_system supports real TCP DICOM connections.
uint16_t find_available_port(uint16_t start=default_test_port, int max_attempts=200)
Find an available port for testing.
bool wait_for(Func &&condition, std::chrono::milliseconds timeout, std::chrono::milliseconds interval=std::chrono::milliseconds{50})
Wait for a condition with timeout.
std::chrono::milliseconds server_ready_timeout()
Port listening timeout for pacs_system servers (5s normal, 30s CI)
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.
auto make_c_find_rq(uint16_t message_id, std::string_view sop_class_uid, uint16_t priority=priority_medium) -> dimse_message
Create a C-FIND request message.
constexpr std::string_view study_root_find_sop_class_uid
Study Root Query/Retrieve Information Model - FIND.
query_level
DICOM Query/Retrieve level enumeration.
@ study
Study level - query study information.
DICOM Query SCP service (C-FIND handler)
DICOM Storage SCP service (C-STORE handler)
Common test fixtures and utilities for integration tests.
DICOM Verification SCP service (C-ECHO handler)