diff --git a/.gitignore b/.gitignore index 25ed03be..f75bed67 100644 --- a/.gitignore +++ b/.gitignore @@ -223,6 +223,8 @@ _pkginfo.txt *.[Cc]ache # but keep track of directories ending in .cache !?*.[Cc]ache/ +# C# Dev Kit cache files +*.lscache # Others ClientBin/ diff --git a/src/PAModel/CanvasDocument.cs b/src/PAModel/CanvasDocument.cs index 5b196cd9..e22a799b 100644 --- a/src/PAModel/CanvasDocument.cs +++ b/src/PAModel/CanvasDocument.cs @@ -16,22 +16,22 @@ namespace Microsoft.PowerPlatform.Formulas.Tools; /// -/// Represents a PowerApps document. This can be save/loaded from a MsApp or Source representation. -/// This is a full in-memory representation of the msapp file. +/// Represents a PowerApps document. This can be save/loaded from a MsApp or Source representation. +/// This is a full in-memory representation of the msapp file. /// public class CanvasDocument { /// - /// Current source format version. + /// Current source format version. /// public static Version CurrentSourceVersion => SourceSerializer.CurrentSourceVersion; // Rules for CanvasDocument - // - Save/Load must faithfully roundtrip an msapp exactly. - // - this is an in-memory representation - so it must parse/shard everything on load. - // - Save should not mutate any state. + // - Save/Load must faithfully roundtrip an msapp exactly. + // - this is an in-memory representation - so it must parse/shard everything on load. + // - Save should not mutate any state. - // Track all unknown "files". Ensures round-tripping isn't lossy. + // Track all unknown "files". Ensures round-tripping isn't lossy. // Only contains files of FileKind.Unknown internal Dictionary _unknownFiles = new(); @@ -42,7 +42,7 @@ public class CanvasDocument internal EditorStateStore _editorStateStore; internal TemplateStore _templateStore; - // Various data sources + // Various data sources // This is references\dataSources.json // Also includes entries for DataSources made from a DataComponent // Key is parent entity name (datasource name for non cds data sources) @@ -76,7 +76,7 @@ public class CanvasDocument internal IDictionary _dataSourceReferences; // Extracted from _properties.LibraryDependencies - // Must preserve server ordering. + // Must preserve server ordering. internal ComponentDependencyInfo[] _libraryReferences; internal FileEntry _logoFile; @@ -84,7 +84,7 @@ public class CanvasDocument // Save for roundtripping. internal Entropy _entropy = new(); - // Checksum from existing msapp. + // Checksum from existing msapp. internal ChecksumJson _checksum; // Track all asset files, key is file name @@ -102,7 +102,7 @@ public class CanvasDocument #region Save/Load /// - /// Load an .msapp file for a Canvas Document. + /// Load an .msapp file for a Canvas Document. /// /// path to an .msapp file /// A tuple of the document and errors and warnings. If there are errors, the document is null. @@ -178,32 +178,28 @@ public ErrorContainer SaveToSources(string pathToSourceDirectory, string verifyO var errors = new ErrorContainer(); Wrapper(() => SourceSerializer.SaveAsSource(this, pathToSourceDirectory, errors), errors); - // Test that we can repack if (!errors.HasErrors && verifyOriginalPath != null) { - (var msApp2, var errors2) = LoadFromSources(pathToSourceDirectory); + var (msApp2, errors2) = LoadFromSources(pathToSourceDirectory); if (errors2.HasErrors) { errors2.PostUnpackValidationFailed(); return errors2; } - using (var temp = new TempFile()) + using var temp = new TempFile(); + errors2 = msApp2.SaveToMsAppValidation(temp.FullPath); + if (errors2.HasErrors) { - errors2 = msApp2.SaveToMsAppValidation(temp.FullPath); - if (errors2.HasErrors) - { - errors2.PostUnpackValidationFailed(); - return errors2; - } + errors2.PostUnpackValidationFailed(); + return errors2; + } - var ok = MsAppTest.Compare(verifyOriginalPath, temp.FullPath, TextWriter.Null, errors2); - if (!ok) - { - errors2.PostUnpackValidationFailed(); - return errors2; - } + if (!MsAppTest.Compare(verifyOriginalPath, temp.FullPath, errors2)) + { + errors2.PostUnpackValidationFailed(); + return errors2; } } @@ -218,7 +214,7 @@ public static (CanvasDocument, ErrorContainer) MakeFromSources(string appName, s #endregion - // Wrapper to ensure consistent invariants between loading a document, exception handling, and returning errors. + // Wrapper to ensure consistent invariants between loading a document, exception handling, and returning errors. private static CanvasDocument Wrapper(Func worker, ErrorContainer errors) { try @@ -320,7 +316,7 @@ internal CanvasDocument(CanvasDocument other) _localAssetInfoJson = other._localAssetInfoJson.JsonClone(); } - // iOrder is used to preserve ordering value for round-tripping. + // iOrder is used to preserve ordering value for round-tripping. internal void AddDataSourceForLoad(DataSourceEntry ds, int? order = null) { // Key is parent entity name @@ -376,7 +372,7 @@ internal void ApplyAfterMsAppLoadTransforms(ErrorContainer errors) var componentInstanceTransform = new ComponentInstanceTransform(errors); var componentDefTransform = new ComponentDefinitionTransform(errors, _templateStore, componentInstanceTransform); - // Transform component definitions and populate template set of component instances that need updates + // Transform component definitions and populate template set of component instances that need updates foreach (var ctrl in _components) { AddComponentDefaults(ctrl.Value, templateDefaults); @@ -433,7 +429,7 @@ internal void ApplyBeforeMsAppWriteTransforms(ErrorContainer errors) var componentInstanceTransform = new ComponentInstanceTransform(errors); var componentDefTransform = new ComponentDefinitionTransform(errors, _templateStore, componentInstanceTransform); - // Transform component definitions and populate template set of component instances that need updates + // Transform component definitions and populate template set of component instances that need updates foreach (var ctrl in _components) { componentDefTransform.BeforeWrite(ctrl.Value); @@ -466,15 +462,16 @@ private void AddComponentDefaults(BlockNode topParent, Dictionary GetImportedComponents() { var set = new HashSet(); @@ -679,10 +675,10 @@ private void RestoreAssetFilePaths(ErrorContainer errors) } } - // Helper for traversing and ensuring unique control names. + // Helper for traversing and ensuring unique control names. internal class UniqueControlNameVisitor { - // Control names are case sensitive. + // Control names are case sensitive. private readonly Dictionary _names = new(StringComparer.Ordinal); private readonly ErrorContainer _errors; @@ -693,7 +689,7 @@ public UniqueControlNameVisitor(ErrorContainer errors) public void Visit(BlockNode node) { - // Ignore test templates here. + // Ignore test templates here. // Test templates have control-like syntax, but allowed to repeat names: // Step4 As TestStep: if (AppTestTransform.IsTestSuite(node.Name.Kind.TypeName)) diff --git a/src/PAModel/Checksum/ChecksumMaker.cs b/src/PAModel/Checksum/ChecksumMaker.cs index ccd16014..c702fe9e 100644 --- a/src/PAModel/Checksum/ChecksumMaker.cs +++ b/src/PAModel/Checksum/ChecksumMaker.cs @@ -70,8 +70,7 @@ internal static byte[] ChecksumFile(string filename, byte[] bytes) if (filename.EndsWith(".json", StringComparison.OrdinalIgnoreCase) || filename.EndsWith(".sarif", StringComparison.OrdinalIgnoreCase)) { - var key = ChecksumJsonFile(filename, bytes); - return key; + return ChecksumJsonFile(filename, bytes); } else { @@ -164,20 +163,14 @@ private static byte[] ChecksumJsonFile(string filename, byte[] bytes) where T : IHashMaker, new() { var s = new ReadOnlyMemory(bytes); - using (var doc = JsonDocument.Parse(s)) - { - var je = doc.RootElement; - - var ctx = new Context { Filename = filename }; + using var doc = JsonDocument.Parse(s); + var je = doc.RootElement; - using (var hash = new T()) - { - ChecksumJson(ctx, hash, je); + var ctx = new Context { Filename = filename }; - var key = hash.GetFinalValue(); - return key; - } - } + using var hash = new T(); + ChecksumJson(ctx, hash, je); + return hash.GetFinalValue(); } internal static string ChecksumToString(byte[] bytes) diff --git a/src/PAModel/Entropy.cs b/src/PAModel/Entropy.cs index 09a7dff7..1e33e6e6 100644 --- a/src/PAModel/Entropy.cs +++ b/src/PAModel/Entropy.cs @@ -90,12 +90,12 @@ internal class PropertyEntropy public bool? WasLocalDatabaseReferencesEmpty { get; set; } /// - /// Tracks whether TestStepsMetadata is empty or not. + /// Indicates when a Test has missing steps metadata property; which happens UX creates a new test, but the user doesn't add a step. /// - public bool? DoesTestStepsMetadataExist { get; set; } + public HashSet AppTestsMissingStepsMetadata { get; set; } = new HashSet(StringComparer.Ordinal); // Key is connection id, value is connection instance id - public Dictionary LocalConnectionIDReferences { get; set; } = new Dictionary(StringComparer.Ordinal); + public Dictionary LocalConnectionIDReferences { get; set; } = new Dictionary(); // Key is test rule, value is test screen id without Screen name public Dictionary RuleScreenIdWithoutScreen { get; set; } = new Dictionary(StringComparer.Ordinal); diff --git a/src/PAModel/MsAppTest.cs b/src/PAModel/MsAppTest.cs index 1d5a0181..1c06bb8c 100644 --- a/src/PAModel/MsAppTest.cs +++ b/src/PAModel/MsAppTest.cs @@ -14,41 +14,31 @@ namespace Microsoft.PowerPlatform.Formulas.Tools; internal class MsAppTest { - public static bool Compare(CanvasDocument doc1, CanvasDocument doc2, TextWriter log) + public static bool Compare(CanvasDocument doc1, CanvasDocument doc2) { - using (var temp1 = new TempFile()) - using (var temp2 = new TempFile()) - { - doc1.SaveToMsApp(temp1.FullPath); - doc2.SaveToMsApp(temp2.FullPath); - return Compare(temp1.FullPath, temp2.FullPath, log); - } + using var temp1 = new TempFile(); + using var temp2 = new TempFile(); + doc1.SaveToMsApp(temp1.FullPath); + doc2.SaveToMsApp(temp2.FullPath); + return Compare(temp1.FullPath, temp2.FullPath); } [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "")] public static bool MergeStressTest(string pathToMsApp1, string pathToMsApp2) { - try - { - (var doc1, var errors) = CanvasDocument.LoadFromMsapp(pathToMsApp1); - errors.ThrowOnErrors(); + (var doc1, var errors) = CanvasDocument.LoadFromMsapp(pathToMsApp1); + errors.ThrowOnErrors(); - (var doc2, var errors2) = CanvasDocument.LoadFromMsapp(pathToMsApp2); - errors2.ThrowOnErrors(); + (var doc2, var errors2) = CanvasDocument.LoadFromMsapp(pathToMsApp2); + errors2.ThrowOnErrors(); - var doc1New = CanvasMerger.Merge(doc1, doc2, doc2); - var ok1 = HasNoDeltas(doc1, doc1New); + var doc1New = CanvasMerger.Merge(doc1, doc2, doc2); + var ok1 = HasNoDeltas(doc1, doc1New); - var doc2New = CanvasMerger.Merge(doc2, doc1, doc1); - var ok2 = HasNoDeltas(doc2, doc2New); + var doc2New = CanvasMerger.Merge(doc2, doc1, doc1); + var ok2 = HasNoDeltas(doc2, doc2New); - return ok1 && ok2; - } - catch (Exception e) - { - Console.WriteLine(e.Message); - return false; - } + return ok1 && ok2; } public static bool TestClone(string pathToMsApp) @@ -70,159 +60,136 @@ public static bool DiffStressTest(string pathToMsApp) } // Verify there are no deltas (detected via smart merge) between doc1 and doc2 - // Strict =true, also compare entropy files. + // Strict =true, also compare entropy files. private static bool HasNoDeltas(CanvasDocument doc1, CanvasDocument doc2, bool strict = false) { var ourDeltas = Diff.ComputeDelta(doc1, doc1); // ThemeDelta always added - ourDeltas = ourDeltas.Where(x => x.GetType() != typeof(ThemeChange)).ToArray(); + ourDeltas = ourDeltas.Where(x => x.GetType() != typeof(ThemeChange)); if (ourDeltas.Any()) { - foreach (var diff in ourDeltas) - { - Console.WriteLine($" {diff.GetType().Name}"); - } // Error! app shouldn't have any diffs with itself. return false; } // Save and verify checksums. - using (var temp1 = new TempFile()) - using (var temp2 = new TempFile()) - { - doc1.SaveToMsApp(temp1.FullPath); - doc2.SaveToMsApp(temp2.FullPath); + using var temp1 = new TempFile(); + using var temp2 = new TempFile(); + doc1.SaveToMsApp(temp1.FullPath); + doc2.SaveToMsApp(temp2.FullPath); - bool same; - if (strict) - { - same = Compare(temp1.FullPath, temp2.FullPath, Console.Out); - } - else - { - var doc1NoEntropy = RemoveEntropy(temp1.FullPath); - var doc2NoEntropy = RemoveEntropy(temp2.FullPath); - - same = Compare(doc1NoEntropy, doc2NoEntropy, Console.Out); - } + bool same; + if (strict) + { + same = Compare(temp1.FullPath, temp2.FullPath); + } + else + { + var doc1NoEntropy = RemoveEntropy(temp1.FullPath); + var doc2NoEntropy = RemoveEntropy(temp2.FullPath); - if (!same) - { - return false; - } + same = Compare(doc1NoEntropy, doc2NoEntropy); } - return true; + return same; } - // Unpack, delete the entropy dirs, repack. + // Unpack, delete the entropy dirs, repack. public static CanvasDocument RemoveEntropy(string pathToMsApp) { - using (var temp1 = new TempDir()) - { - (var doc1, var errors) = CanvasDocument.LoadFromMsapp(pathToMsApp); - errors.ThrowOnErrors(); + using var temp1 = new TempDir(); + (var doc1, var errors) = CanvasDocument.LoadFromMsapp(pathToMsApp); + errors.ThrowOnErrors(); - doc1.SaveToSources(temp1.Dir); + doc1.SaveToSources(temp1.Dir); - var entropyDir = Path.Combine(temp1.Dir, "Entropy"); - if (!Directory.Exists(entropyDir)) - { - throw new Exception($"Missing entropy dir: " + entropyDir); - } + var entropyDir = Path.Combine(temp1.Dir, "Entropy"); + if (!Directory.Exists(entropyDir)) + { + throw new Exception($"Missing entropy dir: " + entropyDir); + } - Directory.Delete(entropyDir, recursive: true); - (var doc2, _) = CanvasDocument.LoadFromSources(temp1.Dir); - errors.ThrowOnErrors(); + Directory.Delete(entropyDir, recursive: true); + (var doc2, _) = CanvasDocument.LoadFromSources(temp1.Dir); + errors.ThrowOnErrors(); - return doc2; - } + return doc2; } - // Given an msapp (original source of truth), stress test the conversions + /// + /// Given an msapp (original source of truth), stress test the conversions + /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "")] public static bool StressTest(string pathToMsApp) { - try + using (var temp1 = new TempFile()) { - using (var temp1 = new TempFile()) - { - var outFile = temp1.FullPath; - - var log = TextWriter.Null; + var outFile = temp1.FullPath; - // MsApp --> Model - CanvasDocument msapp; - var errors = new ErrorContainer(); - try - { - using (var stream = new FileStream(pathToMsApp, FileMode.Open, FileAccess.Read, FileShare.Read)) - { - msapp = MsAppSerializer.Load(stream, errors); - } - errors.Write(log); - errors.ThrowOnErrors(); - - // We can still get warnings here. Commonly: - // - PA2001, checksum mismatch - // - PA2999, colliding asset names - } - catch (NotSupportedException) + // MsApp --> Model + CanvasDocument msapp; + var errors = new ErrorContainer(); + try + { + using (var stream = new FileStream(pathToMsApp, FileMode.Open, FileAccess.Read, FileShare.Read)) { - errors.FormatNotSupported($"Too old: {pathToMsApp}"); - return false; + msapp = MsAppSerializer.Load(stream, errors); } - - // Model --> MsApp - errors = msapp.SaveToMsApp(outFile); errors.ThrowOnErrors(); - var ok = Compare(pathToMsApp, outFile, log); - if (!ok) { return false; } - - - // Model --> Source - using (var tempDir = new TempDir()) - { - var outSrcDir = tempDir.Dir; - errors = msapp.SaveToSources(outSrcDir, verifyOriginalPath: pathToMsApp); - errors.ThrowOnErrors(); - } - } // end using - if (!TestClone(pathToMsApp)) + // We can still get warnings here. Commonly: + // - PA2001, checksum mismatch + // - PA2999, colliding asset names + } + catch (NotSupportedException) { + errors.FormatNotSupported($"Too old: {pathToMsApp}"); return false; } - if (!DiffStressTest(pathToMsApp)) + // Model --> MsApp + errors = msapp.SaveToMsApp(outFile); + errors.ThrowOnErrors(); + + if (!Compare(pathToMsApp, outFile, errors)) { + errors.ThrowOnErrors(); return false; } + + // Model --> Source + using var tempDir = new TempDir(); + var outSrcDir = tempDir.Dir; + errors = msapp.SaveToSources(outSrcDir, verifyOriginalPath: pathToMsApp); + errors.ThrowOnErrors(); + } // end using + + if (!TestClone(pathToMsApp)) + { + return false; } - catch (Exception e) + + if (!DiffStressTest(pathToMsApp)) { - Console.WriteLine(e.ToString()); return false; } return true; } - public static bool Compare(string pathToZip1, string pathToZip2, TextWriter log) + public static bool Compare(string pathToZip1, string pathToZip2) { var errorContainer = new ErrorContainer(); - return Compare(pathToZip1, pathToZip2, log, errorContainer); + return Compare(pathToZip1, pathToZip2, errorContainer); } // Overload with ErrorContainer - public static bool Compare(string pathToZip1, string pathToZip2, TextWriter log, ErrorContainer errorContainer) + public static bool Compare(string pathToZip1, string pathToZip2, ErrorContainer errorContainer) { - var c1 = ChecksumMaker.GetChecksum(pathToZip1); - var c2 = ChecksumMaker.GetChecksum(pathToZip2); - if (c1.wholeChecksum == c2.wholeChecksum) + if (ChecksumMaker.GetChecksum(pathToZip1).wholeChecksum == ChecksumMaker.GetChecksum(pathToZip2).wholeChecksum) { return true; } @@ -230,18 +197,18 @@ public static bool Compare(string pathToZip1, string pathToZip2, TextWriter log, // Provide a comparison that can be very specific about what the difference is. var comp = new Dictionary(); - CompareChecksums(pathToZip1, log, comp, true, errorContainer); - CompareChecksums(pathToZip2, log, comp, false, errorContainer); + CompareChecksums(pathToZip1, comp, true, errorContainer); + CompareChecksums(pathToZip2, comp, false, errorContainer); return false; } - // Compare the debug checksums. + // Compare the debug checksums. // Get a hash for the MsApp file. // First pass adds file/hash to comp. // Second pass checks hash equality and removes files from comp. // After second pass, comp should be 0. Any files in comp were missing from 2nd pass. - public static void CompareChecksums(string pathToZip, TextWriter log, Dictionary comp, bool first, ErrorContainer errorContainer) + private static void CompareChecksums(string pathToZip, Dictionary comp, bool first, ErrorContainer errorContainer) { // Path to the directory where we are creating the normalized form var normFormDir = ".\\diffFiles"; @@ -252,191 +219,161 @@ public static void CompareChecksums(string pathToZip, TextWriter log, Dictionary Directory.CreateDirectory(normFormDir); } - using (var zip = ZipFile.OpenRead(pathToZip)) + using var zip = ZipFile.OpenRead(pathToZip); + foreach (var entry in zip.Entries.OrderBy(x => x.FullName)) { - foreach (var entry in zip.Entries.OrderBy(x => x.FullName)) + var newContents = ChecksumMaker.ChecksumFile(entry.FullName, entry.ToBytes()); + if (newContents == null) + { + continue; + } + + // Do easy diffs + var entryFullName = entry.FullName; + if (first) { - var newContents = ChecksumMaker.ChecksumFile(entry.FullName, entry.ToBytes()); - if (newContents == null) + comp.Add(entryFullName, newContents); + } + else + { + if (comp.TryGetValue(entryFullName, out var originalContents)) { - continue; - } + CompareEntryContents(entryFullName, originalContents, newContents, errorContainer); - // Do easy diffs + comp.Remove(entryFullName); + } + else { - if (first) - { - comp.Add(entry.FullName, newContents); - } - else - { - if (comp.TryGetValue(entry.FullName, out var originalContents)) - { - var same = newContents.SequenceEqual(originalContents); - - if (!same) - { - - var isJson = true; - - // Catch in case of originalContents/newContents not being JSON - try - { - JsonDocument.Parse(originalContents); - JsonDocument.Parse(newContents); - } - catch (JsonException) - { - isJson = false; - } - - if (isJson) - { - var jsonDictionary1 = FlattenJson(originalContents); - var jsonDictionary2 = FlattenJson(newContents); - - // Add JSONMismatch error if JSON property was changed or removed - CheckPropertyChangedRemoved(jsonDictionary1, jsonDictionary2, errorContainer, ""); - - // Add JSONMismatch error if JSON property was added - CheckPropertyAdded(jsonDictionary1, jsonDictionary2, errorContainer, ""); - } - -#if DEBUG - //DebugMismatch(entry, originalContents, newContents, normFormDir); -#endif - - if (!isJson) - { - throw new ArgumentException($"Mismatch detected in non-Json properties: " + entry.FullName); - } - } - - comp.Remove(entry.FullName); - } - else - { - // Missing file! - Console.WriteLine("FAIL: 2nd has added file: " + entry.FullName); - } - } + // Missing file! + errorContainer.AddedZipEntry(entryFullName); } } } } - public static Dictionary FlattenJson(byte[] json) + private static void CompareEntryContents(string entryFullName, byte[] originalContents, byte[] newContents, ErrorContainer errorContainer) { - using (var document = JsonDocument.Parse(json)) + if (!newContents.SequenceEqual(originalContents)) { - var jsonObject = document.RootElement.EnumerateObject().SelectMany(property => GetLeaves(null, property)); - return jsonObject.ToDictionary(key => key.Path, value => value.Property.Value.Clone()); + // Catch in case of originalContents/newContents not being JSON + try + { + JsonDocument.Parse(originalContents); + JsonDocument.Parse(newContents); + } + catch (JsonException ex) + { + throw new ArgumentException($"Mismatch detected in non-Json properties: " + entryFullName, ex); + } + + var flattenedJsonOrig = FlattenJson(originalContents); + var flattenedJsonNew = FlattenJson(newContents); + + // Add JSONMismatch error if JSON property was changed or removed + CheckPropertyChangedRemoved(entryFullName, flattenedJsonOrig, flattenedJsonNew, errorContainer); + + // Add JSONMismatch error if JSON property was added + CheckPropertyAdded(entryFullName, flattenedJsonOrig, flattenedJsonNew, errorContainer); } + } + public static Dictionary FlattenJson(byte[] json) + { + using var document = JsonDocument.Parse(json); + return FlattenJson(string.Empty, document.RootElement) + .ToDictionary(t => t.Path, t => t.Value.Clone()); } - public static IEnumerable<(string Path, JsonProperty Property)> GetLeaves(string path, JsonProperty property) + private static IEnumerable<(string Path, JsonElement Value)> FlattenJson(string path, JsonElement value) { - if (path == null) - { - path = property.Name; - } - else - { - path += "." + property.Name; - } + Debug.Assert(path is not null); - if (property.Value.ValueKind == JsonValueKind.Object) + if (value.ValueKind == JsonValueKind.Object) { - return property.Value.EnumerateObject().SelectMany(child => GetLeaves(path, child)); + return FlattenObject(path, value); } - else if (property.Value.ValueKind == JsonValueKind.Array) + else if (value.ValueKind == JsonValueKind.Array) { - if (property.Value.GetArrayLength() == 0) + // Only flatten array if it has an object as one of its items. Otherwise, treat the entire array as a leaf. + if (value.EnumerateArray().Any(item => item.ValueKind == JsonValueKind.Object)) { - return new[] { (path, property) }; - } - else - { - var arrayType = property.Value[0].ValueKind; - - // Peek, if member types, return - if (arrayType == JsonValueKind.Object) - { - return FlattenArray(path, property.Value); - } - else - { - return new[] { (path, property) }; - } + return FlattenArray(path, value); } } - else + + return [(path, value)]; + } + + public static IEnumerable<(string Path, JsonElement Value)> FlattenObject(string path, JsonElement jsonObject) + { + Debug.Assert(path is not null); + Debug.Assert(jsonObject.ValueKind == JsonValueKind.Object); + + // pre-append the '.' operator if not the root object + if (path.Length > 0) { - return new[] { (path, property) }; + path += "."; } + + return jsonObject.EnumerateObject() + .SelectMany(property => FlattenJson(path + property.Name, property.Value)); } - public static IEnumerable<(string Path, JsonProperty Property)> FlattenArray(string path, JsonElement array) - { - var enumeratedObjects = new List<(string arrayPath, JsonProperty arrayProperty)>(); - var index = 0; + public static IEnumerable<(string Path, JsonElement Value)> FlattenArray(string path, JsonElement jsonArray) + { + Debug.Assert(path is not null); + Debug.Assert(jsonArray.ValueKind == JsonValueKind.Array); - foreach (var member in array.EnumerateArray()) - { - var arraySubPath = $"{path}[{index}]"; + var isRulesArray = path.StartsWith("TopParent.") && path.EndsWith(".Rules"); - if (member.ValueKind == JsonValueKind.Object) + return jsonArray.EnumerateArray() + .SelectMany((arrayItem, index) => { - enumeratedObjects.AddRange(member.EnumerateObject().SelectMany(child => GetLeaves(arraySubPath, child))); - } + var arraySubPath = $"{path}[{index}]"; - index++; - } - return enumeratedObjects.ToArray(); + // For Rules arrays, use the Property name as the key instead of the index, since order doesn't matter and Property is (likely) unique + if (isRulesArray && arrayItem.ValueKind == JsonValueKind.Object && arrayItem.TryGetProperty("Property", out var propertyName)) + { + arraySubPath = $"{path}['{propertyName.GetString()}']"; + } + + return FlattenJson(arraySubPath, arrayItem); + }); } - public static void CheckPropertyChangedRemoved(Dictionary dictionary1, Dictionary dictionary2, ErrorContainer errorContainer, string jsonPath) + public static void CheckPropertyChangedRemoved(string entryFullName, Dictionary flattenedJsonOrig, Dictionary flattenedJsonNew, ErrorContainer errorContainer) { - // Iterate through each path/json pair in Dictionary 1 - foreach (var currentPair1 in dictionary1) + foreach (var kvpOrig in flattenedJsonOrig) { - // Check if the second dictionary contains the same key as in Dictionary 1 - if (dictionary2.TryGetValue(currentPair1.Key, out var json2)) + // Check if the property exists in the new JSON + if (flattenedJsonNew.TryGetValue(kvpOrig.Key, out var newJsonValue)) { - // Check if the value in Dictionary 2's property is equal to the value in Dictionary1's property - if (!currentPair1.Value.GetRawText().Equals(json2.GetRawText())) + // Check if the raw value is different, if so, it's a mismatch + if (!kvpOrig.Value.GetRawText().Equals(newJsonValue.GetRawText())) { - errorContainer.JSONValueChanged(currentPair1.Key); + errorContainer.JSONValueChanged(entryFullName, kvpOrig.Key); } - } - // If current property from first file does not exist in second - else + else // Then the property was removed { - errorContainer.JSONPropertyRemoved(currentPair1.Key); + errorContainer.JSONPropertyRemoved(entryFullName, kvpOrig.Key); } - } } - public static void CheckPropertyAdded(Dictionary dictionary1, Dictionary dictionary2, ErrorContainer errorContainer, string jsonPath) + public static void CheckPropertyAdded(string entryFullName, Dictionary flattenedJsonOrig, Dictionary flattenedJsonNew, ErrorContainer errorContainer) { - // Check each property and value in json1 to see if each exists and is equal to json2 - foreach (var currentPair2 in dictionary2) + // Report any properties in new that are not in original + foreach (var newPath in flattenedJsonNew.Keys.Except(flattenedJsonOrig.Keys)) { - // If current property from second json file does not exist in the first file - if (!dictionary1.ContainsKey(currentPair2.Key)) - { - errorContainer.JSONPropertyAdded(currentPair2.Key); - } + errorContainer.JSONPropertyAdded(entryFullName, newPath); } } public static void DebugMismatch(ZipArchiveEntry entry, byte[] originalContents, byte[] newContents, string normFormDir) { // Fail! Mismatch - Console.WriteLine("FAIL: hash mismatch: " + entry.FullName); + //Console.WriteLine("FAIL: hash mismatch: " + entry.FullName); // Paths to current diff files var aPath = normFormDir + "\\" + Path.ChangeExtension(entry.Name, null) + "-A.json"; diff --git a/src/PAModel/PAConvert/Error.cs b/src/PAModel/PAConvert/Error.cs index 4479df5f..1f282c3c 100644 --- a/src/PAModel/PAConvert/Error.cs +++ b/src/PAModel/PAConvert/Error.cs @@ -29,6 +29,21 @@ internal Error(ErrorCode code, SourceLocation span, string message) public override string ToString() { var sb = new StringBuilder(); + WriteTo(sb); + return sb.ToString(); + } + + internal void WriteTo(StringBuilder sb) + { + // Format using VS error format + // 1>E:\repos\github\microsoft\PA-Tooling\src\PAModel\PAConvert\Error.cs(42,11,42,11): error CS1002: ; expected + var origLen = sb.Length; + Span.WriteTo(sb); + if (sb.Length != origLen) + { + sb.Append(": "); + } + if (IsError) { sb.Append("Error "); @@ -37,9 +52,8 @@ public override string ToString() { sb.Append("Warning "); } + sb.Append($"PA{(int)Code}: "); sb.Append(Message); - - return sb.ToString(); } } diff --git a/src/PAModel/PAConvert/ErrorCode.cs b/src/PAModel/PAConvert/ErrorCode.cs index 1cfcbbdf..b9a9c7ad 100644 --- a/src/PAModel/PAConvert/ErrorCode.cs +++ b/src/PAModel/PAConvert/ErrorCode.cs @@ -67,6 +67,8 @@ internal enum ErrorCode // JSON Property was removed JSONPropertyRemoved = 3015, + ArchiveEntryAdded = 3101, + // Catch-all. Should review and make these more specific. Generic = 3999, @@ -168,20 +170,26 @@ public static void MsAppFormatError(this ErrorContainer errors, string message) errors.AddError(ErrorCode.CantReadMsApp, default, $"MsApp is corrupted: {message}"); } - public static void JSONValueChanged(this ErrorContainer errors, string message) + public static void JSONValueChanged(this ErrorContainer errors, string entryPath, string jsonPath) { - errors.AddError(ErrorCode.JSONValueChanged, default, $"Property Value Changed: {message}"); + errors.AddError(ErrorCode.JSONValueChanged, SourceLocation.FromFile(entryPath), $"Property Value Changed: {jsonPath}"); } - public static void JSONPropertyAdded(this ErrorContainer errors, string message) + public static void JSONPropertyAdded(this ErrorContainer errors, string entryPath, string jsonPath) { - errors.AddError(ErrorCode.JSONPropertyAdded, default, $"Property Added: {message}"); + errors.AddError(ErrorCode.JSONPropertyAdded, SourceLocation.FromFile(entryPath), $"Property Added: {jsonPath}"); } - public static void JSONPropertyRemoved(this ErrorContainer errors, string message) + public static void JSONPropertyRemoved(this ErrorContainer errors, string entryPath, string jsonPath) { - errors.AddError(ErrorCode.JSONPropertyRemoved, default, $"Property Removed: {message}"); + errors.AddError(ErrorCode.JSONPropertyRemoved, SourceLocation.FromFile(entryPath), $"Property Removed: {jsonPath}"); } + + public static void AddedZipEntry(this ErrorContainer errors, string entryPath) + { + errors.AddError(ErrorCode.ArchiveEntryAdded, SourceLocation.FromFile(entryPath), "Archive entry added."); + } + public static void UnsupportedError(this ErrorContainer errors, string message) { errors.AddError(ErrorCode.UnsupportedError, default, $"Not Supported: {message}"); diff --git a/src/PAModel/PAConvert/Parser/SourceLocation.cs b/src/PAModel/PAConvert/Parser/SourceLocation.cs index cb96aabb..aa8861f6 100644 --- a/src/PAModel/PAConvert/Parser/SourceLocation.cs +++ b/src/PAModel/PAConvert/Parser/SourceLocation.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Linq; +using System.Text; namespace Microsoft.PowerPlatform.Formulas.Tools.IR; @@ -30,7 +31,37 @@ public static SourceLocation FromFile(string filename) public override string ToString() { - return $"{FileName}:{StartLine},{StartChar}-{EndLine},{EndChar}"; + var sb = new StringBuilder(); + WriteTo(sb); + return sb.ToString(); + } + + /// + /// Writes this location to the given StringBuilder in the format "filename(startLine,startChar,endLine,endChar)". + /// If the filename is empty, it is omitted. + /// If the start and end positions are all zeros, the end position is omitted. + /// + internal void WriteTo(StringBuilder sb) + { + // Format using VS error format + // 1>src\PAModel\PAConvert\Error.cs(42,11,42,11): error CS1002: ; expected + if (FileName is not null) + { + sb.Append(FileName); + } + + if (StartLine != default || StartChar != default || EndLine != default || EndChar != default) + { + sb.Append('('); + sb.Append(StartLine); + sb.Append(','); + sb.Append(StartChar); + sb.Append(','); + sb.Append(EndLine); + sb.Append(','); + sb.Append(EndChar); + sb.Append(')'); + } } public static SourceLocation FromChildren(List locations) diff --git a/src/PAModel/SourceTransforms/AppTestTransform.cs b/src/PAModel/SourceTransforms/AppTestTransform.cs index c51c45a4..4c3a46f4 100644 --- a/src/PAModel/SourceTransforms/AppTestTransform.cs +++ b/src/PAModel/SourceTransforms/AppTestTransform.cs @@ -56,21 +56,21 @@ public void AfterRead(BlockNode control) var properties = control.Properties.ToDictionary(prop => prop.Identifier); if (!properties.TryGetValue(_metadataPropName, out var metadataProperty)) { - // If no metadata props, TestStepsMetadata nonexistent - _entropy.DoesTestStepsMetadataExist = false; - // If the test studio is opened, but no tests are created, it's possible for a test case to exist without any - // steps or teststepmetadata. In that case, write only the base properties. - if (properties.Count == 2) - return; + // steps or teststepmetadata. Save to entropy if this is the case. + // This is only needed for round-trip validation; when packing, we'll always create an empty-array property if no steps exist. + _entropy.AppTestsMissingStepsMetadata.Add(control.Name.Identifier); - _errors.ValidationError($"Unable to find TestStepsMetadata property for TestCase {control.Name.Identifier}"); - throw new DocumentException(); - } - else - { - _entropy.DoesTestStepsMetadataExist = true; + if (properties.Count != 2) + { + _errors.ValidationError($"Unable to find TestStepsMetadata property for TestCase {control.Name.Identifier}"); + throw new DocumentException(); + } + + // No steps, so we can exit this method early + return; } + properties.Remove(_metadataPropName); var metadataJsonString = metadataProperty.Expression.Expression.UnEscapePAString(); var testStepsMetadata = JsonConvert.DeserializeObject>(metadataJsonString); @@ -148,7 +148,6 @@ public void AfterRead(BlockNode control) public void BeforeWrite(BlockNode control) { var testStepsMetadata = new List(); - var doesTestStepsMetadataExist = _entropy.DoesTestStepsMetadataExist ?? true; foreach (var child in control.Children) { @@ -209,24 +208,22 @@ public void BeforeWrite(BlockNode control) } } - if (doesTestStepsMetadataExist) + testStepsMetadata.Add(new TestStepsMetadataJson() { - testStepsMetadata.Add(new TestStepsMetadataJson() - { - Description = descriptionProp.Expression.Expression.UnEscapePAString(), - Rule = propName, - ScreenId = screenId - }); + Description = descriptionProp.Expression.Expression.UnEscapePAString(), + Rule = propName, + ScreenId = screenId + }); - control.Properties.Add(new PropertyNode() - { - Expression = valueProp.Expression, - Identifier = propName - }); - } + control.Properties.Add(new PropertyNode() + { + Expression = valueProp.Expression, + Identifier = propName + }); } - if (doesTestStepsMetadataExist) + if (testStepsMetadata.Count > 0 + || !_entropy.AppTestsMissingStepsMetadata.Contains(control.Name.Identifier)) { /* When Canvas creates the TestStepsMetadata value, it does so using Newtonsoft, creating a JArray of JObjects and calling * the ToString method on that JArray with no special formatting. This skips escaping on a number of Unicode characters @@ -234,7 +231,7 @@ public void BeforeWrite(BlockNode control) * certain Unicode characters to be escaped in all cases. As such, we use Newtonsoft for TestStepsMetadata to match the * behavior in Canvas and prevent roundtrip errors. The appropriate encoding will ultimately happen when the full document * is serialized to JSON during the creation of the msapp, and will be consistent with how Canvas serializes an msapp. - * + * * See: https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-character-encoding#global-block-list */ var testStepMetadataStr = JsonConvert.SerializeObject(testStepsMetadata, Formatting.None); diff --git a/src/PAModelTests/Apps/EmptyTestCase.msapp b/src/PAModelTests/Apps/EmptyTestCase.msapp index e66a7e99..86de5f92 100644 Binary files a/src/PAModelTests/Apps/EmptyTestCase.msapp and b/src/PAModelTests/Apps/EmptyTestCase.msapp differ diff --git a/src/PAModelTests/EntropyTests.cs b/src/PAModelTests/EntropyTests.cs index 28448bc7..a9da9ad3 100644 --- a/src/PAModelTests/EntropyTests.cs +++ b/src/PAModelTests/EntropyTests.cs @@ -148,7 +148,7 @@ public void TestPCFControlWillFallBackToControlTemplate(string appName) using (var tempFile = new TempFile()) { MsAppSerializer.SaveAsMsApp(msapp, tempFile.FullPath, new ErrorContainer()); - Assert.IsTrue(MsAppTest.Compare(root, tempFile.FullPath, Console.Out)); + Assert.IsTrue(MsAppTest.Compare(root, tempFile.FullPath)); } } } diff --git a/src/PAModelTests/ErrorTests.cs b/src/PAModelTests/ErrorTests.cs index 8a193aa4..20e46fbf 100644 --- a/src/PAModelTests/ErrorTests.cs +++ b/src/PAModelTests/ErrorTests.cs @@ -15,6 +15,8 @@ public class ErrorTests public static string PathMissingDir = Path.Combine(Environment.CurrentDirectory, "MissingDirectory"); public static int counter; + public TestContext TestContext { get; set; } + [TestMethod] public void OpenCorruptedMsApp() { @@ -88,11 +90,13 @@ public void TestJSONValueChanged(string file1, string file2, string file3) var jsonDictionary2 = MsAppTest.FlattenJson(jsonString2); // IsMismatched on mismatched files - MsAppTest.CheckPropertyChangedRemoved(jsonDictionary1, jsonDictionary2, errorContainer, ""); - MsAppTest.CheckPropertyAdded(jsonDictionary1, jsonDictionary2, errorContainer, ""); + MsAppTest.CheckPropertyChangedRemoved(file1, jsonDictionary1, jsonDictionary2, errorContainer); + MsAppTest.CheckPropertyAdded(file1, jsonDictionary1, jsonDictionary2, errorContainer); // Confirm that the unit tests have the expected output - File.ReadAllText(path3).Should().Be(errorContainer.ToString()); + var errorContainerString = errorContainer.ToString(); + TestContext.WriteLine(errorContainerString); + errorContainerString.Should().Be(File.ReadAllText(path3)); } [TestMethod] @@ -105,7 +109,7 @@ public void CompareChecksumImageNotReadAsJSONTest(string app1, string app2) // When there's a file content mismatch on non-JSON files, // we must throw an error and not use JSON to compare non JSON-files - var exception = Assert.ThrowsExactly(() => MsAppTest.Compare(pathToZip1, pathToZip2, Console.Out)); + var exception = Assert.ThrowsExactly(() => MsAppTest.Compare(pathToZip1, pathToZip2)); exception.Message.Should().Be("Mismatch detected in non-Json properties: Assets\\Images\\1556681b-11bd-4d72-9b17-4f884fb4b465.png"); } } diff --git a/src/PAModelTests/RoundtripTests.cs b/src/PAModelTests/RoundtripTests.cs index 297f0df7..066580a0 100644 --- a/src/PAModelTests/RoundtripTests.cs +++ b/src/PAModelTests/RoundtripTests.cs @@ -11,16 +11,29 @@ namespace PAModelTests; [TestClass] public class RoundtripTests { - private static IEnumerable TestAppFilePaths => Directory.GetFiles(Path.Combine(Environment.CurrentDirectory, "Apps"), "*.msapp").Select(p => new[] { p }); + private static IEnumerable TestAppFilePaths + { + get + { + var appsDirectory = new DirectoryInfo("Apps"); + foreach (var file in appsDirectory.EnumerateFiles("*.msapp", SearchOption.AllDirectories)) + { + var testAppRelativePath = file.FullName.Substring(Environment.CurrentDirectory.Length + 1); + + yield return new object[] { testAppRelativePath }; + } + } + } // Apps live in the "Apps" folder, and should have a build action of "Copy to output" [TestMethod] [DynamicData(nameof(TestAppFilePaths))] public void StressTestApps(string msappPath) { - MsAppTest.StressTest(msappPath).Should().BeTrue(); + var msappFullPath = Path.GetFullPath(msappPath); + MsAppTest.StressTest(msappFullPath).Should().BeTrue(); // If this fails, to debug it, rerun and set a breakpoint in DebugChecksum(). - MsAppTest.TestClone(msappPath).Should().BeTrue(); + MsAppTest.TestClone(msappFullPath).Should().BeTrue(); } } diff --git a/src/PAModelTests/TemplateStoreTests.cs b/src/PAModelTests/TemplateStoreTests.cs index 5e32d90b..25a6c7be 100644 --- a/src/PAModelTests/TemplateStoreTests.cs +++ b/src/PAModelTests/TemplateStoreTests.cs @@ -41,7 +41,7 @@ public void TestHostControlInstancesWithHostType(string appName) using (var tempFile = new TempFile()) { MsAppSerializer.SaveAsMsApp(msapp, tempFile.FullPath, new ErrorContainer()); - Assert.IsTrue(MsAppTest.Compare(root, tempFile.FullPath, Console.Out)); + Assert.IsTrue(MsAppTest.Compare(root, tempFile.FullPath)); } } @@ -68,7 +68,7 @@ public void TestModernControlWithDynamicTemplate(string appName) using (var tempFile = new TempFile()) { MsAppSerializer.SaveAsMsApp(msapp, tempFile.FullPath, new ErrorContainer()); - Assert.IsTrue(MsAppTest.Compare(root, tempFile.FullPath, Console.Out)); + Assert.IsTrue(MsAppTest.Compare(root, tempFile.FullPath)); } } } diff --git a/src/PAModelTests/TestData/complexAddedOutput.txt b/src/PAModelTests/TestData/complexAddedOutput.txt index 4bfcaf54..ed369871 100644 --- a/src/PAModelTests/TestData/complexAddedOutput.txt +++ b/src/PAModelTests/TestData/complexAddedOutput.txt @@ -1,5 +1,5 @@ -Error PA3014: Property Added: Noun.Adjectives[1].Colors[0].Primary -Error PA3014: Property Added: Noun.Adjectives[1].Colors[0].Secondary -Error PA3014: Property Added: Noun.Adjectives[1].Colors[1].Primary -Error PA3014: Property Added: Noun.Adjectives[1].Colors[1].Secondary +complexAdded1.json: Error PA3014: Property Added: Noun.Adjectives[1].Colors[0].Primary +complexAdded1.json: Error PA3014: Property Added: Noun.Adjectives[1].Colors[0].Secondary +complexAdded1.json: Error PA3014: Property Added: Noun.Adjectives[1].Colors[1].Primary +complexAdded1.json: Error PA3014: Property Added: Noun.Adjectives[1].Colors[1].Secondary 4 errors, 0 warnings. diff --git a/src/PAModelTests/TestData/complexChangedOutput.txt b/src/PAModelTests/TestData/complexChangedOutput.txt index 293ed884..ad1b08f1 100644 --- a/src/PAModelTests/TestData/complexChangedOutput.txt +++ b/src/PAModelTests/TestData/complexChangedOutput.txt @@ -1,6 +1,6 @@ -Error PA3013: Property Value Changed: Noun.Adjectives[0].Shapes[0].2-D -Error PA3013: Property Value Changed: Noun.Adjectives[0].Shapes[0].3-D -Error PA3013: Property Value Changed: Noun.Adjectives[0].Shapes[1].2-D -Error PA3013: Property Value Changed: Noun.Adjectives[0].Shapes[1].3-D -Error PA3013: Property Value Changed: Noun.Adjectives[1].Colors[0].Primary +complexChanged1.json: Error PA3013: Property Value Changed: Noun.Adjectives[0].Shapes[0].2-D +complexChanged1.json: Error PA3013: Property Value Changed: Noun.Adjectives[0].Shapes[0].3-D +complexChanged1.json: Error PA3013: Property Value Changed: Noun.Adjectives[0].Shapes[1].2-D +complexChanged1.json: Error PA3013: Property Value Changed: Noun.Adjectives[0].Shapes[1].3-D +complexChanged1.json: Error PA3013: Property Value Changed: Noun.Adjectives[1].Colors[0].Primary 5 errors, 0 warnings. diff --git a/src/PAModelTests/TestData/complexRemovedOutput.txt b/src/PAModelTests/TestData/complexRemovedOutput.txt index 259b073f..646168a1 100644 --- a/src/PAModelTests/TestData/complexRemovedOutput.txt +++ b/src/PAModelTests/TestData/complexRemovedOutput.txt @@ -1,5 +1,5 @@ -Error PA3015: Property Removed: Noun.Adjectives[1].Colors[0].Primary -Error PA3015: Property Removed: Noun.Adjectives[1].Colors[0].Secondary -Error PA3015: Property Removed: Noun.Adjectives[1].Colors[1].Primary -Error PA3015: Property Removed: Noun.Adjectives[1].Colors[1].Secondary +complexRemoved1.json: Error PA3015: Property Removed: Noun.Adjectives[1].Colors[0].Primary +complexRemoved1.json: Error PA3015: Property Removed: Noun.Adjectives[1].Colors[0].Secondary +complexRemoved1.json: Error PA3015: Property Removed: Noun.Adjectives[1].Colors[1].Primary +complexRemoved1.json: Error PA3015: Property Removed: Noun.Adjectives[1].Colors[1].Secondary 4 errors, 0 warnings. diff --git a/src/PAModelTests/TestData/emptyNestedArrayOutput.txt b/src/PAModelTests/TestData/emptyNestedArrayOutput.txt index 9ede55e7..9dfb440b 100644 --- a/src/PAModelTests/TestData/emptyNestedArrayOutput.txt +++ b/src/PAModelTests/TestData/emptyNestedArrayOutput.txt @@ -1,2 +1,2 @@ -Error PA3013: Property Value Changed: Adjectives +emptyNestedArray1.json: Error PA3013: Property Value Changed: Adjectives 1 errors, 0 warnings. diff --git a/src/PAModelTests/TestData/simpleAddedOutput.txt b/src/PAModelTests/TestData/simpleAddedOutput.txt index 376ba8ef..583e260e 100644 --- a/src/PAModelTests/TestData/simpleAddedOutput.txt +++ b/src/PAModelTests/TestData/simpleAddedOutput.txt @@ -1,5 +1,5 @@ -Error PA3014: Property Added: Words.Adverb -Error PA3014: Property Added: Numbers.Single -Error PA3014: Property Added: Numbers.Double -Error PA3014: Property Added: Numbers.Triple +simpleAdded1.json: Error PA3014: Property Added: Words.Adverb +simpleAdded1.json: Error PA3014: Property Added: Numbers.Single +simpleAdded1.json: Error PA3014: Property Added: Numbers.Double +simpleAdded1.json: Error PA3014: Property Added: Numbers.Triple 4 errors, 0 warnings. diff --git a/src/PAModelTests/TestData/simpleArrayOutput.txt b/src/PAModelTests/TestData/simpleArrayOutput.txt index 9ede55e7..b9ac0b37 100644 --- a/src/PAModelTests/TestData/simpleArrayOutput.txt +++ b/src/PAModelTests/TestData/simpleArrayOutput.txt @@ -1,2 +1,2 @@ -Error PA3013: Property Value Changed: Adjectives +simpleArray1.json: Error PA3013: Property Value Changed: Adjectives 1 errors, 0 warnings. diff --git a/src/PAModelTests/TestData/simpleChangedOutput.txt b/src/PAModelTests/TestData/simpleChangedOutput.txt index 81802026..448313ae 100644 --- a/src/PAModelTests/TestData/simpleChangedOutput.txt +++ b/src/PAModelTests/TestData/simpleChangedOutput.txt @@ -1,10 +1,10 @@ -Error PA3015: Property Removed: Words.Noun -Error PA3015: Property Removed: Words.Adjective -Error PA3015: Property Removed: Words.Verb -Error PA3015: Property Removed: Words.Adverb -Error PA3013: Property Value Changed: Numbers.Double -Error PA3014: Property Added: Things.Noun -Error PA3014: Property Added: Things.Adjective -Error PA3014: Property Added: Things.Verb -Error PA3014: Property Added: Things.Adverb +simpleChanged1.json: Error PA3015: Property Removed: Words.Noun +simpleChanged1.json: Error PA3015: Property Removed: Words.Adjective +simpleChanged1.json: Error PA3015: Property Removed: Words.Verb +simpleChanged1.json: Error PA3015: Property Removed: Words.Adverb +simpleChanged1.json: Error PA3013: Property Value Changed: Numbers.Double +simpleChanged1.json: Error PA3014: Property Added: Things.Noun +simpleChanged1.json: Error PA3014: Property Added: Things.Adjective +simpleChanged1.json: Error PA3014: Property Added: Things.Verb +simpleChanged1.json: Error PA3014: Property Added: Things.Adverb 9 errors, 0 warnings. diff --git a/src/PAModelTests/TestData/simpleRemovedOutput.txt b/src/PAModelTests/TestData/simpleRemovedOutput.txt index 1e3cb2e1..a10619b5 100644 --- a/src/PAModelTests/TestData/simpleRemovedOutput.txt +++ b/src/PAModelTests/TestData/simpleRemovedOutput.txt @@ -1,5 +1,5 @@ -Error PA3015: Property Removed: Words.Adverb -Error PA3015: Property Removed: Numbers.Single -Error PA3015: Property Removed: Numbers.Double -Error PA3015: Property Removed: Numbers.Triple +simpleRemoved1.json: Error PA3015: Property Removed: Words.Adverb +simpleRemoved1.json: Error PA3015: Property Removed: Numbers.Single +simpleRemoved1.json: Error PA3015: Property Removed: Numbers.Double +simpleRemoved1.json: Error PA3015: Property Removed: Numbers.Triple 4 errors, 0 warnings. diff --git a/src/PAModelTests/YamlTest.cs b/src/PAModelTests/YamlTest.cs index c5051618..9cbddfc7 100644 --- a/src/PAModelTests/YamlTest.cs +++ b/src/PAModelTests/YamlTest.cs @@ -240,10 +240,10 @@ public void ReadBasicSpans() using var sr = new StringReader(text); using var y = new YamlLexer(sr, "test.yaml"); - AssertLex("Obj1:", y, "test.yaml:1,1-1,6"); - AssertLex("P1=456", y, "test.yaml:2,4-2,12"); + AssertLex("Obj1:", y, "test.yaml(1,1,1,6)"); + AssertLex("P1=456", y, "test.yaml(2,4,2,12)"); AssertLexEndObj(y); - AssertLex("Obj2:", y, "test.yaml:4,1-4,6"); + AssertLex("Obj2:", y, "test.yaml(4,1,4,6)"); AssertLexEndObj(y); AssertLexEndFile(y); } diff --git a/src/PASopa/Program.cs b/src/PASopa/Program.cs index ad330cb8..cf731e45 100644 --- a/src/PASopa/Program.cs +++ b/src/PASopa/Program.cs @@ -116,16 +116,9 @@ private static void Main(string[] args) throw new InvalidOperationException("must be path to .msapp file"); } - string outDir; - if (args.Length == 2) - { - outDir = msAppPath.Substring(0, msAppPath.Length - 6) + "_src"; // chop off ".msapp"; - } - else - { - outDir = args[2]; - } - + string outDir = args.Length >= 3 + ? Path.GetFullPath(args[2]) + : Path.Combine(Path.GetDirectoryName(msAppPath), Path.GetFileNameWithoutExtension(msAppPath) + "_src"); Console.WriteLine($"Unpack: {msAppPath} --> {outDir} "); (var msApp, var errors) = TryOperation(() => CanvasDocument.LoadFromMsapp(msAppPath));