diff --git a/docs/advanced-filemaker-scripting-syntax.md b/docs/advanced-filemaker-scripting-syntax.md
index 1353d46..175e09f 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 0000000..10042c4
--- /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 dbb7d0e..263199d 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 a2780d3..24b3693 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 a601ac3..612be6c 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 3ea4854..59aa094 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 c03dbf6..94c18a7 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 cfa5e28..028ce1c 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 135adf4..5fdb9e6 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 0000000..43becd0
--- /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 eb2a3ad..ffdab2b 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 33cf58b..ecf4340 100644
--- a/tests/SharpFM.Tests/Scripting/Steps/GoToRelatedRecordStepTests.cs
+++ b/tests/SharpFM.Tests/Scripting/Steps/GoToRelatedRecordStepTests.cs
@@ -12,6 +12,10 @@ public class GoToRelatedRecordStepTests
""";
+ private const string QuoteInTableNameXml = """
+
+ """;
+
[Fact]
public void RoundTrip_CanonicalXml_IsPreserved()
{
@@ -26,4 +30,21 @@ public void Registry_HasStep()
Assert.True(StepRegistry.ByName.TryGetValue("Go to Related Record", out var metadata));
Assert.Equal(74, metadata!.Id);
}
+
+ [Fact]
+ public void Display_QuoteInTableName_DoublesEmbeddedQuote()
+ {
+ var step = GoToRelatedRecordStep.Parse(XElement.Parse(QuoteInTableNameXml));
+ Assert.Equal("Go to Related Record [ From table: \"O\"\"Brien Customers\" ]", step.ToDisplayLine());
+ }
+
+ [Fact]
+ public void FullRoundTrip_QuoteInTableName_PreservesName()
+ {
+ var step1 = GoToRelatedRecordStep.Parse(XElement.Parse(QuoteInTableNameXml));
+ var display = step1.ToDisplayLine();
+ var step2 = SharpFM.Scripting.ScriptTextParser.FromDisplayLine(display);
+ var xml = step2.ToXml();
+ Assert.Equal("O\"Brien Customers", xml.Element("Table")!.Attribute("name")!.Value);
+ }
}
diff --git a/tests/SharpFM.Tests/Scripting/Steps/InstallMenuSetStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/InstallMenuSetStepTests.cs
index 8d626f8..d52904a 100644
--- a/tests/SharpFM.Tests/Scripting/Steps/InstallMenuSetStepTests.cs
+++ b/tests/SharpFM.Tests/Scripting/Steps/InstallMenuSetStepTests.cs
@@ -11,6 +11,10 @@ public class InstallMenuSetStepTests
""";
+ private const string QuoteInNameXml = """
+
+ """;
+
[Fact]
public void RoundTrip_CanonicalXml_IsPreserved()
{
@@ -32,4 +36,21 @@ public void Registry_HasStep()
Assert.True(StepRegistry.ByName.TryGetValue("Install Menu Set", out var metadata));
Assert.Equal(142, metadata!.Id);
}
+
+ [Fact]
+ public void Display_QuoteInName_DoublesEmbeddedQuote()
+ {
+ var step = InstallMenuSetStep.Parse(XElement.Parse(QuoteInNameXml));
+ Assert.Equal("Install Menu Set [ \"O\"\"Brien Menu\" ; Use as file default: Off ]", step.ToDisplayLine());
+ }
+
+ [Fact]
+ public void FullRoundTrip_QuoteInName_PreservesName()
+ {
+ var step1 = InstallMenuSetStep.Parse(XElement.Parse(QuoteInNameXml));
+ var display = step1.ToDisplayLine();
+ var step2 = SharpFM.Scripting.ScriptTextParser.FromDisplayLine(display);
+ var xml = step2.ToXml();
+ Assert.Equal("O\"Brien Menu", xml.Element("CustomMenuSet")!.Attribute("name")!.Value);
+ }
}
diff --git a/tests/SharpFM.Tests/Scripting/Steps/InstallOnTimerScriptStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/InstallOnTimerScriptStepTests.cs
index 9fcdd05..6d8f496 100644
--- a/tests/SharpFM.Tests/Scripting/Steps/InstallOnTimerScriptStepTests.cs
+++ b/tests/SharpFM.Tests/Scripting/Steps/InstallOnTimerScriptStepTests.cs
@@ -12,6 +12,10 @@ public class InstallOnTimerScriptStepTests
""";
+ private const string QuoteInNameXml = """
+
+ """;
+
[Fact]
public void RoundTrip_CanonicalXml_IsPreserved()
{
@@ -33,4 +37,21 @@ public void Registry_HasStep()
Assert.True(StepRegistry.ByName.TryGetValue("Install OnTimer Script", out var metadata));
Assert.Equal(148, metadata!.Id);
}
+
+ [Fact]
+ public void Display_QuoteInName_DoublesEmbeddedQuote()
+ {
+ var step = InstallOnTimerScriptStep.Parse(XElement.Parse(QuoteInNameXml));
+ Assert.Equal("Install OnTimer Script [ \"O\"\"Brien\" ; Interval: 30 ]", step.ToDisplayLine());
+ }
+
+ [Fact]
+ public void FullRoundTrip_QuoteInName_PreservesName()
+ {
+ var step1 = InstallOnTimerScriptStep.Parse(XElement.Parse(QuoteInNameXml));
+ var display = step1.ToDisplayLine();
+ var step2 = SharpFM.Scripting.ScriptTextParser.FromDisplayLine(display);
+ var xml = step2.ToXml();
+ Assert.Equal("O\"Brien", xml.Element("Script")!.Attribute("name")!.Value);
+ }
}
diff --git a/tests/SharpFM.Tests/Scripting/Steps/PerformScriptOnServerStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/PerformScriptOnServerStepTests.cs
index d886cca..396d87a 100644
--- a/tests/SharpFM.Tests/Scripting/Steps/PerformScriptOnServerStepTests.cs
+++ b/tests/SharpFM.Tests/Scripting/Steps/PerformScriptOnServerStepTests.cs
@@ -11,6 +11,10 @@ public class PerformScriptOnServerStepTests
""";
+ private const string QuoteInScriptNameXml = """
+
+ """;
+
[Fact]
public void RoundTrip_CanonicalXml_IsPreserved()
{
@@ -25,4 +29,25 @@ public void Registry_HasStep()
Assert.True(StepRegistry.ByName.TryGetValue("Perform Script on Server", out var metadata));
Assert.Equal(164, metadata!.Id);
}
+
+ [Fact]
+ public void Display_QuoteInScriptName_DoublesEmbeddedQuote()
+ {
+ var step = PerformScriptOnServerStep.Parse(XElement.Parse(QuoteInScriptNameXml));
+ Assert.Equal("Perform Script on Server [ Wait for completion: On ; \"O\"\"Brien Sync\" (#5) ]", step.ToDisplayLine());
+ }
+
+ [Fact]
+ public void FullRoundTrip_QuoteInScriptName_PreservesNameAndId()
+ {
+ var step1 = PerformScriptOnServerStep.Parse(XElement.Parse(QuoteInScriptNameXml));
+ var display = step1.ToDisplayLine();
+ var step2 = SharpFM.Scripting.ScriptTextParser.FromDisplayLine(display);
+ var xml = step2.ToXml();
+
+ var script = xml.Element("Script");
+ Assert.NotNull(script);
+ Assert.Equal("5", script!.Attribute("id")!.Value);
+ Assert.Equal("O\"Brien Sync", script.Attribute("name")!.Value);
+ }
}
diff --git a/tests/SharpFM.Tests/Scripting/Steps/PerformScriptOnServerWithCallbackStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/PerformScriptOnServerWithCallbackStepTests.cs
index 8514507..dcc2995 100644
--- a/tests/SharpFM.Tests/Scripting/Steps/PerformScriptOnServerWithCallbackStepTests.cs
+++ b/tests/SharpFM.Tests/Scripting/Steps/PerformScriptOnServerWithCallbackStepTests.cs
@@ -11,6 +11,10 @@ public class PerformScriptOnServerWithCallbackStepTests
""";
+ private const string QuoteInNamesXml = """
+
+ """;
+
[Fact]
public void RoundTrip_CanonicalXml_IsPreserved()
{
@@ -25,4 +29,13 @@ public void Registry_HasStep()
Assert.True(StepRegistry.ByName.TryGetValue("Perform Script on Server with Callback", out var metadata));
Assert.Equal(210, metadata!.Id);
}
+
+ [Fact]
+ public void Display_QuoteInNames_DoublesEmbeddedQuotes()
+ {
+ var step = PerformScriptOnServerWithCallbackStep.Parse(XElement.Parse(QuoteInNamesXml));
+ Assert.Equal(
+ "Perform Script on Server with Callback [ State: Continue ; \"O\"\"Brien Sync\" (#5) ; Callback: \"On\"\"Done\" ]",
+ step.ToDisplayLine());
+ }
}
diff --git a/tests/SharpFM.Tests/Scripting/Steps/PerformScriptStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/PerformScriptStepTests.cs
index e4d81c8..004a9d6 100644
--- a/tests/SharpFM.Tests/Scripting/Steps/PerformScriptStepTests.cs
+++ b/tests/SharpFM.Tests/Scripting/Steps/PerformScriptStepTests.cs
@@ -30,6 +30,11 @@ public class PerformScriptStepTests
+ ""
+ "";
+ private const string ByRefWithQuoteInNameXml =
+ ""
+ + ""
+ + "";
+
[Fact]
public void ByRefWithParam_Display_IncludesIdSuffixAndParameterLabel()
{
@@ -129,4 +134,27 @@ public void FullRoundTrip_ByRef_PreservesScriptId()
Assert.Equal("4", script!.Attribute("id")!.Value);
Assert.Equal("Dummy-Script-For-Reference", script.Attribute("name")!.Value);
}
+
+ [Fact]
+ public void ByRefWithQuoteInName_Display_DoublesEmbeddedQuote()
+ {
+ var step = ScriptStep.FromXml(MakeStep(ByRefWithQuoteInNameXml));
+ Assert.Equal(
+ "Perform Script [ \"O\"\"Brien\" (#9) ]",
+ step.ToDisplayLine());
+ }
+
+ [Fact]
+ public void ByRefWithQuoteInName_FullRoundTrip_PreservesNameAndId()
+ {
+ var step1 = ScriptStep.FromXml(MakeStep(ByRefWithQuoteInNameXml));
+ var display = step1.ToDisplayLine();
+ var step2 = SharpFM.Scripting.ScriptTextParser.FromDisplayLine(display);
+ var xml = step2.ToXml();
+
+ var script = xml.Element("Script");
+ Assert.NotNull(script);
+ Assert.Equal("9", script!.Attribute("id")!.Value);
+ Assert.Equal("O\"Brien", script.Attribute("name")!.Value);
+ }
}