diff --git a/.github/workflows/ci-build-test-docs.yml b/.github/workflows/ci-build-test-docs.yml index 3ee8c9a..8e9f556 100644 --- a/.github/workflows/ci-build-test-docs.yml +++ b/.github/workflows/ci-build-test-docs.yml @@ -11,7 +11,7 @@ jobs: CARGO_TERM_COLOR: always CMAKE_BUILD_PARALLEL_LEVEL: 2 steps: - - name: Checkout (with submodules) + - name: Checkout (with tempoch submodule) uses: actions/checkout@v4 with: submodules: recursive @@ -39,6 +39,10 @@ jobs: libssl-dev \ graphviz + - name: Install official qtty-cpp package + shell: bash + run: scripts/install-official-cpp-deps.sh + - name: Install Doxygen 1.16.1 shell: bash run: | diff --git a/.github/workflows/ci-coverage.yml b/.github/workflows/ci-coverage.yml index 5af8f7c..2c5c3e2 100644 --- a/.github/workflows/ci-coverage.yml +++ b/.github/workflows/ci-coverage.yml @@ -11,7 +11,7 @@ jobs: CARGO_TERM_COLOR: always CMAKE_BUILD_PARALLEL_LEVEL: 2 steps: - - name: Checkout (with submodules) + - name: Checkout (with tempoch submodule) uses: actions/checkout@v4 with: submodules: recursive @@ -30,6 +30,10 @@ jobs: libssl-dev \ gcovr + - name: Install official qtty-cpp package + shell: bash + run: scripts/install-official-cpp-deps.sh + - name: Set up Rust (stable) uses: actions-rust-lang/setup-rust-toolchain@v1 with: diff --git a/.github/workflows/ci-lint.yml b/.github/workflows/ci-lint.yml index b304553..4817d05 100644 --- a/.github/workflows/ci-lint.yml +++ b/.github/workflows/ci-lint.yml @@ -10,7 +10,7 @@ jobs: env: CARGO_TERM_COLOR: always steps: - - name: Checkout (with submodules) + - name: Checkout (with tempoch submodule) uses: actions/checkout@v4 with: submodules: recursive @@ -26,6 +26,10 @@ jobs: ninja-build \ clang-tidy + - name: Install official qtty-cpp package + shell: bash + run: scripts/install-official-cpp-deps.sh + - name: Install clang-format 18 shell: bash run: | diff --git a/.github/workflows/ci-package.yml b/.github/workflows/ci-package.yml index 73eb26c..14ccfa8 100644 --- a/.github/workflows/ci-package.yml +++ b/.github/workflows/ci-package.yml @@ -11,7 +11,7 @@ jobs: CARGO_TERM_COLOR: always CMAKE_BUILD_PARALLEL_LEVEL: 2 steps: - - name: Checkout (with submodules) + - name: Checkout (with tempoch submodule) uses: actions/checkout@v4 with: submodules: recursive @@ -30,6 +30,10 @@ jobs: libssl-dev \ rpm + - name: Install official qtty-cpp package + shell: bash + run: scripts/install-official-cpp-deps.sh + - name: Set up Rust (stable) uses: actions-rust-lang/setup-rust-toolchain@v1 with: diff --git a/.gitmodules b/.gitmodules index 8e93535..fdeaee0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,6 +2,3 @@ path = tempoch url = git@github.com:Siderust/tempoch.git branch = helpers -[submodule "qtty-cpp"] - path = qtty-cpp - url = https://github.com/Siderust/qtty-cpp.git diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c40d4e..43d1a5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.5] - 2026-06-14 + +### Added + +- Added tests covering qtty-backed encoded-time arithmetic, period boundary + helpers, and FFI status/error translation paths. +- Added `scripts/install-official-cpp-deps.sh` for installing the official + `qtty-cpp` package on DEB/RPM systems. + +### Changed + +- Production builds now require the official `qtty-cpp >= 0.4.5` CMake package + via `find_package(qtty_cpp 0.4.5 REQUIRED)`. +- CI and Docker now install the official `qtty-cpp` package before configuring + tempoch-cpp. +- DEB/RPM package metadata now declares `qtty-cpp >= 0.4.5`. + +### Removed + +- Removed the vendored `qtty-cpp` submodule from the repository and build path. + ## [0.5.4] - 2026-06-13 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index 4a3e929..fe41d2e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.15) -project(tempoch_cpp VERSION 0.5.4 LANGUAGES CXX) +project(tempoch_cpp VERSION 0.5.5 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -73,10 +73,12 @@ endif() add_dependencies(tempoch_ffi build_tempoch_ffi) # qtty-cpp integration (provides unit types for duration<>) -if(NOT TARGET qtty_cpp) - set(QTTY_BUILD_DOCS OFF CACHE BOOL "Disable qtty-cpp docs when vendored by tempoch-cpp" FORCE) - add_subdirectory(qtty-cpp) +find_package(qtty_cpp 0.4.5 REQUIRED) +get_target_property(QTTY_CPP_INCLUDE_DIRS qtty::qtty_cpp INTERFACE_INCLUDE_DIRECTORIES) +if(NOT QTTY_CPP_INCLUDE_DIRS) + set(QTTY_CPP_INCLUDE_DIRS "") endif() +string(REPLACE ";" " " QTTY_CPP_DOXYGEN_INCLUDE_PATHS "${QTTY_CPP_INCLUDE_DIRS}") # Header-only C++ wrapper library add_library(tempoch_cpp INTERFACE) @@ -87,8 +89,7 @@ target_include_directories(tempoch_cpp INTERFACE ) target_link_libraries(tempoch_cpp INTERFACE tempoch_ffi - $ - $ + qtty::qtty_cpp ) add_dependencies(tempoch_cpp build_tempoch_ffi) @@ -186,6 +187,7 @@ set(TEST_SOURCES tests/test_constants.cpp tests/test_data_status.cpp tests/test_gnss_week.cpp + tests/test_context.cpp ) add_executable(test_tempoch ${TEST_SOURCES}) @@ -287,14 +289,14 @@ if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) set(CPACK_DEBIAN_PACKAGE_NAME "tempoch-cpp") set(CPACK_DEBIAN_PACKAGE_MAINTAINER "VPRamon ") set(CPACK_DEBIAN_PACKAGE_SECTION "libs") - set(CPACK_DEBIAN_PACKAGE_DEPENDS "libc6 (>= 2.17), libstdc++6 (>= 9), qtty-cpp (>= 0.4.2)") + set(CPACK_DEBIAN_PACKAGE_DEPENDS "libc6 (>= 2.17), libstdc++6 (>= 9), qtty-cpp (>= 0.4.5)") set(CPACK_DEBIAN_FILE_NAME DEB-DEFAULT) # -- RPM ----------------------------------------------------------------------- set(CPACK_RPM_PACKAGE_NAME "tempoch-cpp") set(CPACK_RPM_PACKAGE_LICENSE "AGPL-3.0") set(CPACK_RPM_PACKAGE_GROUP "Development/Libraries") - set(CPACK_RPM_PACKAGE_REQUIRES "glibc >= 2.17, libstdc++ >= 9, qtty-cpp >= 0.4.2") + set(CPACK_RPM_PACKAGE_REQUIRES "glibc >= 2.17, libstdc++ >= 9, qtty-cpp >= 0.4.5") set(CPACK_RPM_FILE_NAME RPM-DEFAULT) set(CPACK_GENERATOR "DEB;RPM") diff --git a/Dockerfile b/Dockerfile index 09ffb76..a8ceea5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,8 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --pr WORKDIR /workspace COPY . /workspace +RUN scripts/install-official-cpp-deps.sh + RUN test -f tempoch/Cargo.toml && \ test -f tempoch/tempoch-ffi/Cargo.toml diff --git a/README.md b/README.md index e61f704..1013d05 100644 --- a/README.md +++ b/README.md @@ -26,12 +26,18 @@ The C++ wrapper intentionally mirrors the current `tempoch-ffi` surface. Rust-on - CMake 3.15+ - C++17 compiler (GCC 8+, Clang 7+, MSVC 2019+) - Rust/Cargo (builds `tempoch-ffi`) +- Installed `qtty-cpp >= 0.4.5` package, or a local staged install passed + through `CMAKE_PREFIX_PATH` ## Build and Test ```bash -git clone --recurse-submodules +git clone cd tempoch-cpp +git submodule update --init tempoch + +# Debian/RPM systems can install the official qtty-cpp package with: +./scripts/install-official-cpp-deps.sh cmake -S . -B build cmake --build build --parallel @@ -39,10 +45,11 @@ ctest --test-dir build --output-on-failure ./build/01_quickstart ``` -If you cloned without submodules: +For local qtty-cpp development, install qtty-cpp to a staging prefix and pass it +to CMake: ```bash -git submodule update --init --recursive +cmake -S . -B build -DCMAKE_PREFIX_PATH=/path/to/qtty-stage ``` ## Usage @@ -132,6 +139,7 @@ std::cout << dt.to() << "\n"; ### Add as a Subdirectory ```cmake +find_package(qtty_cpp 0.4.5 REQUIRED) add_subdirectory(path/to/tempoch-cpp) target_link_libraries(your_target PRIVATE tempoch_cpp) ``` diff --git a/cmake/tempoch_cppConfig.cmake.in b/cmake/tempoch_cppConfig.cmake.in index 78707de..5e5805c 100644 --- a/cmake/tempoch_cppConfig.cmake.in +++ b/cmake/tempoch_cppConfig.cmake.in @@ -2,7 +2,7 @@ include(CMakeFindDependencyMacro) -find_dependency(qtty_cpp REQUIRED) +find_dependency(qtty_cpp 0.4.5 REQUIRED) # Include the targets file include("${CMAKE_CURRENT_LIST_DIR}/tempoch_cppTargets.cmake") diff --git a/docs/Doxyfile.in b/docs/Doxyfile.in index 57355a9..986e2df 100644 --- a/docs/Doxyfile.in +++ b/docs/Doxyfile.in @@ -46,7 +46,8 @@ MACRO_EXPANSION = NO SKIP_FUNCTION_MACROS = YES INCLUDE_PATH = \ "@CMAKE_CURRENT_SOURCE_DIR@/include" \ - "@CMAKE_CURRENT_SOURCE_DIR@/tempoch/tempoch-ffi/include" + "@CMAKE_CURRENT_SOURCE_DIR@/tempoch/tempoch-ffi/include" \ + @QTTY_CPP_DOXYGEN_INCLUDE_PATHS@ SOURCE_BROWSER = YES INLINE_SOURCES = NO diff --git a/docs/mainpage.md b/docs/mainpage.md index 9847e98..48d925b 100644 --- a/docs/mainpage.md +++ b/docs/mainpage.md @@ -116,8 +116,10 @@ FFI `tempoch_status_t` codes are translated into typed C++ exceptions: ## Building ```bash -git clone --recurse-submodules +git clone cd tempoch-cpp +git submodule update --init tempoch +./scripts/install-official-cpp-deps.sh cmake -S . -B build cmake --build build --parallel diff --git a/qtty-cpp b/qtty-cpp deleted file mode 160000 index 35a679e..0000000 --- a/qtty-cpp +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 35a679e8575599c5a56feea4a21a9789a601a46f diff --git a/scripts/ci.sh b/scripts/ci.sh index e47793d..7d2315a 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -105,7 +105,7 @@ ensure_repo_root header "Submodule check" git submodule status --recursive || true if [[ ! -f tempoch/Cargo.toml ]]; then - fail "tempoch submodule missing (run: git submodule update --init --recursive)" + fail "tempoch submodule missing (run: git submodule update --init tempoch)" exit 1 fi ok "tempoch submodule present" diff --git a/scripts/install-official-cpp-deps.sh b/scripts/install-official-cpp-deps.sh new file mode 100755 index 0000000..a0a3244 --- /dev/null +++ b/scripts/install-official-cpp-deps.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +QTTY_CPP_VERSION="${QTTY_CPP_VERSION:-0.4.5}" +SIDERUST_PACKAGE_BASE_URL="${SIDERUST_PACKAGE_BASE_URL:-https://siderust.org}" + +tmp_dir="$(mktemp -d)" +cleanup() { + rm -rf "${tmp_dir}" +} +trap cleanup EXIT + +run_privileged() { + if [[ "$(id -u)" -eq 0 ]]; then + "$@" + elif command -v sudo >/dev/null 2>&1; then + sudo "$@" + else + echo "This installer needs root privileges or sudo to install system packages." >&2 + return 1 + fi +} + +download() { + local url="$1" + local output="$2" + echo "Downloading ${url}" + curl -fsSL "${url}" -o "${output}" +} + +if command -v apt-get >/dev/null 2>&1 && command -v dpkg >/dev/null 2>&1; then + qtty_pkg="${tmp_dir}/qtty-cpp_${QTTY_CPP_VERSION}_amd64.deb" + download "${SIDERUST_PACKAGE_BASE_URL}/apt/qtty-cpp_${QTTY_CPP_VERSION}_amd64.deb" "${qtty_pkg}" + run_privileged apt-get install -y --no-install-recommends "${qtty_pkg}" +elif command -v rpm >/dev/null 2>&1; then + qtty_pkg="${tmp_dir}/qtty-cpp-${QTTY_CPP_VERSION}-1.x86_64.rpm" + download "${SIDERUST_PACKAGE_BASE_URL}/rpm/qtty-cpp-${QTTY_CPP_VERSION}-1.x86_64.rpm" "${qtty_pkg}" + if command -v dnf >/dev/null 2>&1; then + run_privileged dnf install -y "${qtty_pkg}" + elif command -v yum >/dev/null 2>&1; then + run_privileged yum install -y "${qtty_pkg}" + else + run_privileged rpm -Uvh "${qtty_pkg}" + fi +else + echo "Unsupported package manager. Install qtty-cpp manually, then pass its prefix through CMAKE_PREFIX_PATH if needed." >&2 + exit 1 +fi diff --git a/tests/test_context.cpp b/tests/test_context.cpp new file mode 100644 index 0000000..114ca65 --- /dev/null +++ b/tests/test_context.cpp @@ -0,0 +1,26 @@ +#include +#include + +using namespace tempoch; + +TEST(FfiCore, CheckStatusTranslatesKnownErrors) { + EXPECT_NO_THROW(check_status(TEMPOCH_STATUS_T_OK, "ok")); + EXPECT_THROW(check_status(TEMPOCH_STATUS_T_NULL_POINTER, "op"), NullPointerError); + EXPECT_THROW(check_status(TEMPOCH_STATUS_T_UTC_CONVERSION_FAILED, "op"), UtcConversionError); + EXPECT_THROW(check_status(TEMPOCH_STATUS_T_INVALID_PERIOD, "op"), InvalidPeriodError); + EXPECT_THROW(check_status(TEMPOCH_STATUS_T_NO_INTERSECTION, "op"), NoIntersectionError); + EXPECT_THROW(check_status(TEMPOCH_STATUS_T_INVALID_SCALE_ID, "op"), InvalidScaleIdError); + EXPECT_THROW(check_status(TEMPOCH_STATUS_T_INVALID_DURATION_UNIT, "op"), + InvalidDurationUnitError); + EXPECT_THROW(check_status(TEMPOCH_STATUS_T_CONVERSION_FAILED, "op"), ConversionFailedError); + EXPECT_THROW(check_status(TEMPOCH_STATUS_T_INVALID_FORMAT_ID, "op"), InvalidFormatIdError); + EXPECT_THROW(check_status(TEMPOCH_STATUS_T_INTERNAL_PANIC, "op"), InternalPanicError); + EXPECT_THROW(check_status(TEMPOCH_STATUS_T_UT1_HORIZON_EXCEEDED, "op"), Ut1HorizonExceededError); + EXPECT_THROW(check_status(TEMPOCH_STATUS_T_PERIOD_LIST_UNSORTED, "op"), PeriodListUnsortedError); + EXPECT_THROW(check_status(TEMPOCH_STATUS_T_PERIOD_LIST_OVERLAPPING, "op"), + PeriodListOverlappingError); + + const auto unknown_status = + static_cast(9999); // NOLINT(clang-analyzer-optin.core.EnumCastOutOfRange) + EXPECT_THROW(check_status(unknown_status, "op"), TempochException); +} diff --git a/tests/test_period.cpp b/tests/test_period.cpp index 9ec59b1..65fcc8e 100644 --- a/tests/test_period.cpp +++ b/tests/test_period.cpp @@ -9,7 +9,9 @@ TEST(Period, GenericMjdTtDuration) { Period period(MjdTt(60200.0), MjdTt(60201.5)); EXPECT_NEAR(period.duration().value(), 1.5, 1e-12); + EXPECT_NEAR(period.length().value(), 1.5, 1e-12); EXPECT_NEAR(period.duration().value(), 36.0, 1e-9); + EXPECT_NEAR(period.length().value(), 2160.0, 1e-6); } TEST(Period, IntersectionPreservesType) { @@ -23,6 +25,97 @@ TEST(Period, IntersectionPreservesType) { EXPECT_NEAR(overlap.end().value(), 60202.0, 1e-12); } +TEST(Period, ContainsUsesHalfOpenBoundary) { + using MjdTt = ModifiedJulianDate; + + Period period(MjdTt(60200.0), MjdTt(60201.0)); + + EXPECT_TRUE(period.contains(MjdTt(60200.0))); + EXPECT_TRUE(period.contains(MjdTt(60200.5))); + EXPECT_FALSE(period.contains(MjdTt(60201.0))); + EXPECT_FALSE(period.contains(MjdTt(60199.999))); +} + +TEST(Period, UnionWithHandlesAdjacentAndDisjointPeriods) { + using MjdTt = ModifiedJulianDate; + + Period a(MjdTt(60200.0), MjdTt(60201.0)); + Period b(MjdTt(60201.0), MjdTt(60202.0)); + Period c(MjdTt(60203.0), MjdTt(60204.0)); + + auto adjacent = a.union_with(b); + ASSERT_EQ(adjacent.size(), 1u); + EXPECT_NEAR(adjacent.front().start().value(), 60200.0, 1e-12); + EXPECT_NEAR(adjacent.front().end().value(), 60202.0, 1e-12); + + auto disjoint = a.union_with(c); + ASSERT_EQ(disjoint.size(), 2u); + EXPECT_NEAR(disjoint[0].start().value(), 60200.0, 1e-12); + EXPECT_NEAR(disjoint[1].start().value(), 60203.0, 1e-12); +} + +TEST(Period, ListOperationsValidateNormalizeIntersectAndUnion) { + using MjdTt = ModifiedJulianDate; + + std::vector> a = { + Period(MjdTt(60200.0), MjdTt(60201.0)), + Period(MjdTt(60202.0), MjdTt(60204.0)), + }; + std::vector> b = { + Period(MjdTt(60200.5), MjdTt(60202.5)), + }; + + EXPECT_NO_THROW(validate_periods(a)); + + auto intersection = intersect_periods(a, b); + ASSERT_EQ(intersection.size(), 2u); + EXPECT_NEAR(intersection[0].start().value(), 60200.5, 1e-12); + EXPECT_NEAR(intersection[0].end().value(), 60201.0, 1e-12); + EXPECT_NEAR(intersection[1].start().value(), 60202.0, 1e-12); + EXPECT_NEAR(intersection[1].end().value(), 60202.5, 1e-12); + + auto merged = union_periods(a, b); + ASSERT_EQ(merged.size(), 1u); + EXPECT_NEAR(merged.front().start().value(), 60200.0, 1e-12); + EXPECT_NEAR(merged.front().end().value(), 60204.0, 1e-12); + + std::vector> overlapping = { + Period(MjdTt(60200.0), MjdTt(60201.0)), + Period(MjdTt(60200.5), MjdTt(60202.0)), + }; + auto normalized = normalize_periods(overlapping); + ASSERT_EQ(normalized.size(), 1u); + EXPECT_NEAR(normalized.front().start().value(), 60200.0, 1e-12); + EXPECT_NEAR(normalized.front().end().value(), 60202.0, 1e-12); +} + +TEST(Period, ComplementAndValidationErrorsSurfaceTypedExceptions) { + using MjdTt = ModifiedJulianDate; + + Period universe(MjdTt(60200.0), MjdTt(60205.0)); + std::vector> occupied = { + Period(MjdTt(60201.0), MjdTt(60202.0)), + Period(MjdTt(60203.0), MjdTt(60204.0)), + }; + + auto complement = universe.complement_of(occupied); + ASSERT_EQ(complement.size(), 3u); + EXPECT_NEAR(complement.front().start().value(), 60200.0, 1e-12); + EXPECT_NEAR(complement.back().end().value(), 60205.0, 1e-12); + + std::vector> unsorted = { + Period(MjdTt(60202.0), MjdTt(60203.0)), + Period(MjdTt(60201.0), MjdTt(60202.0)), + }; + EXPECT_THROW(validate_periods(unsorted), PeriodListUnsortedError); + + std::vector> overlapping_for_validation = { + Period(MjdTt(60201.0), MjdTt(60203.0)), + Period(MjdTt(60202.0), MjdTt(60204.0)), + }; + EXPECT_THROW(validate_periods(overlapping_for_validation), PeriodListOverlappingError); +} + TEST(Period, CivilPeriodsUseUtcMjdRoundtrip) { Period period(CivilTime(2026, 1, 1, 0, 0, 0), CivilTime(2026, 1, 2, 0, 0, 0)); auto start = period.start(); diff --git a/tests/test_time.cpp b/tests/test_time.cpp index 8904544..b7dd259 100644 --- a/tests/test_time.cpp +++ b/tests/test_time.cpp @@ -1,6 +1,7 @@ #include #include +#include #include using namespace tempoch; @@ -83,6 +84,46 @@ TEST(Time, ArithmeticIsAffineAndSecondBased) { EXPECT_NEAR(tt.split_seconds().first.value(), 10.0, 1e-12); } +TEST(Time, QttyDurationArithmeticSupportsMultipleUnits) { + auto tt = Time::from_split_seconds(qtty::Second(1000.0), qtty::Second(0.5)); + + tt += qtty::Minute(2.0); + tt -= qtty::Second(30.5); + auto shifted = tt + qtty::Day(0.25); + + EXPECT_NEAR((tt - Time::from_raw_j2000_seconds(qtty::Second(1000.0))) + .to() + .value(), + 90.0, 1e-9); + EXPECT_NEAR((shifted - tt).to().value(), 6.0, 1e-9); + EXPECT_NEAR(tt.total_seconds().value(), 1090.0, 1e-12); +} + +TEST(Time, EncodedTryNewAndRawQuantityRejectNonFiniteValues) { + auto valid = JulianDate::try_new(qtty::Day(2451545.0)); + ASSERT_TRUE(valid.has_value()); + EXPECT_NEAR(valid->value(), 2451545.0, 1e-12); + + EXPECT_FALSE(JulianDate::try_new(qtty::Day(std::numeric_limits::infinity())) + .has_value()); + EXPECT_THROW(JulianDate::from_raw(qtty::Day(std::numeric_limits::quiet_NaN())), + ConversionFailedError); +} + +TEST(Time, EncodedComparisonsMinMaxMeanAndStream) { + auto a = JulianDate::J2000(); + auto b = a + qtty::Hour(48.0); + + EXPECT_TRUE(a < b); + EXPECT_TRUE(a <= b); + EXPECT_TRUE(b > a); + EXPECT_TRUE(b >= a); + EXPECT_TRUE(a != b); + EXPECT_EQ(a.min(b), a); + EXPECT_EQ(a.max(b), b); + EXPECT_NEAR(a.mean(b).jd_value(), a.jd_value() + 1.0, 1e-12); +} + TEST(Time, TtJulianDateConvenienceUtcRoundtrip) { auto jd = JulianDate::from_utc({2026, 7, 15, 22, 0, 0}); auto utc = jd.to_utc();