diff --git a/.ci/code/Versioning_Test/Helpers/TopLevelFieldFromJson.cs b/.ci/code/Versioning_Test/Helpers/TopLevelFieldFromJson.cs new file mode 100644 index 0000000..68da6cc --- /dev/null +++ b/.ci/code/Versioning_Test/Helpers/TopLevelFieldFromJson.cs @@ -0,0 +1,132 @@ +/* + * This file is part of the Buildings and Habitats object Model (BHoM) + * Copyright (c) 2015 - 2026, the respective contributors. All rights reserved. + * + * Each contributor holds copyright over their respective contributions. + * The project versioning (Git) records all such contribution source information. + * + * + * The BHoM is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3.0 of the License, or + * (at your option) any later version. + * + * The BHoM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this code. If not, see . + */ + + +using System.Text; + +namespace BH.Test.Versioning +{ + public static partial class Helpers + { + /*************************************/ + /**** Public Methods ****/ + /*************************************/ + + // Reads a top-level field from a serialised record. Depth-aware so an identically named + // field on a nested fragment is not mistaken for the record's own. + public static string TopLevelFieldFromJson(string json, string field) + { + if (string.IsNullOrEmpty(json) || string.IsNullOrEmpty(field)) + return null; + + int depth = 0; + bool inString = false; + bool escaped = false; + int stringStart = -1; + + for (int i = 0; i < json.Length; i++) + { + char c = json[i]; + + if (inString) + { + if (escaped) + escaped = false; + else if (c == '\\') + escaped = true; + else if (c == '"') + { + inString = false; + + // A string is a key only when a colon follows it; a value never is. + bool isWantedName = i - stringStart - 1 == field.Length + && string.CompareOrdinal(json, stringStart + 1, field, 0, field.Length) == 0; + + if (depth == 1 && isWantedName) + { + int j = i + 1; + while (j < json.Length && (json[j] == ' ' || json[j] == '\t')) + j++; + + if (j < json.Length && json[j] == ':') + return StringValueAt(json, j + 1); + } + } + + continue; + } + + if (c == '"') + { + inString = true; + escaped = false; + stringStart = i; + } + else if (c == '{' || c == '[') + depth++; + else if (c == '}' || c == ']') + depth--; + } + + return null; + } + + /*************************************/ + /**** Private Methods ****/ + /*************************************/ + + private static string StringValueAt(string json, int from) + { + int i = from; + while (i < json.Length && (json[i] == ' ' || json[i] == '\t')) + i++; + + // Present but not a string. Returning null leaves the caller on its existing path. + if (i >= json.Length || json[i] != '"') + return null; + + StringBuilder value = new StringBuilder(); + bool escaped = false; + + for (i++; i < json.Length; i++) + { + char c = json[i]; + + if (escaped) + { + value.Append(c); + escaped = false; + } + else if (c == '\\') + escaped = true; + else if (c == '"') + return value.ToString(); + else + value.Append(c); + } + + return null; + } + + /*************************************/ + } +} diff --git a/.ci/code/Versioning_Test/Verify/FromJson.cs b/.ci/code/Versioning_Test/Verify/FromJson.cs index ebc508a..f56a67b 100644 --- a/.ci/code/Versioning_Test/Verify/FromJson.cs +++ b/.ci/code/Versioning_Test/Verify/FromJson.cs @@ -245,14 +245,53 @@ public static TestResult FromJsonItem(string json, bool isMethod) if (message == "") return Engine.Test.Create.PassResult(description); - else - return new TestResult - { - Description = description, - Status = TestStatus.Error, - Message = message, - Information = Engine.Base.Query.CurrentEvents().Select(x => x.ToEventMessage()).ToList() - }; + + List information = Engine.Base.Query.CurrentEvents().Select(x => x.ToEventMessage()).ToList(); + + ITestInformation provenance = ProvenanceEvent(json, isMethod); + if (provenance != null) + information.Add(provenance); + + return new TestResult + { + Description = description, + Status = TestStatus.Error, + Message = message, + Information = information + }; + } + + /*************************************/ + + // Surfaces the record's declaring assembly for the checker, which reads this TestResult + // and never the dataset file. The wording is a contract with CI_Toolkit's VersioningRunner: + // + // Object declared in "" + // + // The quoted part holds the assembly alone because a closed generic type name contains + // commas, so a comma-delimited form cannot represent one. + private static ITestInformation ProvenanceEvent(string json, bool isMethod) + { + // A method record already carries its declaring assembly, and its top-level type is + // System.Reflection.MethodBase, so emitting this for one would state something untrue. + if (isMethod) + return null; + + string declaringAssembly = Helpers.TopLevelFieldFromJson(json, "_asm"); + if (string.IsNullOrWhiteSpace(declaringAssembly)) + return null; + + string declaringType = Helpers.TopLevelFieldFromJson(json, "_t"); + if (string.IsNullOrWhiteSpace(declaringType)) + return null; + + return new EventMessage + { + // Not a failure. Note this does not hide it from FullMessage, which defaults to + // minSeverity Pass. + Status = TestStatus.Pass, + Message = $"Object {declaringType} declared in \"{declaringAssembly}\"" + }; } /*************************************/