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/.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/CHANGELOG.md b/CHANGELOG.md index 2bb3ab4..e072451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,29 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 2025-07-20 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`tonik_util` - `v0.0.7`](#tonik_util---v007) + +--- + +#### `tonik_util` - `v0.0.7` + + - **FEAT**: Uri property encoding and decoding. + - **FEAT**: time zone aware date time parsing. + - **FEAT**: time zone aware encoding of date time objects. + + ## 2025-06-15 ### Changes diff --git a/docs/data_types.md b/docs/data_types.md new file mode 100644 index 0000000..6360699 --- /dev/null +++ b/docs/data_types.md @@ -0,0 +1,33 @@ +# Data Types + +This document provides information about how Tonik is mapping data types in OpenAPI into Dart. + + +## Primitive Types + +| OAS Type | OAS Format | Dart Type | Dart Package | Comment | +|----------|------------|-----------|--------------|---------| +| `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 | +| `string` | `enum` | `enum` | Generated | Custom enum type | +| `string` | (default) | `String` | `dart:core` | Standard string type | +| `number` | `float`, `double` | `double` | `dart:core` | 64-bit floating point | +| `number` | (default) | `num` | `dart:core` | Generic number type | +| `integer` | `enum` | `enum` | Generated | Custom enum type | +| `integer` | (default) | `int` | `dart:core` | 64-bit integer | +| `boolean` | (any) | `bool` | `dart:core` | Boolean type | +| `array` | (any) | `List` | `dart:core` | List of specified type | + +### Timezone-Aware DateTime Parsing + +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 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 | diff --git a/integration_test/gov/gov_test/pubspec.yaml b/integration_test/gov/gov_test/pubspec.yaml index b4c67f7..b8f463d 100644 --- a/integration_test/gov/gov_test/pubspec.yaml +++ b/integration_test/gov/gov_test/pubspec.yaml @@ -10,10 +10,13 @@ dependencies: dio: ^5.8.0 gov_api: path: ../gov_api - path: ^1.8.3 - tonik_util: ^0.0.6 + path: ^1.9.1 + tonik_util: ^0.0.7 dev_dependencies: - test: ^1.24.0 + 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 0c8693c..4514a8c 100644 --- a/integration_test/music_streaming/music_streaming_test/pubspec.lock +++ b/integration_test/music_streaming/music_streaming_test/pubspec.lock @@ -387,11 +387,10 @@ packages: tonik_util: dependency: "direct main" description: - name: tonik_util - sha256: f16c86d5349fac40893d1d8ae87f6507192d8886f16ba6acefeabef4a61ae3ef - url: "https://pub.dev" - source: hosted - version: "0.0.5" + path: "../../../packages/tonik_util" + relative: true + source: path + version: "0.0.7" typed_data: dependency: transitive description: diff --git a/integration_test/music_streaming/music_streaming_test/pubspec.yaml b/integration_test/music_streaming/music_streaming_test/pubspec.yaml index 27ed7d8..2376cb5 100644 --- a/integration_test/music_streaming/music_streaming_test/pubspec.yaml +++ b/integration_test/music_streaming/music_streaming_test/pubspec.yaml @@ -10,9 +10,13 @@ dependencies: dio: ^5.8.0 music_streaming_api: path: ../music_streaming_api - path: ^1.8.3 - tonik_util: ^0.0.2 + path: ^1.9.1 + tonik_util: ^0.0.7 dev_dependencies: - test: ^1.24.0 + 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 6e483f5..10d0913 100644 --- a/integration_test/petstore/petstore_test/pubspec.yaml +++ b/integration_test/petstore/petstore_test/pubspec.yaml @@ -8,13 +8,15 @@ environment: dependencies: dio: ^5.8.0 - path: ^1.8.3 + path: ^1.9.1 petstore_api: path: ../petstore_api - tonik_util: ^0.0.2 - + tonik_util: ^0.0.7 dev_dependencies: - test: ^1.24.0 + 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 diff --git a/packages/tonik/README.md b/packages/tonik/README.md index 5f80f6f..78dd103 100644 --- a/packages/tonik/README.md +++ b/packages/tonik/README.md @@ -96,8 +96,6 @@ For a full list of changes of each release, refer to [release notes](https://git ### Short term goals - `allowReserved` support for query parameters -- `format: uri` mapping to Dart `Uri` -- Add custom `Date` model in util package to handle `format: date` properly - More E2E tests - Full decoding and encoding support for any of and one of - Support for `x-dart-name`, `x-dart-type` and `x-dart-enums` diff --git a/packages/tonik/pubspec.yaml b/packages/tonik/pubspec.yaml index df60006..442cb3f 100644 --- a/packages/tonik/pubspec.yaml +++ b/packages/tonik/pubspec.yaml @@ -14,6 +14,14 @@ topics: - rest - swagger +platforms: + android: + ios: + linux: + macos: + web: + windows: + environment: sdk: ">=3.7.0 <4.0.0" diff --git a/packages/tonik_core/lib/src/model/model.dart b/packages/tonik_core/lib/src/model/model.dart index bc92452..619d496 100644 --- a/packages/tonik_core/lib/src/model/model.dart +++ b/packages/tonik_core/lib/src/model/model.dart @@ -235,6 +235,13 @@ class DecimalModel extends PrimitiveModel { String toString() => 'DecimalModel'; } +class UriModel extends PrimitiveModel { + const UriModel({required super.context}); + + @override + String toString() => 'UriModel'; +} + @immutable class Property { const Property({ diff --git a/packages/tonik_core/test/model/model_type_properties_test.dart b/packages/tonik_core/test/model/model_type_properties_test.dart index b20fc81..92dbf37 100644 --- a/packages/tonik_core/test/model/model_type_properties_test.dart +++ b/packages/tonik_core/test/model/model_type_properties_test.dart @@ -36,6 +36,10 @@ void main() { DecimalModel(context: Context.initial()).encodingShape, EncodingShape.simple, ); + expect( + UriModel(context: Context.initial()).encodingShape, + EncodingShape.simple, + ); }); test('Enum models are simple', () { 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_core/test/model/query_parameter_test.dart b/packages/tonik_core/test/model/query_parameter_test.dart index 732ede2..76446c1 100644 --- a/packages/tonik_core/test/model/query_parameter_test.dart +++ b/packages/tonik_core/test/model/query_parameter_test.dart @@ -23,17 +23,17 @@ 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.allowReserved, isFalse); expect(resolved.explode, isFalse); - expect(resolved.model, equals(model)); - expect(resolved.encoding, equals(QueryParameterEncoding.form)); - expect(resolved.context, equals(context)); + expect(resolved.model, model); + expect(resolved.encoding, QueryParameterEncoding.form); + expect(resolved.context, context); }); test('resolve preserves original name when no new name provided', () { @@ -56,7 +56,7 @@ void main() { final resolved = param.resolve(); - expect(resolved.name, equals('originalName')); + expect(resolved.name, 'originalName'); }); test('QueryParameterAlias.resolve resolves with alias name', () { @@ -85,8 +85,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( @@ -117,7 +117,7 @@ void main() { final resolved = alias.resolve(name: 'overrideName'); - expect(resolved.name, equals('overrideName')); + expect(resolved.name, 'overrideName'); }, ); @@ -153,8 +153,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_core/test/model/request_header_test.dart b/packages/tonik_core/test/model/request_header_test.dart index b6edca2..38b5147 100644 --- a/packages/tonik_core/test/model/request_header_test.dart +++ b/packages/tonik_core/test/model/request_header_test.dart @@ -22,16 +22,16 @@ void main() { final resolved = header.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(HeaderParameterEncoding.simple)); - expect(resolved.context, equals(context)); + expect(resolved.model, model); + expect(resolved.encoding, HeaderParameterEncoding.simple); + expect(resolved.context, context); }); test('resolve preserves original name when no new name provided', () { @@ -53,7 +53,7 @@ void main() { final resolved = header.resolve(); - expect(resolved.name, equals('originalName')); + expect(resolved.name, 'originalName'); }); test('RequestHeaderAlias.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_core/test/model/response_header_test.dart b/packages/tonik_core/test/model/response_header_test.dart index 628766b..7bef64a 100644 --- a/packages/tonik_core/test/model/response_header_test.dart +++ b/packages/tonik_core/test/model/response_header_test.dart @@ -20,14 +20,14 @@ void main() { final resolved = header.resolve(name: 'newName'); - expect(resolved.name, equals('newName')); - expect(resolved.description, equals('description')); + expect(resolved.name, 'newName'); + expect(resolved.description, 'description'); expect(resolved.isRequired, isTrue); expect(resolved.isDeprecated, isFalse); expect(resolved.explode, isFalse); - expect(resolved.model, equals(model)); - expect(resolved.encoding, equals(ResponseHeaderEncoding.simple)); - expect(resolved.context, equals(context)); + expect(resolved.model, model); + expect(resolved.encoding, ResponseHeaderEncoding.simple); + expect(resolved.context, context); }); test('resolve preserves original name when no new name provided', () { @@ -47,7 +47,7 @@ void main() { final resolved = header.resolve(); - expect(resolved.name, equals('originalName')); + expect(resolved.name, 'originalName'); }); test('ResponseHeaderAlias.resolve resolves with alias name', () { @@ -73,8 +73,8 @@ void main() { final resolved = alias.resolve(); - expect(resolved.name, equals('aliasName')); - expect(resolved.description, equals('description')); + expect(resolved.name, 'aliasName'); + expect(resolved.description, 'description'); }); test( @@ -102,7 +102,7 @@ void main() { final resolved = alias.resolve(name: 'overrideName'); - expect(resolved.name, equals('overrideName')); + expect(resolved.name, 'overrideName'); }, ); @@ -135,8 +135,8 @@ void main() { final resolved = secondAlias.resolve(); - expect(resolved.name, equals('secondAliasName')); - expect(resolved.description, equals('description')); + expect(resolved.name, 'secondAliasName'); + expect(resolved.description, 'description'); }); }); } diff --git a/packages/tonik_generate/lib/src/naming/name_generator.dart b/packages/tonik_generate/lib/src/naming/name_generator.dart index 937f658..2547e1c 100644 --- a/packages/tonik_generate/lib/src/naming/name_generator.dart +++ b/packages/tonik_generate/lib/src/naming/name_generator.dart @@ -376,7 +376,7 @@ class NameGenerator { ({String baseName, Map serverMap, String customName}) _generateServerNames(List servers, List uniqueNames) { - final baseName = _makeUnique('ApiServer', ''); + final baseName = _makeUnique('Server', ''); final resultMap = {}; for (var index = 0; index < servers.length; index++) { @@ -398,7 +398,7 @@ class NameGenerator { ({String baseName, Map serverMap, String customName}) _generateFallbackServerNames(List servers) { - final baseName = _makeUnique('ApiServer', ''); + final baseName = _makeUnique('Server', ''); final resultMap = {}; for (final server in servers) { diff --git a/packages/tonik_generate/lib/src/naming/name_utils.dart b/packages/tonik_generate/lib/src/naming/name_utils.dart index 56e3e22..1b8a13c 100644 --- a/packages/tonik_generate/lib/src/naming/name_utils.dart +++ b/packages/tonik_generate/lib/src/naming/name_utils.dart @@ -1,13 +1,9 @@ import 'package:change_case/change_case.dart'; -import 'package:spell_out_numbers/spell_out_numbers.dart'; -/// Default prefix used for empty or invalid enum values. const defaultEnumPrefix = 'value'; -/// Default prefix used for empty or invalid field names. const defaultFieldPrefix = 'field'; -/// Reserved Dart keywords that cannot be used as identifiers. const dartKeywords = { 'abstract', 'as', @@ -89,6 +85,72 @@ const generatedClassTokens = { const Set allKeywords = {...dartKeywords, ...generatedClassTokens}; +/// Converts a number to its English word representation. +/// Supports numbers up to trillions. +String _numberToWords(int number) { + if (number == 0) return 'zero'; + + const ones = [ + '', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', + 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', + 'sixteen', 'seventeen', 'eighteen', 'nineteen' + ]; + + const tens = [ + '', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', + 'eighty', 'ninety' + ]; + + final result = []; + var remaining = number; + + if (remaining >= 1000000000000) { + result + ..add(_numberToWords(remaining ~/ 1000000000000)) + ..add('trillion'); + remaining %= 1000000000000; + } + + if (remaining >= 1000000000) { + result + ..add(_numberToWords(remaining ~/ 1000000000)) + ..add('billion'); + remaining %= 1000000000; + } + + if (remaining >= 1000000) { + result + ..add(_numberToWords(remaining ~/ 1000000)) + ..add('million'); + remaining %= 1000000; + } + + if (remaining >= 1000) { + result + ..add(_numberToWords(remaining ~/ 1000)) + ..add('thousand'); + remaining %= 1000; + } + + if (remaining >= 100) { + result + ..add(ones[remaining ~/ 100]) + ..add('hundred'); + remaining %= 100; + } + + if (remaining >= 20) { + result.add(tens[remaining ~/ 10]); + if (remaining % 10 != 0) { + result.add(ones[remaining % 10]); + } + } else if (remaining > 0) { + result.add(ones[remaining]); + } + + return result.join(' ').trim(); +} + /// Ensures a name is not a Dart keyword by adding a $ prefix if necessary. String ensureNotKeyword(String name) { if (allKeywords.contains(name.toCamelCase()) || @@ -98,80 +160,103 @@ String ensureNotKeyword(String name) { return name; } -/// Processes a part of a name, handling numbers and casing. -/// If [isFirstPart] is true, numbers at the start will be moved to the end. -({String processed, String? number}) processPart( - String part, { - required bool isFirstPart, -}) { - final processedPart = part.replaceAll(RegExp('[^a-zA-Z0-9]'), ''); - if (processedPart.isEmpty) return (processed: '', number: null); - - // Handle numbers differently for first part vs subsequent parts - if (isFirstPart) { - final numberMatch = RegExp(r'^(\d+)(.+)$').firstMatch(processedPart); +/// Splits text into tokens and normalizes each one. +String _normalizeText(String text, {bool preserveNumbers = false}) { + if (text.isEmpty) return ''; + + // Clean invalid characters but preserve separators for splitting + final cleaned = text.replaceAll(RegExp(r'[^a-zA-Z0-9_\-\s]'), ''); + + // Split on separators and case boundaries + final tokens = cleaned + .split(RegExp(r'[_\-\s]+|(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])')) + .where((token) => token.isNotEmpty) + .toList(); + + if (tokens.isEmpty) return ''; + + final result = []; + final numbersToAppend = []; + + for (var i = 0; i < tokens.length; i++) { + final token = tokens[i]; + final isFirst = i == 0; + + // Extract numbers from token + final numberMatch = + RegExp(r'^(\d+)(.*)$|^(.+?)(\d+)$').firstMatch(token); + + String textPart; + String? numberPart; + if (numberMatch != null) { - final number = numberMatch.group(1)!; - final rest = numberMatch.group(2)!; - return (processed: rest.toCamelCase(), number: number); + if (numberMatch.group(1) != null) { + // Leading number: 123abc + numberPart = numberMatch.group(1); + textPart = numberMatch.group(2) ?? ''; + } else { + // Trailing number: abc123 + textPart = numberMatch.group(3) ?? ''; + numberPart = numberMatch.group(4); + } + } else if (RegExp(r'^\d+$').hasMatch(token)) { + // Pure number + numberPart = token; + textPart = ''; + } else { + // No numbers + textPart = token; + numberPart = null; } - return (processed: processedPart.toCamelCase(), number: null); - } else { - final numberMatch = RegExp( - r'^(\d+)(.+)$|^(.+?)(\d+)$', - ).firstMatch(processedPart); - if (numberMatch != null) { - final leadingNumber = numberMatch.group(1); - final leadingRest = numberMatch.group(2); - final trailingBase = numberMatch.group(3); - final trailingNumber = numberMatch.group(4); - - if (leadingNumber != null && leadingRest != null) { - return (processed: leadingRest.toPascalCase(), number: leadingNumber); - } else if (trailingBase != null && trailingNumber != null) { - return (processed: trailingBase.toPascalCase(), number: trailingNumber); + + // Process text part + if (textPart.isNotEmpty) { + final normalized = _normalizeCasing(textPart, isFirst: isFirst); + result.add(normalized); + } + + // Handle numbers + if (numberPart != null) { + if (isFirst && textPart.isNotEmpty && + numberMatch?.group(1) != null) { + // Move leading numbers from first token to end + // (e.g., "1status" -> "status1") + numbersToAppend.add(numberPart); + } else { + // Keep numbers in place for trailing numbers or non-first tokens + result.add(numberPart); } } - return (processed: processedPart.toPascalCase(), number: null); } + + // Append any numbers that were moved from the first token + result.addAll(numbersToAppend); + + return result.join(); } -/// Splits a string into parts based on common separators and case boundaries. -List splitIntoParts(String value) => - value.split(RegExp(r'[_\- ]|(?=[A-Z])')); - -/// Processes parts into a normalized name. -String processPartsIntoName(List parts) { - if (parts.isEmpty) return ''; - - final processedParts = []; - - // Process first part - final firstResult = processPart(parts.first, isFirstPart: true); - if (firstResult.processed.isNotEmpty) { - processedParts.add(firstResult.processed); - if (firstResult.number != null) { - processedParts.add(firstResult.number!); - } +/// Normalizes the casing of a text token. +String _normalizeCasing(String text, {required bool isFirst}) { + if (text.isEmpty) return text; + + final isAllCaps = text == text.toUpperCase() && text != text.toLowerCase(); + + // Special handling for keywords - keep them lowercase for first part only + if (isFirst && allKeywords.contains(text.toLowerCase())) { + return text.toLowerCase(); } - - // Process remaining parts - for (var i = 1; i < parts.length; i++) { - final result = processPart(parts[i], isFirstPart: false); - if (result.processed.isNotEmpty) { - processedParts.add(result.processed); - if (result.number != null) { - processedParts.add(result.number!); - } - } + + if (isFirst) { + return isAllCaps ? text.toLowerCase() : text.toCamelCase(); + } else { + return isAllCaps ? text.toPascalCase() : text.toPascalCase(); } - - return processedParts.join(); } + + /// Normalizes a single name to follow Dart guidelines. String normalizeSingle(String name, {bool preserveNumbers = false}) { - // Handle empty or underscore-only strings if (name.isEmpty || RegExp(r'^_+$').hasMatch(name)) { return ''; } @@ -180,40 +265,43 @@ String normalizeSingle(String name, {bool preserveNumbers = false}) { var processedName = name.replaceAll(RegExp('^_+'), ''); if (processedName.isEmpty) return ''; - // If we need to preserve numbers and the name is just a number, return it + // If preserving numbers and it's just a number, return as-is if (preserveNumbers && RegExp(r'^\d+$').hasMatch(processedName)) { return processedName; } - final parts = splitIntoParts(processedName); - processedName = processPartsIntoName(parts); - - // If preserving numbers, ensure we don't lose them in the normalization - if (preserveNumbers) { - final originalNumber = RegExp(r'\d+$').firstMatch(name)?.group(0); - final processedNumber = RegExp(r'\d+$').firstMatch(processedName)?.group(0); - if (originalNumber != null && processedNumber != originalNumber) { - // Remove any trailing numbers and append the original number - final baseProcessed = processedName.replaceAll(RegExp(r'\d+$'), ''); - processedName = '$baseProcessed$originalNumber'; - } - } - + processedName = _normalizeText( + processedName, + preserveNumbers: preserveNumbers, + ); + return ensureNotKeyword(processedName); } /// Normalizes an enum value name, handling special cases like integers. String normalizeEnumValueName(String value) { - // For integer values, spell out the number - if (RegExp(r'^\d+$').hasMatch(value)) { + // Only spell out numbers if the entire value is just a number (no prefix) + if (RegExp(r'^-?\d+$').hasMatch(value)) { final number = int.parse(value); - final words = EnglishNumberScheme().toWord(number); + final words = number < 0 + ? 'minus ${_numberToWords(number.abs())}' + : _numberToWords(number); final normalized = normalizeSingle(words); - return normalized.isEmpty ? defaultEnumPrefix : normalized; + return normalized.isEmpty + ? defaultEnumPrefix + : normalized.toCamelCase(); } - final normalized = normalizeSingle(value); - return normalized.isEmpty ? defaultEnumPrefix : normalized; + // For values with prefixes (like ERROR_404), preserve numbers as-is + final normalized = normalizeSingle(value, preserveNumbers: true); + if (normalized.isEmpty) return defaultEnumPrefix; + + // Don't apply toCamelCase if the normalized value starts with $ + if (normalized.startsWith(r'$')) { + return normalized; + } + + return normalized.toCamelCase(); } /// Ensures uniqueness in a list of normalized names diff --git a/packages/tonik_generate/lib/src/pubspec_generator.dart b/packages/tonik_generate/lib/src/pubspec_generator.dart index 7bb991e..dcd2c9b 100644 --- a/packages/tonik_generate/lib/src/pubspec_generator.dart +++ b/packages/tonik_generate/lib/src/pubspec_generator.dart @@ -27,7 +27,7 @@ dependencies: dio: ^5.8.0+1 lints: ^6.0.0 meta: ^1.16.0 - tonik_util: ^0.0.6 + tonik_util: ^0.0.7 '''; pubspecFile.writeAsStringSync(content); diff --git a/packages/tonik_generate/lib/src/util/from_json_value_expression_generator.dart b/packages/tonik_generate/lib/src/util/from_json_value_expression_generator.dart index f30b4d0..01ba7b7 100644 --- a/packages/tonik_generate/lib/src/util/from_json_value_expression_generator.dart +++ b/packages/tonik_generate/lib/src/util/from_json_value_expression_generator.dart @@ -59,6 +59,10 @@ Expression buildFromJsonValueExpression( return refer(value) .property(isNullable ? 'decodeJsonNullableDate' : 'decodeJsonDate') .call([], contextParam); + case UriModel(): + return refer(value) + .property(isNullable ? 'decodeJsonNullableUri' : 'decodeJsonUri') + .call([], contextParam); case ListModel(): return _buildListFromJsonExpression( value, diff --git a/packages/tonik_generate/lib/src/util/from_simple_value_expression_generator.dart b/packages/tonik_generate/lib/src/util/from_simple_value_expression_generator.dart index 40441ab..0447dfd 100644 --- a/packages/tonik_generate/lib/src/util/from_simple_value_expression_generator.dart +++ b/packages/tonik_generate/lib/src/util/from_simple_value_expression_generator.dart @@ -62,6 +62,10 @@ Expression buildSimpleValueExpression( isRequired ? value.property('decodeSimpleDate').call([], contextParam) : value.property('decodeSimpleNullableDate').call([], contextParam), + UriModel() => + isRequired + ? value.property('decodeSimpleUri').call([], contextParam) + : value.property('decodeSimpleNullableUri').call([], contextParam), EnumModel() || ClassModel() || AllOfModel() || @@ -207,6 +211,12 @@ Expression _buildListFromSimpleExpression( isRequired, contextParam: contextParam, ), + UriModel() => _buildPrimitiveList( + listDecode, + 'decodeSimpleUri', + isRequired, + contextParam: contextParam, + ), ClassModel() => throw UnimplementedError( 'ClassModel is not supported in lists for simple encoding', diff --git a/packages/tonik_generate/lib/src/util/to_json_value_expression_generator.dart b/packages/tonik_generate/lib/src/util/to_json_value_expression_generator.dart index 7d0a55d..7d4982a 100644 --- a/packages/tonik_generate/lib/src/util/to_json_value_expression_generator.dart +++ b/packages/tonik_generate/lib/src/util/to_json_value_expression_generator.dart @@ -47,8 +47,9 @@ String? _getSerializationSuffix(Model model, bool isNullable) { isNullable || (model is EnumModel && model.isNullable) ? '?' : ''; return switch (model) { - DateTimeModel() => '$nullablePart.toIso8601String()', + DateTimeModel() => '$nullablePart.toTimeZonedIso8601String()', DecimalModel() => '$nullablePart.toString()', + UriModel() => '$nullablePart.toString()', DateModel() || EnumModel() || ClassModel() || diff --git a/packages/tonik_generate/lib/src/util/type_reference_generator.dart b/packages/tonik_generate/lib/src/util/type_reference_generator.dart index 63d6e85..4af221a 100644 --- a/packages/tonik_generate/lib/src/util/type_reference_generator.dart +++ b/packages/tonik_generate/lib/src/util/type_reference_generator.dart @@ -82,6 +82,13 @@ TypeReference typeReference( ..url = 'package:big_decimal/big_decimal.dart' ..isNullable = isNullableOverride, ), + UriModel _ => TypeReference( + (b) => + b + ..symbol = 'Uri' + ..url = 'dart:core' + ..isNullable = isNullableOverride, + ), final CompositeModel m => TypeReference( (b) => b diff --git a/packages/tonik_generate/pubspec.yaml b/packages/tonik_generate/pubspec.yaml index ef81ba1..d7c644a 100644 --- a/packages/tonik_generate/pubspec.yaml +++ b/packages/tonik_generate/pubspec.yaml @@ -15,7 +15,6 @@ dependencies: logging: ^1.3.0 meta: ^1.16.0 path: ^1.9.1 - spell_out_numbers: ^1.0.0 tonik_core: ^0.0.6 dev_dependencies: diff --git a/packages/tonik_generate/test/src/model/class_generator_test.dart b/packages/tonik_generate/test/src/model/class_generator_test.dart index 10c9360..7c73a17 100644 --- a/packages/tonik_generate/test/src/model/class_generator_test.dart +++ b/packages/tonik_generate/test/src/model/class_generator_test.dart @@ -372,5 +372,135 @@ void main() { expect(field.annotations, isEmpty); }); }); + + test( + 'generates constructor with required fields before non-required fields', + () { + final model = ClassModel( + name: 'User', + properties: [ + Property( + name: 'id', + model: IntegerModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + Property( + name: 'name', + model: StringModel(context: context), + isRequired: false, + isNullable: true, + isDeprecated: false, + ), + ], + context: context, + ); + + final result = generator.generateClass(model); + final constructor = result.constructors.first; + + expect(constructor.optionalParameters, hasLength(2)); + + final idParam = constructor.optionalParameters[0]; + expect(idParam.name, 'id'); + expect(idParam.required, isTrue); + + final nameParam = constructor.optionalParameters[1]; + expect(nameParam.name, 'name'); + expect(nameParam.required, isFalse); + }, + ); + + test('generates field with Uri type for UriModel property', () { + final model = ClassModel( + name: 'Resource', + properties: [ + Property( + name: 'endpoint', + model: UriModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + ], + context: context, + ); + + final result = generator.generateClass(model); + final field = result.fields.first; + + expect(field.name, 'endpoint'); + expect(field.modifier, FieldModifier.final$); + + final typeRef = field.type! as TypeReference; + expect(typeRef.symbol, 'Uri'); + expect(typeRef.url, 'dart:core'); + expect(typeRef.isNullable, isFalse); + }); + + test('generates nullable Uri field for nullable UriModel property', () { + final model = ClassModel( + name: 'Resource', + properties: [ + Property( + name: 'optionalEndpoint', + model: UriModel(context: context), + isRequired: false, + isNullable: true, + isDeprecated: false, + ), + ], + context: context, + ); + + final result = generator.generateClass(model); + final field = result.fields.first; + + expect(field.name, 'optionalEndpoint'); + + final typeRef = field.type! as TypeReference; + expect(typeRef.symbol, 'Uri'); + expect(typeRef.url, 'dart:core'); + expect(typeRef.isNullable, isTrue); + }); + + test('generates constructor parameter for Uri property', () { + final model = ClassModel( + name: 'Resource', + properties: [ + Property( + name: 'endpoint', + model: UriModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + Property( + name: 'callback', + model: UriModel(context: context), + isRequired: false, + isNullable: true, + isDeprecated: false, + ), + ], + context: context, + ); + + final result = generator.generateClass(model); + final constructor = result.constructors.first; + + expect(constructor.optionalParameters, hasLength(2)); + + final endpointParam = constructor.optionalParameters[0]; + expect(endpointParam.name, 'endpoint'); + expect(endpointParam.required, isTrue); + expect(endpointParam.toThis, isTrue); + + final callbackParam = constructor.optionalParameters[1]; + expect(callbackParam.name, 'callback'); + expect(callbackParam.required, isFalse); + expect(callbackParam.toThis, isTrue); + }); }); } diff --git a/packages/tonik_generate/test/src/model/class_json_generator_test.dart b/packages/tonik_generate/test/src/model/class_json_generator_test.dart index 7fb5505..a8ba3bc 100644 --- a/packages/tonik_generate/test/src/model/class_json_generator_test.dart +++ b/packages/tonik_generate/test/src/model/class_json_generator_test.dart @@ -258,7 +258,7 @@ void main() { const expectedMethod = ''' Object? toJson() => { r'name': name, - r'createdAt': createdAt.toIso8601String(), + r'createdAt': createdAt.toTimeZonedIso8601String(), r'status': status.toJson(), r'homeAddress': homeAddress?.toJson(), }; @@ -323,7 +323,9 @@ void main() { const expectedMethod = ''' Object? toJson() => { r'tags': tags, - r'meetingTimes': meetingTimes.map((e) => e.toIso8601String()).toList(), + r'meetingTimes': meetingTimes + .map((e) => e.toTimeZonedIso8601String()) + .toList(), r'addresses': addresses?.map((e) => e.toJson()).toList(), }; '''; @@ -408,6 +410,176 @@ void main() { contains(collapseWhitespace(expectedMethod)), ); }); + + test('generates toJson method with polymorphic model types', () { + final baseModel = ClassModel( + name: 'Base', + properties: const [], + context: context, + ); + final mixinModel = ClassModel( + name: 'Mixin', + properties: const [], + context: context, + ); + + final allOfModel = AllOfModel( + name: 'Combined', + models: {baseModel, mixinModel}, + context: context, + ); + + final catModel = ClassModel( + name: 'Cat', + properties: const [], + context: context, + ); + final dogModel = ClassModel( + name: 'Dog', + properties: const [], + context: context, + ); + + final oneOfModel = OneOfModel( + name: 'Pet', + models: { + (discriminatorValue: 'cat', model: catModel), + (discriminatorValue: 'dog', model: dogModel), + }, + discriminator: 'petType', + context: context, + ); + + final model = ClassModel( + name: 'Container', + properties: [ + Property( + name: 'combinedData', + model: allOfModel, + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + Property( + name: 'pet', + model: oneOfModel, + isRequired: false, + isNullable: true, + isDeprecated: false, + ), + ], + context: context, + ); + + const expectedMethod = ''' + Object? toJson() => { + r'combinedData': combinedData.toJson(), + r'pet': pet?.toJson(), + }; + '''; + + final generatedClass = generator.generateClass(model); + expect( + collapseWhitespace(format(generatedClass.accept(emitter).toString())), + contains(collapseWhitespace(expectedMethod)), + ); + }); + + test('generates toJson method for Uri property', () { + final model = ClassModel( + name: 'Resource', + properties: [ + Property( + name: 'endpoint', + model: UriModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + ], + context: context, + ); + + const expectedMethod = ''' + Object? toJson() => {r'endpoint': endpoint.toString()}; + '''; + + final generatedClass = generator.generateClass(model); + expect( + collapseWhitespace(format(generatedClass.accept(emitter).toString())), + contains(collapseWhitespace(expectedMethod)), + ); + }); + + test('generates toJson method for nullable Uri property', () { + final model = ClassModel( + name: 'Resource', + properties: [ + Property( + name: 'callback', + model: UriModel(context: context), + isRequired: false, + isNullable: true, + isDeprecated: false, + ), + ], + context: context, + ); + + const expectedMethod = ''' + Object? toJson() => {r'callback': callback?.toString()}; + '''; + + final generatedClass = generator.generateClass(model); + expect( + collapseWhitespace(format(generatedClass.accept(emitter).toString())), + contains(collapseWhitespace(expectedMethod)), + ); + }); + + test('generates toJson method for multiple Uri properties', () { + final model = ClassModel( + name: 'Resource', + properties: [ + Property( + name: 'endpoint', + model: UriModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + Property( + name: 'callback', + model: UriModel(context: context), + isRequired: false, + isNullable: true, + isDeprecated: false, + ), + Property( + name: 'webhook', + model: UriModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + ], + context: context, + ); + + const expectedMethod = ''' + Object? toJson() => { + r'endpoint': endpoint.toString(), + r'callback': callback?.toString(), + r'webhook': webhook.toString(), + }; + '''; + + final generatedClass = generator.generateClass(model); + expect( + collapseWhitespace(format(generatedClass.accept(emitter).toString())), + contains(collapseWhitespace(expectedMethod)), + ); + }); }); group('ClassGenerator fromJson generation', () { diff --git a/packages/tonik_generate/test/src/model/class_simple_generator_test.dart b/packages/tonik_generate/test/src/model/class_simple_generator_test.dart index 8189a6a..06bdab5 100644 --- a/packages/tonik_generate/test/src/model/class_simple_generator_test.dart +++ b/packages/tonik_generate/test/src/model/class_simple_generator_test.dart @@ -473,5 +473,177 @@ void main() { ); expect(hasFromSimple, isFalse); }); + + test('fromSimple handles unsupported complex properties', () { + final complexModel = ClassModel( + name: 'Address', + properties: const [], + context: context, + ); + final model = ClassModel( + name: 'User', + properties: [ + Property( + name: 'id', + model: IntegerModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + Property( + name: 'address', + model: complexModel, + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + ], + context: context, + ); + + final generatedClass = generator.generateClass(model); + final constructors = generatedClass.constructors; + + // Should not have fromSimple constructor due to complex property + final fromSimpleConstructor = + constructors.where((c) => c.name == 'fromSimple').firstOrNull; + expect(fromSimpleConstructor, isNull); + }); + + test('generates fromSimple for Uri property', () { + final model = ClassModel( + name: 'Resource', + properties: [ + Property( + name: 'endpoint', + model: UriModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + ], + context: context, + ); + + final generatedClass = generator.generateClass(model); + final classCode = format(generatedClass.accept(emitter).toString()); + + const expectedMethod = r''' + factory Resource.fromSimple(String? value) { + final properties = value.decodeSimpleStringList(context: r'Resource'); + if (properties.length < 1) { + throw SimpleDecodingException('Invalid value for Resource: $value'); + } + return Resource( + endpoint: properties[0].decodeSimpleUri(context: r'Resource.endpoint'), + ); + } + '''; + + expect( + collapseWhitespace(classCode), + contains(collapseWhitespace(expectedMethod)), + ); + }); + + test('generates fromSimple for nullable Uri property', () { + final model = ClassModel( + name: 'Resource', + properties: [ + Property( + name: 'callback', + model: UriModel(context: context), + isRequired: false, + isNullable: true, + isDeprecated: false, + ), + ], + context: context, + ); + + final generatedClass = generator.generateClass(model); + final classCode = format(generatedClass.accept(emitter).toString()); + + const expectedMethod = r''' + factory Resource.fromSimple(String? value) { + final properties = value.decodeSimpleStringList(context: r'Resource'); + if (properties.length < 1) { + throw SimpleDecodingException('Invalid value for Resource: $value'); + } + return Resource( + callback: properties[0].decodeSimpleNullableUri( + context: r'Resource.callback', + ), + ); + } + '''; + + expect( + collapseWhitespace(classCode), + contains(collapseWhitespace(expectedMethod)), + ); + }); + + test('generates fromSimple for mixed Uri and primitive properties', () { + final model = ClassModel( + name: 'Resource', + properties: [ + Property( + name: 'name', + model: StringModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + Property( + name: 'endpoint', + model: UriModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + Property( + name: 'port', + model: IntegerModel(context: context), + isRequired: true, + isNullable: false, + isDeprecated: false, + ), + Property( + name: 'callback', + model: UriModel(context: context), + isRequired: false, + isNullable: true, + isDeprecated: false, + ), + ], + context: context, + ); + + final generatedClass = generator.generateClass(model); + final classCode = format(generatedClass.accept(emitter).toString()); + + const expectedMethod = r''' + factory Resource.fromSimple(String? value) { + final properties = value.decodeSimpleStringList(context: r'Resource'); + if (properties.length < 4) { + throw SimpleDecodingException('Invalid value for Resource: $value'); + } + return Resource( + name: properties[0].decodeSimpleString(context: r'Resource.name'), + endpoint: properties[1].decodeSimpleUri(context: r'Resource.endpoint'), + port: properties[2].decodeSimpleInt(context: r'Resource.port'), + callback: properties[3].decodeSimpleNullableUri( + context: r'Resource.callback', + ), + ); + } + '''; + + expect( + collapseWhitespace(classCode), + contains(collapseWhitespace(expectedMethod)), + ); + }); }); } diff --git a/packages/tonik_generate/test/src/model/typedef_generator_test.dart b/packages/tonik_generate/test/src/model/typedef_generator_test.dart index 4d9d270..6c2d7a3 100644 --- a/packages/tonik_generate/test/src/model/typedef_generator_test.dart +++ b/packages/tonik_generate/test/src/model/typedef_generator_test.dart @@ -115,6 +115,7 @@ void main() { (model: DateTimeModel(context: context), expectedType: 'DateTime'), (model: DateModel(context: context), expectedType: 'Date'), (model: DecimalModel(context: context), expectedType: 'BigDecimal'), + (model: UriModel(context: context), expectedType: 'Uri'), ]; for (final (index, type) in primitiveTypes.indexed) { @@ -132,6 +133,83 @@ void main() { } }); + group('Uri typedef generation', () { + test('generates typedef for Uri type', () { + final model = AliasModel( + name: 'ApiEndpoint', + model: UriModel(context: context), + context: context, + ); + + final result = generator.generateAlias(model); + final typedef = generator.generateAliasTypedef(model); + + expect(result.filename, 'api_endpoint.dart'); + expect( + typedef.accept(emitter).toString().trim(), + 'typedef ApiEndpoint = Uri;', + ); + }); + + test('generates typedef for required Uri type', () { + final model = AliasModel( + name: 'RequiredEndpoint', + model: UriModel(context: context), + context: context, + ); + + final typedef = generator.generateAliasTypedef(model); + + expect( + typedef.accept(emitter).toString().trim(), + 'typedef RequiredEndpoint = Uri;', + ); + }); + + test('generates typedef for list of URIs', () { + final model = AliasModel( + name: 'EndpointList', + model: ListModel( + content: UriModel(context: context), + context: context, + ), + context: context, + ); + + final result = generator.generateAlias(model); + final typedef = generator.generateAliasTypedef(model); + + expect(result.filename, 'endpoint_list.dart'); + expect( + typedef.accept(emitter).toString().trim(), + 'typedef EndpointList = List;', + ); + }); + + test('generates typedef for nested list of URIs', () { + final model = AliasModel( + name: 'EndpointMatrix', + model: ListModel( + content: ListModel( + content: UriModel(context: context), + context: context, + ), + context: context, + ), + context: context, + ); + + final result = generator.generateAlias(model); + final typedef = generator.generateAliasTypedef(model); + + expect(result.filename, 'endpoint_matrix.dart'); + expect( + typedef.accept(emitter).toString().trim(), + 'typedef EndpointMatrix = List>;', + ); + }); + }); + group('generateFromList', () { test('generates typedef for list of primitive types', () { final model = ListModel( @@ -208,6 +286,23 @@ void main() { 'typedef Anonymous = List;', ); }); + + test('generates typedef for list of URIs', () { + final model = ListModel( + name: 'UriList', + content: UriModel(context: context), + context: context, + ); + + final result = generator.generateList(model); + final typedef = generator.generateListTypedef(model); + + expect(result.filename, 'uri_list.dart'); + expect( + typedef.accept(emitter).toString().trim(), + 'typedef UriList = List;', + ); + }); }); }); } diff --git a/packages/tonik_generate/test/src/naming/name_generator_test.dart b/packages/tonik_generate/test/src/naming/name_generator_test.dart index 8e2e473..8c35cff 100644 --- a/packages/tonik_generate/test/src/naming/name_generator_test.dart +++ b/packages/tonik_generate/test/src/naming/name_generator_test.dart @@ -1075,11 +1075,11 @@ void main() { final result = generator.generateServerNames(servers); expect(result.serverMap.length, 3); - expect(result.serverMap[servers[0]], 'ApiServer2'); + expect(result.serverMap[servers[0]], 'ApiServer'); expect(result.serverMap[servers[1]], 'StagingServer'); expect(result.serverMap[servers[2]], 'DevServer'); expect(result.customName, 'CustomServer'); - expect(result.baseName, 'ApiServer'); + expect(result.baseName, 'Server'); }); test('generates names based on multi-level subdomain differences', () { @@ -1100,7 +1100,7 @@ void main() { expect(result.serverMap[servers[1]], 'ApiStagingServer'); expect(result.serverMap[servers[2]], 'ApiProdServer'); expect(result.customName, 'CustomServer'); - expect(result.baseName, 'ApiServer'); + expect(result.baseName, 'Server'); }); test( @@ -1120,7 +1120,7 @@ void main() { expect(result.serverMap[servers[1]], 'AcmeServer'); expect(result.serverMap[servers[2]], 'TestServer'); expect(result.customName, 'CustomServer'); - expect(result.baseName, 'ApiServer'); + expect(result.baseName, 'Server'); }, ); @@ -1140,7 +1140,7 @@ void main() { expect(result.serverMap[servers[1]], 'V2Server'); expect(result.serverMap[servers[2]], 'BetaServer'); expect(result.customName, 'CustomServer'); - expect(result.baseName, 'ApiServer'); + expect(result.baseName, 'Server'); }); test( @@ -1156,11 +1156,11 @@ void main() { final result = generator.generateServerNames(servers); expect(result.serverMap.length, 3); - expect(result.serverMap[servers[0]], 'Server'); - expect(result.serverMap[servers[1]], 'Server2'); - expect(result.serverMap[servers[2]], 'Server3'); + expect(result.serverMap[servers[0]], 'Server2'); + expect(result.serverMap[servers[1]], 'Server3'); + expect(result.serverMap[servers[2]], 'Server4'); expect(result.customName, 'CustomServer'); - expect(result.baseName, 'ApiServer'); + expect(result.baseName, 'Server'); }, ); @@ -1180,7 +1180,7 @@ void main() { expect(result.serverMap.length, 1); expect(result.serverMap[servers[0]], 'CustomServer'); expect(result.customName, r'CustomServer$'); - expect(result.baseName, 'ApiServer'); + expect(result.baseName, 'Server'); }, ); @@ -1199,12 +1199,12 @@ void main() { final result = generator.generateServerNames(servers); expect(result.serverMap.length, 4); - expect(result.serverMap[servers[0]], 'Server'); - expect(result.serverMap[servers[1]], 'Server2'); - expect(result.serverMap[servers[2]], 'Server3'); - expect(result.serverMap[servers[3]], 'Server4'); + expect(result.serverMap[servers[0]], 'Server2'); + expect(result.serverMap[servers[1]], 'Server3'); + expect(result.serverMap[servers[2]], 'Server4'); + expect(result.serverMap[servers[3]], 'Server5'); expect(result.customName, 'CustomServer'); - expect(result.baseName, 'ApiServer'); + expect(result.baseName, 'Server'); }); }); }); diff --git a/packages/tonik_generate/test/src/naming/name_utils_test.dart b/packages/tonik_generate/test/src/naming/name_utils_test.dart new file mode 100644 index 0000000..1109322 --- /dev/null +++ b/packages/tonik_generate/test/src/naming/name_utils_test.dart @@ -0,0 +1,154 @@ +import 'package:test/test.dart'; +import 'package:tonik_generate/src/naming/name_utils.dart'; + +void main() { + group('normalizeEnumValueName', () { + group('number conversion', () { + test('converts single digits to words', () { + expect(normalizeEnumValueName('0'), 'zero'); + expect(normalizeEnumValueName('1'), 'one'); + expect(normalizeEnumValueName('2'), 'two'); + expect(normalizeEnumValueName('3'), 'three'); + expect(normalizeEnumValueName('9'), 'nine'); + }); + + test('converts teen numbers to words', () { + expect(normalizeEnumValueName('10'), 'ten'); + expect(normalizeEnumValueName('11'), 'eleven'); + expect(normalizeEnumValueName('15'), 'fifteen'); + expect(normalizeEnumValueName('19'), 'nineteen'); + }); + + test('converts larger numbers to exact expected output', () { + expect(normalizeEnumValueName('42'), 'fortyTwo'); + expect(normalizeEnumValueName('100'), 'oneHundred'); + expect(normalizeEnumValueName('123'), 'oneHundredTwentyThree'); + expect(normalizeEnumValueName('1000'), 'oneThousand'); + }); + + test('handles negative numbers with exact output', () { + expect(normalizeEnumValueName('-1'), 'minusOne'); + expect(normalizeEnumValueName('-42'), 'minusFortyTwo'); + expect(normalizeEnumValueName('-100'), 'minusOneHundred'); + expect(normalizeEnumValueName('-999'), 'minusNineHundredNinetyNine'); + }); + + test('converts millions to exact expected output', () { + expect(normalizeEnumValueName('1000000'), 'oneMillion'); + expect(normalizeEnumValueName('2000000'), 'twoMillion'); + expect(normalizeEnumValueName('5000000'), 'fiveMillion'); + expect(normalizeEnumValueName('1500000'), + 'oneMillionFiveHundredThousand'); + }); + + test('converts billions to exact expected output', () { + expect(normalizeEnumValueName('1000000000'), 'oneBillion'); + expect(normalizeEnumValueName('3000000000'), 'threeBillion'); + expect(normalizeEnumValueName('7000000000'), 'sevenBillion'); + expect(normalizeEnumValueName('1500000000'), + 'oneBillionFiveHundredMillion'); + }); + + test('converts trillions to exact expected output', () { + expect(normalizeEnumValueName('1000000000000'), 'oneTrillion'); + expect(normalizeEnumValueName('5000000000000'), 'fiveTrillion'); + expect(normalizeEnumValueName('9000000000000'), 'nineTrillion'); + expect(normalizeEnumValueName('1500000000000'), + 'oneTrillionFiveHundredBillion'); + }); + + test('handles complex large numbers', () { + expect(normalizeEnumValueName('1234567890'), + 'oneBillionTwoHundredThirtyFourMillion' + 'FiveHundredSixtySevenThousandEightHundredNinety'); + expect(normalizeEnumValueName('999999999999'), + 'nineHundredNinetyNineBillionNineHundredNinetyNineMillion' + 'NineHundredNinetyNineThousandNineHundredNinetyNine'); + }); + + test('produces camelCase identifiers', () { + // Based on existing test expectations + expect(normalizeEnumValueName('1'), 'one'); + expect(normalizeEnumValueName('2'), 'two'); + expect(normalizeEnumValueName('3'), 'three'); + }); + }); + + group('string normalization', () { + test('normalizes simple strings', () { + expect(normalizeEnumValueName('active'), 'active'); + expect(normalizeEnumValueName('inactive'), 'inactive'); + expect(normalizeEnumValueName('pending'), 'pending'); + }); + + test('handles case conversion properly', () { + expect(normalizeEnumValueName('ACTIVE'), 'active'); // Clean lowercase + expect(normalizeEnumValueName('InActive'), 'inActive'); + expect(normalizeEnumValueName('PENDING'), 'pending'); + }); + + test('handles strings with separators', () { + expect(normalizeEnumValueName('in-progress'), 'inProgress'); + expect(normalizeEnumValueName('not_started'), 'notStarted'); + expect(normalizeEnumValueName('on hold'), 'onHold'); + }); + + test('handles mixed alphanumeric strings', () { + expect(normalizeEnumValueName('status1'), 'status1'); + expect( + normalizeEnumValueName('1status'), + 'status1', + ); // Number moved to end + expect(normalizeEnumValueName('v2_final'), 'v2Final'); + }); + + test('comprehensive real-world enum value cases', () { + // Common API status codes and enum patterns + expect(normalizeEnumValueName('SUCCESS_CODE'), 'successCode'); + expect(normalizeEnumValueName('ERROR_404'), 'error404'); + expect(normalizeEnumValueName('HTTP_STATUS'), 'httpStatus'); + expect(normalizeEnumValueName('NOT_FOUND'), 'notFound'); + expect(normalizeEnumValueName('API_VERSION_2'), 'apiVersion2'); + expect(normalizeEnumValueName('USER-ACCOUNT'), 'userAccount'); + expect(normalizeEnumValueName('data_model'), 'dataModel'); + expect(normalizeEnumValueName('ADMIN'), 'admin'); + expect(normalizeEnumValueName('guest'), 'guest'); + expect(normalizeEnumValueName('999'), 'nineHundredNinetyNine'); + expect(normalizeEnumValueName('2024'), 'twoThousandTwentyFour'); + }); + }); + + group('edge cases', () { + test('handles empty and invalid inputs', () { + expect(normalizeEnumValueName(''), 'value'); + expect(normalizeEnumValueName('_'), 'value'); + expect(normalizeEnumValueName('__'), 'value'); + }); + + test('handles special characters', () { + expect(normalizeEnumValueName('!@#'), 'value'); + expect(normalizeEnumValueName('status!'), 'status'); + expect(normalizeEnumValueName('test@#123'), 'test123'); + }); + + test('handles leading underscores', () { + expect(normalizeEnumValueName('_active'), 'active'); + expect(normalizeEnumValueName('__pending'), 'pending'); + }); + test('matches expected enum generation behavior', () { + // These are the expectations from the existing enum generator tests + expect(normalizeEnumValueName('1'), 'one'); + expect(normalizeEnumValueName('2'), 'two'); + expect(normalizeEnumValueName('3'), 'three'); + }); + + test('produces clean, readable identifiers', () { + // Common enum value patterns should be clean and readable + expect(normalizeEnumValueName('SUCCESS'), 'success'); + expect(normalizeEnumValueName('ERROR'), 'error'); + expect(normalizeEnumValueName('PENDING'), 'pending'); + expect(normalizeEnumValueName('IN_PROGRESS'), 'inProgress'); + }); + }); + }); +} diff --git a/packages/tonik_generate/test/src/operation/options_generator_test.dart b/packages/tonik_generate/test/src/operation/options_generator_test.dart index dd7b4cf..255633c 100644 --- a/packages/tonik_generate/test/src/operation/options_generator_test.dart +++ b/packages/tonik_generate/test/src/operation/options_generator_test.dart @@ -367,7 +367,7 @@ void main() { allowEmpty: false, ); headers[r'X-Required-Date'] = headerEncoder.encode( - xRequiredDate.toIso8601String(), + xRequiredDate.toTimeZonedIso8601String(), explode: false, allowEmpty: true, ); diff --git a/packages/tonik_generate/test/src/response/response_class_generator_test.dart b/packages/tonik_generate/test/src/response/response_class_generator_test.dart index ae48ec6..f5438a5 100644 --- a/packages/tonik_generate/test/src/response/response_class_generator_test.dart +++ b/packages/tonik_generate/test/src/response/response_class_generator_test.dart @@ -80,21 +80,21 @@ void main() { // Required header field final xTestField = fields[0]; expect(xTestField.name, 'xTest'); - expect(xTestField.modifier, equals(FieldModifier.final$)); + expect(xTestField.modifier, FieldModifier.final$); expect(xTestField.type?.accept(emitter).toString(), 'String'); expect(xTestField.annotations.isEmpty, isTrue); // Optional header field final xOptionalField = fields[2]; expect(xOptionalField.name, 'xOptional'); - expect(xOptionalField.modifier, equals(FieldModifier.final$)); + expect(xOptionalField.modifier, FieldModifier.final$); expect(xOptionalField.type?.accept(emitter).toString(), 'int?'); expect(xOptionalField.annotations.isEmpty, isTrue); // Body field final bodyField = fields[1]; expect(bodyField.name, 'body'); - expect(bodyField.modifier, equals(FieldModifier.final$)); + expect(bodyField.modifier, FieldModifier.final$); expect(bodyField.type?.accept(emitter).toString(), 'String'); expect(bodyField.annotations.isEmpty, isTrue); @@ -158,14 +158,14 @@ void main() { final headerField = fields[0]; expect(headerField.name, 'bodyHeader'); expect(headerField.type?.accept(emitter).toString(), 'String'); - expect(headerField.modifier, equals(FieldModifier.final$)); + expect(headerField.modifier, FieldModifier.final$); expect(headerField.annotations.isEmpty, isTrue); // Body field should keep original name final bodyField = fields[1]; expect(bodyField.name, 'body'); expect(bodyField.type?.accept(emitter).toString(), 'int'); - expect(bodyField.modifier, equals(FieldModifier.final$)); + expect(bodyField.modifier, FieldModifier.final$); expect(bodyField.annotations.isEmpty, isTrue); // Verify constructor parameters maintain the same names diff --git a/packages/tonik_generate/test/src/response/response_generator_test.dart b/packages/tonik_generate/test/src/response/response_generator_test.dart index c1920a8..1c99560 100644 --- a/packages/tonik_generate/test/src/response/response_generator_test.dart +++ b/packages/tonik_generate/test/src/response/response_generator_test.dart @@ -84,7 +84,7 @@ void main() { ); final result = generator.generate(aliasResponse); - expect(result.filename, equals('alias_response.dart')); + expect(result.filename, 'alias_response.dart'); expect(result.code, contains('typedef AliasResponse =')); }); @@ -115,7 +115,7 @@ void main() { ); final result = generator.generate(response); - expect(result.filename, equals('single_body_response.dart')); + expect(result.filename, 'single_body_response.dart'); expect(result.code, contains('class SingleBodyResponse')); }); diff --git a/packages/tonik_generate/test/src/server/server_file_generator_test.dart b/packages/tonik_generate/test/src/server/server_file_generator_test.dart index 580bd63..3015cab 100644 --- a/packages/tonik_generate/test/src/server/server_file_generator_test.dart +++ b/packages/tonik_generate/test/src/server/server_file_generator_test.dart @@ -109,10 +109,10 @@ void main() { final fileContent = File(generatedFile.path).readAsStringSync(); // Check file name - expect(actualFileName, equals('api_server.dart')); + expect(actualFileName, 'server.dart'); // Check file content - expect(fileContent, contains('sealed class ApiServer')); + expect(fileContent, contains('sealed class Server')); expect(fileContent, contains('class ProductionServer')); expect(fileContent, contains('class StagingServer')); expect(fileContent, contains('class CustomServer')); @@ -154,7 +154,7 @@ void main() { final fileContent = File(generatedFile.path).readAsStringSync(); // Expect base class and custom class to be generated - expect(fileContent, contains('sealed class ApiServer')); + expect(fileContent, contains('sealed class Server')); expect(fileContent, contains('class CustomServer')); // No server-specific classes should be present diff --git a/packages/tonik_generate/test/src/server/server_generator_test.dart b/packages/tonik_generate/test/src/server/server_generator_test.dart index 8f32742..97e912a 100644 --- a/packages/tonik_generate/test/src/server/server_generator_test.dart +++ b/packages/tonik_generate/test/src/server/server_generator_test.dart @@ -110,7 +110,7 @@ void main() { final productionClass = generatedClasses[1]; expect(productionClass.name, 'ProductionServer'); - expect(productionClass.extend?.accept(emitter).toString(), 'ApiServer'); + expect(productionClass.extend?.accept(emitter).toString(), 'Server'); expect( productionClass.docs.first, '/// Production server - https://production.example.com', @@ -144,7 +144,7 @@ void main() { final stagingClass = generatedClasses[2]; expect(stagingClass.name, 'StagingServer'); - expect(stagingClass.extend?.accept(emitter).toString(), 'ApiServer'); + expect(stagingClass.extend?.accept(emitter).toString(), 'Server'); expect( stagingClass.docs.first, '/// Staging server - https://staging.example.com', @@ -180,7 +180,7 @@ void main() { final customClass = generatedClasses.last; expect(customClass.name, 'CustomServer'); - expect(customClass.extend?.accept(emitter).toString(), 'ApiServer'); + expect(customClass.extend?.accept(emitter).toString(), 'Server'); expect( customClass.docs.first, '/// Custom server with user-defined base URL', diff --git a/packages/tonik_generate/test/src/util/to_json_value_expression_generator_test.dart b/packages/tonik_generate/test/src/util/to_json_value_expression_generator_test.dart index f54b21a..2c7e9e9 100644 --- a/packages/tonik_generate/test/src/util/to_json_value_expression_generator_test.dart +++ b/packages/tonik_generate/test/src/util/to_json_value_expression_generator_test.dart @@ -53,7 +53,7 @@ void main() { ); expect( buildToJsonPropertyExpression('startTime', property), - 'startTime.toIso8601String()', + 'startTime.toTimeZonedIso8601String()', ); }); @@ -188,7 +188,7 @@ void main() { ); expect( buildToJsonPropertyExpression('meetingTimes', property), - 'meetingTimes.map((e) => e.toIso8601String()).toList()', + 'meetingTimes.map((e) => e.toTimeZonedIso8601String()).toList()', ); }); @@ -259,7 +259,7 @@ void main() { ); expect( buildToJsonPropertyExpression('createdAt', property), - 'createdAt.toIso8601String()', + 'createdAt.toTimeZonedIso8601String()', ); }); @@ -278,7 +278,7 @@ void main() { ); expect( buildToJsonPropertyExpression('updatedAt', property), - 'updatedAt?.toIso8601String()', + 'updatedAt?.toTimeZonedIso8601String()', ); }); @@ -497,7 +497,7 @@ void main() { ); expect( buildToJsonPropertyExpression('meetingTimes', property), - 'meetingTimes.map((e) => e.toIso8601String()).toList()', + 'meetingTimes.map((e) => e.toTimeZonedIso8601String()).toList()', ); }); @@ -519,7 +519,7 @@ void main() { ); expect( buildToJsonPropertyExpression('meetingTimes', property), - 'meetingTimes?.map((e) => e.toIso8601String()).toList()', + 'meetingTimes?.map((e) => e.toTimeZonedIso8601String()).toList()', ); }); @@ -626,7 +626,7 @@ void main() { ); expect( buildToJsonPathParameterExpression('startTime', parameter), - 'startTime.toIso8601String()', + 'startTime.toTimeZonedIso8601String()', ); }); @@ -713,7 +713,7 @@ void main() { ); expect( buildToJsonQueryParameterExpression('startTime', parameter), - 'startTime.toIso8601String()', + 'startTime.toTimeZonedIso8601String()', ); }); @@ -779,7 +779,7 @@ void main() { ); expect( buildToJsonHeaderParameterExpression('timestamp', parameter), - 'timestamp.toIso8601String()', + 'timestamp.toTimeZonedIso8601String()', ); }); diff --git a/packages/tonik_parse/lib/src/model_importer.dart b/packages/tonik_parse/lib/src/model_importer.dart index 12bb191..e79bc6b 100644 --- a/packages/tonik_parse/lib/src/model_importer.dart +++ b/packages/tonik_parse/lib/src/model_importer.dart @@ -119,8 +119,16 @@ class ModelImporter { context: context, ), 'string' when schema.format == 'date' => DateModel(context: context), - 'string' when schema.format == 'decimal' || schema.format == 'currency' => + 'string' + when [ + 'decimal', + 'currency', + 'money', + 'number', + ].contains(schema.format) => DecimalModel(context: context), + 'string' when schema.format == 'uri' || schema.format == 'url' => + UriModel(context: context), 'string' when schema.enumerated != null => _parseEnum( name, schema.enumerated!, diff --git a/packages/tonik_parse/test/model/model_alias_test.dart b/packages/tonik_parse/test/model/model_alias_test.dart index cf390d3..dfe2fd8 100644 --- a/packages/tonik_parse/test/model/model_alias_test.dart +++ b/packages/tonik_parse/test/model/model_alias_test.dart @@ -85,7 +85,7 @@ void main() { context: context, ); - expect(alias.resolved, equals(stringModel)); + expect(alias.resolved, stringModel); }); test('resolves single-level alias', () { @@ -102,7 +102,7 @@ void main() { context: context, ); - expect(outerAlias.resolved, equals(stringModel)); + expect(outerAlias.resolved, stringModel); }); test('resolves multi-level alias', () { @@ -124,7 +124,7 @@ void main() { context: context, ); - expect(level1.resolved, equals(stringModel)); + expect(level1.resolved, stringModel); }); }); } diff --git a/packages/tonik_parse/test/model/model_multiple_types_test.dart b/packages/tonik_parse/test/model/model_multiple_types_test.dart index d2f9dca..9ce32d2 100644 --- a/packages/tonik_parse/test/model/model_multiple_types_test.dart +++ b/packages/tonik_parse/test/model/model_multiple_types_test.dart @@ -66,7 +66,7 @@ void main() { expect(stringOrNumber.isNullable, isFalse); final oneOf = stringOrNumber.model as OneOfModel; - expect(oneOf.models.length, equals(2)); + expect(oneOf.models.length, 2); expect( oneOf.models.map((m) => m.model).toList(), containsAll([isA(), isA()]), @@ -109,7 +109,7 @@ void main() { expect(nullableMultiType.isNullable, isTrue); final oneOf = nullableMultiType.model as OneOfModel; - expect(oneOf.models.length, equals(2)); + expect(oneOf.models.length, 2); expect( oneOf.models.map((m) => m.model).toList(), containsAll([isA(), isA()]), @@ -128,7 +128,7 @@ void main() { expect(nullableMultiType.isNullable, isTrue); final oneOf = nullableMultiType.model as OneOfModel; - expect(oneOf.models.length, equals(2)); + expect(oneOf.models.length, 2); expect( oneOf.models.map((m) => m.model).toList(), containsAll([isA(), isA()]), @@ -150,7 +150,7 @@ void main() { expect(listModel.content, isA()); final content = listModel.content as OneOfModel; - expect(content.models.length, equals(2)); + expect(content.models.length, 2); expect( content.models.map((m) => m.model).toList(), containsAll([isA(), isA()]), diff --git a/packages/tonik_parse/test/model/model_property_test.dart b/packages/tonik_parse/test/model/model_property_test.dart index ae141b0..8155e53 100644 --- a/packages/tonik_parse/test/model/model_property_test.dart +++ b/packages/tonik_parse/test/model/model_property_test.dart @@ -20,10 +20,14 @@ void main() { 'float': {'type': 'number', 'format': 'float'}, 'double': {'type': 'number', 'format': 'double'}, 'decimal': {'type': 'string', 'format': 'decimal'}, - 'decimal-alt': {'type': 'string', 'format': 'currency'}, + 'currency': {'type': 'string', 'format': 'currency'}, + 'money': {'type': 'string', 'format': 'money'}, + 'numberString': {'type': 'string', 'format': 'number'}, 'boolean': {'type': 'boolean'}, 'date': {'type': 'string', 'format': 'date'}, 'dateTime': {'type': 'string', 'format': 'date-time'}, + 'uri': {'type': 'string', 'format': 'uri'}, + 'url': {'type': 'string', 'format': 'url'}, }, }, }, @@ -88,17 +92,38 @@ void main() { expect(double.model, isA()); }); - test('imports decimal', () { + test('imports decimal format', () { final api = Importer().import(fileContent); final model = api.models.first as ClassModel; final decimal = model.properties.firstWhere((p) => p.name == 'decimal'); expect(decimal.model, isA()); + }); + + test('imports currency format', () { + final api = Importer().import(fileContent); + + final model = api.models.first as ClassModel; + final currency = model.properties.firstWhere((p) => p.name == 'currency'); + expect(currency.model, isA()); + }); + + test('imports money format', () { + final api = Importer().import(fileContent); + + final model = api.models.first as ClassModel; + final money = model.properties.firstWhere((p) => p.name == 'money'); + expect(money.model, isA()); + }); + + test('imports number string format', () { + final api = Importer().import(fileContent); - final decimalAlt = model.properties.firstWhere( - (p) => p.name == 'decimal-alt', + final model = api.models.first as ClassModel; + final numberString = model.properties.firstWhere( + (p) => p.name == 'numberString', ); - expect(decimalAlt.model, isA()); + expect(numberString.model, isA()); }); test('imports boolean', () { @@ -124,4 +149,20 @@ void main() { final dateTime = model.properties.firstWhere((p) => p.name == 'dateTime'); expect(dateTime.model, isA()); }); + + test('imports uri format', () { + final api = Importer().import(fileContent); + + final model = api.models.first as ClassModel; + final uri = model.properties.firstWhere((p) => p.name == 'uri'); + expect(uri.model, isA()); + }); + + test('imports url format', () { + final api = Importer().import(fileContent); + + final model = api.models.first as ClassModel; + final url = model.properties.firstWhere((p) => p.name == 'url'); + expect(url.model, isA()); + }); } diff --git a/packages/tonik_parse/test/model/single_schema_import_test.dart b/packages/tonik_parse/test/model/single_schema_import_test.dart index 37dcfe6..464fc07 100644 --- a/packages/tonik_parse/test/model/single_schema_import_test.dart +++ b/packages/tonik_parse/test/model/single_schema_import_test.dart @@ -86,6 +86,46 @@ void main() { ), ); + final inlineUri = InlinedObject( + Schema( + type: ['string'], + format: 'uri', + required: [], + enumerated: null, + allOf: null, + anyOf: null, + oneOf: null, + not: null, + items: null, + properties: {}, + description: '', + isNullable: false, + discriminator: null, + isDeprecated: false, + uniqueItems: false, + ), + ); + + final inlineUrl = InlinedObject( + Schema( + type: ['string'], + format: 'url', + required: [], + enumerated: null, + allOf: null, + anyOf: null, + oneOf: null, + not: null, + items: null, + properties: {}, + description: '', + isNullable: false, + discriminator: null, + isDeprecated: false, + uniqueItems: false, + ), + ); + final reference = Reference('#/components/schemas/TestModel'); late ModelImporter importer; @@ -132,4 +172,36 @@ void main() { expect(importer.models, models); }); + + test('returns UriModel for uri format schema', () { + final context = Context.initial().pushAll(['components', 'schemas']); + + final result = importer.importSchema(inlineUri, context); + + expect(result, isA()); + expect(result.context.path, ['components', 'schemas']); + }); + + test('returns UriModel for url format schema', () { + final context = Context.initial().pushAll(['components', 'schemas']); + + final result = importer.importSchema(inlineUrl, context); + + expect(result, isA()); + expect(result.context.path, ['components', 'schemas']); + }); + + test('does not add inline uri schema to models', () { + final context = Context.initial().pushAll(['components', 'schemas']); + + final result = importer.importSchema(inlineUri, context); + expect(importer.models.contains(result), isFalse); + }); + + test('does not add inline url schema to models', () { + final context = Context.initial().pushAll(['components', 'schemas']); + + final result = importer.importSchema(inlineUrl, context); + expect(importer.models.contains(result), isFalse); + }); } diff --git a/packages/tonik_util/CHANGELOG.md b/packages/tonik_util/CHANGELOG.md index 8a18a94..b15a987 100644 --- a/packages/tonik_util/CHANGELOG.md +++ b/packages/tonik_util/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.0.7 + + - **FEAT**: Uri property encoding and decoding. + - **FEAT**: time zone aware date time parsing. + - **FEAT**: time zone aware encoding of date time objects. + ## 0.0.6 - **FIX**: proper handle dates. diff --git a/packages/tonik_util/lib/src/decoding/json_decoder.dart b/packages/tonik_util/lib/src/decoding/json_decoder.dart index 957f61d..29e11cc 100644 --- a/packages/tonik_util/lib/src/decoding/json_decoder.dart +++ b/packages/tonik_util/lib/src/decoding/json_decoder.dart @@ -1,10 +1,11 @@ import 'package:big_decimal/big_decimal.dart'; import 'package:tonik_util/src/date.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? { - /// Decodes a JSON value to a DateTime. + /// Decodes a JSON value to a DateTime with timezone awareness. /// /// Expects ISO 8601 format string. /// Throws [InvalidTypeException] if the value is not a valid date string @@ -25,7 +26,7 @@ extension JsonDecoder on Object? { ); } try { - return DateTime.parse(this! as String); + return OffsetDateTime.parse(this! as String); } on FormatException catch (e) { throw InvalidTypeException( value: this! as String, @@ -383,12 +384,54 @@ extension JsonDecoder on Object? { /// Decodes a JSON value to a nullable Date. /// - /// Returns null if the value is null or an empty string. + /// Returns null if the value is null. /// Throws [InvalidTypeException] if the value is not a valid date string. Date? decodeJsonNullableDate({String? context}) { - if (this == null || (this is String && (this! as String).isEmpty)) { + if (this == null) { return null; } return decodeJsonDate(context: context); } + + /// Decodes a JSON value to a Uri. + /// + /// Expects a valid URI string. + /// Throws [InvalidTypeException] if the value is not a valid URI string + /// or if the value is null. + Uri decodeJsonUri({String? context}) { + if (this == null) { + throw InvalidTypeException( + value: 'null', + targetType: Uri, + context: context, + ); + } + if (this is! String) { + throw InvalidTypeException( + value: toString(), + targetType: Uri, + context: context, + ); + } + try { + return Uri.parse(this! as String); + } on FormatException catch (e) { + throw InvalidTypeException( + value: this! as String, + targetType: Uri, + context: e.message, + ); + } + } + + /// Decodes a JSON value to a nullable Uri. + /// + /// Returns null if the value is null. + /// Throws [InvalidTypeException] if the value is not a valid URI string. + Uri? decodeJsonNullableUri({String? context}) { + if (this == null) { + return null; + } + return decodeJsonUri(context: context); + } } diff --git a/packages/tonik_util/lib/src/decoding/simple_decoder.dart b/packages/tonik_util/lib/src/decoding/simple_decoder.dart index 9f7d9a8..3b61583 100644 --- a/packages/tonik_util/lib/src/decoding/simple_decoder.dart +++ b/packages/tonik_util/lib/src/decoding/simple_decoder.dart @@ -1,6 +1,7 @@ import 'package:big_decimal/big_decimal.dart'; import 'package:tonik_util/src/date.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? { @@ -131,7 +132,7 @@ extension SimpleDecoder on String? { return decodeSimpleBool(context: context); } - /// Decodes a string to a DateTime. + /// Decodes a string to a DateTime with timezone awareness. /// /// Expects ISO 8601 format. /// Throws [InvalidTypeException] if the string is not a valid date @@ -145,7 +146,7 @@ extension SimpleDecoder on String? { ); } try { - return DateTime.parse(this!); + return OffsetDateTime.parse(this!); } on Object { throw InvalidTypeException( value: this!, @@ -299,4 +300,44 @@ extension SimpleDecoder on String? { if (this?.isEmpty ?? true) return null; return decodeSimpleDate(context: context); } + + /// Decodes a string to a Uri. + /// + /// Expects a valid URI string. + /// Throws [InvalidTypeException] if the value is null or if the string + /// is not a valid URI. + Uri decodeSimpleUri({String? context}) { + if (this == null) { + throw InvalidTypeException( + value: 'null', + targetType: Uri, + context: context, + ); + } + if (this!.isEmpty) { + throw InvalidTypeException( + value: 'empty string', + targetType: Uri, + context: context, + ); + } + try { + return Uri.parse(this!); + } on FormatException catch (e) { + throw InvalidTypeException( + value: this!, + targetType: Uri, + context: e.message, + ); + } + } + + /// Decodes a string to a nullable Uri. + /// + /// Returns null if the string is empty or null. + /// Throws [InvalidTypeException] if the string is not a valid URI. + Uri? decodeSimpleNullableUri({String? context}) { + if (this?.isEmpty ?? true) return null; + return decodeSimpleUri(context: context); + } } diff --git a/packages/tonik_util/lib/src/encoding/base_encoder.dart b/packages/tonik_util/lib/src/encoding/base_encoder.dart index 145cefa..49ca516 100644 --- a/packages/tonik_util/lib/src/encoding/base_encoder.dart +++ b/packages/tonik_util/lib/src/encoding/base_encoder.dart @@ -1,5 +1,5 @@ import 'package:meta/meta.dart'; -import 'package:tonik_util/src/encoding/encoding_exception.dart'; +import 'package:tonik_util/tonik_util.dart'; /// Base class for OpenAPI parameter style encoders. /// @@ -17,7 +17,11 @@ abstract class BaseEncoder { /// that don't support object encoding). @protected void checkSupportedType(dynamic value, {bool supportMaps = true}) { - if (value == null || value is String || value is num || value is bool) { + if (value == null || + value is String || + value is num || + value is bool || + value is Uri) { return; } @@ -31,6 +35,7 @@ abstract class BaseEncoder { return element is! String && element is! num && element is! bool && + element is! Uri && element != null; }); @@ -63,7 +68,11 @@ abstract class BaseEncoder { } final hasUnsupportedValue = value.values.any((val) { - return val is! String && val is! num && val is! bool && val != null; + return val is! String && + val is! num && + val is! bool && + val is! Uri && + val != null; }); if (hasUnsupportedValue) { @@ -84,7 +93,11 @@ abstract class BaseEncoder { } if (value is DateTime) { - return value.toIso8601String(); + return value.toTimeZonedIso8601String(); + } + + if (value is Uri) { + return value.toString(); } return value.toString(); @@ -107,4 +120,25 @@ abstract class BaseEncoder { return Uri.encodeComponent(value); } } + + /// Encodes a dynamic value, handling URI objects specially. + /// + /// For URI objects: + /// - When [useQueryEncoding] is true (query parameters), returns the URI + /// string as-is since URIs are already properly encoded + /// - When [useQueryEncoding] is false (path parameters), encodes the URI + /// string for use in path segments + /// + /// For other values, converts to string and applies standard encoding. + @protected + String encodeValueDynamic(dynamic value, {bool useQueryEncoding = false}) { + final stringValue = valueToString(value); + + if (value is Uri && useQueryEncoding) { + // URIs are already properly encoded, don't double-encode for query params + return stringValue; + } + + return encodeValue(stringValue, useQueryEncoding: useQueryEncoding); + } } diff --git a/packages/tonik_util/lib/src/encoding/datetime_extension.dart b/packages/tonik_util/lib/src/encoding/datetime_extension.dart new file mode 100644 index 0000000..0561254 --- /dev/null +++ b/packages/tonik_util/lib/src/encoding/datetime_extension.dart @@ -0,0 +1,60 @@ +/// Extension on DateTime to provide timezone-aware encoding. +/// +/// This extension ensures that DateTime objects are encoded with their full +/// timezone information, unlike [DateTime.toIso8601String()] which only +/// works correctly for UTC dates. +extension DateTimeEncodingExtension on DateTime { + /// Encodes this DateTime to a string representation that preserves + /// timezone information. + /// + /// For UTC dates, this returns the same as [DateTime.toIso8601String()]. + /// For local dates, this ensures the timezone offset is properly included. + String toTimeZonedIso8601String() { + if (isUtc) { + // For UTC dates, use toIso8601String which works correctly + return toIso8601String(); + } + + // For local dates, we need to include timezone offset + // Format the base date and time manually to match toIso8601String format + final year = this.year; + final month = _twoDigits(this.month); + final day = _twoDigits(this.day); + final hour = _twoDigits(this.hour); + final minute = _twoDigits(this.minute); + final second = _twoDigits(this.second); + + // Add milliseconds if present + final millisecondString = millisecond > 0 + ? '.${_threeDigits(millisecond)}' + : ''; + + // Add microseconds if present + final microsecondString = microsecond > 0 + ? _threeDigits(microsecond) + : ''; + + // Get the timezone offset in hours and minutes + final offset = timeZoneOffset; + final offsetHours = offset.inHours.abs(); + final offsetMinutes = offset.inMinutes.abs() % 60; + + // Format timezone offset + final offsetSign = offset.isNegative ? '-' : '+'; + final offsetString = '$offsetSign' + '${_twoDigits(offsetHours)}:${_twoDigits(offsetMinutes)}'; + + return '$year-$month-${day}T$hour:$minute:$second' + '$millisecondString$microsecondString$offsetString'; + } + + /// Formats a number as two digits with leading zero if needed. + static String _twoDigits(int n) { + return n.toString().padLeft(2, '0'); + } + + /// Formats a number as three digits with leading zeros if needed. + static String _threeDigits(int n) { + return n.toString().padLeft(3, '0'); + } +} diff --git a/packages/tonik_util/lib/src/encoding/deep_object_encoder.dart b/packages/tonik_util/lib/src/encoding/deep_object_encoder.dart index f6d35ea..72df291 100644 --- a/packages/tonik_util/lib/src/encoding/deep_object_encoder.dart +++ b/packages/tonik_util/lib/src/encoding/deep_object_encoder.dart @@ -83,8 +83,8 @@ class DeepObjectEncoder extends BaseEncoder { } else if (value is String && !allowEmpty && value.isEmpty) { throw const EmptyValueException(); } else { - final encodedValue = encodeValue( - valueToString(value), + final encodedValue = encodeValueDynamic( + value, useQueryEncoding: true, ); result.add((name: '$path[$key]', value: encodedValue)); @@ -112,7 +112,11 @@ class DeepObjectEncoder extends BaseEncoder { for (final entry in value.entries) { final val = entry.value; - if (val == null || val is String || val is num || val is bool) { + if (val == null || + val is String || + val is num || + val is bool || + val is Uri) { continue; } @@ -143,6 +147,11 @@ class DeepObjectEncoder extends BaseEncoder { if (value == null) { return ''; } + + if (value is Uri) { + return value.toString(); + } + return value.toString(); } } diff --git a/packages/tonik_util/lib/src/encoding/delimited_encoder.dart b/packages/tonik_util/lib/src/encoding/delimited_encoder.dart index bc4ac30..9dcbfbd 100644 --- a/packages/tonik_util/lib/src/encoding/delimited_encoder.dart +++ b/packages/tonik_util/lib/src/encoding/delimited_encoder.dart @@ -75,7 +75,7 @@ class DelimitedEncoder extends BaseEncoder { return value .map( (item) => - encodeValue(valueToString(item), useQueryEncoding: true), + encodeValueDynamic(item, useQueryEncoding: true), ) .toList(); } else { @@ -84,14 +84,14 @@ class DelimitedEncoder extends BaseEncoder { value .map( (item) => - encodeValue(valueToString(item), useQueryEncoding: true), + encodeValueDynamic(item, useQueryEncoding: true), ) .join(delimiter), ]; } } - return [encodeValue(valueToString(value), useQueryEncoding: true)]; + return [encodeValueDynamic(value, useQueryEncoding: true)]; } } diff --git a/packages/tonik_util/lib/src/encoding/form_encoder.dart b/packages/tonik_util/lib/src/encoding/form_encoder.dart index fab5344..e1c4284 100644 --- a/packages/tonik_util/lib/src/encoding/form_encoder.dart +++ b/packages/tonik_util/lib/src/encoding/form_encoder.dart @@ -50,7 +50,7 @@ class FormEncoder extends BaseEncoder { if (value is Iterable) { final values = value.map( - (item) => encodeValue(valueToString(item), useQueryEncoding: true), + (item) => encodeValueDynamic(item, useQueryEncoding: true), ); if (explode) { @@ -67,8 +67,8 @@ class FormEncoder extends BaseEncoder { .map( (entry) => ( name: entry.key, - value: encodeValue( - valueToString(entry.value), + value: encodeValueDynamic( + entry.value, useQueryEncoding: true, ), ), @@ -83,8 +83,8 @@ class FormEncoder extends BaseEncoder { .expand( (entry) => [ entry.key, - encodeValue( - valueToString(entry.value), + encodeValueDynamic( + entry.value, useQueryEncoding: true, ), ], @@ -98,7 +98,7 @@ class FormEncoder extends BaseEncoder { return [ ( name: paramName, - value: encodeValue(valueToString(value), useQueryEncoding: true), + value: encodeValueDynamic(value, useQueryEncoding: true), ), ]; } diff --git a/packages/tonik_util/lib/src/encoding/label_encoder.dart b/packages/tonik_util/lib/src/encoding/label_encoder.dart index 30c2c90..abca55f 100644 --- a/packages/tonik_util/lib/src/encoding/label_encoder.dart +++ b/packages/tonik_util/lib/src/encoding/label_encoder.dart @@ -52,12 +52,12 @@ class LabelEncoder extends BaseEncoder { if (explode) { // With explode=true, each value gets its own dot prefix return value - .map((item) => '.${encodeValue(valueToString(item))}') + .map((item) => '.${encodeValueDynamic(item)}') .join(); } else { // With explode=false (default), comma-separate the values final encodedValues = value - .map((item) => encodeValue(valueToString(item))) + .map(encodeValueDynamic) .join(','); return '.$encodedValues'; } @@ -69,20 +69,20 @@ class LabelEncoder extends BaseEncoder { return value.entries .map( (entry) => - '.${entry.key}=${encodeValue(valueToString(entry.value))}', + '.${entry.key}=${encodeValueDynamic(entry.value)}', ) .join(); } else { // With explode=false, properties are comma-separated pairs final encodedPairs = value.entries .expand( - (entry) => [entry.key, encodeValue(valueToString(entry.value))], + (entry) => [entry.key, encodeValueDynamic(entry.value)], ) .join(','); return '.$encodedPairs'; } } - return '.${encodeValue(valueToString(value))}'; + return '.${encodeValueDynamic(value)}'; } } diff --git a/packages/tonik_util/lib/src/encoding/matrix_encoder.dart b/packages/tonik_util/lib/src/encoding/matrix_encoder.dart index 856211c..5aae4dc 100644 --- a/packages/tonik_util/lib/src/encoding/matrix_encoder.dart +++ b/packages/tonik_util/lib/src/encoding/matrix_encoder.dart @@ -57,12 +57,12 @@ class MatrixEncoder extends BaseEncoder { if (explode) { return value - .map((item) => ';$paramName=${encodeValue(valueToString(item))}') + .map((item) => ';$paramName=${encodeValueDynamic(item)}') .join(); } else { // With explode=false (default), comma-separate the values final encodedValues = value - .map((item) => encodeValue(valueToString(item))) + .map(encodeValueDynamic) .join(','); return ';$paramName=$encodedValues'; } @@ -83,7 +83,7 @@ class MatrixEncoder extends BaseEncoder { .map( (entry) => ';$paramName.${entry.key}=' - '${encodeValue(valueToString(entry.value))}', + '${encodeValueDynamic(entry.value)}', ) .join(); } else { @@ -91,13 +91,13 @@ class MatrixEncoder extends BaseEncoder { // comma-separated pairs: ;point=x,1,y,2 final encodedPairs = value.entries .expand( - (entry) => [entry.key, encodeValue(valueToString(entry.value))], + (entry) => [entry.key, encodeValueDynamic(entry.value)], ) .join(','); return ';$paramName=$encodedPairs'; } } - return ';$paramName=${encodeValue(valueToString(value))}'; + return ';$paramName=${encodeValueDynamic(value)}'; } } diff --git a/packages/tonik_util/lib/src/encoding/simple_encoder.dart b/packages/tonik_util/lib/src/encoding/simple_encoder.dart index a7cce20..7816531 100644 --- a/packages/tonik_util/lib/src/encoding/simple_encoder.dart +++ b/packages/tonik_util/lib/src/encoding/simple_encoder.dart @@ -52,10 +52,10 @@ class SimpleEncoder extends BaseEncoder { // but since SimpleEncoder only encodes the value part // (not the full parameter), we'll use comma-separated values // as a fallback - return value.map((item) => encodeValue(valueToString(item))).join(','); + return value.map(encodeValueDynamic).join(','); } else { // With explode=false, comma-separate the values - return value.map((item) => encodeValue(valueToString(item))).join(','); + return value.map(encodeValueDynamic).join(','); } } @@ -64,20 +64,19 @@ class SimpleEncoder extends BaseEncoder { // With explode=true, key=value pairs are comma-separated return value.entries .map( - (entry) => - '${entry.key}=${encodeValue(valueToString(entry.value))}', + (entry) => '${entry.key}=${encodeValueDynamic(entry.value)}', ) .join(','); } else { // With explode=false, keys and values are comma-separated return value.entries .expand( - (entry) => [entry.key, encodeValue(valueToString(entry.value))], + (entry) => [entry.key, encodeValueDynamic(entry.value)], ) .join(','); } } - return encodeValue(valueToString(value)); + return encodeValueDynamic(value); } } 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 936c273..40cc418 100644 --- a/packages/tonik_util/lib/tonik_util.dart +++ b/packages/tonik_util/lib/tonik_util.dart @@ -6,6 +6,7 @@ export 'src/decoding/decoding_exception.dart'; export 'src/decoding/json_decoder.dart'; export 'src/decoding/simple_decoder.dart'; export 'src/dio/server_config.dart'; +export 'src/encoding/datetime_extension.dart'; export 'src/encoding/deep_object_encoder.dart'; export 'src/encoding/delimited_encoder.dart'; export 'src/encoding/encoding_exception.dart'; @@ -14,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 2bccc0a..61df51f 100644 --- a/packages/tonik_util/pubspec.yaml +++ b/packages/tonik_util/pubspec.yaml @@ -1,6 +1,6 @@ name: tonik_util description: Runtime tools for packages generated by Tonik. -version: 0.0.6 +version: 0.0.7 repository: https://github.com/t-unit/tonik resolution: workspace @@ -15,3 +15,4 @@ dependencies: dev_dependencies: test: ^1.24.0 + timezone: ^0.10.1 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 1b21dc2..8bb1ae8 100644 --- a/packages/tonik_util/test/src/decoding/json_decoder_test.dart +++ b/packages/tonik_util/test/src/decoding/json_decoder_test.dart @@ -9,9 +9,57 @@ import 'package:tonik_util/src/decoding/json_decoder.dart'; void main() { group('JsonDecoder', () { group('DateTime', () { - test('decodes DateTime values', () { - final date = DateTime.utc(2024, 3, 14); - expect(date.toIso8601String().decodeJsonDateTime(), date); + test('decodes DateTime values with timezone awareness', () { + // Test UTC parsing + const utcString = '2024-03-14T10:30:45Z'; + final utcResult = utcString.decodeJsonDateTime(); + 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.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.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( () => 123.decodeJsonDateTime(), throwsA(isA()), @@ -22,9 +70,17 @@ void main() { ); }); - test('decodes nullable DateTime values', () { - final date = DateTime.utc(2024, 3, 14); - expect(date.toIso8601String().decodeJsonNullableDateTime(), date); + test('decodes nullable DateTime values with timezone awareness', () { + 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( @@ -236,7 +292,10 @@ void main() { final date = Date(2024, 3, 15); expect('2024-03-15'.decodeJsonNullableDate(), date); expect(null.decodeJsonNullableDate(), isNull); - expect(''.decodeJsonNullableDate(), isNull); + expect( + () => ''.decodeJsonNullableDate(), + throwsA(isA()), + ); expect( () => 123.decodeJsonNullableDate(), throwsA(isA()), @@ -263,6 +322,49 @@ void main() { ); }); }); + + group('Uri', () { + test('decodes Uri values', () { + final uri = Uri.parse('https://example.com'); + expect('https://example.com'.decodeJsonUri(), uri); + expect('ftp://files.example.com/file.txt'.decodeJsonUri(), + Uri.parse('ftp://files.example.com/file.txt')); + expect('/relative/path'.decodeJsonUri(), Uri.parse('/relative/path')); + expect('mailto:user@example.com'.decodeJsonUri(), + Uri.parse('mailto:user@example.com')); + expect( + () => 123.decodeJsonUri(), + throwsA(isA()), + ); + expect( + () => null.decodeJsonUri(), + throwsA(isA()), + ); + }); + + test('decodes nullable Uri values', () { + final uri = Uri.parse('https://example.com'); + expect('https://example.com'.decodeJsonNullableUri(), uri); + expect('/api/v1/users'.decodeJsonNullableUri(), Uri.parse('/api/v1/users')); + expect(null.decodeJsonNullableUri(), isNull); + expect(''.decodeJsonNullableUri(), Uri.parse('')); + expect( + () => 123.decodeJsonNullableUri(), + throwsA(isA()), + ); + }); + + test('handles invalid URI strings', () { + expect( + () => ':::invalid:::'.decodeJsonUri(), + throwsA(isA()), + ); + expect( + () => ':::invalid:::'.decodeJsonNullableUri(), + throwsA(isA()), + ); + }); + }); }); group('List', () { @@ -317,7 +419,7 @@ void main() { group('decodeMap', () { test('decodes valid map', () { final map = {'key': 'value'}; - expect(map.decodeMap(), equals(map)); + expect(map.decodeMap(), map); }); test('throws on null', () { 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 366b6a1..3e1d6c4 100644 --- a/packages/tonik_util/test/src/decoding/simple_decoder_test.dart +++ b/packages/tonik_util/test/src/decoding/simple_decoder_test.dart @@ -46,9 +46,58 @@ void main() { ); }); - test('decodes DateTime values', () { - final date = DateTime.utc(2024, 3, 14); - expect(date.toIso8601String().decodeSimpleDateTime(), date); + test('decodes DateTime values with timezone awareness', () { + // Test UTC parsing + const utcString = '2024-03-14T10:30:45Z'; + final utcResult = utcString.decodeSimpleDateTime(); + 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.decodeSimpleDateTime(); + 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.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( () => 'not-a-date'.decodeSimpleDateTime(), throwsA(isA()), @@ -104,6 +153,27 @@ void main() { throwsA(isA()), ); }); + + test('decodes Uri values', () { + final uri = Uri.parse('https://example.com'); + expect('https://example.com'.decodeSimpleUri(), uri); + expect('ftp://files.example.com/file.txt'.decodeSimpleUri(), + Uri.parse('ftp://files.example.com/file.txt')); + expect('/relative/path'.decodeSimpleUri(), Uri.parse('/relative/path')); + expect('mailto:user@example.com'.decodeSimpleUri(), + Uri.parse('mailto:user@example.com')); + expect( + () => null.decodeSimpleUri(), + throwsA(isA()), + ); + }); + + test('handles URI parsing errors', () { + expect( + () => ':::invalid:::'.decodeSimpleUri(), + throwsA(isA()), + ); + }); }); group('Nullable Values', () { @@ -114,6 +184,7 @@ void main() { expect(''.decodeSimpleNullableDateTime(), isNull); expect(''.decodeSimpleNullableBigDecimal(), isNull); expect(''.decodeSimpleNullableDate(), isNull); + expect(''.decodeSimpleNullableUri(), isNull); expect(null.decodeSimpleNullableInt(), isNull); expect(null.decodeSimpleNullableDouble(), isNull); @@ -121,6 +192,7 @@ void main() { expect(null.decodeSimpleNullableDateTime(), isNull); expect(null.decodeSimpleNullableBigDecimal(), isNull); expect(null.decodeSimpleNullableDate(), isNull); + expect(null.decodeSimpleNullableUri(), isNull); }); test('decodes non-empty strings for nullable types', () { @@ -128,8 +200,8 @@ void main() { expect('3.14'.decodeSimpleNullableDouble(), 3.14); expect('true'.decodeSimpleNullableBool(), isTrue); expect( - '2024-03-14T00:00:00.000Z'.decodeSimpleNullableDateTime(), - DateTime.utc(2024, 3, 14), + '2024-03-14T10:30:45Z'.decodeSimpleNullableDateTime(), + DateTime.utc(2024, 3, 14, 10, 30, 45), ); expect( '3.14'.decodeSimpleNullableBigDecimal(), @@ -139,6 +211,10 @@ void main() { '2024-03-15'.decodeSimpleNullableDate(), Date(2024, 3, 15), ); + expect( + 'https://example.com'.decodeSimpleNullableUri(), + Uri.parse('https://example.com'), + ); }); }); @@ -168,6 +244,10 @@ void main() { () => ''.decodeSimpleDate(), throwsA(isA()), ); + expect( + () => ''.decodeSimpleUri(), + throwsA(isA()), + ); }); }); diff --git a/packages/tonik_util/test/src/encoder/datetime_extension_test.dart b/packages/tonik_util/test/src/encoder/datetime_extension_test.dart new file mode 100644 index 0000000..0490711 --- /dev/null +++ b/packages/tonik_util/test/src/encoder/datetime_extension_test.dart @@ -0,0 +1,162 @@ +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/encoding/datetime_extension.dart'; + +void main() { + setUpAll(tz.initializeTimeZones); + + group('DateTimeEncodingExtension', () { + group('toTimeZonedIso8601String', () { + test('encodes UTC DateTime correctly', () { + final utcDateTime = DateTime.utc(2023, 12, 25, 15, 30, 45, 123); + final result = utcDateTime.toTimeZonedIso8601String(); + + // Should match toIso8601String for UTC dates + expect(result, utcDateTime.toIso8601String()); + expect(result, '2023-12-25T15:30:45.123Z'); + }); + }); + + group('timezone handling', () { + test('encodes in EST (UTC-5:00)', () { + final estLocation = tz.getLocation('America/New_York'); + final estDateTime = tz.TZDateTime( + estLocation, + 2023, + 12, + 25, + 15, + 30, + 45, + ); + + final result = estDateTime.toTimeZonedIso8601String(); + + // Should include EST timezone offset (-05:00) + expect(result, '2023-12-25T15:30:45-05:00'); + }); + + test('encodes in PST (UTC-8:00)', () { + final pstLocation = tz.getLocation('America/Los_Angeles'); + final pstDateTime = tz.TZDateTime( + pstLocation, + 2023, + 12, + 25, + 18, + 30, + 45, + ); + + final result = pstDateTime.toTimeZonedIso8601String(); + + // Should include PST timezone offset (-08:00) + expect(result, '2023-12-25T18:30:45-08:00'); + }); + + test('encodes in IST (UTC+5:30)', () { + final istLocation = tz.getLocation('Asia/Kolkata'); + final istDateTime = tz.TZDateTime( + istLocation, + 2023, + 12, + 25, + 20, + 0, + 45, + ); + + final result = istDateTime.toTimeZonedIso8601String(); + + // Should include IST timezone offset (+05:30) + expect(result, '2023-12-25T20:00:45+05:30'); + }); + + test('encodes in CET (UTC+1:00)', () { + final cetLocation = tz.getLocation('Europe/Paris'); + final cetDateTime = tz.TZDateTime( + cetLocation, + 2023, + 12, + 25, + 16, + 30, + 45, + ); + + final result = cetDateTime.toTimeZonedIso8601String(); + + // Should include CET timezone offset (+01:00) + expect(result, '2023-12-25T16:30:45+01:00'); + }); + + test('encodes in GMT (UTC+0:00)', () { + final gmtLocation = tz.getLocation('Europe/London'); + final gmtDateTime = tz.TZDateTime( + gmtLocation, + 2023, + 12, + 25, + 15, + 30, + 45, + ); + + final result = gmtDateTime.toTimeZonedIso8601String(); + + // Should include GMT timezone offset (+00:00) + expect(result, '2023-12-25T15:30:45+00:00'); + }); + + test('encodes with milliseconds in timezone', () { + final estLocation = tz.getLocation('America/New_York'); + final estDateTime = tz.TZDateTime( + estLocation, + 2023, + 12, + 25, + 15, + 30, + 45, + 123, + ); + + final result = estDateTime.toTimeZonedIso8601String(); + + // Should include EST timezone offset (-05:00) and milliseconds + expect(result, '2023-12-25T15:30:45.123-05:00'); + }); + + test('encodes with microseconds in timezone', () { + final pstLocation = tz.getLocation('America/Los_Angeles'); + final pstDateTime = tz.TZDateTime( + pstLocation, + 2023, + 12, + 25, + 18, + 30, + 45, + 123, + 456, + ); + + final result = pstDateTime.toTimeZonedIso8601String(); + + // Should include PST timezone offset (-08:00) and microseconds + expect(result, '2023-12-25T18:30:45.123456-08:00'); + }); + + test('encodes in JST (UTC+9:00)', () { + final jstLocation = tz.getLocation('Asia/Tokyo'); + final jstDateTime = tz.TZDateTime(jstLocation, 2009, 6, 30, 18, 30); + + final result = jstDateTime.toTimeZonedIso8601String(); + + // Should include JST timezone offset (+09:00) + expect(result, '2009-06-30T18:30:00+09:00'); + }); + }); + }); +} diff --git a/packages/tonik_util/test/src/encoder/deep_object_encoder_test.dart b/packages/tonik_util/test/src/encoder/deep_object_encoder_test.dart index 9bfd98b..536a117 100644 --- a/packages/tonik_util/test/src/encoder/deep_object_encoder_test.dart +++ b/packages/tonik_util/test/src/encoder/deep_object_encoder_test.dart @@ -38,6 +38,39 @@ void main() { ]); }); + test('encodes Uri properties', () { + final result = encoder.encode( + 'filter', + { + 'endpoint': Uri.parse('https://example.com/api/v1'), + 'callback': Uri.parse('https://example.com/callback'), + }, + allowEmpty: true, + ); + + expect(result, [ + (name: 'filter[endpoint]', value: 'https://example.com/api/v1'), + (name: 'filter[callback]', value: 'https://example.com/callback'), + ]); + }); + + test('encodes Uri properties with special characters', () { + final result = encoder.encode( + 'params', + { + 'url': Uri.parse('https://example.com/search?q=hello world'), + }, + allowEmpty: true, + ); + + expect(result, [ + ( + name: 'params[url]', + value: 'https://example.com/search?q=hello%20world', + ), + ]); + }); + test('encodes an object with a null value', () { final result = encoder.encode( 'filter', diff --git a/packages/tonik_util/test/src/encoder/delimited_encoder_test.dart b/packages/tonik_util/test/src/encoder/delimited_encoder_test.dart index 5c2bbbb..c7bcc47 100644 --- a/packages/tonik_util/test/src/encoder/delimited_encoder_test.dart +++ b/packages/tonik_util/test/src/encoder/delimited_encoder_test.dart @@ -42,6 +42,20 @@ void main() { ]); }); + test('encodes Uri value', () { + final uri = Uri.parse('https://example.com/api/v1'); + expect(encoder.encode(uri, explode: false, allowEmpty: true), [ + 'https://example.com/api/v1', + ]); + }); + + test('encodes Uri value with special characters', () { + final uri = Uri.parse('https://example.com/search?q=hello world'); + expect(encoder.encode(uri, explode: false, allowEmpty: true), [ + 'https://example.com/search?q=hello%20world', + ]); + }); + test('encodes null value when allowEmpty is true', () { expect(encoder.encode(null, explode: false, allowEmpty: true), ['']); }); @@ -265,6 +279,20 @@ void main() { ]); }); + test('encodes Uri value', () { + final uri = Uri.parse('https://example.com/api/v1'); + expect(encoder.encode(uri, explode: false, allowEmpty: true), [ + 'https://example.com/api/v1', + ]); + }); + + test('encodes Uri value with special characters', () { + final uri = Uri.parse('https://example.com/search?q=hello world'); + expect(encoder.encode(uri, explode: false, allowEmpty: true), [ + 'https://example.com/search?q=hello%20world', + ]); + }); + test('encodes null value when allowEmpty is true', () { expect(encoder.encode(null, explode: false, allowEmpty: true), ['']); }); @@ -448,6 +476,226 @@ void main() { ); }); }); + + group('RFC 3986 reserved character encoding', () { + group('gen-delims characters', () { + test('encodes colon (:) properly', () { + expect( + encoder.encode( + 'http://example.com', + explode: false, + allowEmpty: true, + ), + ['http%3A%2F%2Fexample.com'], + ); + }); + + test('encodes forward slash (/) properly', () { + expect( + encoder.encode('/api/v1/users', explode: false, allowEmpty: true), + ['%2Fapi%2Fv1%2Fusers'], + ); + }); + + test('encodes question mark (?) properly', () { + expect( + encoder.encode( + 'search?term=test', + explode: false, + allowEmpty: true, + ), + ['search%3Fterm%3Dtest'], + ); + }); + + test('encodes hash (#) properly', () { + expect( + encoder.encode('page#section1', explode: false, allowEmpty: true), + ['page%23section1'], + ); + }); + + test('encodes square brackets ([]) properly', () { + expect( + encoder.encode('[2001:db8::1]', explode: false, allowEmpty: true), + ['%5B2001%3Adb8%3A%3A1%5D'], + ); + }); + + test('encodes at symbol (@) properly', () { + expect( + encoder.encode( + 'user@example.com', + explode: false, + allowEmpty: true, + ), + ['user%40example.com'], + ); + }); + }); + + group('sub-delims characters', () { + test('encodes exclamation mark (!) properly', () { + expect( + encoder.encode('Hello!', explode: false, allowEmpty: true), + ['Hello%21'], + ); + }); + + test(r'encodes dollar sign ($) properly', () { + expect( + encoder.encode(r'$19.99', explode: false, allowEmpty: true), + ['%2419.99'], + ); + }); + + test('encodes ampersand (&) properly', () { + expect( + encoder.encode( + 'Johnson & Johnson', + explode: false, + allowEmpty: true, + ), + ['Johnson+%26+Johnson'], + ); + }); + + test("encodes single quote (') properly", () { + expect( + encoder.encode("It's working", explode: false, allowEmpty: true), + ['It%27s+working'], + ); + }); + + test('encodes parentheses () properly', () { + expect( + encoder.encode( + '(555) 123-4567', + explode: false, + allowEmpty: true, + ), + ['%28555%29+123-4567'], + ); + }); + + test('encodes asterisk (*) properly', () { + expect( + encoder.encode('file*.txt', explode: false, allowEmpty: true), + ['file%2A.txt'], + ); + }); + + test('encodes plus (+) properly', () { + expect( + encoder.encode('2+2=4', explode: false, allowEmpty: true), + ['2%2B2%3D4'], + ); + }); + + test('encodes comma (,) properly', () { + expect( + encoder.encode( + 'apple,banana,cherry', + explode: false, + allowEmpty: true, + ), + ['apple%2Cbanana%2Ccherry'], + ); + }); + + test('encodes semicolon (;) properly', () { + expect( + encoder.encode('a=1;b=2', explode: false, allowEmpty: true), + ['a%3D1%3Bb%3D2'], + ); + }); + + test('encodes equals (=) properly', () { + expect( + encoder.encode('x=y', explode: false, allowEmpty: true), + ['x%3Dy'], + ); + }); + }); + + group('unreserved characters should NOT be encoded', () { + test('does not encode letters', () { + expect( + encoder.encode('ABCdef', explode: false, allowEmpty: true), + ['ABCdef'], + ); + }); + + test('does not encode digits', () { + expect( + encoder.encode('1234567890', explode: false, allowEmpty: true), + ['1234567890'], + ); + }); + + test('does not encode hyphen (-)', () { + expect( + encoder.encode( + '123e4567-e89b-12d3', + explode: false, + allowEmpty: true, + ), + ['123e4567-e89b-12d3'], + ); + }); + + test('does not encode period (.)', () { + expect( + encoder.encode('example.com', explode: false, allowEmpty: true), + ['example.com'], + ); + }); + + test('does not encode underscore (_)', () { + expect( + encoder.encode('my_variable', explode: false, allowEmpty: true), + ['my_variable'], + ); + }); + + test('does not encode tilde (~)', () { + expect( + encoder.encode('~%2Fdocuments', explode: false, allowEmpty: true), + ['~%252Fdocuments'], + ); + }); + }); + + group('percent-encoding normalization', () { + test('uses uppercase hex digits for encoding', () { + expect( + encoder.encode('hello world!', explode: false, allowEmpty: true), + ['hello+world%21'], + ); + }); + + test('properly encodes non-ASCII characters', () { + expect( + encoder.encode('café', explode: false, allowEmpty: true), + ['caf%C3%A9'], + ); + }); + + test('properly encodes emoji', () { + expect( + encoder.encode('👍', explode: false, allowEmpty: true), + ['%F0%9F%91%8D'], + ); + }); + + test('properly encodes Chinese characters', () { + expect( + encoder.encode('你好', explode: false, allowEmpty: true), + ['%E4%BD%A0%E5%A5%BD'], + ); + }); + }); + }); }); }); } diff --git a/packages/tonik_util/test/src/encoder/form_encoder_test.dart b/packages/tonik_util/test/src/encoder/form_encoder_test.dart index ec39673..e900e14 100644 --- a/packages/tonik_util/test/src/encoder/form_encoder_test.dart +++ b/packages/tonik_util/test/src/encoder/form_encoder_test.dart @@ -46,6 +46,22 @@ void main() { ); }); + test('encodes Uri value', () { + final uri = Uri.parse('https://example.com/api/v1'); + expect( + encoder.encode('endpoint', uri, explode: false, allowEmpty: true), + [(name: 'endpoint', value: 'https://example.com/api/v1')], + ); + }); + + test('encodes Uri value with special characters', () { + final uri = Uri.parse('https://example.com/search?q=hello world'); + expect( + encoder.encode('url', uri, explode: false, allowEmpty: true), + [(name: 'url', value: 'https://example.com/search?q=hello%20world')], + ); + }); + group('empty value handling', () { test('encodes null value when allowEmpty is true', () { expect( @@ -346,6 +362,313 @@ void main() { throwsA(isA()), ); }); + + group('RFC 3986 reserved character encoding', () { + group('gen-delims characters', () { + test('encodes colon (:) properly', () { + expect( + encoder.encode( + 'url', + 'http://example.com', + explode: false, + allowEmpty: true, + ), + [(name: 'url', value: 'http%3A%2F%2Fexample.com')], + ); + }); + + test('encodes forward slash (/) properly', () { + expect( + encoder.encode( + 'path', + '/api/v1/users', + explode: false, + allowEmpty: true, + ), + [(name: 'path', value: '%2Fapi%2Fv1%2Fusers')], + ); + }); + + test('encodes question mark (?) properly', () { + expect( + encoder.encode( + 'query', + 'search?term=test', + explode: false, + allowEmpty: true, + ), + [(name: 'query', value: 'search%3Fterm%3Dtest')], + ); + }); + + test('encodes hash (#) properly', () { + expect( + encoder.encode( + 'fragment', + 'page#section1', + explode: false, + allowEmpty: true, + ), + [(name: 'fragment', value: 'page%23section1')], + ); + }); + + test('encodes square brackets ([]) properly', () { + expect( + encoder.encode( + 'ipv6', + '[2001:db8::1]', + explode: false, + allowEmpty: true, + ), + [(name: 'ipv6', value: '%5B2001%3Adb8%3A%3A1%5D')], + ); + }); + + test('encodes at symbol (@) properly', () { + expect( + encoder.encode( + 'email', + 'user@example.com', + explode: false, + allowEmpty: true, + ), + [(name: 'email', value: 'user%40example.com')], + ); + }); + }); + + group('sub-delims characters', () { + test('encodes exclamation mark (!) properly', () { + expect( + encoder.encode( + 'exclaim', + 'Hello!', + explode: false, + allowEmpty: true, + ), + [(name: 'exclaim', value: 'Hello%21')], + ); + }); + + test(r'encodes dollar sign ($) properly', () { + expect( + encoder.encode( + 'price', + r'$19.99', + explode: false, + allowEmpty: true, + ), + [(name: 'price', value: '%2419.99')], + ); + }); + + test('encodes ampersand (&) properly', () { + expect( + encoder.encode( + 'company', + 'Johnson & Johnson', + explode: false, + allowEmpty: true, + ), + [(name: 'company', value: 'Johnson+%26+Johnson')], + ); + }); + + test("encodes single quote (') properly", () { + expect( + encoder.encode( + 'text', + "It's working", + explode: false, + allowEmpty: true, + ), + [(name: 'text', value: 'It%27s+working')], + ); + }); + + test('encodes parentheses () properly', () { + expect( + encoder.encode( + 'phone', + '(555) 123-4567', + explode: false, + allowEmpty: true, + ), + [(name: 'phone', value: '%28555%29+123-4567')], + ); + }); + + test('encodes asterisk (*) properly', () { + expect( + encoder.encode( + 'wildcard', + 'file*.txt', + explode: false, + allowEmpty: true, + ), + [(name: 'wildcard', value: 'file%2A.txt')], + ); + }); + + test('encodes plus (+) properly', () { + expect( + encoder.encode('math', '2+2=4', explode: false, allowEmpty: true), + [(name: 'math', value: '2%2B2%3D4')], + ); + }); + + test('encodes comma (,) properly', () { + expect( + encoder.encode( + 'list', + 'apple,banana,cherry', + explode: false, + allowEmpty: true, + ), + [(name: 'list', value: 'apple%2Cbanana%2Ccherry')], + ); + }); + + test('encodes semicolon (;) properly', () { + expect( + encoder.encode( + 'params', + 'a=1;b=2', + explode: false, + allowEmpty: true, + ), + [(name: 'params', value: 'a%3D1%3Bb%3D2')], + ); + }); + + test('encodes equals (=) properly', () { + expect( + encoder.encode( + 'equation', + 'x=y', + explode: false, + allowEmpty: true, + ), + [(name: 'equation', value: 'x%3Dy')], + ); + }); + }); + + group('unreserved characters should NOT be encoded', () { + test('does not encode letters', () { + expect( + encoder.encode( + 'text', + 'ABCdef', + explode: false, + allowEmpty: true, + ), + [(name: 'text', value: 'ABCdef')], + ); + }); + + test('does not encode digits', () { + expect( + encoder.encode( + 'numbers', + '1234567890', + explode: false, + allowEmpty: true, + ), + [(name: 'numbers', value: '1234567890')], + ); + }); + + test('does not encode hyphen (-)', () { + expect( + encoder.encode( + 'uuid', + '123e4567-e89b-12d3', + explode: false, + allowEmpty: true, + ), + [(name: 'uuid', value: '123e4567-e89b-12d3')], + ); + }); + + test('does not encode period (.)', () { + expect( + encoder.encode( + 'domain', + 'example.com', + explode: false, + allowEmpty: true, + ), + [(name: 'domain', value: 'example.com')], + ); + }); + + test('does not encode underscore (_)', () { + expect( + encoder.encode( + 'var', + 'my_variable', + explode: false, + allowEmpty: true, + ), + [(name: 'var', value: 'my_variable')], + ); + }); + + test('does not encode tilde (~)', () { + expect( + encoder.encode( + 'path', + '~/documents', + explode: false, + allowEmpty: true, + ), + [(name: 'path', value: '~%2Fdocuments')], + ); + }); + }); + + group('percent-encoding normalization', () { + test('uses uppercase hex digits for encoding', () { + expect( + encoder.encode( + 'special', + 'hello world!', + explode: false, + allowEmpty: true, + ), + [(name: 'special', value: 'hello+world%21')], + ); + }); + + test('properly encodes non-ASCII characters', () { + expect( + encoder.encode( + 'unicode', + 'café', + explode: false, + allowEmpty: true, + ), + [(name: 'unicode', value: 'caf%C3%A9')], + ); + }); + + test('properly encodes emoji', () { + expect( + encoder.encode('emoji', '👍', explode: false, allowEmpty: true), + [(name: 'emoji', value: '%F0%9F%91%8D')], + ); + }); + + test('properly encodes Chinese characters', () { + expect( + encoder.encode('chinese', '你好', explode: false, allowEmpty: true), + [(name: 'chinese', value: '%E4%BD%A0%E5%A5%BD')], + ); + }); + }); + }); }); }); } diff --git a/packages/tonik_util/test/src/encoder/label_encoder_test.dart b/packages/tonik_util/test/src/encoder/label_encoder_test.dart index 2054309..55a1c72 100644 --- a/packages/tonik_util/test/src/encoder/label_encoder_test.dart +++ b/packages/tonik_util/test/src/encoder/label_encoder_test.dart @@ -34,6 +34,22 @@ void main() { expect(encoder.encode(false, explode: false, allowEmpty: true), '.false'); }); + test('encodes Uri value', () { + final uri = Uri.parse('https://example.com/api/v1'); + expect( + encoder.encode(uri, explode: false, allowEmpty: true), + '.https%3A%2F%2Fexample.com%2Fapi%2Fv1', + ); + }); + + test('encodes Uri value with special characters', () { + final uri = Uri.parse('https://example.com/search?q=hello world'); + expect( + encoder.encode(uri, explode: false, allowEmpty: true), + '.https%3A%2F%2Fexample.com%2Fsearch%3Fq%3Dhello%2520world', + ); + }); + test('encodes null value', () { expect(encoder.encode(null, explode: false, allowEmpty: true), '.'); }); @@ -206,6 +222,171 @@ void main() { throwsA(isA()), ); }); + + group('RFC 3986 reserved character encoding', () { + group('gen-delims characters', () { + test('encodes colon (:) properly', () { + expect( + encoder.encode( + 'http://example.com', + explode: false, + allowEmpty: true, + ), + '.http%3A%2F%2Fexample.com', + ); + }); + + test('encodes forward slash (/) properly', () { + expect( + encoder.encode('/api/v1/users', explode: false, allowEmpty: true), + '.%2Fapi%2Fv1%2Fusers', + ); + }); + + test('encodes question mark (?) properly', () { + expect( + encoder.encode( + 'search?term=test', + explode: false, + allowEmpty: true, + ), + '.search%3Fterm%3Dtest', + ); + }); + + test('encodes hash (#) properly', () { + expect( + encoder.encode('page#section1', explode: false, allowEmpty: true), + '.page%23section1', + ); + }); + + test('encodes square brackets ([]) properly', () { + expect( + encoder.encode('[2001:db8::1]', explode: false, allowEmpty: true), + '.%5B2001%3Adb8%3A%3A1%5D', + ); + }); + + test('encodes at symbol (@) properly', () { + expect( + encoder.encode( + 'user@example.com', + explode: false, + allowEmpty: true, + ), + '.user%40example.com', + ); + }); + }); + + group('sub-delims characters', () { + test('encodes exclamation mark (!) properly', () { + expect( + encoder.encode('Hello!', explode: false, allowEmpty: true), + '.Hello!', + ); + }); + + test(r'encodes dollar sign ($) properly', () { + expect( + encoder.encode(r'$19.99', explode: false, allowEmpty: true), + '.%2419.99', + ); + }); + + test('encodes ampersand (&) properly', () { + expect( + encoder.encode( + 'Johnson & Johnson', + explode: false, + allowEmpty: true, + ), + '.Johnson%20%26%20Johnson', + ); + }); + + test("encodes single quote (') properly", () { + expect( + encoder.encode("It's working", explode: false, allowEmpty: true), + ".It's%20working", + ); + }); + + test('encodes parentheses () properly', () { + expect( + encoder.encode( + '(555) 123-4567', + explode: false, + allowEmpty: true, + ), + '.(555)%20123-4567', + ); + }); + + test('encodes asterisk (*) properly', () { + expect( + encoder.encode('file*.txt', explode: false, allowEmpty: true), + '.file*.txt', + ); + }); + + test('encodes plus (+) properly', () { + expect( + encoder.encode('2+2=4', explode: false, allowEmpty: true), + '.2%2B2%3D4', + ); + }); + + test('encodes comma (,) properly', () { + expect( + encoder.encode( + 'apple,banana,cherry', + explode: false, + allowEmpty: true, + ), + '.apple%2Cbanana%2Ccherry', + ); + }); + + test('encodes semicolon (;) properly', () { + expect( + encoder.encode('a=1;b=2', explode: false, allowEmpty: true), + '.a%3D1%3Bb%3D2', + ); + }); + + test('encodes equals (=) properly', () { + expect( + encoder.encode('x=y', explode: false, allowEmpty: true), + '.x%3Dy', + ); + }); + }); + + group('percent-encoding normalization', () { + test('properly encodes non-ASCII characters', () { + expect( + encoder.encode('café', explode: false, allowEmpty: true), + '.caf%C3%A9', + ); + }); + + test('properly encodes emoji', () { + expect( + encoder.encode('👍', explode: false, allowEmpty: true), + '.%F0%9F%91%8D', + ); + }); + + test('properly encodes Chinese characters', () { + expect( + encoder.encode('你好', explode: false, allowEmpty: true), + '.%E4%BD%A0%E5%A5%BD', + ); + }); + }); + }); }); }); } diff --git a/packages/tonik_util/test/src/encoder/matrix_encoder_test.dart b/packages/tonik_util/test/src/encoder/matrix_encoder_test.dart index e9072c9..ffb1555 100644 --- a/packages/tonik_util/test/src/encoder/matrix_encoder_test.dart +++ b/packages/tonik_util/test/src/encoder/matrix_encoder_test.dart @@ -48,6 +48,22 @@ void main() { ); }); + test('encodes Uri value', () { + final uri = Uri.parse('https://example.com/api/v1'); + expect( + encoder.encode('endpoint', uri, explode: false, allowEmpty: true), + ';endpoint=https%3A%2F%2Fexample.com%2Fapi%2Fv1', + ); + }); + + test('encodes Uri value with special characters', () { + final uri = Uri.parse('https://example.com/search?q=hello world'); + expect( + encoder.encode('url', uri, explode: false, allowEmpty: true), + ';url=https%3A%2F%2Fexample.com%2Fsearch%3Fq%3Dhello%2520world', + ); + }); + test('encodes null value', () { expect( encoder.encode('nullValue', null, explode: false, allowEmpty: true), @@ -285,6 +301,227 @@ void main() { throwsA(isA()), ); }); + + group('RFC 3986 reserved character encoding', () { + group('gen-delims characters', () { + test('encodes colon (:) properly', () { + expect( + encoder.encode( + 'url', + 'http://example.com', + explode: false, + allowEmpty: true, + ), + ';url=http%3A%2F%2Fexample.com', + ); + }); + + test('encodes forward slash (/) properly', () { + expect( + encoder.encode( + 'path', + '/api/v1/users', + explode: false, + allowEmpty: true, + ), + ';path=%2Fapi%2Fv1%2Fusers', + ); + }); + + test('encodes question mark (?) properly', () { + expect( + encoder.encode( + 'query', + 'search?term=test', + explode: false, + allowEmpty: true, + ), + ';query=search%3Fterm%3Dtest', + ); + }); + + test('encodes hash (#) properly', () { + expect( + encoder.encode( + 'fragment', + 'page#section1', + explode: false, + allowEmpty: true, + ), + ';fragment=page%23section1', + ); + }); + + test('encodes square brackets ([]) properly', () { + expect( + encoder.encode( + 'ipv6', + '[2001:db8::1]', + explode: false, + allowEmpty: true, + ), + ';ipv6=%5B2001%3Adb8%3A%3A1%5D', + ); + }); + + test('encodes at symbol (@) properly', () { + expect( + encoder.encode( + 'email', + 'user@example.com', + explode: false, + allowEmpty: true, + ), + ';email=user%40example.com', + ); + }); + }); + + group('sub-delims characters', () { + test('encodes exclamation mark (!) properly', () { + expect( + encoder.encode( + 'exclaim', + 'Hello!', + explode: false, + allowEmpty: true, + ), + ';exclaim=Hello!', + ); + }); + + test(r'encodes dollar sign ($) properly', () { + expect( + encoder.encode( + 'price', + r'$19.99', + explode: false, + allowEmpty: true, + ), + ';price=%2419.99', + ); + }); + + test('encodes ampersand (&) properly', () { + expect( + encoder.encode( + 'company', + 'Johnson & Johnson', + explode: false, + allowEmpty: true, + ), + ';company=Johnson%20%26%20Johnson', + ); + }); + + test("encodes single quote (') properly", () { + expect( + encoder.encode( + 'text', + "It's working", + explode: false, + allowEmpty: true, + ), + ";text=It's%20working", + ); + }); + + test('encodes parentheses () properly', () { + expect( + encoder.encode( + 'phone', + '(555) 123-4567', + explode: false, + allowEmpty: true, + ), + ';phone=(555)%20123-4567', + ); + }); + + test('encodes asterisk (*) properly', () { + expect( + encoder.encode( + 'wildcard', + 'file*.txt', + explode: false, + allowEmpty: true, + ), + ';wildcard=file*.txt', + ); + }); + + test('encodes plus (+) properly', () { + expect( + encoder.encode('math', '2+2=4', explode: false, allowEmpty: true), + ';math=2%2B2%3D4', + ); + }); + + test('encodes comma (,) properly', () { + expect( + encoder.encode( + 'list', + 'apple,banana,cherry', + explode: false, + allowEmpty: true, + ), + ';list=apple%2Cbanana%2Ccherry', + ); + }); + + test('encodes semicolon (;) properly', () { + expect( + encoder.encode( + 'params', + 'a=1;b=2', + explode: false, + allowEmpty: true, + ), + ';params=a%3D1%3Bb%3D2', + ); + }); + + test('encodes equals (=) properly', () { + expect( + encoder.encode( + 'equation', + 'x=y', + explode: false, + allowEmpty: true, + ), + ';equation=x%3Dy', + ); + }); + }); + + group('percent-encoding normalization', () { + test('properly encodes non-ASCII characters', () { + expect( + encoder.encode( + 'unicode', + 'café', + explode: false, + allowEmpty: true, + ), + ';unicode=caf%C3%A9', + ); + }); + + test('properly encodes emoji', () { + expect( + encoder.encode('emoji', '👍', explode: false, allowEmpty: true), + ';emoji=%F0%9F%91%8D', + ); + }); + + test('properly encodes Chinese characters', () { + expect( + encoder.encode('chinese', '你好', explode: false, allowEmpty: true), + ';chinese=%E4%BD%A0%E5%A5%BD', + ); + }); + }); + }); }); }); } diff --git a/packages/tonik_util/test/src/encoder/simple_encoder_test.dart b/packages/tonik_util/test/src/encoder/simple_encoder_test.dart index d59464d..4d3b3fb 100644 --- a/packages/tonik_util/test/src/encoder/simple_encoder_test.dart +++ b/packages/tonik_util/test/src/encoder/simple_encoder_test.dart @@ -34,6 +34,22 @@ void main() { expect(encoder.encode(false, explode: false, allowEmpty: true), 'false'); }); + test('encodes Uri value', () { + final uri = Uri.parse('https://example.com/api/v1'); + expect( + encoder.encode(uri, explode: false, allowEmpty: true), + 'https%3A%2F%2Fexample.com%2Fapi%2Fv1', + ); + }); + + test('encodes Uri value with special characters', () { + final uri = Uri.parse('https://example.com/search?q=hello world'); + expect( + encoder.encode(uri, explode: false, allowEmpty: true), + 'https%3A%2F%2Fexample.com%2Fsearch%3Fq%3Dhello%2520world', + ); + }); + test('encodes null value', () { expect(encoder.encode(null, explode: false, allowEmpty: true), ''); }); @@ -201,5 +217,166 @@ void main() { ); }); }); + + group('RFC 3986 reserved character encoding', () { + group('gen-delims characters', () { + test('encodes colon (:) properly', () { + expect( + encoder.encode( + 'http://example.com', + explode: false, + allowEmpty: true, + ), + 'http%3A%2F%2Fexample.com', + ); + }); + + test('encodes forward slash (/) properly', () { + expect( + encoder.encode('/api/v1/users', explode: false, allowEmpty: true), + '%2Fapi%2Fv1%2Fusers', + ); + }); + + test('encodes question mark (?) properly', () { + expect( + encoder.encode( + 'search?term=test', + explode: false, + allowEmpty: true, + ), + 'search%3Fterm%3Dtest', + ); + }); + + test('encodes hash (#) properly', () { + expect( + encoder.encode('page#section1', explode: false, allowEmpty: true), + 'page%23section1', + ); + }); + + test('encodes square brackets ([]) properly', () { + expect( + encoder.encode('[2001:db8::1]', explode: false, allowEmpty: true), + '%5B2001%3Adb8%3A%3A1%5D', + ); + }); + + test('encodes at symbol (@) properly', () { + expect( + encoder.encode( + 'user@example.com', + explode: false, + allowEmpty: true, + ), + 'user%40example.com', + ); + }); + }); + + group('sub-delims characters', () { + test('encodes exclamation mark (!) properly', () { + expect( + encoder.encode('Hello!', explode: false, allowEmpty: true), + 'Hello!', + ); + }); + + test(r'encodes dollar sign ($) properly', () { + expect( + encoder.encode(r'$19.99', explode: false, allowEmpty: true), + '%2419.99', + ); + }); + + test('encodes ampersand (&) properly', () { + expect( + encoder.encode( + 'Johnson & Johnson', + explode: false, + allowEmpty: true, + ), + 'Johnson%20%26%20Johnson', + ); + }); + + test("encodes single quote (') properly", () { + expect( + encoder.encode("It's working", explode: false, allowEmpty: true), + "It's%20working", + ); + }); + + test('encodes parentheses () properly', () { + expect( + encoder.encode('(555) 123-4567', explode: false, allowEmpty: true), + '(555)%20123-4567', + ); + }); + + test('encodes asterisk (*) properly', () { + expect( + encoder.encode('file*.txt', explode: false, allowEmpty: true), + 'file*.txt', + ); + }); + + test('encodes plus (+) properly', () { + expect( + encoder.encode('2+2=4', explode: false, allowEmpty: true), + '2%2B2%3D4', + ); + }); + + test('encodes comma (,) properly', () { + expect( + encoder.encode( + 'apple,banana,cherry', + explode: false, + allowEmpty: true, + ), + 'apple%2Cbanana%2Ccherry', + ); + }); + + test('encodes semicolon (;) properly', () { + expect( + encoder.encode('a=1;b=2', explode: false, allowEmpty: true), + 'a%3D1%3Bb%3D2', + ); + }); + + test('encodes equals (=) properly', () { + expect( + encoder.encode('x=y', explode: false, allowEmpty: true), + 'x%3Dy', + ); + }); + }); + + group('percent-encoding normalization', () { + test('properly encodes non-ASCII characters', () { + expect( + encoder.encode('café', explode: false, allowEmpty: true), + 'caf%C3%A9', + ); + }); + + test('properly encodes emoji', () { + expect( + encoder.encode('👍', explode: false, allowEmpty: true), + '%F0%9F%91%8D', + ); + }); + + test('properly encodes Chinese characters', () { + expect( + encoder.encode('你好', explode: false, allowEmpty: true), + '%E4%BD%A0%E5%A5%BD', + ); + }); + }); + }); }); } 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..3aef2a5 --- /dev/null +++ b/packages/tonik_util/test/src/offset_date_time_test.dart @@ -0,0 +1,890 @@ +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 offset + final expectedLocalTime = DateTime.parse(input); + 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); + }); + + 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); + }); + }); +} diff --git a/pubspec.yaml b/pubspec.yaml index 9d59be4..387898b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,12 +16,11 @@ dependencies: logging: ^1.3.0 meta: ^1.16.0 path: ^1.9.1 - spell_out_numbers: ^1.0.0 - tonik: ^0.0.4 - tonik_core: ^0.0.4 - tonik_generate: ^0.0.4 - tonik_parse: ^0.0.4 - tonik_util: ^0.0.4 + tonik: ^0.0.6 + tonik_core: ^0.0.6 + tonik_generate: ^0.0.6 + tonik_parse: ^0.0.6 + tonik_util: ^0.0.7 dev_dependencies: melos: ^7.0.0-dev.9