diff --git a/EvenBetterFastSim/EvenBetterFastSim.csproj b/EvenBetterFastSim/EvenBetterFastSim.csproj
index 5d60372..bc24521 100644
--- a/EvenBetterFastSim/EvenBetterFastSim.csproj
+++ b/EvenBetterFastSim/EvenBetterFastSim.csproj
@@ -45,8 +45,8 @@
-
-
+
+
diff --git a/EvenBetterFastSim/WPF/ViewModels/Responders/NodeResponderViewModel.cs b/EvenBetterFastSim/WPF/ViewModels/Responders/NodeResponderViewModel.cs
index 0c0f1a6..0b8f3cd 100644
--- a/EvenBetterFastSim/WPF/ViewModels/Responders/NodeResponderViewModel.cs
+++ b/EvenBetterFastSim/WPF/ViewModels/Responders/NodeResponderViewModel.cs
@@ -53,9 +53,6 @@ public partial class NodeResponderViewModel : ObservableObject, IBaseViewModel
private ResponderFieldMode fieldMode;
private Func>? fieldParameterLookup;
- /// Bindings mode: cloned message shapes keyed by the Receive node id that captured them.
- private readonly Dictionary triggerShapes = [];
-
/// Bindings mode: selectable parameters per Receive node id.
private readonly Dictionary> parametersByNode = [];
@@ -71,13 +68,13 @@ public partial class NodeResponderViewModel : ObservableObject, IBaseViewModel
/// First dropdown options โ the received messages available on this path.
public ObservableCollection SourceMessages { get; } = [];
- public ObservableCollection SampleInputs { get; } = [];
-
public IReadOnlyList ConditionKinds { get; } = Enum.GetValues();
public IReadOnlyList BindingSourceKinds { get; } = Enum.GetValues();
- [ObservableProperty] public partial string TestResult { get; set; } = string.Empty;
- public ObservableCollection TestPreviewFields { get; } = [];
+ /// Row selected in the field tree โ target for the Add / Duplicate / Delete / Move buttons.
+ [ObservableProperty] public partial ResponderFieldViewModel? SelectedField { get; set; }
+
+ partial void OnSelectedFieldChanged(ResponderFieldViewModel? value) => NotifyStructureCommands();
/// Row selected in the field tree โ target for the Add / Duplicate / Delete / Move buttons.
[ObservableProperty] public partial ResponderFieldViewModel? SelectedField { get; set; }
@@ -118,7 +115,6 @@ public void InitializeForSend(ScenarioNodeViewModel sendNode, IReadOnlyList();
@@ -177,8 +173,6 @@ private void BuildFields(SecsGemDataMessage? message, ResponderFieldMode mode,
private void CollectParameters(ResponderFieldViewModel field, string sourceNodeId, string sourceLabel, List into)
{
into.Add(new PathOption(field.Path, $"[{field.Path}] {field.DisplayLabel}", sourceNodeId));
- if (field.IsLeaf)
- SampleInputs.Add(new SampleInputViewModel(sourceNodeId, $"{sourceLabel} ยท [{field.Path}]", field.Path));
foreach (var child in field.Children)
CollectParameters(child, sourceNodeId, sourceLabel, into);
@@ -236,53 +230,223 @@ private static IEnumerable Flatten(IEnumerable CurrentConditions() =>
+ Flatten(Fields).Select(f => f.ToCondition()).OfType();
+
+ private IEnumerable CurrentBindings() =>
+ Flatten(Fields).Select(f => f.ToBinding()).OfType();
- [RelayCommand]
- private void RunTest()
+ // ---- structural message editing --------------------------------
+ //
+ // The toolbar in NodeResponderView drives these. Each one mutates `workingMessage`, then
+ // CommitStructuralChange rebuilds the field tree (so positional paths are recomputed) while
+ // carrying the user's in-progress condition / binding edits across by item identity.
+
+ /// Add Item targets a container: a selected List, or the message body when nothing is selected.
+ private bool CanAddChildItem() => workingMessage is not null && SelectedField is null or { IsList: true };
+ private bool HasSelectedField() => workingMessage is not null && SelectedField is not null;
+
+ private void NotifyStructureCommands()
+ {
+ AddItemCommand.NotifyCanExecuteChanged();
+ AddSiblingCommand.NotifyCanExecuteChanged();
+ DuplicateItemCommand.NotifyCanExecuteChanged();
+ DeleteItemCommand.NotifyCanExecuteChanged();
+ MoveItemUpCommand.NotifyCanExecuteChanged();
+ MoveItemDownCommand.NotifyCanExecuteChanged();
+ }
+
+ /// Adds a U1 item as the last child of the selected List (or of the message body).
+ [RelayCommand(CanExecute = nameof(CanAddChildItem))]
+ private void AddItem()
+ {
+ if (workingMessage is null) return;
+
+ ICanBeParent container = SelectedField is { IsList: true } listField ? listField.Item : workingMessage;
+ var newItem = SecsGemItem.Create(SecsGemItemFormatType.U1);
+ newItem.SetParent(container);
+ CommitStructuralChange(newItem);
+ }
+
+ /// Adds a U1 item as the next sibling of the selected item.
+ [RelayCommand(CanExecute = nameof(HasSelectedField))]
+ private void AddSibling()
+ {
+ if (SelectedField is not { } field || ContainerOf(field) is not { } location) return;
+
+ var newItem = SecsGemItem.Create(SecsGemItemFormatType.U1);
+ newItem.SetParent(location.Container);
+ MoveWithin(location.Container, ChildrenOf(location.Container).Count - 1, location.Index + 1);
+ CommitStructuralChange(newItem);
+ }
+
+ [RelayCommand(CanExecute = nameof(HasSelectedField))]
+ private void DuplicateItem()
+ {
+ if (SelectedField is not { } field || ContainerOf(field) is not { } location) return;
+
+ var clone = field.Item.Clone();
+ clone.SetParent(location.Container);
+ MoveWithin(location.Container, ChildrenOf(location.Container).Count - 1, location.Index + 1);
+ CommitStructuralChange(clone);
+ }
+
+ [RelayCommand(CanExecute = nameof(HasSelectedField))]
+ private void DeleteItem()
+ {
+ if (SelectedField is not { } field || ContainerOf(field) is not { } location) return;
+
+ var siblings = ChildrenOf(location.Container);
+ field.Item.SetParent(null);
+ var next = siblings.Count == 0 ? null : siblings[Math.Min(location.Index, siblings.Count - 1)] as SecsGemItem;
+ CommitStructuralChange(next);
+ }
+
+ [RelayCommand(CanExecute = nameof(CanMoveItemUp))]
+ private void MoveItemUp() => MoveSelected(-1);
+
+ private bool CanMoveItemUp() =>
+ SelectedField is { } f && ContainerOf(f) is { } location && location.Index > 0;
+
+ [RelayCommand(CanExecute = nameof(CanMoveItemDown))]
+ private void MoveItemDown() => MoveSelected(+1);
+
+ private bool CanMoveItemDown() =>
+ SelectedField is { } f && ContainerOf(f) is { } location
+ && location.Index < ChildrenOf(location.Container).Count - 1;
+
+ private void MoveSelected(int delta)
+ {
+ if (SelectedField is not { } field || ContainerOf(field) is not { } location) return;
+
+ var target = location.Index + delta;
+ if (target < 0 || target >= ChildrenOf(location.Container).Count) return;
+ MoveWithin(location.Container, location.Index, target);
+ CommitStructuralChange(field.Item);
+ }
+
+ private void OnFieldFormatChangeRequested(ResponderFieldViewModel field)
{
- TestPreviewFields.Clear();
- if (Mode != NodeResponderMode.Bindings || node?.Transaction is null || workingMessage is null)
+ // The change comes from a ComboBox selection commit; rebuilding Fields synchronously
+ // underneath it upsets the binding pipeline, so hop through the dispatcher when there is one.
+ var dispatcher = Application.Current?.Dispatcher;
+ if (dispatcher is null || dispatcher.CheckAccess() == false)
+ ApplyFormatChange(field);
+ else
+ dispatcher.BeginInvoke(() => ApplyFormatChange(field));
+ }
+
+ private void ApplyFormatChange(ResponderFieldViewModel field)
+ {
+ if (workingMessage is null || field.Format == field.Item.FormatType) return;
+ if (ContainerOf(field) is not { } location) return;
+
+ var replacement = SecsGemItem.Create(field.Format);
+ replacement.Description = field.Item.Description;
+ if (field.Format == SecsGemItemFormatType.List)
{
- TestResult = "Nothing to preview.";
- return;
+ // Preserve any children when a value item becomes a list.
+ foreach (var child in field.Item.Children.OfType().ToList())
+ child.SetParent(replacement);
+ }
+ else
+ {
+ // Best-effort value carry-over; parsing drops anything the new format can't represent.
+ replacement.SetValuesFromStrings(field.Item.GetStringValues());
}
- var samples = triggerShapes.ToDictionary(
- kvp => kvp.Key,
- kvp => (SecsGemDataMessage)kvp.Value.Clone());
+ var siblings = ChildrenOf(location.Container);
+ field.Item.SetParent(null);
+ replacement.SetParent(location.Container);
+ MoveWithin(location.Container, siblings.Count - 1, location.Index);
+ CommitStructuralChange(replacement);
+ }
+
+ /// The container (message body or a List item) holding a field, plus the field's index in it.
+ private readonly record struct FieldLocation(ICanBeParent Container, int Index);
+
+ private FieldLocation? ContainerOf(ResponderFieldViewModel field)
+ {
+ if (workingMessage is null) return null;
+
+ var segments = field.Path.Split('.');
+ if (!int.TryParse(segments[^1], out var index)) return null;
+
+ if (segments.Length == 1)
+ return new FieldLocation(workingMessage, index);
+
+ var parentPath = string.Join('.', segments[..^1]);
+ return SecsGemItemPath.TryResolve(workingMessage, parentPath, out var parentItem)
+ ? new FieldLocation(parentItem, index)
+ : null;
+ }
+
+ private static ObservableCollection ChildrenOf(ICanBeParent container) =>
+ (ObservableCollection)((IDataItem)container).Children;
+
+ private static void MoveWithin(ICanBeParent container, int from, int to)
+ {
+ var children = ChildrenOf(container);
+ if (from >= 0 && to >= 0 && from < children.Count && to < children.Count && from != to)
+ children.Move(from, to);
+ }
- foreach (var sample in SampleInputs)
+ ///
+ /// Rebuilds the field tree from the mutated , re-applies the user's
+ /// pending condition / binding edits (keyed by item identity, stable across the rebuild), and
+ /// re-selects .
+ ///
+ private void CommitStructuralChange(SecsGemItem? itemToSelect)
+ {
+ structureDirty = true;
+
+ var conditionEdits = new Dictionary();
+ var bindingEdits = new Dictionary();
+ foreach (var field in Flatten(Fields))
{
- if (samples.TryGetValue(sample.SourceNodeId, out var message)
- && SecsGemItemPath.TryResolve(message, sample.ItemPath, out var item))
+ if (fieldMode == ResponderFieldMode.Trigger)
{
- item.SetValuesFromStrings(sample.Value.Split(',', StringSplitOptions.RemoveEmptyEntries));
+ if (field.Condition != ConditionKind.Any)
+ conditionEdits[field.Item] = (field.Condition, field.ConditionValue);
+ }
+ else
+ {
+ bindingEdits[field.Item] =
+ (field.Source, field.StaticValue, field.SourceMessage?.NodeId, field.SourceParameter?.Path);
}
}
- var fallback = samples.Values.LastOrDefault();
- SecsGemDataMessage? Resolve(string? id) =>
- id is not null && samples.TryGetValue(id, out var m) ? m : fallback;
-
- var outgoing = (SecsGemDataMessage)workingMessage.Clone();
- foreach (var binding in CurrentBindings())
- binding.Apply(outgoing, Resolve);
+ BuildFields(workingMessage, fieldMode, fieldParameterLookup);
- var index = 0;
- foreach (var item in outgoing.Children.OfType())
+ foreach (var field in Flatten(Fields))
{
- TestPreviewFields.Add(new ResponderFieldViewModel(item, index.ToString(), ResponderFieldMode.Response));
- index++;
- }
- TestResult = $"Resolved {node.Transaction.PrimaryMessage.Name}";
- }
+ if (conditionEdits.TryGetValue(field.Item, out var condition))
+ {
+ field.Condition = condition.Kind;
+ field.ConditionValue = condition.Value;
+ }
- private IEnumerable CurrentConditions() =>
- Flatten(Fields).Select(f => f.ToCondition()).OfType();
+ if (bindingEdits.TryGetValue(field.Item, out var binding))
+ {
+ field.Source = binding.Source;
+ if (binding.Source == BindingSourceKind.Literal)
+ {
+ field.StaticValue = binding.StaticValue;
+ }
+ else
+ {
+ field.SourceMessage = SourceMessages.FirstOrDefault(m => m.NodeId == binding.NodeId)
+ ?? SourceMessages.FirstOrDefault();
+ field.SourceParameter = field.AvailableParameters.FirstOrDefault(p => p.Path == binding.ParamPath);
+ }
+ }
+ }
- private IEnumerable CurrentBindings() =>
- Flatten(Fields).Select(f => f.ToBinding()).OfType();
+ SelectedField = itemToSelect is null
+ ? null
+ : Flatten(Fields).FirstOrDefault(f => ReferenceEquals(f.Item, itemToSelect));
+ NotifyStructureCommands();
+ }
// ---- structural message editing --------------------------------
//
@@ -535,14 +699,3 @@ private void AcceptButtonClick()
[RelayCommand]
private void CancelClick() => CloseAction?.Invoke();
}
-
-/// An incoming leaf plus a sample value, used by the Bindings-mode "Test" button.
-public partial class SampleInputViewModel(string sourceNodeId, string label, string itemPath) : ObservableObject
-{
- public string SourceNodeId { get; } = sourceNodeId;
- public string Label { get; } = label;
- public string ItemPath { get; } = itemPath;
-
- [ObservableProperty]
- public partial string Value { get; set; } = string.Empty;
-}
diff --git a/EvenBetterFastSim/WPF/ViewModels/Responders/ResponderFieldViewModel.cs b/EvenBetterFastSim/WPF/ViewModels/Responders/ResponderFieldViewModel.cs
index 2c0b4d0..59e19ec 100644
--- a/EvenBetterFastSim/WPF/ViewModels/Responders/ResponderFieldViewModel.cs
+++ b/EvenBetterFastSim/WPF/ViewModels/Responders/ResponderFieldViewModel.cs
@@ -83,10 +83,6 @@ public string DisplayLabel
[ObservableProperty]
public partial string ConditionValue { get; set; } = string.Empty;
- /// Value used for this leaf when the editor's "Test" builds a sample incoming message.
- [ObservableProperty]
- public partial string SampleValue { get; set; } = string.Empty;
-
// --- Response mode -----------------------------------------------------
[ObservableProperty]
public partial BindingSourceKind Source { get; set; } = BindingSourceKind.Literal;
@@ -120,13 +116,8 @@ public ResponderFieldViewModel(
this.parameterLookup = parameterLookup;
InitialLeafValue = IsLeaf ? string.Join(",", item.GetStringValues()) : string.Empty;
- if (IsLeaf)
- {
- if (mode == ResponderFieldMode.Response)
- StaticValue = InitialLeafValue;
- else
- SampleValue = InitialLeafValue;
- }
+ if (IsLeaf && mode == ResponderFieldMode.Response)
+ StaticValue = InitialLeafValue;
var index = 0;
foreach (var child in item.Children.OfType())
diff --git a/EvenBetterFastSim/WPF/Windows/NodeResponderView.xaml b/EvenBetterFastSim/WPF/Windows/NodeResponderView.xaml
index 461612e..1c6abaf 100644
--- a/EvenBetterFastSim/WPF/Windows/NodeResponderView.xaml
+++ b/EvenBetterFastSim/WPF/Windows/NodeResponderView.xaml
@@ -53,7 +53,7 @@
ToolTip="Fixed value for this item"
Text="{Binding StaticValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
-