From 70b2230b12daf0277a32f3985a3399545680b3e6 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 17:24:48 +1100 Subject: [PATCH 1/3] DateTimePrecision: Add New Configurable DateTimeWithTimeZone Format (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mplement HL7 datetime timezone offset support with deterministic UTC default and new precision format constant for field configuration. ## Summary Added configurable timezone offset support for HL7 datetime serialization with a new precision format constant that can be used with `SetPrecision` for field-level configuration. ## Changes **New constant in `Consts.cs`:** - `DateTimeFormatPrecisionSecondWithTimezoneOffset` - Documents the `yyyyMMddHHmmss±HHMM` format pattern for use with `SetPrecision` **New configuration in `Hl7DateTimeFormatConfig.cs`:** - `TimezoneOffset` property (default: `TimeSpan.Zero` for UTC) - Thread-safe global timezone configuration - `ToHl7OffsetString(TimeSpan)` - Converts `TimeSpan` to HL7's `±HHMM` format - `FormatDateTimeWithConfiguredOffset(DateTime)` - Overload for DateTime (treats as UTC) - `FormatDateTimeWithConfiguredOffset(DateTimeOffset)` - Serializes using configured timezone - `FormatDateTimeUsingSourceOffset(DateTime)` - Overload for DateTime (uses configured offset) - `FormatDateTimeUsingSourceOffset(DateTimeOffset)` - Serializes preserving source timezone **Comprehensive documentation:** - Updated README.md showing how to use `SetPrecision` with the new constant - Updated DateTime-Precision-Configuration.md with per-field configuration examples - Added timezone offset support section with helper method documentation - Updated API Reference with new methods and properties **Tests:** - 31 unit tests covering all timezone offset functionality - Tests for DateTime and DateTimeOffset overloads - Tests for default UTC behavior, custom offsets, and source offset preservation - All 956 tests passing ## Key Usage: Configuring Field Precision ```csharp // Configure a field to use timezone offset format Hl7DateTimeFormatConfig.SetPrecision( x => x.DateTimeOfMessage, Consts.DateTimeFormatPrecisionSecondWithTimezoneOffset ); ``` ## Helper Methods Available ```csharp // Default: UTC (+0000) for deterministic behavior var dt = new DateTime(2024, 3, 15, 14, 30, 45); var result = Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset(dt); // => "20240315143045+0000" // Configure custom timezone (e.g., +11:30) Hl7DateTimeFormatConfig.TimezoneOffset = new TimeSpan(11, 30, 0); var result2 = Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset(dt); // => "20240316013045+1130" (converted to +11:30 timezone) // Preserve source offset with DateTimeOffset var dto = new DateTimeOffset(2024, 3, 15, 14, 30, 45, TimeSpan.FromHours(5)); var result3 = Hl7DateTimeFormatConfig.FormatDateTimeUsingSourceOffset(dto); // => "20240315143045+0500" ``` No breaking changes. Existing serialization behavior unchanged. --- DateTime-Precision-Configuration.md | 154 +++++++++ README.md | 79 +++++ src/ClearHl7/Consts.cs | 10 + src/ClearHl7/Hl7DateTimeFormatConfig.cs | 93 ++++++ .../Hl7DateTimeTimezoneOffsetTests.cs | 311 ++++++++++++++++++ .../FormatTests/DateFormatTests.cs | 14 + 6 files changed, 661 insertions(+) create mode 100644 test/ClearHl7.Tests/ConfigurationTests/Hl7DateTimeTimezoneOffsetTests.cs diff --git a/DateTime-Precision-Configuration.md b/DateTime-Precision-Configuration.md index d0f0f56d9..6d6bd9200 100644 --- a/DateTime-Precision-Configuration.md +++ b/DateTime-Precision-Configuration.md @@ -38,6 +38,10 @@ Hl7DateTimeFormatConfig.SetPrecision(x => x.DateTimeOfMessage, Const // Configure EVN.RecordedDateTime to use hour precision Hl7DateTimeFormatConfig.SetPrecision(x => x.RecordedDateTime, Consts.DateTimeFormatPrecisionHour); + +// NEW: Configure a field to use timezone offset format (yyyyMMddHHmmss±HHMM) +Hl7DateTimeFormatConfig.SetPrecision(x => x.DateTimeOfMessage, Consts.DateTimeFormatPrecisionSecondWithTimezoneOffset); +// This documents that the field should include timezone offset in HL7 format ``` ### Available Precision Formats @@ -50,6 +54,7 @@ You can use any of the predefined format constants: - `Consts.DateTimeFormatPrecisionHour` - Date and hour (yyyyMMddHH) - e.g., "2024031514" - `Consts.DateTimeFormatPrecisionMinute` - Date, hour, and minute (yyyyMMddHHmm) - e.g., "202403151430" - `Consts.DateTimeFormatPrecisionSecond` - Full precision (yyyyMMddHHmmss) - e.g., "20240315143045" (default) +- `Consts.DateTimeFormatPrecisionSecondWithTimezoneOffset` - Full precision with timezone (yyyyMMddHHmmss±HHMM) - e.g., "20240315143045+0530" (documentation constant; use helper methods to format) ## Usage Examples @@ -169,6 +174,9 @@ public static class Hl7DateTimeFormatConfig // Global override (null = no global override, uses original precisions) public static string GlobalDateTimeFormatOverride { get; set; } + // Timezone offset configuration (default: TimeSpan.Zero for UTC) + public static TimeSpan TimezoneOffset { get; set; } + // Type-safe per-field configuration public static void SetPrecision(Expression> property, string format); @@ -178,6 +186,13 @@ public static class Hl7DateTimeFormatConfig // Get format for a specific field (respects hierarchy) - DEPRECATED: uses fallback [Obsolete] public static string GetFormatForField(Type segmentType, string propertyName); + // Timezone offset helper methods + public static string ToHl7OffsetString(TimeSpan offset); + public static string FormatDateTimeWithConfiguredOffset(DateTime dt); + public static string FormatDateTimeWithConfiguredOffset(DateTimeOffset dt); + public static string FormatDateTimeUsingSourceOffset(DateTime dt); + public static string FormatDateTimeUsingSourceOffset(DateTimeOffset dt); + // Clear configurations public static void ClearFieldPrecisions(); public static void ClearGlobalOverride(); @@ -225,6 +240,145 @@ Look for the original precision in: 2. Direct `ToString()` calls with `Consts.DateTimeFormatPrecision*` constants 3. Documentation or specifications for the field +## Timezone Offset Support + +### Overview + +In addition to precision configuration, `Hl7DateTimeFormatConfig` provides support for formatting datetime values with HL7-compliant timezone offsets. HL7 requires timezone offsets in `±HHMM` format (without colon), but .NET's standard format strings produce `"+HH:mm"` (with colon). The library provides helper methods to ensure compliant output. + +### Configuration + +The timezone offset configuration is managed through a static property: + +```csharp +// Default: UTC (TimeSpan.Zero) +Hl7DateTimeFormatConfig.TimezoneOffset = TimeSpan.Zero; + +// Configure to use local system timezone +Hl7DateTimeFormatConfig.TimezoneOffset = DateTimeOffset.Now.Offset; + +// Configure to use a specific timezone (e.g., IST +05:30) +Hl7DateTimeFormatConfig.TimezoneOffset = new TimeSpan(5, 30, 0); +``` + +**Default Behavior**: The `TimezoneOffset` property defaults to `TimeSpan.Zero` (UTC, represented as `+0000`), ensuring deterministic behavior across different machines and CI environments. + +### Helper Methods + +The library provides three helper methods for working with timezone offsets: + +#### 1. ToHl7OffsetString(TimeSpan offset) + +Converts a TimeSpan to HL7's `±HHMM` format (without colon). + +```csharp +// Positive offset +var offset1 = Hl7DateTimeFormatConfig.ToHl7OffsetString(TimeSpan.FromHours(5)); +// Returns: "+0500" + +// Negative offset +var offset2 = Hl7DateTimeFormatConfig.ToHl7OffsetString(TimeSpan.FromHours(-5)); +// Returns: "-0500" + +// Offset with minutes +var offset3 = Hl7DateTimeFormatConfig.ToHl7OffsetString(new TimeSpan(5, 30, 0)); +// Returns: "+0530" +``` + +#### 2. FormatDateTimeWithConfiguredOffset(DateTime/DateTimeOffset dt) + +Formats a DateTime or DateTimeOffset using the configured `TimezoneOffset` property. The datetime is converted to the configured timezone before formatting. + +```csharp +// With DateTime (treated as UTC) +var dt1 = new DateTime(2024, 3, 15, 14, 30, 45); +Hl7DateTimeFormatConfig.TimezoneOffset = TimeSpan.Zero; +var result1 = Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset(dt1); +// Returns: "20240315143045+0000" (UTC) + +// With DateTimeOffset +var dt2 = new DateTimeOffset(2024, 3, 15, 14, 30, 45, TimeSpan.FromHours(-5)); +var result2 = Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset(dt2); +// Returns: "20240315193045+0000" (converted to UTC from EST) + +// Change to a different timezone +Hl7DateTimeFormatConfig.TimezoneOffset = new TimeSpan(5, 30, 0); +result1 = Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset(dt1); +// Returns: "20240315200045+0530" (converted to IST) +``` + +#### 3. FormatDateTimeUsingSourceOffset(DateTime/DateTimeOffset dt) + +Formats a DateTime or DateTimeOffset using the configured timezone offset. For DateTimeOffset, it preserves the source timezone. + +```csharp +// With DateTime (uses configured offset) +var dt1 = new DateTime(2024, 3, 15, 14, 30, 45); +Hl7DateTimeFormatConfig.TimezoneOffset = new TimeSpan(5, 30, 0); +var result1 = Hl7DateTimeFormatConfig.FormatDateTimeUsingSourceOffset(dt1); +// Returns: "20240315143045+0530" (uses configured offset) + +// With DateTimeOffset (preserves source offset) +var dt2 = new DateTimeOffset(2024, 3, 15, 14, 30, 45, TimeSpan.FromHours(5)); +var result2 = Hl7DateTimeFormatConfig.FormatDateTimeUsingSourceOffset(dt2); +// Returns: "20240315143045+0500" (preserves the +05:00 offset) + +var dt3 = new DateTimeOffset(2024, 3, 15, 14, 30, 45, TimeSpan.FromHours(-5)); +var result3 = Hl7DateTimeFormatConfig.FormatDateTimeUsingSourceOffset(dt3); +// Returns: "20240315143045-0500" (preserves the -05:00 offset) +``` + +### Usage Examples + +#### Scenario 1: Deterministic UTC Output (Default) + +```csharp +// No configuration needed - uses UTC by default +var dt = DateTimeOffset.Now; // Any timezone +var hl7String = Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset(dt); +// Always produces UTC time with +0000 offset +``` + +#### Scenario 2: Application-Wide Local Timezone + +```csharp +// Set once at application startup +Hl7DateTimeFormatConfig.TimezoneOffset = DateTimeOffset.Now.Offset; + +// All subsequent calls use the local timezone +var dt1 = new DateTimeOffset(2024, 3, 15, 14, 30, 45, TimeSpan.Zero); +var hl7String1 = Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset(dt1); +// Converts from UTC to local timezone + +var dt2 = new DateTimeOffset(2024, 6, 20, 10, 15, 30, TimeSpan.FromHours(3)); +var hl7String2 = Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset(dt2); +// Converts from +03:00 to local timezone +``` + +#### Scenario 3: Preserving Source Timezone Information + +```csharp +// When you need to preserve the original timezone information +var dt = new DateTimeOffset(2024, 3, 15, 14, 30, 45, TimeSpan.FromHours(8)); +var hl7String = Hl7DateTimeFormatConfig.FormatDateTimeUsingSourceOffset(dt); +// Keeps the +08:00 timezone: "20240315143045+0800" +``` + +### HL7 Compliance + +The library ensures that all timezone offsets are formatted according to HL7 requirements: +- Format: `±HHMM` (without colon) +- Examples: + - UTC: `+0000` + - EST: `-0500` + - IST: `+0530` + - ACDT: `+1030` + - PST: `-0800` + +### Thread Safety + +The `TimezoneOffset` property is thread-safe and uses locks for concurrent access. However, it's recommended to set this configuration once at application startup rather than changing it frequently during runtime. + ## Thread Safety The configuration is stored in static properties. In multi-threaded environments, ensure proper synchronization when changing the configuration to avoid race conditions. diff --git a/README.md b/README.md index c3fa07f9c..d5325a584 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,9 @@ Hl7DateTimeFormatConfig.GlobalDateTimeFormatOverride = Consts.DateTimeFormatPrec Hl7DateTimeFormatConfig.SetPrecision(x => x.DateTimeOfMessage, Consts.DateFormatPrecisionDay); Hl7DateTimeFormatConfig.SetPrecision(x => x.RecordedDateTime, Consts.DateTimeFormatPrecisionHour); +// NEW: Configure field to use timezone offset format +Hl7DateTimeFormatConfig.SetPrecision(x => x.DateTimeOfMessage, Consts.DateTimeFormatPrecisionSecondWithTimezoneOffset); + // Clear overrides to revert to default behavior Hl7DateTimeFormatConfig.ClearGlobalOverride(); Hl7DateTimeFormatConfig.ClearFieldPrecisions(); @@ -319,6 +322,82 @@ Hl7DateTimeFormatConfig.ClearFieldPrecisions(); - `Consts.DateTimeFormatPrecisionHour` - Date and hour (yyyyMMddHH) - e.g., "2024031514" - `Consts.DateTimeFormatPrecisionMinute` - Date, hour, and minute (yyyyMMddHHmm) - e.g., "202403151430" - `Consts.DateTimeFormatPrecisionSecond` - Full precision (yyyyMMddHHmmss) - e.g., "20240315143045" +- `Consts.DateTimeFormatPrecisionSecondWithTimezoneOffset` - Full precision with timezone offset (yyyyMMddHHmmss±HHMM) - e.g., "20240315143045+0530" (documentation constant; use helper methods below to format with HL7-compliant offsets) + +#### Timezone Offset Support +clear-hl7-net provides deterministic timezone offset support for HL7 datetime values. By default, all datetime values with timezone offsets are serialized using UTC (+0000) to ensure consistent, deterministic behavior across different machines and CI environments. + +##### Default Behavior (UTC) +```csharp +using ClearHl7; + +// Default: Uses UTC offset (+0000) +var dt = new DateTimeOffset(2024, 3, 15, 14, 30, 45, TimeSpan.FromHours(-5)); +var result = Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset(dt); +// Result: "20240315193045+0000" (converted to UTC) +``` + +##### Using System Local Timezone +```csharp +using ClearHl7; + +// Configure to use local system timezone +Hl7DateTimeFormatConfig.TimezoneOffset = DateTimeOffset.Now.Offset; + +var dt = new DateTimeOffset(2024, 3, 15, 14, 30, 45, TimeSpan.Zero); +var result = Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset(dt); +// Result: "20240315093045-0500" (if local offset is -05:00) +``` + +##### Using Custom Timezone Offset +```csharp +using ClearHl7; + +// Configure a specific timezone offset (e.g., IST +05:30) +Hl7DateTimeFormatConfig.TimezoneOffset = new TimeSpan(5, 30, 0); + +var dt = new DateTimeOffset(2024, 3, 15, 14, 30, 45, TimeSpan.Zero); +var result = Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset(dt); +// Result: "20240315200045+0530" (converted to +05:30) +``` + +##### Preserving Source Offset +```csharp +using ClearHl7; + +// Use the source DateTimeOffset's own timezone offset +var dt = new DateTimeOffset(2024, 3, 15, 14, 30, 45, TimeSpan.FromHours(5)); +var result = Hl7DateTimeFormatConfig.FormatDateTimeUsingSourceOffset(dt); +// Result: "20240315143045+0500" (preserves original +05:00 offset) +``` + +##### HL7 Offset Format +The library ensures HL7-compliant timezone offsets in ±HHMM format (without colon), as required by the HL7 standard: +- UTC: `+0000` +- EST: `-0500` +- IST: `+0530` +- ACDT: `+1030` + +##### Helper Methods +The `Hl7DateTimeFormatConfig` class provides three helper methods for working with timezone offsets: + +1. **`ToHl7OffsetString(TimeSpan offset)`** - Converts a TimeSpan to HL7's ±HHMM format (without colon) + ```csharp + var offsetString = Hl7DateTimeFormatConfig.ToHl7OffsetString(TimeSpan.FromHours(5)); + // Returns: "+0500" + ``` + +2. **`FormatDateTimeWithConfiguredOffset(DateTimeOffset dt)`** - Formats using the configured `TimezoneOffset` property + - Converts the DateTimeOffset to the configured timezone + - Returns HL7 format: `yyyyMMddHHmmss±HHMM` + - Uses the global `TimezoneOffset` configuration (defaults to UTC) + +3. **`FormatDateTimeUsingSourceOffset(DateTimeOffset dt)`** - Formats using the DateTimeOffset's own timezone + - Preserves the original timezone of the DateTimeOffset + - Returns HL7 format: `yyyyMMddHHmmss±HHMM` + - Useful when you want to keep the source timezone information + +**Note**: The configured `TimezoneOffset` is global and thread-safe. Set it once at application startup for consistent behavior throughout your application. For detailed documentation and advanced scenarios, see [DateTime Precision Configuration](DateTime-Precision-Configuration.md). diff --git a/src/ClearHl7/Consts.cs b/src/ClearHl7/Consts.cs index 627411d38..b355ad85d 100644 --- a/src/ClearHl7/Consts.cs +++ b/src/ClearHl7/Consts.cs @@ -75,6 +75,16 @@ public static class Consts /// public const string DateTimeFormatPrecisionHour = "yyyyMMddHH"; + /// + /// HL7 datetime format with second precision and timezone offset (yyyyMMddHHmmss±HHMM). + /// Note: The offset must be appended manually as HL7 requires ±HHMM format without colon, + /// while .NET's standard format strings (e.g., "zzz") produce "+HH:mm" with a colon. + /// Use Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset or + /// Hl7DateTimeFormatConfig.FormatDateTimeUsingSourceOffset helper methods + /// to format datetime values with HL7-compliant timezone offsets. + /// + public const string DateTimeFormatPrecisionSecondWithTimezoneOffset = "yyyyMMddHHmmss±HHMM"; + /// /// Standard time format string. /// diff --git a/src/ClearHl7/Hl7DateTimeFormatConfig.cs b/src/ClearHl7/Hl7DateTimeFormatConfig.cs index 0efebfc4e..bfe333648 100644 --- a/src/ClearHl7/Hl7DateTimeFormatConfig.cs +++ b/src/ClearHl7/Hl7DateTimeFormatConfig.cs @@ -12,6 +12,32 @@ public static class Hl7DateTimeFormatConfig { private static readonly object _globalOverrideLock = new object(); private static string _globalDateTimeFormatOverride = null; + private static readonly object _timezoneOffsetLock = new object(); + private static TimeSpan _timezoneOffset = TimeSpan.Zero; + + /// + /// Gets or sets the timezone offset to use when serializing DateTime/DateTimeOffset values with timezone information. + /// Default is TimeSpan.Zero (UTC, represented as +0000 in HL7 format). + /// Set to DateTimeOffset.Now.Offset to use the system's local timezone offset. + /// Thread-safe. + /// + public static TimeSpan TimezoneOffset + { + get + { + lock (_timezoneOffsetLock) + { + return _timezoneOffset; + } + } + set + { + lock (_timezoneOffsetLock) + { + _timezoneOffset = value; + } + } + } /// /// Gets or sets the global DateTime format override for all DateTime fields. @@ -133,5 +159,72 @@ public static bool HasGlobalOverride } } } + + /// + /// Converts a TimeSpan offset to HL7-compliant offset string format (±HHMM without colon). + /// + /// The timezone offset to convert. + /// A string in ±HHMM format, e.g., "+0000", "-0500", "+0530". + public static string ToHl7OffsetString(TimeSpan offset) + { + var sign = offset.TotalMinutes >= 0 ? "+" : "-"; + var absoluteOffset = offset.Duration(); + var hours = (int)absoluteOffset.TotalHours; + var minutes = absoluteOffset.Minutes; + return $"{sign}{hours:D2}{minutes:D2}"; + } + + /// + /// Formats a DateTime using the configured TimezoneOffset. + /// The datetime is treated as UTC and converted to the configured timezone, then formatted as yyyyMMddHHmmss±HHMM. + /// + /// The DateTime to format (treated as UTC). + /// An HL7-formatted datetime string with the configured timezone offset. + public static string FormatDateTimeWithConfiguredOffset(DateTime dt) + { + var dateTimeOffset = new DateTimeOffset(dt, TimeSpan.Zero); + return FormatDateTimeWithConfiguredOffset(dateTimeOffset); + } + + /// + /// Formats a DateTimeOffset using the configured TimezoneOffset. + /// The datetime is converted to the configured timezone and formatted as yyyyMMddHHmmss±HHMM. + /// + /// The DateTimeOffset to format. + /// An HL7-formatted datetime string with the configured timezone offset. + public static string FormatDateTimeWithConfiguredOffset(DateTimeOffset dt) + { + var configuredOffset = TimezoneOffset; + var convertedDt = dt.ToOffset(configuredOffset); + var baseString = convertedDt.ToString(Consts.DateTimeFormatPrecisionSecond); + var offsetString = ToHl7OffsetString(configuredOffset); + return baseString + offsetString; + } + + /// + /// Formats a DateTime using the configured TimezoneOffset. + /// The datetime is treated as unspecified and the configured timezone offset is applied. + /// + /// The DateTime to format. + /// An HL7-formatted datetime string with the configured timezone offset. + public static string FormatDateTimeUsingSourceOffset(DateTime dt) + { + var baseString = dt.ToString(Consts.DateTimeFormatPrecisionSecond); + var offsetString = ToHl7OffsetString(TimezoneOffset); + return baseString + offsetString; + } + + /// + /// Formats a DateTimeOffset using its own offset (preserves the source offset). + /// The datetime is formatted as yyyyMMddHHmmss±HHMM using dt.Offset. + /// + /// The DateTimeOffset to format. + /// An HL7-formatted datetime string with the source timezone offset. + public static string FormatDateTimeUsingSourceOffset(DateTimeOffset dt) + { + var baseString = dt.ToString(Consts.DateTimeFormatPrecisionSecond); + var offsetString = ToHl7OffsetString(dt.Offset); + return baseString + offsetString; + } } } \ No newline at end of file diff --git a/test/ClearHl7.Tests/ConfigurationTests/Hl7DateTimeTimezoneOffsetTests.cs b/test/ClearHl7.Tests/ConfigurationTests/Hl7DateTimeTimezoneOffsetTests.cs new file mode 100644 index 000000000..009181321 --- /dev/null +++ b/test/ClearHl7.Tests/ConfigurationTests/Hl7DateTimeTimezoneOffsetTests.cs @@ -0,0 +1,311 @@ +using System; +using Xunit; + +namespace ClearHl7.Tests.ConfigurationTests +{ + [Collection("Hl7DateTimeFormatConfig")] + public class Hl7DateTimeTimezoneOffsetTests : IDisposable + { + public Hl7DateTimeTimezoneOffsetTests() + { + // Reset configuration before each test to ensure clean state + Hl7DateTimeFormatConfig.TimezoneOffset = TimeSpan.Zero; + } + + public void Dispose() + { + // Reset configuration after each test to prevent affecting other tests + Hl7DateTimeFormatConfig.TimezoneOffset = TimeSpan.Zero; + } + + [Fact] + public void TimezoneOffset_DefaultValue_IsUtcZero() + { + // Arrange & Act + var offset = Hl7DateTimeFormatConfig.TimezoneOffset; + + // Assert + Assert.Equal(TimeSpan.Zero, offset); + } + + [Fact] + public void TimezoneOffset_SetValue_IsThreadSafe() + { + // Arrange + var newOffset = TimeSpan.FromHours(5); + + // Act + Hl7DateTimeFormatConfig.TimezoneOffset = newOffset; + var retrievedOffset = Hl7DateTimeFormatConfig.TimezoneOffset; + + // Assert + Assert.Equal(newOffset, retrievedOffset); + } + + [Theory] + [InlineData(0, 0, "+0000")] + [InlineData(5, 0, "+0500")] + [InlineData(-5, 0, "-0500")] + [InlineData(5, 30, "+0530")] + [InlineData(-4, -30, "-0430")] + [InlineData(10, 0, "+1000")] + [InlineData(-10, 0, "-1000")] + [InlineData(0, 30, "+0030")] + [InlineData(0, -30, "-0030")] + [InlineData(12, 45, "+1245")] + [InlineData(-12, -45, "-1245")] + public void ToHl7OffsetString_WithVariousOffsets_ReturnsCorrectFormat(int hours, int minutes, string expected) + { + // Arrange + var offset = new TimeSpan(hours, minutes, 0); + + // Act + var result = Hl7DateTimeFormatConfig.ToHl7OffsetString(offset); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void ToHl7OffsetString_WithZeroOffset_ReturnsPlus0000() + { + // Arrange + var offset = TimeSpan.Zero; + + // Act + var result = Hl7DateTimeFormatConfig.ToHl7OffsetString(offset); + + // Assert + Assert.Equal("+0000", result); + } + + [Fact] + public void FormatDateTimeWithConfiguredOffset_WithDefaultUtc_ReturnsUtcTimestamp() + { + // Arrange + var dt = new DateTimeOffset(2024, 3, 15, 14, 30, 45, TimeSpan.FromHours(-5)); + Hl7DateTimeFormatConfig.TimezoneOffset = TimeSpan.Zero; + + // Act + var result = Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset(dt); + + // Assert - DateTime converted to UTC + Assert.Equal("20240315193045+0000", result); + } + + [Fact] + public void FormatDateTimeWithConfiguredOffset_WithCustomOffset_ReturnsConvertedTimestamp() + { + // Arrange + var dt = new DateTimeOffset(2024, 3, 15, 14, 30, 45, TimeSpan.Zero); + Hl7DateTimeFormatConfig.TimezoneOffset = TimeSpan.FromHours(5); + + // Act + var result = Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset(dt); + + // Assert - DateTime converted to +0500 + Assert.Equal("20240315193045+0500", result); + } + + [Fact] + public void FormatDateTimeWithConfiguredOffset_WithNegativeOffset_ReturnsCorrectTimestamp() + { + // Arrange + var dt = new DateTimeOffset(2024, 3, 15, 14, 30, 45, TimeSpan.Zero); + Hl7DateTimeFormatConfig.TimezoneOffset = TimeSpan.FromHours(-5); + + // Act + var result = Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset(dt); + + // Assert - DateTime converted to -0500 + Assert.Equal("20240315093045-0500", result); + } + + [Fact] + public void FormatDateTimeWithConfiguredOffset_WithOffsetIncludingMinutes_ReturnsCorrectTimestamp() + { + // Arrange + var dt = new DateTimeOffset(2024, 3, 15, 14, 30, 45, TimeSpan.Zero); + Hl7DateTimeFormatConfig.TimezoneOffset = new TimeSpan(5, 30, 0); + + // Act + var result = Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset(dt); + + // Assert - DateTime converted to +0530 + Assert.Equal("20240315200045+0530", result); + } + + [Fact] + public void FormatDateTimeUsingSourceOffset_WithPositiveOffset_ReturnsSourceTimestamp() + { + // Arrange + var dt = new DateTimeOffset(2024, 3, 15, 14, 30, 45, TimeSpan.FromHours(5)); + + // Act + var result = Hl7DateTimeFormatConfig.FormatDateTimeUsingSourceOffset(dt); + + // Assert - Uses source offset + Assert.Equal("20240315143045+0500", result); + } + + [Fact] + public void FormatDateTimeUsingSourceOffset_WithNegativeOffset_ReturnsSourceTimestamp() + { + // Arrange + var dt = new DateTimeOffset(2024, 3, 15, 14, 30, 45, TimeSpan.FromHours(-5)); + + // Act + var result = Hl7DateTimeFormatConfig.FormatDateTimeUsingSourceOffset(dt); + + // Assert - Uses source offset + Assert.Equal("20240315143045-0500", result); + } + + [Fact] + public void FormatDateTimeUsingSourceOffset_WithUtcOffset_ReturnsUtcTimestamp() + { + // Arrange + var dt = new DateTimeOffset(2024, 3, 15, 14, 30, 45, TimeSpan.Zero); + + // Act + var result = Hl7DateTimeFormatConfig.FormatDateTimeUsingSourceOffset(dt); + + // Assert - Uses UTC offset + Assert.Equal("20240315143045+0000", result); + } + + [Fact] + public void FormatDateTimeUsingSourceOffset_WithOffsetIncludingMinutes_ReturnsCorrectTimestamp() + { + // Arrange + var dt = new DateTimeOffset(2024, 3, 15, 14, 30, 45, new TimeSpan(5, 30, 0)); + + // Act + var result = Hl7DateTimeFormatConfig.FormatDateTimeUsingSourceOffset(dt); + + // Assert - Uses source offset with minutes + Assert.Equal("20240315143045+0530", result); + } + + [Fact] + public void FormatDateTimeWithConfiguredOffset_PreservesDateTime_WhenConvertingOffsets() + { + // Arrange - Create a datetime at midnight UTC + var dt = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); + Hl7DateTimeFormatConfig.TimezoneOffset = TimeSpan.FromHours(-5); + + // Act + var result = Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset(dt); + + // Assert - Should be the previous day at 19:00 in -0500 timezone + Assert.Equal("20231231190000-0500", result); + } + + [Fact] + public void Constants_HaveCorrectValues() + { + // Assert + Assert.Equal("yyyyMMddHHmmss±HHMM", Consts.DateTimeFormatPrecisionSecondWithTimezoneOffset); + } + + [Fact] + public void FormatDateTimeWithConfiguredOffset_DoesNotAffectOtherInstances_WhenOffsetChanges() + { + // Arrange + var dt1 = new DateTimeOffset(2024, 3, 15, 14, 30, 45, TimeSpan.Zero); + Hl7DateTimeFormatConfig.TimezoneOffset = TimeSpan.FromHours(2); + var result1 = Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset(dt1); + + // Act - Change offset + Hl7DateTimeFormatConfig.TimezoneOffset = TimeSpan.FromHours(5); + var dt2 = new DateTimeOffset(2024, 3, 15, 14, 30, 45, TimeSpan.Zero); + var result2 = Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset(dt2); + + // Assert - Results should be different based on when they were called + Assert.Equal("20240315163045+0200", result1); + Assert.Equal("20240315193045+0500", result2); + } + + [Fact] + public void ToHl7OffsetString_WithLargePositiveOffset_FormatsCorrectly() + { + // Arrange + var offset = TimeSpan.FromHours(14); // Maximum typical offset + + // Act + var result = Hl7DateTimeFormatConfig.ToHl7OffsetString(offset); + + // Assert + Assert.Equal("+1400", result); + } + + [Fact] + public void ToHl7OffsetString_WithLargeNegativeOffset_FormatsCorrectly() + { + // Arrange + var offset = TimeSpan.FromHours(-12); // Common Pacific offset + + // Act + var result = Hl7DateTimeFormatConfig.ToHl7OffsetString(offset); + + // Assert + Assert.Equal("-1200", result); + } + + [Fact] + public void FormatDateTimeWithConfiguredOffset_WithDateTime_DefaultUtc_ReturnsUtcTimestamp() + { + // Arrange + var dt = new DateTime(2024, 3, 15, 14, 30, 45); + Hl7DateTimeFormatConfig.TimezoneOffset = TimeSpan.Zero; + + // Act + var result = Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset(dt); + + // Assert - DateTime treated as UTC + Assert.Equal("20240315143045+0000", result); + } + + [Fact] + public void FormatDateTimeWithConfiguredOffset_WithDateTime_CustomOffset_ReturnsConvertedTimestamp() + { + // Arrange + var dt = new DateTime(2024, 3, 15, 14, 30, 45); + Hl7DateTimeFormatConfig.TimezoneOffset = new TimeSpan(5, 30, 0); + + // Act + var result = Hl7DateTimeFormatConfig.FormatDateTimeWithConfiguredOffset(dt); + + // Assert - DateTime treated as UTC, converted to +0530 + Assert.Equal("20240315200045+0530", result); + } + + [Fact] + public void FormatDateTimeUsingSourceOffset_WithDateTime_UsesConfiguredOffset() + { + // Arrange + var dt = new DateTime(2024, 3, 15, 14, 30, 45); + Hl7DateTimeFormatConfig.TimezoneOffset = new TimeSpan(5, 30, 0); + + // Act + var result = Hl7DateTimeFormatConfig.FormatDateTimeUsingSourceOffset(dt); + + // Assert - Uses configured offset + Assert.Equal("20240315143045+0530", result); + } + + [Fact] + public void FormatDateTimeUsingSourceOffset_WithDateTime_DefaultUtc() + { + // Arrange + var dt = new DateTime(2024, 3, 15, 14, 30, 45); + Hl7DateTimeFormatConfig.TimezoneOffset = TimeSpan.Zero; + + // Act + var result = Hl7DateTimeFormatConfig.FormatDateTimeUsingSourceOffset(dt); + + // Assert - Uses UTC offset + Assert.Equal("20240315143045+0000", result); + } + } +} diff --git a/test/ClearHl7.Tests/FormatTests/DateFormatTests.cs b/test/ClearHl7.Tests/FormatTests/DateFormatTests.cs index d8eeb0563..6596d7416 100644 --- a/test/ClearHl7.Tests/FormatTests/DateFormatTests.cs +++ b/test/ClearHl7.Tests/FormatTests/DateFormatTests.cs @@ -148,5 +148,19 @@ public void ToString_WithTimeFormatPrecisionHour_ReturnsCorrectlyFormattedString Assert.Equal(expected, actual); } + + /// + /// Validates that the DateTimeFormatPrecisionSecondWithTimezoneOffset constant is documented correctly. + /// Note: This constant documents the format but the offset must be appended manually using helper methods + /// because .NET's standard format tokens produce "+HH:mm" with a colon, not HL7's "±HHMM" without colon. + /// + [Fact] + public void Constant_DateTimeFormatPrecisionSecondWithTimezoneOffset_HasCorrectValue() + { + string expected = "yyyyMMddHHmmss±HHMM"; + string actual = Consts.DateTimeFormatPrecisionSecondWithTimezoneOffset; + + Assert.Equal(expected, actual); + } } } From 09dc71e032eecca654a1f07b483a0628cfbec8df Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 15:37:16 +1100 Subject: [PATCH 2/3] Add timezone offset validation and fix test data for HL7 datetime formatting (#20) * Initial plan * Add timezone offset validation and comprehensive tests Co-authored-by: aarondglover <8821892+aarondglover@users.noreply.github.com> * Add validation to TimezoneOffset property setter Co-authored-by: aarondglover <8821892+aarondglover@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: aarondglover <8821892+aarondglover@users.noreply.github.com> --- src/ClearHl7/Hl7DateTimeFormatConfig.cs | 34 +++++- .../Hl7DateTimeTimezoneOffsetTests.cs | 107 +++++++++++++++++- 2 files changed, 137 insertions(+), 4 deletions(-) diff --git a/src/ClearHl7/Hl7DateTimeFormatConfig.cs b/src/ClearHl7/Hl7DateTimeFormatConfig.cs index bfe333648..1bbff3c94 100644 --- a/src/ClearHl7/Hl7DateTimeFormatConfig.cs +++ b/src/ClearHl7/Hl7DateTimeFormatConfig.cs @@ -21,6 +21,12 @@ public static class Hl7DateTimeFormatConfig /// Set to DateTimeOffset.Now.Offset to use the system's local timezone offset. /// Thread-safe. /// + /// Thrown when the value is outside the valid range of -12:00 to +14:00. + /// + /// Valid timezone offsets typically range from -12:00 (UTC-12) to +14:00 (UTC+14). + /// When using TimeSpan constructor with negative offsets, note that for a negative offset like -4 hours 30 minutes, + /// you must use new TimeSpan(-4, -30, 0) where both components are negative, or use TimeSpan.FromMinutes(-270) for clarity. + /// public static TimeSpan TimezoneOffset { get @@ -32,6 +38,16 @@ public static TimeSpan TimezoneOffset } set { + // Validate offset is within typical timezone range: -12:00 to +14:00 + if (value < TimeSpan.FromHours(-12) || value > TimeSpan.FromHours(14)) + { + throw new ArgumentOutOfRangeException( + nameof(value), + value, + "Timezone offset must be between -12:00 and +14:00 hours. " + + $"Provided offset: {value.TotalHours:F2} hours."); + } + lock (_timezoneOffsetLock) { _timezoneOffset = value; @@ -163,10 +179,26 @@ public static bool HasGlobalOverride /// /// Converts a TimeSpan offset to HL7-compliant offset string format (±HHMM without colon). /// - /// The timezone offset to convert. + /// The timezone offset to convert. Must be between -12:00 and +14:00 hours (typical timezone range). /// A string in ±HHMM format, e.g., "+0000", "-0500", "+0530". + /// Thrown when offset is outside the valid range of -12:00 to +14:00. + /// + /// Valid timezone offsets typically range from -12:00 (UTC-12) to +14:00 (UTC+14). + /// When using TimeSpan constructor with negative offsets, note that for a negative offset like -4 hours 30 minutes, + /// you must use new TimeSpan(-4, -30, 0) where both components are negative, or use TimeSpan.FromMinutes(-270) for clarity. + /// public static string ToHl7OffsetString(TimeSpan offset) { + // Validate offset is within typical timezone range: -12:00 to +14:00 + if (offset < TimeSpan.FromHours(-12) || offset > TimeSpan.FromHours(14)) + { + throw new ArgumentOutOfRangeException( + nameof(offset), + offset, + "Timezone offset must be between -12:00 and +14:00 hours. " + + $"Provided offset: {offset.TotalHours:F2} hours."); + } + var sign = offset.TotalMinutes >= 0 ? "+" : "-"; var absoluteOffset = offset.Duration(); var hours = (int)absoluteOffset.TotalHours; diff --git a/test/ClearHl7.Tests/ConfigurationTests/Hl7DateTimeTimezoneOffsetTests.cs b/test/ClearHl7.Tests/ConfigurationTests/Hl7DateTimeTimezoneOffsetTests.cs index 009181321..faf78b6ef 100644 --- a/test/ClearHl7.Tests/ConfigurationTests/Hl7DateTimeTimezoneOffsetTests.cs +++ b/test/ClearHl7.Tests/ConfigurationTests/Hl7DateTimeTimezoneOffsetTests.cs @@ -42,18 +42,61 @@ public void TimezoneOffset_SetValue_IsThreadSafe() Assert.Equal(newOffset, retrievedOffset); } + [Fact] + public void TimezoneOffset_SetValidEdgeCases_Succeeds() + { + // Test maximum positive offset boundary (UTC+14) + Hl7DateTimeFormatConfig.TimezoneOffset = TimeSpan.FromHours(14); + Assert.Equal(TimeSpan.FromHours(14), Hl7DateTimeFormatConfig.TimezoneOffset); + + // Test maximum negative offset boundary (UTC-12) + Hl7DateTimeFormatConfig.TimezoneOffset = TimeSpan.FromHours(-12); + Assert.Equal(TimeSpan.FromHours(-12), Hl7DateTimeFormatConfig.TimezoneOffset); + + // Test offset with fractional hours (e.g., India UTC+5:30) + var indiaOffset = new TimeSpan(5, 30, 0); + Hl7DateTimeFormatConfig.TimezoneOffset = indiaOffset; + Assert.Equal(indiaOffset, Hl7DateTimeFormatConfig.TimezoneOffset); + + // Reset to default + Hl7DateTimeFormatConfig.TimezoneOffset = TimeSpan.Zero; + } + + [Theory] + [InlineData(15, 0)] // UTC+15 exceeds maximum + [InlineData(14, 1)] // UTC+14:01 exceeds maximum + [InlineData(-13, 0)] // UTC-13 exceeds minimum + [InlineData(-12, -1)] // UTC-12:01 exceeds minimum + [InlineData(24, 0)] // UTC+24 far exceeds maximum + [InlineData(100, 0)] // UTC+100 extremely out of range + public void TimezoneOffset_SetInvalidValue_ThrowsArgumentOutOfRangeException(int hours, int minutes) + { + // Arrange + var invalidOffset = new TimeSpan(hours, minutes, 0); + + // Act & Assert + var exception = Assert.Throws(() => + Hl7DateTimeFormatConfig.TimezoneOffset = invalidOffset); + + Assert.Equal("value", exception.ParamName); + Assert.Contains("between -12:00 and +14:00", exception.Message); + + // Ensure the property value hasn't changed + Assert.Equal(TimeSpan.Zero, Hl7DateTimeFormatConfig.TimezoneOffset); + } + [Theory] [InlineData(0, 0, "+0000")] [InlineData(5, 0, "+0500")] [InlineData(-5, 0, "-0500")] [InlineData(5, 30, "+0530")] - [InlineData(-4, -30, "-0430")] + [InlineData(-4, -30, "-0430")] // Note: Both hours and minutes must be negative for negative offsets [InlineData(10, 0, "+1000")] [InlineData(-10, 0, "-1000")] [InlineData(0, 30, "+0030")] [InlineData(0, -30, "-0030")] - [InlineData(12, 45, "+1245")] - [InlineData(-12, -45, "-1245")] + [InlineData(12, 0, "+1200")] + [InlineData(-12, 0, "-1200")] public void ToHl7OffsetString_WithVariousOffsets_ReturnsCorrectFormat(int hours, int minutes, string expected) { // Arrange @@ -252,6 +295,64 @@ public void ToHl7OffsetString_WithLargeNegativeOffset_FormatsCorrectly() Assert.Equal("-1200", result); } + [Fact] + public void ToHl7OffsetString_WithValidEdgeCases_FormatsCorrectly() + { + // Test maximum positive offset boundary (UTC+14) + var maxPositive = TimeSpan.FromHours(14); + Assert.Equal("+1400", Hl7DateTimeFormatConfig.ToHl7OffsetString(maxPositive)); + + // Test maximum negative offset boundary (UTC-12) + var maxNegative = TimeSpan.FromHours(-12); + Assert.Equal("-1200", Hl7DateTimeFormatConfig.ToHl7OffsetString(maxNegative)); + + // Test offset with fractional hours (e.g., Nepal UTC+5:45) + var nepalOffset = new TimeSpan(5, 45, 0); + Assert.Equal("+0545", Hl7DateTimeFormatConfig.ToHl7OffsetString(nepalOffset)); + + // Test offset with 30-minute increment (e.g., India UTC+5:30) + var indiaOffset = new TimeSpan(5, 30, 0); + Assert.Equal("+0530", Hl7DateTimeFormatConfig.ToHl7OffsetString(indiaOffset)); + } + + [Theory] + [InlineData(15, 0)] // UTC+15 exceeds maximum + [InlineData(14, 1)] // UTC+14:01 exceeds maximum + [InlineData(-13, 0)] // UTC-13 exceeds minimum + [InlineData(-12, -1)] // UTC-12:01 exceeds minimum + [InlineData(24, 0)] // UTC+24 far exceeds maximum + [InlineData(100, 0)] // UTC+100 extremely out of range + public void ToHl7OffsetString_WithInvalidOffset_ThrowsArgumentOutOfRangeException(int hours, int minutes) + { + // Arrange + var invalidOffset = new TimeSpan(hours, minutes, 0); + + // Act & Assert + var exception = Assert.Throws(() => + Hl7DateTimeFormatConfig.ToHl7OffsetString(invalidOffset)); + + Assert.Equal("offset", exception.ParamName); + Assert.Contains("between -12:00 and +14:00", exception.Message); + } + + [Fact] + public void ToHl7OffsetString_WithNegativeOffset_UsesCorrectTimeSpanConstructor() + { + // This test demonstrates the correct way to construct negative TimeSpan offsets + // For a negative offset like UTC-4:30, both hours and minutes must be negative + + // Correct way 1: Using negative values for both components + var offset1 = new TimeSpan(-4, -30, 0); + Assert.Equal("-0430", Hl7DateTimeFormatConfig.ToHl7OffsetString(offset1)); + + // Correct way 2: Using FromMinutes for clarity + var offset2 = TimeSpan.FromMinutes(-270); // -4.5 hours = -270 minutes + Assert.Equal("-0430", Hl7DateTimeFormatConfig.ToHl7OffsetString(offset2)); + + // Both approaches produce the same result + Assert.Equal(offset1, offset2); + } + [Fact] public void FormatDateTimeWithConfiguredOffset_WithDateTime_DefaultUtc_ReturnsUtcTimestamp() { From ac895d5ce070aeb241e3a6245afd930d8c27752c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Nov 2025 01:40:14 +1100 Subject: [PATCH 3/3] Cache TimeSpan boundary values to avoid repeated allocations (#21) * Cache TimeSpan boundary values as static readonly fields Co-authored-by: aarondglover <8821892+aarondglover@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: aarondglover <8821892+aarondglover@users.noreply.github.com> --- src/ClearHl7/Hl7DateTimeFormatConfig.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ClearHl7/Hl7DateTimeFormatConfig.cs b/src/ClearHl7/Hl7DateTimeFormatConfig.cs index 1bbff3c94..1e3860435 100644 --- a/src/ClearHl7/Hl7DateTimeFormatConfig.cs +++ b/src/ClearHl7/Hl7DateTimeFormatConfig.cs @@ -14,6 +14,8 @@ public static class Hl7DateTimeFormatConfig private static string _globalDateTimeFormatOverride = null; private static readonly object _timezoneOffsetLock = new object(); private static TimeSpan _timezoneOffset = TimeSpan.Zero; + private static readonly TimeSpan MinTimezoneOffset = TimeSpan.FromHours(-12); + private static readonly TimeSpan MaxTimezoneOffset = TimeSpan.FromHours(14); /// /// Gets or sets the timezone offset to use when serializing DateTime/DateTimeOffset values with timezone information. @@ -39,7 +41,7 @@ public static TimeSpan TimezoneOffset set { // Validate offset is within typical timezone range: -12:00 to +14:00 - if (value < TimeSpan.FromHours(-12) || value > TimeSpan.FromHours(14)) + if (value < MinTimezoneOffset || value > MaxTimezoneOffset) { throw new ArgumentOutOfRangeException( nameof(value), @@ -190,7 +192,7 @@ public static bool HasGlobalOverride public static string ToHl7OffsetString(TimeSpan offset) { // Validate offset is within typical timezone range: -12:00 to +14:00 - if (offset < TimeSpan.FromHours(-12) || offset > TimeSpan.FromHours(14)) + if (offset < MinTimezoneOffset || offset > MaxTimezoneOffset) { throw new ArgumentOutOfRangeException( nameof(offset),