Skip to content

Commit ec079f1

Browse files
authored
Merge pull request #33 from IvanMurzak/fix/date-time-json-serialization
Enhance JSON converters for DateTime, DateTimeOffset, and TimeSpan
2 parents dcf0904 + 994bd14 commit ec079f1

5 files changed

Lines changed: 320 additions & 15 deletions

File tree

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
using System;
2+
using System.Text.Json.Nodes;
3+
using com.IvanMurzak.ReflectorNet.Tests.Model;
4+
using com.IvanMurzak.ReflectorNet.Utils;
5+
using Xunit.Abstractions;
6+
7+
namespace com.IvanMurzak.ReflectorNet.Tests.SchemaTests
8+
{
9+
/// <summary>
10+
/// Tests that validate JSON schemas correctly represent serialized instances
11+
/// and that round-trip serialization/deserialization works correctly.
12+
/// </summary>
13+
public class SchemaSerializationValidationTests : SchemaTestBase
14+
{
15+
public SchemaSerializationValidationTests(ITestOutputHelper output) : base(output) { }
16+
17+
/// <summary>
18+
/// Generic validation method that tests:
19+
/// 1. Schema generation succeeds without errors
20+
/// 2. Instance serialization succeeds
21+
/// 3. Instance deserialization succeeds
22+
/// 4. Round-trip equals original value
23+
/// 5. Serialized JSON conforms to basic schema structure
24+
/// </summary>
25+
private void ValidateTypeSchemaAndRoundTrip(Type type, object? instance, Reflector reflector)
26+
{
27+
var typeName = type.GetTypeName(pretty: true);
28+
_output.WriteLine($"=== Testing type: {typeName} ===");
29+
30+
// Step 1: Generate schema and validate it has no errors
31+
var schema = reflector.GetSchema(type);
32+
Assert.NotNull(schema);
33+
34+
if (schema.AsObject().TryGetPropertyValue(JsonSchema.Error, out var errorValue))
35+
{
36+
Assert.Fail($"Schema generation failed for {typeName}: {errorValue}");
37+
}
38+
39+
_output.WriteLine($"✓ Schema generated successfully");
40+
_output.WriteLine($"Schema:\n{schema}\n");
41+
42+
// Step 2: Serialize the instance
43+
var serializeLogger = new StringBuilderLogger();
44+
var serialized = reflector.Serialize(
45+
instance,
46+
fallbackType: type,
47+
name: "testInstance",
48+
logger: serializeLogger);
49+
50+
Assert.NotNull(serialized);
51+
_output.WriteLine($"✓ Serialization succeeded");
52+
_output.WriteLine($"Serialization log:\n{serializeLogger}");
53+
54+
var serializedJson = serialized.ToJson(reflector);
55+
_output.WriteLine($"Serialized JSON:\n{serializedJson}\n");
56+
57+
// Step 3: Validate basic schema conformance
58+
ValidateBasicSchemaConformance(schema, serialized, type, reflector);
59+
_output.WriteLine($"✓ Basic schema conformance validated");
60+
61+
// Step 4: Deserialize the instance
62+
var deserializeLogger = new StringBuilderLogger();
63+
var deserialized = reflector.Deserialize(
64+
serialized,
65+
logger: deserializeLogger);
66+
67+
// Note: deserialized can be null for null values, which is valid
68+
_output.WriteLine($"✓ Deserialization succeeded");
69+
_output.WriteLine($"Deserialization log:\n{deserializeLogger}");
70+
71+
// Step 5: Validate round-trip equality
72+
var originalJson = instance.ToJson(reflector);
73+
var deserializedJson = deserialized.ToJson(reflector);
74+
75+
_output.WriteLine($"Original JSON:\n{originalJson}");
76+
_output.WriteLine($"Deserialized JSON:\n{deserializedJson}\n");
77+
78+
Assert.Equal(originalJson, deserializedJson);
79+
_output.WriteLine($"✓ Round-trip validation passed");
80+
_output.WriteLine($"=== Test completed for {typeName} ===\n");
81+
}
82+
83+
/// <summary>
84+
/// Validates that the serialized instance conforms to basic schema structure.
85+
/// Checks type compatibility between schema and serialized value.
86+
/// </summary>
87+
private void ValidateBasicSchemaConformance(JsonNode schema, object serialized, Type originalType, Reflector reflector)
88+
{
89+
// Get the schema type
90+
if (!schema.AsObject().TryGetPropertyValue(JsonSchema.Type, out var schemaTypeNode))
91+
{
92+
// If there's a $ref, that's also valid
93+
if (schema.AsObject().TryGetPropertyValue(JsonSchema.Ref, out _))
94+
{
95+
// Complex type with reference is valid
96+
return;
97+
}
98+
// No type and no ref - this might be okay for some schemas
99+
return;
100+
}
101+
102+
var schemaType = schemaTypeNode?.ToString();
103+
104+
// Convert serialized object to JSON and extract the "value" field
105+
var json = serialized.ToJson(reflector);
106+
var jsonNode = JsonNode.Parse(json);
107+
108+
// SerializedMember has structure: { "name": "...", "typeName": "...", "value": ... }
109+
// We need to extract just the "value" field to compare against the schema
110+
// Note: If value is null, the "value" field might be missing or null
111+
JsonNode? valueNode = null;
112+
if (jsonNode is JsonObject jsonObject)
113+
{
114+
if (jsonObject.TryGetPropertyValue("value", out var extractedValue))
115+
{
116+
valueNode = extractedValue;
117+
}
118+
else
119+
{
120+
// No "value" field means the value was null - this is valid for nullable types
121+
// Skip validation for null values
122+
return;
123+
}
124+
}
125+
else
126+
{
127+
// If it's not a JsonObject, use the whole node
128+
valueNode = jsonNode;
129+
}
130+
131+
// If valueNode is null, it's a valid null value for nullable types
132+
if (valueNode == null)
133+
{
134+
return;
135+
}
136+
137+
// Basic type validation
138+
if (schemaType == JsonSchema.Object)
139+
{
140+
Assert.True(valueNode is JsonObject,
141+
$"Schema declares type 'object' but JSON value is {valueNode?.GetType().Name}. JSON: {valueNode}");
142+
}
143+
else if (schemaType == JsonSchema.Array)
144+
{
145+
Assert.True(valueNode is JsonArray,
146+
$"Schema declares type 'array' but JSON value is {valueNode?.GetType().Name}. JSON: {valueNode}");
147+
}
148+
else if (schemaType == JsonSchema.String)
149+
{
150+
Assert.True(valueNode is JsonValue,
151+
$"Schema declares type 'string' but JSON value is {valueNode?.GetType().Name}. JSON: {valueNode}");
152+
}
153+
else if (schemaType == JsonSchema.Number || schemaType == JsonSchema.Integer)
154+
{
155+
Assert.True(valueNode is JsonValue,
156+
$"Schema declares type '{schemaType}' but JSON value is {valueNode?.GetType().Name}. JSON: {valueNode}");
157+
}
158+
else if (schemaType == JsonSchema.Boolean)
159+
{
160+
Assert.True(valueNode is JsonValue,
161+
$"Schema declares type 'boolean' but JSON value is {valueNode?.GetType().Name}. JSON: {valueNode}");
162+
}
163+
}
164+
165+
[Fact]
166+
public void ValidateAllBaseNonStaticTypes_NonNull()
167+
{
168+
var reflector = new Reflector();
169+
170+
foreach (var type in TestUtils.Types.BaseNonStaticTypes)
171+
{
172+
var instance = reflector.CreateInstance(type);
173+
ValidateTypeSchemaAndRoundTrip(type, instance, reflector);
174+
}
175+
}
176+
177+
[Fact]
178+
public void ValidateAllBaseNonStaticTypes_DefaultValues()
179+
{
180+
var reflector = new Reflector();
181+
182+
foreach (var type in TestUtils.Types.BaseNonStaticTypes)
183+
{
184+
var instance = reflector.GetDefaultValue(type);
185+
ValidateTypeSchemaAndRoundTrip(type, instance, reflector);
186+
}
187+
}
188+
189+
[Fact]
190+
public void ValidateDateTime_UnixMilliseconds()
191+
{
192+
var reflector = new Reflector();
193+
var dateTime = new DateTime(2025, 1, 1, 12, 30, 45, DateTimeKind.Utc);
194+
195+
ValidateTypeSchemaAndRoundTrip(typeof(DateTime), dateTime, reflector);
196+
197+
// Additional validation: verify the JSON contains the DateTime in correct format
198+
var serialized = reflector.Serialize(dateTime, name: "testDate");
199+
var json = serialized.ToJson(reflector);
200+
201+
_output.WriteLine($"DateTime serialized as: {json}");
202+
Assert.Contains("2025", json); // Should contain the year
203+
}
204+
205+
[Fact]
206+
public void ValidateDateTimeOffset_UnixMilliseconds()
207+
{
208+
var reflector = new Reflector();
209+
var dateTimeOffset = new DateTimeOffset(2025, 1, 1, 12, 30, 45, TimeSpan.FromHours(5));
210+
211+
ValidateTypeSchemaAndRoundTrip(typeof(DateTimeOffset), dateTimeOffset, reflector);
212+
213+
// Additional validation: verify the JSON contains the DateTimeOffset in correct format
214+
var serialized = reflector.Serialize(dateTimeOffset, name: "testDate");
215+
var json = serialized.ToJson(reflector);
216+
217+
_output.WriteLine($"DateTimeOffset serialized as: {json}");
218+
Assert.Contains("2025", json); // Should contain the year
219+
}
220+
221+
[Fact]
222+
public void ValidateTimeSpan_Ticks()
223+
{
224+
var reflector = new Reflector();
225+
var timeSpan = TimeSpan.FromHours(2.5);
226+
227+
ValidateTypeSchemaAndRoundTrip(typeof(TimeSpan), timeSpan, reflector);
228+
229+
// Additional validation: verify round-trip preserves the value
230+
var serialized = reflector.Serialize(timeSpan, name: "testTimeSpan");
231+
var deserialized = reflector.Deserialize(serialized);
232+
233+
Assert.NotNull(deserialized);
234+
Assert.IsType<TimeSpan>(deserialized);
235+
var deserializedTimeSpan = (TimeSpan)deserialized;
236+
237+
Assert.Equal(timeSpan, deserializedTimeSpan);
238+
_output.WriteLine($"TimeSpan round-trip: {timeSpan} -> {deserializedTimeSpan}");
239+
}
240+
241+
[Fact]
242+
public void ValidateNullableTypes()
243+
{
244+
var reflector = new Reflector();
245+
246+
// Test nullable DateTime with value
247+
DateTime? nullableDateTime = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);
248+
ValidateTypeSchemaAndRoundTrip(typeof(DateTime?), nullableDateTime, reflector);
249+
250+
// Note: Null values for nullable value types don't round-trip correctly in ReflectorNet
251+
// They deserialize to the default value (e.g., DateTime.MinValue) instead of null
252+
// This is a known limitation
253+
254+
// Test nullable DateTimeOffset with value
255+
DateTimeOffset? nullableDateTimeOffset = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
256+
ValidateTypeSchemaAndRoundTrip(typeof(DateTimeOffset?), nullableDateTimeOffset, reflector);
257+
258+
// Test nullable TimeSpan with value
259+
TimeSpan? nullableTimeSpan = TimeSpan.FromMinutes(30);
260+
ValidateTypeSchemaAndRoundTrip(typeof(TimeSpan?), nullableTimeSpan, reflector);
261+
}
262+
263+
[Fact]
264+
public void ValidateCollectionTypes()
265+
{
266+
var reflector = new Reflector();
267+
268+
// Test DateTime array
269+
var dateTimeArray = new DateTime[]
270+
{
271+
new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
272+
new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Utc)
273+
};
274+
ValidateTypeSchemaAndRoundTrip(typeof(DateTime[]), dateTimeArray, reflector);
275+
276+
// Test TimeSpan array
277+
var timeSpanArray = new TimeSpan[]
278+
{
279+
TimeSpan.FromHours(1),
280+
TimeSpan.FromMinutes(30)
281+
};
282+
ValidateTypeSchemaAndRoundTrip(typeof(TimeSpan[]), timeSpanArray, reflector);
283+
}
284+
}
285+
}

