19#include <catch2/catch_test_macros.hpp>
44 inline constexpr dicom_tag requested_procedure_description{0x0032, 0x1060};
45 inline constexpr dicom_tag performed_procedure_step_discontinuation_reason_code_sequence{0x0040, 0x0281};
58class ris_mock_server {
60 explicit ris_mock_server(uint16_t port,
const std::string& ae_title =
"RIS_MOCK")
62 , ae_title_(ae_title) {
72 server_ = std::make_unique<dicom_server>(config);
77 server_->register_service(std::make_shared<verification_scp>());
80 auto worklist_scp_ptr = std::make_shared<worklist_scp>();
81 worklist_scp_ptr->set_handler([
this](
83 const std::string& ) -> std::vector<dicom_dataset> {
84 return this->query_worklist(query);
86 server_->register_service(worklist_scp_ptr);
89 auto mpps_scp_ptr = std::make_shared<mpps_scp>();
91 return this->create_mpps(instance);
93 mpps_scp_ptr->set_set_handler([
this](
94 const std::string&
uid,
97 return this->update_mpps(
uid, modifications, status);
99 server_->register_service(mpps_scp_ptr);
105 auto result = server_->start();
106 if (result.is_ok()) {
107 std::this_thread::sleep_for(std::chrono::milliseconds{100});
120 void add_scheduled_procedure(
const dicom_dataset& procedure) {
121 std::lock_guard<std::mutex> lock(mutex_);
122 scheduled_procedures_.push_back(procedure);
128 std::vector<mpps_instance> get_mpps_instances()
const {
129 std::lock_guard<std::mutex> lock(mutex_);
130 return mpps_instances_;
136 std::optional<mpps_instance> get_mpps(
const std::string& sop_instance_uid)
const {
137 std::lock_guard<std::mutex> lock(mutex_);
138 for (
const auto&
mpps : mpps_instances_) {
139 if (
mpps.sop_instance_uid == sop_instance_uid) {
146 uint16_t port()
const {
return port_; }
147 const std::string& ae_title()
const {
return ae_title_; }
149 size_t scheduled_count()
const {
150 std::lock_guard<std::mutex> lock(mutex_);
151 return scheduled_procedures_.size();
154 size_t mpps_count()
const {
155 std::lock_guard<std::mutex> lock(mutex_);
156 return mpps_instances_.size();
160 std::vector<dicom_dataset> query_worklist(
const dicom_dataset& query_keys) {
161 std::lock_guard<std::mutex> lock(mutex_);
163 std::vector<dicom_dataset> results;
165 for (
const auto& procedure : scheduled_procedures_) {
169 auto query_modality = query_keys.
get_string(tags::modality);
170 if (!query_modality.empty()) {
171 auto proc_modality = procedure.
get_string(tags::modality);
172 if (proc_modality != query_modality) {
177 auto query_date = query_keys.
get_string(tags::scheduled_procedure_step_start_date);
178 if (!query_date.empty()) {
179 auto proc_date = procedure.
get_string(tags::scheduled_procedure_step_start_date);
181 if (proc_date != query_date && query_date !=
"*") {
186 auto query_station = query_keys.
get_string(tags::scheduled_station_ae_title);
187 if (!query_station.empty()) {
188 auto proc_station = procedure.
get_string(tags::scheduled_station_ae_title);
189 if (proc_station != query_station && query_station !=
"*") {
195 results.push_back(procedure);
203 std::lock_guard<std::mutex> lock(mutex_);
206 for (
const auto& existing : mpps_instances_) {
210 "MPPS instance already exists"
215 mpps_instances_.push_back(instance);
220 const std::string& sop_instance_uid,
224 std::lock_guard<std::mutex> lock(mutex_);
226 for (
auto&
mpps : mpps_instances_) {
227 if (
mpps.sop_instance_uid == sop_instance_uid) {
229 if (
mpps.status == mpps_status::completed ||
230 mpps.status == mpps_status::discontinued) {
233 "Cannot modify completed/discontinued MPPS"
238 mpps.status = new_status;
239 mpps.data.merge(modifications);
247 "MPPS instance not found"
252 std::string ae_title_;
253 std::unique_ptr<dicom_server> server_;
255 mutable std::mutex mutex_;
256 std::vector<dicom_dataset> scheduled_procedures_;
257 std::vector<mpps_instance> mpps_instances_;
264 const std::string& patient_name,
265 const std::string& patient_id,
266 const std::string& modality,
267 const std::string& station_ae,
268 const std::string& procedure_desc,
269 const std::string& scheduled_date,
270 const std::string& scheduled_time) {
275 ds.
set_string(tags::patient_name, vr_type::PN, patient_name);
276 ds.
set_string(tags::patient_id, vr_type::LO, patient_id);
277 ds.
set_string(tags::patient_birth_date, vr_type::DA,
"19800101");
278 ds.
set_string(tags::patient_sex, vr_type::CS,
"M");
281 ds.
set_string(tags::scheduled_procedure_step_start_date, vr_type::DA, scheduled_date);
282 ds.
set_string(tags::scheduled_procedure_step_start_time, vr_type::TM, scheduled_time);
283 ds.
set_string(tags::modality, vr_type::CS, modality);
284 ds.
set_string(tags::scheduled_station_ae_title, vr_type::AE, station_ae);
285 ds.
set_string(tags::scheduled_procedure_step_description, vr_type::LO, procedure_desc);
289 ds.
set_string(tags::requested_procedure_id, vr_type::SH,
"RP_" + patient_id);
290 ds.
set_string(tags::accession_number, vr_type::SH,
"ACC_" + patient_id);
292 ds.
set_string(requested_procedure_description, vr_type::LO, procedure_desc);
303TEST_CASE(
"Worklist query returns scheduled procedures",
"[worklist][query]") {
305 ris_mock_server ris(port,
"RIS_MOCK");
307 REQUIRE(ris.initialize());
308 REQUIRE(ris.start());
311 ris.add_scheduled_procedure(create_scheduled_procedure(
312 "TEST^PATIENT1",
"P001",
"CT",
"CT_SCANNER",
"CT Chest",
"20240201",
"090000"));
313 ris.add_scheduled_procedure(create_scheduled_procedure(
314 "TEST^PATIENT2",
"P002",
"MR",
"MR_SCANNER",
"MR Brain",
"20240201",
"100000"));
315 ris.add_scheduled_procedure(create_scheduled_procedure(
316 "TEST^PATIENT3",
"P003",
"CT",
"CT_SCANNER",
"CT Abdomen",
"20240202",
"080000"));
318 SECTION(
"Query all scheduled procedures") {
326 {
"1.2.840.10008.1.2.1",
"1.2.840.10008.1.2"}
329 auto connect_result = association::connect(
331 REQUIRE(connect_result.is_ok());
333 auto& assoc = connect_result.value();
337 query_keys.
set_string(tags::patient_name, vr_type::PN,
"");
338 query_keys.
set_string(tags::patient_id, vr_type::LO,
"");
339 query_keys.
set_string(tags::modality, vr_type::CS,
"");
340 query_keys.
set_string(tags::scheduled_procedure_step_start_date, vr_type::DA,
"");
341 query_keys.
set_string(tags::scheduled_station_ae_title, vr_type::AE,
"");
345 find_rq.set_dataset(std::move(query_keys));
346 (void)assoc.send_dimse(context_id, find_rq);
348 std::vector<dicom_dataset> results;
351 if (recv_result.is_err())
break;
353 auto& [recv_ctx, rsp] = recv_result.value();
354 if (rsp.status() == status_success)
break;
355 if (rsp.status() == status_pending && rsp.has_dataset()) {
356 auto ds_result = rsp.dataset();
357 if (ds_result.is_ok()) {
358 results.push_back(ds_result.value().get());
363 REQUIRE(results.size() == 3);
368 SECTION(
"Query by modality filter") {
376 {
"1.2.840.10008.1.2.1"}
379 auto connect_result = association::connect(
381 REQUIRE(connect_result.is_ok());
383 auto& assoc = connect_result.value();
387 query_keys.
set_string(tags::patient_name, vr_type::PN,
"");
388 query_keys.
set_string(tags::modality, vr_type::CS,
"CT");
389 query_keys.
set_string(tags::scheduled_station_ae_title, vr_type::AE,
"");
393 find_rq.set_dataset(std::move(query_keys));
394 (void)assoc.send_dimse(context_id, find_rq);
396 std::vector<dicom_dataset> results;
399 if (recv_result.is_err())
break;
401 auto& [recv_ctx, rsp] = recv_result.value();
402 if (rsp.status() == status_success)
break;
403 if (rsp.status() == status_pending && rsp.has_dataset()) {
404 auto ds_result = rsp.dataset();
405 if (ds_result.is_ok()) {
406 results.push_back(ds_result.value().get());
412 REQUIRE(results.size() == 2);
413 for (
const auto& result : results) {
414 REQUIRE(result.get_string(tags::modality) ==
"CT");
423TEST_CASE(
"Complete MPPS workflow",
"[worklist][mpps][workflow]") {
425 ris_mock_server ris(port,
"RIS_MOCK");
427 REQUIRE(ris.initialize());
428 REQUIRE(ris.start());
431 auto procedure = create_scheduled_procedure(
432 "MPPS^TEST",
"MPPS001",
"CT",
"CT_SCANNER",
"CT Head",
"20240201",
"090000");
433 ris.add_scheduled_procedure(procedure);
435 auto study_uid = procedure.
get_string(tags::study_instance_uid);
446 {
"1.2.840.10008.1.2.1"}
449 auto wl_connect = association::connect(
"localhost", port, wl_config,
default_timeout());
450 REQUIRE(wl_connect.is_ok());
452 auto& wl_assoc = wl_connect.value();
455 wl_query.
set_string(tags::patient_id, vr_type::LO,
"MPPS001");
456 wl_query.
set_string(tags::modality, vr_type::CS,
"CT");
460 wl_rq.set_dataset(std::move(wl_query));
461 (void)wl_assoc.send_dimse(wl_ctx, wl_rq);
463 std::vector<dicom_dataset> wl_results;
466 if (recv.is_err())
break;
467 auto& [ctx, rsp] = recv.value();
468 if (rsp.status() == status_success)
break;
469 if (rsp.status() == status_pending && rsp.has_dataset()) {
470 auto ds_result = rsp.dataset();
471 if (ds_result.is_ok()) {
472 wl_results.push_back(ds_result.value().get());
477 REQUIRE(wl_results.size() == 1);
488 {
"1.2.840.10008.1.2.1"}
491 auto mpps_connect = association::connect(
"localhost", port, mpps_config,
default_timeout());
492 REQUIRE(mpps_connect.is_ok());
494 auto& mpps_assoc = mpps_connect.value();
498 mpps_create_ds.
set_string(tags::performed_procedure_step_status, vr_type::CS,
"IN PROGRESS");
499 mpps_create_ds.
set_string(tags::performed_procedure_step_start_date, vr_type::DA,
"20240201");
500 mpps_create_ds.
set_string(tags::performed_procedure_step_start_time, vr_type::TM,
"091500");
502 mpps_create_ds.
set_string(tags::modality, vr_type::CS,
"CT");
503 mpps_create_ds.
set_string(tags::study_instance_uid, vr_type::UI, study_uid);
504 mpps_create_ds.
set_string(tags::patient_name, vr_type::PN,
"MPPS^TEST");
505 mpps_create_ds.
set_string(tags::patient_id, vr_type::LO,
"MPPS001");
509 n_create_rq.set_dataset(std::move(mpps_create_ds));
510 (void)mpps_assoc.send_dimse(mpps_ctx,
n_create_rq);
513 REQUIRE(create_recv.is_ok());
514 auto& [create_ctx, create_rsp] = create_recv.value();
515 REQUIRE(create_rsp.command() == command_field::n_create_rsp);
516 REQUIRE(create_rsp.status() == status_success);
519 REQUIRE(ris.mpps_count() == 1);
520 auto mpps_opt = ris.get_mpps(mpps_uid);
521 REQUIRE(mpps_opt.has_value());
522 REQUIRE(mpps_opt->status == mpps_status::in_progress);
526 mpps_set_ds.
set_string(tags::performed_procedure_step_status, vr_type::CS,
"COMPLETED");
531 n_set_rq.set_dataset(std::move(mpps_set_ds));
532 (void)mpps_assoc.send_dimse(mpps_ctx,
n_set_rq);
535 REQUIRE(set_recv.is_ok());
536 auto& [set_ctx, set_rsp] = set_recv.value();
537 REQUIRE(set_rsp.command() == command_field::n_set_rsp);
538 REQUIRE(set_rsp.status() == status_success);
541 mpps_opt = ris.get_mpps(mpps_uid);
542 REQUIRE(mpps_opt.has_value());
543 REQUIRE(mpps_opt->status == mpps_status::completed);
549TEST_CASE(
"MPPS discontinue workflow",
"[worklist][mpps][discontinue]") {
551 ris_mock_server ris(port,
"RIS_MOCK");
553 REQUIRE(ris.initialize());
554 REQUIRE(ris.start());
565 {
"1.2.840.10008.1.2.1"}
568 auto connect_result = association::connect(
"localhost", port, config,
default_timeout());
569 REQUIRE(connect_result.is_ok());
571 auto& assoc = connect_result.value();
575 mpps_ds.
set_string(tags::performed_procedure_step_status, vr_type::CS,
"IN PROGRESS");
576 mpps_ds.
set_string(tags::performed_procedure_step_start_date, vr_type::DA,
"20240201");
577 mpps_ds.
set_string(tags::performed_procedure_step_start_time, vr_type::TM,
"100000");
579 mpps_ds.
set_string(tags::modality, vr_type::CS,
"CT");
581 mpps_ds.
set_string(tags::patient_name, vr_type::PN,
"DISCONTINUE^TEST");
582 mpps_ds.
set_string(tags::patient_id, vr_type::LO,
"DISC001");
586 n_create.set_dataset(std::move(mpps_ds));
587 (void)assoc.send_dimse(ctx, n_create);
590 REQUIRE(create_recv.is_ok());
591 REQUIRE(create_recv.value().second.status() == status_success);
595 disc_ds.
set_string(tags::performed_procedure_step_status, vr_type::CS,
"DISCONTINUED");
598 disc_ds.
set_string(performed_procedure_step_discontinuation_reason_code_sequence, vr_type::SQ,
"");
601 n_set.set_dataset(std::move(disc_ds));
602 (void)assoc.send_dimse(ctx, n_set);
605 REQUIRE(set_recv.is_ok());
606 REQUIRE(set_recv.value().second.status() == status_success);
609 auto mpps_opt = ris.get_mpps(mpps_uid);
610 REQUIRE(mpps_opt.has_value());
611 REQUIRE(mpps_opt->status == mpps_status::discontinued);
617TEST_CASE(
"MPPS cannot modify completed procedure",
"[worklist][mpps][error]") {
619 ris_mock_server ris(port,
"RIS_MOCK");
621 REQUIRE(ris.initialize());
622 REQUIRE(ris.start());
633 {
"1.2.840.10008.1.2.1"}
636 auto connect_result = association::connect(
"localhost", port, config,
default_timeout());
637 REQUIRE(connect_result.is_ok());
639 auto& assoc = connect_result.value();
644 create_ds.
set_string(tags::performed_procedure_step_status, vr_type::CS,
"IN PROGRESS");
645 create_ds.
set_string(tags::performed_procedure_step_start_date, vr_type::DA,
"20240201");
646 create_ds.
set_string(tags::performed_procedure_step_start_time, vr_type::TM,
"110000");
648 create_ds.
set_string(tags::modality, vr_type::CS,
"CT");
651 n_create.set_dataset(std::move(create_ds));
652 (void)assoc.send_dimse(ctx, n_create);
654 REQUIRE(create_recv.is_ok());
658 complete_ds.
set_string(tags::performed_procedure_step_status, vr_type::CS,
"COMPLETED");
660 n_set_complete.set_dataset(std::move(complete_ds));
661 (void)assoc.send_dimse(ctx, n_set_complete);
663 REQUIRE(complete_recv.is_ok());
669 n_set_modify.set_dataset(std::move(modify_ds));
670 (void)assoc.send_dimse(ctx, n_set_modify);
673 REQUIRE(modify_recv.is_ok());
675 REQUIRE(modify_recv.value().second.status() != status_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.
DIMSE message encoding and decoding.
DICOM MPPS (Modality Performed Procedure Step) SCP service.
uint16_t find_available_port(uint16_t start=default_test_port, int max_attempts=200)
Find an available port for testing.
std::chrono::milliseconds default_timeout()
Default timeout for test operations (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_n_create_rq(uint16_t message_id, std::string_view sop_class_uid, std::string_view sop_instance_uid="") -> dimse_message
Create an N-CREATE request message.
auto make_n_set_rq(uint16_t message_id, std::string_view sop_class_uid, std::string_view sop_instance_uid) -> dimse_message
Create an N-SET request message.
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.
@ n_create_rq
N-CREATE Request - Create SOP instance.
@ n_set_rq
N-SET Request - Set attribute values.
constexpr std::string_view worklist_find_sop_class_uid
Modality Worklist Information Model - FIND SOP Class UID.
mpps_status
MPPS status enumeration.
constexpr std::string_view mpps_sop_class_uid
MPPS (Modality Performed Procedure Step) SOP Class UID.
@ mpps
Modality Performed Procedure Step.
Configuration for SCU association request.
std::string called_ae_title
Remote AE Title (16 chars max)
std::string calling_ae_title
Our AE Title (16 chars max)
std::string implementation_class_uid
std::vector< proposed_presentation_context > proposed_contexts
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.
std::string sop_instance_uid
SOP Instance UID - unique identifier for this MPPS.
Common test fixtures and utilities for integration tests.
DICOM Verification SCP service (C-ECHO handler)
DICOM Modality Worklist SCP service (MWL C-FIND handler)