diff --git a/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.ContextMenu.cs b/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.ContextMenu.cs
index 63edac4..f067455 100644
--- a/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.ContextMenu.cs
+++ b/src/XTMF2.GUI/Controls/ModelSystemCanvas/ModelSystemCanvas.ContextMenu.cs
@@ -548,6 +548,13 @@ el is NodeViewModel envm && !envm.IsParameterNode
InvalidateAndMeasure();
};
+ var expandItem = new MenuItem { Header = "Expand Function Instance" };
+ expandItem.Click += async (_, _) =>
+ {
+ await vm.ExpandFunctionInstanceAsync(capturedFi);
+ InvalidateAndMeasure();
+ };
+
var renameItem = new MenuItem { Header = "Rename…" };
renameItem.Click += async (_, _) =>
{
@@ -579,6 +586,7 @@ el is NodeViewModel envm && !envm.IsParameterNode
menu.Items.Add(new Separator());
menu.Items.Add(openTemplateItem);
+ menu.Items.Add(expandItem);
menu.Items.Add(renameItem);
menu.Items.Add(moveFiItem);
menu.Items.Add(disableFiItem);
diff --git a/src/XTMF2.GUI/ViewModels/FunctionInstanceViewModel.cs b/src/XTMF2.GUI/ViewModels/FunctionInstanceViewModel.cs
index a5ecab1..4013016 100644
--- a/src/XTMF2.GUI/ViewModels/FunctionInstanceViewModel.cs
+++ b/src/XTMF2.GUI/ViewModels/FunctionInstanceViewModel.cs
@@ -35,6 +35,12 @@ namespace XTMF2.GUI.ViewModels;
///
public sealed partial class FunctionInstanceViewModel : ObservableObject, ICanvasElement
{
+ // Keep these in sync with ModelSystemCanvas function-instance layout constants.
+ private const double DefaultWidth = 120.0;
+ private const double DefaultHeight = 50.0;
+ private const double HeaderHeight = 28.0;
+ private const double HookRowHeight = 16.0;
+
/// The underlying model object.
public FunctionInstance UnderlyingInstance { get; }
@@ -52,10 +58,22 @@ public sealed partial class FunctionInstanceViewModel : ObservableObject, ICanva
public double Y => _previewY ?? (double)UnderlyingInstance.Location.Y;
/// Rendered width; defaults to 120 when the stored value is 0.
- public double Width => _previewW ?? (UnderlyingInstance.Location.Width is 0 ? 120.0 : (double)UnderlyingInstance.Location.Width);
+ public double Width => _previewW ?? (UnderlyingInstance.Location.Width is 0 ? DefaultWidth : (double)UnderlyingInstance.Location.Width);
- /// Rendered height; defaults to 50 when the stored value is 0.
- public double Height => _previewH ?? (UnderlyingInstance.Location.Height is 0 ? 50.0 : (double)UnderlyingInstance.Location.Height);
+ ///
+ /// Minimum height needed to show the FI header and all function-parameter hook rows.
+ ///
+ public double MinimumHeight => Math.Max(DefaultHeight, HeaderHeight + FunctionParameters.Count * HookRowHeight);
+
+ /// Rendered height; never below .
+ public double Height
+ {
+ get
+ {
+ var h = _previewH ?? (UnderlyingInstance.Location.Height is 0 ? DefaultHeight : (double)UnderlyingInstance.Location.Height);
+ return Math.Max(h, MinimumHeight);
+ }
+ }
public double CenterX => X + Width / 2.0;
public double CenterY => Y + Height / 2.0;
@@ -129,7 +147,12 @@ private void OnTemplatePropertyChanged(object? sender, PropertyChangedEventArgs
}
private void OnFunctionParametersChanged(object? sender, NotifyCollectionChangedEventArgs e)
- => SyncFunctionParameters();
+ {
+ SyncFunctionParameters();
+ OnPropertyChanged(nameof(Height));
+ OnPropertyChanged(nameof(MinimumHeight));
+ OnPropertyChanged(nameof(CenterY));
+ }
private void SyncFunctionParameters()
{
@@ -172,16 +195,18 @@ public void CommitMove()
_previewX = null;
_previewY = null;
var loc = UnderlyingInstance.Location;
- var w = loc.Width is 0 ? 120f : loc.Width;
- var h = loc.Height is 0 ? 50f : loc.Height;
+ var w = loc.Width is 0 ? (float)DefaultWidth : loc.Width;
+ var h = loc.Height is 0 ? (float)DefaultHeight : loc.Height;
+ if (h < (float)MinimumHeight) h = (float)MinimumHeight;
return new Rectangle((float)x, (float)y, w, h);
}
public void MoveTo(double x, double y)
{
var loc = UnderlyingInstance.Location;
- var w = loc.Width is 0 ? 120f : loc.Width;
- var h = loc.Height is 0 ? 50f : loc.Height;
+ var w = loc.Width is 0 ? (float)DefaultWidth : loc.Width;
+ var h = loc.Height is 0 ? (float)DefaultHeight : loc.Height;
+ if (h < (float)MinimumHeight) h = (float)MinimumHeight;
_session.SetFunctionInstanceLocation(_user, UnderlyingInstance,
new Rectangle((float)x, (float)y, w, h), out _);
}
@@ -190,8 +215,8 @@ public void MoveTo(double x, double y)
public void ResizeToPreview(double w, double h)
{
- _previewW = Math.Max(120.0, w);
- _previewH = Math.Max(50.0, h);
+ _previewW = Math.Max(DefaultWidth, w);
+ _previewH = Math.Max(MinimumHeight, h);
OnPropertyChanged(nameof(Width));
OnPropertyChanged(nameof(Height));
OnPropertyChanged(nameof(CenterX));
diff --git a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs
index 6867907..fa33389 100644
--- a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs
+++ b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs
@@ -2682,6 +2682,15 @@ public async Task DeleteFunctionInstanceAsync(FunctionInstanceViewModel fivm)
await ShowError("Delete Function Instance Failed", error);
}
+ ///
+ /// Expands (inlines) the given function instance back into regular boundary elements.
+ ///
+ public async Task ExpandFunctionInstanceAsync(FunctionInstanceViewModel fivm)
+ {
+ if (!Session.ExpandFunctionInstance(User, fivm.UnderlyingInstance, out var error))
+ await ShowError("Expand Function Instance Failed", error);
+ }
+
///
/// Navigates the canvas into 's
/// boundary so the user can
diff --git a/src/XTMF2/Editing/ModelSystemSession.cs b/src/XTMF2/Editing/ModelSystemSession.cs
index f138222..b10dadd 100644
--- a/src/XTMF2/Editing/ModelSystemSession.cs
+++ b/src/XTMF2/Editing/ModelSystemSession.cs
@@ -3834,24 +3834,57 @@ public bool ExtractToFunctionTemplate(
}
// ── Classify links ─────────────────────────────────────────────
- // External incoming: origin outside selection, destination (single) inside selection.
- var externalIncomingLinks = boundary.Links
- .Where(l => l is SingleLink sl
- && !selectedSet.Contains(l.Origin)
- && selectedSet.Contains(sl.Destination))
- .Cast()
- .ToList();
+ // External incoming: origin outside selection, destination inside selection.
+ // Supports both SingleLink and MultiLink originating from outside the selection.
+ // Each entry: (originalDestNode, redirectDelegate) where redirectDelegate(newDest)
+ // redirects that particular connection to newDest.
+ var incomingRedirects = new List<(Node OriginalDest, Action Redirect)>();
- if (externalIncomingLinks.Count != 1)
+ foreach (var link in boundary.Links)
+ {
+ if (selectedSet.Contains(link.Origin)) continue;
+
+ if (link is SingleLink inSl && selectedSet.Contains(inSl.Destination))
+ {
+ var capturedSl = inSl;
+ incomingRedirects.Add((inSl.Destination,
+ dest => capturedSl.SetDestination(dest, out _)));
+ }
+ else if (link is MultiLink inMl)
+ {
+ // Each destination inside the selection is an individual redirect.
+ // Replacing at the same index (remove + insert at i) doesn't shift other indices.
+ for (int mlDi = 0; mlDi < inMl.Destinations.Count; mlDi++)
+ {
+ if (!selectedSet.Contains(inMl.Destinations[mlDi])) continue;
+ var capturedMl = inMl;
+ var capturedIdx = mlDi;
+ var capturedDest = inMl.Destinations[mlDi];
+ incomingRedirects.Add((capturedDest, dest =>
+ {
+ capturedMl.RemoveDestination(capturedIdx);
+ capturedMl.AddDestination(dest, capturedIdx, out _);
+ }));
+ }
+ }
+ }
+
+ // All external incoming edges must lead to the same selected node (the entry node).
+ var distinctEntries = incomingRedirects.Select(r => r.OriginalDest).Distinct().ToList();
+ if (distinctEntries.Count == 0)
+ {
+ error = new CommandError("Extraction requires at least one external incoming link.");
+ return false;
+ }
+ if (distinctEntries.Count > 1)
{
error = new CommandError(
- $"Extraction requires exactly one external incoming link, " +
- $"but {externalIncomingLinks.Count} were found.");
+ $"Extraction requires all external incoming links to target the same selected node, " +
+ $"but {distinctEntries.Count} different destination nodes were found.");
return false;
}
- var externalIncoming = externalIncomingLinks[0];
- var entryNode = externalIncoming.Destination;
+ var entryNode = distinctEntries[0];
// Reject mixed MultiLinks that cross the selection boundary (some dests in, some out).
var ambiguousMulti = boundary.Links
@@ -3910,14 +3943,27 @@ public bool ExtractToFunctionTemplate(
ft!.SetLocation(functionTemplateLocation);
var internalBoundary = ft.InternalModules;
- // Step 2 – Remove external outgoing links from boundary so they don't follow
- // their origin nodes into InternalModules during the move.
- foreach (var link in externalOutgoing)
- boundary.RemoveLink(link, out _);
-
- // Step 3 – Move each selected node (and its hidden inline children) to
- // InternalModules. Their remaining outgoing links (internal ones
- // plus links to hidden children) also move.
+ // Step 2 – Set entry node so the FunctionInstance has the correct type when added.
+ ft.SetEntryNode(entryNode);
+
+ // Step 3 – Create FunctionInstance and redirect incoming links BEFORE removing
+ // any nodes from the boundary. This ensures that when the incoming
+ // MultiLink/SingleLink ObservableCollection events fire, both the old
+ // destination (still in boundary) and the new one (fi) are resolvable
+ // by the canvas VM, preventing a stale extra arrowhead.
+ boundary.AddFunctionInstance(functionInstanceName, ft, functionInstanceLocation,
+ out var fi, out _);
+ foreach (var (_, redirect) in incomingRedirects)
+ redirect(fi!);
+
+ // Step 4 – Remove external outgoing links from boundary so they don't follow
+ // their origin nodes into InternalModules during the move.
+ foreach (var link in externalOutgoing)
+ boundary.RemoveLink(link, out _);
+
+ // Step 5 – Move each selected node (and its hidden inline children) to
+ // InternalModules. Their remaining outgoing links (internal ones
+ // plus links to hidden children) also move.
var moveInfo = new List<(Node Node, List HiddenChildren, List MovedLinks)>();
foreach (var node in selectedNodes)
{
@@ -3965,7 +4011,7 @@ public bool ExtractToFunctionTemplate(
moveInfo.Add((node, hiddenChildren, nodeOutgoing));
}
- // Compute the bounding box of the selected nodes so we can place
+ // Compute bounding box of selected nodes so we can place
// FunctionParameters visibly above them, centred horizontally.
const float FpWidth = 120f;
const float FpHeight = 50f;
@@ -3986,7 +4032,7 @@ public bool ExtractToFunctionTemplate(
float fpStartX = (bboxMinX + bboxMaxX) / 2f - totalFpWidth / 2f;
float fpY = MathF.Max(0f, bboxMinY - FpHeight - 40f);
- // Step 4 – Create FunctionParameters and internal FP-destination links.
+ // Step 6 – Create FunctionParameters and internal FP-destination links.
var usedFpNames = new HashSet(StringComparer.Ordinal);
var fpData = new List<(FunctionParameter Fp, SingleLink ExternalLink, SingleLink InternalFpLink)>();
int fpIndex = 0;
@@ -4012,17 +4058,7 @@ public bool ExtractToFunctionTemplate(
fpData.Add((fp!, extLink, internalFpLink));
}
- // Step 5 – Create FunctionInstance.
- boundary.AddFunctionInstance(functionInstanceName, ft, functionInstanceLocation,
- out var fi, out _);
-
- // Step 6 – Set the entry node on the template.
- ft.SetEntryNode(entryNode);
-
- // Step 7 – Redirect the external incoming link to the new FunctionInstance.
- externalIncoming.SetDestination(fi!, out _);
-
- // Step 8 – Create links from the FunctionInstance's hooks to the external destinations.
+ // Step 7 – Create links from the FunctionInstance's hooks to the external destinations.
var fiOutgoingLinks = new List();
foreach (var (fp, extLink, _) in fpData)
{
@@ -4045,7 +4081,7 @@ public bool ExtractToFunctionTemplate(
var capturedFt = ft;
var capturedFi = fi!;
var capturedEntryNode = entryNode;
- var capturedExternalIn = externalIncoming;
+ var capturedIncomingRedirects = incomingRedirects;
var capturedFpData = fpData;
var capturedMoveInfo = moveInfo;
var capturedExternalOut = externalOutgoing;
@@ -4093,9 +4129,10 @@ public bool ExtractToFunctionTemplate(
foreach (var lk in capturedExternalOut)
boundary.AddLink(lk, out _);
- // 4. Restore external incoming link destination (fires PropertyChanged;
+ // 4. Restore external incoming link/destination (fires PropertyChanged;
// capturedEntryNode is now in the boundary so the VM resolves correctly).
- capturedExternalIn.SetDestination(capturedEntryNode, out _);
+ foreach (var (origDest, redirect) in capturedIncomingRedirects)
+ redirect(origDest);
// 5. Clear the entry node.
capturedFt.SetEntryNode(null);
@@ -4122,11 +4159,19 @@ public bool ExtractToFunctionTemplate(
boundary.AddFunctionTemplate(capturedFt, out _);
capturedFt.SetLocation(functionTemplateLocation);
- // Remove external outgoing links.
- foreach (var lk in capturedExternalOut)
- boundary.RemoveLink(lk, out _);
+ // Re-add FunctionInstance and set entry node, then redirect incoming
+ // links BEFORE moving nodes (mirrors the corrected forward-direction order
+ // so that stale MultiLink arrowheads are properly cleaned up on redo too).
+ boundary.AddFunctionInstance(capturedFi, out _);
+ capturedFt.SetEntryNode(capturedEntryNode);
+ foreach (var (_, redirect) in capturedIncomingRedirects)
+ redirect(capturedFi);
+
+ // Remove external outgoing links.
+ foreach (var lk in capturedExternalOut)
+ boundary.RemoveLink(lk, out _);
- // Move nodes to InternalModules.
+ // Move nodes to InternalModules.
foreach (var (node, hiddenChildren, movedLinks) in capturedMoveInfo)
{
foreach (var lk in movedLinks)
@@ -4154,16 +4199,7 @@ public bool ExtractToFunctionTemplate(
internalBoundary.AddLink(internalFpLink, out _);
}
- // Re-add FunctionInstance.
- boundary.AddFunctionInstance(capturedFi, out _);
-
- // Set entry node.
- capturedFt.SetEntryNode(capturedEntryNode);
-
- // Redirect external incoming link.
- capturedExternalIn.SetDestination(capturedFi, out _);
-
- // Re-add fi→external links.
+ // Re-add fi→external links.
foreach (var lk in capturedFiOutgoing)
boundary.AddLink(lk, out _);
@@ -4182,6 +4218,266 @@ public bool ExtractToFunctionTemplate(
/// The path to export the model system to.
/// The error message if the export fails.
/// True if the export was successful, false otherwise with error message.
+ ///
+ /// Expands (inlines) a back into the boundary it lives in.
+ /// This is the inverse of :
+ /// internal nodes are moved out, links are reconnected, and the template + instance are removed.
+ ///
+ public bool ExpandFunctionInstance(
+ User user,
+ FunctionInstance instance,
+ [NotNullWhen(false)] out CommandError? error)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+ ArgumentNullException.ThrowIfNull(instance);
+
+ lock (_sessionLock)
+ {
+ if (!_session.HasAccess(user))
+ {
+ error = new CommandError("The user does not have access to this project.", true);
+ return false;
+ }
+
+ var ft = instance.Template;
+ var boundary = instance.ContainedWithin!;
+ var internalBoundary = ft.InternalModules;
+
+ if (ft.EntryNode is null)
+ {
+ error = new CommandError(
+ $"Cannot expand '{instance.Name}': the template '{ft.Name}' has no entry node set.");
+ return false;
+ }
+
+ var allInstances = GetAllFunctionInstancesOf(ft);
+ if (allInstances.Count > 1)
+ {
+ error = new CommandError(
+ $"Cannot expand '{instance.Name}': template '{ft.Name}' has {allInstances.Count} instances. " +
+ $"Expansion is only supported when a single instance exists.");
+ return false;
+ }
+
+ var entryNode = ft.EntryNode;
+ var internalNodes = internalBoundary.Modules.ToList();
+
+ // Partition internal links: those targeting a FunctionParameter vs. the rest.
+ var allInternalLinks = internalBoundary.Links.ToList();
+ var fpLinks = allInternalLinks.OfType()
+ .Where(sl => sl.Destination is FunctionParameter)
+ .ToList();
+ var movedLinks = allInternalLinks.Except(fpLinks.Cast()).ToList();
+
+ // FI outgoing links: fi → external via FunctionParameterHook.
+ var fiOutgoingLinks = boundary.Links
+ .OfType()
+ .Where(sl => ReferenceEquals(sl.Origin, instance))
+ .ToList();
+
+ // Map each FunctionParameter to its external destination.
+ var fpToExternal = new Dictionary();
+ foreach (var sl in fiOutgoingLinks)
+ {
+ if (sl.OriginHook is FunctionParameterHook fph)
+ fpToExternal[fph.Parameter] = (fph, sl.Destination);
+ }
+
+ // For each FP-internal link, build the replacement direct link.
+ var newDirectLinks = new List();
+ foreach (var fpLink in fpLinks)
+ {
+ if (fpLink.Destination is FunctionParameter fp
+ && fpToExternal.TryGetValue(fp, out var ext))
+ {
+ newDirectLinks.Add(new SingleLink(fpLink.Origin, fpLink.OriginHook, ext.Dest, false));
+ }
+ }
+
+ // Incoming links pointing at the FI (to be redirected to entryNode).
+ var incomingToFi = GetLinksGoingTo(instance);
+ var multiLinkInfo = BuildMultiLinkRestoreInfo(incomingToFi, instance);
+
+ // ── Perform the expansion ─────────────────────────────────────────
+
+ // 1. Remove fi→external (FP outgoing) links from boundary.
+ foreach (var lk in fiOutgoingLinks)
+ boundary.RemoveLink(lk, out _);
+
+ // 2. Remove FP-destination links from internalBoundary.
+ foreach (var lk in fpLinks)
+ internalBoundary.RemoveLink(lk, out _);
+
+ // 3. Remove non-FP links from internalBoundary (they move to boundary).
+ foreach (var lk in movedLinks)
+ internalBoundary.RemoveLink(lk, out _);
+
+ // 4. Move internal nodes to boundary.
+ foreach (var node in internalNodes)
+ {
+ internalBoundary.RemoveNode(node, out _);
+ node.UpdateContainedWithin(boundary);
+ boundary.AddNode(node, out _);
+ }
+
+ // 5. Re-add moved links to boundary.
+ foreach (var lk in movedLinks)
+ boundary.AddLink(lk, out _);
+
+ // 6. Add replacement direct links (bypass FP) to boundary.
+ foreach (var lk in newDirectLinks)
+ boundary.AddLink(lk, out _);
+
+ // 7. Redirect all incoming links from fi to entryNode.
+ // Do this only after entryNode is back in the boundary so the canvas
+ // can resolve both old and new destinations during link VM updates.
+ foreach (var lk in incomingToFi)
+ {
+ if (lk is SingleLink sl)
+ {
+ sl.SetDestination(entryNode, out _);
+ }
+ else if (lk is MultiLink ml && multiLinkInfo.TryGetValue(ml, out var entries))
+ {
+ foreach (var (idx, _) in entries)
+ {
+ ml.RemoveDestination(idx);
+ ml.AddDestination(entryNode, idx, out _);
+ }
+ }
+ }
+
+ // 8. Remove fi from boundary.
+ boundary.RemoveFunctionInstance(instance, out _);
+
+ // 9. Remove FunctionParameters from template, then remove template from boundary.
+ var fpsToRestore = ft.FunctionParameters.ToList();
+ foreach (var fp in fpsToRestore)
+ ft.RemoveFunctionParameter(fp, out _);
+ boundary.RemoveFunctionTemplate(ft, out _);
+
+ error = null;
+
+ // ── Register undo/redo ─────────────────────────────────────────────
+ var capturedFt = ft;
+ var capturedFi = instance;
+ var capturedEntryNode = entryNode;
+ var capturedInternalNodes = internalNodes;
+ var capturedMovedLinks = movedLinks;
+ var capturedFpLinks = fpLinks;
+ var capturedNewDirect = newDirectLinks;
+ var capturedFiOutgoing = fiOutgoingLinks;
+ var capturedIncomingToFi = incomingToFi;
+ var capturedMultiInfo = multiLinkInfo;
+ var capturedFps = fpsToRestore;
+
+ Buffer.AddUndo(new Command(
+ // ── Undo: restore everything ──
+ () =>
+ {
+ // 1. Restore FunctionTemplate and FunctionParameters.
+ boundary.AddFunctionTemplate(capturedFt, out _);
+ foreach (var (fp, idx) in capturedFps.Select((fp, i) => (fp, i)))
+ capturedFt.RestoreFunctionParameter(fp, idx);
+
+ // 2. Re-add FunctionInstance to boundary.
+ boundary.AddFunctionInstance(capturedFi, out _);
+
+ // 3. Restore incoming links to point back at FI while entry is still
+ // in the boundary so the canvas can resolve both endpoints.
+ RestoreIncomingLinks(capturedIncomingToFi, capturedFi, capturedMultiInfo);
+
+ // 4. Remove replacement direct links.
+ foreach (var lk in capturedNewDirect)
+ boundary.RemoveLink(lk, out _);
+
+ // 5. Remove moved links from boundary.
+ foreach (var lk in capturedMovedLinks)
+ boundary.RemoveLink(lk, out _);
+
+ // 6. Move nodes back to internalBoundary.
+ foreach (var node in capturedInternalNodes)
+ {
+ boundary.RemoveNode(node, out _);
+ node.UpdateContainedWithin(internalBoundary);
+ internalBoundary.AddNode(node, out _);
+ }
+
+ // 7. Re-add moved links to internalBoundary.
+ foreach (var lk in capturedMovedLinks)
+ internalBoundary.AddLink(lk, out _);
+
+ // 8. Re-add FP-destination links to internalBoundary.
+ foreach (var lk in capturedFpLinks)
+ internalBoundary.AddLink(lk, out _);
+
+ // 9. Re-add fi→external outgoing links.
+ foreach (var lk in capturedFiOutgoing)
+ boundary.AddLink(lk, out _);
+
+ return (true, null);
+ },
+ // ── Redo: re-apply expansion ──
+ () =>
+ {
+ // Mirror the forward direction exactly.
+
+ // 1. Remove fi→external links.
+ foreach (var lk in capturedFiOutgoing)
+ boundary.RemoveLink(lk, out _);
+
+ // 2. Move internals out to boundary first.
+ foreach (var lk in capturedFpLinks)
+ internalBoundary.RemoveLink(lk, out _);
+
+ foreach (var lk in capturedMovedLinks)
+ internalBoundary.RemoveLink(lk, out _);
+
+ foreach (var node in capturedInternalNodes)
+ {
+ internalBoundary.RemoveNode(node, out _);
+ node.UpdateContainedWithin(boundary);
+ boundary.AddNode(node, out _);
+ }
+
+ foreach (var lk in capturedMovedLinks)
+ boundary.AddLink(lk, out _);
+
+ foreach (var lk in capturedNewDirect)
+ boundary.AddLink(lk, out _);
+
+ // 3. Redirect incoming from FI to entry while both are in boundary.
+ foreach (var lk in capturedIncomingToFi)
+ {
+ if (lk is SingleLink sl)
+ {
+ sl.SetDestination(capturedEntryNode, out _);
+ }
+ else if (lk is MultiLink ml && capturedMultiInfo.TryGetValue(ml, out var entries))
+ {
+ foreach (var (idx, _) in entries)
+ {
+ ml.RemoveDestination(idx);
+ ml.AddDestination(capturedEntryNode, idx, out _);
+ }
+ }
+ }
+
+ // 4. Remove FI and then remove template metadata.
+ boundary.RemoveFunctionInstance(capturedFi, out _);
+
+ foreach (var fp in capturedFps)
+ capturedFt.RemoveFunctionParameter(fp, out _);
+ boundary.RemoveFunctionTemplate(capturedFt, out _);
+
+ return (true, null);
+ }
+ ));
+
+ return true;
+ }
+ }
+
public bool ExportModelSystem(User user, string exportPath, [NotNullWhen(false)] out CommandError? error)
{
ArgumentNullException.ThrowIfNull(user);
diff --git a/tests/XTMF2.UnitTests/Editing/TestExpandFunctionInstance.cs b/tests/XTMF2.UnitTests/Editing/TestExpandFunctionInstance.cs
new file mode 100644
index 0000000..ab19e37
--- /dev/null
+++ b/tests/XTMF2.UnitTests/Editing/TestExpandFunctionInstance.cs
@@ -0,0 +1,240 @@
+/*
+ Copyright 2026, Travel Modelling Group, University of Toronto
+
+ This file is part of XTMF2.
+
+ XTMF2 is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ XTMF2 is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with XTMF2. If not, see .
+*/
+using System.Linq;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using XTMF2.Editing;
+using XTMF2.ModelSystemConstruct;
+using XTMF2.RuntimeModules;
+using XTMF2.UnitTests.Modules;
+
+namespace XTMF2.UnitTests.Editing;
+
+///
+/// Tests for .
+///
+[TestClass]
+public class TestExpandFunctionInstance
+{
+ // ── Shared locations ───────────────────────────────────────────────────
+ private static readonly Rectangle CallerLoc = new(0, 0, 160, 60);
+ private static readonly Rectangle EntryLoc = new(200, 0, 160, 60);
+ private static readonly Rectangle ExtDestLoc = new(400, 0, 160, 60);
+ private static readonly Rectangle FtLoc = new(200, 100, 300, 200);
+ private static readonly Rectangle FiLoc = new(200, 0, 160, 60);
+
+ // ── Helper: set up a simple extract + expand scenario ─────────────────
+
+ ///
+ /// Creates a caller → entryNode → externalDest graph, extracts entryNode into
+ /// a FunctionTemplate, then expands the resulting FunctionInstance back.
+ ///
+ private static void RunExtractThenExpand(
+ User user, ModelSystemSession ms,
+ out Node caller,
+ out Node entryNode,
+ out Node externalDest)
+ {
+ var boundary = ms.ModelSystem.GlobalBoundary;
+
+ Assert.IsTrue(ms.AddNode(user, boundary, "Caller", typeof(Execute), CallerLoc, out caller, out var err), err?.Message);
+ Assert.IsTrue(ms.AddNode(user, boundary, "Entry", typeof(IgnoreResult), EntryLoc, out entryNode, out err), err?.Message);
+ Assert.IsTrue(ms.AddNode(user, boundary, "ExtDest", typeof(SimpleTestModule), ExtDestLoc, out externalDest, out err), err?.Message);
+
+ var callerHook = TestHelper.GetHook(caller!.Hooks, "To Execute");
+ var entryHook = TestHelper.GetHook(entryNode!.Hooks, "To Ignore");
+
+ Assert.IsTrue(ms.AddLink(user, caller, callerHook, entryNode, out var _lk1, out err), err?.Message);
+ Assert.IsTrue(ms.AddLink(user, entryNode, entryHook, externalDest, out var _lk2, out err), err?.Message);
+
+ Assert.IsTrue(ms.ExtractToFunctionTemplate(
+ user, boundary,
+ new[] { entryNode },
+ "MyTemplate", "MyInstance",
+ FtLoc, FiLoc,
+ out var _ft, out var fi, out err), err?.Message);
+
+ Assert.IsTrue(ms.ExpandFunctionInstance(user, fi!, out err), err?.Message);
+ }
+
+ // ── Validation failures ────────────────────────────────────────────────
+
+ [TestMethod]
+ public void ExpandFunctionInstance_MultipleInstances_Fails()
+ {
+ TestHelper.RunInModelSystemContext(
+ nameof(ExpandFunctionInstance_MultipleInstances_Fails),
+ (user, pSess, ms) =>
+ {
+ var boundary = ms.ModelSystem.GlobalBoundary;
+
+ Assert.IsTrue(ms.AddNode(user, boundary, "Caller", typeof(Execute), CallerLoc, out var caller, out var err), err?.Message);
+ Assert.IsTrue(ms.AddNode(user, boundary, "Entry", typeof(IgnoreResult), EntryLoc, out var entry, out err), err?.Message);
+ Assert.IsTrue(ms.AddNode(user, boundary, "Caller2", typeof(Execute), new Rectangle(0, 200, 160, 60), out var caller2, out err), err?.Message);
+
+ var callerHook = TestHelper.GetHook(caller!.Hooks, "To Execute");
+ var caller2Hook = TestHelper.GetHook(caller2!.Hooks, "To Execute");
+
+ Assert.IsTrue(ms.AddLink(user, caller, callerHook, entry, out var _lk1, out err), err?.Message);
+ Assert.IsTrue(ms.AddLink(user, caller2, caller2Hook, entry, out var _lk2, out err), err?.Message);
+
+ Assert.IsTrue(ms.ExtractToFunctionTemplate(
+ user, boundary, new[] { entry! },
+ "T", "FI", FtLoc, FiLoc,
+ out var ft, out var fi1, out err), err?.Message);
+
+ // Add a second instance of the same template.
+ Assert.IsTrue(ms.AddFunctionInstance(user, boundary, ft!, "FI2", new Rectangle(0, 300, 160, 60), out var _fi2, out err), err?.Message);
+
+ Assert.IsFalse(ms.ExpandFunctionInstance(user, fi1!, out err));
+ Assert.IsNotNull(err);
+ });
+ }
+
+ // ── Success scenarios ──────────────────────────────────────────────────
+
+ [TestMethod]
+ public void ExpandFunctionInstance_Basic_Succeeds()
+ {
+ TestHelper.RunInModelSystemContext(
+ nameof(ExpandFunctionInstance_Basic_Succeeds),
+ (user, pSess, ms) =>
+ {
+ RunExtractThenExpand(user, ms, out var _c, out var entryNode, out var _e);
+
+ var boundary = ms.ModelSystem.GlobalBoundary;
+
+ // entryNode should be back in the boundary modules.
+ Assert.Contains(entryNode, boundary.Modules,
+ "entryNode must be moved back into the boundary.");
+ });
+ }
+
+ [TestMethod]
+ public void ExpandFunctionInstance_NoFunctionTemplateOrInstanceRemains()
+ {
+ TestHelper.RunInModelSystemContext(
+ nameof(ExpandFunctionInstance_NoFunctionTemplateOrInstanceRemains),
+ (user, pSess, ms) =>
+ {
+ RunExtractThenExpand(user, ms, out var _c, out var _en, out var _e);
+
+ var boundary = ms.ModelSystem.GlobalBoundary;
+
+ Assert.IsEmpty(boundary.FunctionTemplates,
+ "FunctionTemplate must be removed after expansion.");
+ Assert.IsEmpty(boundary.FunctionInstances,
+ "FunctionInstance must be removed after expansion.");
+ });
+ }
+
+ [TestMethod]
+ public void ExpandFunctionInstance_CallerLinksToEntryNode()
+ {
+ TestHelper.RunInModelSystemContext(
+ nameof(ExpandFunctionInstance_CallerLinksToEntryNode),
+ (user, pSess, ms) =>
+ {
+ RunExtractThenExpand(user, ms, out var caller, out var entryNode, out var _e);
+
+ var boundary = ms.ModelSystem.GlobalBoundary;
+
+ // The link from caller should now point directly to entryNode.
+ var callerLinks = boundary.Links.Where(l => l.Origin == caller).ToList();
+ Assert.HasCount(1, callerLinks,
+ "Caller should have exactly one outgoing link after expansion.");
+ if (callerLinks[0] is SingleLink sl)
+ {
+ Assert.AreEqual(entryNode, sl.Destination,
+ "Caller's link must target entryNode after expansion.");
+ }
+ else
+ {
+ var ml = (MultiLink)callerLinks[0];
+ Assert.Contains(entryNode, ml.Destinations,
+ "Caller's link must contain entryNode after expansion.");
+ }
+ });
+ }
+
+ [TestMethod]
+ public void ExpandFunctionInstance_InternalLinkPreserved()
+ {
+ TestHelper.RunInModelSystemContext(
+ nameof(ExpandFunctionInstance_InternalLinkPreserved),
+ (user, pSess, ms) =>
+ {
+ RunExtractThenExpand(user, ms, out var _c, out var entryNode, out var externalDest);
+
+ var boundary = ms.ModelSystem.GlobalBoundary;
+
+ // The link entryNode → externalDest must be re-created as a direct link.
+ var entryLinks = boundary.Links.Where(l => l.Origin == entryNode).ToList();
+ Assert.HasCount(1, entryLinks,
+ "entryNode should have exactly one outgoing link after expansion.");
+ Assert.AreEqual(externalDest, ((SingleLink)entryLinks[0]).Destination,
+ "entryNode's link must target externalDest after expansion.");
+ });
+ }
+
+ [TestMethod]
+ public void ExpandFunctionInstance_UndoRestoresFunctionInstance()
+ {
+ TestHelper.RunInModelSystemContext(
+ nameof(ExpandFunctionInstance_UndoRestoresFunctionInstance),
+ (user, pSess, ms) =>
+ {
+ RunExtractThenExpand(user, ms, out var _c, out var entryNode, out var _e);
+
+ var boundary = ms.ModelSystem.GlobalBoundary;
+
+ // Undo the expansion.
+ Assert.IsTrue(ms.Undo(user, out var err), err?.Message);
+
+ Assert.HasCount(1, boundary.FunctionInstances,
+ "Undo must restore the FunctionInstance.");
+ Assert.HasCount(1, boundary.FunctionTemplates,
+ "Undo must restore the FunctionTemplate.");
+ Assert.DoesNotContain(entryNode, boundary.Modules,
+ "After undo, entryNode should not be in the boundary.");
+ });
+ }
+
+ [TestMethod]
+ public void ExpandFunctionInstance_RedoReappliesExpansion()
+ {
+ TestHelper.RunInModelSystemContext(
+ nameof(ExpandFunctionInstance_RedoReappliesExpansion),
+ (user, pSess, ms) =>
+ {
+ RunExtractThenExpand(user, ms, out var _c, out var entryNode, out var _e);
+
+ var boundary = ms.ModelSystem.GlobalBoundary;
+
+ Assert.IsTrue(ms.Undo(user, out var err), err?.Message);
+ Assert.IsTrue(ms.Redo(user, out err), err?.Message);
+
+ Assert.IsEmpty(boundary.FunctionTemplates,
+ "Redo must remove the FunctionTemplate again.");
+ Assert.IsEmpty(boundary.FunctionInstances,
+ "Redo must remove the FunctionInstance again.");
+ Assert.Contains(entryNode, boundary.Modules,
+ "Redo must move entryNode back to the boundary.");
+ });
+ }
+}
diff --git a/tests/XTMF2.UnitTests/Editing/TestExtractToFunctionTemplate.cs b/tests/XTMF2.UnitTests/Editing/TestExtractToFunctionTemplate.cs
index c2cf546..57df485 100644
--- a/tests/XTMF2.UnitTests/Editing/TestExtractToFunctionTemplate.cs
+++ b/tests/XTMF2.UnitTests/Editing/TestExtractToFunctionTemplate.cs
@@ -110,17 +110,17 @@ public void ExtractToFunctionTemplate_NoExternalIncomingLink_Fails()
}
[TestMethod]
- public void ExtractToFunctionTemplate_MultipleExternalIncomingLinks_Fails()
+ public void ExtractToFunctionTemplate_MultipleIncomingLinksToSameNode_Succeeds()
{
+ // Both incoming SingleLinks point to the same entry node → should succeed;
+ // all incoming links must be redirected to the FunctionInstance.
TestHelper.RunInModelSystemContext(
- nameof(ExtractToFunctionTemplate_MultipleExternalIncomingLinks_Fails),
+ nameof(ExtractToFunctionTemplate_MultipleIncomingLinksToSameNode_Succeeds),
(user, pSess, ms) =>
{
var boundary = ms.ModelSystem.GlobalBoundary;
- // entryNode: IgnoreResult (IAction)
Assert.IsTrue(ms.AddNode(user, boundary, "Entry", typeof(IgnoreResult),
EntryLoc, out var entryNode, out var error), error?.Message);
- // Two callers pointing to entryNode.
Assert.IsTrue(ms.AddModelSystemStart(user, boundary, "Start1",
CallerLoc, out var start1, out error), error?.Message);
Assert.IsTrue(ms.AddModelSystemStart(user, boundary, "Start2",
@@ -132,14 +132,113 @@ public void ExtractToFunctionTemplate_MultipleExternalIncomingLinks_Fails()
TestHelper.GetHook(start2.Hooks, "ToExecute"), entryNode,
out _, out error), error?.Message);
- Assert.IsFalse(ms.ExtractToFunctionTemplate(
+ Assert.IsTrue(ms.ExtractToFunctionTemplate(
user, boundary, new[] { entryNode! },
"T", "FI", FtLoc, FiLoc,
+ out var ft, out var fi, out error), error?.Message);
+
+ Assert.IsNotNull(ft);
+ Assert.IsNotNull(fi);
+
+ // Both start nodes must now link to the FunctionInstance.
+ var link1 = boundary.Links.OfType()
+ .FirstOrDefault(l => l.Origin == start1 && l.Destination == fi);
+ var link2 = boundary.Links.OfType()
+ .FirstOrDefault(l => l.Origin == start2 && l.Destination == fi);
+ Assert.IsNotNull(link1, "start1 must link to FunctionInstance.");
+ Assert.IsNotNull(link2, "start2 must link to FunctionInstance.");
+ });
+ }
+
+ [TestMethod]
+ public void ExtractToFunctionTemplate_MultipleIncomingLinksToDifferentNodes_Fails()
+ {
+ // Two incoming SingleLinks pointing to different selected nodes → should fail.
+ TestHelper.RunInModelSystemContext(
+ nameof(ExtractToFunctionTemplate_MultipleIncomingLinksToDifferentNodes_Fails),
+ (user, pSess, ms) =>
+ {
+ var boundary = ms.ModelSystem.GlobalBoundary;
+ Assert.IsTrue(ms.AddNode(user, boundary, "NodeA", typeof(IgnoreResult),
+ EntryLoc, out var nodeA, out var error), error?.Message);
+ Assert.IsTrue(ms.AddNode(user, boundary, "NodeB", typeof(IgnoreResult),
+ ExtDestLoc, out var nodeB, out error), error?.Message);
+ Assert.IsTrue(ms.AddModelSystemStart(user, boundary, "Start1",
+ CallerLoc, out var start1, out error), error?.Message);
+ Assert.IsTrue(ms.AddModelSystemStart(user, boundary, "Start2",
+ new Rectangle(0, 80, 160, 60), out var start2, out error), error?.Message);
+ Assert.IsTrue(ms.AddLink(user, start1,
+ TestHelper.GetHook(start1.Hooks, "ToExecute"), nodeA,
+ out _, out error), error?.Message);
+ Assert.IsTrue(ms.AddLink(user, start2,
+ TestHelper.GetHook(start2.Hooks, "ToExecute"), nodeB,
+ out _, out error), error?.Message);
+
+ Assert.IsFalse(ms.ExtractToFunctionTemplate(
+ user, boundary, new[] { nodeA!, nodeB! },
+ "T", "FI", FtLoc, FiLoc,
out _, out _, out error));
Assert.IsNotNull(error);
});
}
+ [TestMethod]
+ public void ExtractToFunctionTemplate_IncomingMultiLink_Succeeds()
+ {
+ // The external caller connects to the selected entry node via a MultiLink
+ // (Execute's "To Execute" hook accepts IAction[]).
+ TestHelper.RunInModelSystemContext(
+ nameof(ExtractToFunctionTemplate_IncomingMultiLink_Succeeds),
+ (user, pSess, ms) =>
+ {
+ var boundary = ms.ModelSystem.GlobalBoundary;
+ // entryNode and otherDest are both IAction (IgnoreResult / Execute).
+ Assert.IsTrue(ms.AddNode(user, boundary, "Entry", typeof(IgnoreResult),
+ EntryLoc, out var entryNode, out var error), error?.Message);
+ Assert.IsTrue(ms.AddNode(user, boundary, "OtherDest", typeof(IgnoreResult),
+ ExtDestLoc, out var otherDest, out error), error?.Message);
+ // Use Execute as the multi-cardinality caller (IAction[] "To Execute" hook).
+ Assert.IsTrue(ms.AddNode(user, boundary, "Caller", typeof(Execute),
+ CallerLoc, out var caller, out error), error?.Message);
+ Assert.IsTrue(ms.AddModelSystemStart(user, boundary, "Start",
+ new Rectangle(0, 200, 160, 60), out var start, out error), error?.Message);
+ // start → caller (single link).
+ Assert.IsTrue(ms.AddLink(user, start,
+ TestHelper.GetHook(start.Hooks, "ToExecute"), caller,
+ out _, out error), error?.Message);
+
+ // Build a MultiLink on caller's "To Execute": first dest = entryNode.
+ Assert.IsTrue(ms.AddLink(user, caller,
+ TestHelper.GetHook(caller.Hooks, "To Execute"), entryNode,
+ out var firstLink, out error), error?.Message);
+ // Second dest = otherDest; this upgrades to a MultiLink and returns it.
+ Assert.IsTrue(ms.AddLink(user, caller,
+ TestHelper.GetHook(caller.Hooks, "To Execute"), otherDest,
+ out var multiLink, out error), error?.Message);
+ Assert.IsInstanceOfType(multiLink, typeof(MultiLink),
+ "Adding a second destination must produce a MultiLink.");
+
+ Assert.IsTrue(ms.ExtractToFunctionTemplate(
+ user, boundary, new[] { entryNode! },
+ "MLTemplate", "MLInstance",
+ FtLoc, FiLoc,
+ out var ft, out var fi, out error), error?.Message);
+
+ Assert.IsNotNull(ft);
+ Assert.IsNotNull(fi);
+
+ // The MultiLink must now contain the FunctionInstance instead of entryNode.
+ var ml = (MultiLink)multiLink!;
+ Assert.Contains(fi, ml.Destinations,
+ "MultiLink must contain the FunctionInstance.");
+ Assert.DoesNotContain(entryNode, ml.Destinations,
+ "MultiLink must no longer contain entryNode.");
+ // otherDest remains in the MultiLink.
+ Assert.Contains(otherDest, ml.Destinations,
+ "MultiLink must still contain otherDest.");
+ });
+ }
+
[TestMethod]
public void ExtractToFunctionTemplate_DuplicateTemplateName_Fails()
{