ReflectorNet/ReflectorNet.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
<!-- NuGet Package Information -->
1111
<PackageId>com.IvanMurzak.ReflectorNet</PackageId>
12-
<Version>2.4.1</Version>
12+
<Version>2.4.2</Version>
1313
<Authors>Ivan Murzak</Authors>
1414
<Copyright>Copyright © Ivan Murzak 2025</Copyright>
1515
<Description>ReflectorNet is an advanced .NET reflection toolkit designed for AI-driven scenarios. Effortlessly search for C# methods using natural language queries, invoke any method by supplying arguments as JSON, and receive results as JSON. The library also provides a powerful API to inspect, modify, and manage in-memory object instances dynamically via JSON data. Ideal for automation, testing, and AI integration workflows.</Description>

ReflectorNet/src/Convertor/Json/DateTimeJsonConverter.cs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
namespace com.IvanMurzak.ReflectorNet.Json
1414
{
1515
/// <summary>
16-
/// JsonConverter that handles conversion from JSON string values to DateTime type.
16+
/// JsonConverter that handles conversion from JSON string and number values to DateTime type.
1717
/// Supports nullable DateTime types and uses ISO 8601 format for writing.
18+
/// Number values are treated as Unix time in milliseconds (UTC).
1819
/// </summary>
1920
public class DateTimeJsonConverter : JsonConverter<object>
2021
{
@@ -35,11 +36,11 @@ public override bool CanConvert(Type typeToConvert)
3536
throw new JsonException($"Cannot convert null to non-nullable type {typeToConvert.GetTypeName(pretty: true)}.");
3637
}
3738

38-
// Handle numeric timestamps (Unix time)
39+
// Handle numeric timestamps (Unix time in milliseconds)
3940
if (reader.TokenType == JsonTokenType.Number)
4041
{
41-
var ticks = reader.GetInt64();
42-
return new DateTime(ticks);
42+
var unixTimeMilliseconds = reader.GetInt64();
43+
return DateTimeOffset.FromUnixTimeMilliseconds(unixTimeMilliseconds).DateTime;
4344
}
4445

4546
// Handle string tokens
@@ -54,7 +55,7 @@ public override bool CanConvert(Type typeToConvert)
5455
throw new JsonException($"Cannot convert null string to non-nullable type {typeToConvert.GetTypeName(pretty: true)}.");
5556
}
5657

