using System; using System.Collections; using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Runtime.CompilerServices; namespace OpenTap { static class ParameterManager { public class ScopeMember { public ITestStepParent Scope { get; } public IMemberData Member { get; } public ScopeMember(ITestStepParent scope, IMemberData member) { Scope = scope; Member = member; } string scopeName { get { switch (Scope) { case ITestStep step: return step.GetFormattedName(); case TestPlan _: return "Test plan"; case object x: return x.ToString(); default: return "null"; } } } public override string ToString() => $"'{Member.GetDisplayAttribute().Name}' from '{scopeName}'"; static IEnumerable getScopes(ITestStepParent step) { while (step != null) { yield return step; step = step.Parent; } } static ITestStepParent[] getCommonParents(ITestStepParent[] array) { if (array.Length == 0) return Array.Empty(); var firstArray = getScopes(array[0].Parent).Reverse().ToArray(); int cnt = firstArray.Length; for (int i = 1; i < array.Length; i++) { var secondArray = getScopes(array[i].Parent).Reverse().ToArray(); cnt = Math.Min(cnt, secondArray.Length); int j = 0; foreach (var item in secondArray) { if (item != firstArray[j]) { cnt = j; break; } j += 1; if (j == cnt) break; } } Array.Resize(ref firstArray, cnt); Array.Reverse(firstArray); return firstArray; } public static IEnumerable GetScopes(object source) { switch (source) { case ITestStepParent[] array: return getCommonParents(array); case ITestStepParent step: return getScopes(step.Parent); default: return Array.Empty(); } } public static (object Scope, IMemberData member) GetScope(object[] owners, IMemberData member) { foreach (var scope in GetScopes(owners)) { if (scope is ITestStepParent parent) { var forwardedMembers = TypeData.GetTypeData(scope) .GetMembers().OfType(); var f = forwardedMembers.FirstOrDefault(x => owners.All(y => x.ContainsMember((y, member)))); if(f != null) return (parent, f); } } return (null, null); } } [HelpLink("EditorHelp.chm::/CreatingATestPlan/Scoped Parameters/Readme.html")] public class NamingQuestion : ValidatingObject { public struct ScopeItem { public ITestStepParent Object { get; set; } public override string ToString() { if (Object is ITestStep step) return "Test Step '" + step.GetFormattedName() +"'"; if (Object is TestPlan plan) return "Test Plan '" + plan.Name +"'"; return null; } } public string Name { get; internal set; } [Display("Name", "Name of the parameter. Groups are delimited by \\'." + " This may be the same as an existing parameter name in which case they will be merged.")] [SuggestedValues(nameof(SuggestedNames))] //When merging the value type must be the same for all parameters." + // "\nIt cannot be the same as a non-parameter name. public string SelectedName { get => selectedName; set { if (value == "") return; // fixes a bug related to SuggestedValueAttribute selectedName = value; } } string selectedName; IEnumerable getMemberNames() { if (Scope.Object == null) return Enumerable.Empty(); return TypeData.GetTypeData(Scope.Object).GetMembers() .OfType() .Select(x => x.Name); } public IEnumerable SuggestedNames => new[] {defaultFullName} .Concat(getMemberNames()) .Distinct(); [Display("Scope", "The location of the parameter. This must be a parent of the step(s) containing the settings.")] [AvailableValues(nameof(AvailableScopes))] public ScopeItem Scope { get => scope; set { bool useDefaultName = SelectedName == defaultFullName; scope = value; if (useDefaultName) SelectedName = defaultFullName; } } ScopeItem scope; IEnumerable<(string message, string error)> getMessage() { var selectedName = SelectedName?.Trim() ?? ""; if(Scope.Object is ITestStepParent step) { string name; if (step is TestPlan plan) name = $"test plan '{plan.Name}'"; else name = $"test step '{(step as ITestStep)?.GetFormattedName()}'"; var existing = TypeData.GetTypeData(step).GetMember(selectedName.Trim()); var originalExisting = TypeData.GetTypeData(step).GetMember(defaultFullName); if (existing != null && (step != originalScope || !ReferenceEquals(originalExisting, existing))) { var t1 = existing?.TypeDescriptor; var t2 = member?.TypeDescriptor; if (t1 != null && t2 != null && typeComparison(t1, t2) != null) { var error = $"Cannot merge properties of this kind."; yield return (error, error); yield break; } var val = existing.GetValue(step); var cloner = new ObjectCloner(val); if (member.HasAttribute() || existing.HasAttribute()) { var error = $"The selected property does not support merging."; yield return (error, error); yield break; } if (member is IParameterMemberData memberAsParameter && originalExisting is IParameterMemberData targetParameter) { // Verify that a parameter does not get merged with another parameter that has it in its list of dependencies. (cyclic dependency). var dependencyList = Utils.FlattenHeirarchy( targetParameter.ParameterizedMembers.Select(x => x.Member) .OfType(), x => x.ParameterizedMembers.Select(x2 => x2.Member).OfType()); if (dependencyList.Contains(memberAsParameter)) { var error = $"The selected scope's parameter cannot be merged with a parameterization that it depends on."; yield return (error, error); yield break; } } if( cloner.CanClone(step, member.TypeDescriptor) == false) { var error = $"Cannot merge properties of this kind."; yield return (error, error); yield break; } else { yield return ($"Merge with an existing parameter on {name}.", null); } } else if(!isEdit) yield return ($"Create new parameter on {name}.", null); if (isEdit) { var availSettingsCount = AvailableSettings.Count(); if (Settings.Count == 0) { yield return ("Remove the parameter.", null); yield break; } if (Settings.Count < availSettingsCount) yield return ("Remove settings from being controlled by the parameter.", null); if (step == originalScope) { if (Equals(defaultFullName, selectedName) == false) yield return ($"Rename parameter to '{SelectedName}'.", null); } else yield return ($"Move parameter to {name}.", null); } } } [Display("Message", Order: 1)] [Layout(LayoutMode.FullRow | LayoutMode.WrapText, 3)] [Browsable(true)] public string Message => string.Join("\n", getMessage().Select(x => x.message)); public IEnumerable AvailableSettings => scopeMembers; public bool SelectedSettingAvailable => scopeMembers != null; [AvailableValues(nameof(AvailableSettings))] [EnabledIf(nameof(SelectedSettingAvailable), true, HideIfDisabled = true)] [Display("Settings", "Select which settings are controlled by this parameter. To remove the parameter, deselect all of them.")] public List Settings { get; set; } public IEnumerable AvailableScopes => ScopeMember.GetScopes(source).OfType() .Select(x => new ScopeItem{Object = x}); readonly ITestStepParent[] source; readonly string defaultName; // default group (if step parameter) readonly string[] defaultGroup; public string OverrideDefaultName; string defaultFullName { get { if (OverrideDefaultName != null) return OverrideDefaultName; return string.Join(" \\ ", defaultGroup.Append(defaultName).Select(x => x.Trim())); } } HashSet numericTypes = new HashSet() { TypeData.FromType(typeof(decimal)), TypeData.FromType(typeof(double)), TypeData.FromType(typeof(float)), TypeData.FromType(typeof(long)), TypeData.FromType(typeof(int)), TypeData.FromType(typeof(short)), TypeData.FromType(typeof(sbyte)), TypeData.FromType(typeof(ulong)), TypeData.FromType(typeof(uint)), TypeData.FromType(typeof(ushort)), TypeData.FromType(typeof(byte)) }; string typeComparison(ITypeData firstType, ITypeData secondType) { if (Equals(firstType, secondType)) return null; if (numericTypes.Contains(firstType) && numericTypes.Contains(secondType)) return null; return "Value types must match to support merging parameters"; } string validateName() { if (string.IsNullOrWhiteSpace(SelectedName)) return "Name cannot be left empty."; return null; } IMemberData member; ScopeMember[] scopeMembers; public string GetError() => getMessage().FirstOrDefault(x => x.error != null).error; bool isEdit => scopeMembers != null; object originalScope; public NamingQuestion(ITestStepParent[] source, IMemberData member, ScopeMember[] scopeMembers = null, object originalScope = null) { Rules.Add(() => validateName() == null, validateName, nameof(SelectedName)); Rules.Add(() => GetError() == null, () => getMessage().FirstOrDefault(x => x.error != null).error, nameof(Message)); this.member = member; this.source = source; this.scopeMembers = scopeMembers; this.originalScope = originalScope; Settings = scopeMembers?.ToList(); var display = member.GetDisplayAttribute(); Name = "Parameterize '" + display.Name + "'"; if (display.Group.FirstOrDefault() == "Parameters") defaultGroup = display.Group; else defaultGroup = new[] {"Parameters"}.Concat(display.Group).ToArray(); defaultName = display.Name; Scope = AvailableScopes.FirstOrDefault(); SelectedName = defaultFullName; } [Submit] [Layout(LayoutMode.FloatBottom | LayoutMode.FullRow)] public OkCancel Response { get; set; } } public enum OkCancel { [Display("OK", "Accept the selected configuration.")] Ok = 1, [Display("Cancel", "Discard changes to the test plan.")] Cancel = 2 } static TraceSource log = Log.CreateSource("Parameter"); [ThreadStatic] private static bool parameterSanityCheckDelayed; [ThreadStatic] private static bool quickSanityCheck; public static IDisposable WithSanityCheckDelayed() => WithSanityCheckDelayed(false); public static IDisposable WithSanityCheckDelayed(bool quickCheck) { if (parameterSanityCheckDelayed) return Utils.WithDisposable(() => { }); parameterSanityCheckDelayed = true; quickSanityCheck = quickCheck; return Utils.WithDisposable(() => parameterSanityCheckDelayed = false); } public static void Parameterize(ITestStepParent scope, IMemberData targetMember, ITestStepParent[] source, string selectedName) { var currentMember = TypeData.GetTypeData(scope).GetMember(selectedName); object currentValue = currentMember?.GetValue(scope); using (WithSanityCheckDelayed()) { foreach (var src in source) { // Try to fetch the member, multi select might make some members hide each-other non-perfectly. var member = TypeData.GetTypeData(src).GetMember(targetMember.Name) ?? targetMember; var newMember = member.Parameterize(scope, src, selectedName); if (currentValue != null) newMember.SetValue(scope, currentValue); } } } public static void CreateParameter(ITestStepMenuModel s, ITestStepParent preselectedScope, bool showDialog) { var source = s.Source; var parameterUserRequest = new NamingQuestion(source, s.Member); if (preselectedScope != null) parameterUserRequest.Scope = new NamingQuestion.ScopeItem {Object = preselectedScope}; if (showDialog) UserInput.Request(parameterUserRequest, modal: true); parameterUserRequest.SelectedName = parameterUserRequest.SelectedName.Trim(); if (parameterUserRequest.Response == OkCancel.Cancel) return; var err = parameterUserRequest.Error; if (string.IsNullOrWhiteSpace(err) == false) { log.Error("{0}", err); return; } var scope = parameterUserRequest.Scope.Object; Parameterize(scope, s.Member, source, parameterUserRequest.SelectedName); } public static bool UnmergableListType(IMemberData property) { var propertyType = property.TypeDescriptor; // merging IEnumerable is not supported (unless its a string). if (propertyType.DescendsTo(typeof(IEnumerable))) { if (propertyType.IsA(typeof(string)) == false) { var elementType = propertyType.AsTypeData()?.ElementType; if (elementType == null || elementType.IsNumeric == false) return true; } } return false; } public static void EditParameter(ITestStepMenuModel ui) { var member = (ParameterMemberData)ui.Member; var scopeMembers = member.ParameterizedMembers.Select(x => new ScopeMember((ITestStepParent)x.Source, x.Member)).ToArray(); var parameterUserRequest = new NamingQuestion(scopeMembers.Select(x => (ITestStepParent)x.Scope).ToArray(), ui.Member, scopeMembers, originalScope: ui.Source.First()) { Name = $"Edit Parameter '{ui.Member.GetDisplayAttribute().Name}'" }; parameterUserRequest.Scope = new NamingQuestion.ScopeItem { Object = ui.Source.First() }; parameterUserRequest.SelectedName = ui.Member.Name; parameterUserRequest.OverrideDefaultName = ui.Member.Name; var prevScope = parameterUserRequest.Scope; UserInput.Request(parameterUserRequest, true); if (parameterUserRequest.Response == OkCancel.Cancel || string.IsNullOrWhiteSpace(parameterUserRequest.Name)) return; var err = parameterUserRequest.Error; if (string.IsNullOrWhiteSpace(err) == false) { log.Error("{0}", err); return; } using (WithSanityCheckDelayed()) { foreach (var oldScopeMember in scopeMembers) { if (parameterUserRequest.SelectedName != member.Name || parameterUserRequest.Settings.Contains(oldScopeMember) == false || prevScope.Object != parameterUserRequest.Scope.Object) oldScopeMember.Member.Unparameterize((ParameterMemberData) ui.Member, oldScopeMember.Scope); } var target = parameterUserRequest.Scope.Object; var selectedName = parameterUserRequest.SelectedName; bool anyCreated = false; foreach (var newScopeMember in parameterUserRequest.Settings) { var scope = newScopeMember.Scope; if (selectedName != member.Name || scopeMembers.Contains(newScopeMember) == false || prevScope.Object != target) { newScopeMember.Member.Parameterize(target, scope, selectedName); anyCreated = true; } } if (anyCreated) { // Update the value, to make sure all parameterized properties // has the right value. var param = TypeData.GetTypeData(target).GetMember(selectedName); param.SetValue(target, param.GetValue(target)); } } var step = ui.Source.First(); checkParameterSanity(step); } public static bool CanUnparameter(ITestStepMenuModel menuItemModel) => CanUnparameter(menuItemModel.Member, menuItemModel.Source); public static bool CanParameter(ITestStepMenuModel menuItemModel) => CanParameter(menuItemModel.Member, menuItemModel.Source); public static bool CanEditParameter(ITestStepMenuModel menuItemModel) => IsValidParameter(menuItemModel.Member, menuItemModel.Source, false); static bool isParameterized(ITestStepParent item, IMemberData member) => item.GetParents().Any(parent => TypeData.GetTypeData(parent).GetMembers().OfType() .Any(x => x.ContainsMember(item, member))); static bool IsValidParameter(IMemberData property, ITestStepParent[] steps, bool checkTestPlan = true) { if (steps.Length == 0) return false; if (property == null) return false; if (property.HasAttribute()) { // XmlIgnored properties cannot be serialized, so external property does not work for them. return false; } if (property.HasAttribute()) return false; foreach (var x in steps) { if (property.Readable == false || property.Writable == false) return false; if (x is ITestStep step && step.IsReadOnly) return false; if (checkTestPlan && x is TestPlan) return false; } return true; } public static bool CanParameter(IMemberData property, ITestStepParent[] steps) { if (IsValidParameter(property, steps) == false) return false; return !steps.Any(step => isParameterized(step, property)); } public static bool CanUnparameter(IMemberData property, ITestStepParent[] steps) { if (IsValidParameter(property, steps) == false) return false; return steps.Any(step => isParameterized(step, property)); } public static bool CanAutoParameterize(IMemberData property, ITestStepParent[] steps ) { if (steps.Length == 0) return false; if (property == null) return false; var parameterUserRequest = new NamingQuestion(steps, property); var err = parameterUserRequest.Error; if (string.IsNullOrWhiteSpace(err) == false) return false; return true; } public static void RemoveParameter(ITestStepMenuModel data) { if (data.Member is ParameterMemberData member) { foreach (var (obj, mem) in member.ParameterizedMembers.ToArray()) mem.Unparameterize(member, obj); } else { throw new Exception("Invalid parameter"); } } public static void Unparameterize(ITestStepMenuModel data) { List targets = new List(); using (WithSanityCheckDelayed()) { foreach (var src in data.Source) { IMemberData property; var (scope, member) = ScopeMember.GetScope(new[] {src}, data.Member); if (scope == null || member == null) { // this may occur if src does not actually have the right member, but just a member with the same name. // in this case we just extract the member with the right name and use that. property = TypeData.GetTypeData(src).GetMember(data.Member.Name); (scope, member) = ScopeMember.GetScope(new[] { src }, property); } else { property = data.Member; } if (scope is ITestStepParent p) { targets.Add(p); property.Unparameterize((ParameterMemberData) member, src); } } } foreach (var src in data.Source.Concat(targets)) { checkParameterSanity(src); } } class ChangeId { public int Value { get; set; } } // used to store test plan change ids // if the test plan did not change since last sanity check, // then we can do it a lot faster. static readonly ConditionalWeakTable recordedChangeIds = new ConditionalWeakTable(); public static bool CheckParameterSanity(ParameterMemberData parameter, bool overrideCache = false) { if (parameter.Target is ITestStepParent par) return CheckParameterSanity(par, new IMemberData[] {parameter}, overrideCache); return false; } public static bool CheckParameterSanity(ITestStepParent step, ICollection parameters, bool overrideCache = false) { if (parameterSanityCheckDelayed) { // in some cases we may want to do a sanity check even if it has been delayed. if(quickSanityCheck == false) return true; var changeid = recordedChangeIds.GetValue(step, x => new ChangeId()); if (changeid.Value == step.ChildTestSteps.ChangeId) return true; } foreach (var elem in parameters) { if (elem is ParameterMemberData) return checkParameterSanity(step, parameters, overrideCache); } return true; } /// /// Verify that source of a declared parameter on a parent also exists in the step hierarchy. /// public static bool checkParameterSanity(ITestStepParent step, ICollection parameters, bool overrideCache = false) { bool isSane = true; var changeid = recordedChangeIds.GetValue(step, x => new ChangeId()); foreach (var _item in parameters.ToArray()) { if (_item is ParameterMemberData item) { if (changeid.Value == step.ChildTestSteps.ChangeId && item.AnyDynamicMembers == false && !overrideCache) { continue; } foreach (var fwd in item.ParameterizedMembers) { // Multiple situations possible. // 1. the step is no longer a child of the parent to which it has parameterized a setting. // 2. the member of a parameter no longer exists. // 3. the child has been deleted from the step heirarchy. var parameterSource = fwd.Source as ITestStepParent; if (parameterSource == null) continue; var member = fwd.Member; bool isParent = false; if (parameterSource == step) { // the parameter is parementerized on itself. isParent = true; } else if (changeid.Value != step.ChildTestSteps.ChangeId || overrideCache) { // now iterate up to confirm that there is an unbroken chain between the parameter source and target. var stepi = parameterSource; while (stepi != null) { if (stepi.Parent == step) { isParent = true; break; } stepi = stepi.Parent; } } else { isParent = true; } if (member is IParameterMemberData) isSane &= CheckParameterSanity(parameterSource, [member]); bool memberDisposed = member is IDynamicMemberData dynamicMember && dynamicMember.IsDisposed; if (!memberDisposed && member is EmbeddedMemberData emb) { // This is a special case, if the member is not as such dynamic, but an embedded member, then the owner member could have been disposed. if (emb.OwnerMember is IDynamicMemberData dynamicMember2 && dynamicMember2.IsDisposed) memberDisposed = true; } if (memberDisposed || isParent == false ) { member.Unparameterize(item, parameterSource); if (!isParent) log.Info("Step {0} is no longer a child step of the parameter owner. Removing from {1}.", (parameterSource as ITestStep)?.GetFormattedName() ?? parameterSource?.ToString(), item.Name); else log.Warning("Member {0} no longer exists, unparameterizing member.", member.Name); isSane = false; } } } } // only update the change id for this step if sanity check passed. if(isSane) changeid.Value = step.ChildTestSteps.ChangeId; return isSane; } static bool checkParameterSanity(ITestStepParent step) { if (step == null) return true; ParameterMemberData[] parameters; using(WithSanityCheckDelayed()) // sanity checks are being done inside check members. parameters = TypeData.GetTypeData(step).GetMembers().OfType().ToArray(); var isSane = CheckParameterSanity(step, parameters); if(step.Parent is ITestStepParent parent) return checkParameterSanity(parent) & isSane; return isSane; } public class SettingsName : IStringReadOnlyValueAnnotation { AnnotationCollection annotation; public SettingsName(AnnotationCollection a) => annotation = a; public string Value { get { var members = annotation.Get()?.Value as List; if (members == null) return ""; return string.Join(", ", members.Select(x => x.ToString())); } } } } }