PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
test_fixtures.h
Go to the documentation of this file.
1
20#ifndef PACS_INTEGRATION_TESTS_TEST_FIXTURES_HPP
21#define PACS_INTEGRATION_TESTS_TEST_FIXTURES_HPP
22
32
33// Include comprehensive test data generator
34#include "test_data_generator.h"
35
36#include <array>
37#include <atomic>
38#include <chrono>
39#include <cstdio>
40#include <cstdlib>
41#include <filesystem>
42#include <memory>
43#include <random>
44#include <sstream>
45#include <string>
46#include <thread>
47#include <vector>
48
49#ifdef _WIN32
50#include <windows.h>
51#else
52#include <arpa/inet.h>
53#include <cerrno>
54#include <csignal>
55#include <fcntl.h>
56#include <netinet/in.h>
57#include <sys/socket.h>
58#include <sys/wait.h>
59#include <unistd.h>
60#endif
61
63
64// =============================================================================
65// CI Environment Detection
66// =============================================================================
67
81inline bool is_ci_environment() {
82 static const bool ci_detected = []() {
83 const char* ci_vars[] = {
84 "CI", "GITHUB_ACTIONS", "GITLAB_CI",
85 "JENKINS_URL", "CIRCLECI", "TRAVIS"
86 };
87 for (const auto* var : ci_vars) {
88 if (std::getenv(var) != nullptr) {
89 return true;
90 }
91 }
92 return false;
93 }();
94 return ci_detected;
95}
96
97// =============================================================================
98// Constants
99// =============================================================================
100
102constexpr uint16_t default_test_port = 41104;
103
105inline std::chrono::milliseconds default_timeout() {
106 return is_ci_environment()
107 ? std::chrono::milliseconds{30000}
108 : std::chrono::milliseconds{5000};
109}
110
112inline std::chrono::milliseconds server_ready_timeout() {
113 return is_ci_environment()
114 ? std::chrono::milliseconds{30000}
115 : std::chrono::milliseconds{5000};
116}
117
119inline std::chrono::milliseconds dcmtk_server_ready_timeout() {
120 return is_ci_environment()
121 ? std::chrono::milliseconds{60000}
122 : std::chrono::milliseconds{10000};
123}
124
125// =============================================================================
126// Feature Detection
127// =============================================================================
128
149 // Currently, pacs_system does not support real TCP connections
150 // for DICOM protocol. The accept_worker accepts TCP connections
151 // but immediately closes them without performing DICOM handshake.
152 //
153 // This causes DCMTK clients to receive "Peer aborted Association"
154 // errors when attempting to connect.
155 //
156 // When real TCP support is implemented, update this to return true
157 // or perform an actual connection test.
158 return false;
159}
160
162constexpr const char* test_scp_ae_title = "TEST_SCP";
163constexpr const char* test_scu_ae_title = "TEST_SCU";
164
165// =============================================================================
166// Utility Functions
167// =============================================================================
168
174inline std::string generate_uid(const std::string& root = "1.2.826.0.1.3680043.9.9999") {
175 static std::atomic<uint64_t> counter{0};
176 auto now = std::chrono::system_clock::now();
177 auto timestamp = std::chrono::duration_cast<std::chrono::milliseconds>(
178 now.time_since_epoch()).count();
179
180 return root + "." + std::to_string(timestamp) + "." + std::to_string(++counter);
181}
182
188inline bool is_port_available(uint16_t port) {
189#ifdef _WIN32
190 SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
191 if (sock == INVALID_SOCKET) {
192 return false;
193 }
194
195 int reuse = 1;
196 setsockopt(sock, SOL_SOCKET, SO_REUSEADDR,
197 reinterpret_cast<const char*>(&reuse), sizeof(reuse));
198
199 sockaddr_in addr{};
200 addr.sin_family = AF_INET;
201 addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
202 addr.sin_port = htons(port);
203
204 bool available = (bind(sock, reinterpret_cast<sockaddr*>(&addr),
205 sizeof(addr)) == 0);
206 closesocket(sock);
207 return available;
208#else
209 int sock = socket(AF_INET, SOCK_STREAM, 0);
210 if (sock < 0) {
211 return false;
212 }
213
214 int reuse = 1;
215 setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
216
217 sockaddr_in addr{};
218 addr.sin_family = AF_INET;
219 addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
220 addr.sin_port = htons(port);
221
222 bool available = (bind(sock, reinterpret_cast<sockaddr*>(&addr),
223 sizeof(addr)) == 0);
224 close(sock);
225 return available;
226#endif
227}
228
239inline uint16_t find_available_port(uint16_t start = default_test_port,
240 int max_attempts = 200) {
241 static std::atomic<uint16_t> port_offset{0};
242
243 // Use a wider range and randomize starting point to reduce conflicts
244 static std::random_device rd;
245 static std::mt19937 gen(rd());
246 std::uniform_int_distribution<uint16_t> dist(0, 500);
247
248 uint16_t base_offset = port_offset.fetch_add(1) % 200;
249 uint16_t random_offset = dist(gen);
250
251 for (int attempt = 0; attempt < max_attempts; ++attempt) {
252 uint16_t port = start + ((base_offset + random_offset + attempt) % 1000);
253
254 // Avoid well-known ports and ensure we stay in a valid range
255 if (port < 1024) {
256 port += 40000;
257 }
258 if (port > 65000) {
259 port = start + (attempt % 500);
260 }
261
262 if (is_port_available(port)) {
263 return port;
264 }
265 }
266
267 // Fallback: return incremental port (original behavior)
268 return start + (port_offset++ % 100);
269}
270
278template <typename Func>
280 Func&& condition,
281 std::chrono::milliseconds timeout,
282 std::chrono::milliseconds interval = std::chrono::milliseconds{50}) {
283
284 auto start = std::chrono::steady_clock::now();
285 while (!condition()) {
286 auto elapsed = std::chrono::steady_clock::now() - start;
287 if (elapsed >= timeout) {
288 return false;
289 }
290 std::this_thread::sleep_for(interval);
291 }
292 return true;
293}
294
300template <typename Func>
301bool wait_for(Func&& condition) {
302 return wait_for(std::forward<Func>(condition), default_timeout());
303}
304
305// =============================================================================
306// DICOM Dataset Generators
307// =============================================================================
308
317 const std::string& study_uid = "",
318 const std::string& series_uid = "",
319 const std::string& instance_uid = "") {
320
322
323 // Patient module
328
329 // Study module
331 study_uid.empty() ? generate_uid() : study_uid);
337
338 // Series module
340 series_uid.empty() ? generate_uid() : series_uid);
344
345 // SOP Common module
346 ds.set_string(core::tags::sop_class_uid, encoding::vr_type::UI, "1.2.840.10008.5.1.4.1.1.2"); // CT Image Storage
348 instance_uid.empty() ? generate_uid() : instance_uid);
349
350 // Image module (minimal)
359
360 // Generate minimal pixel data (64x64 16-bit)
361 std::vector<uint16_t> pixel_data(64 * 64, 512);
363 pixel_elem.set_value(std::span<const uint8_t>(
364 reinterpret_cast<const uint8_t*>(pixel_data.data()),
365 pixel_data.size() * sizeof(uint16_t)));
366 ds.insert(std::move(pixel_elem));
367
368 return ds;
369}
370
376inline core::dicom_dataset generate_mr_dataset(const std::string& study_uid = "") {
378
379 // Patient module
384
385 // Study module
387 study_uid.empty() ? generate_uid() : study_uid);
393
394 // Series module
399
400 // SOP Common module
401 ds.set_string(core::tags::sop_class_uid, encoding::vr_type::UI, "1.2.840.10008.5.1.4.1.1.4"); // MR Image Storage
403
404 // Image module (minimal)
413
414 // Generate minimal pixel data
415 std::vector<uint16_t> pixel_data(64 * 64, 256);
417 pixel_elem.set_value(std::span<const uint8_t>(
418 reinterpret_cast<const uint8_t*>(pixel_data.data()),
419 pixel_data.size() * sizeof(uint16_t)));
420 ds.insert(std::move(pixel_elem));
421
422 return ds;
423}
424
430inline core::dicom_dataset generate_xa_dataset(const std::string& study_uid = "") {
432
433 // Patient module
438
439 // Study module
441 study_uid.empty() ? generate_uid() : study_uid);
447
448 // Series module
453
454 // SOP Common module
455 ds.set_string(core::tags::sop_class_uid, encoding::vr_type::UI, "1.2.840.10008.5.1.4.1.1.12.1"); // XA Image Storage
457
458 // Image module
467
468 // XA Specific
469 // Note: Using raw tag numbers if constants are not defined yet
470 // Positioner Primary Angle
471 ds.set_string({0x0018, 0x1510}, encoding::vr_type::DS, "0");
472 // Positioner Secondary Angle
473 ds.set_string({0x0018, 0x1511}, encoding::vr_type::DS, "0");
474 // KVP
475 ds.set_string({0x0018, 0x0060}, encoding::vr_type::DS, "80");
476 // X-Ray Tube Current
477 ds.set_numeric<uint16_t>({0x0018, 0x1151}, encoding::vr_type::IS, 500);
478 // Exposure Time
479 ds.set_numeric<uint16_t>({0x0018, 0x1150}, encoding::vr_type::IS, 100);
480
481 // Generate minimal pixel data
482 std::vector<uint16_t> pixel_data(512 * 512, 128);
484 pixel_elem.set_value(std::span<const uint8_t>(
485 reinterpret_cast<const uint8_t*>(pixel_data.data()),
486 pixel_data.size() * sizeof(uint16_t)));
487 ds.insert(std::move(pixel_elem));
488
489 return ds;
490}
491
519
520// =============================================================================
521// Test Server Fixture
522// =============================================================================
523
531public:
537 explicit test_server(
538 uint16_t port = 0,
539 const std::string& ae_title = test_scp_ae_title)
540 : port_(port == 0 ? find_available_port() : port)
542
544 config.ae_title = ae_title_;
545 config.port = port_;
546 config.max_associations = 20;
547 config.idle_timeout = std::chrono::seconds{60};
548 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.1";
549 config.implementation_version_name = "TEST_SCP";
550
551 server_ = std::make_unique<network::dicom_server>(config);
552 }
553
555 stop();
556 }
557
558 // Non-copyable, non-movable
559 test_server(const test_server&) = delete;
563
568 template <typename Service>
569 void register_service(std::shared_ptr<Service> service) {
570 server_->register_service(std::move(service));
571 }
572
585 [[nodiscard]] bool start() {
586 auto result = server_->start();
587 if (!result.is_ok()) {
588 return false;
589 }
590
591 running_ = true;
592
593 // Wait for port to actually be listening
594 // This is more reliable than a fixed delay, especially in CI
595 auto timeout = server_ready_timeout();
596 auto start_time = std::chrono::steady_clock::now();
597
598 while (std::chrono::steady_clock::now() - start_time < timeout) {
600 return true;
601 }
602 std::this_thread::sleep_for(std::chrono::milliseconds{50});
603 }
604
605 // Timeout waiting for port - stop the server and return failure
606 server_->stop();
607 running_ = false;
608 return false;
609 }
610
614 void stop() {
615 if (running_) {
616 server_->stop();
617 running_ = false;
618 }
619 }
620
622 [[nodiscard]] uint16_t port() const noexcept { return port_; }
623
625 [[nodiscard]] const std::string& ae_title() const noexcept { return ae_title_; }
626
628 [[nodiscard]] bool is_running() const noexcept { return running_; }
629
631 [[nodiscard]] network::dicom_server& server() { return *server_; }
632
633private:
640 static bool check_port_listening(uint16_t port) {
641 const long timeout_us = is_ci_environment() ? 1000000 : 200000;
642
643#ifdef _WIN32
644 WSADATA wsa_data;
645 if (WSAStartup(MAKEWORD(2, 2), &wsa_data) != 0) return false;
646
647 SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
648 if (sock == INVALID_SOCKET) { WSACleanup(); return false; }
649
650 u_long mode = 1;
651 ioctlsocket(sock, FIONBIO, &mode);
652
653 sockaddr_in addr{};
654 addr.sin_family = AF_INET;
655 addr.sin_port = htons(port);
656 addr.sin_addr.s_addr = inet_addr("127.0.0.1");
657
658 connect(sock, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
659
660 fd_set writefds;
661 FD_ZERO(&writefds);
662 FD_SET(sock, &writefds);
663
664 timeval tv{};
665 tv.tv_sec = static_cast<decltype(tv.tv_sec)>(timeout_us / 1000000);
666 tv.tv_usec = static_cast<decltype(tv.tv_usec)>(timeout_us % 1000000);
667 int result = select(0, nullptr, &writefds, nullptr, &tv);
668
669 bool listening = false;
670 if (result > 0) {
671 int error = 0;
672 int len = sizeof(error);
673 getsockopt(sock, SOL_SOCKET, SO_ERROR, reinterpret_cast<char*>(&error), &len);
674 listening = (error == 0);
675 }
676 closesocket(sock);
677 WSACleanup();
678 return listening;
679#else
680 int sock = socket(AF_INET, SOCK_STREAM, 0);
681 if (sock < 0) return false;
682
683 int flags = fcntl(sock, F_GETFL, 0);
684 fcntl(sock, F_SETFL, flags | O_NONBLOCK);
685
686 sockaddr_in addr{};
687 addr.sin_family = AF_INET;
688 addr.sin_port = htons(port);
689 addr.sin_addr.s_addr = inet_addr("127.0.0.1");
690
691 connect(sock, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
692
693 fd_set writefds;
694 FD_ZERO(&writefds);
695 FD_SET(sock, &writefds);
696
697 timeval tv{};
698 tv.tv_sec = static_cast<decltype(tv.tv_sec)>(timeout_us / 1000000);
699 tv.tv_usec = static_cast<decltype(tv.tv_usec)>(timeout_us % 1000000);
700 int result = select(sock + 1, nullptr, &writefds, nullptr, &tv);
701
702 bool listening = false;
703 if (result > 0) {
704 int error = 0;
705 socklen_t len = sizeof(error);
706 getsockopt(sock, SOL_SOCKET, SO_ERROR, &error, &len);
707 listening = (error == 0);
708 }
709 close(sock);
710 return listening;
711#endif
712 }
713
714 uint16_t port_;
715 std::string ae_title_;
716 std::unique_ptr<network::dicom_server> server_;
717 bool running_{false};
718};
719
720// =============================================================================
721// Test Association Helper
722// =============================================================================
723
728public:
739 const std::string& host,
740 uint16_t port,
741 const std::string& called_ae,
742 const std::string& calling_ae = test_scu_ae_title,
743 const std::vector<std::string>& sop_classes = {"1.2.840.10008.1.1"}) {
744
746 config.calling_ae_title = calling_ae;
747 config.called_ae_title = called_ae;
748 config.implementation_class_uid = "1.2.826.0.1.3680043.9.9999.2";
749 config.implementation_version_name = "TEST_SCU";
750
751 uint8_t context_id = 1;
752 for (const auto& sop_class : sop_classes) {
753 config.proposed_contexts.push_back({
754 context_id,
755 sop_class,
756 {
757 "1.2.840.10008.1.2.1", // Explicit VR Little Endian
758 "1.2.840.10008.1.2" // Implicit VR Little Endian
759 }
760 });
761 context_id += 2;
762 }
763
764 return network::association::connect(host, port, config, default_timeout());
765 }
766};
767
768// =============================================================================
769// Test Data Directory
770// =============================================================================
771
776public:
777 explicit test_directory(const std::string& prefix = "pacs_test_") {
778 auto temp_base = std::filesystem::temp_directory_path();
779 path_ = temp_base / (prefix + std::to_string(
780 std::chrono::system_clock::now().time_since_epoch().count()));
781 std::filesystem::create_directories(path_);
782 }
783
785 if (std::filesystem::exists(path_)) {
786 std::filesystem::remove_all(path_);
787 }
788 }
789
790 // Non-copyable, non-movable
795
797 [[nodiscard]] const std::filesystem::path& path() const noexcept { return path_; }
798
800 [[nodiscard]] std::string string() const { return path_.string(); }
801
802private:
803 std::filesystem::path path_;
804};
805
806// =============================================================================
807// Test Result Counters
808// =============================================================================
809
814public:
818
819 [[nodiscard]] size_t success() const noexcept { return success_.load(); }
820 [[nodiscard]] size_t failure() const noexcept { return failure_.load(); }
821 [[nodiscard]] size_t warning() const noexcept { return warning_.load(); }
822 [[nodiscard]] size_t total() const noexcept {
823 return success_.load() + failure_.load() + warning_.load();
824 }
825
826 void reset() {
827 success_ = 0;
828 failure_ = 0;
829 warning_ = 0;
830 }
831
832private:
833 std::atomic<size_t> success_{0};
834 std::atomic<size_t> failure_{0};
835 std::atomic<size_t> warning_{0};
836};
837
838// =============================================================================
839// Process Launcher for Binary Integration Tests
840// =============================================================================
841
846 int exit_code{-1};
847 std::string stdout_output;
848 std::string stderr_output;
849 std::chrono::milliseconds duration{0};
850 bool timed_out{false};
851};
852
862public:
871 const std::string& executable,
872 const std::vector<std::string>& args = {},
873 std::chrono::seconds timeout = std::chrono::seconds{30}) {
874
875 process_result result;
876 auto start_time = std::chrono::steady_clock::now();
877
878#ifdef _WIN32
879 result = run_windows(executable, args, timeout);
880#else
881 result = run_posix(executable, args, timeout);
882#endif
883
884 auto end_time = std::chrono::steady_clock::now();
885 result.duration = std::chrono::duration_cast<std::chrono::milliseconds>(
886 end_time - start_time);
887
888 return result;
889 }
890
897#ifdef _WIN32
898 using pid_type = DWORD;
899#else
900 using pid_type = pid_t;
901#endif
902
904 static constexpr pid_type invalid_pid = 0;
905
907 const std::string& executable,
908 const std::vector<std::string>& args = {}) {
909
910#ifdef _WIN32
911 return start_background_windows(executable, args);
912#else
913 return start_background_posix(executable, args);
914#endif
915 }
916
922 static bool stop_background(pid_type pid) {
923#ifdef _WIN32
924 return stop_background_windows(pid);
925#else
926 return stop_background_posix(pid);
927#endif
928 }
929
935 static bool is_running(pid_type pid) {
936#ifdef _WIN32
937 HANDLE process = OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, pid);
938 if (process == nullptr) {
939 return false;
940 }
941 DWORD exit_code;
942 if (GetExitCodeProcess(process, &exit_code)) {
943 CloseHandle(process);
944 return exit_code == STILL_ACTIVE;
945 }
946 CloseHandle(process);
947 return false;
948#else
949 return kill(pid, 0) == 0;
950#endif
951 }
952
960 static bool wait_for_port(
961 uint16_t port,
962 std::chrono::seconds timeout = std::chrono::seconds{10},
963 const std::string& host = "127.0.0.1") {
964
965 auto start = std::chrono::steady_clock::now();
966 auto interval = std::chrono::milliseconds{100};
967
968 while (true) {
969 if (is_port_listening(port, host)) {
970 return true;
971 }
972
973 auto elapsed = std::chrono::steady_clock::now() - start;
974 if (elapsed >= timeout) {
975 return false;
976 }
977
978 std::this_thread::sleep_for(interval);
979 }
980 }
981
992 static bool is_port_listening(uint16_t port, const std::string& host = "127.0.0.1") {
993 // Adaptive timeout: longer in CI environments where VMs may be slower
994 const long timeout_us = is_ci_environment() ? 1000000 : 200000; // 1s CI, 200ms normal
995
996#ifdef _WIN32
997 WSADATA wsa_data;
998 if (WSAStartup(MAKEWORD(2, 2), &wsa_data) != 0) {
999 return false;
1000 }
1001
1002 SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
1003 if (sock == INVALID_SOCKET) {
1004 WSACleanup();
1005 return false;
1006 }
1007
1008 // Set non-blocking
1009 u_long mode = 1;
1010 ioctlsocket(sock, FIONBIO, &mode);
1011
1012 sockaddr_in addr{};
1013 addr.sin_family = AF_INET;
1014 addr.sin_port = htons(port);
1015 addr.sin_addr.s_addr = inet_addr(host.c_str());
1016
1017 int result = connect(sock, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
1018
1019 fd_set writefds;
1020 FD_ZERO(&writefds);
1021 FD_SET(sock, &writefds);
1022
1023 timeval tv{};
1024 tv.tv_sec = static_cast<decltype(tv.tv_sec)>(timeout_us / 1000000);
1025 tv.tv_usec = static_cast<decltype(tv.tv_usec)>(timeout_us % 1000000);
1026 result = select(0, nullptr, &writefds, nullptr, &tv);
1027
1028 if (result > 0) {
1029 // Check if connection actually succeeded
1030 int error = 0;
1031 int len = sizeof(error);
1032 getsockopt(sock, SOL_SOCKET, SO_ERROR, reinterpret_cast<char*>(&error), &len);
1033 closesocket(sock);
1034 WSACleanup();
1035 return error == 0;
1036 }
1037
1038 closesocket(sock);
1039 WSACleanup();
1040
1041 return false;
1042#else
1043 int sock = socket(AF_INET, SOCK_STREAM, 0);
1044 if (sock < 0) {
1045 return false;
1046 }
1047
1048 // Set socket to non-blocking
1049 int flags = fcntl(sock, F_GETFL, 0);
1050 fcntl(sock, F_SETFL, flags | O_NONBLOCK);
1051
1052 sockaddr_in addr{};
1053 addr.sin_family = AF_INET;
1054 addr.sin_port = htons(port);
1055
1056 if (inet_pton(AF_INET, host.c_str(), &addr.sin_addr) != 1) {
1057 close(sock);
1058 return false;
1059 }
1060
1061 int result = connect(sock, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
1062
1063 if (result == 0) {
1064 close(sock);
1065 return true;
1066 }
1067
1068 if (errno == EINPROGRESS || errno == EWOULDBLOCK) {
1069 fd_set writefds;
1070 FD_ZERO(&writefds);
1071 FD_SET(sock, &writefds);
1072
1073 timeval tv{};
1074 tv.tv_sec = static_cast<decltype(tv.tv_sec)>(timeout_us / 1000000);
1075 tv.tv_usec = static_cast<decltype(tv.tv_usec)>(timeout_us % 1000000);
1076 result = select(sock + 1, nullptr, &writefds, nullptr, &tv);
1077
1078 if (result > 0) {
1079 int error = 0;
1080 socklen_t len = sizeof(error);
1081 getsockopt(sock, SOL_SOCKET, SO_ERROR, &error, &len);
1082 close(sock);
1083 return error == 0;
1084 }
1085 }
1086
1087 close(sock);
1088 return false;
1089#endif
1090 }
1091
1092private:
1093#ifndef _WIN32
1094 // POSIX implementation
1096 const std::string& executable,
1097 const std::vector<std::string>& args,
1098 std::chrono::seconds timeout) {
1099
1100 process_result result;
1101
1102 // Create pipes for stdout and stderr
1103 int stdout_pipe[2];
1104 int stderr_pipe[2];
1105
1106 if (pipe(stdout_pipe) != 0 || pipe(stderr_pipe) != 0) {
1107 result.exit_code = -1;
1108 result.stderr_output = "Failed to create pipes";
1109 return result;
1110 }
1111
1112 pid_t pid = fork();
1113
1114 if (pid < 0) {
1115 result.exit_code = -1;
1116 result.stderr_output = "Failed to fork";
1117 close(stdout_pipe[0]);
1118 close(stdout_pipe[1]);
1119 close(stderr_pipe[0]);
1120 close(stderr_pipe[1]);
1121 return result;
1122 }
1123
1124 if (pid == 0) {
1125 // Child process
1126 close(stdout_pipe[0]);
1127 close(stderr_pipe[0]);
1128
1129 dup2(stdout_pipe[1], STDOUT_FILENO);
1130 dup2(stderr_pipe[1], STDERR_FILENO);
1131
1132 close(stdout_pipe[1]);
1133 close(stderr_pipe[1]);
1134
1135 // Build argv
1136 std::vector<char*> argv;
1137 argv.push_back(const_cast<char*>(executable.c_str()));
1138 for (const auto& arg : args) {
1139 argv.push_back(const_cast<char*>(arg.c_str()));
1140 }
1141 argv.push_back(nullptr);
1142
1143 execv(executable.c_str(), argv.data());
1144 _exit(127); // execv failed
1145 }
1146
1147 // Parent process
1148 close(stdout_pipe[1]);
1149 close(stderr_pipe[1]);
1150
1151 // Set pipes to non-blocking
1152 fcntl(stdout_pipe[0], F_SETFL, O_NONBLOCK);
1153 fcntl(stderr_pipe[0], F_SETFL, O_NONBLOCK);
1154
1155 auto start = std::chrono::steady_clock::now();
1156 int status = 0;
1157 bool child_exited = false;
1158
1159 std::array<char, 4096> buffer{};
1160
1161 while (!child_exited) {
1162 // Check timeout
1163 auto elapsed = std::chrono::steady_clock::now() - start;
1164 if (elapsed >= timeout) {
1165 kill(pid, SIGKILL);
1166 waitpid(pid, &status, 0);
1167 result.timed_out = true;
1168 result.exit_code = -1;
1169 break;
1170 }
1171
1172 // Check if child has exited
1173 pid_t wait_result = waitpid(pid, &status, WNOHANG);
1174 if (wait_result > 0) {
1175 child_exited = true;
1176 if (WIFEXITED(status)) {
1177 result.exit_code = WEXITSTATUS(status);
1178 } else if (WIFSIGNALED(status)) {
1179 result.exit_code = -WTERMSIG(status);
1180 }
1181 } else if (wait_result < 0) {
1182 break;
1183 }
1184
1185 // Read available output
1186 ssize_t n;
1187 while ((n = read(stdout_pipe[0], buffer.data(), buffer.size() - 1)) > 0) {
1188 buffer[static_cast<size_t>(n)] = '\0';
1189 result.stdout_output += buffer.data();
1190 }
1191 while ((n = read(stderr_pipe[0], buffer.data(), buffer.size() - 1)) > 0) {
1192 buffer[static_cast<size_t>(n)] = '\0';
1193 result.stderr_output += buffer.data();
1194 }
1195
1196 if (!child_exited) {
1197 std::this_thread::sleep_for(std::chrono::milliseconds{10});
1198 }
1199 }
1200
1201 // Read any remaining output
1202 ssize_t n;
1203 while ((n = read(stdout_pipe[0], buffer.data(), buffer.size() - 1)) > 0) {
1204 buffer[static_cast<size_t>(n)] = '\0';
1205 result.stdout_output += buffer.data();
1206 }
1207 while ((n = read(stderr_pipe[0], buffer.data(), buffer.size() - 1)) > 0) {
1208 buffer[static_cast<size_t>(n)] = '\0';
1209 result.stderr_output += buffer.data();
1210 }
1211
1212 close(stdout_pipe[0]);
1213 close(stderr_pipe[0]);
1214
1215 return result;
1216 }
1217
1219 const std::string& executable,
1220 const std::vector<std::string>& args) {
1221
1222 pid_t pid = fork();
1223
1224 if (pid < 0) {
1225 return -1;
1226 }
1227
1228 if (pid == 0) {
1229 // Child process
1230 // Redirect stdout and stderr to /dev/null
1231 int null_fd = open("/dev/null", O_RDWR);
1232 if (null_fd >= 0) {
1233 dup2(null_fd, STDOUT_FILENO);
1234 dup2(null_fd, STDERR_FILENO);
1235 close(null_fd);
1236 }
1237
1238 // Create new session to detach from terminal
1239 setsid();
1240
1241 // Build argv
1242 std::vector<char*> argv;
1243 argv.push_back(const_cast<char*>(executable.c_str()));
1244 for (const auto& arg : args) {
1245 argv.push_back(const_cast<char*>(arg.c_str()));
1246 }
1247 argv.push_back(nullptr);
1248
1249 execv(executable.c_str(), argv.data());
1250 _exit(127);
1251 }
1252
1253 return pid;
1254 }
1255
1256 static bool stop_background_posix(pid_t pid) {
1257 if (pid <= 0) {
1258 return false;
1259 }
1260
1261 // Send SIGTERM first for graceful shutdown
1262 if (kill(pid, SIGTERM) != 0) {
1263 return errno == ESRCH; // Process already gone
1264 }
1265
1266 // Wait for process to terminate (up to 5 seconds)
1267 for (int i = 0; i < 50; ++i) {
1268 int status;
1269 pid_t result = waitpid(pid, &status, WNOHANG);
1270 if (result > 0 || (result < 0 && errno == ECHILD)) {
1271 return true;
1272 }
1273 std::this_thread::sleep_for(std::chrono::milliseconds{100});
1274 }
1275
1276 // Force kill if still running
1277 kill(pid, SIGKILL);
1278 waitpid(pid, nullptr, 0);
1279
1280 return true;
1281 }
1282#else
1283 // Windows implementation
1284 static process_result run_windows(
1285 const std::string& executable,
1286 const std::vector<std::string>& args,
1287 std::chrono::seconds timeout) {
1288
1289 process_result result;
1290
1291 // Build command line
1292 std::ostringstream cmd;
1293 cmd << "\"" << executable << "\"";
1294 for (const auto& arg : args) {
1295 cmd << " \"" << arg << "\"";
1296 }
1297 std::string cmd_line = cmd.str();
1298
1299 // Create pipes for stdout and stderr
1300 SECURITY_ATTRIBUTES sa{};
1301 sa.nLength = sizeof(sa);
1302 sa.bInheritHandle = TRUE;
1303 sa.lpSecurityDescriptor = nullptr;
1304
1305 HANDLE stdout_read, stdout_write;
1306 HANDLE stderr_read, stderr_write;
1307
1308 if (!CreatePipe(&stdout_read, &stdout_write, &sa, 0) ||
1309 !CreatePipe(&stderr_read, &stderr_write, &sa, 0)) {
1310 result.exit_code = -1;
1311 result.stderr_output = "Failed to create pipes";
1312 return result;
1313 }
1314
1315 SetHandleInformation(stdout_read, HANDLE_FLAG_INHERIT, 0);
1316 SetHandleInformation(stderr_read, HANDLE_FLAG_INHERIT, 0);
1317
1318 STARTUPINFOA si{};
1319 si.cb = sizeof(si);
1320 si.dwFlags = STARTF_USESTDHANDLES;
1321 si.hStdOutput = stdout_write;
1322 si.hStdError = stderr_write;
1323 si.hStdInput = nullptr;
1324
1325 PROCESS_INFORMATION pi{};
1326
1327 if (!CreateProcessA(
1328 nullptr,
1329 const_cast<char*>(cmd_line.c_str()),
1330 nullptr,
1331 nullptr,
1332 TRUE,
1333 0,
1334 nullptr,
1335 nullptr,
1336 &si,
1337 &pi)) {
1338 result.exit_code = -1;
1339 result.stderr_output = "Failed to create process";
1340 CloseHandle(stdout_read);
1341 CloseHandle(stdout_write);
1342 CloseHandle(stderr_read);
1343 CloseHandle(stderr_write);
1344 return result;
1345 }
1346
1347 CloseHandle(stdout_write);
1348 CloseHandle(stderr_write);
1349
1350 // Wait for process with timeout
1351 DWORD timeout_ms = static_cast<DWORD>(
1352 std::chrono::duration_cast<std::chrono::milliseconds>(timeout).count());
1353 DWORD wait_result = WaitForSingleObject(pi.hProcess, timeout_ms);
1354
1355 if (wait_result == WAIT_TIMEOUT) {
1356 TerminateProcess(pi.hProcess, 1);
1357 result.timed_out = true;
1358 result.exit_code = -1;
1359 } else {
1360 DWORD exit_code;
1361 GetExitCodeProcess(pi.hProcess, &exit_code);
1362 result.exit_code = static_cast<int>(exit_code);
1363 }
1364
1365 // Read output
1366 char buffer[4096];
1367 DWORD bytes_read;
1368
1369 while (ReadFile(stdout_read, buffer, sizeof(buffer) - 1, &bytes_read, nullptr) && bytes_read > 0) {
1370 buffer[bytes_read] = '\0';
1371 result.stdout_output += buffer;
1372 }
1373
1374 while (ReadFile(stderr_read, buffer, sizeof(buffer) - 1, &bytes_read, nullptr) && bytes_read > 0) {
1375 buffer[bytes_read] = '\0';
1376 result.stderr_output += buffer;
1377 }
1378
1379 CloseHandle(stdout_read);
1380 CloseHandle(stderr_read);
1381 CloseHandle(pi.hProcess);
1382 CloseHandle(pi.hThread);
1383
1384 return result;
1385 }
1386
1387 static DWORD start_background_windows(
1388 const std::string& executable,
1389 const std::vector<std::string>& args) {
1390
1391 std::ostringstream cmd;
1392 cmd << "\"" << executable << "\"";
1393 for (const auto& arg : args) {
1394 cmd << " \"" << arg << "\"";
1395 }
1396 std::string cmd_line = cmd.str();
1397
1398 STARTUPINFOA si{};
1399 si.cb = sizeof(si);
1400
1401 PROCESS_INFORMATION pi{};
1402
1403 if (!CreateProcessA(
1404 nullptr,
1405 const_cast<char*>(cmd_line.c_str()),
1406 nullptr,
1407 nullptr,
1408 FALSE,
1409 DETACHED_PROCESS,
1410 nullptr,
1411 nullptr,
1412 &si,
1413 &pi)) {
1414 return 0;
1415 }
1416
1417 CloseHandle(pi.hThread);
1418 DWORD pid = pi.dwProcessId;
1419 CloseHandle(pi.hProcess);
1420
1421 return pid;
1422 }
1423
1424 static bool stop_background_windows(DWORD pid) {
1425 if (pid == 0) {
1426 return false;
1427 }
1428
1429 HANDLE process = OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, pid);
1430 if (process == nullptr) {
1431 return GetLastError() == ERROR_INVALID_PARAMETER; // Process already gone
1432 }
1433
1434 TerminateProcess(process, 0);
1435 WaitForSingleObject(process, 5000);
1436 CloseHandle(process);
1437
1438 return true;
1439 }
1440#endif
1441};
1442
1449public:
1453
1457
1458 // Non-copyable
1461
1462 // Movable
1464 : pid_(other.pid_) {
1465 other.pid_ = process_launcher::invalid_pid;
1466 }
1467
1469 if (this != &other) {
1470 stop();
1471 pid_ = other.pid_;
1472 other.pid_ = process_launcher::invalid_pid;
1473 }
1474 return *this;
1475 }
1476
1479
1481 [[nodiscard]] process_launcher::pid_type pid() const noexcept { return pid_; }
1482
1484 [[nodiscard]] bool is_running() const {
1485 return pid_ > 0 && process_launcher::is_running(pid_);
1486 }
1487
1489 void stop() {
1490 if (pid_ > 0) {
1493 }
1494 }
1495
1498 auto p = pid_;
1500 return p;
1501 }
1502
1503private:
1505};
1506
1507} // namespace kcenon::pacs::integration_test
1508
1509#endif // PACS_INTEGRATION_TESTS_TEST_FIXTURES_HPP
DICOM Association management per PS3.8.
void set_numeric(dicom_tag tag, encoding::vr_type vr, T value)
Set a numeric value for the given tag.
void insert(dicom_element element)
Insert or replace an element in the dataset.
void set_string(dicom_tag tag, encoding::vr_type vr, std::string_view value)
Set a string value for the given tag.
void set_value(std::span< const uint8_t > data)
Set the raw value data.
background_process_guard & operator=(background_process_guard &&other) noexcept
void set_pid(process_launcher::pid_type pid)
Set the process ID.
process_launcher::pid_type release()
Release ownership without stopping.
process_launcher::pid_type pid() const noexcept
background_process_guard(process_launcher::pid_type pid=process_launcher::invalid_pid)
background_process_guard & operator=(const background_process_guard &)=delete
background_process_guard(background_process_guard &&other) noexcept
bool is_running() const
Check if process is running.
background_process_guard(const background_process_guard &)=delete
Cross-platform process launcher for binary integration testing.
static constexpr pid_type invalid_pid
Invalid PID constant (0 is reserved on both platforms)
static bool is_running(pid_type pid)
Check if a process is still running.
static process_result run_posix(const std::string &executable, const std::vector< std::string > &args, std::chrono::seconds timeout)
pid_t pid_type
Start a process in the background.
static process_result run(const std::string &executable, const std::vector< std::string > &args={}, std::chrono::seconds timeout=std::chrono::seconds{30})
Run a process and wait for completion.
static bool is_port_listening(uint16_t port, const std::string &host="127.0.0.1")
Check if a port is currently listening.
static bool stop_background(pid_type pid)
Stop a background process.
static pid_type start_background(const std::string &executable, const std::vector< std::string > &args={})
static bool wait_for_port(uint16_t port, std::chrono::seconds timeout=std::chrono::seconds{10}, const std::string &host="127.0.0.1")
Wait for a port to be listening.
static pid_t start_background_posix(const std::string &executable, const std::vector< std::string > &args)
Helper for establishing test associations.
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.
Thread-safe test result counter.
RAII wrapper for temporary test directory.
const std::filesystem::path & path() const noexcept
test_directory(const test_directory &)=delete
test_directory(const std::string &prefix="pacs_test_")
test_directory & operator=(const test_directory &)=delete
test_directory & operator=(test_directory &&)=delete
RAII wrapper for a test DICOM server.
test_server(const test_server &)=delete
bool start()
Start the server and wait for it to be ready.
test_server & operator=(const test_server &)=delete
const std::string & ae_title() const noexcept
test_server(uint16_t port=0, const std::string &ae_title=test_scp_ae_title)
Construct and start a test server.
std::unique_ptr< network::dicom_server > server_
void register_service(std::shared_ptr< Service > service)
Register a service provider.
test_server & operator=(test_server &&)=delete
static bool check_port_listening(uint16_t port)
Check if a port is listening (inline implementation)
static Result< association > connect(const std::string &host, uint16_t port, const association_config &config, duration timeout=default_timeout)
Initiate an SCU association to a remote SCP.
DICOM Dataset - ordered collection of Data Elements.
DICOM Part 10 file handling for reading/writing DICOM files.
Multi-threaded DICOM server for handling multiple associations.
Compile-time constants for commonly used DICOM tags.
constexpr dicom_tag high_bit
High Bit.
constexpr dicom_tag scheduled_procedure_step_description
Scheduled Procedure Step Description.
constexpr dicom_tag study_description
Study Description.
constexpr dicom_tag patient_id
Patient ID.
constexpr dicom_tag bits_allocated
Bits Allocated.
constexpr dicom_tag rows
Rows.
constexpr dicom_tag columns
Columns.
constexpr dicom_tag scheduled_procedure_step_start_date
Scheduled Procedure Step Start Date.
constexpr dicom_tag sop_instance_uid
SOP Instance UID.
constexpr dicom_tag bits_stored
Bits Stored.
constexpr dicom_tag patient_birth_date
Patient's Birth Date.
constexpr dicom_tag pixel_data
Pixel Data.
constexpr dicom_tag pixel_representation
Pixel Representation.
constexpr dicom_tag accession_number
Accession Number.
constexpr dicom_tag modality
Modality.
constexpr dicom_tag scheduled_station_ae_title
Scheduled Station AE Title.
constexpr dicom_tag study_time
Study Time.
constexpr dicom_tag patient_sex
Patient's Sex.
constexpr dicom_tag samples_per_pixel
Samples per Pixel.
constexpr dicom_tag study_instance_uid
Study Instance UID.
constexpr dicom_tag series_number
Series Number.
constexpr dicom_tag photometric_interpretation
Photometric Interpretation.
constexpr dicom_tag series_description
Series Description.
constexpr dicom_tag sop_class_uid
SOP Class UID.
constexpr dicom_tag requested_procedure_id
Requested Procedure ID.
constexpr dicom_tag study_id
Study ID.
constexpr dicom_tag patient_name
Patient's Name.
constexpr dicom_tag study_date
Study Date.
constexpr dicom_tag scheduled_procedure_step_start_time
Scheduled Procedure Step Start Time.
constexpr dicom_tag series_instance_uid
Series Instance UID.
@ DA
Date (8 chars, format: YYYYMMDD)
@ IS
Integer String (12 chars max)
@ LO
Long String (64 chars max)
@ DS
Decimal String (16 chars max)
@ UI
Unique Identifier (64 chars max)
@ US
Unsigned Short (2 bytes)
@ PN
Person Name (64 chars max per component group)
@ CS
Code String (16 chars max, uppercase + digits + space + underscore)
@ OW
Other Word (variable length)
@ TM
Time (14 chars max, format: HHMMSS.FFFFFF)
@ AE
Application Entity (16 chars max)
@ SH
Short String (16 chars max)
bool supports_real_tcp_dicom()
Check if pacs_system supports real TCP DICOM connections.
bool is_ci_environment()
Check if running in a CI environment.
uint16_t find_available_port(uint16_t start=default_test_port, int max_attempts=200)
Find an available port for testing.
core::dicom_dataset generate_mr_dataset(const std::string &study_uid="")
Generate a MR image dataset 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.
bool is_port_available(uint16_t port)
Check if a port is actually available by attempting to bind.
core::dicom_dataset generate_ct_dataset(const std::string &study_uid="", const std::string &series_uid="", const std::string &instance_uid="")
Generate a minimal CT image dataset for testing.
constexpr uint16_t default_test_port
Default test port range start (use high ports to avoid conflicts)
std::chrono::milliseconds server_ready_timeout()
Port listening timeout for pacs_system servers (5s normal, 30s CI)
constexpr const char * test_scu_ae_title
std::chrono::milliseconds default_timeout()
Default timeout for test operations (5s normal, 30s CI)
core::dicom_dataset generate_worklist_item()
Generate a worklist item dataset.
constexpr const char * test_scp_ae_title
Default AE titles.
core::dicom_dataset generate_xa_dataset(const std::string &study_uid="")
Generate a XA (X-Ray Angiographic) image dataset for testing.
std::chrono::milliseconds dcmtk_server_ready_timeout()
Port listening timeout for DCMTK servers (10s normal, 60s CI)
std::string generate_uid(const std::string &root="1.2.826.0.1.3680043.9.9999")
Generate a unique UID for testing.
DICOM Server configuration structures.
DICOM Storage SCP service (C-STORE handler)
DICOM Storage SCU service (C-STORE sender)
std::chrono::milliseconds duration
Execution duration.
bool timed_out
Whether the process timed out.
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::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.
Comprehensive DICOM test data generators for integration testing.
DICOM Verification SCP service (C-ECHO handler)