From 6c3b80dfc33f2c15df5b30b64c6e8ae60fbcde91 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sun, 12 Jul 2026 15:13:06 -0500 Subject: [PATCH] fix(scripting): escape embedded quotes in display-text names Names quoted in a step's display line (script, layout, table, menu-set) had no escape convention, so a name containing a literal quote rendered ambiguously and its round-trip correctness was untested. DisplayQuoting centralizes the quote/unquote logic FileMaker's own calculation strings use: a literal quote is doubled rather than left bare. - Add DisplayQuoting with Quote/QuoteWithId/TryParseQuoted/ TryParseQuotedWithId/TryParseNamedRef helpers. - Replace the seven hand-rolled quote emitters and parsers (Perform Script, Perform Script on Server[ with Callback], Go to Layout, Go to Related Record, Install Menu Set, Install OnTimer Script) with calls into the shared helper. - Add round-trip tests for names containing embedded quotes. Closes #221 --- docs/advanced-filemaker-scripting-syntax.md | 8 ++ .../Scripting/Serialization/DisplayQuoting.cs | 84 ++++++++++++++++ .../Scripting/Steps/GoToLayoutStep.cs | 33 ++----- .../Scripting/Steps/GoToRelatedRecordStep.cs | 21 ++-- .../Scripting/Steps/InstallMenuSetStep.cs | 7 +- .../Steps/InstallOnTimerScriptStep.cs | 7 +- .../Steps/PerformScriptOnServerStep.cs | 15 +-- .../PerformScriptOnServerWithCallbackStep.cs | 5 +- .../Scripting/Steps/PerformScriptStep.cs | 25 +---- .../Serialization/DisplayQuotingTests.cs | 99 +++++++++++++++++++ .../Scripting/Steps/GoToLayoutStepTests.cs | 31 ++++++ .../Steps/GoToRelatedRecordStepTests.cs | 21 ++++ .../Steps/InstallMenuSetStepTests.cs | 21 ++++ .../Steps/InstallOnTimerScriptStepTests.cs | 21 ++++ .../Steps/PerformScriptOnServerStepTests.cs | 25 +++++ ...formScriptOnServerWithCallbackStepTests.cs | 13 +++ .../Scripting/Steps/PerformScriptStepTests.cs | 28 ++++++ 17 files changed, 387 insertions(+), 77 deletions(-) create mode 100644 src/SharpFM.Model/Scripting/Serialization/DisplayQuoting.cs create mode 100644 tests/SharpFM.Tests/Scripting/Serialization/DisplayQuotingTests.cs diff --git a/docs/advanced-filemaker-scripting-syntax.md b/docs/advanced-filemaker-scripting-syntax.md index 1353d46d..175e09f9 100644 --- a/docs/advanced-filemaker-scripting-syntax.md +++ b/docs/advanced-filemaker-scripting-syntax.md @@ -52,6 +52,14 @@ Appended to a quoted or `Table::Field` name: Omitted when the id is zero or unknown — `(#0)` would be visual noise for unresolved references. +A literal `"` inside the quoted name is doubled (`"O""Brien"`), +FileMaker's own calculation-string escape convention. This keeps a +hand-edited display line unambiguous about where the name ends, even +when the name itself contains a quote or looks like an `(#id)` suffix. +`DisplayQuoting` (`SharpFM.Model.Scripting.Serialization`) is the shared +helper for emitting and parsing this form — steps with a quoted +`NamedRef` should call it rather than hand-rolling quote handling. + ### Form 2 — inline word tokens Parsed at a specific named prefix, matching FM Pro's own rendering of diff --git a/src/SharpFM.Model/Scripting/Serialization/DisplayQuoting.cs b/src/SharpFM.Model/Scripting/Serialization/DisplayQuoting.cs new file mode 100644 index 00000000..10042c43 --- /dev/null +++ b/src/SharpFM.Model/Scripting/Serialization/DisplayQuoting.cs @@ -0,0 +1,84 @@ +using System.Text.RegularExpressions; +using SharpFM.Model.Scripting.Values; + +namespace SharpFM.Model.Scripting.Serialization; + +/// +/// Quote/unquote convention for names embedded in a step's human-readable +/// display line — e.g. the script name in Perform Script [ "Sync" (#4) ]. +/// Follows FileMaker's own calculation-string escape: a literal quote inside +/// the name is doubled ("O""Brien") rather than backslash-escaped, so +/// a hand-edited display line stays unambiguous about where the name ends. +/// +public static class DisplayQuoting +{ + private static readonly Regex QuotedWithId = new( + "^\"(?.*)\"\\s*\\(#(?\\d+)\\)$", + RegexOptions.Compiled); + + /// Wraps in quotes, doubling any embedded quote. + public static string Quote(string name) => $"\"{name.Replace("\"", "\"\"")}\""; + + /// + /// Formats the lossless "name" (#id) form used for static + /// references, or just "name" when is the + /// unknown sentinel (0). + /// + public static string QuoteWithId(string name, int id) => + id == 0 ? Quote(name) : $"{Quote(name)} (#{id})"; + + /// Parses a "name" (#id) token, undoubling escaped quotes. + public static bool TryParseQuotedWithId(string token, out string name, out int id) + { + var match = QuotedWithId.Match(token); + if (match.Success) + { + name = Unescape(match.Groups["name"].Value); + id = int.Parse(match.Groups["id"].Value); + return true; + } + + name = ""; + id = 0; + return false; + } + + /// Parses a bare "name" token, undoubling escaped quotes. + public static bool TryParseQuoted(string token, out string name) + { + if (token.Length >= 2 && token[0] == '"' && token[^1] == '"') + { + name = Unescape(token[1..^1]); + return true; + } + + name = ""; + return false; + } + + /// + /// Parses a "name" (#id) token, falling back to a bare + /// "name" token with the unknown-id sentinel (0) when no id + /// suffix is present — the shared degrade-on-hand-edit behavior used by + /// every step that quotes a in its display line. + /// + public static bool TryParseNamedRef(string token, out NamedRef namedRef) + { + if (TryParseQuotedWithId(token, out var name, out var id)) + { + namedRef = new NamedRef(id, name); + return true; + } + + if (TryParseQuoted(token, out var bareName)) + { + namedRef = new NamedRef(0, bareName); + return true; + } + + namedRef = new NamedRef(0, ""); + return false; + } + + private static string Unescape(string s) => s.Replace("\"\"", "\""); +} diff --git a/src/SharpFM.Model/Scripting/Steps/GoToLayoutStep.cs b/src/SharpFM.Model/Scripting/Steps/GoToLayoutStep.cs index dbb7d0ee..263199d3 100644 --- a/src/SharpFM.Model/Scripting/Steps/GoToLayoutStep.cs +++ b/src/SharpFM.Model/Scripting/Steps/GoToLayoutStep.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; -using System.Text.RegularExpressions; using System.Xml.Linq; using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Serialization; using SharpFM.Model.Scripting.Shapes; using SharpFM.Model.Scripting.Values; @@ -105,9 +105,7 @@ public override string ToDisplayLine() // and dropped the suffix, or caller constructed without an // id). Suppressing (#0) keeps the display clean when we // don't actually have an id to preserve. - parts.Add(named.Layout.Id == 0 - ? $"\"{named.Layout.Name}\"" - : $"\"{named.Layout.Name}\" (#{named.Layout.Id})"); + parts.Add(DisplayQuoting.QuoteWithId(named.Layout.Name, named.Layout.Id)); break; case LayoutTarget.ByNameCalc byName: @@ -127,10 +125,6 @@ public override string ToDisplayLine() // --- Display text parse --- - private static readonly Regex NamedLayoutToken = new( - "^\"(?.*)\"\\s*\\(#(?\\d+)\\)$", - RegexOptions.Compiled); - protected internal override void PopulateFromDisplay(string[] hrParams) { LayoutTarget target = new LayoutTarget.Original(); @@ -160,24 +154,13 @@ protected internal override void PopulateFromDisplay(string[] hrParams) var expr = token.Substring("Layout Number:".Length).Trim(); target = new LayoutTarget.ByNumberCalc(new Calculation(expr)); } - else + // Named layout with (#id) suffix is the lossless form. Bare + // quoted names without an id degrade to a NamedRef with id 0 — + // the user edited the display text and dropped the id, there's + // nothing better we can do. + else if (DisplayQuoting.TryParseNamedRef(token, out var namedRef)) { - // Named layout with (#id) suffix is the lossless form. - // Bare quoted names without an id degrade to a NamedRef - // with id 0 — the user edited the display text and - // dropped the id, there's nothing better we can do. - var match = NamedLayoutToken.Match(token); - if (match.Success) - { - var name = match.Groups["name"].Value; - var id = int.Parse(match.Groups["id"].Value); - target = new LayoutTarget.Named(new NamedRef(id, name)); - } - else if (token.StartsWith("\"") && token.EndsWith("\"") && token.Length >= 2) - { - var name = token.Substring(1, token.Length - 2); - target = new LayoutTarget.Named(new NamedRef(0, name)); - } + target = new LayoutTarget.Named(namedRef); } } diff --git a/src/SharpFM.Model/Scripting/Steps/GoToRelatedRecordStep.cs b/src/SharpFM.Model/Scripting/Steps/GoToRelatedRecordStep.cs index a2780d32..24b36931 100644 --- a/src/SharpFM.Model/Scripting/Steps/GoToRelatedRecordStep.cs +++ b/src/SharpFM.Model/Scripting/Steps/GoToRelatedRecordStep.cs @@ -1,5 +1,6 @@ using System; using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Serialization; using SharpFM.Model.Scripting.Shapes; using SharpFM.Model.Scripting.Values; @@ -64,9 +65,9 @@ public GoToRelatedRecordStep( public override string ToDisplayLine() { var parts = new System.Collections.Generic.List(); - parts.Add($"From table: \"{Table.Name}\""); + parts.Add($"From table: {DisplayQuoting.Quote(Table.Name)}"); if (Layout is not null && (Layout.Id != 0 || !string.IsNullOrEmpty(Layout.Name))) - parts.Add($"Using layout: \"{Layout.Name}\""); + parts.Add($"Using layout: {DisplayQuoting.Quote(Layout.Name)}"); if (ShowOnlyRelated) parts.Add("Show only related records"); if (MatchAllRecords) parts.Add("Match found set"); if (ShowInNewWindow) parts.Add("New window"); @@ -84,9 +85,15 @@ protected internal override void PopulateFromDisplay(string[] hrParams) { var t = tok.Trim(); if (t.StartsWith("From table:", StringComparison.OrdinalIgnoreCase)) - table = new NamedRef(0, Unquote(t.Substring(11).Trim())); + { + var name = t.Substring(11).Trim(); + table = new NamedRef(0, DisplayQuoting.TryParseQuoted(name, out var parsed) ? parsed : name); + } else if (t.StartsWith("Using layout:", StringComparison.OrdinalIgnoreCase)) - layout = new NamedRef(0, Unquote(t.Substring(13).Trim())); + { + var name = t.Substring(13).Trim(); + layout = new NamedRef(0, DisplayQuoting.TryParseQuoted(name, out var parsed) ? parsed : name); + } else if (t.Equals("Show only related records", StringComparison.OrdinalIgnoreCase)) showOnly = true; else if (t.Equals("Match found set", StringComparison.OrdinalIgnoreCase)) @@ -104,12 +111,6 @@ protected internal override void PopulateFromDisplay(string[] hrParams) Layout = layout; } - private static string Unquote(string s) - { - if (s.StartsWith("\"") && s.EndsWith("\"") && s.Length >= 2) return s.Substring(1, s.Length - 2); - return s; - } - public static StepMetadata Metadata { get; } = new() { Name = XmlName, diff --git a/src/SharpFM.Model/Scripting/Steps/InstallMenuSetStep.cs b/src/SharpFM.Model/Scripting/Steps/InstallMenuSetStep.cs index a601ac3a..612be6c5 100644 --- a/src/SharpFM.Model/Scripting/Steps/InstallMenuSetStep.cs +++ b/src/SharpFM.Model/Scripting/Steps/InstallMenuSetStep.cs @@ -1,5 +1,6 @@ using System; using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Serialization; using SharpFM.Model.Scripting.Shapes; using SharpFM.Model.Scripting.Values; @@ -24,7 +25,7 @@ public InstallMenuSetStep(NamedRef? menuSet = null, bool useAsFileDefault = fals // Hand-written: quoted menu-set name token the shape renderer cannot produce. public override string ToDisplayLine() => - $"Install Menu Set [ \"{MenuSet.Name}\" ; Use as file default: {(UseAsFileDefault ? "On" : "Off")} ]"; + $"Install Menu Set [ {DisplayQuoting.Quote(MenuSet.Name)} ; Use as file default: {(UseAsFileDefault ? "On" : "Off")} ]"; protected internal override void PopulateFromDisplay(string[] hrParams) { @@ -40,9 +41,7 @@ protected internal override void PopulateFromDisplay(string[] hrParams) } else if (!menuSeen && !string.IsNullOrWhiteSpace(t)) { - var name = t; - if (name.StartsWith("\"") && name.EndsWith("\"") && name.Length >= 2) - name = name.Substring(1, name.Length - 2); + var name = DisplayQuoting.TryParseQuoted(t, out var parsed) ? parsed : t; // The built-in menu set has the fixed id 1; custom menu sets // carry file-specific ids the canonical form wildcards. menu = new NamedRef(name == "[Standard FileMaker Menus]" ? 1 : 0, name); diff --git a/src/SharpFM.Model/Scripting/Steps/InstallOnTimerScriptStep.cs b/src/SharpFM.Model/Scripting/Steps/InstallOnTimerScriptStep.cs index 3ea48547..59aa0943 100644 --- a/src/SharpFM.Model/Scripting/Steps/InstallOnTimerScriptStep.cs +++ b/src/SharpFM.Model/Scripting/Steps/InstallOnTimerScriptStep.cs @@ -1,5 +1,6 @@ using System; using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Serialization; using SharpFM.Model.Scripting.Shapes; using SharpFM.Model.Scripting.Values; @@ -31,7 +32,7 @@ public InstallOnTimerScriptStep(NamedRef? script = null, Calculation? interval = // the shape renderer cannot produce. public override string ToDisplayLine() { - var script = Script is null ? "" : $"\"{Script.Name}\""; + var script = Script is null ? "" : DisplayQuoting.Quote(Script.Name); return Interval is null ? $"Install OnTimer Script [ {script} ]" : $"Install OnTimer Script [ {script} ; Interval: {Interval.Text} ]"; @@ -51,9 +52,7 @@ protected internal override void PopulateFromDisplay(string[] hrParams) } else if (!scriptSeen && !string.IsNullOrWhiteSpace(t) && t != "") { - var name = t; - if (name.StartsWith("\"") && name.EndsWith("\"") && name.Length >= 2) - name = name.Substring(1, name.Length - 2); + var name = DisplayQuoting.TryParseQuoted(t, out var parsed) ? parsed : t; script = new NamedRef(0, name); scriptSeen = true; } diff --git a/src/SharpFM.Model/Scripting/Steps/PerformScriptOnServerStep.cs b/src/SharpFM.Model/Scripting/Steps/PerformScriptOnServerStep.cs index c03dbf60..94c18a7b 100644 --- a/src/SharpFM.Model/Scripting/Steps/PerformScriptOnServerStep.cs +++ b/src/SharpFM.Model/Scripting/Steps/PerformScriptOnServerStep.cs @@ -1,5 +1,6 @@ using System.Xml.Linq; using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Serialization; using SharpFM.Model.Scripting.Shapes; using SharpFM.Model.Scripting.Values; @@ -56,9 +57,7 @@ public override string ToDisplayLine() switch (Target) { case PerformScriptTarget.ByReference byRef: - parts.Add(byRef.Script.Id == 0 - ? $"\"{byRef.Script.Name}\"" - : $"\"{byRef.Script.Name}\" (#{byRef.Script.Id})"); + parts.Add(DisplayQuoting.QuoteWithId(byRef.Script.Name, byRef.Script.Id)); break; case PerformScriptTarget.ByCalculation byCalc: parts.Add($"By name: {byCalc.NameCalc.Text}"); @@ -103,14 +102,8 @@ protected internal override void PopulateFromDisplay(string[] hrParams) parameter = new Calculation(t.Substring(10).Trim()); else if (t.StartsWith("By name:", System.StringComparison.OrdinalIgnoreCase)) target = new PerformScriptTarget.ByCalculation(new Calculation(t.Substring(8).Trim())); - else if (t.StartsWith("\"") && t.Contains("(#")) - { - var idMatch = System.Text.RegularExpressions.Regex.Match(t, @"^""(?.*)""\s*\(#(?\d+)\)$"); - if (idMatch.Success) - target = new PerformScriptTarget.ByReference(new NamedRef(int.Parse(idMatch.Groups["id"].Value), idMatch.Groups["name"].Value)); - } - else if (t.StartsWith("\"") && t.EndsWith("\"") && t.Length >= 2) - target = new PerformScriptTarget.ByReference(new NamedRef(0, t.Substring(1, t.Length - 2))); + else if (DisplayQuoting.TryParseNamedRef(t, out var namedRef)) + target = new PerformScriptTarget.ByReference(namedRef); } WaitForCompletion = wait; Target = target; diff --git a/src/SharpFM.Model/Scripting/Steps/PerformScriptOnServerWithCallbackStep.cs b/src/SharpFM.Model/Scripting/Steps/PerformScriptOnServerWithCallbackStep.cs index cfa5e286..028ce1cf 100644 --- a/src/SharpFM.Model/Scripting/Steps/PerformScriptOnServerWithCallbackStep.cs +++ b/src/SharpFM.Model/Scripting/Steps/PerformScriptOnServerWithCallbackStep.cs @@ -1,5 +1,6 @@ using System.Xml.Linq; using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Serialization; using SharpFM.Model.Scripting.Shapes; using SharpFM.Model.Scripting.Values; @@ -64,14 +65,14 @@ public override string ToDisplayLine() switch (Target) { case PerformScriptTarget.ByReference byRef: - parts.Add(byRef.Script.Id == 0 ? $"\"{byRef.Script.Name}\"" : $"\"{byRef.Script.Name}\" (#{byRef.Script.Id})"); + parts.Add(DisplayQuoting.QuoteWithId(byRef.Script.Name, byRef.Script.Id)); break; case PerformScriptTarget.ByCalculation byCalc: parts.Add($"By name: {byCalc.NameCalc.Text}"); break; } if (Parameter is not null) parts.Add($"Parameter: {Parameter.Text}"); - if (CallbackScript is not null) parts.Add($"Callback: \"{CallbackScript.Name}\""); + if (CallbackScript is not null) parts.Add($"Callback: {DisplayQuoting.Quote(CallbackScript.Name)}"); return $"Perform Script on Server with Callback [ {string.Join(" ; ", parts)} ]"; } diff --git a/src/SharpFM.Model/Scripting/Steps/PerformScriptStep.cs b/src/SharpFM.Model/Scripting/Steps/PerformScriptStep.cs index 135adf47..5fdb9e66 100644 --- a/src/SharpFM.Model/Scripting/Steps/PerformScriptStep.cs +++ b/src/SharpFM.Model/Scripting/Steps/PerformScriptStep.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; -using System.Text.RegularExpressions; using System.Xml.Linq; using SharpFM.Model.Scripting.Registry; +using SharpFM.Model.Scripting.Serialization; using SharpFM.Model.Scripting.Shapes; using SharpFM.Model.Scripting.Values; @@ -84,9 +84,7 @@ public override string ToDisplayLine() case PerformScriptTarget.ByReference byRef: // Suppress (#0) when we don't actually have a script id to // preserve — same convention as GoToLayoutStep. - parts.Add(byRef.Script.Id == 0 - ? $"\"{byRef.Script.Name}\"" - : $"\"{byRef.Script.Name}\" (#{byRef.Script.Id})"); + parts.Add(DisplayQuoting.QuoteWithId(byRef.Script.Name, byRef.Script.Id)); break; case PerformScriptTarget.ByCalculation byCalc: @@ -100,10 +98,6 @@ public override string ToDisplayLine() return $"Perform Script [ {string.Join(" ; ", parts)} ]"; } - private static readonly Regex NamedScriptToken = new( - "^\"(?.*)\"\\s*\\(#(?\\d+)\\)$", - RegexOptions.Compiled); - protected internal override void PopulateFromDisplay(string[] hrParams) { PerformScriptTarget target = new PerformScriptTarget.ByReference(new NamedRef(0, "")); @@ -124,20 +118,9 @@ protected internal override void PopulateFromDisplay(string[] hrParams) var expr = token.Substring("By name:".Length).Trim(); target = new PerformScriptTarget.ByCalculation(new Calculation(expr)); } - else + else if (DisplayQuoting.TryParseNamedRef(token, out var namedRef)) { - var match = NamedScriptToken.Match(token); - if (match.Success) - { - var name = match.Groups["name"].Value; - var id = int.Parse(match.Groups["id"].Value); - target = new PerformScriptTarget.ByReference(new NamedRef(id, name)); - } - else if (token.StartsWith("\"") && token.EndsWith("\"") && token.Length >= 2) - { - var name = token.Substring(1, token.Length - 2); - target = new PerformScriptTarget.ByReference(new NamedRef(0, name)); - } + target = new PerformScriptTarget.ByReference(namedRef); } } diff --git a/tests/SharpFM.Tests/Scripting/Serialization/DisplayQuotingTests.cs b/tests/SharpFM.Tests/Scripting/Serialization/DisplayQuotingTests.cs new file mode 100644 index 00000000..43becd0e --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/Serialization/DisplayQuotingTests.cs @@ -0,0 +1,99 @@ +using SharpFM.Model.Scripting.Serialization; +using SharpFM.Model.Scripting.Values; +using Xunit; + +namespace SharpFM.Tests.Scripting.Serialization; + +public class DisplayQuotingTests +{ + [Theory] + [InlineData("Refresh", "\"Refresh\"")] + [InlineData("", "\"\"")] + [InlineData("O\"Brien", "\"O\"\"Brien\"")] + [InlineData(" Padded ", "\" Padded \"")] + [InlineData("looks\" (#9) done", "\"looks\"\" (#9) done\"")] + public void Quote_DoublesEmbeddedQuotes(string name, string expected) => + Assert.Equal(expected, DisplayQuoting.Quote(name)); + + [Theory] + [InlineData("Refresh", 4, "\"Refresh\" (#4)")] + [InlineData("Refresh", 0, "\"Refresh\"")] + [InlineData("O\"Brien", 5, "\"O\"\"Brien\" (#5)")] + public void QuoteWithId_SuppressesZeroSentinel(string name, int id, string expected) => + Assert.Equal(expected, DisplayQuoting.QuoteWithId(name, id)); + + [Theory] + [InlineData("\"Refresh\"", "Refresh")] + [InlineData("\"\"", "")] + [InlineData("\"O\"\"Brien\"", "O\"Brien")] + [InlineData("\" Padded \"", " Padded ")] + public void TryParseQuoted_UndoublesEmbeddedQuotes(string token, string expectedName) + { + Assert.True(DisplayQuoting.TryParseQuoted(token, out var name)); + Assert.Equal(expectedName, name); + } + + [Theory] + [InlineData("Refresh")] + [InlineData("\"Refresh")] + [InlineData("Refresh\"")] + [InlineData("")] + public void TryParseQuoted_RejectsUnquotedTokens(string token) => + Assert.False(DisplayQuoting.TryParseQuoted(token, out _)); + + [Theory] + [InlineData("\"Refresh\" (#4)", "Refresh", 4)] + [InlineData("\"O\"\"Brien\" (#5)", "O\"Brien", 5)] + [InlineData("\"looks\"\" (#9) done\" (#7)", "looks\" (#9) done", 7)] + public void TryParseQuotedWithId_UndoublesEmbeddedQuotesAndParsesId(string token, string expectedName, int expectedId) + { + Assert.True(DisplayQuoting.TryParseQuotedWithId(token, out var name, out var id)); + Assert.Equal(expectedName, name); + Assert.Equal(expectedId, id); + } + + [Theory] + [InlineData("\"Refresh\"")] + [InlineData("Refresh (#4)")] + public void TryParseQuotedWithId_RejectsTokensMissingEitherPart(string token) => + Assert.False(DisplayQuoting.TryParseQuotedWithId(token, out _, out _)); + + [Theory] + [InlineData("Refresh")] + [InlineData("O\"Brien")] + [InlineData("")] + [InlineData(" Padded ")] + [InlineData("looks\" (#9) done")] + public void QuoteThenParse_RoundTrips(string name) + { + Assert.True(DisplayQuoting.TryParseQuoted(DisplayQuoting.Quote(name), out var parsed)); + Assert.Equal(name, parsed); + } + + [Theory] + [InlineData("Refresh", 4)] + [InlineData("O\"Brien", 5)] + [InlineData("looks\" (#9) done", 7)] + public void QuoteWithIdThenParse_RoundTrips(string name, int id) + { + Assert.True(DisplayQuoting.TryParseQuotedWithId(DisplayQuoting.QuoteWithId(name, id), out var parsedName, out var parsedId)); + Assert.Equal(name, parsedName); + Assert.Equal(id, parsedId); + } + + [Theory] + [InlineData("\"Refresh\" (#4)", "Refresh", 4)] + [InlineData("\"O\"\"Brien\"", "O\"Brien", 0)] + public void TryParseNamedRef_PrefersIdSuffixOverBareForm(string token, string expectedName, int expectedId) + { + Assert.True(DisplayQuoting.TryParseNamedRef(token, out var namedRef)); + Assert.Equal(new NamedRef(expectedId, expectedName), namedRef); + } + + [Fact] + public void TryParseNamedRef_RejectsUnquotedToken() + { + Assert.False(DisplayQuoting.TryParseNamedRef("Refresh", out var namedRef)); + Assert.Equal(new NamedRef(0, ""), namedRef); + } +} diff --git a/tests/SharpFM.Tests/Scripting/Steps/GoToLayoutStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/GoToLayoutStepTests.cs index eb2a3ad1..ffdab2b8 100644 --- a/tests/SharpFM.Tests/Scripting/Steps/GoToLayoutStepTests.cs +++ b/tests/SharpFM.Tests/Scripting/Steps/GoToLayoutStepTests.cs @@ -50,6 +50,11 @@ public class GoToLayoutStepTests "" + ""; + private const string VerbatimSelectedLayoutWithQuoteInNameXml = + "" + + "" + + ""; + // --- SelectedLayout (named) --- [Fact] @@ -194,4 +199,30 @@ public void SelectedLayout_FullRoundTrip_FromDisplayTextPreservesId() private static FmScript ScriptFromDisplay(string display) => SharpFM.Scripting.ScriptTextParser.FromDisplayText(display); + + // --- Embedded quote in layout name --- + + [Fact] + public void SelectedLayout_WithQuoteInName_Display_DoublesEmbeddedQuote() + { + var step = ScriptStep.FromXml(MakeStep(VerbatimSelectedLayoutWithQuoteInNameXml)); + + Assert.Equal("Go to Layout [ \"O\"\"Brien\" (#81) ]", step.ToDisplayLine()); + } + + [Fact] + public void SelectedLayout_WithQuoteInName_FullRoundTrip_PreservesName() + { + var step1 = ScriptStep.FromXml(MakeStep(VerbatimSelectedLayoutWithQuoteInNameXml)); + var display = step1.ToDisplayLine(); + + var script = ScriptFromDisplay(display); + var rebuilt = script.ToXml(); + + var roundTripped = XElement.Parse(rebuilt).Element("Step")!; + var layout = roundTripped.Element("Layout"); + Assert.NotNull(layout); + Assert.Equal("81", layout!.Attribute("id")!.Value); + Assert.Equal("O\"Brien", layout.Attribute("name")!.Value); + } } diff --git a/tests/SharpFM.Tests/Scripting/Steps/GoToRelatedRecordStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/GoToRelatedRecordStepTests.cs index 33cf58be..ecf4340a 100644 --- a/tests/SharpFM.Tests/Scripting/Steps/GoToRelatedRecordStepTests.cs +++ b/tests/SharpFM.Tests/Scripting/Steps/GoToRelatedRecordStepTests.cs @@ -12,6 +12,10 @@ public class GoToRelatedRecordStepTests