57-
if (DateTime.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateTimeResult))
58+
if (DateTime.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dateTimeResult))
5859
return dateTimeResult;
5960

6061
throw new JsonException($"Unable to convert '{stringValue}' to {typeof(DateTime).GetTypeName(pretty: true)}.");
@@ -65,7 +66,13 @@ public override bool CanConvert(Type typeToConvert)
6566

6667
public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
6768
{
68-
writer.WriteNumberValue(((DateTime)value).Ticks);
69+
if (value == null)
70+
{
71+
writer.WriteNullValue();
72+
return;
73+
}
74+
75+
writer.WriteStringValue(((DateTime)value).ToString("o", CultureInfo.InvariantCulture));
6976
}
7077
}
7178
}

ReflectorNet/src/Convertor/Json/DateTimeOffsetJsonConverter.cs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
namespace com.IvanMurzak.ReflectorNet.Json
1414
{
1515
/// <summary>
16-
/// JsonConverter that handles conversion from JSON string values to DateTimeOffset type.
16+
/// JsonConverter that handles conversion from JSON string and number values to DateTimeOffset type.
1717
/// Supports nullable DateTimeOffset types and uses ISO 8601 format for writing.
18+
/// Number values are treated as Unix time in milliseconds.
1819
/// </summary>
1920
public class DateTimeOffsetJsonConverter : JsonConverter<object>
2021
{
@@ -35,11 +36,11 @@ public override bool CanConvert(Type typeToConvert)
3536
throw new JsonException($"Cannot convert null to non-nullable type {typeToConvert.GetTypeName(pretty: true)}.");
3637
}
3738

38-
// Handle numeric timestamps (Unix time)
39+
// Handle numeric timestamps (Unix time in milliseconds)
3940
if (reader.TokenType == JsonTokenType.Number)
4041
{
41-
var unixTime = reader.GetInt64();
42-
return DateTimeOffset.FromUnixTimeMilliseconds(unixTime);
42+
var unixTimeMilliseconds = reader.GetInt64();
43+
return DateTimeOffset.FromUnixTimeMilliseconds(unixTimeMilliseconds);
4344
}
4445

4546
// Handle string tokens
@@ -54,7 +55,7 @@ public override bool CanConvert(Type typeToConvert)
5455
throw new JsonException($"Cannot convert null string to non-nullable type {typeToConvert.GetTypeName(pretty: true)}.");
5556
}
5657

57-
if (DateTimeOffset.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateTimeOffsetResult))
58+
if (DateTimeOffset.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dateTimeOffsetResult))
5859
return dateTimeOffsetResult;
5960

6061
throw new JsonException($"Unable to convert '{stringValue}' to {typeof(DateTimeOffset).GetTypeName(pretty: true)}.");
@@ -65,7 +66,13 @@ public override bool CanConvert(Type typeToConvert)
6566

6667
public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
6768
{
68-
writer.WriteNumberValue(((DateTimeOffset)value).ToUnixTimeMilliseconds());
69+
if (value == null)
70+
{
71+
writer.WriteNullValue();
72+
return;
73+
}
74+
75+
writer.WriteStringValue(((DateTimeOffset)value).ToString("o", CultureInfo.InvariantCulture));
6976
}
7077
}
7178
}

0 commit comments

Comments
 (0)