diff --git a/src/SharpMUTerm.Core/Automation/Trigger.cs b/src/SharpMUTerm.Core/Automation/Trigger.cs index e512ca4..d11a44b 100644 --- a/src/SharpMUTerm.Core/Automation/Trigger.cs +++ b/src/SharpMUTerm.Core/Automation/Trigger.cs @@ -29,7 +29,10 @@ public sealed class TriggerActions /// /// Replace the whole line's text with this template (supports $1..$9 and - /// ${name} capture references). Rewritten text renders with the default style. Null — which + /// ${name} capture references). Rewritten text starts from the default style — a rewrite is + /// how a server's own colour is dropped as well as how its wording is changed — and then wears + /// whatever , and + /// this same rule asks for, across the whole of it. Null — which /// is what the F2 screen writes for a blank field — means the rule rewrites nothing; settable for /// the same reason is, and with the same absence of any cached state. /// diff --git a/src/SharpMUTerm.Core/Automation/TriggerEngine.cs b/src/SharpMUTerm.Core/Automation/TriggerEngine.cs index d01c5e9..8a97313 100644 --- a/src/SharpMUTerm.Core/Automation/TriggerEngine.cs +++ b/src/SharpMUTerm.Core/Automation/TriggerEngine.cs @@ -253,17 +253,31 @@ public TriggerResult Process(StyledLine line) suppress = true; } - if (actions.HighlightForeground is not null || - actions.HighlightBackground is not null || - actions.AddAttributes != TextAttributes.None) + // The rewrite runs *before* the highlight, and that order is the whole of a reported defect. + // It used to run after, and a rewrite replaces the line wholesale with an unstyled one — so a + // rule that both rewrote and highlighted threw its own colours, attributes and left rule away + // on the very next statement. That is not an exotic combination: it is what a channel rule + // looks like (route it, tidy it to `» $1`, colour it), and it is the shape of the demo + // configuration's own headline rule. The F2 screen badged such a rule `H` and painted its + // swatches, so the client promised a highlight that could never appear. + var rewritten = false; + if (actions.Rewrite is not null) { - current = ApplyHighlight(current, match, actions); + current = StyledLine.FromText(match.Result(actions.Rewrite), TextStyle.Default); + rewritten = true; } - if (actions.Rewrite is not null) + if (actions.HighlightForeground is not null || + actions.HighlightBackground is not null || + actions.AddAttributes != TextAttributes.None) { - var text = match.Result(actions.Rewrite); - current = StyledLine.FromText(text, TextStyle.Default); + // A rewrite makes the match's own offsets meaningless — they described the string the + // rewrite replaced — so the highlight covers the whole of what the rule produced, which + // is the only region of the new line the rule can be said to be talking about. Without a + // rewrite it covers the match and only the match, exactly as it always has. + current = rewritten + ? ApplyHighlight(current, 0, current.Length, actions) + : ApplyHighlight(current, match.Index, match.Length, actions); } if (!string.IsNullOrEmpty(actions.SendResponse)) @@ -382,9 +396,15 @@ actions.HighlightBackground is not null || /// public const int MaxTargetLength = 64; - private static StyledLine ApplyHighlight(StyledLine line, Match match, TriggerActions actions) + /// + /// Recolours characters from and carries the + /// rule's colour onto the whole line so the output pane can draw its left rule. The region is a + /// parameter rather than a because a rewritten line has no match offsets left to + /// speak of — see the call site. + /// + private static StyledLine ApplyHighlight(StyledLine line, int start, int length, TriggerActions actions) { - var restyled = StyledText.Restyle(line, match.Index, match.Length, style => + var restyled = StyledText.Restyle(line, start, length, style => { if (actions.HighlightForeground is not null) { diff --git a/src/SharpMUTerm.Core/Workspace/Workspace.cs b/src/SharpMUTerm.Core/Workspace/Workspace.cs index fea867b..85a4cb1 100644 --- a/src/SharpMUTerm.Core/Workspace/Workspace.cs +++ b/src/SharpMUTerm.Core/Workspace/Workspace.cs @@ -155,11 +155,111 @@ public WorkspaceWindow OpenWindow( return window; } + /// + /// Routes a matched trigger's line to the window names, on behalf of + /// : a window that already exists wins, and a spawn window is what + /// happens when nothing answers. Counts the line as unread unless the destination is currently + /// being read, and returns it. + /// + /// This is the resolver a routed line goes through, and the finding half of it is the point. A rule's + /// destination used to be and nothing else, which computes a spawn id and + /// registers a new window when nothing answers to it — so "put this in + /// the window I already have open" was not a thing a rule could ask for however it was spelt, and a + /// route naming a window on the screen opened a second one beside it wearing the same label. + /// + /// + /// What a target may reach is deliberately narrower than "any window with that title". It is + /// this session's own windows, the windows nobody owns, and another character's main window + /// — one alt's channel collected into the pane you actually read. It is not another + /// session's spawn or auxiliary window: two characters running one capture rule get a pane each, and + /// a bare title lookup would collapse them back into one and file the second character's channel + /// under the first, which is the exact defect was given + /// an owner to fix. A main window is admitted across that boundary because it is a window the user + /// opened by connecting, rather than one a rule conjured out of a capture. + /// + /// + /// Only a placed window is a destination. Appending to a window no pane holds writes into a + /// buffer nothing can draw, which from the reader's side is indistinguishable from the rule not + /// firing at all; a closed window is passed over and the line goes somewhere visible. + /// + /// + /// Finding never creates. A target is often a template with capture groups in it + /// (Channel $1), so the name can be the server's text — and the security property that keeps + /// that bounded is that this arm can only ever land in a window the user already has. Making one out + /// of a captured name still goes through , which puts the matching session's + /// own key on it. + /// + /// + public WorkspaceWindow RouteLine(string target, string? sessionKey = null) + { + ArgumentException.ThrowIfNullOrEmpty(target); + if (FindRouteTarget(target, sessionKey) is not { } existing) + { + return RouteSpawn(target, sessionKey); + } + + NoteActivity(existing.Id); + return existing; + } + + /// + /// The window already names for , or null when + /// nothing does — the finding half of , with no side effects, so a caller can + /// tell "this line opened a pane" from "this line went to one that was already there" without + /// routing twice. See for what a target may and may not reach. + /// + public WorkspaceWindow? FindRouteTarget(string target, string? sessionKey = null) + { + ArgumentException.ThrowIfNullOrEmpty(target); + + // Preference order, and it has to be total: several windows may carry one title, and a route that + // resolved differently from one line to the next would scatter a channel across panes. This + // session's own first, then the unowned, then another character's main; ties inside a group go to + // the older window, which is the same creation order everything else here numbers windows in. + var best = _windows.Values + .Where(w => string.Equals(w.Title, target, StringComparison.Ordinal)) + .Where(w => Layout.FindWindow(w.Id) is not null) + .Select(w => (Window: w, Rank: RouteRank(w, sessionKey))) + .Where(candidate => candidate.Rank >= 0) + .OrderBy(candidate => candidate.Rank) + .ThenBy(candidate => candidate.Window.Sequence) + .Select(candidate => candidate.Window) + .FirstOrDefault(); + + // A spawn window the user has since renamed answers to no title, and its rule must go on feeding + // it rather than opening a second pane beside it under the old name. + // + // Placed, like the title lookup above it, and for the same reason: a window the registry still + // knows and no pane holds is *closed* (see the numbering remarks), and routing to one writes the + // channel into a buffer nobody can see. The registry outlives the layout in two ways — a restored + // workspace registers windows a saved layout no longer places — so this is reachable rather than + // theoretical. Falling through instead is not a loss: RouteLine then reaches RouteSpawn, which + // places this very window again under the same id, so the pane reopens with its history in it. + var renamed = _windows.GetValueOrDefault(SpawnWindowId(sessionKey, target)); + return best ?? (renamed is not null && Layout.FindWindow(renamed.Id) is not null ? renamed : null); + } + + /// + /// How willingly takes a line routed by — + /// lower is better, and negative means never. + /// + private static int RouteRank(WorkspaceWindow window, string? sessionKey) => window switch + { + _ when window.SessionKey is not null && string.Equals(window.SessionKey, sessionKey, StringComparison.Ordinal) => 0, + _ when window.SessionKey is null => 1, + _ when window.Kind == WindowKind.Main => 2, + _ => -1, + }; + /// /// Routes trigger-spawned output to 's spawn window named /// , creating and placing the window on first use, and counts the line as /// unread unless the window is currently visible. Returns the destination window. /// + /// This is the creating half only; is what a routed line goes through, + /// and it reaches here when no window the target names already exists. + /// + /// /// The destination is per session, not per workspace. Two connected characters running the /// same capture rule each get a window of their own; the id carries the owner, so the second /// session to match cannot land in the first's window. It used to: the id was the target alone, so @@ -173,9 +273,17 @@ public WorkspaceWindow RouteSpawn(string target, string? sessionKey = null) { ArgumentException.ThrowIfNullOrEmpty(target); var id = SpawnWindowId(sessionKey, target); - if (!_windows.TryGetValue(id, out var window)) + var window = _windows.TryGetValue(id, out var existing) + ? existing + : Register(new WorkspaceWindow(id, target, WindowKind.Spawn, sessionKey)); + + // Placed on the way past, and *not* only when the window is new. The registry outlives the layout + // — a restored workspace registers windows a saved layout no longer places — so a window can be + // known and closed at once, and returning that from a route writes the channel into a buffer + // nobody can see. Making this total is what lets FindRouteTarget decline a closed window and fall + // through here: the pane reopens under the same id, with its history already in it. + if (Layout.FindWindow(id) is null) { - window = Register(new WorkspaceWindow(id, target, WindowKind.Spawn, sessionKey)); Layout.AddWindow(id, activate: false); // spawns open in the background and accrue unread } diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index a6ac50e..d788e32 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -3390,7 +3390,11 @@ private void OnLine(WorldSession session, string windowId, StyledLine line) } /// - /// Routes a trigger-spawned line to its spawn window (creating the tab on first use). + /// Routes a trigger-matched line to the window its rule names — one that already exists when the + /// target names one, and a spawn window created on the spot when it does not + /// (). Routing used to be RouteSpawn and nothing else, so + /// every destination a rule could have was a spawn pane of its own session's; a route naming a window + /// on the screen opened a second one beside it wearing the same label. /// /// The owner recorded on a first-seen window is the whose trigger fired, /// not _active. It used to be the latter, so a background world's capture opened a window @@ -3399,6 +3403,12 @@ private void OnLine(WorldSession session, string windowId, StyledLine line) /// which world a link clicked in a spawn window sends to by it. /// /// + /// It is stamped on this session's own capture panes only. The label prefixes a tab as + /// Owner: Name so a spawn scattered into another pane stays tied to its character, and a + /// destination this session does not own is somebody else's window or nobody's — writing our name + /// onto another character's main window would relabel their pane after whoever routed into it. + /// + /// /// The same session key also picks the window (), /// which is what gives two characters running one capture rule a pane each. While the id was the /// target alone there was one window per workspace: the first session to match created it with its @@ -3409,12 +3419,19 @@ private void OnLine(WorldSession session, string windowId, StyledLine line) /// private void OnSpawnLine(WorldSession session, string target, StyledLine line) { - var existed = _workspace.FindWindow(Workspace.SpawnWindowId(session.SessionKey, target)) is not null; - var window = _workspace.RouteSpawn(target, session.SessionKey); + // Asked before routing rather than after, because routing is what makes the answer false: this is + // "was there already somewhere for this line to go", which is what decides between adding a tab + // and merely refreshing the badges. + var existed = _workspace.FindRouteTarget(target, session.SessionKey) is not null; + var window = _workspace.RouteLine(target, session.SessionKey); // Its owner's own name, which for a session with no character is its world's. It used to fall back on // the *main window's* title, which is a different session's name as soon as more than one is open. - window.OwnerLabel ??= SessionTitle(session); + if (window.Kind == WindowKind.Spawn && + string.Equals(window.SessionKey, session.SessionKey, StringComparison.Ordinal)) + { + window.OwnerLabel ??= SessionTitle(session); + } PaneContentFor(window.Id, window.Title); // ensure the live control exists before buffering // The restore log is fed here as well as in OnLine, and that is the crux of the whole feature: @@ -3424,7 +3441,7 @@ private void OnSpawnLine(WorldSession session, string target, StyledLine line) AppendWindowLine(window.Id, _formatter.ToMarkup(line), stamp); RecordForRestore(session, window.Id, window.Title, line, stamp); - // A first-seen spawn adds a tab to its pane, so rebuild; otherwise just refresh badges. + // A first-seen destination adds a tab to its pane, so rebuild; otherwise just refresh badges. if (existed) { RefreshTabTitles(); @@ -5565,14 +5582,41 @@ private void PersistConfiguration() } } - /// Distinct spawn-window targets referenced by any trigger (for the F2 route-to list). - private IReadOnlyList SpawnTargets() => - _config.TriggerSets.SelectMany(s => s.Triggers) - .Select(t => t.Actions.SpawnTarget) - .Where(t => !string.IsNullOrEmpty(t)) - .Select(t => t!) - .Distinct(StringComparer.Ordinal) - .ToList(); + /// + /// The destinations the F2 route field offers: every window some trigger already routes to, + /// then the windows this workspace actually holds. + /// + /// The second half is what makes routing to an existing window expressible. A rule's + /// destination is resolved by name against the windows that are open + /// (), and while the list was the other rules' targets alone the + /// only names it could offer were spawn panes — so the one place a user reads what a route may say + /// could not name the character's own window, another character's, or any window they had opened. The + /// list is suggestions and not the permitted set, so this widens what is discoverable rather than + /// what is legal. + /// + /// + /// Trigger targets lead, because a rule that has not opened its pane yet names a window nothing else + /// can offer, and because that is the order this list has always been read in. + /// + /// + private IReadOnlyList RouteTargets() + { + var targets = new List(); + foreach (var name in _config.TriggerSets.SelectMany(s => s.Triggers) + .Select(t => t.Actions.SpawnTarget) + .Concat(_workspace.Windows + .Where(w => _workspace.Layout.FindWindow(w.Id) is not null) + .OrderBy(w => w.Sequence) + .Select(w => w.Title))) + { + if (!string.IsNullOrEmpty(name) && !targets.Contains(name, StringComparer.Ordinal)) + { + targets.Add(name); + } + } + + return targets; + } /// Every configured macro across all trigger sets (for the F4 keypad/hotkey list). private IReadOnlyList Macros() => _config.TriggerSets.SelectMany(s => s.Macros).ToList(); @@ -5753,13 +5797,13 @@ private ScreenBinding WorldsScreen(string fkey, bool onCharacters) private ScreenBinding TriggersScreen() { var session = new SettingsSession(selection => - TriggersScreenRenderer.Model(_config.TriggerSets, selection.SelectionIn(0), SpawnTargets()), + TriggersScreenRenderer.Model(_config.TriggerSets, selection.SelectionIn(0), RouteTargets()), SaveConfiguration); return new ScreenBinding(session, () => TriggersScreenView.Build( _config.TriggerSets, session.Selection.SelectionIn(0), - SpawnTargets(), + RouteTargets(), _system.DesktopDimensions.Width, session.Focus(), _system.DesktopDimensions.Height)); @@ -8462,6 +8506,11 @@ internal void OpenUnownedWindowForTest(string id, string title) /// , and it goes stale silently, so it is worth asserting directly. internal string? WindowOwnerOf(string windowId) => _workspace.FindWindow(windowId)?.SessionKey; + /// A window's owner label — the Owner: Name prefix its tab wears. A different + /// fact from and worth asserting separately: a line routed into a window + /// somebody else owns must not stamp the routing character's name onto it. + internal string? WindowOwnerLabelOf(string windowId) => _workspace.FindWindow(windowId)?.OwnerLabel; + /// A pane's visible tab, so a test can say which window a click on a tab strip brought up. internal string? PaneActiveTab(string paneId) => _workspace.Layout.FindPane(paneId)?.ActiveTab; diff --git a/src/SharpMUTerm.Tui/TriggersScreenRenderer.cs b/src/SharpMUTerm.Tui/TriggersScreenRenderer.cs index e7f8abb..f5f5c25 100644 --- a/src/SharpMUTerm.Tui/TriggersScreenRenderer.cs +++ b/src/SharpMUTerm.Tui/TriggersScreenRenderer.cs @@ -119,21 +119,23 @@ internal static class TriggersScreenRenderer private static string Route(Trigger trigger) => trigger.Actions.SpawnTarget ?? MainWindow; /// - /// The windows offered as ↑↓ suggestions on the route field: the main output, every spawn window - /// the workspace knows about, and — always — the one this rule already points at, so a rule - /// routed somewhere the current workspace has no window for still shows its own value. + /// The destinations offered as ↑↓ suggestions on the route field: the main output, every window a + /// rule or the workspace can name (SharpMUTermApp.RouteTargets), and — always — the one this + /// rule already points at, so a rule routed somewhere the current workspace has no window for still + /// shows its own value. /// /// These are suggestions, not the permitted set. Typing a name that isn't here is how a new spawn - /// window comes into existence: the workspace's spawn windows are defined by what triggers route - /// to, so a closed list could only ever re-use one that already exists. + /// window comes into existence: a name nothing answers to is created as a capture pane + /// (Workspace.RouteLine), so a closed list could only ever re-use a destination that already + /// exists. /// /// - internal static IReadOnlyList Routes(Trigger trigger, IReadOnlyList? spawnTargets) + internal static IReadOnlyList Routes(Trigger trigger, IReadOnlyList? routeTargets) { ArgumentNullException.ThrowIfNull(trigger); var routes = new List { MainWindow }; - foreach (var target in (spawnTargets ?? Array.Empty()).Append(Route(trigger))) + foreach (var target in (routeTargets ?? Array.Empty()).Append(Route(trigger))) { if (!string.IsNullOrEmpty(target) && !routes.Contains(target, StringComparer.Ordinal)) { @@ -210,15 +212,15 @@ private static List NamedCallbacks(IReadOnlyList sets) public static List Render( IReadOnlyList sets, int selectedTrigger, - IReadOnlyList spawnTargets) + IReadOnlyList routeTargets) { ArgumentNullException.ThrowIfNull(sets); - ArgumentNullException.ThrowIfNull(spawnTargets); + ArgumentNullException.ThrowIfNull(routeTargets); var left = RulesColumn(sets, selectedTrigger); - var right = EditorColumn(sets, selectedTrigger, spawnTargets); + var right = EditorColumn(sets, selectedTrigger, routeTargets); - var lines = new List { HeaderLine(0, Model(sets, selectedTrigger, spawnTargets)), string.Empty }; + var lines = new List { HeaderLine(0, Model(sets, selectedTrigger, routeTargets)), string.Empty }; var rowCount = Math.Max(left.Count, right.Count); for (var i = 0; i < rowCount; i++) @@ -261,7 +263,7 @@ internal static string HeaderLine(int width, ScreenModel? model = null, ScreenFo /// and a palette are — while the editor keeps drawing them where they are read. /// /// - /// + /// /// The spawn windows a rule may route to, beyond main and its own current target. Optional /// so a caller that only wants the navigable shape (the header hints, the tests) need not know the /// workspace's windows. @@ -269,7 +271,7 @@ internal static string HeaderLine(int width, ScreenModel? model = null, ScreenFo internal static ScreenModel Model( IReadOnlyList sets, int selectedTrigger, - IReadOnlyList? spawnTargets = null) + IReadOnlyList? routeTargets = null) { ArgumentNullException.ThrowIfNull(sets); @@ -284,7 +286,7 @@ internal static ScreenModel Model( "route", () => Route(entry.Trigger), v => entry.Trigger.Actions.SpawnTarget = v == MainWindow ? null : v.Trim(), - Routes(entry.Trigger, spawnTargets)), + Routes(entry.Trigger, routeTargets)), ScreenField.Colour( "highlight fg", () => entry.Trigger.Actions.HighlightForeground, @@ -517,13 +519,13 @@ private static IEnumerable FlagLegend(Trigger? trigger, int width) internal static List EditorColumn( IReadOnlyList sets, int selectedTrigger, - IReadOnlyList spawnTargets, + IReadOnlyList routeTargets, ScreenFocus? focus = null, int width = ColumnWidth, int height = 0) { ArgumentNullException.ThrowIfNull(sets); - ArgumentNullException.ThrowIfNull(spawnTargets); + ArgumentNullException.ThrowIfNull(routeTargets); var cursor = focus ?? ScreenFocus.None; var flattened = Flatten(sets); @@ -531,7 +533,7 @@ internal static List EditorColumn( ? BuildEditor( flattened[selectedTrigger].Trigger, flattened[selectedTrigger].SetName, - spawnTargets, + routeTargets, cursor, selectedTrigger, width, @@ -609,7 +611,7 @@ private static string Flags(TriggerActions actions) private static List BuildEditor( Trigger trigger, string setName, - IReadOnlyList spawnTargets, + IReadOnlyList routeTargets, ScreenFocus cursor, int index, int width = ColumnWidth, diff --git a/src/SharpMUTerm.Tui/TriggersScreenView.cs b/src/SharpMUTerm.Tui/TriggersScreenView.cs index 469684d..90cb703 100644 --- a/src/SharpMUTerm.Tui/TriggersScreenView.cs +++ b/src/SharpMUTerm.Tui/TriggersScreenView.cs @@ -18,14 +18,14 @@ internal static class TriggersScreenView public static IWindowControl Build( IReadOnlyList sets, int selectedTrigger, - IReadOnlyList spawnTargets, + IReadOnlyList routeTargets, int width, ScreenFocus? focus = null, int height = 0) { var header = ScreenChrome.Band( TriggersScreenRenderer.HeaderLine( - width, TriggersScreenRenderer.Model(sets, selectedTrigger, spawnTargets), focus), + width, TriggersScreenRenderer.Model(sets, selectedTrigger, routeTargets), focus), ScreenPalette.HeaderBg); var footer = ScreenChrome.Band( TriggersScreenRenderer.FooterLine(sets, selectedTrigger, width, focus), ScreenPalette.FooterBg); @@ -41,7 +41,7 @@ public static IWindowControl Build( var body = ScreenChrome.Rows(height); var left = TriggersScreenRenderer.RulesColumn(sets, selectedTrigger, focus, rules); var right = TriggersScreenRenderer.EditorColumn( - sets, selectedTrigger, spawnTargets, focus, width <= 0 ? rules : width - rules - ScreenChrome.ColumnDivider, body); + sets, selectedTrigger, routeTargets, focus, width <= 0 ? rules : width - rules - ScreenChrome.ColumnDivider, body); var rulesCol = ScreenChrome.Stretch(new MarkupControl(ScreenChrome.Window(left, body))); var editorCol = ScreenChrome.Stretch(new MarkupControl( diff --git a/tests/SharpMUTerm.Core.Tests/Automation/HighlightRewriteTests.cs b/tests/SharpMUTerm.Core.Tests/Automation/HighlightRewriteTests.cs new file mode 100644 index 0000000..f39bd1c --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Automation/HighlightRewriteTests.cs @@ -0,0 +1,148 @@ +using SharpMUTerm.Core.Automation; +using SharpMUTerm.Core.Text; + +namespace SharpMUTerm.Core.Tests.Automation; + +/// +/// The reported defect: highlight colours don't seem to actually work. They work on their own — +/// has always passed — and they were +/// destroyed by the rule's own rewrite. TriggerEngine.Process applied the highlight to +/// the matched region and then, four lines later, replaced the whole line with +/// StyledLine.FromText(text, TextStyle.Default), which is a line with no colour, no attributes +/// and no left rule on it. +/// +/// That combination is not exotic; it is what a channel rule looks like. Route the line to a capture +/// pane, tidy it up (» $1) and colour it — which is exactly the shape of the demo +/// configuration's own headline rule (DemoScene's public: teal, bold, and +/// Rewrite = "» $1"). The F2 screen badges such a rule H and paints both swatches, so the +/// client promised a highlight it then threw away, and the only way to get one was to discover that +/// deleting the rewrite brought it back. +/// +/// +/// The fix is an ordering one: the rewrite runs first, and the highlight is then applied to +/// the whole rewritten line. It cannot be applied to the match's own offsets, because after a rewrite +/// those address a string that no longer exists — the rewritten text is the rule's product in its +/// entirety, so colouring all of it is the only reading that means anything. +/// +/// +public class HighlightRewriteTests +{ + private static readonly TerminalColor Gold = TerminalColor.FromRgb(0xff, 0xd7, 0x00); + + private static StyledLine Line(string text) => StyledLine.FromText(text, TextStyle.Default); + + private static TriggerResult Run(TriggerActions actions, string pattern, string text) + { + var engine = new TriggerEngine(); + engine.Add(new Trigger { Pattern = pattern, Actions = actions }); + return engine.Process(Line(text)); + } + + /// The headline: a rule that rewrites and highlights does both. + [Test] + public async Task ARewrittenLineStillWearsItsRulesHighlight() + { + var result = Run( + new TriggerActions { HighlightForeground = Gold, Rewrite = "» $1" }, + @"^\[public\] (.+)$", + "[public] hello there"); + + await Assert.That(result.Line.Text).IsEqualTo("» hello there"); + await Assert.That(result.Line.Spans.All(s => s.Style.Foreground == Gold)).IsTrue(); + } + + /// + /// The whole rewritten line, not a fragment of it. The match's offsets described the line the + /// rewrite replaced, so re-using them would colour an arbitrary prefix of the new text — which is + /// the same defect wearing a different mask, and harder to spot. + /// + [Test] + public async Task TheHighlightCoversTheWholeRewrittenLine() + { + // The rewrite is far longer than the region that matched, so a highlight still keyed to + // match.Index/Length would leave the tail of the new text unstyled. + var result = Run( + new TriggerActions { HighlightBackground = Gold, Rewrite = "$1 — and a great deal more text besides" }, + @"^\[(\w+)\]", + "[public] hello there"); + + await Assert.That(result.Line.Spans.All(s => s.Style.Background == Gold)).IsTrue(); + } + + /// Attributes are part of the same promise, and were lost with the colours. + [Test] + public async Task ARewrittenLineKeepsTheAttributesItsRuleAdded() + { + var result = Run( + new TriggerActions { AddAttributes = TextAttributes.Bold, Rewrite = "» $1" }, + @"^\[public\] (.+)$", + "[public] hello there"); + + await Assert.That(result.Line.Spans.All(s => s.Style.HasAttribute(TextAttributes.Bold))).IsTrue(); + } + + /// + /// And the left rule, which is the marker the output pane draws to say a trigger touched this line + /// at all. It went with the colours, so a rewritten line was indistinguishable from an untouched one. + /// + [Test] + public async Task ARewrittenLineKeepsItsLeftRule() + { + var result = Run( + new TriggerActions { HighlightForeground = Gold, Rewrite = "» $1" }, + @"^\[public\] (.+)$", + "[public] hello there"); + + await Assert.That(result.Line.RuleColor).IsEqualTo(Gold); + } + + /// + /// A rule that only rewrites still produces unstyled text. Reordering the two actions must not smuggle + /// a style onto a line whose rule asked for none — the rewritten text is deliberately the default + /// style, so that a rewrite is a way to drop a server's colour as well as to reword it. + /// + [Test] + public async Task ARewriteWithNoHighlightIsStillPlain() + { + var result = Run(new TriggerActions { Rewrite = "» $1" }, @"^\[public\] (.+)$", "[public] hello there"); + + await Assert.That(result.Line.Text).IsEqualTo("» hello there"); + await Assert.That(result.Line.RuleColor).IsNull(); + await Assert.That(result.Line.Spans.All(s => s.Style.Equals(TextStyle.Default))).IsTrue(); + } + + /// + /// Without a rewrite nothing moves: the highlight still covers the matched region and only that. This + /// is the property the reordering could most easily have broken, and it is the behaviour every rule + /// that does not rewrite depends on. + /// + [Test] + public async Task WithoutARewriteTheHighlightStillCoversOnlyTheMatch() + { + var result = Run(new TriggerActions { HighlightForeground = Gold }, "gold", "you find gold today"); + + var gold = result.Line.Spans.Single(s => s.Text == "gold"); + await Assert.That(gold.Style.Foreground).IsEqualTo(Gold); + await Assert.That(result.Line.Spans.Where(s => s.Text != "gold").All(s => + s.Style.Foreground == TerminalColor.Default)).IsTrue(); + } + + /// + /// A later rule's rewrite still replaces an earlier rule's highlighted text, and that is + /// correct rather than the same bug one rule over: the characters the first rule coloured are gone. + /// Pinned so the ordering fix is not later "generalised" into carrying styles across rules, where it + /// would be re-colouring text the first rule never saw. + /// + [Test] + public async Task ALaterRulesRewriteStillReplacesAnEarlierRulesHighlight() + { + var engine = new TriggerEngine(); + engine.Add(new Trigger { Pattern = "gold", Actions = new TriggerActions { HighlightForeground = Gold } }); + engine.Add(new Trigger { Pattern = "^you find (.+)$", Actions = new TriggerActions { Rewrite = "found: $1" } }); + + var result = engine.Process(Line("you find gold today")); + + await Assert.That(result.Line.Text).IsEqualTo("found: gold today"); + await Assert.That(result.Line.Spans.All(s => s.Style.Foreground == TerminalColor.Default)).IsTrue(); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Workspace/RouteToExistingWindowTests.cs b/tests/SharpMUTerm.Core.Tests/Workspace/RouteToExistingWindowTests.cs new file mode 100644 index 0000000..7671909 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Workspace/RouteToExistingWindowTests.cs @@ -0,0 +1,218 @@ +using SharpMUTerm.Core.Workspaces; + +namespace SharpMUTerm.Core.Tests.Workspaces; + +/// +/// The reported defect: a trigger's routing could only ever go to a spawn window. +/// Workspace.RouteSpawn is the one destination resolver a matched rule has, and it computes +/// SpawnWindowId(sessionKey, target) and — when nothing answers to that id — registers a brand +/// new window. There is no branch in it that can reach a window that +/// already exists under any other name, so "route to the window I already have open" was not a thing a +/// rule could ask for however it was spelt. +/// +/// is the resolver now: an existing window the target names wins, and +/// creating a spawn is what happens when nothing does. What that buys is a rule feeding a window +/// somebody opened deliberately — a character's own main window, another character's, or any named +/// auxiliary window — rather than a fourth pane appearing beside them. +/// +/// +/// What it deliberately cannot reach is another session's spawn window, and that is the whole +/// reason resolution is scoped rather than a bare title lookup over the registry. Two characters running +/// one capture rule get a pane each; a title that crossed between them would collapse the two back into +/// one and file the second character's channel under the first, which is exactly the defect +/// SpawnWindowId was given an owner to fix. +/// +/// +public class RouteToExistingWindowTests +{ + private const string Ann = "Convergence.Ann"; + private const string Bob = "Convergence.Bob"; + + /// A workspace whose main window belongs to Ann and is titled the way the shell titles it. + private static Workspace AnnsWorkspace() => new("main", "Ann", Ann); + + // ---- The report ------------------------------------------------------------------------- + + /// + /// The headline. A rule routing to the title of a window that already exists lands in that + /// window; before the fix it opened a second one beside it, of a different kind, with the same name on + /// its tab. + /// + [Test] + public async Task ARuleRoutesToAnExistingWindowRatherThanOpeningASpawnBesideIt() + { + var workspace = AnnsWorkspace(); + workspace.OpenWindow("notes", "Notes", WindowKind.Auxiliary, Ann); + + var destination = workspace.RouteLine("Notes", Ann); + + await Assert.That(destination.Id).IsEqualTo("notes"); + await Assert.That(workspace.Windows.Count(w => w.Title == "Notes")).IsEqualTo(1); + } + + /// + /// Including the character's own main window, which is the destination the F2 route list has always + /// named and the one a rule could least express: main there means do not route, which + /// only reaches the main window for a line the rule does not also gag. + /// + [Test] + public async Task ARuleCanRouteToItsOwnCharactersMainWindow() + { + var workspace = AnnsWorkspace(); + + var destination = workspace.RouteLine("Ann", Ann); + + await Assert.That(destination.Id).IsEqualTo("main"); + await Assert.That(destination.Kind).IsEqualTo(WindowKind.Main); + } + + /// + /// And another character's main window — one alt's channel collected into the pane you actually read. + /// A main window is the one window another session owns that this may reach, because it is a window + /// the user opened by connecting rather than one a capture rule conjured. + /// + [Test] + public async Task ARuleCanRouteToAnotherCharactersMainWindow() + { + var workspace = AnnsWorkspace(); + workspace.OpenWindow("main:bob", "Bob", WindowKind.Main, Bob); + + var destination = workspace.RouteLine("Bob", Ann); + + await Assert.That(destination.Id).IsEqualTo("main:bob"); + } + + /// An unowned window — the web view is the one in this client — is in everybody's reach. + [Test] + public async Task ARuleCanRouteToAnUnownedWindow() + { + var workspace = AnnsWorkspace(); + workspace.OpenWindow("web", "Scratch", WindowKind.Auxiliary); + + await Assert.That(workspace.RouteLine("Scratch", Ann).Id).IsEqualTo("web"); + } + + // ---- What must not move ------------------------------------------------------------------ + + /// + /// The per-session guarantee, which this resolution is scoped to preserve: Bob's rule may not land in + /// Ann's capture pane just because they chose the same channel name. Bob gets his own, as before. + /// + [Test] + public async Task ARuleCannotRouteIntoAnotherSessionsSpawnWindow() + { + var workspace = AnnsWorkspace(); + var anns = workspace.RouteLine("Public", Ann); + + var bobs = workspace.RouteLine("Public", Bob); + + await Assert.That(bobs.Id).IsNotEqualTo(anns.Id); + await Assert.That(bobs.Id).IsEqualTo(Workspace.SpawnWindowId(Bob, "Public")); + await Assert.That(bobs.SessionKey).IsEqualTo(Bob); + } + + /// + /// Nor into another session's auxiliary window. Only a main window crosses the owner + /// boundary: everything else another character owns was created for them, and the two cases a route + /// must never conflate are "the window you meant" and "somebody else's window with the same label". + /// + [Test] + public async Task ARuleCannotRouteIntoAnotherSessionsAuxiliaryWindow() + { + var workspace = AnnsWorkspace(); + workspace.OpenWindow("bobs-notes", "Notes", WindowKind.Auxiliary, Bob); + + var destination = workspace.RouteLine("Notes", Ann); + + await Assert.That(destination.Id).IsEqualTo(Workspace.SpawnWindowId(Ann, "Notes")); + } + + /// + /// Nothing found is still a spawn window, created and placed exactly as it always was. This is the + /// path every existing capture rule takes and it must be untouched. + /// + [Test] + public async Task ATargetNothingAnswersToStillOpensASpawnWindow() + { + var workspace = AnnsWorkspace(); + + var destination = workspace.RouteLine("Chat", Ann); + + await Assert.That(destination.Id).IsEqualTo(Workspace.SpawnWindowId(Ann, "Chat")); + await Assert.That(destination.Kind).IsEqualTo(WindowKind.Spawn); + await Assert.That(destination.Title).IsEqualTo("Chat"); + await Assert.That(workspace.Layout.FindWindow(destination.Id)).IsNotNull(); + } + + /// + /// A rule feeding its own capture pane goes on feeding the same one — the second line of a channel + /// must not find the window by a different route than the first did and end up somewhere else. + /// + [Test] + public async Task TheSecondLineOfACaptureLandsInTheSamePaneAsTheFirst() + { + var workspace = AnnsWorkspace(); + + var first = workspace.RouteLine("Chat", Ann); + var second = workspace.RouteLine("Chat", Ann); + + await Assert.That(second.Id).IsEqualTo(first.Id); + } + + /// + /// A window no pane holds is not a destination. Routing there would append to a buffer nothing can + /// draw, which is indistinguishable from the rule not firing — so a closed window is passed over and + /// the line goes to a spawn pane that can actually be seen. + /// + [Test] + public async Task AClosedWindowIsNotADestination() + { + var workspace = AnnsWorkspace(); + workspace.OpenWindow("notes", "Notes", WindowKind.Auxiliary, Ann); + workspace.CloseWindow("notes"); + + var destination = workspace.RouteLine("Notes", Ann); + + await Assert.That(destination.Id).IsEqualTo(Workspace.SpawnWindowId(Ann, "Notes")); + } + + /// + /// The same rule through the renamed-spawn fallback, which is the one arm that does not go by title. + /// A spawn window whose pane the user closed is still in the registry — the registry outlives the + /// layout, and a restored workspace can register windows a saved layout no longer places — so the + /// fallback would hand back a window nothing draws. It has to place the pane again instead, under + /// the same id, so the channel comes back with its history rather than going somewhere invisible. + /// + [Test] + public async Task ARenamedSpawnWindowWhosePaneWasClosedIsPlacedAgainRatherThanFedInvisibly() + { + var workspace = AnnsWorkspace(); + var spawned = workspace.RouteLine("Chat", Ann); + spawned.Title = "Tells"; // the user renames it, so no title answers to "Chat" any more + workspace.Layout.RemoveWindow(spawned.Id); // and closes its pane, leaving it registered + + await Assert.That(workspace.FindRouteTarget("Chat", Ann)).IsNull(); + + var destination = workspace.RouteLine("Chat", Ann); + + await Assert.That(destination.Id).IsEqualTo(spawned.Id); + await Assert.That(workspace.Layout.FindWindow(destination.Id)).IsNotNull(); + } + + /// + /// Routing badges the destination unread when it is not the window being read, whichever kind it + /// turned out to be. The badge is the only thing that says a background pane gained a line, and a + /// resolution that reached a new kind of window without it would make the feature silent. + /// + [Test] + public async Task RoutingToAnExistingWindowStillBadgesItUnread() + { + var workspace = AnnsWorkspace(); + workspace.OpenWindow("notes", "Notes", WindowKind.Auxiliary, Ann); + workspace.ActivateWindow("main"); // Notes shares the pane as a tab, and is now the hidden one + + var destination = workspace.RouteLine("Notes", Ann); + + await Assert.That(destination.Unread).IsGreaterThan(0); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/TriggerRouteDestinationTests.cs b/tests/SharpMUTerm.Tui.Tests/TriggerRouteDestinationTests.cs new file mode 100644 index 0000000..03e0f64 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/TriggerRouteDestinationTests.cs @@ -0,0 +1,225 @@ +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Automation; +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Text; +using SharpMUTerm.Core.Workspaces; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// The two reported defects, from the shell's side and over a live connection: a trigger could only +/// route to a spawn window, and a highlight colour did not survive the same rule's rewrite. +/// +/// Both are asserted end to end rather than on the engine alone, because both have a second half here. +/// A destination is only a destination if the shell appends to it (OnSpawnLine), and a highlight +/// is only a highlight if the markup a pane is fed carries the colour — a pane holds Spectre markup and +/// nothing else, so a StyledLine that was right on the way in proves nothing about the frame. +/// +/// +/// +/// Serialised with the other end-to-end suites: constructing the app and rendering a frame both touch +/// the process-global console streams. +/// +[NotInParallel] +public class TriggerRouteDestinationTests +{ + private const int Width = 160; + private const int Height = 40; + + private const string Ann = "Convergence.Ann"; + private const string Bob = "Convergence.Bob"; + + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + // ---- Routing somewhere that is not a fresh spawn window ----------------------------------- + + /// + /// The headline. Ann's rule routes to Bob — a window that already exists, of a kind no rule + /// could reach — and the line lands in it. Before the fix routing went through + /// Workspace.RouteSpawn, which can only ever answer with a spawn id it owns, so this opened a + /// fourth pane called Bob beside Bob's own and left his empty. + /// + [Test] + public async Task ARuleRoutesIntoAnotherCharactersWindowThatAlreadyExists() + { + var app = await Two(RouteTo("Bob")); + + Receive(app, AnnWire, " Ann says, \"hello\"\n"); + + await Assert.That(string.Join("\n", app.PaneLines(MainWindowOf(app, Bob)))).Contains("hello"); + await Assert.That(app.WindowIds()).DoesNotContain(Workspace.SpawnWindowId(Ann, "Bob")); + } + + /// + /// And into a character's own main window, which the F2 route list has always been able to name and a + /// rule could not reach: main there means do not route, so it only ever described a + /// line the rule did not also gag. A gagging rule aimed at the main window used to delete the line. + /// + [Test] + public async Task AGaggingRuleCanStillPutItsLineInItsOwnMainWindow() + { + var app = await Two(RouteTo("Ann")); + + Receive(app, AnnWire, " Ann says, \"hello\"\n"); + + await Assert.That(string.Join("\n", app.PaneLines(MainWindowOf(app, Ann)))).Contains("hello"); + } + + /// + /// A target nothing answers to is still a capture pane, created and owned by the matching session — + /// the path every capture rule that ships takes, and the one that must not have moved. + /// + [Test] + public async Task AnUnknownTargetStillOpensACapturePane() + { + var app = await Two(RouteTo("Chat")); + + Receive(app, AnnWire, " Ann says, \"hello\"\n"); + + var id = Workspace.SpawnWindowId(Ann, "Chat"); + await Assert.That(app.WindowIds()).Contains(id); + await Assert.That(app.WindowOwnerOf(id)).IsEqualTo(Ann); + await Assert.That(string.Join("\n", app.PaneLines(id))).Contains("hello"); + } + + /// + /// The per-session guarantee is untouched, and this is the fixture it was bought with: two characters + /// running one capture rule still get a pane each. Resolution admits another character's main + /// window and never their capture panes, so a shared channel name cannot collapse the two back into + /// one and file the second character's channel under the first. + /// + [Test] + public async Task TwoCharactersCapturingOneNameStillGetAPaneEach() + { + var app = await Two(RouteTo("Public")); + + Receive(app, AnnWire, " Ann says, \"first\"\n"); + Receive(app, BobWire, " Bob says, \"second\"\n"); + + var ann = string.Join("\n", app.PaneLines(Workspace.SpawnWindowId(Ann, "Public"))); + var bob = string.Join("\n", app.PaneLines(Workspace.SpawnWindowId(Bob, "Public"))); + + await Assert.That(ann).Contains("first"); + await Assert.That(ann).DoesNotContain("second"); + await Assert.That(bob).Contains("second"); + await Assert.That(bob).DoesNotContain("first"); + } + + /// + /// A window this session does not own is not relabelled by routing into it. OwnerLabel prefixes + /// a tab as Owner: Name to tie a capture pane scattered into another pane back to its + /// character; stamping it on a destination somebody else owns would rename their pane after whoever + /// last routed a line into it. + /// + [Test] + public async Task RoutingIntoAWindowDoesNotRelabelItAfterTheRoutingCharacter() + { + var app = await Two(RouteTo("Bob")); + + Receive(app, AnnWire, " Ann says, \"hello\"\n"); + + await Assert.That(app.WindowOwnerLabelOf(MainWindowOf(app, Bob))).IsNull(); + } + + // ---- The highlight, in the markup a pane is actually fed ------------------------------------ + + /// + /// A rule that rewrites and highlights: the pane's markup carries the colour. On the unfixed build + /// the rewrite ran after the highlight and replaced the line with an unstyled one, so the pane was + /// fed plain text while the F2 screen went on badging that rule H and painting its swatch. + /// + [Test] + public async Task ARewrittenLineReachesThePaneWearingItsHighlight() + { + var app = await Two(Configuration(new TriggerActions + { + HighlightForeground = TerminalColor.FromRgb(0xff, 0xd7, 0x00), + Rewrite = "» $1", + })); + + Receive(app, AnnWire, " Ann says, \"hello\"\n"); + + var line = app.PaneLines(MainWindowOf(app, Ann)).Last(); + await Assert.That(line).Contains("» Ann says"); + await Assert.That(line).Contains("#ffd700"); + } + + // ---- Harness ------------------------------------------------------------------------------ + + private RecordingTelnetSession AnnWire { get; set; } = new(); + + private RecordingTelnetSession BobWire { get; set; } = new(); + + /// The window a character's own output goes to — found by its owner rather than assumed. + private static string MainWindowOf(SharpMUTermApp app, string sessionKey) => + app.WindowIds().Single(id => + app.WindowOwnerOf(id) == sessionKey && !id.StartsWith(Workspace.SpawnPrefix, StringComparison.Ordinal)); + + private static AppConfiguration RouteTo(string target) => + Configuration(new TriggerActions { SpawnTarget = target, Gag = true }); + + private static AppConfiguration Configuration(TriggerActions actions) + { + var config = new AppConfiguration(); + config.TriggerSets.Add(new TriggerSet + { + Name = "Comms", + Triggers = + { + new Trigger { Name = "Public", Pattern = "^ (.+)$", Actions = actions }, + }, + }); + + config.Worlds.Add(new WorldDefinition + { + Name = "Convergence", + Host = "convergence.example.org", + Port = 4201, + Characters = + { + new CharacterDefinition { Name = "Ann", Logging = new LoggingSettings(), TriggerSets = { "Comms" } }, + new CharacterDefinition { Name = "Bob", Logging = new LoggingSettings(), TriggerSets = { "Comms" } }, + }, + }); + + return config; + } + + /// + /// Both characters open and connected. Two, because every destination this is about is a window + /// somebody else has — and because a session that was never connected never runs its receive path, + /// which would make any routing assertion true whatever the code does. + /// + private async Task Two(AppConfiguration config) + { + Console.SetIn(TextReader.Null); + AnnWire = new RecordingTelnetSession(); + BobWire = new RecordingTelnetSession(); + + var app = new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(Width, Height)); + await Open(app, Ann, AnnWire); + await Open(app, Bob, BobWire); + app.RenderNextFrame(); + return app; + } + + private static async Task Open(SharpMUTermApp app, string sessionKey, RecordingTelnetSession wire) + { + app.TelnetFactory = _ => wire; + if (!app.DispatchCommand(CommandIds.Character(sessionKey))) + { + throw new InvalidOperationException($"the app would not switch to {sessionKey}"); + } + + await app.FindSession(sessionKey)!.ConnectAsync(); + } + + private static void Receive(SharpMUTermApp app, RecordingTelnetSession wire, string text) + { + wire.Receive(text); + app.RenderNextFrame(); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/TriggersScreenRendererTests.cs b/tests/SharpMUTerm.Tui.Tests/TriggersScreenRendererTests.cs index b87c423..9b42091 100644 --- a/tests/SharpMUTerm.Tui.Tests/TriggersScreenRendererTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/TriggersScreenRendererTests.cs @@ -54,7 +54,7 @@ private static IReadOnlyList Scene() => new[] [Test] public async Task Render_RuleListShowsNamePatternOwningSetAndRoute() { - var lines = TriggersScreenRenderer.Render(Scene(), selectedTrigger: 0, spawnTargets: new[] { "Chat", "Combat log" }); + var lines = TriggersScreenRenderer.Render(Scene(), selectedTrigger: 0, routeTargets: new[] { "Chat", "Combat log" }); var rowIndex = lines.FindIndex(l => l.Contains("Tell") && l.Contains(@"^(\w+) tells you")); await Assert.That(lines[rowIndex]).Contains("→ Chat"); @@ -66,7 +66,7 @@ public async Task Render_RuleListShowsNamePatternOwningSetAndRoute() [Test] public async Task Render_FlagsSummariseGagHighlightAndSpawn() { - var lines = TriggersScreenRenderer.Render(Scene(), selectedTrigger: 0, spawnTargets: new[] { "Chat" }); + var lines = TriggersScreenRenderer.Render(Scene(), selectedTrigger: 0, routeTargets: new[] { "Chat" }); var tellRowIndex = lines.FindIndex(l => l.Contains("Tell") && l.Contains(@"^(\w+) tells you")); var tellSub = lines[tellRowIndex + 1]; @@ -82,7 +82,7 @@ public async Task Render_FlagsSummariseGagHighlightAndSpawn() [Test] public async Task Render_SelectedTriggerEditorShowsPatternAndRoute() { - var lines = TriggersScreenRenderer.Render(Scene(), selectedTrigger: 0, spawnTargets: new[] { "Chat", "Combat log" }); + var lines = TriggersScreenRenderer.Render(Scene(), selectedTrigger: 0, routeTargets: new[] { "Chat", "Combat log" }); await Assert.That(lines.Any(l => l.Contains("match pattern"))).IsTrue(); await Assert.That(lines.Any(l => l.Contains(@"^(\w+) tells you"))).IsTrue(); @@ -99,10 +99,10 @@ public async Task Render_SelectedTriggerEditorShowsPatternAndRoute() [Test] public async Task Render_GagToggleReflectsActionsGag() { - var gagged = TriggersScreenRenderer.Render(Scene(), selectedTrigger: 1, spawnTargets: Array.Empty()); + var gagged = TriggersScreenRenderer.Render(Scene(), selectedTrigger: 1, routeTargets: Array.Empty()); await Assert.That(gagged.Any(l => l.Contains("[[x]] gag line") || l.Contains("#00f5b7][[x]][/] gag line"))).IsTrue(); - var notGagged = TriggersScreenRenderer.Render(Scene(), selectedTrigger: 0, spawnTargets: Array.Empty()); + var notGagged = TriggersScreenRenderer.Render(Scene(), selectedTrigger: 0, routeTargets: Array.Empty()); await Assert.That(notGagged.Any(l => l.Contains("[dim][[ ]] gag line[/]"))).IsTrue(); } @@ -115,7 +115,7 @@ public async Task Render_GagToggleReflectsActionsGag() [Test] public async Task Render_HighlightCaptionAndSwatchAppearWhenColourSet() { - var lines = TriggersScreenRenderer.Render(Scene(), selectedTrigger: 0, spawnTargets: Array.Empty()); + var lines = TriggersScreenRenderer.Render(Scene(), selectedTrigger: 0, routeTargets: Array.Empty()); var heading = lines.Single(l => l.Contains("highlight") && !l.Contains("fg") && !l.Contains("bg")); await Assert.That(heading).Contains("recoloured"); @@ -129,7 +129,7 @@ public async Task Render_HighlightCaptionAndSwatchAppearWhenColourSet() [Test] public async Task Render_EmptySetsShowsNoTriggers() { - var lines = TriggersScreenRenderer.Render(Array.Empty(), selectedTrigger: -1, spawnTargets: Array.Empty()); + var lines = TriggersScreenRenderer.Render(Array.Empty(), selectedTrigger: -1, routeTargets: Array.Empty()); await Assert.That(lines.Any(l => l.Contains("no triggers"))).IsTrue(); await Assert.That(lines.Any(l => l.Contains("Triggers & spawn routing"))).IsTrue(); @@ -150,7 +150,7 @@ public async Task Render_EscapesMarkupBracketsInNamesAndPatterns() }, }; - var lines = TriggersScreenRenderer.Render(sets, selectedTrigger: 0, spawnTargets: Array.Empty()); + var lines = TriggersScreenRenderer.Render(sets, selectedTrigger: 0, routeTargets: Array.Empty()); await Assert.That(lines.Any(l => l.Contains("Br[[acket]]"))).IsTrue(); await Assert.That(lines.Any(l => l.Contains("x[[1]]"))).IsTrue(); await Assert.That(lines.Any(l => l.Contains("Weird[[Set]]"))).IsTrue();