From 70811552aabf3cde584f02a31919d044421550fb Mon Sep 17 00:00:00 2001 From: rogordon01 <71304426+rogordon01@users.noreply.github.com> Date: Mon, 21 Mar 2022 18:29:36 -0700 Subject: [PATCH] Personal/rogordon/validation aggregration updates (#179) * Adding category to each validation error. Allowing Mapping Validator to be subclassed * Adding aggregation logic. Updating tests * Adding unit test --- .../Extensions/IResultExtensions.cs | 12 +- .../IMappingValidator.cs | 3 +- .../MappingValidator.cs | 93 ++++++++-- .../Models/DeviceResult.cs | 6 + .../Models/ValidationCategory.cs | 20 +++ .../Models/ValidationError.cs | 5 +- .../MappingValidatorTests.cs | 167 +++++++++++++++--- 7 files changed, 260 insertions(+), 46 deletions(-) create mode 100644 src/lib/Microsoft.Health.Fhir.Ingest.Validation/Models/ValidationCategory.cs diff --git a/src/lib/Microsoft.Health.Fhir.Ingest.Validation/Extensions/IResultExtensions.cs b/src/lib/Microsoft.Health.Fhir.Ingest.Validation/Extensions/IResultExtensions.cs index 655c4f9f..54f1d036 100644 --- a/src/lib/Microsoft.Health.Fhir.Ingest.Validation/Extensions/IResultExtensions.cs +++ b/src/lib/Microsoft.Health.Fhir.Ingest.Validation/Extensions/IResultExtensions.cs @@ -13,28 +13,28 @@ namespace Microsoft.Health.Fhir.Ingest.Validation.Extensions { public static class IResultExtensions { - public static void CaptureError(this IResult validationResult, string message, ErrorLevel errorLevel) + public static void CaptureError(this IResult validationResult, string message, ErrorLevel errorLevel, ValidationCategory category) { EnsureArg.IsNotNull(validationResult, nameof(validationResult)); EnsureArg.IsNotNullOrWhiteSpace(message, nameof(message)); - validationResult.Exceptions.Add(new ValidationError(message, errorLevel)); + validationResult.Exceptions.Add(new ValidationError(message, category, errorLevel)); } - public static void CaptureException(this IResult validationResult, Exception exception) + public static void CaptureException(this IResult validationResult, Exception exception, ValidationCategory category) { EnsureArg.IsNotNull(validationResult, nameof(validationResult)); EnsureArg.IsNotNull(exception, nameof(exception)); - validationResult.Exceptions.Add(new ValidationError(exception.Message)); + validationResult.Exceptions.Add(new ValidationError(exception.Message, category)); } - public static void CaptureWarning(this IResult validationResult, string warning) + public static void CaptureWarning(this IResult validationResult, string warning, ValidationCategory category) { EnsureArg.IsNotNull(validationResult, nameof(validationResult)); EnsureArg.IsNotNullOrWhiteSpace(warning, nameof(warning)); - validationResult.Exceptions.Add(new ValidationError(warning, ErrorLevel.WARN)); + validationResult.Exceptions.Add(new ValidationError(warning, category, ErrorLevel.WARN)); } public static IEnumerable GetErrors(this IResult validationResult, ErrorLevel errorLevel) diff --git a/src/lib/Microsoft.Health.Fhir.Ingest.Validation/IMappingValidator.cs b/src/lib/Microsoft.Health.Fhir.Ingest.Validation/IMappingValidator.cs index f1aa7f04..b6cb65a3 100644 --- a/src/lib/Microsoft.Health.Fhir.Ingest.Validation/IMappingValidator.cs +++ b/src/lib/Microsoft.Health.Fhir.Ingest.Validation/IMappingValidator.cs @@ -34,7 +34,8 @@ public interface IMappingValidator /// A collection of DeviceEvents. Optional /// A device mapping template. Optional /// A fhir mapping template. Optional + /// Indicates if DeviceResults should be aggregated /// A ValidationResult object - ValidationResult PerformValidation(IEnumerable deviceEvents, string deviceMappingContent, string fhirMappingContent); + ValidationResult PerformValidation(IEnumerable deviceEvents, string deviceMappingContent, string fhirMappingContent, bool aggregateDeviceEvents = false); } } \ No newline at end of file diff --git a/src/lib/Microsoft.Health.Fhir.Ingest.Validation/MappingValidator.cs b/src/lib/Microsoft.Health.Fhir.Ingest.Validation/MappingValidator.cs index 250755f2..4d1aaa12 100644 --- a/src/lib/Microsoft.Health.Fhir.Ingest.Validation/MappingValidator.cs +++ b/src/lib/Microsoft.Health.Fhir.Ingest.Validation/MappingValidator.cs @@ -38,13 +38,14 @@ public ValidationResult PerformValidation( string deviceMappingContent, string fhirMappingContent) { - return PerformValidation(new List() { deviceEvent }, deviceMappingContent, fhirMappingContent); + return PerformValidation(new List() { deviceEvent }, deviceMappingContent, fhirMappingContent, false); } public ValidationResult PerformValidation( IEnumerable deviceEvents, string deviceMappingContent, - string fhirMappingContent) + string fhirMappingContent, + bool aggregateDeviceEvents = false) { if (string.IsNullOrWhiteSpace(deviceMappingContent) && string.IsNullOrWhiteSpace(fhirMappingContent)) { @@ -77,13 +78,34 @@ public ValidationResult PerformValidation( return validationResult; } + ValidateDeviceEvents(deviceEvents, contentTemplate, fhirTemplate, validationResult, aggregateDeviceEvents); + + return validationResult; + } + + /// + /// Validates device events. This method then enriches the passed in ValidationResult object with DeviceResults. + /// + /// The device events to validate + /// The device mapping template + /// The fhir mapping template + /// The ValidationResult + /// Indicates if DeviceResults should be aggregated + protected virtual void ValidateDeviceEvents( + IEnumerable deviceEvents, + IContentTemplate contentTemplate, + ILookupTemplate fhirTemplate, + ValidationResult validationResult, + bool aggregateDeviceEvents) + { + var aggregatedDeviceResults = new Dictionary(); + foreach (var payload in deviceEvents) { if (payload != null && contentTemplate != null) { var deviceResult = new DeviceResult(); deviceResult.DeviceEvent = payload; - validationResult.DeviceResults.Add(deviceResult); ProcessDeviceEvent(payload, contentTemplate, deviceResult); @@ -91,13 +113,45 @@ public ValidationResult PerformValidation( { foreach (var m in deviceResult.Measurements) { - ProcessNormalizedeEvent(m, fhirTemplate, deviceResult); + ProcessNormalizedEvent(m, fhirTemplate, deviceResult); } } + + if (aggregateDeviceEvents) + { + /* + * During aggregation we group DeviceEvents by the exceptions that they produce. + * This allows us to return a DeviceResult with a sample Device Event payload, + * the running count grouped DeviceEvents and the exception that they are grouped by. + */ + foreach (var exception in deviceResult.Exceptions) + { + if (aggregatedDeviceResults.TryGetValue(exception.Message, out DeviceResult result)) + { + // If we've already seen this error message before, simply increment the running total + result.AggregatedCount++; + } + else + { + // Create a new DeviceResult to hold details about this new exception. + var aggregatedDeviceResult = new DeviceResult() + { + DeviceEvent = deviceResult.DeviceEvent, // A sample device event which exhibits the error + AggregatedCount = 1, + Exceptions = new List() { exception }, + }; + + aggregatedDeviceResults[exception.Message] = aggregatedDeviceResult; + validationResult.DeviceResults.Add(aggregatedDeviceResult); + } + } + } + else + { + validationResult.DeviceResults.Add(deviceResult); + } } } - - return validationResult; } private IContentTemplate LoadDeviceTemplate(string deviceMappingContent, TemplateResult validationResult) @@ -110,7 +164,7 @@ private IContentTemplate LoadDeviceTemplate(string deviceMappingContent, Templat } catch (Exception e) { - validationResult.CaptureException(e); + validationResult.CaptureException(e, ValidationCategory.NORMALIZATION); } return null; @@ -126,7 +180,7 @@ private ILookupTemplate LoadFhirTemplate(string fhirMappingConten } catch (Exception e) { - validationResult.CaptureException(e); + validationResult.CaptureException(e, ValidationCategory.FHIRTRANSFORMATION); } return null; @@ -168,23 +222,27 @@ private void CheckForTemplateCompatibility(IContentTemplate contentTemplate, ILo { if (!availableFhirValueNames.Contains(v.ValueName)) { - validationResult.CaptureWarning($"The value [{v.ValueName}] in Device Mapping [{extractor.Template.TypeName}] is not represented within the Fhir Template of type [{innerTemplate.TypeName}]. Available values are: [{availableFhirValueNamesDisplay}]. No value will appear inside of Observations."); + validationResult.CaptureWarning( + $"The value [{v.ValueName}] in Device Mapping [{extractor.Template.TypeName}] is not represented within the Fhir Template of type [{innerTemplate.TypeName}]. Available values are: [{availableFhirValueNamesDisplay}]. No value will appear inside of Observations.", + ValidationCategory.FHIRTRANSFORMATION); } } } } catch (TemplateNotFoundException) { - validationResult.CaptureWarning($"No matching Fhir Template exists for Device Mapping [{extractor.Template.TypeName}]. Ensure case matches. Available Fhir Templates: [{availableFhirTemplates}]."); + validationResult.CaptureWarning( + $"No matching Fhir Template exists for Device Mapping [{extractor.Template.TypeName}]. Ensure case matches. Available Fhir Templates: [{availableFhirTemplates}].", + ValidationCategory.FHIRTRANSFORMATION); } catch (Exception e) { - validationResult.CaptureException(e); + validationResult.CaptureException(e, ValidationCategory.FHIRTRANSFORMATION); } } } - private void ProcessDeviceEvent(JToken deviceEvent, IContentTemplate contentTemplate, DeviceResult validationResult) + protected virtual void ProcessDeviceEvent(JToken deviceEvent, IContentTemplate contentTemplate, DeviceResult validationResult) { try { @@ -195,16 +253,16 @@ private void ProcessDeviceEvent(JToken deviceEvent, IContentTemplate contentTemp if (validationResult.Measurements.Count == 0) { - validationResult.CaptureWarning("No measurements were produced for the given device data."); + validationResult.CaptureWarning("No measurements were produced for the given device data.", ValidationCategory.NORMALIZATION); } } catch (Exception e) { - validationResult.CaptureException(e); + validationResult.CaptureException(e, ValidationCategory.NORMALIZATION); } } - private void ProcessNormalizedeEvent(Measurement normalizedEvent, ILookupTemplate fhirTemplate, DeviceResult validationResult) + protected virtual void ProcessNormalizedEvent(Measurement normalizedEvent, ILookupTemplate fhirTemplate, DeviceResult validationResult) { var measurementGroup = new MeasurementGroup { @@ -228,11 +286,12 @@ private void ProcessNormalizedeEvent(Measurement normalizedEvent, ILookupTemplat { validationResult.CaptureError( $"No Fhir Template exists with the type name [{e.Message}]. Ensure that all Fhir Template type names match Device Mapping type names (including casing)", - ErrorLevel.ERROR); + ErrorLevel.ERROR, + ValidationCategory.FHIRTRANSFORMATION); } catch (Exception e) { - validationResult.CaptureException(e); + validationResult.CaptureException(e, ValidationCategory.FHIRTRANSFORMATION); } } diff --git a/src/lib/Microsoft.Health.Fhir.Ingest.Validation/Models/DeviceResult.cs b/src/lib/Microsoft.Health.Fhir.Ingest.Validation/Models/DeviceResult.cs index 475d5fd7..14721892 100644 --- a/src/lib/Microsoft.Health.Fhir.Ingest.Validation/Models/DeviceResult.cs +++ b/src/lib/Microsoft.Health.Fhir.Ingest.Validation/Models/DeviceResult.cs @@ -19,5 +19,11 @@ public class DeviceResult : IResult public IList Observations { get; set; } = new List(); public IList Exceptions { get; set; } = new List(); + + /// + /// Indicates how many Device Events produced the associated Exception. This value is only set when aggregating DeviceEvent results. Otherwise + /// it will be zero. + /// + public int AggregatedCount { get; set; } } } diff --git a/src/lib/Microsoft.Health.Fhir.Ingest.Validation/Models/ValidationCategory.cs b/src/lib/Microsoft.Health.Fhir.Ingest.Validation/Models/ValidationCategory.cs new file mode 100644 index 00000000..0577ff90 --- /dev/null +++ b/src/lib/Microsoft.Health.Fhir.Ingest.Validation/Models/ValidationCategory.cs @@ -0,0 +1,20 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +namespace Microsoft.Health.Fhir.Ingest.Validation.Models +{ + public enum ValidationCategory + { + /// + /// Indicates that the area of validation is related to Normalization. + /// + NORMALIZATION, + + /// + /// Indicates that the area of validation is related to Fhir Transformation. + /// + FHIRTRANSFORMATION, + } +} \ No newline at end of file diff --git a/src/lib/Microsoft.Health.Fhir.Ingest.Validation/Models/ValidationError.cs b/src/lib/Microsoft.Health.Fhir.Ingest.Validation/Models/ValidationError.cs index 7439b5f2..f1f3101b 100644 --- a/src/lib/Microsoft.Health.Fhir.Ingest.Validation/Models/ValidationError.cs +++ b/src/lib/Microsoft.Health.Fhir.Ingest.Validation/Models/ValidationError.cs @@ -9,14 +9,17 @@ namespace Microsoft.Health.Fhir.Ingest.Validation.Models { public class ValidationError { - public ValidationError(string message, ErrorLevel errorLevel = ErrorLevel.ERROR) + public ValidationError(string message, ValidationCategory category, ErrorLevel errorLevel = ErrorLevel.ERROR) { Message = EnsureArg.IsNotNullOrWhiteSpace(message, nameof(message)); Level = errorLevel; + Category = category; } public string Message { get; } public ErrorLevel Level { get; } + + public ValidationCategory Category { get; } } } diff --git a/test/Microsoft.Health.Fhir.Ingest.Validation.UnitTests/MappingValidatorTests.cs b/test/Microsoft.Health.Fhir.Ingest.Validation.UnitTests/MappingValidatorTests.cs index 8b83ea47..8437318b 100644 --- a/test/Microsoft.Health.Fhir.Ingest.Validation.UnitTests/MappingValidatorTests.cs +++ b/test/Microsoft.Health.Fhir.Ingest.Validation.UnitTests/MappingValidatorTests.cs @@ -5,8 +5,10 @@ using System; using System.Collections.Generic; +using System.Linq; using Microsoft.Health.Fhir.Ingest.Template; using Microsoft.Health.Fhir.Ingest.Validation.Extensions; +using Microsoft.Health.Fhir.Ingest.Validation.Models; using Microsoft.Health.Tests.Common; using Newtonsoft.Json.Linq; using Xunit; @@ -118,8 +120,12 @@ public void When_Only_InvalidDeviceMapping_Provided_Only_DeviceMapping_Exception { var result = _iotConnectorValidator.PerformValidation(null, deviceMapping, null); Assert.Collection( - result.TemplateResult.GetErrors(Models.ErrorLevel.ERROR), - (error) => error.Message.Contains("Required property 'DeviceIdExpression' not found in JSON")); + result.TemplateResult.GetErrors(ErrorLevel.ERROR), + (error) => + { + Assert.Contains("Required property 'DeviceIdExpression' not found in JSON", error.Message); + Assert.Equal(ValidationCategory.NORMALIZATION, error.Category); + }); Assert.Empty(result.DeviceResults); } @@ -129,8 +135,12 @@ public void When_Only_InvalidFhirMapping_Provided_Only_FhirMapping_Exceptions_Lo { var result = _iotConnectorValidator.PerformValidation(null, null, fhirMapping); Assert.Collection( - result.TemplateResult.GetErrors(Models.ErrorLevel.ERROR), - (error) => error.Message.Contains("Expected TemplateType value CollectionFhirTemplate, actual CodeValueFhir")); + result.TemplateResult.GetErrors(ErrorLevel.ERROR), + (error) => + { + Assert.Contains("Expected TemplateType value CollectionFhirTemplate, actual CodeValueFhir", error.Message); + Assert.Equal(ValidationCategory.FHIRTRANSFORMATION, error.Category); + }); Assert.Empty(result.DeviceResults); } @@ -142,9 +152,17 @@ public void Given_InvalidMappingFiles_Exceptions_Found(string deviceMapping, str // "Validation errors:\nExpected TemplateType value CollectionFhirTemplate, actual CodeValueFhir." var result = _iotConnectorValidator.PerformValidation(null, deviceMapping, fhirMapping); Assert.Collection( - result.TemplateResult.GetErrors(Models.ErrorLevel.ERROR), - (error) => error.Message.Contains("Required property 'DeviceIdExpression' not found in JSON"), - (error) => error.Message.Contains("Expected TemplateType value CollectionFhirTemplate, actual CodeValueFhir")); + result.TemplateResult.GetErrors(ErrorLevel.ERROR), + (error) => + { + Assert.Contains("Required property 'DeviceIdExpression' not found in JSON", error.Message); + Assert.Equal(ValidationCategory.NORMALIZATION, error.Category); + }, + (error) => + { + Assert.Contains("Expected TemplateType value CollectionFhirTemplate, actual CodeValueFhir", error.Message); + Assert.Equal(ValidationCategory.FHIRTRANSFORMATION, error.Category); + }); Assert.Empty(result.DeviceResults); } @@ -163,19 +181,27 @@ public void Given_ValidDeviceMapping_And_FhirMapping_WithMissingTypeName_Warning patient = "patient123", }); var result = _iotConnectorValidator.PerformValidation(token, deviceMapping, fhirMapping); - Assert.Empty(result.TemplateResult.GetErrors(Models.ErrorLevel.ERROR)); + Assert.Empty(result.TemplateResult.GetErrors(ErrorLevel.ERROR)); Assert.Collection( - result.TemplateResult.GetErrors(Models.ErrorLevel.WARN), - (error) => error.Message.Contains("No matching Fhir Template exists for Device Mapping [bloodpressure]")); + result.TemplateResult.GetErrors(ErrorLevel.WARN), + (error) => + { + Assert.Contains("No matching Fhir Template exists for Device Mapping [bloodpressure]", error.Message); + Assert.Equal(ValidationCategory.FHIRTRANSFORMATION, error.Category); + }); Assert.Collection(result.DeviceResults, d => { Assert.Collection( - d.GetErrors(Models.ErrorLevel.ERROR), - e => e.Message.StartsWith("No Fhir Template exists with the type name [bloodpressure]")); + d.GetErrors(ErrorLevel.ERROR), + (error) => + { + Assert.Contains("No Fhir Template exists with the type name [bloodpressure]", error.Message); + Assert.Equal(ValidationCategory.FHIRTRANSFORMATION, error.Category); + }); Assert.Single(d.Measurements); Assert.NotNull(d.DeviceEvent); Assert.Empty(d.Observations); - Assert.Empty(d.GetErrors(Models.ErrorLevel.WARN)); + Assert.Empty(d.GetErrors(ErrorLevel.WARN)); }); } @@ -205,18 +231,23 @@ public void Given_ValidDeviceMapping_And_FhirMapping_WithIncorrectValueName_Warn var result = _iotConnectorValidator.PerformValidation(new List() { token, token2 }, deviceMapping, fhirMapping); - Assert.Empty(result.TemplateResult.GetErrors(Models.ErrorLevel.ERROR)); + Assert.Empty(result.TemplateResult.GetErrors(ErrorLevel.ERROR)); Assert.Collection( - result.TemplateResult.GetErrors(Models.ErrorLevel.WARN), - (error) => error.Message.StartsWith("The value [systolic] in Device Mapping [bloodpressure] is not represented within the Fhir Template")); + result.TemplateResult.GetErrors(ErrorLevel.WARN), + (error) => + { + Assert.StartsWith("The value [systolic] in Device Mapping [bloodpressure] is not represented within the Fhir Template", error.Message); + Assert.Equal(ValidationCategory.FHIRTRANSFORMATION, error.Category); + }); Assert.Collection( result.DeviceResults, d => { + Assert.Equal(0, d.AggregatedCount); Assert.Single(d.Measurements); Assert.NotNull(d.DeviceEvent); - Assert.Empty(d.GetErrors(Models.ErrorLevel.WARN)); + Assert.Empty(d.GetErrors(ErrorLevel.WARN)); Assert.Empty(d.Exceptions); Assert.Collection(d.Observations, o => { @@ -234,7 +265,7 @@ public void Given_ValidDeviceMapping_And_FhirMapping_WithIncorrectValueName_Warn { Assert.Single(d.Measurements); Assert.NotNull(d.DeviceEvent); - Assert.Empty(d.GetErrors(Models.ErrorLevel.WARN)); + Assert.Empty(d.GetErrors(ErrorLevel.WARN)); Assert.Empty(d.Exceptions); Assert.Collection(d.Observations, o => { @@ -267,14 +298,108 @@ public void Given_ValidMappingFiles_And_Valid_DeviceMapping_NoMeasurementsAreCre Assert.Empty(result.TemplateResult.Exceptions); Assert.Collection(result.DeviceResults, d => { + Assert.Equal(0, d.AggregatedCount); Assert.Collection( - d.GetErrors(Models.ErrorLevel.WARN), - (error) => error.Message.StartsWith("No measurements were produced")); + d.GetErrors(ErrorLevel.WARN), + (error) => + { + Assert.Contains("No measurements were produced", error.Message); + Assert.Equal(ValidationCategory.NORMALIZATION, error.Category); + }); Assert.Empty(d.Measurements); Assert.Empty(d.Observations); Assert.NotNull(d.DeviceEvent); - Assert.Empty(d.GetErrors(Models.ErrorLevel.ERROR)); + Assert.Empty(d.GetErrors(ErrorLevel.ERROR)); + }); + } + + [Theory] + [FileData(@"TestInput/data_CollectionContentTemplateHrAndBloodPressureValid.json", @"TestInput/data_CollectionFhirTemplateValid.json")] + public void Given_ValidMappingFiles_And_Valid_DeviceMapping_When_BadDataIsSupplied_ErrorsAreAggregated(string deviceMapping, string fhirMapping) + { + var time = DateTime.UtcNow; + var tokens = Enumerable.Range(1, 10).Select(i => + { + if (i <= 5) + { + return JToken.FromObject(new + { + systolic = "60", + diastolic = "80", + date = time, + session = "abcdefg", + patient = "patient123", + }); + } + else + { + return JToken.FromObject(new + { + systolic = "60", + diastolic = "80", + device = $"abc{i}", + session = "abcdefg", + patient = "patient123", + }); + } }); + + var result = _iotConnectorValidator.PerformValidation(tokens, deviceMapping, fhirMapping, true); + Assert.Empty(result.TemplateResult.Exceptions); + Assert.Collection( + result.DeviceResults, + d => + { + Assert.NotEmpty(d.DeviceEvent); + Assert.Equal(5, d.AggregatedCount); + Assert.Collection( + d.GetErrors(ErrorLevel.ERROR), + (error) => + { + Assert.Contains("Unable to extract required value for [DeviceIdExpression] using $.device", error.Message); + Assert.Equal(ValidationCategory.NORMALIZATION, error.Category); + }); + Assert.Empty(d.Measurements); + Assert.Empty(d.Observations); + Assert.NotNull(d.DeviceEvent); + Assert.Empty(d.GetErrors(ErrorLevel.WARN)); + }, + d => + { + Assert.NotEmpty(d.DeviceEvent); + Assert.Equal(5, d.AggregatedCount); + Assert.Collection( + d.GetErrors(ErrorLevel.ERROR), + (error) => + { + Assert.Contains("Unable to extract required value for [TimestampExpression] using $.date", error.Message); + Assert.Equal(ValidationCategory.NORMALIZATION, error.Category); + }); + Assert.Empty(d.Measurements); + Assert.Empty(d.Observations); + Assert.NotNull(d.DeviceEvent); + Assert.Empty(d.GetErrors(ErrorLevel.WARN)); + }); + } + + [Theory] + [FileData(@"TestInput/data_CollectionContentTemplateHrAndBloodPressureValid.json", @"TestInput/data_CollectionFhirTemplateValid.json")] + public void Given_ValidMappingFiles_And_ValidDeviceData_When_Aggregating_NoErrorsAreAggregated(string deviceMapping, string fhirMapping) + { + var time = DateTime.UtcNow; + var tokens = Enumerable.Range(1, 10).Select(i => JToken.FromObject(new + { + systolic = "60", + diastolic = "80", + device = $"abc{i}", + date = time, + session = "abcdefg", + patient = "patient123", + })); + + var result = _iotConnectorValidator.PerformValidation(tokens, deviceMapping, fhirMapping, true); + Assert.Empty(result.TemplateResult.Exceptions); + Assert.Empty(result.DeviceResults); } } }