diff --git a/SpeckleAutomateDotnetExample/AutomateFunction.cs b/SpeckleAutomateDotnetExample/AutomateFunction.cs index 70221e0..7b746ca 100644 --- a/SpeckleAutomateDotnetExample/AutomateFunction.cs +++ b/SpeckleAutomateDotnetExample/AutomateFunction.cs @@ -1,17 +1,15 @@ using Objects; using Speckle.Automate.Sdk; -using Speckle.Automate.Sdk.Schema; using Speckle.Core.Api; -using Speckle.Core.Credentials; using Speckle.Core.Models; -using Speckle.Core.Models.Extensions; -using Speckle.Core.Transports; +using SpeckleAutomateDotnetExample; static class AutomateFunction { - public static string ADDED = "Added"; - public static string MODIFIED = "Modified"; - public static string DELETED = "Deleted"; + public static string ADDED = "ADDED"; + public static string MODIFIED = "MODIFIED"; + public static string DELETED = "DELETED"; + public static string UNCHANGED = "UNCHANGED"; public static async Task Run( AutomationContext automationContext, @@ -21,9 +19,12 @@ static class AutomateFunction Console.WriteLine("Starting execution"); _ = typeof(ObjectsKit).Assembly; // INFO: Force objects kit to initialize - // get the test and release branch name - var testBranchName = automationContext.AutomationRunData.BranchName; - var releaseBranchName = testBranchName.Replace("/testing", "/release"); + double tolerance = functionInputs.Tolerance; + + // get the testing and release branches + string testBranchName = automationContext.AutomationRunData.BranchName; + string releaseBranchName = testBranchName.Replace("/testing", "/release"); + Console.WriteLine($"Comparing {testBranchName} against {releaseBranchName}"); Branch? releaseBranch = await automationContext.SpeckleClient .BranchGet(automationContext.AutomationRunData.ProjectId, releaseBranchName, 1) .ConfigureAwait(false); @@ -32,251 +33,240 @@ static class AutomateFunction throw new Exception("Release branch was null"); } + // get the release branch latest commit Commit releaseCommit = releaseBranch.commits.items.First(); if (releaseCommit is null) { throw new Exception("Release branch has no commits"); } - var tolerance = functionInputs.Tolerance; - - Console.WriteLine($"Comparing {testBranchName} against {releaseBranchName}"); - - // get the test and release commits + // get the test and release commit base Console.WriteLine("Receiving test version"); - Base? testingCommitObject = await automationContext.ReceiveVersion(); + Base testingCommitObject = await automationContext.ReceiveVersion(); Console.WriteLine("Received test version: " + testingCommitObject); Console.WriteLine("Receiving release version"); - ServerTransport serverTransport = new ServerTransport( - automationContext.SpeckleClient.Account, - automationContext.AutomationRunData.ProjectId + Base releaseCommitObject = await Utils.RecieveVersionAsync( + releaseCommit.id, + automationContext ); - Base? releaseCommitObject = await Operations - .Receive( - ( - await automationContext.SpeckleClient - .CommitGet(automationContext.AutomationRunData.ProjectId, releaseCommit.id) - .ConfigureAwait(continueOnCapturedContext: false) - ).referencedObject, - serverTransport, - new MemoryTransport() - ) - .ConfigureAwait(continueOnCapturedContext: false); - if (releaseCommitObject == null) - { - throw new Exception("Commit root object was null"); - } Console.WriteLine("Received release version: " + releaseCommitObject); - // flatten both commits - IEnumerable releaseCommitObjects = releaseCommitObject.Flatten(); - IEnumerable testCommitObjects = testingCommitObject.Flatten(); - var releaseCommitObjectsDict = new Dictionary(); - foreach (var releaseObject in releaseCommitObjects) + // Create dictionaries by appId (or speckle id if no appId exists) for the release and testing commits + // note that it is possible for multiple objects to have the same app id + // (eg mapped schema objects have the same id as their parent geometry) + // and it is also possible for multiple objects (with no app id) to have the same speckle id + // (eg the same mesh sent from gh multiple times) + Dictionary> releaseCommitAppIdDict = new(); + Dictionary> releaseCommitSpeckleIdDict = new(); + Dictionary> testingCommitAppIdDict = new(); + Dictionary> testingCommitSpeckleIdDict = new(); + Utils.CreateDictionaryFromBaseById( + releaseCommitObject, + out releaseCommitAppIdDict, + out releaseCommitSpeckleIdDict, + out int releaseObjectCount + ); + Console.WriteLine( + $"Found {releaseObjectCount} objects in RELEASE with {releaseCommitAppIdDict.Count} unique applicationIds and {releaseCommitSpeckleIdDict} unique speckle ids (for objects with no application id)." + ); + Utils.CreateDictionaryFromBaseById( + testingCommitObject, + out testingCommitAppIdDict, + out testingCommitSpeckleIdDict, + out int testingObjectCount + ); + Console.WriteLine( + $"Found {testingObjectCount} objects in TESTING with {testingCommitAppIdDict.Count} unique applicationIds and {testingCommitSpeckleIdDict} unique speckle ids (for objects with no application id)." + ); + + // COMPARE COMMIT OBJECTS WITH APPLICATION IDS + // and store in hash lists where each object is (id, appId, type), and for modified, and additional string of property changes. + HashSet> deletedAppIdObjects = new(); + HashSet> unchangedAppIdObjects = new(); + HashSet> addedAppIdObjects = new(); + HashSet> modifiedAppIdObjects = new(); + + // first find deleted objects in the testing commit and remove their keys from the release commit dict + foreach (string releaseAppId in releaseCommitAppIdDict.Keys) { - if ( - releaseObject.applicationId != null - && !releaseCommitObjectsDict.ContainsKey(releaseObject.applicationId) - ) + if (!testingCommitAppIdDict.ContainsKey(releaseAppId)) { - releaseCommitObjectsDict.Add(releaseObject.applicationId, releaseObject); + releaseCommitAppIdDict[releaseAppId].ForEach( + o => + deletedAppIdObjects.Add( + new Tuple(o.id, releaseAppId, o.speckle_type) + ) + ); + releaseCommitAppIdDict.Remove(releaseAppId); } } - Console.WriteLine( - $"Found {releaseCommitObjects.Count()} objects in release version" - ); - Console.WriteLine($"Found {testCommitObjects.Count()} objects in release version"); - - // compare objects - int unchangedCount = 0; - var addedList = new List>(); - var modifiedList = new List>(); - foreach (Base testObject in testCommitObjects) + // then find unchanged, added, and modified objects by iterating through testing commit app ids + foreach (string testingAppId in testingCommitAppIdDict.Keys) { - if ( - testObject.applicationId != null - && releaseCommitObjectsDict.ContainsKey(testObject.applicationId) - ) + List testObjects = testingCommitAppIdDict[testingAppId]; + + // test for added objects + if (!releaseCommitAppIdDict.ContainsKey(testingAppId)) { - Base releaseObject = releaseCommitObjectsDict[testObject.applicationId]; + testObjects.ForEach( + o => + addedAppIdObjects.Add( + new Tuple(o.id, testingAppId, o.speckle_type) + ) + ); + } + else + { + List releaseObjects = releaseCommitAppIdDict[testingAppId]; - // if these have the same hash, no properties have changed - if (testObject.id == releaseObject.id) + // test for unchanged objects + // by filtering the testing and release objects with matching speckle ids + Utils + .FilterListsBySpeckleIdMatch(testObjects, releaseObjects) + .ForEach(o => unchangedAppIdObjects.Add(o)); + + // for remaining objects, determine deleted objects + // and then compare them in order (assume modified) and handle leftovers (added) + // this is imperfect, as there's a chance we are not comparing the correct objects. + if (releaseObjects.Count > testObjects.Count) { - unchangedCount++; + for (int i = releaseObjects.Count - 1; i >= testObjects.Count; i--) + { + deletedAppIdObjects.Add( + new Tuple( + releaseObjects[i].id, + testingAppId, + releaseObjects[i].speckle_type + ) + ); + releaseObjects.RemoveAt(i); + } } - // if ids are different, find property differences - else + + for (int i = 0; i < testObjects.Count; i++) { - var diffDictionary = new Dictionary(); - Dictionary releaseObjectPropDict = - releaseObject.GetMembers(); - Dictionary testObjectPropDict = testObject.GetMembers(); - foreach (var entry in testObjectPropDict) - { - if (releaseObjectPropDict.ContainsKey(entry.Key)) - { - try - { - bool changed = !Equals(entry.Value, releaseObjectPropDict[entry.Key]); - if (changed) - { - object? releaseValue = releaseObjectPropDict[entry.Key]; - object? testValue = entry.Value; - string diff = $"Property ({entry.Key}) changed"; - if ( - releaseValue is not null - && testValue is not null - && !releaseValue.GetType().Equals(testValue.GetType()) - ) - { - diff += - $" from ({releaseObjectPropDict[entry.Key]}) to ({entry.Value})"; - } + Base testObject = testObjects[i]; - if (!diffDictionary.ContainsKey(entry.Key)) - { - diffDictionary.Add(entry.Key, diff); - } - } - } - catch { } - releaseObjectPropDict.Remove(entry.Key); - } - else - { - if (!diffDictionary.ContainsKey(entry.Key)) - { - diffDictionary.Add(entry.Key, ADDED); - } - } - } - - // check if there are any props left on the release object - these were missing in the test object - foreach (var entry in releaseObjectPropDict) + if (i < releaseObjectCount) { - if (!diffDictionary.ContainsKey(entry.Key)) - { - diffDictionary.Add(entry.Key, DELETED); - } - } + Base releaseObject = releaseObjects[i]; - // add the diff dict info to the object - if (diffDictionary.Count > 0) - { + // compare object properties to determine changes + List addedProps = new(); + List deletedProps = new(); + List> modifiedProps = new(); + Utils.CompareBaseProperties( + testObject, + releaseObject, + out addedProps, + out deletedProps, + out modifiedProps + ); var sb = new System.Text.StringBuilder(); - foreach (var entry in diffDictionary) - { - sb.AppendLine($"{entry.Key}: {entry.Value}. "); - } - - modifiedList.Add( - new Tuple( + addedProps.ForEach(s => sb.AppendLine($"{ADDED} prop ({s}).")); + deletedProps.ForEach(s => sb.AppendLine($"{DELETED} prop ({s}).")); + modifiedProps.ForEach( + t => sb.AppendLine($"{MODIFIED} ({t.Item2}) of prop ({t.Item1})") + ); + modifiedAppIdObjects.Add( + new Tuple( testObject.id, + testObject.applicationId, testObject.speckle_type, sb.ToString() ) ); - //automationContext.AttachWarningToObjects( MODIFIED, new List() { testObject.id }, sb.ToString()); + } + // remaining test objects are considered added + else + { + addedAppIdObjects.Add( + new Tuple( + testObject.id, + testingAppId, + testObject.speckle_type + ) + ); } } - - releaseCommitObjectsDict.Remove(testObject.applicationId); - } - else - { - // we're skipping objects without an applicationId for now, since we're doing so in the release commit - if (!string.IsNullOrEmpty(testObject.applicationId)) - { - //automationContext.AttachInfoToObjects(ADDED, new List() { testObject.id }); - addedList.Add( - new Tuple(testObject.id, testObject.speckle_type) - ); - } } } - // if there are any remaining release commit objects, this indicates missing objects in the test run. - var deletedList = new List>(); - foreach (var entry in releaseCommitObjectsDict) + // COMPARE COMMIT OBJECTS WITHOUT APPLICATION IDS USING SPECKLE IDS + // since we only have 1 parameter of comparison, we can rule out any matching speckle ids as unchanged. + // for all other objects, mark as modified, and indicate quantity of any added or deleted objects + // store modified in hash lists of (id, type) + HashSet> unchangedSpeckleIdObjects = new(); + HashSet> changedSpeckleIdObjects = new(); + + // first filter out matching speckle ids + List flattenedTestingSpeckleIdDict = testingCommitSpeckleIdDict.Values + .SelectMany(o => o) + .ToList(); + List flattenedReleaseSpeckleIdDict = releaseCommitSpeckleIdDict.Values + .SelectMany(o => o) + .ToList(); + Utils + .FilterListsBySpeckleIdMatch( + flattenedTestingSpeckleIdDict, + flattenedReleaseSpeckleIdDict + ) + .ForEach(o => unchangedSpeckleIdObjects.Add(o)); + + // then store all remaining testing objects as changed + flattenedTestingSpeckleIdDict.ForEach( + o => changedSpeckleIdObjects.Add(new Tuple(o.id, o.speckle_type)) + ); + + // calculate count difference + int speckleIdObjectCountDifference = + flattenedTestingSpeckleIdDict.Count - flattenedReleaseSpeckleIdDict.Count; + + // REPORT ALL DIFF RESULTS FOR APP IDS AND SPECKLE IDS + // mark run succeeded if there are no added, modified, or deleted app id objects, and no changed speckle id objects + // mark run failed otherwise + if ( + addedAppIdObjects.Count + + deletedAppIdObjects.Count + + modifiedAppIdObjects.Count + + changedSpeckleIdObjects.Count + == 0 + ) { - deletedList.Add(new Tuple(entry.Key, entry.Value.speckle_type)); - } - - // mark run failed if there are any added, modified, or deleted objects and report - if (addedList.Count + deletedList.Count + modifiedList.Count > 0) - { - automationContext.MarkRunFailed( - $"Run failed due to {addedList.Count} ADDED, {modifiedList.Count} MODIFIED, and {deletedList.Count} DELETED objects compared to the release commit ({unchangedCount} objects were unchanged)." - ); - - addedList.ForEach( - o => Console.WriteLine($"ADDED object: id( {o.Item1} ), type( {o.Item2} )") - ); - deletedList.ForEach( - o => Console.WriteLine($"DELETED object: id( {o.Item1} ), type( {o.Item2} )") - ); - modifiedList.ForEach( - o => - Console.WriteLine( - $"MODIFIED object: id( {o.Item1} ), type( {o.Item2} ), changed props:( {o.Item3} )" - ) - ); - - automationContext.AttachWarningToObjects( - MODIFIED, - modifiedList.Select(o => o.Item1).ToList() - ); + automationContext.MarkRunSuccess($"Run passed with no changes to objects."); } else { - automationContext.MarkRunSuccess( - $"Run passed with {unchangedCount} unchanged objects." + automationContext.MarkRunFailed( + $"Run failed due to: {addedAppIdObjects.Count} {ADDED}, {modifiedAppIdObjects.Count} {MODIFIED}, and {deletedAppIdObjects.Count} {DELETED} objects WITH APP IDS, and {(speckleIdObjectCountDifference > 0 ? $"{speckleIdObjectCountDifference} {ADDED}" : $"{Math.Abs(speckleIdObjectCountDifference)} {DELETED}")} and {changedSpeckleIdObjects.Count} CHANGED objects WITHOUT APP IDS compared to the release commit. " ); - } - } - public static bool Equals(T a, T b) - { - switch (a) - { - case Base o: - return b is Base bBase ? o.id == bBase.id : false; - case List aList: - if (b is List bList && aList.Count == bList.Count) - { - for (int i = 0; i < aList.Count; i++) - { - if (!Equals(aList[i], bList[i])) - { - return false; - } - } - return true; - } - return false; - case Dictionary aDictionary: - if ( - b is Dictionary bDictionary - && aDictionary.Count == bDictionary.Count - ) - { - foreach (var entry in aDictionary) - { - if ( - !bDictionary.ContainsKey(entry.Key) - || !Equals(entry.Value, bDictionary[entry.Key]) - ) - { - return false; - } - } - return true; - } - return false; - default: - return EqualityComparer.Default.Equals(a, b); + foreach (var added in addedAppIdObjects) + { + Console.WriteLine( + $"{ADDED} {added.Item3} object: id( {added.Item1} ), appId: {added.Item2}" + ); + } + + foreach (var deleted in deletedAppIdObjects) + { + Console.WriteLine( + $"{DELETED} {deleted.Item3} object: id( {deleted.Item1} ), appId: {deleted.Item2}" + ); + } + + foreach (var modified in modifiedAppIdObjects) + { + Console.WriteLine( + $"{MODIFIED} {modified.Item3} object: id( {modified.Item1} ), appId: {modified.Item2}. Changed props: {modified.Item4}" + ); + } + + foreach (var changed in changedSpeckleIdObjects) + { + Console.WriteLine($"CHANGED {changed.Item2} object: id( {changed.Item1} )"); + } } } } diff --git a/SpeckleAutomateDotnetExample/FunctionInputs.cs b/SpeckleAutomateDotnetExample/FunctionInputs.cs index 061c4af..864a9e9 100644 --- a/SpeckleAutomateDotnetExample/FunctionInputs.cs +++ b/SpeckleAutomateDotnetExample/FunctionInputs.cs @@ -7,7 +7,7 @@ using System.ComponentModel.DataAnnotations; /// are valid and match the required schema. struct FunctionInputs { - [Required] + //[Required] public double Tolerance; //public string Exclusions; diff --git a/SpeckleAutomateDotnetExample/Utils.cs b/SpeckleAutomateDotnetExample/Utils.cs new file mode 100644 index 0000000..12ef64b --- /dev/null +++ b/SpeckleAutomateDotnetExample/Utils.cs @@ -0,0 +1,275 @@ +using Speckle.Automate.Sdk; +using Speckle.Automate.Sdk.Schema; +using Speckle.Core.Api; +using Speckle.Core.Models; +using Speckle.Core.Models.Extensions; +using Speckle.Core.Transports; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SpeckleAutomateDotnetExample +{ + internal class Utils + { + /// + /// Receives a commit object from the same project and account as the + /// + /// The id of the commit to receive + /// The Automation context + /// + /// + public static async Task RecieveVersionAsync( + string commitId, + AutomationContext context + ) + { + ServerTransport serverTransport = new ServerTransport( + context.SpeckleClient.Account, + context.AutomationRunData.ProjectId + ); + + Base? receivedCommitObject = await Operations + .Receive( + ( + await context.SpeckleClient + .CommitGet(context.AutomationRunData.ProjectId, commitId) + .ConfigureAwait(continueOnCapturedContext: false) + ).referencedObject, + serverTransport, + new MemoryTransport() + ) + .ConfigureAwait(continueOnCapturedContext: false); + + if (receivedCommitObject == null) + { + throw new Exception("Commit root object was null"); + } + + return receivedCommitObject; + } + + /// + /// Creates Dictionaries from a commit Base, using applicationId if available or speckle id if no applicationId exists. + /// + /// The commit Base to create dictionaries from + /// The dictionary of commit objects sorted by applicationId + /// The dictionary of commit objects without applicationIds, sorted by speckle id + public static void CreateDictionaryFromBaseById( + Base commit, + out Dictionary> appIdDict, + out Dictionary> speckleIdDict, + out int objectCount + ) + { + IEnumerable commitObjects = commit.Flatten(); + objectCount = commitObjects.Count(); + + appIdDict = new Dictionary>(); + speckleIdDict = new Dictionary>(); + foreach (var commitObject in commitObjects) + { + if (!string.IsNullOrWhiteSpace(commitObject.applicationId)) + { + if (appIdDict.ContainsKey(commitObject.applicationId)) + { + appIdDict[commitObject.applicationId].Add(commitObject); + } + else + { + appIdDict.Add( + commitObject.applicationId, + new List() { commitObject } + ); + } + } + else + { + if (speckleIdDict.ContainsKey(commitObject.id)) + { + speckleIdDict[commitObject.id].Add(commitObject); + } + else + { + speckleIdDict.Add(commitObject.id, new List() { commitObject }); + } + } + } + } + + /// + /// Filters two lists by removing all objects from with a matching speckle id in + /// + /// The list to filter + /// The list to filter against + /// A list of matches found in + public static List> FilterListsBySpeckleIdMatch( + List setA, + List setB + ) + { + var matches = new List>(); + + for (int i = setA.Count - 1; i >= 0; i--) + { + var testObject = setA[i]; + for (int j = setB.Count - 1; j >= 0; j--) + { + var releaseObject = setB[j]; + + // if a match was found, remove from both lists and add to matches + if (testObject.id == releaseObject.id) + { + matches.Add( + new Tuple( + testObject.id, + testObject.applicationId, + testObject.speckle_type + ) + ); + setA.RemoveAt(i); + setB.RemoveAt(j); + } + } + } + return matches; + } + + /// + /// Compares the properties of against + /// + /// + /// + /// Properties on that do not exist on + /// Properties on that do not exist on + /// Properties on that have a different value on . The second string is the category of modification (eg count/primitive/base) + public static void CompareBaseProperties( + Base a, + Base b, + out List addedProps, + out List deletedProps, + out List> modifiedProps + ) + { + addedProps = new List(); + deletedProps = new List(); + modifiedProps = new List>(); + Dictionary bObjectPropDict = b.GetMembers(); + Dictionary aObjectPropDict = a.GetMembers(); + foreach (KeyValuePair entry in aObjectPropDict) + { + if (bObjectPropDict.ContainsKey(entry.Key)) + { + bool changed = !IsEqual( + entry.Value, + bObjectPropDict[entry.Key], + out string category + ); + if (changed) + { + modifiedProps.Add(new Tuple(entry.Key, category)); + } + + bObjectPropDict.Remove(entry.Key); + } + else + { + addedProps.Add(entry.Key); + } + } + + // check if there are any props left on the b - these were missing in the a + foreach (var entry in bObjectPropDict) + { + deletedProps.Add(entry.Key); + } + } + + /// + /// Determines equality between two objects + /// + /// + /// + /// + /// The category of the modification, if objects are not equal + /// + public static bool IsEqual(T a, T b, out string category) + { + category = "Value"; + switch (a) + { + case Base aBase: + if (b is Base bBase && bBase.speckle_type == aBase.speckle_type) + { + return aBase.id == bBase.id; + } + else + { + category = "Type"; + return false; + } + + case List aList: + if (b is List bList) + { + if (aList.Count != bList.Count) + { + category = "Count"; + return false; + } + + for (int i = 0; i < aList.Count; i++) + { + if (!Equals(aList[i], bList[i])) + { + return false; + } + } + return true; + } + else + { + category = "Type"; + return false; + } + + case Dictionary aDictionary: + if (b is Dictionary bDictionary) + { + if (aDictionary.Count != bDictionary.Count) + { + category = "Count"; + return false; + } + foreach (var entry in aDictionary) + { + if ( + !bDictionary.ContainsKey(entry.Key) + || !Equals(entry.Value, bDictionary[entry.Key]) + ) + { + return false; + } + } + return true; + } + else + { + category = "Type"; + return false; + } + default: + try + { + return EqualityComparer.Default.Equals(a, b); + } + catch + { + return false; + } + } + } + } +}