diff --git a/Schema.Test/GeneratedDataEditorTests.cs b/Schema.Test/GeneratedDataEditorTests.cs index bb644c8..ff410a6 100644 --- a/Schema.Test/GeneratedDataEditorTests.cs +++ b/Schema.Test/GeneratedDataEditorTests.cs @@ -308,6 +308,22 @@ public void TestAMissingOrMisshapenValueThrows() Assert.ThrowsExactly(() => SchemaDataValues.GetString(record, "Missing")); } + [TestMethod] + public void TestANumberPastItsTypesRangeThrowsRatherThanReadingAsInfinity() + { + JsonObject record = new() + { + ["Speed"] = JsonNode.Parse("1e39"), + ["D"] = JsonNode.Parse("1e309"), + ["Position"] = JsonNode.Parse("[1, 1e39, 2]"), + }; + + Assert.ThrowsExactly(() => SchemaDataValues.GetFloat(record, "Speed")); + Assert.ThrowsExactly(() => SchemaDataValues.GetDouble(record, "D")); + Assert.ThrowsExactly(() => SchemaDataValues.GetVector3(record, "Position")); + Assert.AreEqual(1e39, SchemaDataValues.GetDouble(record, "Speed")); + } + [TestMethod] public void TestVectorsAndColoursRoundTrip() { diff --git a/Schema.Test/SchemaDataValidatorTypeTests.cs b/Schema.Test/SchemaDataValidatorTypeTests.cs index 5b42d74..b73610a 100644 --- a/Schema.Test/SchemaDataValidatorTypeTests.cs +++ b/Schema.Test/SchemaDataValidatorTypeTests.cs @@ -136,4 +136,60 @@ public void ATypeWithNoStoredFormIsReportedAsUnchecked() Assert.AreEqual(SchemaValidationSeverity.Warning, issues[0].Severity, type.DisplayName); } } + + // ---- floating-point overflow ----------------------------------------------- + + [TestMethod] + [DataRow("1e39")] + [DataRow("-1e39")] + public void AFloatPastFloatsRangeIsAnError(string value) + { + Collection issues = Validate(new Float(), value); + + Assert.HasCount(1, issues, string.Join("; ", issues)); + Assert.AreEqual(SchemaValidationSeverity.Error, issues[0].Severity); + Assert.Contains("range of Float", issues[0].Message); + } + + [TestMethod] + [DataRow("3.4028234663852886E+38")] + [DataRow("1e38")] + [DataRow("0")] + public void AFloatAtOrInsideFloatsRangeIsAccepted(string value) => + Assert.HasCount(0, Validate(new Float(), value)); + + [TestMethod] + public void ADoublePastDoublesRangeIsAnError() + { + Assert.HasCount(1, Validate(new Double(), "1e309")); + Assert.HasCount(0, Validate(new Double(), "1e39")); + } + + [TestMethod] + public void AFloatVectorComponentPastFloatsRangeIsAnError() + { + Collection issues = Validate(new Vector3(), "[1, 1e39, 2]"); + + Assert.HasCount(1, issues, string.Join("; ", issues)); + Assert.AreEqual("data.Value[1]", issues[0].Path); + } + + [TestMethod] + public void AColourComponentAndAQuantityPastFloatsRangeAreErrors() + { + Assert.HasCount(1, Validate(new ColorRGB(), "[0, 1e39, 0]")); + Assert.HasCount(1, Validate(Quantity("Mass"), "1e39")); + } + + [TestMethod] + public void ASemanticTypeOverAFloatIsHeldToFloatsRange() + { + SchemaClass schemaClass = ClassWith((schema, c) => + { + schema.AddSemanticType("Speed".As())!.SetUnderlyingType(new Float()); + c.AddMember("Value".As())!.SetType(new Semantic { SemanticTypeName = "Speed".As() }); + }); + + Assert.HasCount(1, SchemaDataValidator.ValidateData(schemaClass, """{ "Value": 1e39 }""", "data")); + } } diff --git a/Schema/Models/SchemaDataValidator.cs b/Schema/Models/SchemaDataValidator.cs index 3cb401f..ee4b666 100644 --- a/Schema/Models/SchemaDataValidator.cs +++ b/Schema/Models/SchemaDataValidator.cs @@ -262,7 +262,7 @@ private static void ValidateValue(BaseType type, JsonElement value, string path, break; case Float or Double: - Expect(value, JsonValueKind.Number, path, type, issues); + ExpectNumber(value, type, path, type, issues); break; case Types.String: @@ -399,7 +399,24 @@ private static void ExpectNumber(JsonElement value, BaseType storage, string pat return; } - Expect(value, JsonValueKind.Number, path, type, issues); + if (value.ValueKind != JsonValueKind.Number) + { + Expect(value, JsonValueKind.Number, path, type, issues); + return; + } + + // A number past what the storage holds reads back as an infinity rather than failing, so + // "1e39" in a Float passes every reader and arrives as one. Refuse it here, as the + // integral types already are and as schema validation already refuses such a default. + bool finite = value.TryGetDouble(out double number) && double.IsFinite(number); + if (represented is Float && !(finite && Math.Abs(number) <= float.MaxValue)) + { + issues.Add(Error(path, $"Expected a number within the range of Float (±{float.MaxValue.ToString("R", CultureInfo.InvariantCulture)}) for type '{type.DisplayName}' but found {value.GetRawText()}.")); + } + else if (represented is Double && !finite) + { + issues.Add(Error(path, $"Expected a number within the range of Double (±{double.MaxValue.ToString("R", CultureInfo.InvariantCulture)}) for type '{type.DisplayName}' but found {value.GetRawText()}.")); + } } private static void ValidateEnumValue(Types.Enum enumType, JsonElement value, string path, Collection issues) diff --git a/Schema/Runtime/SchemaDataValues.cs b/Schema/Runtime/SchemaDataValues.cs index e106855..5b3ac90 100644 --- a/Schema/Runtime/SchemaDataValues.cs +++ b/Schema/Runtime/SchemaDataValues.cs @@ -115,7 +115,7 @@ public static long GetLong(JsonObject record, string name) => /// The member's declared name. /// The value. public static float GetFloat(JsonObject record, string name) => - ReadNumber(record, name, static (JsonElement e, out float v) => e.TryGetSingle(out v)); + ReadNumber(record, name, static (JsonElement e, out float v) => e.TryGetSingle(out v) && float.IsFinite(v)); /// Writes a Float member. /// The record. @@ -128,7 +128,7 @@ public static float GetFloat(JsonObject record, string name) => /// The member's declared name. /// The value. public static double GetDouble(JsonObject record, string name) => - ReadNumber(record, name, static (JsonElement e, out double v) => e.TryGetDouble(out v)); + ReadNumber(record, name, static (JsonElement e, out double v) => e.TryGetDouble(out v) && double.IsFinite(v)); /// Writes a Double member. /// The record. @@ -342,7 +342,8 @@ private static float[] GetComponents(JsonObject record, string name, int count) { JsonNode? component = array[i]; if (component?.GetValueKind() != JsonValueKind.Number - || !JsonSerializer.SerializeToElement(component).TryGetSingle(out components[i])) + || !JsonSerializer.SerializeToElement(component).TryGetSingle(out components[i]) + || !float.IsFinite(components[i])) { throw Malformed(name, $"component {i} is not a number"); }