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() {