From 0f7af7f583d9fb75d90714129e9c5b6249ac35ec Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 27 Jul 2025 12:57:59 +0200 Subject: [PATCH 1/4] feat: parse time zones agnostic of locations --- .vscode/settings.json | 3 +- docs/data_types.md | 30 +- .../test/model/path_parameter_test.dart | 24 +- .../decoding/datetime_decoding_extension.dart | 286 ------ .../lib/src/decoding/json_decoder.dart | 4 +- .../lib/src/decoding/simple_decoder.dart | 4 +- .../tonik_util/lib/src/offset_date_time.dart | 364 +++++++ packages/tonik_util/lib/tonik_util.dart | 1 + packages/tonik_util/pubspec.yaml | 2 +- .../datetime_decoding_extension_test.dart | 548 ----------- .../test/src/decoding/json_decoder_test.dart | 62 +- .../src/decoding/simple_decoder_test.dart | 49 +- .../test/src/offset_date_time_test.dart | 893 ++++++++++++++++++ 13 files changed, 1377 insertions(+), 893 deletions(-) delete mode 100644 packages/tonik_util/lib/src/decoding/datetime_decoding_extension.dart create mode 100644 packages/tonik_util/lib/src/offset_date_time.dart delete mode 100644 packages/tonik_util/test/src/decoding/datetime_decoding_extension_test.dart create mode 100644 packages/tonik_util/test/src/offset_date_time_test.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index 944a233..9331346 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "dart.flutterSdkPath": ".fvm/versions/3.32.0", "cSpell.words": [ - "Pubspec" + "Pubspec", + "tonik" ] } \ No newline at end of file diff --git a/docs/data_types.md b/docs/data_types.md index 680e7e2..54df516 100644 --- a/docs/data_types.md +++ b/docs/data_types.md @@ -7,7 +7,7 @@ This document provides information about how Tonik is mapping data types in Open | OAS Type | OAS Format | Dart Type | Dart Package | Comment | |----------|------------|-----------|--------------|---------| -| `string` | `date-time` | `DateTime` | `dart:core` | See [Timezone-Aware DateTime Parsing](#timezone-aware-datetime-parsing) | +| `string` | `date-time` | `DateTime` / `OffsetDateTime` | `dart:core` / `tonik_util` | See [Timezone-Aware DateTime Parsing](#timezone-aware-datetime-parsing) | | `string` | `date` | `Date` | `tonik_util` | RFC3339 date format (YYYY-MM-DD) | | `string` | `decimal`, `currency`, `money`, `number` | `BigDecimal` | `big_decimal` | High-precision decimal numbers | | `string` | `uri`, `url` | `Uri` | `dart:core` | URI/URL parsing and validation | @@ -22,27 +22,25 @@ This document provides information about how Tonik is mapping data types in Open ### Timezone-Aware DateTime Parsing -Tonik provides intelligent timezone-aware parsing for `date-time` format strings. The parsing behavior depends on the timezone information present in the input: +Tonik provides intelligent timezone-aware parsing for `date-time` format strings using the `OffsetDateTime` class. The parsing behavior depends on the timezone information present in the input: -> **⚠️ Important:** Before using timezone-aware parsing features, you must initialize the timezone database by calling `tz.initializeTimeZones()` from the `timezone` package. This is typically done in your application's setup code. - -All generated code will always expose Dart `DateTime` objects. However, standard Dart `DateTime` objects do not preserve timezone information, which is why Tonik uses `TZDateTime` internally during parsing to maintain timezone location data. During parsing, Tonik selects the most appropriate type to represent the date and time value: +All generated code will always expose Dart `DateTime` objects through the standard decoder methods. However, internally Tonik uses `OffsetDateTime.parse()` to provide consistent timezone handling. The `OffsetDateTime` class extends Dart's `DateTime` interface while preserving timezone offset information: | Input Format | Return Type | Example | Description | |--------------|-------------|---------|-------------| -| UTC (with Z) | `DateTime` (UTC) | `2023-12-25T15:30:45Z` | Standard Dart DateTime in UTC | -| Local (no timezone) | `DateTime` (local) | `2023-12-25T15:30:45` | Standard Dart DateTime in local timezone | -| Timezone offset | `TZDateTime` | `2023-12-25T15:30:45+05:00` | Timezone-aware DateTime with proper location | - +| UTC (with Z) | `OffsetDateTime` (UTC) | `2023-12-25T15:30:45Z` | OffsetDateTime with zero offset (UTC) | +| Local (no timezone) | `OffsetDateTime` (system timezone) | `2023-12-25T15:30:45` | OffsetDateTime with system timezone offset | +| Timezone offset | `OffsetDateTime` (specified offset) | `2023-12-25T15:30:45+05:00` | OffsetDateTime with the specified offset | +#### OffsetDateTime Benefits -#### Timezone Location Selection +The `OffsetDateTime` class provides several advantages over standard `DateTime` objects: -For strings with timezone offsets (e.g., `+05:00`), Tonik intelligently selects the best matching timezone location: +- **Preserves timezone offset information**: Unlike `DateTime`, `OffsetDateTime` retains the original timezone offset +- **Consistent API**: Implements the complete `DateTime` interface, so it can be used as a drop-in replacement +- **Fixed offset semantics**: Uses fixed timezone offsets rather than location-based timezones, avoiding DST ambiguity +- **Auto-generated timezone names**: Provides human-readable timezone names like `UTC+05:30`, `UTC-08:00`, or `UTC` -1. **Prefers common locations** from the timezone package's curated list of 535+ well-known timezones -2. **Accounts for DST changes** by checking the offset at the specific timestamp -3. **Avoids deprecated locations** (e.g., `US/Eastern` → `America/New_York`) -4. **Attempts fixed offset locations** (`Etc/GMT±N`) for standard hour offsets when no timezone match is found -5. **Falls back to UTC** for non-standard offsets or when `Etc/GMT±N` locations are unavailable +#### Local Time Preservation +For strings without timezone information (e.g., `2023-12-25T15:30:45`), `OffsetDateTime.parse()` preserves the local timezone behavior by using the system's timezone offset for that specific date and time. This ensures consistency with Dart's `DateTime.parse()` while providing the additional timezone offset information. diff --git a/packages/tonik_core/test/model/path_parameter_test.dart b/packages/tonik_core/test/model/path_parameter_test.dart index 3cf7b44..6e6a2fc 100644 --- a/packages/tonik_core/test/model/path_parameter_test.dart +++ b/packages/tonik_core/test/model/path_parameter_test.dart @@ -22,16 +22,16 @@ void main() { final resolved = param.resolve(name: 'newName'); - expect(resolved.name, equals('newName')); - expect(resolved.rawName, equals('originalRawName')); - expect(resolved.description, equals('description')); + expect(resolved.name, 'newName'); + expect(resolved.rawName, 'originalRawName'); + expect(resolved.description, 'description'); expect(resolved.isRequired, isTrue); expect(resolved.isDeprecated, isFalse); expect(resolved.allowEmptyValue, isFalse); expect(resolved.explode, isFalse); - expect(resolved.model, equals(model)); - expect(resolved.encoding, equals(PathParameterEncoding.simple)); - expect(resolved.context, equals(context)); + expect(resolved.model, model); + expect(resolved.encoding, PathParameterEncoding.simple); + expect(resolved.context, context); }); test('resolve preserves original name when no new name provided', () { @@ -53,7 +53,7 @@ void main() { final resolved = param.resolve(); - expect(resolved.name, equals('originalName')); + expect(resolved.name, 'originalName'); }); test('PathParameterAlias.resolve resolves with alias name', () { @@ -81,8 +81,8 @@ void main() { final resolved = alias.resolve(); - expect(resolved.name, equals('aliasName')); - expect(resolved.rawName, equals('originalRawName')); + expect(resolved.name, 'aliasName'); + expect(resolved.rawName, 'originalRawName'); }); test( @@ -112,7 +112,7 @@ void main() { final resolved = alias.resolve(name: 'overrideName'); - expect(resolved.name, equals('overrideName')); + expect(resolved.name, 'overrideName'); }, ); @@ -147,8 +147,8 @@ void main() { final resolved = secondAlias.resolve(); - expect(resolved.name, equals('secondAliasName')); - expect(resolved.rawName, equals('originalRawName')); + expect(resolved.name, 'secondAliasName'); + expect(resolved.rawName, 'originalRawName'); }); }); } diff --git a/packages/tonik_util/lib/src/decoding/datetime_decoding_extension.dart b/packages/tonik_util/lib/src/decoding/datetime_decoding_extension.dart deleted file mode 100644 index adde16d..0000000 --- a/packages/tonik_util/lib/src/decoding/datetime_decoding_extension.dart +++ /dev/null @@ -1,286 +0,0 @@ -import 'package:timezone/timezone.dart' as tz; -import 'package:tonik_util/src/decoding/decoding_exception.dart'; - -/// Extension on DateTime to provide timezone-aware parsing. -/// -/// This extension handles timezone information correctly: -/// - UTC strings return DateTime.utc objects -/// - Strings without timezone info return local DateTime objects -/// - Strings with timezone offsets return TZDateTime objects -extension DateTimeParsingExtension on DateTime { - /// Parses an ISO8601 datetime string with proper timezone handling. - /// - /// Returns: - /// - [DateTime] (UTC) for strings ending with 'Z' - /// - [DateTime] (local) for strings without timezone information - /// - [tz.TZDateTime] for strings with timezone offset information - /// - /// Throws [DecodingException] if the string is not a valid ISO8601 format. - static DateTime parseWithTimeZone(String input) { - if (input.isEmpty) { - throw const InvalidFormatException( - value: '', - format: 'ISO8601 datetime string', - ); - } - - // Handle different separator formats (T or space) - final normalizedInput = input.replaceFirst(' ', 'T'); - - // Check if it has timezone offset (±HH:MM or ±HHMM) - final timezoneRegex = RegExp(r'[+-]\d{2}:?\d{2}$'); - final timezoneMatch = timezoneRegex.firstMatch(normalizedInput); - - if (timezoneMatch != null) { - return _parseWithTimezoneOffset(normalizedInput, timezoneMatch); - } - - // Parse as UTC (ends with Z) or local time (no timezone info) - try { - return DateTime.parse(normalizedInput); - } on FormatException { - throw InvalidFormatException( - value: normalizedInput, - format: 'ISO8601 datetime format', - ); - } - } - - /// Parses a datetime string with timezone offset. - static tz.TZDateTime _parseWithTimezoneOffset( - String input, - RegExpMatch timezoneMatch, - ) { - final offsetString = timezoneMatch.group(0)!; - final datetimeString = input.substring( - 0, - input.length - offsetString.length, - ); - - final offset = _parseTimezoneOffset(offsetString); - final localDateTime = DateTime.parse(datetimeString); - final location = _findLocationForOffset(offset, localDateTime); - - // For standard offsets that have proper timezone locations, use them - if (location.name != 'UTC' || offset == Duration.zero) { - final utcDateTime = localDateTime.subtract(offset); - - final utcTz = tz.TZDateTime.utc( - utcDateTime.year, - utcDateTime.month, - utcDateTime.day, - utcDateTime.hour, - utcDateTime.minute, - utcDateTime.second, - utcDateTime.millisecond, - utcDateTime.microsecond, - ); - - return tz.TZDateTime.from(utcTz, location); - } - - // For unusual offsets that don't have proper timezone locations, - // fall back to UTC and convert the time correctly - final utcDateTime = localDateTime.subtract(offset); - - return tz.TZDateTime.utc( - utcDateTime.year, - utcDateTime.month, - utcDateTime.day, - utcDateTime.hour, - utcDateTime.minute, - utcDateTime.second, - utcDateTime.millisecond, - utcDateTime.microsecond, - ); - } - - /// Finds the best timezone location for a given offset at a - /// specific datetime. - /// - /// This leverages the timezone package's comprehensive database to find - /// locations that match the offset, taking into account DST changes. - static tz.Location _findLocationForOffset( - Duration offset, - DateTime dateTime, - ) { - final offsetMinutes = offset.inMinutes; - final timestamp = dateTime.millisecondsSinceEpoch; - - for (final locationName in _commonLocations) { - try { - final location = tz.getLocation(locationName); - final timeZone = location.timeZone(timestamp); - if (timeZone.offset == offsetMinutes * 60 * 1000) { - return location; - } - } on tz.LocationNotFoundException { - // Location doesn't exist, continue - } - } - - final matchingLocations = []; - for (final location in tz.timeZoneDatabase.locations.values) { - final timeZone = location.timeZone(timestamp); - if (timeZone.offset == offsetMinutes * 60 * 1000) { - matchingLocations.add(location); - } - } - - if (matchingLocations.isNotEmpty) { - // Prefer locations that don't use deprecated prefixes - final preferredMatches = - matchingLocations - .where( - (loc) => - !loc.name.startsWith('US/') && - !loc.name.startsWith('Etc/') && - !loc.name.contains('GMT'), - ) - .toList(); - - if (preferredMatches.isNotEmpty) { - return preferredMatches.first; - } - - return matchingLocations.first; - } - - return _createFixedOffsetLocation(offset); - } - - /// Creates a location with a fixed offset when no matching timezone is found. - static tz.Location _createFixedOffsetLocation(Duration offset) { - final offsetMinutes = offset.inMinutes; - - // For standard hour offsets, try to use Etc/GMT locations - if (offsetMinutes % 60 == 0) { - final offsetHours = offsetMinutes ~/ 60; - // Use Etc/GMT format which is supported by the timezone database - // Note: Etc/GMT offsets are inverted (Etc/GMT+5 is actually GMT-5) - final etcName = - offset.isNegative - ? 'Etc/GMT+${offsetHours.abs()}' - : 'Etc/GMT-${offsetHours.abs()}'; - - try { - return tz.getLocation(etcName); - } on tz.LocationNotFoundException { - // Fall through to UTC - } - } - - // For non-standard offsets, fall back to UTC - // This is a limitation - the timezone package doesn't easily support - // arbitrary fixed offsets, so we use UTC as fallback - return tz.getLocation('UTC'); - } - - /// Parses timezone offset string (±HH:MM or ±HHMM) into Duration. - static Duration _parseTimezoneOffset(String offsetString) { - // Remove optional colon for compact format - final normalized = offsetString.replaceAll(':', ''); - - if (normalized.length != 5) { - throw InvalidFormatException( - value: offsetString, - format: '±HHMM or ±HH:MM timezone offset', - ); - } - - final sign = normalized[0] == '+' ? 1 : -1; - final hoursStr = normalized.substring(1, 3); - final minutesStr = normalized.substring(3, 5); - - final hours = int.parse(hoursStr); - final minutes = int.parse(minutesStr); - - // Let Duration handle any overflow gracefully - return Duration(hours: sign * hours, minutes: sign * minutes); - } -} - -/// Commonly used timezone locations, prioritized for offset matching. -/// Based on major cities and avoiding deprecated location names. -const _commonLocations = [ - // Europe - 'Europe/London', - 'Europe/Paris', - 'Europe/Berlin', - 'Europe/Rome', - 'Europe/Madrid', - 'Europe/Amsterdam', - 'Europe/Brussels', - 'Europe/Vienna', - 'Europe/Zurich', - 'Europe/Stockholm', - 'Europe/Oslo', - 'Europe/Copenhagen', - 'Europe/Helsinki', - 'Europe/Warsaw', - 'Europe/Prague', - 'Europe/Budapest', - 'Europe/Athens', - 'Europe/Istanbul', - 'Europe/Moscow', - - // Americas - 'America/New_York', - 'America/Chicago', - 'America/Denver', - 'America/Los_Angeles', - 'America/Toronto', - 'America/Vancouver', - 'America/Montreal', - 'America/Mexico_City', - 'America/Sao_Paulo', - 'America/Buenos_Aires', - 'America/Lima', - 'America/Bogota', - 'America/Caracas', - 'America/Santiago', - 'America/Montevideo', - - // Asia - 'Asia/Tokyo', - 'Asia/Seoul', - 'Asia/Shanghai', - 'Asia/Hong_Kong', - 'Asia/Singapore', - 'Asia/Bangkok', - 'Asia/Jakarta', - 'Asia/Manila', - 'Asia/Kuala_Lumpur', - 'Asia/Kolkata', - 'Asia/Mumbai', - 'Asia/Karachi', - 'Asia/Dubai', - 'Asia/Riyadh', - 'Asia/Baghdad', - 'Asia/Tehran', - 'Asia/Kabul', - 'Asia/Tashkent', - 'Asia/Almaty', - - // Australia & Pacific - 'Australia/Sydney', - 'Australia/Melbourne', - 'Australia/Brisbane', - 'Australia/Perth', - 'Australia/Adelaide', - 'Pacific/Auckland', - 'Pacific/Honolulu', - 'Pacific/Fiji', - - // Africa - 'Africa/Cairo', - 'Africa/Johannesburg', - 'Africa/Lagos', - 'Africa/Nairobi', - 'Africa/Casablanca', - 'Africa/Tunis', - 'Africa/Algiers', - - // UTC - 'UTC', -]; diff --git a/packages/tonik_util/lib/src/decoding/json_decoder.dart b/packages/tonik_util/lib/src/decoding/json_decoder.dart index 0630df7..29e11cc 100644 --- a/packages/tonik_util/lib/src/decoding/json_decoder.dart +++ b/packages/tonik_util/lib/src/decoding/json_decoder.dart @@ -1,7 +1,7 @@ import 'package:big_decimal/big_decimal.dart'; import 'package:tonik_util/src/date.dart'; -import 'package:tonik_util/src/decoding/datetime_decoding_extension.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; +import 'package:tonik_util/src/offset_date_time.dart'; /// Extensions for decoding JSON values. extension JsonDecoder on Object? { @@ -26,7 +26,7 @@ extension JsonDecoder on Object? { ); } try { - return DateTimeParsingExtension.parseWithTimeZone(this! as String); + return OffsetDateTime.parse(this! as String); } on FormatException catch (e) { throw InvalidTypeException( value: this! as String, diff --git a/packages/tonik_util/lib/src/decoding/simple_decoder.dart b/packages/tonik_util/lib/src/decoding/simple_decoder.dart index 930c665..3b61583 100644 --- a/packages/tonik_util/lib/src/decoding/simple_decoder.dart +++ b/packages/tonik_util/lib/src/decoding/simple_decoder.dart @@ -1,7 +1,7 @@ import 'package:big_decimal/big_decimal.dart'; import 'package:tonik_util/src/date.dart'; -import 'package:tonik_util/src/decoding/datetime_decoding_extension.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; +import 'package:tonik_util/src/offset_date_time.dart'; /// Extensions for decoding simple form values from strings. extension SimpleDecoder on String? { @@ -146,7 +146,7 @@ extension SimpleDecoder on String? { ); } try { - return DateTimeParsingExtension.parseWithTimeZone(this!); + return OffsetDateTime.parse(this!); } on Object { throw InvalidTypeException( value: this!, diff --git a/packages/tonik_util/lib/src/offset_date_time.dart b/packages/tonik_util/lib/src/offset_date_time.dart new file mode 100644 index 0000000..53c0332 --- /dev/null +++ b/packages/tonik_util/lib/src/offset_date_time.dart @@ -0,0 +1,364 @@ +import 'dart:core'; + +import 'package:meta/meta.dart'; +import 'package:tonik_util/src/decoding/decoding_exception.dart'; + +/// A DateTime implementation that supports fixed timezone offsets. +/// +/// This class provides timezone-aware DateTime functionality with a fixed +/// offset from UTC. +/// It implements the DateTime interface by delegating to an internal UTC +/// DateTime object and applying offset adjustments for local time operations. +@immutable +class OffsetDateTime implements DateTime { + /// Creates an [OffsetDateTime] from an existing [DateTime] with the + /// specified offset. + /// + /// The [dateTime] is interpreted as being in the timezone specified + /// by [offset]. The resulting [OffsetDateTime] will represent the same + /// moment in time, but with the specified offset. + OffsetDateTime.from( + DateTime dateTime, { + required this.offset, + String? timeZoneName, + }) : timeZoneName = timeZoneName ?? _generateTimeZoneName(offset), + _utcDateTime = + dateTime.isUtc ? dateTime : _toUtcDateTime(dateTime, offset); + + const OffsetDateTime._fromUtc( + this._utcDateTime, { + required this.offset, + required this.timeZoneName, + }); + + /// Parses a datetime string with timezone offset. + factory OffsetDateTime._parseWithTimezoneOffset( + String input, + RegExpMatch timezoneMatch, + ) { + final offsetString = timezoneMatch.group(0)!; + final datetimeString = input.substring( + 0, + input.length - offsetString.length, + ); + + final offset = _parseTimezoneOffset(offsetString); + + // Parse the datetime part (without timezone) as local time + final localDateTime = DateTime.parse(datetimeString); + + // Create OffsetDateTime from the local time and offset + return OffsetDateTime.from( + localDateTime, + offset: offset, + ); + } + + /// Parses an ISO8601 datetime string with timezone support. + /// + /// Always returns an [OffsetDateTime] object: + /// - For strings ending with 'Z': OffsetDateTime with zero offset (UTC) + /// - For strings without timezone: OffsetDateTime with system timezone offset + /// - For strings with timezone offset: OffsetDateTime with the specified + /// offset + /// + /// Throws [DecodingException] if the string is not a valid ISO8601 format. + /// + /// Examples: + /// ```dart + /// OffsetDateTime.parse('2023-12-25T15:30:45Z'); // OffsetDateTime (UTC) + /// OffsetDateTime.parse('2023-12-25T15:30:45'); // OffsetDateTime (system timezone) + /// OffsetDateTime.parse('2023-12-25T15:30:45+05:30'); // OffsetDateTime (+05:30) + /// ``` + static OffsetDateTime parse(String input) { + if (input.isEmpty) { + throw const InvalidFormatException( + value: '', + format: 'ISO8601 datetime string', + ); + } + + // Handle different separator formats (T or space) + final normalizedInput = input.replaceFirst(' ', 'T'); + + // Check if it has timezone offset (±HH:MM or ±HHMM) + final timezoneRegex = RegExp(r'[+-]\d{2}:?\d{2}$'); + final timezoneMatch = timezoneRegex.firstMatch(normalizedInput); + + if (timezoneMatch != null) { + return OffsetDateTime._parseWithTimezoneOffset( + normalizedInput, + timezoneMatch, + ); + } + + // Parse as UTC (ends with Z) or local time (no timezone info) + try { + final dateTime = DateTime.parse(normalizedInput); + + // Create OffsetDateTime from the parsed DateTime + if (dateTime.isUtc) { + // UTC datetime - create with zero offset + return OffsetDateTime.from(dateTime, offset: Duration.zero); + } else { + // Local datetime - preserve the system timezone offset + return OffsetDateTime.from(dateTime, offset: dateTime.timeZoneOffset); + } + } on FormatException { + throw InvalidFormatException( + value: normalizedInput, + format: 'ISO8601 datetime format', + ); + } + } + + /// Parses timezone offset string (±HH:MM or ±HHMM) into Duration. + static Duration _parseTimezoneOffset(String offsetString) { + // Remove optional colon for compact format + final normalized = offsetString.replaceAll(':', ''); + + if (normalized.length != 5) { + throw InvalidFormatException( + value: offsetString, + format: '±HHMM or ±HH:MM timezone offset', + ); + } + + final sign = normalized[0] == '+' ? 1 : -1; + final hoursStr = normalized.substring(1, 3); + final minutesStr = normalized.substring(3, 5); + + final hours = int.parse(hoursStr); + final minutes = int.parse(minutesStr); + + if (hours < 0 || hours > 23) { + throw InvalidFormatException( + value: offsetString, + format: 'timezone offset hours must be between 00 and 23', + ); + } + + if (minutes < 0 || minutes > 59) { + throw InvalidFormatException( + value: offsetString, + format: 'timezone offset minutes must be between 00 and 59', + ); + } + + return Duration(hours: sign * hours, minutes: sign * minutes); + } + + /// The canonical UTC representation of this datetime. + /// + /// This represents the same moment in time as this [OffsetDateTime], + /// but in UTC time zone. + final DateTime _utcDateTime; + + /// The timezone offset from UTC. + /// + /// Positive values are east of UTC, negative values are west of UTC. + /// For example, an offset of +5 hours would be Duration(hours: 5). + final Duration offset; + + @override + final String timeZoneName; + + /// Converts a local DateTime with an offset to UTC DateTime. + static DateTime _toUtcDateTime(DateTime localDateTime, Duration offset) { + // Calculate the UTC moment by subtracting the offset + final utcMoment = localDateTime.subtract(offset); + // Return a proper UTC DateTime with isUtc = true + return DateTime.utc( + utcMoment.year, + utcMoment.month, + utcMoment.day, + utcMoment.hour, + utcMoment.minute, + utcMoment.second, + utcMoment.millisecond, + utcMoment.microsecond, + ); + } + + /// Generates a timezone name from an offset. + /// + /// Returns 'UTC' for zero offset, otherwise returns a UTC-based formatted + /// offset like 'UTC+05:30' or 'UTC-08:00'. + static String _generateTimeZoneName(Duration offset) { + if (offset == Duration.zero) { + return 'UTC'; + } + + final hours = offset.inHours; + final minutes = offset.inMinutes.abs() % 60; + final sign = hours < 0 || (hours == 0 && offset.isNegative) ? '-' : '+'; + final absHours = hours.abs(); + + final hourPart = absHours.toString().padLeft(2, '0'); + final minutePart = minutes.toString().padLeft(2, '0'); + return 'UTC$sign$hourPart:$minutePart'; + } + + @override + OffsetDateTime toUtc() { + if (offset == Duration.zero) { + return this; + } + return OffsetDateTime._fromUtc( + _utcDateTime, + offset: Duration.zero, + timeZoneName: 'UTC', + ); + } + + @override + DateTime toLocal() { + return DateTime.fromMicrosecondsSinceEpoch( + microsecondsSinceEpoch, + ); + } + + @override + int get millisecondsSinceEpoch => _utcDateTime.millisecondsSinceEpoch; + + @override + int get microsecondsSinceEpoch => _utcDateTime.microsecondsSinceEpoch; + + @override + bool get isUtc => offset == Duration.zero; + + @override + OffsetDateTime add(Duration duration) { + final newUtcDateTime = _utcDateTime.add(duration); + return OffsetDateTime._fromUtc( + newUtcDateTime, + offset: offset, + timeZoneName: timeZoneName, + ); + } + + @override + OffsetDateTime subtract(Duration duration) { + final newUtcDateTime = _utcDateTime.subtract(duration); + return OffsetDateTime._fromUtc( + newUtcDateTime, + offset: offset, + timeZoneName: timeZoneName, + ); + } + + @override + Duration difference(DateTime other) => + _utcDateTime.difference(_toNative(other)); + + @override + bool isBefore(DateTime other) => _utcDateTime.isBefore(_toNative(other)); + + @override + bool isAfter(DateTime other) => _utcDateTime.isAfter(_toNative(other)); + + @override + bool isAtSameMomentAs(DateTime other) => + _utcDateTime.isAtSameMomentAs(_toNative(other)); + + @override + int compareTo(DateTime other) => _utcDateTime.compareTo(_toNative(other)); + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is OffsetDateTime && + _utcDateTime.isAtSameMomentAs(other._utcDateTime); + } + + @override + int get hashCode => _utcDateTime.hashCode; + + @override + Duration get timeZoneOffset => offset; + + DateTime get _localDateTime => _utcDateTime.add(offset); + + @override + int get year => _localDateTime.year; + + @override + int get month => _localDateTime.month; + + @override + int get day => _localDateTime.day; + + @override + int get hour => _localDateTime.hour; + + @override + int get minute => _localDateTime.minute; + + @override + int get second => _localDateTime.second; + + @override + int get millisecond => _localDateTime.millisecond; + + @override + int get microsecond => _localDateTime.microsecond; + + @override + int get weekday => _localDateTime.weekday; + + @override + String toString() => _toString(iso8601: false); + + @override + String toIso8601String() => _toString(); + + String _toString({bool iso8601 = true}) { + final local = _localDateTime; + final y = _fourDigits(local.year); + final m = _twoDigits(local.month); + final d = _twoDigits(local.day); + final sep = iso8601 ? 'T' : ' '; + final h = _twoDigits(local.hour); + final min = _twoDigits(local.minute); + final sec = _twoDigits(local.second); + final ms = _threeDigits(local.millisecond); + final us = local.microsecond == 0 ? '' : _threeDigits(local.microsecond); + + if (isUtc || offset == Duration.zero) { + return '$y-$m-$d$sep$h:$min:$sec.$ms${us}Z'; + } else { + final offsetSign = offset.isNegative ? '-' : '+'; + final offsetAbs = offset.abs(); + final offsetHours = offsetAbs.inHours; + final offsetMinutes = offsetAbs.inMinutes % 60; + final offH = _twoDigits(offsetHours); + final offM = _twoDigits(offsetMinutes); + + return '$y-$m-$d$sep$h:$min:$sec.$ms$us$offsetSign$offH$offM'; + } + } + + static String _fourDigits(int n) { + final absN = n.abs(); + final sign = n < 0 ? '-' : ''; + if (absN >= 1000) return '$n'; + if (absN >= 100) return '${sign}0$absN'; + if (absN >= 10) return '${sign}00$absN'; + return '${sign}000$absN'; + } + + static String _threeDigits(int n) { + if (n >= 100) return '$n'; + if (n >= 10) return '0$n'; + return '00$n'; + } + + static String _twoDigits(int n) { + if (n >= 10) return '$n'; + return '0$n'; + } + + /// Returns the native [DateTime] object. + static DateTime _toNative(DateTime t) => + t is OffsetDateTime ? t._utcDateTime : t; +} diff --git a/packages/tonik_util/lib/tonik_util.dart b/packages/tonik_util/lib/tonik_util.dart index 98bbe84..40cc418 100644 --- a/packages/tonik_util/lib/tonik_util.dart +++ b/packages/tonik_util/lib/tonik_util.dart @@ -15,4 +15,5 @@ export 'src/encoding/label_encoder.dart'; export 'src/encoding/matrix_encoder.dart'; export 'src/encoding/parameter_entry.dart'; export 'src/encoding/simple_encoder.dart'; +export 'src/offset_date_time.dart'; export 'src/tonik_result.dart'; diff --git a/packages/tonik_util/pubspec.yaml b/packages/tonik_util/pubspec.yaml index 74d2a3f..61df51f 100644 --- a/packages/tonik_util/pubspec.yaml +++ b/packages/tonik_util/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: collection: ^1.19.1 dio: ^5.0.0 meta: ^1.16.0 - timezone: ^0.10.1 dev_dependencies: test: ^1.24.0 + timezone: ^0.10.1 diff --git a/packages/tonik_util/test/src/decoding/datetime_decoding_extension_test.dart b/packages/tonik_util/test/src/decoding/datetime_decoding_extension_test.dart deleted file mode 100644 index ed6b868..0000000 --- a/packages/tonik_util/test/src/decoding/datetime_decoding_extension_test.dart +++ /dev/null @@ -1,548 +0,0 @@ -import 'package:test/test.dart'; -import 'package:timezone/data/latest.dart' as tz; -import 'package:tonik_util/src/decoding/datetime_decoding_extension.dart'; -import 'package:tonik_util/src/decoding/decoding_exception.dart'; - -void main() { - setUpAll(tz.initializeTimeZones); - - group('DateTimeParsingExtension', () { - group('parseWithTimeZone', () { - group('UTC parsing', () { - test('parses UTC datetime with Z suffix', () { - const input = '2023-12-25T15:30:45Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.isUtc, isTrue); - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset, Duration.zero); - }); - - test('parses UTC datetime with milliseconds', () { - const input = '2023-12-25T15:30:45.123Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.isUtc, isTrue); - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 123); - expect(result.microsecond, 0); - expect(result.timeZoneOffset, Duration.zero); - }); - - test('parses UTC datetime with microseconds', () { - const input = '2023-12-25T15:30:45.123456Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.isUtc, isTrue); - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 123); - expect(result.microsecond, 456); - expect(result.timeZoneOffset, Duration.zero); - }); - - test('parses UTC datetime at midnight', () { - const input = '2023-12-25T00:00:00Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.isUtc, isTrue); - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 0); - expect(result.minute, 0); - expect(result.second, 0); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset, Duration.zero); - }); - - test('parses UTC datetime at end of day', () { - const input = '2023-12-25T23:59:59Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.isUtc, isTrue); - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 23); - expect(result.minute, 59); - expect(result.second, 59); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset, Duration.zero); - }); - }); - - group('local time parsing (no timezone info)', () { - test('parses datetime without timezone as local time', () { - const input = '2023-12-25T15:30:45'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - final expected = DateTime(2023, 12, 25, 15, 30, 45); - - expect(result.isUtc, isFalse); - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset, expected.timeZoneOffset); - }); - - test('parses datetime with milliseconds as local time', () { - const input = '2023-12-25T15:30:45.123'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - final expected = DateTime(2023, 12, 25, 15, 30, 45, 123); - - expect(result.isUtc, isFalse); - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 123); - expect(result.microsecond, 0); - expect(result.timeZoneOffset, expected.timeZoneOffset); - }); - - test('parses datetime with microseconds as local time', () { - const input = '2023-12-25T15:30:45.123456'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - final expected = DateTime(2023, 12, 25, 15, 30, 45, 123, 456); - - expect(result.isUtc, isFalse); - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 123); - expect(result.microsecond, 456); - expect(result.timeZoneOffset, expected.timeZoneOffset); - }); - - test('parses date-only format as local midnight', () { - const input = '2023-12-25'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - final expected = DateTime(2023, 12, 25); - - expect(result.isUtc, isFalse); - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 0); - expect(result.minute, 0); - expect(result.second, 0); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset, expected.timeZoneOffset); - }); - }); - - group('timezone offset parsing', () { - test('parses positive timezone offset (+05:00)', () { - const input = '2023-12-25T15:30:45+05:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset.inHours, 5); - }); - - test('parses negative timezone offset (-08:00)', () { - const input = '2023-12-25T15:30:45-08:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset.inHours, -8); - }); - - test('parses timezone offset with 30-minute offset (+05:30)', () { - const input = '2023-12-25T15:30:45+05:30'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset.inMinutes, 330); // 5.5 hours - }); - - test('parses timezone offset with 45-minute offset (+05:45)', () { - const input = '2023-12-25T15:30:45+05:45'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset.inMinutes, 345); // 5.75 hours - }); - - test('parses timezone offset with milliseconds', () { - const input = '2023-12-25T15:30:45.123+05:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 123); - expect(result.microsecond, 0); - expect(result.timeZoneOffset.inHours, 5); - }); - - test('parses timezone offset with microseconds', () { - const input = '2023-12-25T15:30:45.123456+05:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 123); - expect(result.microsecond, 456); - expect(result.timeZoneOffset.inHours, 5); - }); - - test('parses compact timezone offset format (+0500)', () { - const input = '2023-12-25T15:30:45+0500'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset.inHours, 5); - }); - - test('parses compact negative timezone offset format (-0800)', () { - const input = '2023-12-25T15:30:45-0800'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset.inHours, -8); - }); - }); - - group('timezone location matching', () { - test('maps common European timezone offset to CET', () { - const input = '2023-12-25T15:30:45+01:00'; // CET (winter time) - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.timeZoneName, 'CET'); - expect(result.timeZoneOffset.inHours, 1); - }); - - test('maps summer time European offset to CEST', () { - const input = '2023-07-25T15:30:45+02:00'; // CEST (summer time) - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.timeZoneName, 'CEST'); - expect(result.timeZoneOffset.inHours, 2); - }); - - test('maps US Eastern timezone offset to EST', () { - const input = '2023-12-25T15:30:45-05:00'; // EST (winter time) - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.timeZoneName, 'EST'); - expect(result.timeZoneOffset.inHours, -5); - }); - - test('maps US Eastern summer time offset to EDT', () { - const input = '2023-07-25T15:30:45-04:00'; // EDT (summer time) - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.timeZoneName, 'EDT'); - expect(result.timeZoneOffset.inHours, -4); - }); - - test('maps India Standard Time offset to IST', () { - const input = '2023-12-25T15:30:45+05:30'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - expect(result.timeZoneName, 'IST'); - expect(result.timeZoneOffset.inMinutes, 330); // 5.5 hours - }); - - test('maps Japan Standard Time offset to JST', () { - const input = '2023-12-25T15:30:45+09:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.timeZoneName, 'JST'); - expect(result.timeZoneOffset.inHours, 9); - }); - - test( - 'handles unusual timezone offset by falling back to UTC', - () { - const input = '2023-12-25T15:30:45+03:17'; // Unusual offset - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - // For unusual offsets, falls back to UTC due to - // timezone package limitations - expect(result.timeZoneOffset.inMinutes, 0); // UTC - expect(result.timeZoneName, 'UTC'); - - // But the parsed time should still be correctly converted - // Original: 15:30:45+03:17 should convert to UTC time - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 12); // 15:30 - 3:17 = 12:13 - expect(result.minute, 13); - expect(result.second, 45); - }, - ); - }); - - group('edge cases', () { - test('parses leap year date (UTC)', () { - const input = '2024-02-29T12:00:00Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2024); - expect(result.month, 2); - expect(result.day, 29); - }); - - test('parses leap year date (local)', () { - const input = '2024-02-29T12:00:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2024); - expect(result.month, 2); - expect(result.day, 29); - }); - - test('parses leap year date (timezone offset)', () { - const input = '2024-02-29T12:00:00+03:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2024); - expect(result.month, 2); - expect(result.day, 29); - }); - - test('parses year boundaries correctly (UTC)', () { - const input = '2023-12-31T23:59:59Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 31); - }); - - test('parses year boundaries correctly (local)', () { - const input = '2023-12-31T23:59:59'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 31); - }); - - test('parses year boundaries correctly (timezone offset)', () { - const input = '2023-12-31T23:59:59-05:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 31); - }); - - test('parses new year correctly (UTC)', () { - const input = '2024-01-01T00:00:00Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2024); - expect(result.month, 1); - expect(result.day, 1); - }); - - test('parses new year correctly (local)', () { - const input = '2024-01-01T00:00:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2024); - expect(result.month, 1); - expect(result.day, 1); - }); - - test('parses new year correctly (timezone offset)', () { - const input = '2024-01-01T00:00:00+09:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2024); - expect(result.month, 1); - expect(result.day, 1); - }); - - test('handles single digit milliseconds (UTC)', () { - const input = '2023-12-25T15:30:45.1Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.millisecond, 100); - }); - - test('handles single digit milliseconds (local)', () { - const input = '2023-12-25T15:30:45.1'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.millisecond, 100); - }); - - test('handles single digit milliseconds (timezone offset)', () { - const input = '2023-12-25T15:30:45.1+02:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.millisecond, 100); - }); - - test('handles two digit milliseconds (UTC)', () { - const input = '2023-12-25T15:30:45.12Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.millisecond, 120); - }); - - test('handles two digit milliseconds (local)', () { - const input = '2023-12-25T15:30:45.12'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.millisecond, 120); - }); - - test('handles two digit milliseconds (timezone offset)', () { - const input = '2023-12-25T15:30:45.12-07:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.millisecond, 120); - }); - }); - - group('error handling', () { - test('throws InvalidFormatException for invalid format', () { - const input = 'invalid-date-format'; - expect( - () => DateTimeParsingExtension.parseWithTimeZone(input), - throwsA(isA()), - ); - }); - - test('throws InvalidFormatException for incomplete date', () { - const input = '2023-12'; - expect( - () => DateTimeParsingExtension.parseWithTimeZone(input), - throwsA(isA()), - ); - }); - }); - - group('RFC3339 compliance', () { - test('parses full RFC3339 format with T separator', () { - const input = '2023-12-25T15:30:45.123456+05:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 123); - expect(result.microsecond, 456); - expect(result.timeZoneOffset.inHours, 5); - }); - - test('parses RFC3339 format with space separator', () { - const input = '2023-12-25 15:30:45.123+05:00'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 123); - expect(result.microsecond, 0); - expect(result.timeZoneOffset.inHours, 5); - }); - - test('parses minimum required RFC3339 format', () { - const input = '2023-12-25T15:30:45Z'; - final result = DateTimeParsingExtension.parseWithTimeZone(input); - - expect(result.isUtc, isTrue); - expect(result.year, 2023); - expect(result.month, 12); - expect(result.day, 25); - expect(result.hour, 15); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 0); - expect(result.microsecond, 0); - expect(result.timeZoneOffset, Duration.zero); - }); - }); - }); - }); -} diff --git a/packages/tonik_util/test/src/decoding/json_decoder_test.dart b/packages/tonik_util/test/src/decoding/json_decoder_test.dart index d8b2057..8bb1ae8 100644 --- a/packages/tonik_util/test/src/decoding/json_decoder_test.dart +++ b/packages/tonik_util/test/src/decoding/json_decoder_test.dart @@ -2,34 +2,62 @@ import 'dart:convert'; import 'package:big_decimal/big_decimal.dart'; import 'package:test/test.dart'; -import 'package:timezone/data/latest.dart' as tz; -import 'package:timezone/timezone.dart' as tz; import 'package:tonik_util/src/date.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; import 'package:tonik_util/src/decoding/json_decoder.dart'; void main() { - setUpAll(tz.initializeTimeZones); group('JsonDecoder', () { group('DateTime', () { test('decodes DateTime values with timezone awareness', () { // Test UTC parsing const utcString = '2024-03-14T10:30:45Z'; final utcResult = utcString.decodeJsonDateTime(); - expect(utcResult.isUtc, isTrue); - expect(utcResult, DateTime.utc(2024, 3, 14, 10, 30, 45)); - - // Test local time parsing + expect(utcResult.year, 2024); + expect(utcResult.month, 3); + expect(utcResult.day, 14); + expect(utcResult.hour, 10); + expect(utcResult.minute, 30); + expect(utcResult.second, 45); + expect(utcResult.timeZoneOffset, Duration.zero); + + // Test local time parsing (no timezone offset) const localString = '2024-03-14T10:30:45'; final localResult = localString.decodeJsonDateTime(); - expect(localResult.isUtc, isFalse); - expect(localResult, DateTime(2024, 3, 14, 10, 30, 45)); + expect(localResult.year, 2024); + expect(localResult.month, 3); + expect(localResult.day, 14); + expect(localResult.hour, 10); + expect(localResult.minute, 30); + expect(localResult.second, 45); + // Local datetime uses system timezone + // should match same date in local timezone + final expectedLocalTime = DateTime(2024, 3, 14, 10, 30, 45); + expect(localResult.timeZoneOffset, expectedLocalTime.timeZoneOffset); // Test timezone offset parsing const offsetString = '2024-03-14T10:30:45+05:00'; final offsetResult = offsetString.decodeJsonDateTime(); - expect(offsetResult, isA()); + expect(offsetResult.year, 2024); + expect(offsetResult.month, 3); + expect(offsetResult.day, 14); + expect(offsetResult.hour, 10); + expect(offsetResult.minute, 30); + expect(offsetResult.second, 45); expect(offsetResult.timeZoneOffset.inHours, 5); + expect(offsetResult.timeZoneOffset.inMinutes, 5 * 60); + + // Test negative timezone offset + const negativeOffsetString = '2024-03-14T10:30:45-08:00'; + final negativeOffsetResult = negativeOffsetString.decodeJsonDateTime(); + expect(negativeOffsetResult.year, 2024); + expect(negativeOffsetResult.month, 3); + expect(negativeOffsetResult.day, 14); + expect(negativeOffsetResult.hour, 10); + expect(negativeOffsetResult.minute, 30); + expect(negativeOffsetResult.second, 45); + expect(negativeOffsetResult.timeZoneOffset.inHours, -8); + expect(negativeOffsetResult.timeZoneOffset.inMinutes, -8 * 60); // Test error cases expect( @@ -43,10 +71,16 @@ void main() { }); test('decodes nullable DateTime values with timezone awareness', () { - expect( - '2024-03-14T10:30:45Z'.decodeJsonNullableDateTime(), - DateTime.utc(2024, 3, 14, 10, 30, 45), - ); + final result = '2024-03-14T10:30:45Z'.decodeJsonNullableDateTime(); + expect(result, isNotNull); + expect(result!.year, 2024); + expect(result.month, 3); + expect(result.day, 14); + expect(result.hour, 10); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.timeZoneOffset, Duration.zero); + expect(null.decodeJsonNullableDateTime(), isNull); expect(''.decodeJsonNullableDateTime(), isNull); expect( diff --git a/packages/tonik_util/test/src/decoding/simple_decoder_test.dart b/packages/tonik_util/test/src/decoding/simple_decoder_test.dart index 6524bd4..3e1d6c4 100644 --- a/packages/tonik_util/test/src/decoding/simple_decoder_test.dart +++ b/packages/tonik_util/test/src/decoding/simple_decoder_test.dart @@ -1,13 +1,10 @@ import 'package:big_decimal/big_decimal.dart'; import 'package:test/test.dart'; -import 'package:timezone/data/latest.dart' as tz; -import 'package:timezone/timezone.dart' as tz; import 'package:tonik_util/src/date.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; import 'package:tonik_util/src/decoding/simple_decoder.dart'; void main() { - setUpAll(tz.initializeTimeZones); group('SimpleDecoder', () { group('Simple Values', () { test('decodes integer values', () { @@ -51,24 +48,54 @@ void main() { test('decodes DateTime values with timezone awareness', () { // Test UTC parsing - final utcDate = DateTime.utc(2024, 3, 14, 10, 30, 45); const utcString = '2024-03-14T10:30:45Z'; final utcResult = utcString.decodeSimpleDateTime(); - expect(utcResult.isUtc, isTrue); - expect(utcResult, utcDate); + expect(utcResult.year, 2024); + expect(utcResult.month, 3); + expect(utcResult.day, 14); + expect(utcResult.hour, 10); + expect(utcResult.minute, 30); + expect(utcResult.second, 45); + expect(utcResult.timeZoneOffset, Duration.zero); - // Test local time parsing + // Test local time parsing (no timezone offset) const localString = '2024-03-14T10:30:45'; final localResult = localString.decodeSimpleDateTime(); - final expectedLocal = DateTime(2024, 3, 14, 10, 30, 45); - expect(localResult.isUtc, isFalse); - expect(localResult, expectedLocal); + expect(localResult.year, 2024); + expect(localResult.month, 3); + expect(localResult.day, 14); + expect(localResult.hour, 10); + expect(localResult.minute, 30); + expect(localResult.second, 45); + // Local datetime uses system timezone + // should match same date in local timezone + final expectedLocalTime = DateTime(2024, 3, 14, 10, 30, 45); + expect(localResult.timeZoneOffset, expectedLocalTime.timeZoneOffset); // Test timezone offset parsing const offsetString = '2024-03-14T10:30:45+05:00'; final offsetResult = offsetString.decodeSimpleDateTime(); - expect(offsetResult, isA()); + expect(offsetResult.year, 2024); + expect(offsetResult.month, 3); + expect(offsetResult.day, 14); + expect(offsetResult.hour, 10); + expect(offsetResult.minute, 30); + expect(offsetResult.second, 45); expect(offsetResult.timeZoneOffset.inHours, 5); + expect(offsetResult.timeZoneOffset.inMinutes, 5 * 60); + + // Test negative timezone offset + const negativeOffsetString = '2024-03-14T10:30:45-08:00'; + final negativeOffsetResult = + negativeOffsetString.decodeSimpleDateTime(); + expect(negativeOffsetResult.year, 2024); + expect(negativeOffsetResult.month, 3); + expect(negativeOffsetResult.day, 14); + expect(negativeOffsetResult.hour, 10); + expect(negativeOffsetResult.minute, 30); + expect(negativeOffsetResult.second, 45); + expect(negativeOffsetResult.timeZoneOffset.inHours, -8); + expect(negativeOffsetResult.timeZoneOffset.inMinutes, -8 * 60); // Test error cases expect( diff --git a/packages/tonik_util/test/src/offset_date_time_test.dart b/packages/tonik_util/test/src/offset_date_time_test.dart new file mode 100644 index 0000000..961369a --- /dev/null +++ b/packages/tonik_util/test/src/offset_date_time_test.dart @@ -0,0 +1,893 @@ +import 'package:test/test.dart'; +import 'package:tonik_util/tonik_util.dart'; + +void main() { + group('OffsetDateTime', () { + group('constructor', () { + test('should create OffsetDateTime with explicit timezone name', () { + // Arrange + final dateTime = DateTime(2023, 1, 15, 12, 30, 45); + const offset = Duration(hours: 5, minutes: 30); + const timeZoneName = 'Asia/Kolkata'; + + // Act + final offsetDateTime = OffsetDateTime.from( + dateTime, + offset: offset, + timeZoneName: timeZoneName, + ); + + // Assert + expect(offsetDateTime.offset, offset); + expect(offsetDateTime.timeZoneName, timeZoneName); + expect(offsetDateTime.year, 2023); + expect(offsetDateTime.month, 1); + expect(offsetDateTime.day, 15); + expect(offsetDateTime.hour, 12); + expect(offsetDateTime.minute, 30); + expect(offsetDateTime.second, 45); + }); + + test('should auto-generate timezone name when not provided', () { + // Arrange + final dateTime = DateTime(2023, 1, 15, 12); + const offset = Duration(hours: 5, minutes: 30); + + // Act + final offsetDateTime = OffsetDateTime.from( + dateTime, + offset: offset, + ); + + // Assert + expect(offsetDateTime.timeZoneName, 'UTC+05:30'); + }); + + test('should use UTC for zero offset', () { + // Arrange + final dateTime = DateTime.utc(2023, 1, 15, 12); + + // Act + final offsetDateTime = OffsetDateTime.from( + dateTime, + offset: Duration.zero, + ); + + // Assert + expect(offsetDateTime.timeZoneName, 'UTC'); + expect(offsetDateTime.isUtc, isTrue); + }); + }); + + group('timezone name generation', () { + test('should generate UTC for zero offset', () { + // Arrange & Act + final offsetDateTime = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), + offset: Duration.zero, + ); + + // Assert + expect(offsetDateTime.timeZoneName, 'UTC'); + }); + + test('should generate positive offset names', () { + // Arrange & Act + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 17), + offset: const Duration(hours: 5, minutes: 30), + ); + + // Assert + expect(offsetDateTime.timeZoneName, 'UTC+05:30'); + }); + + test('should generate negative offset names', () { + // Arrange & Act + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 4), + offset: const Duration(hours: -8), + ); + + // Assert + expect(offsetDateTime.timeZoneName, 'UTC-08:00'); + }); + + test('should generate names for unusual offsets', () { + // Arrange & Act + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 4), + offset: const Duration(hours: 9, minutes: 45), + ); + + // Assert + expect(offsetDateTime.timeZoneName, 'UTC+09:45'); + }); + + test('should generate names for negative offsets with minutes', () { + // Arrange & Act + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 4), + offset: const Duration(hours: -3, minutes: -30), + ); + + // Assert + expect(offsetDateTime.timeZoneName, 'UTC-03:30'); + }); + + test( + 'should override auto-generated name when explicit name provided', + () { + // Arrange & Act + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 17), + offset: const Duration(hours: 5, minutes: 30), + timeZoneName: 'Asia/Kolkata', + ); + + // Assert + expect(offsetDateTime.timeZoneName, 'Asia/Kolkata'); + }, + ); + }); + + group('toLocal()', () { + test('should convert to local system time', () { + // Arrange + final utcTime = DateTime.utc(2023, 1, 15, 12); + final offsetDateTime = OffsetDateTime.from( + utcTime, + offset: Duration.zero, + timeZoneName: 'UTC', + ); + + // Act + final localDateTime = offsetDateTime.toLocal(); + + // Assert + expect(localDateTime.isUtc, isFalse); + expect( + localDateTime.microsecondsSinceEpoch, + offsetDateTime.microsecondsSinceEpoch, + ); + }); + + test('should preserve exact moment in time when converting to local', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 17, 30, 45), + offset: const Duration(hours: 2), + ); + + // Act + final localDateTime = offsetDateTime.toLocal(); + + // Assert + expect(localDateTime.isUtc, isFalse); + expect( + localDateTime.microsecondsSinceEpoch, + offsetDateTime.microsecondsSinceEpoch, + ); + }); + }); + + group('toUtc()', () { + test('should convert to UTC when offset is not zero', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 17, 30), + offset: const Duration(hours: 5, minutes: 30), + ); + + // Act + final utcDateTime = offsetDateTime.toUtc(); + + // Assert + expect(utcDateTime.isUtc, isTrue); + expect(utcDateTime.timeZoneName, 'UTC'); + expect(utcDateTime.offset, Duration.zero); + expect( + utcDateTime.microsecondsSinceEpoch, + offsetDateTime.microsecondsSinceEpoch, + ); + }); + + test('should return same instance for UTC offset', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), + offset: Duration.zero, + ); + + // Act + final utcDateTime = offsetDateTime.toUtc(); + + // Assert + expect(identical(utcDateTime, offsetDateTime), isTrue); + }); + }); + + group('date and time components', () { + test('should return correct local date components', () { + // Arrange: UTC midnight + 5:30 offset = 5:30 AM local + final offsetDateTime = OffsetDateTime.from( + DateTime.utc(2023, 6, 15), + offset: const Duration(hours: 5, minutes: 30), + ); + + // Act & Assert + expect(offsetDateTime.year, 2023); + expect(offsetDateTime.month, 6); + expect(offsetDateTime.day, 15); + expect(offsetDateTime.hour, 5); + expect(offsetDateTime.minute, 30); + expect(offsetDateTime.second, 0); + expect(offsetDateTime.millisecond, 0); + expect(offsetDateTime.microsecond, 0); + }); + + test('should handle day boundary crossing', () { + // Arrange: UTC 23:00 + 2 hours = 01:00 next day local + final offsetDateTime = OffsetDateTime.from( + DateTime.utc(2023, 6, 15, 23), + offset: const Duration(hours: 2), + ); + + // Act & Assert + expect(offsetDateTime.year, 2023); + expect(offsetDateTime.month, 6); + expect(offsetDateTime.day, 16); // Next day + expect(offsetDateTime.hour, 1); + }); + + test('should return correct weekday', () { + // Arrange: June 15, 2023 is a Thursday (weekday 4) + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 6, 15, 12), + offset: const Duration(hours: 2), + ); + + // Act & Assert + expect(offsetDateTime.weekday, 4); // Thursday + }); + }); + + group('epoch time methods', () { + test('should return correct milliseconds since epoch', () { + // Arrange + final baseDateTime = DateTime.utc(2023, 1, 1, 12); + final offsetDateTime = OffsetDateTime.from( + baseDateTime, + offset: const Duration(hours: 5), + ); + + // Act & Assert + // The epoch time should match the input UTC time + expect( + offsetDateTime.millisecondsSinceEpoch, + baseDateTime.millisecondsSinceEpoch, + ); + }); + + test('should return correct microseconds since epoch', () { + // Arrange + final baseDateTime = DateTime.utc(2023, 1, 1, 12); + final offsetDateTime = OffsetDateTime.from( + baseDateTime, + offset: const Duration(hours: 5), + ); + + // Act & Assert + // The epoch time should match the input UTC time + expect( + offsetDateTime.microsecondsSinceEpoch, + baseDateTime.microsecondsSinceEpoch, + ); + }); + }); + + group('arithmetic operations', () { + test('should add duration correctly', () { + // Arrange: Create from UTC to avoid system timezone issues + final offsetDateTime = OffsetDateTime.from( + DateTime.utc( + 2023, + 1, + 15, + 7, + ), // UTC 07:00 + 5 hour offset = 12:00 local + offset: const Duration(hours: 5), + ); + + // Act + final result = offsetDateTime.add(const Duration(hours: 2)); + + // Assert: Local time should be 12:00 + 2 = 14:00 + expect(result.hour, 14); + expect(result.offset, offsetDateTime.offset); + expect(result.timeZoneName, offsetDateTime.timeZoneName); + }); + + test('should subtract duration correctly', () { + // Arrange: Create from UTC to avoid system timezone issues + final offsetDateTime = OffsetDateTime.from( + DateTime.utc( + 2023, + 1, + 15, + 7, + ), // UTC 07:00 + 5 hour offset = 12:00 local + offset: const Duration(hours: 5), + ); + + // Act + final result = offsetDateTime.subtract(const Duration(hours: 2)); + + // Assert: Local time should be 12:00 - 2 = 10:00 + expect(result.hour, 10); + expect(result.offset, offsetDateTime.offset); + expect(result.timeZoneName, offsetDateTime.timeZoneName); + }); + + test('should calculate difference between OffsetDateTime instances', () { + // Arrange: Both should represent the same UTC moment + final offsetDateTime1 = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), // UTC 12:00 + offset: Duration.zero, + ); + final offsetDateTime2 = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), // Same UTC 12:00 + offset: const Duration(hours: 5), + ); + + // Act + final difference = offsetDateTime2.difference(offsetDateTime1); + + // Assert + expect(difference, Duration.zero); // Same UTC time + }); + + test('should calculate difference with regular DateTime', () { + // Arrange: Both should represent the same UTC moment + final offsetDateTime = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), // UTC 12:00 + offset: const Duration(hours: 5), + ); + final regularDateTime = DateTime.utc(2023, 1, 15, 12); + + // Act + final difference = offsetDateTime.difference(regularDateTime); + + // Assert + expect(difference, Duration.zero); // Same UTC time + }); + }); + + group('comparison methods', () { + late OffsetDateTime offsetDateTime1; + late OffsetDateTime offsetDateTime2; + late OffsetDateTime offsetDateTime3; + + setUp(() { + // All represent the same UTC moment: 2023-01-15 12:00 UTC + offsetDateTime1 = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), + offset: Duration.zero, + ); + // Create from a local time: if offset is +5, + // then local 17:00 should equal UTC 12:00 + // But to avoid system timezone issues, we'll create directly from UTC + offsetDateTime2 = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), // Same UTC base + offset: const Duration(hours: 5), + ); + // Different UTC moment: one hour later + offsetDateTime3 = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 13), + offset: Duration.zero, + ); + }); + + test('should correctly identify same moments', () { + expect(offsetDateTime1.isAtSameMomentAs(offsetDateTime2), isTrue); + expect(offsetDateTime1.isAtSameMomentAs(offsetDateTime3), isFalse); + }); + + test('should correctly compare before', () { + expect(offsetDateTime1.isBefore(offsetDateTime3), isTrue); + expect(offsetDateTime3.isBefore(offsetDateTime1), isFalse); + expect(offsetDateTime1.isBefore(offsetDateTime2), isFalse); + }); + + test('should correctly compare after', () { + expect(offsetDateTime3.isAfter(offsetDateTime1), isTrue); + expect(offsetDateTime1.isAfter(offsetDateTime3), isFalse); + expect(offsetDateTime1.isAfter(offsetDateTime2), isFalse); + }); + + test('should correctly compare with compareTo', () { + expect(offsetDateTime1.compareTo(offsetDateTime2), 0); + expect(offsetDateTime1.compareTo(offsetDateTime3), lessThan(0)); + expect(offsetDateTime3.compareTo(offsetDateTime1), greaterThan(0)); + }); + }); + + group('equality and hashCode', () { + test('should be equal when representing same UTC moment', () { + // Arrange: Both should represent the same UTC moment + final offsetDateTime1 = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), + offset: Duration.zero, + ); + final offsetDateTime2 = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), // Same UTC moment + offset: const Duration(hours: 5), + ); + + // Act & Assert + expect(offsetDateTime1 == offsetDateTime2, isTrue); + expect(offsetDateTime1.hashCode, offsetDateTime2.hashCode); + }); + + test('should not be equal when representing different UTC moments', () { + // Arrange + final offsetDateTime1 = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), + offset: Duration.zero, + ); + final offsetDateTime2 = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 13), + offset: Duration.zero, + ); + + // Act & Assert + expect(offsetDateTime1 == offsetDateTime2, isFalse); + }); + + test('should be identical to itself', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12), + offset: Duration.zero, + ); + + // Act & Assert + expect(offsetDateTime == offsetDateTime, isTrue); + expect(identical(offsetDateTime, offsetDateTime), isTrue); + }); + }); + + group('timeZoneOffset property', () { + test('should return the offset', () { + // Arrange + const offset = Duration(hours: 5, minutes: 30); + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 12), + offset: offset, + ); + + // Act & Assert + expect(offsetDateTime.timeZoneOffset, offset); + }); + }); + + group('string representation', () { + test('should format UTC time with Z suffix', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime.utc(2023, 1, 15, 12, 30, 45, 123), + offset: Duration.zero, + ); + + // Act + final isoString = offsetDateTime.toIso8601String(); + final toString = offsetDateTime.toString(); + + // Assert + expect(isoString, '2023-01-15T12:30:45.123Z'); + expect(toString, '2023-01-15 12:30:45.123Z'); + }); + + test('should format offset time with offset suffix', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 12, 30, 45, 123), + offset: const Duration(hours: 5, minutes: 30), + ); + + // Act + final isoString = offsetDateTime.toIso8601String(); + final toString = offsetDateTime.toString(); + + // Assert + expect(isoString, '2023-01-15T12:30:45.123+0530'); + expect(toString, '2023-01-15 12:30:45.123+0530'); + }); + + test('should format negative offset correctly', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 12, 30, 45, 123), + offset: const Duration(hours: -8), + ); + + // Act + final isoString = offsetDateTime.toIso8601String(); + + // Assert + expect(isoString, '2023-01-15T12:30:45.123-0800'); + }); + + test('should handle microseconds in string representation', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 12, 30, 45, 123, 456), + offset: const Duration(hours: 2), + ); + + // Act + final isoString = offsetDateTime.toIso8601String(); + + // Assert + expect(isoString, '2023-01-15T12:30:45.123456+0200'); + }); + }); + + group('edge cases', () { + test('should handle maximum positive offset', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 12), + offset: const Duration(hours: 14), + ); + + // Act & Assert + expect(offsetDateTime.timeZoneName, 'UTC+14:00'); + }); + + test('should handle maximum negative offset', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 12), + offset: const Duration(hours: -12), + ); + + // Act & Assert + expect(offsetDateTime.timeZoneName, 'UTC-12:00'); + }); + + test('should handle leap year dates', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime(2024, 2, 29, 12), // Leap year + offset: const Duration(hours: 3), + ); + + // Act & Assert + expect(offsetDateTime.year, 2024); + expect(offsetDateTime.month, 2); + expect(offsetDateTime.day, 29); + }); + + test('should handle year boundary crossing', () { + // Arrange: New Year's Eve UTC + positive offset = New Year's Day local + final offsetDateTime = OffsetDateTime.from( + DateTime.utc(2023, 12, 31, 23), + offset: const Duration(hours: 2), + ); + + // Act & Assert + expect(offsetDateTime.year, 2024); + expect(offsetDateTime.month, 1); + expect(offsetDateTime.day, 1); + expect(offsetDateTime.hour, 1); + }); + + test('should handle minute-level offsets', () { + // Arrange + final offsetDateTime = OffsetDateTime.from( + DateTime(2023, 1, 15, 12), + offset: const Duration(minutes: 30), + ); + + // Act & Assert + expect(offsetDateTime.timeZoneName, 'UTC+00:30'); + }); + }); + }); + + group('OffsetDateTime.parse', () { + group('UTC parsing', () { + test('should parse UTC datetime with Z suffix', () { + // Arrange & Act + const input = '2023-12-25T15:30:45Z'; + final result = OffsetDateTime.parse(input); + + // Assert + expect(result, isA()); + expect(result.isUtc, isTrue); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset, Duration.zero); + expect(result.timeZoneName, 'UTC'); + }); + + test('should parse UTC datetime with milliseconds', () { + // Arrange & Act + const input = '2023-12-25T15:30:45.123Z'; + final result = OffsetDateTime.parse(input); + + // Assert + expect(result, isA()); + expect(result.isUtc, isTrue); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + expect(result.microsecond, 0); + expect(result.timeZoneName, 'UTC'); + }); + + test('should parse UTC datetime with microseconds', () { + // Arrange & Act + const input = '2023-12-25T15:30:45.123456Z'; + final result = OffsetDateTime.parse(input); + + // Assert + expect(result, isA()); + expect(result.isUtc, isTrue); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + expect(result.microsecond, 456); + expect(result.timeZoneName, 'UTC'); + }); + }); + + group('local datetime parsing', () { + test('should parse local datetime without timezone', () { + const input = '2023-12-25T15:30:45'; + final result = OffsetDateTime.parse(input); + + expect(result, isA()); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + + // Should match system timezone, not be treated as UTC + final expectedLocalTime = DateTime.parse(input); + expect(result.isUtc, expectedLocalTime.isUtc); + expect(result.timeZoneOffset, expectedLocalTime.timeZoneOffset); + }); + + test('should parse local datetime with space separator', () { + const input = '2023-12-25 15:30:45'; + final result = OffsetDateTime.parse(input); + + expect(result, isA()); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + + // Should match system timezone for space-separated format + final expectedLocalTime = DateTime.parse(input); + expect(result.timeZoneOffset, expectedLocalTime.timeZoneOffset); + }); + + test('should parse local datetime with milliseconds', () { + const input = '2023-12-25T15:30:45.789'; + final result = OffsetDateTime.parse(input); + + expect(result, isA()); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 789); + + // Should match system timezone for milliseconds format + final expectedLocalTime = DateTime.parse(input); + expect(result.timeZoneOffset, expectedLocalTime.timeZoneOffset); + }); + }); + + group('timezone offset parsing', () { + test('should parse positive timezone offset with colon', () { + // Arrange & Act + const input = '2023-12-25T15:30:45+05:30'; + final result = OffsetDateTime.parse(input); + + // Assert + expect(result, isA()); + expect(result.timeZoneName, 'UTC+05:30'); + expect(result.timeZoneOffset.inMinutes, 330); // 5.5 hours + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + }); + + test('should parse negative timezone offset with colon', () { + // Arrange & Act + const input = '2023-12-25T15:30:45-03:15'; + final result = OffsetDateTime.parse(input); + + // Assert + expect(result, isA()); + expect(result.timeZoneName, 'UTC-03:15'); + expect(result.timeZoneOffset.inMinutes, -195); // -3.25 hours + }); + + test('should parse timezone offset without colon', () { + // Arrange & Act + const input = '2023-12-25T15:30:45+0800'; + final result = OffsetDateTime.parse(input); + + // Assert + expect(result, isA()); + expect(result.timeZoneName, 'UTC+08:00'); + expect(result.timeZoneOffset.inHours, 8); + }); + + test('should parse zero timezone offset', () { + // Arrange & Act + const input = '2023-12-25T15:30:45+00:00'; + final result = OffsetDateTime.parse(input); + + // Assert + expect(result, isA()); + expect(result.timeZoneName, 'UTC'); + expect(result.timeZoneOffset.inMinutes, 0); + }); + + test('should parse datetime with offset and milliseconds', () { + // Arrange & Act + const input = '2023-12-25T15:30:45.123+02:00'; + final result = OffsetDateTime.parse(input); + + // Assert + expect(result, isA()); + expect(result.timeZoneName, 'UTC+02:00'); + expect(result.timeZoneOffset.inHours, 2); + expect(result.millisecond, 123); + }); + + test('should parse datetime with offset and microseconds', () { + // Arrange & Act + const input = '2023-12-25T15:30:45.123456-07:00'; + final result = OffsetDateTime.parse(input); + + // Assert + expect(result, isA()); + expect(result.timeZoneName, 'UTC-07:00'); + expect(result.timeZoneOffset.inHours, -7); + expect(result.millisecond, 123); + expect(result.microsecond, 456); + }); + }); + + group('error handling', () { + test('should throw InvalidFormatException for empty string', () { + // Act & Assert + expect( + () => OffsetDateTime.parse(''), + throwsA(isA()), + ); + }); + + test('should throw InvalidFormatException for invalid format', () { + // Act & Assert + expect( + () => OffsetDateTime.parse('not-a-date'), + throwsA(isA()), + ); + }); + + test( + 'should throw InvalidFormatException for invalid timezone offset', + () { + // Act & Assert + expect( + () => OffsetDateTime.parse('2023-12-25T15:30:45+25:00'), + // Invalid hour + throwsA(isA()), + ); + }, + ); + + test( + 'should throw InvalidFormatException for invalid timezone format', + () { + // Act & Assert + expect( + () => OffsetDateTime.parse('2023-12-25T15:30:45+5:30'), + // Missing leading zero + throwsA(isA()), + ); + }, + ); + + test('should throw InvalidFormatException for invalid minutes', () { + // Act & Assert + expect( + () => OffsetDateTime.parse('2023-12-25T15:30:45+05:60'), + // Invalid minutes + throwsA(isA()), + ); + }); + }); + }); + + group('OffsetDateTime.parse local timezone behavior', () { + test('should preserve local timezone for strings ' + 'without timezone info', () { + const localString = '2024-03-14T10:30:45'; + final result = OffsetDateTime.parse(localString); + + final expectedDateTime = DateTime.parse(localString); + + expect(result.year, 2024); + expect(result.month, 3); + expect(result.day, 14); + expect(result.hour, 10); + expect(result.minute, 30); + expect(result.second, 45); + + expect(result.timeZoneOffset, expectedDateTime.timeZoneOffset); + expect(result.timeZoneName, isNot('UTC')); + }); + + test('should handle local timezone vs UTC timezone correctly', () { + const timeString = '2024-03-14T10:30:45'; + + final localResult = OffsetDateTime.parse(timeString); + final utcResult = OffsetDateTime.parse('${timeString}Z'); + + expect(utcResult.timeZoneOffset, Duration.zero); + expect(utcResult.timeZoneName, 'UTC'); + + final expectedLocalTime = DateTime.parse(timeString); + expect(localResult.timeZoneOffset, expectedLocalTime.timeZoneOffset); + + expect(localResult.hour, 10); + expect(localResult.minute, 30); + expect(utcResult.hour, 10); + expect(utcResult.minute, 30); + }); + + test('should preserve local time values correctly', () { + const localString = '2024-03-14T15:30:45'; + final result = OffsetDateTime.parse(localString); + + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + + final expectedLocalTime = DateTime.parse(localString); + expect(result.timeZoneOffset, expectedLocalTime.timeZoneOffset); + }); + }); +} From b6e0829cfc3c220bcea6604e0a23e6ed29cb6e19 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 27 Jul 2025 16:13:17 +0200 Subject: [PATCH 2/4] chore: improved integration tests --- .github/workflows/test.yml | 16 +++++++++++-- integration_test/gov/gov_test/pubspec.yaml | 3 +++ .../music_streaming_test/pubspec.lock | 23 +++--------------- .../music_streaming_test/pubspec.yaml | 4 ++++ .../petstore/petstore_test/pubspec.yaml | 3 +++ integration_test/setup.sh | 24 ++++++++++++++++++- 6 files changed, 50 insertions(+), 23 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c99217e..58246e0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,8 +35,20 @@ jobs: - name: Run tests run: melos run test - - name: Run integration tests + - name: Run petstore integration tests run: | cd integration_test/petstore/petstore_test dart pub get - dart test --concurrency=1 \ No newline at end of file + dart test --concurrency=1 + + - name: Run music streaming integration tests + run: | + cd integration_test/music_streaming/music_streaming_test + dart pub get + dart test --concurrency=1 + + - name: Run gov integration tests + run: | + cd integration_test/gov/gov_test + dart pub get + dart test --concurrency=1 diff --git a/integration_test/gov/gov_test/pubspec.yaml b/integration_test/gov/gov_test/pubspec.yaml index 0a631ab..b8f463d 100644 --- a/integration_test/gov/gov_test/pubspec.yaml +++ b/integration_test/gov/gov_test/pubspec.yaml @@ -17,3 +17,6 @@ dev_dependencies: test: ^1.25.15 very_good_analysis: ^9.0.0 +dependency_overrides: + tonik_util: + path: ../../../packages/tonik_util \ No newline at end of file diff --git a/integration_test/music_streaming/music_streaming_test/pubspec.lock b/integration_test/music_streaming/music_streaming_test/pubspec.lock index 924596e..4514a8c 100644 --- a/integration_test/music_streaming/music_streaming_test/pubspec.lock +++ b/integration_test/music_streaming/music_streaming_test/pubspec.lock @@ -137,14 +137,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" - http: - dependency: transitive - description: - name: http - sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" - url: "https://pub.dev" - source: hosted - version: "1.4.0" http_multi_server: dependency: transitive description: @@ -392,21 +384,12 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.11" - timezone: - dependency: transitive - description: - name: timezone - sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 - url: "https://pub.dev" - source: hosted - version: "0.10.1" tonik_util: dependency: "direct main" description: - name: tonik_util - sha256: "4b86da571a6a3ce18d89bc0e0a489aa1eebf8d43a8a883d85a69a870df3c69e8" - url: "https://pub.dev" - source: hosted + path: "../../../packages/tonik_util" + relative: true + source: path version: "0.0.7" typed_data: dependency: transitive diff --git a/integration_test/music_streaming/music_streaming_test/pubspec.yaml b/integration_test/music_streaming/music_streaming_test/pubspec.yaml index ff338c0..2376cb5 100644 --- a/integration_test/music_streaming/music_streaming_test/pubspec.yaml +++ b/integration_test/music_streaming/music_streaming_test/pubspec.yaml @@ -16,3 +16,7 @@ dependencies: dev_dependencies: test: ^1.25.15 very_good_analysis: ^9.0.0 + +dependency_overrides: + tonik_util: + path: ../../../packages/tonik_util \ No newline at end of file diff --git a/integration_test/petstore/petstore_test/pubspec.yaml b/integration_test/petstore/petstore_test/pubspec.yaml index 5cea358..10d0913 100644 --- a/integration_test/petstore/petstore_test/pubspec.yaml +++ b/integration_test/petstore/petstore_test/pubspec.yaml @@ -17,3 +17,6 @@ dev_dependencies: test: ^1.25.15 very_good_analysis: ^9.0.0 +dependency_overrides: + tonik_util: + path: ../../../packages/tonik_util \ No newline at end of file diff --git a/integration_test/setup.sh b/integration_test/setup.sh index 472b9c6..5864392 100755 --- a/integration_test/setup.sh +++ b/integration_test/setup.sh @@ -14,14 +14,36 @@ if [[ $(echo "$JAVA_VERSION" | cut -d. -f1) -lt 11 ]]; then exit 1 fi -# Generate API code +# Function to add dependency overrides to generated packages +add_dependency_overrides() { + local pubspec_file="$1" + + if [ -f "$pubspec_file" ]; then + echo "Adding dependency overrides to $pubspec_file" + + # Add dependency_overrides section if it doesn't exist + if ! grep -q "dependency_overrides:" "$pubspec_file"; then + echo "" >> "$pubspec_file" + echo "dependency_overrides:" >> "$pubspec_file" + echo " tonik_util:" >> "$pubspec_file" + echo " path: ../../../packages/tonik_util" >> "$pubspec_file" + fi + else + echo "Warning: $pubspec_file not found" + fi +} + +# Generate API code with automatic dependency overrides for local tonik_util dart run ../packages/tonik/bin/tonik.dart -p petstore_api -s petstore/openapi.yaml -o petstore --log-level verbose +add_dependency_overrides "petstore/petstore_api/pubspec.yaml" cd petstore/petstore_api && dart pub get && cd ../.. dart run ../packages/tonik/bin/tonik.dart -p music_streaming_api -s music_streaming/openapi.yaml -o music_streaming --log-level verbose +add_dependency_overrides "music_streaming/music_streaming_api/pubspec.yaml" cd music_streaming/music_streaming_api && dart pub get && cd ../.. dart run ../packages/tonik/bin/tonik.dart -p gov_api -s gov/openapi.yaml -o gov --log-level verbose +add_dependency_overrides "gov/gov_api/pubspec.yaml" cd gov/gov_api && dart pub get && cd ../.. # Download Imposter JAR only if it doesn't exist From 3994fcc1ba48674a5b9f1370f244664deec3b36d Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 27 Jul 2025 16:21:41 +0200 Subject: [PATCH 3/4] chore: fix test --- .../test/src/offset_date_time_test.dart | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/tonik_util/test/src/offset_date_time_test.dart b/packages/tonik_util/test/src/offset_date_time_test.dart index 961369a..3aef2a5 100644 --- a/packages/tonik_util/test/src/offset_date_time_test.dart +++ b/packages/tonik_util/test/src/offset_date_time_test.dart @@ -669,10 +669,9 @@ void main() { expect(result.hour, 15); expect(result.minute, 30); expect(result.second, 45); - - // Should match system timezone, not be treated as UTC + + // Should match system timezone offset final expectedLocalTime = DateTime.parse(input); - expect(result.isUtc, expectedLocalTime.isUtc); expect(result.timeZoneOffset, expectedLocalTime.timeZoneOffset); }); @@ -842,22 +841,20 @@ void main() { }); group('OffsetDateTime.parse local timezone behavior', () { - test('should preserve local timezone for strings ' - 'without timezone info', () { + test('should preserve local timezone for strings without timezone info', () { const localString = '2024-03-14T10:30:45'; final result = OffsetDateTime.parse(localString); - + final expectedDateTime = DateTime.parse(localString); - + expect(result.year, 2024); expect(result.month, 3); expect(result.day, 14); expect(result.hour, 10); expect(result.minute, 30); expect(result.second, 45); - + expect(result.timeZoneOffset, expectedDateTime.timeZoneOffset); - expect(result.timeZoneName, isNot('UTC')); }); test('should handle local timezone vs UTC timezone correctly', () { From faa40ccda496f3f653a099f98c395fec558fcbfe Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Sun, 27 Jul 2025 16:29:51 +0200 Subject: [PATCH 4/4] chore: cleanup --- docs/data_types.md | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/docs/data_types.md b/docs/data_types.md index 54df516..6360699 100644 --- a/docs/data_types.md +++ b/docs/data_types.md @@ -22,25 +22,12 @@ This document provides information about how Tonik is mapping data types in Open ### Timezone-Aware DateTime Parsing -Tonik provides intelligent timezone-aware parsing for `date-time` format strings using the `OffsetDateTime` class. The parsing behavior depends on the timezone information present in the input: +Tonik provides timezone-aware parsing for `date-time` format strings using the `OffsetDateTime` class. The parsing behavior depends on the timezone information present in the input: -All generated code will always expose Dart `DateTime` objects through the standard decoder methods. However, internally Tonik uses `OffsetDateTime.parse()` to provide consistent timezone handling. The `OffsetDateTime` class extends Dart's `DateTime` interface while preserving timezone offset information: +All generated code will always expose Dart `DateTime` objects in the genreated code. However, internally Tonik uses `OffsetDateTime.parse()` to provide consistent timezone handling. The `OffsetDateTime` class extends Dart's `DateTime` interface while preserving timezone offset information: | Input Format | Return Type | Example | Description | |--------------|-------------|---------|-------------| | UTC (with Z) | `OffsetDateTime` (UTC) | `2023-12-25T15:30:45Z` | OffsetDateTime with zero offset (UTC) | | Local (no timezone) | `OffsetDateTime` (system timezone) | `2023-12-25T15:30:45` | OffsetDateTime with system timezone offset | | Timezone offset | `OffsetDateTime` (specified offset) | `2023-12-25T15:30:45+05:00` | OffsetDateTime with the specified offset | - -#### OffsetDateTime Benefits - -The `OffsetDateTime` class provides several advantages over standard `DateTime` objects: - -- **Preserves timezone offset information**: Unlike `DateTime`, `OffsetDateTime` retains the original timezone offset -- **Consistent API**: Implements the complete `DateTime` interface, so it can be used as a drop-in replacement -- **Fixed offset semantics**: Uses fixed timezone offsets rather than location-based timezones, avoiding DST ambiguity -- **Auto-generated timezone names**: Provides human-readable timezone names like `UTC+05:30`, `UTC-08:00`, or `UTC` - -#### Local Time Preservation - -For strings without timezone information (e.g., `2023-12-25T15:30:45`), `OffsetDateTime.parse()` preserves the local timezone behavior by using the system's timezone offset for that specific date and time. This ensures consistency with Dart's `DateTime.parse()` while providing the additional timezone offset information.