diff --git a/src/SharpFM.Model/Clip.cs b/src/SharpFM.Model/Clip.cs
new file mode 100644
index 0000000..058a7d8
--- /dev/null
+++ b/src/SharpFM.Model/Clip.cs
@@ -0,0 +1,171 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using SharpFM.Model.ClipTypes;
+using SharpFM.Model.Parsing;
+using SharpFM.Model.Scripting;
+
+namespace SharpFM.Model;
+
+///
+/// Immutable aggregate for a FileMaker clip: name, wire-format identifier,
+/// canonical XML body, and the parsed domain model + fidelity report. Every
+/// path that ingests a clip (paste from FileMaker, file load, plugin push,
+/// new-clip seed) ends up here, so the parse report is always available to
+/// any consumer regardless of how the clip arrived.
+///
+///
+/// View-models hold a Clip reference and re-publish change
+/// notifications when the reference is replaced — INPC stays out of the
+/// domain layer.
+///
+public sealed class Clip
+{
+ public string Name { get; }
+ public string FormatId { get; }
+ public string Xml { get; }
+
+ private readonly Func _parseFactory;
+ private ClipParseResult? _parsed;
+
+ ///
+ /// Outcome of parsing against the registered strategy.
+ /// Computed on first access — XML→domain parsing is the slow part of
+ /// constructing a clip, and most callers along the editor's hot path
+ /// only read .
+ ///
+ public ClipParseResult Parsed
+ {
+ get
+ {
+ // PublicationOnly semantics: multiple racing threads may compute
+ // the parse, only one result wins. Cheaper than full
+ // double-checked locking and the strategies are pure functions.
+ if (_parsed is null)
+ {
+ Interlocked.CompareExchange(ref _parsed, _parseFactory(), null);
+ }
+ return _parsed;
+ }
+ }
+
+ private byte[]? _cachedWireBytes;
+
+ ///
+ /// FileMaker clipboard wire format: 4-byte little-endian length prefix
+ /// followed by UTF-8 XML. Lazily derived from .
+ ///
+ public byte[] WireBytes
+ {
+ get
+ {
+ if (_cachedWireBytes is null)
+ {
+ var payload = Encoding.UTF8.GetBytes(Xml);
+ var prefix = BitConverter.GetBytes(payload.Length);
+ _cachedWireBytes = prefix.Concat(payload).ToArray();
+ }
+ return _cachedWireBytes;
+ }
+ }
+
+ private Clip(string name, string formatId, string xml, Func parseFactory)
+ {
+ Name = name;
+ FormatId = formatId;
+ Xml = xml;
+ _parseFactory = parseFactory;
+ }
+
+ private Clip(string name, string formatId, string xml, ClipParseResult parsed)
+ : this(name, formatId, xml, () => parsed)
+ {
+ _parsed = parsed;
+ }
+
+ ///
+ /// Construct a clip from raw XML. The XML is canonicalised via
+ /// ; the strategy parse runs lazily
+ /// when is first read. This method itself does not
+ /// throw — well-formedness errors surface as a
+ /// from the strategy on demand.
+ ///
+ public static Clip FromXml(string name, string formatId, string xml)
+ {
+ var canonical = XmlHelpers.PrettyPrint(xml ?? string.Empty);
+ return new Clip(
+ name,
+ formatId,
+ canonical,
+ () => ClipTypeRegistry.For(formatId).Parse(canonical));
+ }
+
+ ///
+ /// Construct a clip from FileMaker clipboard wire bytes (4-byte length
+ /// prefix + UTF-8 XML). Inputs shorter than 4 bytes are treated as empty.
+ ///
+ public static Clip FromWireBytes(string name, string formatId, byte[] bytes)
+ {
+ var xml = bytes.Length < 4
+ ? string.Empty
+ : Encoding.UTF8.GetString(bytes, 4, bytes.Length - 4);
+ return FromXml(name, formatId, xml);
+ }
+
+ ///
+ /// Construct a clip from a model the editor already holds. Skips the
+ /// strategy parse + round-trip diff entirely — the editor's domain model
+ /// is the source of truth, the XML it emits is lossless to that model
+ /// by definition. Only kind-specific diagnostics that aren't structural
+ /// (e.g. for RawStep)
+ /// are derived from the model directly.
+ ///
+ ///
+ /// This is the typing hot path for large scripts. Going through
+ /// on every debounced edit re-parses the XML
+ /// (~ N steps) plus serialises and structurally diffs (~ N more) on
+ /// the UI thread. FromEditor drops all of that.
+ ///
+ public static Clip FromEditor(string name, string formatId, string xml, ClipModel model)
+ {
+ var report = ReportForEditorModel(model);
+ return new Clip(name, formatId, xml, new ParseSuccess(model, report));
+ }
+
+ private static ClipParseReport ReportForEditorModel(ClipModel model)
+ {
+ if (model is ScriptClipModel script)
+ {
+ var diagnostics = ClipStrategyHelpers.RawStepDiagnostics(script.Script).ToList();
+ return diagnostics.Count == 0
+ ? ClipParseReport.Empty
+ : new ClipParseReport(diagnostics);
+ }
+ return ClipParseReport.Empty;
+ }
+
+ ///
+ /// Return a fresh clip with replacement XML. The parse runs lazily on
+ /// the new instance. Returns this when
+ /// is byte-identical to the current canonical XML, which short-circuits
+ /// the change cascade for keystroke-driven re-syncs that don't actually
+ /// change anything.
+ ///
+ public Clip WithXml(string newXml)
+ {
+ if (string.Equals(newXml, Xml, StringComparison.Ordinal))
+ {
+ return this;
+ }
+ return FromXml(Name, FormatId, newXml);
+ }
+
+ /// Return a fresh clip under a new name; the existing parse is reused.
+ public Clip Rename(string newName)
+ {
+ var parsed = Parsed;
+ return new Clip(newName, FormatId, Xml, parsed);
+ }
+}
diff --git a/src/SharpFM.Model/ClipDataExtensions.cs b/src/SharpFM.Model/ClipDataExtensions.cs
index 704f0e0..c01a2ce 100644
--- a/src/SharpFM.Model/ClipDataExtensions.cs
+++ b/src/SharpFM.Model/ClipDataExtensions.cs
@@ -1,4 +1,6 @@
using System.Collections.Generic;
+using SharpFM.Model.ClipTypes;
+using SharpFM.Model.Parsing;
using SharpFM.Model.Schema;
using SharpFM.Model.Scripting;
@@ -6,7 +8,9 @@ namespace SharpFM.Model;
///
/// Convenience extensions on for parsing into the
-/// appropriate domain model based on the clip's format.
+/// appropriate domain model based on the clip's format. Typed accessors
+/// dispatch through so adding a new format
+/// with a registered strategy automatically lights up the helpers.
///
public static class ClipDataExtensions
{
@@ -16,27 +20,23 @@ public static bool IsScript(this ClipData clip) =>
public static bool IsTable(this ClipData clip) =>
clip.ClipType is "Mac-XMTB" or "Mac-XMFD";
- ///
- /// Parse this clip as a script. Returns null if the clip is not a script type.
- ///
+ /// Parse this clip as a script; null if the clip is not a script type.
public static FmScript? AsScript(this ClipData clip) =>
- clip.IsScript() ? FmScript.FromXml(clip.Xml) : null;
+ ClipTypeRegistry.For(clip.ClipType).Parse(clip.Xml) is ParseSuccess { Model: ScriptClipModel s }
+ ? s.Script
+ : null;
- ///
- /// Parse this clip as a table. Returns null if the clip is not a table type.
- ///
+ /// Parse this clip as a table; null if the clip is not a table type.
public static FmTable? AsTable(this ClipData clip) =>
- clip.IsTable() ? FmTable.FromXml(clip.Xml) : null;
+ ClipTypeRegistry.For(clip.ClipType).Parse(clip.Xml) is ParseSuccess { Model: TableClipModel t }
+ ? t.Table
+ : null;
- ///
- /// Get the script's steps as a snapshot list. Returns null if the clip is not a script type.
- ///
+ /// Get the script's steps as a snapshot list; null if the clip is not a script type.
public static IReadOnlyList? GetScriptSteps(this ClipData clip) =>
clip.AsScript()?.Steps;
- ///
- /// Get the table's fields as a snapshot list. Returns null if the clip is not a table type.
- ///
+ /// Get the table's fields as a snapshot list; null if the clip is not a table type.
public static IReadOnlyList? GetTableFields(this ClipData clip) =>
clip.AsTable()?.Fields;
}
diff --git a/src/SharpFM.Model/ClipTypes/ClipStrategyHelpers.cs b/src/SharpFM.Model/ClipTypes/ClipStrategyHelpers.cs
new file mode 100644
index 0000000..e559bfa
--- /dev/null
+++ b/src/SharpFM.Model/ClipTypes/ClipStrategyHelpers.cs
@@ -0,0 +1,88 @@
+using System.Collections.Generic;
+using System.Xml;
+using System.Xml.Linq;
+using SharpFM.Model.Parsing;
+using SharpFM.Model.Scripting;
+using SharpFM.Model.Scripting.Steps;
+
+namespace SharpFM.Model.ClipTypes;
+
+///
+/// Shared parsing primitives used by every
+/// implementation: a one-line failure builder and the standard
+/// <fmxmlsnippet> well-formedness gate.
+///
+internal static class ClipStrategyHelpers
+{
+ /// Build a wrapping a single error-severity diagnostic.
+ public static ParseFailure Failure(
+ ParseDiagnosticKind kind, string location, string message, string reason) =>
+ new(reason, new ClipParseReport(
+ [
+ new ClipParseDiagnostic(kind, ParseDiagnosticSeverity.Error, location, message),
+ ]));
+
+ ///
+ /// Validate that is non-empty, well-formed, and
+ /// rooted at <fmxmlsnippet>. Returns true with the parsed
+ /// on success; false with a populated
+ /// on any of the three failure modes the
+ /// strategies share.
+ ///
+ public static bool TryParseFmxmlsnippet(string xml, out XElement root, out ParseFailure failure)
+ {
+ root = null!;
+ failure = null!;
+
+ if (string.IsNullOrWhiteSpace(xml))
+ {
+ failure = Failure(ParseDiagnosticKind.XmlMalformed, "/", "input was empty", "empty xml");
+ return false;
+ }
+
+ try
+ {
+ root = XElement.Parse(xml);
+ }
+ catch (XmlException ex)
+ {
+ failure = Failure(ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "malformed xml");
+ return false;
+ }
+
+ if (root.Name.LocalName != "fmxmlsnippet")
+ {
+ failure = Failure(
+ ParseDiagnosticKind.UnsupportedClipType,
+ "/" + root.Name.LocalName,
+ $"expected , found <{root.Name.LocalName}>",
+ "unsupported root element");
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Walk a script's steps and emit one Info-severity
+ /// diagnostic per . Used by both the script strategy
+ /// (after a fresh parse) and the trusted-edit path (after a model-only
+ /// reuse) so the same UI signal surfaces regardless of source.
+ ///
+ public static IEnumerable RawStepDiagnostics(FmScript script)
+ {
+ var index = 0;
+ foreach (var step in script.Steps)
+ {
+ index++;
+ if (step is RawStep raw)
+ {
+ yield return new ClipParseDiagnostic(
+ ParseDiagnosticKind.UnknownStep,
+ ParseDiagnosticSeverity.Info,
+ $"/fmxmlsnippet/Step[{index}]",
+ $"step '{raw.Name}' is not modeled by the host; preserved verbatim as RawStep");
+ }
+ }
+ }
+}
diff --git a/src/SharpFM.Model/ClipTypes/ClipTypeRegistry.cs b/src/SharpFM.Model/ClipTypes/ClipTypeRegistry.cs
new file mode 100644
index 0000000..bf30cc4
--- /dev/null
+++ b/src/SharpFM.Model/ClipTypes/ClipTypeRegistry.cs
@@ -0,0 +1,44 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace SharpFM.Model.ClipTypes;
+
+///
+/// Compile-time registry of implementations
+/// keyed by Mac-XM* format id. Strategies are fully owned by SharpFM
+/// (no plugin extension point), so the table is built once from a static
+/// list and is read-only thereafter — no locks, no bootstrap, no reset.
+/// Adding a new clip type means writing the strategy and adding it to
+/// .
+///
+public static class ClipTypeRegistry
+{
+ public static IReadOnlyList BuiltIns { get; } =
+ [
+ ScriptClipStrategy.Steps,
+ ScriptClipStrategy.Script,
+ TableClipStrategy.Table,
+ TableClipStrategy.Field,
+ LayoutClipStrategy.Instance,
+ ];
+
+ private static readonly Dictionary _byFormatId =
+ BuiltIns.ToDictionary(s => s.FormatId);
+
+ /// All built-in strategies (excludes the opaque fallback).
+ public static IReadOnlyList All => BuiltIns;
+
+ ///
+ /// Resolve a strategy for the given format id. Unknown ids fall back to
+ /// so callers always receive a
+ /// usable strategy.
+ ///
+ public static IClipTypeStrategy For(string formatId) =>
+ _byFormatId.TryGetValue(formatId, out var strategy)
+ ? strategy
+ : OpaqueClipStrategy.Instance;
+
+ /// True if the given format id has a dedicated built-in strategy.
+ public static bool IsRegistered(string formatId) =>
+ _byFormatId.ContainsKey(formatId);
+}
diff --git a/src/SharpFM.Model/ClipTypes/IClipTypeStrategy.cs b/src/SharpFM.Model/ClipTypes/IClipTypeStrategy.cs
new file mode 100644
index 0000000..9ac95ce
--- /dev/null
+++ b/src/SharpFM.Model/ClipTypes/IClipTypeStrategy.cs
@@ -0,0 +1,32 @@
+using SharpFM.Model.Parsing;
+
+namespace SharpFM.Model.ClipTypes;
+
+///
+/// Per-clip-type extension point. Each Mac-XM* format gets one
+/// implementation registered with ; everything
+/// the host needs to know about that format (display name, parse, default
+/// XML for a fresh clip) hangs off this interface.
+///
+public interface IClipTypeStrategy
+{
+ /// The wire-format identifier this strategy handles, e.g. "Mac-XMSS".
+ string FormatId { get; }
+
+ /// Human-readable label shown in the UI, e.g. "Script Steps".
+ string DisplayName { get; }
+
+ ///
+ /// Parse into a and emit a
+ /// describing any data the parse couldn't
+ /// represent in the domain model. Implementations must not throw — failures
+ /// are returned as .
+ ///
+ ClipParseResult Parse(string xml);
+
+ ///
+ /// Produce a starter XML body for a fresh clip with the given name. Used by
+ /// "new clip" flows in the host and by plugins.
+ ///
+ string DefaultXml(string clipName);
+}
diff --git a/src/SharpFM.Model/ClipTypes/LayoutClipStrategy.cs b/src/SharpFM.Model/ClipTypes/LayoutClipStrategy.cs
new file mode 100644
index 0000000..8fe3c4b
--- /dev/null
+++ b/src/SharpFM.Model/ClipTypes/LayoutClipStrategy.cs
@@ -0,0 +1,32 @@
+using SharpFM.Model.Parsing;
+
+namespace SharpFM.Model.ClipTypes;
+
+///
+/// Strategy for the FileMaker layout clipboard format Mac-XML2.
+/// SharpFM does not yet model layouts, so the strategy parses for
+/// well-formedness and a sane root element but preserves the body verbatim
+/// in . Promoting layouts to a typed domain
+/// model is a future change; this strategy makes that promotion drop-in.
+///
+public sealed class LayoutClipStrategy : IClipTypeStrategy
+{
+ public static IClipTypeStrategy Instance { get; } = new LayoutClipStrategy();
+
+ private LayoutClipStrategy() { }
+
+ public string FormatId => "Mac-XML2";
+ public string DisplayName => "Layout";
+
+ public ClipParseResult Parse(string xml)
+ {
+ if (!ClipStrategyHelpers.TryParseFmxmlsnippet(xml, out _, out var failure))
+ {
+ return failure;
+ }
+ return new ParseSuccess(new LayoutClipModel(xml), ClipParseReport.Empty);
+ }
+
+ public string DefaultXml(string clipName) =>
+ "";
+}
diff --git a/src/SharpFM.Model/ClipTypes/OpaqueClipStrategy.cs b/src/SharpFM.Model/ClipTypes/OpaqueClipStrategy.cs
new file mode 100644
index 0000000..46c6043
--- /dev/null
+++ b/src/SharpFM.Model/ClipTypes/OpaqueClipStrategy.cs
@@ -0,0 +1,47 @@
+using System.Xml;
+using System.Xml.Linq;
+using SharpFM.Model.Parsing;
+
+namespace SharpFM.Model.ClipTypes;
+
+///
+/// Fallback strategy for clip formats with no registered handler. Validates
+/// XML well-formedness, preserves the body verbatim, and never claims
+/// fidelity beyond "we held onto the bytes." Used by
+/// when an unknown Mac-XM* id arrives.
+///
+public sealed class OpaqueClipStrategy : IClipTypeStrategy
+{
+ public static OpaqueClipStrategy Instance { get; } = new();
+
+ private OpaqueClipStrategy() { }
+
+ /// Synthetic id used only by the fallback singleton; never registered with the registry.
+ public string FormatId => "Mac-XM??";
+
+ public string DisplayName => "Unknown";
+
+ public ClipParseResult Parse(string xml)
+ {
+ if (string.IsNullOrWhiteSpace(xml))
+ {
+ return ClipStrategyHelpers.Failure(
+ ParseDiagnosticKind.XmlMalformed, "/", "input was empty", "empty xml");
+ }
+
+ try
+ {
+ XDocument.Parse(xml);
+ }
+ catch (XmlException ex)
+ {
+ return ClipStrategyHelpers.Failure(
+ ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "malformed xml");
+ }
+
+ return new ParseSuccess(new OpaqueClipModel(xml), ClipParseReport.Empty);
+ }
+
+ public string DefaultXml(string clipName) =>
+ "";
+}
diff --git a/src/SharpFM.Model/ClipTypes/ScriptClipStrategy.cs b/src/SharpFM.Model/ClipTypes/ScriptClipStrategy.cs
new file mode 100644
index 0000000..0c88d82
--- /dev/null
+++ b/src/SharpFM.Model/ClipTypes/ScriptClipStrategy.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Collections.Generic;
+using SharpFM.Model.Parsing;
+using SharpFM.Model.Scripting;
+
+namespace SharpFM.Model.ClipTypes;
+
+///
+/// Strategy for the two FileMaker script clipboard formats. Mac-XMSS
+/// (Script Steps) holds a bare list of steps under <fmxmlsnippet>;
+/// Mac-XMSC (Script) wraps the steps in a <Script> element
+/// carrying name/id/run-fullaccess metadata.
+/// already handles both shapes — this strategy adds the round-trip diff and
+/// the unknown-step inventory to the result.
+///
+public sealed class ScriptClipStrategy : IClipTypeStrategy
+{
+ /// Strategy instance for Mac-XMSS (Script Steps).
+ public static IClipTypeStrategy Steps { get; } =
+ new ScriptClipStrategy("Mac-XMSS", "Script Steps");
+
+ /// Strategy instance for Mac-XMSC (Script).
+ public static IClipTypeStrategy Script { get; } =
+ new ScriptClipStrategy("Mac-XMSC", "Script");
+
+ private ScriptClipStrategy(string formatId, string displayName)
+ {
+ FormatId = formatId;
+ DisplayName = displayName;
+ }
+
+ public string FormatId { get; }
+ public string DisplayName { get; }
+
+ public ClipParseResult Parse(string xml)
+ {
+ if (!ClipStrategyHelpers.TryParseFmxmlsnippet(xml, out var input, out var failure))
+ {
+ return failure;
+ }
+
+ FmScript script;
+ try
+ {
+ script = FmScript.FromXml(xml);
+ }
+ catch (Exception ex)
+ {
+ return ClipStrategyHelpers.Failure(
+ ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "failed to parse script");
+ }
+
+ var diagnostics = new List(
+ XmlRoundTripDiff.Compute(input, script.ToElement()));
+ diagnostics.AddRange(ClipStrategyHelpers.RawStepDiagnostics(script));
+
+ var report = diagnostics.Count == 0
+ ? ClipParseReport.Empty
+ : new ClipParseReport(diagnostics);
+
+ return new ParseSuccess(new ScriptClipModel(script), report);
+ }
+
+ public string DefaultXml(string clipName) =>
+ "";
+}
diff --git a/src/SharpFM.Model/ClipTypes/TableClipStrategy.cs b/src/SharpFM.Model/ClipTypes/TableClipStrategy.cs
new file mode 100644
index 0000000..1dab800
--- /dev/null
+++ b/src/SharpFM.Model/ClipTypes/TableClipStrategy.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Collections.Generic;
+using SharpFM.Model.Parsing;
+using SharpFM.Model.Schema;
+
+namespace SharpFM.Model.ClipTypes;
+
+///
+/// Strategy for FileMaker table-shaped clipboard formats. Mac-XMTB
+/// (Table) carries a <BaseTable> wrapper plus its fields;
+/// Mac-XMFD (Field) is the field-only variant. Both round-trip through
+/// / .
+///
+public sealed class TableClipStrategy : IClipTypeStrategy
+{
+ /// Strategy instance for Mac-XMTB (Table).
+ public static IClipTypeStrategy Table { get; } =
+ new TableClipStrategy("Mac-XMTB", "Table", wrapsBaseTable: true);
+
+ /// Strategy instance for Mac-XMFD (Field).
+ public static IClipTypeStrategy Field { get; } =
+ new TableClipStrategy("Mac-XMFD", "Field", wrapsBaseTable: false);
+
+ private readonly bool _wrapsBaseTable;
+
+ private TableClipStrategy(string formatId, string displayName, bool wrapsBaseTable)
+ {
+ FormatId = formatId;
+ DisplayName = displayName;
+ _wrapsBaseTable = wrapsBaseTable;
+ }
+
+ public string FormatId { get; }
+ public string DisplayName { get; }
+
+ public ClipParseResult Parse(string xml)
+ {
+ if (!ClipStrategyHelpers.TryParseFmxmlsnippet(xml, out var input, out var failure))
+ {
+ return failure;
+ }
+
+ FmTable table;
+ try
+ {
+ table = FmTable.FromXml(xml);
+ }
+ catch (Exception ex)
+ {
+ return ClipStrategyHelpers.Failure(
+ ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "failed to parse table");
+ }
+
+ var diagnostics = new List(
+ XmlRoundTripDiff.Compute(input, table.ToElement()));
+
+ var report = diagnostics.Count == 0
+ ? ClipParseReport.Empty
+ : new ClipParseReport(diagnostics);
+
+ return new ParseSuccess(new TableClipModel(table), report);
+ }
+
+ public string DefaultXml(string clipName) =>
+ _wrapsBaseTable
+ ? $""
+ : "";
+}
diff --git a/src/SharpFM.Model/FileMakerClip.cs b/src/SharpFM.Model/FileMakerClip.cs
deleted file mode 100644
index 977b4ab..0000000
--- a/src/SharpFM.Model/FileMakerClip.cs
+++ /dev/null
@@ -1,188 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.ComponentModel;
-using System.IO;
-using System.Linq;
-using System.Runtime.CompilerServices;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Xml.Linq;
-using SharpFM.Model.Scripting;
-
-namespace SharpFM.Model;
-
-public class FileMakerClip : INotifyPropertyChanged
-{
- public event PropertyChangedEventHandler? PropertyChanged;
-
- private void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
- {
- PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
- }
-
- public class ClipFormat
- {
- public string KeyId { get; set; } = string.Empty;
- public string DisplayName { get; set; } = string.Empty;
- }
-
- public static List ClipTypes { get; set; } = new List
- {
- new ClipFormat() { KeyId = "Mac-XMSS", DisplayName = "ScriptSteps" },
- new ClipFormat() { KeyId = "Mac-XML2", DisplayName = "Layout" },
- new ClipFormat() { KeyId = "Mac-XMTB", DisplayName = "Table" },
- new ClipFormat() { KeyId = "Mac-XMFD", DisplayName = "Field" },
- new ClipFormat() { KeyId = "Mac-XMSC", DisplayName = "Script" }
- };
-
- public FileMakerClip(string name, string format, string xml)
- {
- // grab the input clip name
- Name = name;
-
- // load the format
- ClipboardFormat = format;
-
- XmlData = XmlHelpers.PrettyPrint(xml);
- }
-
- ///
- /// Constructor taking in the raw data byte array.
- ///
- /// The name of the clip.
- /// Format of the clip.
- /// Data containing the clip.
- public FileMakerClip(string name, string format, byte[] data)
- {
- // grab the input clip name
- Name = name;
- // load the format
- ClipboardFormat = format;
- // skip the first four bytes, as this is a length check
- XmlData = ClipBytesToPrettyXml(data.Skip(4));
-
- // if the data is empty, move on.
- if (string.IsNullOrEmpty(XmlData)) { return; }
-
- // try to show better "name" if possible
- var xdoc = XDocument.Load(new StringReader(XmlData));
- var containerName = xdoc.Element("fmxmlsnippet")?.Descendants().FirstOrDefault()?.Attribute("name")?.Value ?? "new-clip";
-
- // set the name from the xml data if possible and fall back to constructor parameter
- Name = containerName ?? name;
- }
-
- ///
- /// Clipboard Format
- ///
- public string ClipboardFormat { get; set; }
-
- private string _name = string.Empty;
-
- ///
- /// Name of Clip
- ///
- public string Name
- {
- get => _name;
- set
- {
- if (_name == value) return;
- _name = value;
- NotifyPropertyChanged();
- }
- }
-
- ///
- /// Raw data that can be put back onto the Clipboard in FileMaker structure.
- /// Cached — invalidated when XmlData changes.
- ///
- public byte[] RawData
- {
- get
- {
- if (_cachedRawData == null)
- {
- byte[] byteList = Encoding.UTF8.GetBytes(XmlData);
- byte[] intBytes = BitConverter.GetBytes(byteList.Length);
- _cachedRawData = intBytes.Concat(byteList).ToArray();
- }
- return _cachedRawData;
- }
- }
-
- private string _xmlData = string.Empty;
- private byte[]? _cachedRawData;
-
- ///
- /// The actual clip. Users work with the Xml version here, and then pull the RawData property when ready to write back to FileMaker.
- ///
- public string XmlData
- {
- get => _xmlData;
- set
- {
- if (_xmlData == value) return;
- _xmlData = value;
- _cachedRawData = null; // invalidate cache
- NotifyPropertyChanged();
- }
- }
-
- ///
- /// The fields exposed through this FileMaker Clip (if its a table or a layout).
- ///
- public IEnumerable Fields
- {
- get
- {
- var xdoc = XDocument.Parse(XmlData);
-
- var clipType = ClipTypes.SingleOrDefault(ct => ct.KeyId == ClipboardFormat);
-
- switch (clipType?.DisplayName)
- {
- case "Table": // When we have a table, we can get rich metadata from the clipboard data.
- return xdoc
- .Descendants("BaseTable")
- .Elements("Field")
- .Select(x => new FileMakerField
- {
- FileMakerFieldId = int.Parse(x.Attribute("id")?.Value ?? ""),
- Name = x.Attribute("name")?.Value ?? "",
- DataType = x.Attribute("dataType")?.Value ?? "",
- FieldType = x.Attribute("fieldType")?.Value ?? "",
- NotEmpty = bool.Parse(x.Element("Validation")?.Element("NotEmpty")?.Attribute("value")?.Value ?? "false"),
- Unique = bool.Parse(x.Element("Validation")?.Element("Unique")?.Attribute("value")?.Value ?? "false"),
- Comment = x.Element("Comment")?.Value,
- });
-
- case "Layout": // on a layout we only have the field name (TABLE::FIELD) to go on, so we do that.
- return xdoc
- .Descendants("Object")
- .Where(x => x.Attribute("type")?.Value == "Field")
- .Descendants("FieldObj")
- .Elements("Name")
- .Select(x => new FileMakerField { Name = Regex.Split(x.Value, "::")[1] });
- }
-
- // return empty list of we don't have a matching type
- return new List();
- }
- }
-
- ///
- /// Utility method for prettifying the Xml for a user to read.
- ///
- /// The byte array containing the xml data.
- /// A prettified version of the byte array as a formatted xml string.
- public static string ClipBytesToPrettyXml(IEnumerable clipData)
- {
- var xmlComments = Encoding.UTF8.GetString(clipData.ToArray());
- if (string.IsNullOrEmpty(xmlComments))
- {
- return xmlComments;
- }
- return XmlHelpers.PrettyPrint(xmlComments);
- }
-}
\ No newline at end of file
diff --git a/src/SharpFM.Model/FileMakerField.cs b/src/SharpFM.Model/FileMakerField.cs
deleted file mode 100644
index 2bb51d1..0000000
--- a/src/SharpFM.Model/FileMakerField.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-
-namespace SharpFM.Model;
-
-public class FileMakerField
-{
- public int FileMakerFieldId { get; set; }
-
- public string Name { get; set; } = null!;
-
- public string DataType { get; set; } = null!;
-
- public string FieldType { get; set; } = null!;
-
- public string? Comment { get; set; }
-
- public bool NotEmpty { get; set; }
-
- public bool Unique { get; set; }
-}
diff --git a/src/SharpFM.Model/Parsing/ClipModel.cs b/src/SharpFM.Model/Parsing/ClipModel.cs
new file mode 100644
index 0000000..9cb2a85
--- /dev/null
+++ b/src/SharpFM.Model/Parsing/ClipModel.cs
@@ -0,0 +1,25 @@
+using SharpFM.Model.Schema;
+using SharpFM.Model.Scripting;
+
+namespace SharpFM.Model.Parsing;
+
+///
+/// Discriminated representation of a parsed clip's body. Concrete subtypes
+/// carry the typed domain object for clip kinds we model
+/// (, ) or the raw
+/// XML for kinds where we don't yet have a domain model
+/// (, ).
+///
+public abstract record ClipModel;
+
+/// Parsed body for Mac-XMSS / Mac-XMSC clips.
+public sealed record ScriptClipModel(FmScript Script) : ClipModel;
+
+/// Parsed body for Mac-XMTB / Mac-XMFD clips.
+public sealed record TableClipModel(FmTable Table) : ClipModel;
+
+/// Parsed body for Mac-XML2 (Layout) clips. No domain model yet — XML round-trips verbatim.
+public sealed record LayoutClipModel(string Xml) : ClipModel;
+
+/// Fallback for clip formats with no registered strategy. Body preserved as-is.
+public sealed record OpaqueClipModel(string Xml) : ClipModel;
diff --git a/src/SharpFM.Model/Parsing/ClipParseDiagnostic.cs b/src/SharpFM.Model/Parsing/ClipParseDiagnostic.cs
new file mode 100644
index 0000000..deef191
--- /dev/null
+++ b/src/SharpFM.Model/Parsing/ClipParseDiagnostic.cs
@@ -0,0 +1,16 @@
+namespace SharpFM.Model.Parsing;
+
+///
+/// A single XML→domain parse-fidelity issue discovered while parsing a clip.
+/// Anchored to a location in the source XML so consumers can point a user
+/// (or an agent authoring XML) at the exact spot.
+///
+/// Category of the loss; consumers can group by this.
+/// Whether this is fatal, lossy, or informational.
+/// An xpath-style locator into the source XML.
+/// A human-readable description of the loss.
+public sealed record ClipParseDiagnostic(
+ ParseDiagnosticKind Kind,
+ ParseDiagnosticSeverity Severity,
+ string Location,
+ string Message);
diff --git a/src/SharpFM.Model/Parsing/ClipParseReport.cs b/src/SharpFM.Model/Parsing/ClipParseReport.cs
new file mode 100644
index 0000000..b3e083a
--- /dev/null
+++ b/src/SharpFM.Model/Parsing/ClipParseReport.cs
@@ -0,0 +1,17 @@
+using System.Collections.Generic;
+
+namespace SharpFM.Model.Parsing;
+
+///
+/// Aggregate of every produced by parsing one
+/// clip. Lossless when empty; consumers check rather
+/// than inspecting the collection directly.
+///
+public sealed record ClipParseReport(IReadOnlyList Diagnostics)
+{
+ /// Shared empty report used by clean parses to avoid allocating.
+ public static ClipParseReport Empty { get; } = new([]);
+
+ /// True if the parse produced no diagnostics of any kind.
+ public bool IsLossless => Diagnostics.Count == 0;
+}
diff --git a/src/SharpFM.Model/Parsing/ClipParseResult.cs b/src/SharpFM.Model/Parsing/ClipParseResult.cs
new file mode 100644
index 0000000..bf2b136
--- /dev/null
+++ b/src/SharpFM.Model/Parsing/ClipParseResult.cs
@@ -0,0 +1,19 @@
+namespace SharpFM.Model.Parsing;
+
+///
+/// Outcome of parsing a clip's XML against a registered strategy. Discriminated
+/// so consumers must explicitly handle the "couldn't produce a model at all"
+/// case () rather than receiving a silent empty model.
+/// Both branches carry a describing what was lost.
+///
+public abstract record ClipParseResult(ClipParseReport Report);
+
+/// Parse produced a domain model. The report may still contain warnings.
+public sealed record ParseSuccess(ClipModel Model, ClipParseReport Report) : ClipParseResult(Report);
+
+///
+/// Parse could not produce a model (e.g. malformed XML, wrong root element,
+/// unsupported clip type). is a short description;
+/// the report contains the underlying diagnostics.
+///
+public sealed record ParseFailure(string Reason, ClipParseReport Report) : ClipParseResult(Report);
diff --git a/src/SharpFM.Model/Parsing/ParseDiagnosticKind.cs b/src/SharpFM.Model/Parsing/ParseDiagnosticKind.cs
new file mode 100644
index 0000000..f15e4f2
--- /dev/null
+++ b/src/SharpFM.Model/Parsing/ParseDiagnosticKind.cs
@@ -0,0 +1,36 @@
+namespace SharpFM.Model.Parsing;
+
+///
+/// Categorises an XML→domain parse loss. Each
+/// carries one of these so consumers (UI, MCP tools, plugins) can surface the
+/// loss in actionable form rather than as opaque text.
+///
+public enum ParseDiagnosticKind
+{
+ /// Source XML was not well-formed or could not be parsed.
+ XmlMalformed,
+
+ /// The clip's format identifier has no registered strategy.
+ UnsupportedClipType,
+
+ /// A <Step> name is not in the step registry; preserved as a RawStep.
+ UnknownStep,
+
+ /// An attribute on a <Step> was not consumed by its parser.
+ UnknownStepAttribute,
+
+ /// A child element under a <Step> was not consumed by its parser.
+ UnknownStepElement,
+
+ /// An element under the clip root (e.g. <fmxmlsnippet>) was not consumed.
+ UnknownClipElement,
+
+ /// An attribute on the clip root was not consumed.
+ UnknownClipAttribute,
+
+ /// A namespace declaration in the source XML was not preserved through the round trip.
+ DroppedNamespace,
+
+ /// A modeled element parsed back with a value that differs from the input.
+ RoundTripValueMismatch,
+}
diff --git a/src/SharpFM.Model/Parsing/ParseDiagnosticKindExtensions.cs b/src/SharpFM.Model/Parsing/ParseDiagnosticKindExtensions.cs
new file mode 100644
index 0000000..8b5b147
--- /dev/null
+++ b/src/SharpFM.Model/Parsing/ParseDiagnosticKindExtensions.cs
@@ -0,0 +1,34 @@
+using System.Collections.Generic;
+
+namespace SharpFM.Model.Parsing;
+
+///
+/// Human-readable labels for . Lives next to
+/// the enum so any consumer surfacing parse diagnostics (UI, MCP tools,
+/// future plugin API) shares the same wording.
+///
+public static class ParseDiagnosticKindExtensions
+{
+ private static readonly Dictionary Labels = new()
+ {
+ [ParseDiagnosticKind.UnknownStep] = ("unknown step", "unknown steps"),
+ [ParseDiagnosticKind.UnknownStepElement] = ("unknown step element", "unknown step elements"),
+ [ParseDiagnosticKind.UnknownStepAttribute] = ("unknown step attribute", "unknown step attributes"),
+ [ParseDiagnosticKind.UnknownClipElement] = ("unknown element", "unknown elements"),
+ [ParseDiagnosticKind.UnknownClipAttribute] = ("unknown attribute", "unknown attributes"),
+ [ParseDiagnosticKind.DroppedNamespace] = ("dropped namespace", "dropped namespaces"),
+ [ParseDiagnosticKind.RoundTripValueMismatch] = ("value mismatch", "value mismatches"),
+ [ParseDiagnosticKind.XmlMalformed] = ("malformed xml", "malformed xml"),
+ [ParseDiagnosticKind.UnsupportedClipType] = ("unsupported clip type", "unsupported clip type"),
+ };
+
+ ///
+ /// Format as a human-readable noun phrase, choosing
+ /// the singular form when is 1 and the plural
+ /// otherwise. Falls back to "issue" for unmapped kinds.
+ ///
+ public static string ToHumanLabel(this ParseDiagnosticKind kind, int count) =>
+ Labels.TryGetValue(kind, out var pair)
+ ? (count == 1 ? pair.Singular : pair.Plural)
+ : "issue";
+}
diff --git a/src/SharpFM.Model/Parsing/ParseDiagnosticSeverity.cs b/src/SharpFM.Model/Parsing/ParseDiagnosticSeverity.cs
new file mode 100644
index 0000000..896087d
--- /dev/null
+++ b/src/SharpFM.Model/Parsing/ParseDiagnosticSeverity.cs
@@ -0,0 +1,13 @@
+namespace SharpFM.Model.Parsing;
+
+///
+/// Severity of a . Intentionally distinct from
+/// : that type belongs to
+/// the display-text validator (display→XML), this one to XML→domain parse fidelity.
+///
+public enum ParseDiagnosticSeverity
+{
+ Error,
+ Warning,
+ Info,
+}
diff --git a/src/SharpFM.Model/Parsing/XmlRoundTripDiff.cs b/src/SharpFM.Model/Parsing/XmlRoundTripDiff.cs
new file mode 100644
index 0000000..c0a94b8
--- /dev/null
+++ b/src/SharpFM.Model/Parsing/XmlRoundTripDiff.cs
@@ -0,0 +1,175 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Xml.Linq;
+
+namespace SharpFM.Model.Parsing;
+
+///
+/// Structural diff between two XML trees, used by clip-type strategies as the
+/// "did this round-trip cleanly?" check. Compares parsed-then-serialized
+/// against the source XML and reports anything the round trip dropped or
+/// changed as s.
+///
+///
+/// The comparison is name-based and order-insensitive within a parent (an
+/// out-of-order rewrite of children is not flagged as a loss); attribute order
+/// is ignored; element-text is compared trimmed (whitespace-only differences
+/// are noise from pretty-printing). Differences are categorised by parent
+/// element so consumers see
+/// vs. rather than a flat
+/// "something differs" stream.
+///
+public static class XmlRoundTripDiff
+{
+ ///
+ /// Compare (source XML) against
+ /// (model serialised back to XML). Anything in input absent from output is a
+ /// loss; anything in output absent from input is reported as informational
+ /// (typically a default the model emits and the input omitted).
+ ///
+ public static IReadOnlyList Compute(XElement input, XElement output)
+ {
+ var diagnostics = new List();
+ CompareElements(input, output, "/" + input.Name.LocalName, diagnostics);
+ DiffNamespaces(input, output, diagnostics);
+ return diagnostics;
+ }
+
+ private static void CompareElements(XElement input, XElement output, string path, List diags)
+ {
+ DiffAttributes(input, output, path, diags);
+
+ var inputHasChildren = input.HasElements;
+ var outputHasChildren = output.HasElements;
+
+ if (!inputHasChildren && !outputHasChildren)
+ {
+ var inputText = input.Value.Trim();
+ var outputText = output.Value.Trim();
+ if (inputText != outputText)
+ {
+ diags.Add(new ClipParseDiagnostic(
+ ParseDiagnosticKind.RoundTripValueMismatch,
+ ParseDiagnosticSeverity.Warning,
+ path,
+ $"text differs: input '{Truncate(inputText)}' vs output '{Truncate(outputText)}'"));
+ }
+ return;
+ }
+
+ var outputByName = output.Elements()
+ .GroupBy(e => e.Name.LocalName)
+ .ToDictionary(g => g.Key, g => new Queue(g));
+
+ var indexByName = new Dictionary();
+ foreach (var inputChild in input.Elements())
+ {
+ var name = inputChild.Name.LocalName;
+ indexByName[name] = indexByName.TryGetValue(name, out var prior) ? prior + 1 : 1;
+ var childPath = $"{path}/{name}[{indexByName[name]}]";
+
+ if (outputByName.TryGetValue(name, out var queue) && queue.Count > 0)
+ {
+ CompareElements(inputChild, queue.Dequeue(), childPath, diags);
+ }
+ else
+ {
+ diags.Add(new ClipParseDiagnostic(
+ KindForUnmodeledChild(input.Name.LocalName),
+ ParseDiagnosticSeverity.Warning,
+ childPath,
+ $"input child <{name}> not preserved through round trip"));
+ }
+ }
+
+ foreach (var (_, leftover) in outputByName)
+ {
+ foreach (var orphan in leftover)
+ {
+ diags.Add(new ClipParseDiagnostic(
+ ParseDiagnosticKind.RoundTripValueMismatch,
+ ParseDiagnosticSeverity.Info,
+ $"{path}/{orphan.Name.LocalName}",
+ $"output emitted <{orphan.Name.LocalName}> not present in input (likely a default)"));
+ }
+ }
+ }
+
+ private static void DiffAttributes(XElement input, XElement output, string path, List diags)
+ {
+ var outputAttrs = output.Attributes()
+ .Where(a => !a.IsNamespaceDeclaration)
+ .ToDictionary(a => a.Name.LocalName, a => a.Value);
+
+ foreach (var inputAttr in input.Attributes())
+ {
+ if (inputAttr.IsNamespaceDeclaration)
+ {
+ continue;
+ }
+
+ var name = inputAttr.Name.LocalName;
+ if (!outputAttrs.TryGetValue(name, out var outputValue))
+ {
+ diags.Add(new ClipParseDiagnostic(
+ KindForUnmodeledAttribute(input.Name.LocalName),
+ ParseDiagnosticSeverity.Warning,
+ $"{path}/@{name}",
+ $"input attribute @{name}=\"{Truncate(inputAttr.Value)}\" not preserved through round trip"));
+ }
+ else if (outputValue != inputAttr.Value)
+ {
+ diags.Add(new ClipParseDiagnostic(
+ ParseDiagnosticKind.RoundTripValueMismatch,
+ ParseDiagnosticSeverity.Warning,
+ $"{path}/@{name}",
+ $"attribute @{name}: input \"{Truncate(inputAttr.Value)}\" vs output \"{Truncate(outputValue)}\""));
+ }
+ }
+ }
+
+ private static void DiffNamespaces(XElement input, XElement output, List diags)
+ {
+ var inputNamespaces = CollectNamespaces(input);
+ var outputNamespaces = CollectNamespaces(output);
+ foreach (var ns in inputNamespaces.Except(outputNamespaces))
+ {
+ diags.Add(new ClipParseDiagnostic(
+ ParseDiagnosticKind.DroppedNamespace,
+ ParseDiagnosticSeverity.Warning,
+ "/",
+ $"namespace \"{ns}\" declared in input was not preserved through round trip"));
+ }
+ }
+
+ private static HashSet CollectNamespaces(XElement root)
+ {
+ var set = new HashSet();
+ foreach (var element in root.DescendantsAndSelf())
+ {
+ foreach (var attr in element.Attributes())
+ {
+ if (attr.IsNamespaceDeclaration)
+ {
+ set.Add(attr.Value);
+ }
+ }
+ }
+ return set;
+ }
+
+ private static ParseDiagnosticKind KindForUnmodeledChild(string parentLocalName) =>
+ parentLocalName == "Step"
+ ? ParseDiagnosticKind.UnknownStepElement
+ : ParseDiagnosticKind.UnknownClipElement;
+
+ private static ParseDiagnosticKind KindForUnmodeledAttribute(string parentLocalName) =>
+ parentLocalName == "Step"
+ ? ParseDiagnosticKind.UnknownStepAttribute
+ : ParseDiagnosticKind.UnknownClipAttribute;
+
+ private const int TruncateLength = 60;
+
+ private static string Truncate(string s) =>
+ s.Length <= TruncateLength ? s : s[..TruncateLength] + "…";
+}
diff --git a/src/SharpFM.Model/Schema/FmTable.cs b/src/SharpFM.Model/Schema/FmTable.cs
index de27461..2d94452 100644
--- a/src/SharpFM.Model/Schema/FmTable.cs
+++ b/src/SharpFM.Model/Schema/FmTable.cs
@@ -39,7 +39,11 @@ public static FmTable FromXml(string xml)
return new FmTable(tableName, fields) { Id = tableId };
}
- public string ToXml()
+ ///
+ /// Build the table's XML as an directly, skipping
+ /// the ToString + pretty-print round-trip does.
+ ///
+ public XElement ToElement()
{
var root = new XElement("fmxmlsnippet", new XAttribute("type", "FMObjectList"));
var baseTable = new XElement("BaseTable", new XAttribute("name", Name));
@@ -51,9 +55,11 @@ public string ToXml()
root.Add(baseTable);
- return XmlHelpers.PrettyPrint(root.ToString());
+ return root;
}
+ public string ToXml() => XmlHelpers.PrettyPrint(ToElement().ToString());
+
public void AddField(FmField field)
{
Fields.Add(field);
diff --git a/src/SharpFM.Model/Scripting/FmScript.cs b/src/SharpFM.Model/Scripting/FmScript.cs
index 91bef54..04cb7a5 100644
--- a/src/SharpFM.Model/Scripting/FmScript.cs
+++ b/src/SharpFM.Model/Scripting/FmScript.cs
@@ -60,7 +60,13 @@ public static FmScript FromXml(string xml)
// --- Serialize to FM XML ---
- public string ToXml()
+ ///
+ /// Build the script's XML as an directly, skipping
+ /// the ToString + pretty-print round-trip does.
+ /// Use this when the caller needs an element to walk (e.g. round-trip
+ /// diffing) rather than a serialised string.
+ ///
+ public XElement ToElement()
{
var root = new XElement("fmxmlsnippet", new XAttribute("type", "FMObjectList"));
@@ -77,9 +83,11 @@ public string ToXml()
root.Add(step.ToXml());
}
- return XmlHelpers.PrettyPrint(root.ToString());
+ return root;
}
+ public string ToXml() => XmlHelpers.PrettyPrint(ToElement().ToString());
+
// --- Render to display text ---
public string ToDisplayText()
diff --git a/src/SharpFM.Model/Scripting/Steps/RawStep.cs b/src/SharpFM.Model/Scripting/Steps/RawStep.cs
index dcc560b..48a7af0 100644
--- a/src/SharpFM.Model/Scripting/Steps/RawStep.cs
+++ b/src/SharpFM.Model/Scripting/Steps/RawStep.cs
@@ -27,6 +27,12 @@ public sealed class RawStep : ScriptStep
///
internal XElement Element => _element;
+ ///
+ /// Step name as preserved from the source XML, e.g. "Future Step".
+ /// Returns "Unknown" if the source had no name attribute.
+ ///
+ public string Name => _element.Attribute("name")?.Value ?? "Unknown";
+
public RawStep(XElement element)
: base(IsEnabled(element))
{
@@ -41,20 +47,17 @@ public override string ToDisplayLine()
{
var rawText = _element.Element("RawText")?.Value;
if (!string.IsNullOrEmpty(rawText)) return rawText;
- return _element.Attribute("name")?.Value ?? "Unknown";
+ return Name;
}
- public override List Validate(int lineIndex)
- {
- var name = _element.Attribute("name")?.Value ?? "Unknown";
- return new List
+ public override List Validate(int lineIndex) =>
+ new()
{
- new(lineIndex, 0, name.Length,
- $"Unknown script step '{name}' — preserved verbatim as a RawStep. "
+ new(lineIndex, 0, Name.Length,
+ $"Unknown script step '{Name}' — preserved verbatim as a RawStep. "
+ "Edit the underlying XML via the XML editor; display-text edits here won't round-trip.",
DiagnosticSeverity.Warning)
};
- }
private static bool IsEnabled(XElement element) =>
element.Attribute("enable")?.Value != "False";
diff --git a/src/SharpFM.Model/SharpFM.Model.csproj b/src/SharpFM.Model/SharpFM.Model.csproj
index f64a949..f6316b2 100644
--- a/src/SharpFM.Model/SharpFM.Model.csproj
+++ b/src/SharpFM.Model/SharpFM.Model.csproj
@@ -7,5 +7,6 @@
+
diff --git a/src/SharpFM/Core/FileMakerClipExtensions.cs b/src/SharpFM/Core/FileMakerClipExtensions.cs
index e7b908b..7491f5f 100644
--- a/src/SharpFM/Core/FileMakerClipExtensions.cs
+++ b/src/SharpFM/Core/FileMakerClipExtensions.cs
@@ -1,141 +1,115 @@
-using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
-
using SharpFM.Model;
+using SharpFM.Model.Parsing;
+using SharpFM.Model.Schema;
namespace SharpFM;
-public static class FileMakerClipExtensions
+///
+/// Code-generation helpers operating on a parsed . Today
+/// only table clips can produce a class; layouts and scripts throw
+/// .
+///
+public static class ClipCodeGenExtensions
{
///
- /// Create a class from scratch.
+ /// Generate a C# class with one [DataMember] property per field
+ /// in this table clip. Throws if the clip isn't a parsed table.
///
- public static string CreateClass(this FileMakerClip _clip, FileMakerClip? fieldProjectionLayout = null)
+ public static string CreateClass(this Clip clip)
{
- if (_clip == null) { return string.Empty; }
-
- var fieldProjectionList = new List();
- if (fieldProjectionLayout != null && FileMakerClip.ClipTypes.Single(ct => ct.KeyId == fieldProjectionLayout.ClipboardFormat).DisplayName == "Layout")
- {
- // a clip that is of type layout, only has name attribute (since the rest isn't available)
- // and we only need the name to skip it down below
- fieldProjectionList.AddRange(fieldProjectionLayout.Fields.Select(f => f.Name));
- }
- else
+ if (clip.Parsed is not ParseSuccess { Model: TableClipModel tableModel })
{
- // otherwise include all fields
- fieldProjectionList.AddRange(_clip.Fields.Select(f => f.Name));
+ throw new NotSupportedException(
+ "Code generation is only supported for table clips (Mac-XMTB / Mac-XMFD).");
}
- return _clip.CreateClass(fieldProjectionList);
+ var table = tableModel.Table;
+ return CreateClassFromTable(table, table.Fields.Select(f => f.Name));
}
///
- /// Create a class from scratch.
+ /// Generate a C# class for a table, projecting only the named fields.
///
- public static string CreateClass(this FileMakerClip _clip, IEnumerable fieldProjectionList)
+ public static string CreateClass(this Clip clip, IEnumerable fieldProjectionList)
{
- // Create a namespace: (namespace CodeGenerationSample)
- var @namespace = SyntaxFactory.NamespaceDeclaration(SyntaxFactory.ParseName("SharpFM.CodeGen")).NormalizeWhitespace();
+ if (clip.Parsed is not ParseSuccess { Model: TableClipModel tableModel })
+ {
+ throw new NotSupportedException(
+ "Code generation is only supported for table clips (Mac-XMTB / Mac-XMFD).");
+ }
- // Add System using statement: (using System)
+ return CreateClassFromTable(tableModel.Table, fieldProjectionList);
+ }
+
+ private static string CreateClassFromTable(FmTable table, IEnumerable fieldProjectionList)
+ {
+ var @namespace = SyntaxFactory.NamespaceDeclaration(SyntaxFactory.ParseName("SharpFM.CodeGen")).NormalizeWhitespace();
@namespace = @namespace.AddUsings(SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("System")));
@namespace = @namespace.AddUsings(SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("System.Runtime.Serialization")));
var dataContractAttribute = SyntaxFactory.Attribute(SyntaxFactory.ParseName("DataContract"));
-
- // Create a class: (class [_clip.Name])
- var classDeclaration = SyntaxFactory.ClassDeclaration(_clip.Name);
-
- // Add the public modifier: (public class [_clip.Name])
+ var classDeclaration = SyntaxFactory.ClassDeclaration(table.Name);
classDeclaration = classDeclaration.AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword));
- classDeclaration = classDeclaration.AddAttributeLists(SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(dataContractAttribute)));
+ classDeclaration = classDeclaration.AddAttributeLists(
+ SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(dataContractAttribute)));
- // add each field from the underling _clip as a public property with the data member attribute
- List fieldsToBeAddedAsProperties = new List(_clip.Fields.Count());
- // include the field projection
- foreach (var field in _clip.Fields.Where(fmF => fieldProjectionList.Contains(fmF.Name)))
- {
- // filemaker to C# data type mapping
- var propertyTypeCSharp = string.Empty;
-
- switch (field.DataType)
- {
- case "Text":
- propertyTypeCSharp = "string";
- break;
- case "Number":
- propertyTypeCSharp = "int";
- break;
- case "Binary":
- propertyTypeCSharp = "byte[]";
- break;
- case "Date":
- propertyTypeCSharp = "DateTime";
- break;
- case "Time":
- propertyTypeCSharp = "TimeSpan";
- break;
- case "TimeStamp":
- propertyTypeCSharp = "DateTime";
- break;
- default:
- propertyTypeCSharp = "string";
- break;
- }
-
- if (field.NotEmpty == false && propertyTypeCSharp != "string")
- {
- propertyTypeCSharp += "?";
- }
-
- var propertyTypeSyntax = SyntaxFactory.ParseTypeName(propertyTypeCSharp);
+ var projection = new HashSet(fieldProjectionList, StringComparer.Ordinal);
+ var properties = new List();
+ foreach (var field in table.Fields.Where(f => projection.Contains(f.Name)))
+ {
+ var propertyType = MapFieldDataType(field);
+ var propertyTypeSyntax = SyntaxFactory.ParseTypeName(propertyType);
var dataMemberAttribute = SyntaxFactory.Attribute(SyntaxFactory.ParseName("DataMember"));
var propertyDeclaration = SyntaxFactory.PropertyDeclaration(propertyTypeSyntax, field.Name)
- .AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword))
- .AddAccessorListAccessors(
- SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration).WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)),
- SyntaxFactory.AccessorDeclaration(SyntaxKind.SetAccessorDeclaration).WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)))
- .NormalizeWhitespace(indentation: "", eol: " ")
- .AddAttributeLists(SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(dataMemberAttribute)))
- .NormalizeWhitespace();
-
- fieldsToBeAddedAsProperties.Add(propertyDeclaration);
+ .AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword))
+ .AddAccessorListAccessors(
+ SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration).WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)),
+ SyntaxFactory.AccessorDeclaration(SyntaxKind.SetAccessorDeclaration).WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)))
+ .NormalizeWhitespace(indentation: "", eol: " ")
+ .AddAttributeLists(SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(dataMemberAttribute)))
+ .NormalizeWhitespace();
+
+ properties.Add(propertyDeclaration);
}
- // Add the field, the property and method to the class.
- classDeclaration = classDeclaration.AddMembers(fieldsToBeAddedAsProperties.ToArray());
-
- // Add the class to the namespace.
+ classDeclaration = classDeclaration.AddMembers(properties.ToArray());
@namespace = @namespace.AddMembers(classDeclaration);
- // Normalize and get code as string.
- var code = @namespace.NormalizeWhitespace().ToFullString().FormatAutoPropertiesOnOneLine();
-
- // Output new code to the console.
- return code;
+ return @namespace.NormalizeWhitespace().ToFullString().FormatAutoPropertiesOnOneLine();
}
-
- ///
- /// https://stackoverflow.com/a/52339795/86860
- ///
- private static readonly Regex AutoPropRegex = new Regex(@"\s*\{\s*get;\s*set;\s*}\s");
-
- ///
- /// https://stackoverflow.com/a/52339795/86860
- ///
- /// Code string to format.
- /// The code string with auto properties formatted to a single line
- private static string FormatAutoPropertiesOnOneLine(this string str)
+ private static string MapFieldDataType(FmField field)
{
- return AutoPropRegex.Replace(str, " { get; set; }");
+ var raw = field.DataType.ToString();
+ var clr = raw switch
+ {
+ "Text" => "string",
+ "Number" => "int",
+ "Binary" => "byte[]",
+ "Date" => "DateTime",
+ "Time" => "TimeSpan",
+ "TimeStamp" => "DateTime",
+ _ => "string",
+ };
+
+ if (!field.NotEmpty && clr != "string")
+ {
+ clr += "?";
+ }
+ return clr;
}
+
+ private static readonly Regex AutoPropRegex = new(@"\s*\{\s*get;\s*set;\s*}\s");
+
+ private static string FormatAutoPropertiesOnOneLine(this string str) =>
+ AutoPropRegex.Replace(str, " { get; set; }");
}
diff --git a/src/SharpFM/Diagnostics/RawClipboardWindow.axaml.cs b/src/SharpFM/Diagnostics/RawClipboardWindow.axaml.cs
index b1a72c8..5bc7314 100644
--- a/src/SharpFM/Diagnostics/RawClipboardWindow.axaml.cs
+++ b/src/SharpFM/Diagnostics/RawClipboardWindow.axaml.cs
@@ -61,10 +61,10 @@ private async System.Threading.Tasks.Task OnPaste()
return;
}
- var xml = FileMakerClip.ClipBytesToPrettyXml(bytes.Skip(4));
+ var clip = SharpFM.Model.Clip.FromWireBytes("preview", first, bytes);
_formatLabel.Text = first;
- _editor.Text = xml;
+ _editor.Text = clip.Xml;
_warningLabel.Text = fmFormats.Length > 1
? $"Multiple FileMaker formats present ({string.Join(", ", fmFormats)}); only the first was rendered."
: "";
diff --git a/src/SharpFM/Editors/ClipEditorViewFactory.cs b/src/SharpFM/Editors/ClipEditorViewFactory.cs
index 12d6b86..95452c7 100644
--- a/src/SharpFM/Editors/ClipEditorViewFactory.cs
+++ b/src/SharpFM/Editors/ClipEditorViewFactory.cs
@@ -5,6 +5,8 @@
using Avalonia.Media;
using AvaloniaEdit;
using AvaloniaEdit.Highlighting;
+using SharpFM.Model;
+using SharpFM.Model.Parsing;
using SharpFM.Schema.Editor;
namespace SharpFM.Editors;
@@ -64,6 +66,24 @@ private static FontFamily ResolveMonospaceFont()
return new FontFamily("Monospace");
}
+ ///
+ /// Build the matching 's
+ /// parsed model. The editor receives an already-parsed domain object — no
+ /// XML parsing happens inside editors any more.
+ ///
+ public static IClipEditor CreateEditor(Clip clip)
+ {
+ var model = (clip.Parsed as ParseSuccess)?.Model;
+ return model switch
+ {
+ ScriptClipModel scriptModel => new ScriptClipEditor(scriptModel.Script),
+ TableClipModel tableModel => new TableClipEditor(tableModel.Table),
+ LayoutClipModel layoutModel => new FallbackXmlEditor(layoutModel.Xml),
+ OpaqueClipModel opaqueModel => new FallbackXmlEditor(opaqueModel.Xml),
+ _ => new FallbackXmlEditor(clip.Xml),
+ };
+ }
+
public static Control Create(IClipEditor editor) => editor switch
{
ScriptClipEditor s => new ScriptTextEditor
diff --git a/src/SharpFM/Editors/FallbackXmlEditor.cs b/src/SharpFM/Editors/FallbackXmlEditor.cs
index e3d3c82..3423131 100644
--- a/src/SharpFM/Editors/FallbackXmlEditor.cs
+++ b/src/SharpFM/Editors/FallbackXmlEditor.cs
@@ -1,5 +1,6 @@
using System;
using AvaloniaEdit.Document;
+using SharpFM.Model.Parsing;
namespace SharpFM.Editors;
@@ -28,8 +29,5 @@ public FallbackXmlEditor(string? xml)
public string ToXml() => Document.Text;
- public void FromXml(string xml)
- {
- Document.Text = xml;
- }
+ public ClipModel GetModel() => new OpaqueClipModel(Document.Text);
}
diff --git a/src/SharpFM/Editors/IClipEditor.cs b/src/SharpFM/Editors/IClipEditor.cs
index e968c6b..458e716 100644
--- a/src/SharpFM/Editors/IClipEditor.cs
+++ b/src/SharpFM/Editors/IClipEditor.cs
@@ -1,4 +1,5 @@
using System;
+using SharpFM.Model.Parsing;
namespace SharpFM.Editors;
@@ -21,10 +22,13 @@ public interface IClipEditor
string ToXml();
///
- /// Load XML into the editor (reverse sync from an external source like a plugin).
- /// Implementations should diff/patch when possible to preserve UI state.
+ /// Snapshot the editor's live domain model. The editor owns this state
+ /// (it produced it from display-text edits), so callers can trust the
+ /// returned model reflects the same content would
+ /// emit — no re-parse required. Returned model is "as good as" the
+ /// editor knows; structural fidelity is the editor's responsibility.
///
- void FromXml(string xml);
+ ClipModel GetModel();
///
/// True if the last produced output from an incomplete or errored parse.
diff --git a/src/SharpFM/Editors/ScriptClipEditor.cs b/src/SharpFM/Editors/ScriptClipEditor.cs
index b4836d6..eb5f448 100644
--- a/src/SharpFM/Editors/ScriptClipEditor.cs
+++ b/src/SharpFM/Editors/ScriptClipEditor.cs
@@ -3,6 +3,7 @@
using System.Linq;
using System.Xml.Linq;
using AvaloniaEdit.Document;
+using SharpFM.Model.Parsing;
using SharpFM.Model.Scripting;
using SharpFM.Model.Scripting.Steps;
using SharpFM.Scripting;
@@ -59,9 +60,9 @@ public class ScriptClipEditor : IClipEditor
public bool IsPartial { get; private set; }
- public ScriptClipEditor(string? xml)
+ public ScriptClipEditor(FmScript script)
{
- _script = FmScript.FromXml(xml ?? "");
+ _script = script;
_metadata = _script.Metadata;
Document = new TextDocument(_script.ToDisplayText());
BuildSealedAnchors();
@@ -225,12 +226,22 @@ public string ToXml()
return _script.ToXml();
}
- public void FromXml(string xml)
+ public ClipModel GetModel()
{
- _script = FmScript.FromXml(xml);
- _metadata = _script.Metadata;
- Document.Text = _script.ToDisplayText();
- BuildSealedAnchors();
+ // RebuildFromDocument runs from ToXml; calling it here too keeps
+ // GetModel callable independently (e.g. in tests) without requiring
+ // a prior ToXml.
+ try
+ {
+ RebuildFromDocument();
+ IsPartial = false;
+ }
+ catch
+ {
+ IsPartial = true;
+ }
+ _script.Metadata = _metadata;
+ return new ScriptClipModel(_script);
}
///
diff --git a/src/SharpFM/Editors/TableClipEditor.cs b/src/SharpFM/Editors/TableClipEditor.cs
index db27b85..6d7a0d2 100644
--- a/src/SharpFM/Editors/TableClipEditor.cs
+++ b/src/SharpFM/Editors/TableClipEditor.cs
@@ -2,6 +2,7 @@
using System.Collections.Specialized;
using System.ComponentModel;
using SharpFM.Schema.Editor;
+using SharpFM.Model.Parsing;
using SharpFM.Model.Schema;
namespace SharpFM.Editors;
@@ -21,9 +22,8 @@ public class TableClipEditor : IClipEditor
public bool IsPartial => false;
- public TableClipEditor(string? xml)
+ public TableClipEditor(FmTable table)
{
- var table = FmTable.FromXml(xml ?? "");
ViewModel = new TableEditorViewModel(table);
_debouncer = new DebouncedEventRaiser(500, () => ContentChanged?.Invoke(this, EventArgs.Empty));
@@ -36,11 +36,7 @@ public string ToXml()
return ViewModel.Table.ToXml();
}
- public void FromXml(string xml)
- {
- // Not used for external updates — ReplaceEditor creates a new TableClipEditor.
- // Kept for IClipEditor interface compliance.
- }
+ public ClipModel GetModel() => new TableClipModel(ViewModel.Table);
private void SubscribeToViewModel(TableEditorViewModel vm)
{
diff --git a/src/SharpFM/MainWindow.axaml b/src/SharpFM/MainWindow.axaml
index 33c4a91..fc6e742 100644
--- a/src/SharpFM/MainWindow.axaml
+++ b/src/SharpFM/MainWindow.axaml
@@ -69,6 +69,21 @@
HorizontalAlignment="Left"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Text="{Binding StatusMessage}" />
+
+
+
+
+
diff --git a/src/SharpFM/Services/PluginHost.cs b/src/SharpFM/Services/PluginHost.cs
index b7329b7..3c86a25 100644
--- a/src/SharpFM/Services/PluginHost.cs
+++ b/src/SharpFM/Services/PluginHost.cs
@@ -5,6 +5,7 @@
using Avalonia.Threading;
using Microsoft.Extensions.Logging;
using SharpFM.Model;
+using SharpFM.Model.ClipTypes;
using SharpFM.Model.Schema;
using SharpFM.Model.Scripting;
using SharpFM.Plugin;
@@ -59,7 +60,7 @@ public ClipData? SelectedClip
{
var clip = _viewModel.SelectedClip;
if (clip is null) return null;
- return new ClipData(clip.Clip.Name, clip.ClipType, clip.Clip.XmlData);
+ return new ClipData(clip.Clip.Name, clip.ClipType, clip.Clip.Xml);
}
}
@@ -69,7 +70,7 @@ public ClipData? SelectedClip
public IReadOnlyList AllClips =>
_viewModel.FileMakerClips
- .Select(c => new ClipData(c.Clip.Name, c.ClipType, c.Clip.XmlData))
+ .Select(c => new ClipData(c.Clip.Name, c.ClipType, c.Clip.Xml))
.ToList();
public ILogger CreateLogger(string categoryName) => _loggerFactory.CreateLogger(categoryName);
@@ -83,9 +84,9 @@ public void UpdateSelectedClipXml(string xml, string originPluginId) =>
var clip = _viewModel.SelectedClip;
if (clip is null) return;
- clip.ReplaceEditor(xml);
+ clip.Replace(xml);
- var info = new ClipData(clip.Clip.Name, clip.ClipType, clip.Clip.XmlData);
+ var info = new ClipData(clip.Clip.Name, clip.ClipType, clip.Clip.Xml);
ClipContentChanged?.Invoke(this, new ClipContentChangedArgs(info, originPluginId, false));
});
@@ -93,8 +94,7 @@ public void UpdateSelectedClipXml(string xml, string originPluginId) =>
{
var clip = FindClipByName(clipName);
if (clip is null) return null;
- // Auto-sync keeps ClipXml current — just return it
- return new ClipData(clip.Clip.Name, clip.ClipType, clip.Clip.XmlData);
+ return new ClipData(clip.Clip.Name, clip.ClipType, clip.Clip.Xml);
}
public void UpdateClipXml(string clipName, string xml, string originPluginId) =>
@@ -103,40 +103,26 @@ public void UpdateClipXml(string clipName, string xml, string originPluginId) =>
var clip = FindClipByName(clipName);
if (clip is null) return;
- // Wholesale replacement — re-ingest the XML
- clip.ReplaceEditor(xml);
+ clip.Replace(xml);
- var info = new ClipData(clip.Clip.Name, clip.ClipType, clip.Clip.XmlData);
+ var info = new ClipData(clip.Clip.Name, clip.ClipType, clip.Clip.Xml);
ClipContentChanged?.Invoke(this, new ClipContentChangedArgs(info, originPluginId, false));
});
- private static readonly HashSet KnownClipTypes = new(StringComparer.Ordinal)
- {
- "Mac-XMSS", // script steps
- "Mac-XMSC", // script
- "Mac-XMTB", // table
- "Mac-XMFD", // field
- "Mac-XML2", // layout
- };
-
public void CreateClip(string name, string clipType, string? xml = null)
{
- if (!KnownClipTypes.Contains(clipType))
+ if (!ClipTypeRegistry.IsRegistered(clipType))
+ {
throw new ArgumentException(
- $"Unknown clip type '{clipType}'. Valid types: {string.Join(", ", KnownClipTypes)}.",
+ $"Unknown clip type '{clipType}'. Valid types: " +
+ $"{string.Join(", ", ClipTypeRegistry.All.Select(s => s.FormatId))}.",
nameof(clipType));
+ }
EnsureUiThread(() =>
{
- xml ??= clipType switch
- {
- "Mac-XMSS" or "Mac-XMSC" => "",
- "Mac-XMTB" => $"",
- _ => "",
- };
-
- var clip = new FileMakerClip(name, clipType, xml);
- var vm = new ClipViewModel(clip);
+ var seed = xml ?? ClipTypeRegistry.For(clipType).DefaultXml(name);
+ var vm = new ClipViewModel(Clip.FromXml(name, clipType, seed));
_viewModel.FileMakerClips.Add(vm);
});
}
@@ -203,7 +189,7 @@ private void OnEditorContentChanged(object? sender, EventArgs e)
var clip = _viewModel.SelectedClip;
if (clip is null) return;
- var info = new ClipData(clip.Clip.Name, clip.ClipType, clip.Clip.XmlData);
+ var info = new ClipData(clip.Clip.Name, clip.ClipType, clip.Clip.Xml);
var isPartial = clip.Editor.IsPartial;
ClipContentChanged?.Invoke(this, new ClipContentChangedArgs(info, "editor", isPartial));
}
diff --git a/src/SharpFM/ViewModels/ClipViewModel.cs b/src/SharpFM/ViewModels/ClipViewModel.cs
index d788f99..aed31e5 100644
--- a/src/SharpFM/ViewModels/ClipViewModel.cs
+++ b/src/SharpFM/ViewModels/ClipViewModel.cs
@@ -6,10 +6,17 @@
using AvaloniaEdit.Document;
using SharpFM.Editors;
using SharpFM.Model;
+using SharpFM.Model.ClipTypes;
+using SharpFM.Model.Parsing;
using SharpFM.Schema.Editor;
namespace SharpFM.ViewModels;
+///
+/// View-model wrapper around an immutable aggregate. The
+/// clip itself owns parsing and the round-trip report; this class adds the
+/// editor lifecycle, dirty tracking, and INPC for Avalonia bindings.
+///
public partial class ClipViewModel : INotifyPropertyChanged, IDisposable
{
public event PropertyChangedEventHandler? PropertyChanged;
@@ -19,12 +26,19 @@ private void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
- public FileMakerClip Clip { get; set; }
+ private Clip _clip;
+
+ public Clip Clip
+ {
+ get => _clip;
+ private set
+ {
+ if (ReferenceEquals(_clip, value)) return;
+ _clip = value;
+ NotifyPropertyChanged();
+ }
+ }
- ///
- /// The clip-type-specific editor. Handles change detection, XML serialization,
- /// and reverse sync for this clip's format.
- ///
public IClipEditor Editor { get; private set; }
///
@@ -44,10 +58,10 @@ private void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
///
private string _savedXml;
- public ClipViewModel(FileMakerClip clip)
+ public ClipViewModel(Clip clip)
{
- Clip = clip;
- Editor = CreateEditor(clip.XmlData);
+ _clip = clip;
+ Editor = ClipEditorViewFactory.CreateEditor(clip);
Editor.ContentChanged += OnEditorContentChanged;
_savedXml = Editor.ToXml();
}
@@ -66,60 +80,45 @@ public void Dispose()
_editorView = null;
}
- ///
- /// Wholesale replacement: discard the current editor and create a fresh one
- /// from the given XML. Used for all external updates (MCP, plugins, XML viewer).
- /// The new editor re-parses the XML into fresh domain model state.
- ///
- public void ReplaceEditor(string xml)
+ /// Re-parse the clip with new XML; used for external updates (MCP, plugins, XML viewer).
+ public void Replace(string xml)
{
Editor.ContentChanged -= OnEditorContentChanged;
- Clip.XmlData = xml;
- Editor = CreateEditor(xml);
+ Clip = _clip.WithXml(xml);
+ Editor = ClipEditorViewFactory.CreateEditor(_clip);
Editor.ContentChanged += OnEditorContentChanged;
- // The cached editor view was built around the old editor instance.
- // Tear it down so the next EditorView access rebuilds cleanly.
if (_editorView is IDisposable d) d.Dispose();
_editorView = null;
- // Reverse sync from plugins / external tools must not flag the clip
- // dirty — the source of truth is what the editor now holds.
+ // Reverse sync from external sources must not flag the clip dirty —
+ // the source of truth is what the editor now holds.
_savedXml = Editor.ToXml();
- NotifyPropertyChanged(nameof(IsDirty));
+ NotifyPropertyChanged(nameof(IsDirty));
NotifyPropertyChanged(nameof(Editor));
NotifyPropertyChanged(nameof(EditorView));
NotifyPropertyChanged(nameof(ScriptDocument));
NotifyPropertyChanged(nameof(TableEditor));
NotifyPropertyChanged(nameof(XmlDocument));
+ NotifyPropertyChanged(nameof(ParseReport));
+ NotifyPropertyChanged(nameof(IsLossless));
}
- ///
- /// Called by the host after a successful save; captures the current XML as
- /// the "clean" baseline so edits that follow light the dirty indicator.
- ///
+ public ClipParseReport ParseReport => _clip.Parsed.Report;
+
+ public bool IsLossless => ParseReport.IsLossless;
+
+ /// Captures the current XML as the saved baseline; clears the dirty indicator.
public void MarkSaved()
{
_savedXml = Editor.ToXml();
NotifyPropertyChanged(nameof(IsDirty));
}
- ///
- /// True when the editor's current XML differs from the last saved snapshot.
- /// Drives the dirty dot on open tabs. Computed on demand — cheap enough
- /// for a UI binding evaluated only when ContentChanged fires.
- ///
public bool IsDirty =>
!string.Equals(Editor.ToXml(), _savedXml, StringComparison.Ordinal);
- private IClipEditor CreateEditor(string? xml) => Clip.ClipboardFormat switch
- {
- "Mac-XMSS" or "Mac-XMSC" => new ScriptClipEditor(xml),
- "Mac-XMTB" or "Mac-XMFD" => new TableClipEditor(xml),
- _ => new FallbackXmlEditor(xml),
- };
-
private void OnEditorContentChanged(object? sender, EventArgs e) =>
HandleEditorContentChanged();
@@ -128,28 +127,29 @@ private void OnEditorContentChanged(object? sender, EventArgs e) =>
// Dispatcher. In production this is invoked from Editor.ContentChanged.
internal void HandleEditorContentChanged()
{
- Clip.XmlData = Editor.ToXml();
+ // Trusted-edit path: the editor produced this XML from a model it
+ // already holds, so the round-trip is lossless by construction. Hand
+ // both over to the aggregate to skip the strategy parse + diff —
+ // critical for large scripts where that work would freeze the UI
+ // every debounced keystroke.
+ var xml = Editor.ToXml();
+ var model = Editor.GetModel();
+ Clip = Clip.FromEditor(_clip.Name, _clip.FormatId, xml, model);
NotifyPropertyChanged(nameof(IsDirty));
+ NotifyPropertyChanged(nameof(ParseReport));
+ NotifyPropertyChanged(nameof(IsLossless));
EditorContentChanged?.Invoke(this, EventArgs.Empty);
}
public bool IsScriptClip =>
- Clip.ClipboardFormat == "Mac-XMSS" || Clip.ClipboardFormat == "Mac-XMSC";
+ _clip.Parsed is ParseSuccess { Model: ScriptClipModel };
public bool IsTableClip =>
- Clip.ClipboardFormat == "Mac-XMTB" || Clip.ClipboardFormat == "Mac-XMFD";
+ _clip.Parsed is ParseSuccess { Model: TableClipModel };
public bool IsFallbackClip => !IsScriptClip && !IsTableClip;
- public string ClipTypeDisplay => Clip.ClipboardFormat switch
- {
- "Mac-XMSS" => "Script Steps",
- "Mac-XMSC" => "Script",
- "Mac-XMTB" => "Table",
- "Mac-XMFD" => "Field",
- "Mac-XML2" => "Layout",
- _ => Clip.ClipboardFormat
- };
+ public string ClipTypeDisplay => ClipTypeRegistry.For(_clip.FormatId).DisplayName;
// --- Convenience properties for AXAML bindings ---
@@ -158,18 +158,7 @@ internal void HandleEditorContentChanged()
public TableEditorViewModel? TableEditor => (Editor as TableClipEditor)?.ViewModel;
public TextDocument XmlDocument =>
- (Editor as FallbackXmlEditor)?.Document ?? new TextDocument(Clip.XmlData ?? "");
+ (Editor as FallbackXmlEditor)?.Document ?? new TextDocument(_clip.Xml);
- public string ClipType
- {
- get => Clip.ClipboardFormat;
- set
- {
- Clip.ClipboardFormat = value;
- NotifyPropertyChanged();
- NotifyPropertyChanged(nameof(IsScriptClip));
- NotifyPropertyChanged(nameof(IsTableClip));
- NotifyPropertyChanged(nameof(IsFallbackClip));
- }
- }
+ public string ClipType => _clip.FormatId;
}
diff --git a/src/SharpFM/ViewModels/MainWindowViewModel.cs b/src/SharpFM/ViewModels/MainWindowViewModel.cs
index c4ad938..f3aab0c 100644
--- a/src/SharpFM/ViewModels/MainWindowViewModel.cs
+++ b/src/SharpFM/ViewModels/MainWindowViewModel.cs
@@ -13,6 +13,8 @@
using Microsoft.Extensions.Logging;
using SharpFM.Models;
using SharpFM.Model;
+using SharpFM.Model.ClipTypes;
+using SharpFM.Model.Parsing;
using SharpFM.Model.Scripting;
using SharpFM.Plugin;
using SharpFM.Services;
@@ -28,10 +30,44 @@ public partial class MainWindowViewModel : INotifyPropertyChanged
private IClipRepository _repository;
private OpenTabViewModel? _trackedActiveTab;
+ private ClipViewModel? _trackedSelectedClip;
+
private void OnActiveTabPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(OpenTabViewModel.Clip))
+ {
NotifyPropertyChanged(nameof(SelectedClip));
+ ResubscribeSelectedClipParseReport();
+ }
+ }
+
+ private void ResubscribeSelectedClipParseReport()
+ {
+ if (_trackedSelectedClip is not null)
+ {
+ _trackedSelectedClip.PropertyChanged -= OnSelectedClipPropertyChanged;
+ }
+
+ _trackedSelectedClip = SelectedClip;
+ if (_trackedSelectedClip is not null)
+ {
+ _trackedSelectedClip.PropertyChanged += OnSelectedClipPropertyChanged;
+ }
+
+ NotifyPropertyChanged(nameof(ParseFidelityVisible));
+ NotifyPropertyChanged(nameof(ParseFidelityIsLossless));
+ NotifyPropertyChanged(nameof(ParseFidelitySummary));
+ }
+
+ private void OnSelectedClipPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(ClipViewModel.ParseReport)
+ || e.PropertyName == nameof(ClipViewModel.IsLossless)
+ || e.PropertyName == nameof(ClipViewModel.Clip))
+ {
+ NotifyPropertyChanged(nameof(ParseFidelityIsLossless));
+ NotifyPropertyChanged(nameof(ParseFidelitySummary));
+ }
}
public event PropertyChangedEventHandler? PropertyChanged;
@@ -78,6 +114,7 @@ public MainWindowViewModel(
_trackedActiveTab.PropertyChanged += OnActiveTabPropertyChanged;
NotifyPropertyChanged(nameof(SelectedClip));
+ ResubscribeSelectedClipParseReport();
};
RootNodes = [];
@@ -135,7 +172,7 @@ private void PopulateClips(IReadOnlyList clips)
foreach (var clip in clips)
{
FileMakerClips.Add(new ClipViewModel(
- new FileMakerClip(clip.Name, clip.ClipType, clip.Xml))
+ Clip.FromXml(clip.Name, clip.ClipType, clip.Xml))
{
FolderPath = clip.FolderPath,
});
@@ -170,12 +207,11 @@ public async Task SaveClipsStorageAsync()
{
try
{
- // Ensure XML is up-to-date from editor state before saving
foreach (var clip in FileMakerClips)
- clip.Clip.XmlData = clip.Editor.ToXml();
+ clip.HandleEditorContentChanged();
var clipData = FileMakerClips
- .Select(c => new ClipData(c.Clip.Name, c.ClipType, c.Clip.XmlData)
+ .Select(c => new ClipData(c.Clip.Name, c.ClipType, c.Clip.Xml)
{
FolderPath = c.FolderPath,
})
@@ -183,8 +219,6 @@ public async Task SaveClipsStorageAsync()
await _repository.SaveClipsAsync(clipData);
- // Clip is now known-persisted — rebase the dirty snapshot so the
- // tab dirty dots clear.
foreach (var c in FileMakerClips) c.MarkSaved();
ShowStatus($"Saved {clipData.Count} clip(s) to {_repository.CurrentLocation}");
@@ -210,11 +244,6 @@ public void ExitApplication()
}
}
- private static readonly string EmptyScriptXml =
- "";
-
- private static readonly string EmptyTableXml =
- "";
public void DeleteSelectedClip()
{
@@ -231,18 +260,16 @@ public void DeleteSelectedClip()
ShowStatus($"Deleted clip '{name}'");
}
- public void NewScriptCommand() =>
- CreateNewClip("New Script", "Mac-XMSS", EmptyScriptXml, "script");
+ public void NewScriptCommand() => CreateNewClip("New Script", "Mac-XMSS", "script");
- public void NewTableCommand() =>
- CreateNewClip("New Table", "Mac-XMTB", EmptyTableXml, "table");
+ public void NewTableCommand() => CreateNewClip("New Table", "Mac-XMTB", "table");
- private void CreateNewClip(string name, string format, string xml, string kind)
+ private void CreateNewClip(string name, string format, string kind)
{
try
{
- var clip = new FileMakerClip(name, format, xml);
- var vm = new ClipViewModel(clip);
+ var seed = ClipTypeRegistry.For(format).DefaultXml(name);
+ var vm = new ClipViewModel(Clip.FromXml(name, format, seed));
FileMakerClips.Add(vm);
SelectedClip = vm;
ShowStatus($"Created new {kind}");
@@ -268,6 +295,11 @@ public async Task CopyAsClass()
await _clipboard.SetTextAsync(classString);
ShowStatus("Copied C# class to clipboard");
}
+ catch (NotSupportedException e)
+ {
+ _logger.LogInformation(e, "Copy as class is only available for table clips.");
+ ShowStatus(e.Message, isError: true);
+ }
catch (Exception e)
{
_logger.LogError(e, "Error copying as class.");
@@ -291,10 +323,10 @@ public async Task PasteFileMakerClipData()
if (clipData is not byte[] dataObj) continue;
- var clip = new FileMakerClip("new-clip", format, dataObj);
+ var clip = Clip.FromWireBytes("new-clip", format, dataObj);
// don't add duplicates
- if (FileMakerClips.Any(k => k.Clip.XmlData == clip.XmlData)) continue;
+ if (FileMakerClips.Any(k => k.Clip.Xml == clip.Xml)) continue;
lastAdded = new ClipViewModel(clip);
FileMakerClips.Add(lastAdded);
@@ -325,9 +357,8 @@ public async Task CopySelectedToClip()
try
{
- // Ensure XML is up-to-date from editor state before copying
- data.Clip.XmlData = data.Editor.ToXml();
- await _clipboard.SetDataAsync(data.ClipType, data.Clip.RawData);
+ data.HandleEditorContentChanged();
+ await _clipboard.SetDataAsync(data.ClipType, data.Clip.WireBytes);
ShowStatus("Copied to FileMaker clipboard");
}
catch (Exception e)
@@ -361,13 +392,13 @@ public async Task CopyAsScript()
try
{
// Editor state → FmScript → force Metadata present → emit as XMSC
- data.Clip.XmlData = data.Editor.ToXml();
- var script = FmScript.FromXml(data.Clip.XmlData);
+ data.HandleEditorContentChanged();
+ var script = FmScript.FromXml(data.Clip.Xml);
script.Metadata ??= ScriptMetadata.Default(data.Clip.Name);
var xmlWithWrapper = script.ToXml();
- var clip = new FileMakerClip(data.Clip.Name, "Mac-XMSC", xmlWithWrapper);
- await _clipboard.SetDataAsync("Mac-XMSC", clip.RawData);
+ var clip = Clip.FromXml(data.Clip.Name, "Mac-XMSC", xmlWithWrapper);
+ await _clipboard.SetDataAsync("Mac-XMSC", clip.WireBytes);
ShowStatus("Copied as Script to FileMaker clipboard");
}
catch (Exception e)
@@ -398,13 +429,13 @@ public async Task CopyAsScriptSteps()
try
{
- data.Clip.XmlData = data.Editor.ToXml();
- var script = FmScript.FromXml(data.Clip.XmlData);
+ data.HandleEditorContentChanged();
+ var script = FmScript.FromXml(data.Clip.Xml);
script.Metadata = null;
var xmlNoWrapper = script.ToXml();
- var clip = new FileMakerClip(data.Clip.Name, "Mac-XMSS", xmlNoWrapper);
- await _clipboard.SetDataAsync("Mac-XMSS", clip.RawData);
+ var clip = Clip.FromXml(data.Clip.Name, "Mac-XMSS", xmlNoWrapper);
+ await _clipboard.SetDataAsync("Mac-XMSS", clip.WireBytes);
ShowStatus("Copied as Script Steps to FileMaker clipboard");
}
catch (Exception e)
@@ -472,6 +503,32 @@ public ClipViewModel? SelectedClip
}
}
+ /// True when the status bar should display a parse-fidelity summary for the selected clip.
+ public bool ParseFidelityVisible => SelectedClip is not null;
+
+ /// True when the selected clip parsed losslessly. Drives the warning glyph in the status bar.
+ public bool ParseFidelityIsLossless => SelectedClip?.IsLossless ?? true;
+
+ ///
+ /// Human-readable summary of the selected clip's parse report, e.g.
+ /// "Parsed losslessly" or "Parsed with 3 issues: 2 unknown step elements, 1 unknown attribute".
+ ///
+ public string ParseFidelitySummary
+ {
+ get
+ {
+ var report = SelectedClip?.ParseReport;
+ if (report is null) return string.Empty;
+ if (report.IsLossless) return "Parsed losslessly";
+
+ var byKind = report.Diagnostics
+ .GroupBy(d => d.Kind)
+ .Select(g => $"{g.Count()} {g.Key.ToHumanLabel(g.Count())}");
+
+ return $"Parsed with {report.Diagnostics.Count} issue(s): {string.Join(", ", byKind)}";
+ }
+ }
+
///
/// Open a clip as a preview tab (single-click in the tree).
///
diff --git a/tests/SharpFM.Plugin.Tests/PluginHostTests.cs b/tests/SharpFM.Plugin.Tests/PluginHostTests.cs
index 973e71a..6d42d03 100644
--- a/tests/SharpFM.Plugin.Tests/PluginHostTests.cs
+++ b/tests/SharpFM.Plugin.Tests/PluginHostTests.cs
@@ -89,7 +89,9 @@ public void UpdateSelectedClipXml_UpdatesClipContent()
var newXml = "";
host.UpdateSelectedClipXml(newXml, "test-plugin");
- Assert.Equal(newXml, vm.SelectedClip!.Clip.XmlData);
+ // Clip.Xml is canonicalised via PrettyPrint; assert structural equivalence.
+ Assert.Contains("");
+
+ Assert.Equal("Untitled", clip.Name);
+ Assert.Equal("Mac-XMUNKNOWN", clip.FormatId);
+ Assert.IsType(clip.Parsed);
+ }
+
+ [Fact]
+ public void FromXml_PrettyPrintsCanonicalForm()
+ {
+ var clip = Clip.FromXml("X", "Mac-XMUNKNOWN", "");
+
+ Assert.Contains("\n", clip.Xml);
+ Assert.Contains("");
+
+ Assert.IsType(clip.Parsed);
+ Assert.False(clip.Parsed.Report.IsLossless);
+ }
+
+ [Fact]
+ public void FromXml_NullXml_TreatedAsEmpty()
+ {
+ var clip = Clip.FromXml("X", "Mac-XMUNKNOWN", null!);
+
+ Assert.IsType(clip.Parsed);
+ }
+
+ [Fact]
+ public void FromWireBytes_StripsLengthPrefix()
+ {
+ const string xml = "";
+ var payload = Encoding.UTF8.GetBytes(xml);
+ var prefix = BitConverter.GetBytes(payload.Length);
+ var bytes = prefix.Concat(payload).ToArray();
+
+ var clip = Clip.FromWireBytes("X", "Mac-XMUNKNOWN", bytes);
+
+ Assert.IsType(clip.Parsed);
+ }
+
+ [Fact]
+ public void FromWireBytes_TooShortInput_TreatedAsEmpty()
+ {
+ var clip = Clip.FromWireBytes("X", "Mac-XMUNKNOWN", [0x00, 0x01]);
+ Assert.IsType(clip.Parsed);
+ }
+
+ [Fact]
+ public void WireBytes_RoundTripsThroughLengthPrefix()
+ {
+ var clip = Clip.FromXml("X", "Mac-XMUNKNOWN", "");
+ var bytes = clip.WireBytes;
+
+ var prefix = BitConverter.ToInt32(bytes, 0);
+ Assert.Equal(bytes.Length - 4, prefix);
+ var payload = Encoding.UTF8.GetString(bytes, 4, bytes.Length - 4);
+ Assert.Equal(clip.Xml, payload);
+ }
+
+ [Fact]
+ public void WireBytes_IsCachedAcrossReads()
+ {
+ var clip = Clip.FromXml("X", "Mac-XMUNKNOWN", "");
+ Assert.Same(clip.WireBytes, clip.WireBytes);
+ }
+
+ [Fact]
+ public void WithXml_ProducesNewInstanceWithReparse()
+ {
+ var original = Clip.FromXml("X", "Mac-XMUNKNOWN", "");
+ var updated = original.WithXml("");
+
+ Assert.NotSame(original, updated);
+ Assert.Contains("other", updated.Xml);
+ Assert.Equal(original.Name, updated.Name);
+ Assert.Equal(original.FormatId, updated.FormatId);
+ }
+
+ [Fact]
+ public void Rename_ReusesParsedResult()
+ {
+ var original = Clip.FromXml("X", "Mac-XMUNKNOWN", "");
+ var renamed = original.Rename("Y");
+
+ Assert.Same(original.Parsed, renamed.Parsed);
+ Assert.Equal("Y", renamed.Name);
+ Assert.Equal(original.Xml, renamed.Xml);
+ }
+}
diff --git a/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryTests.cs b/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryTests.cs
new file mode 100644
index 0000000..98e0fe1
--- /dev/null
+++ b/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryTests.cs
@@ -0,0 +1,18 @@
+using SharpFM.Model.ClipTypes;
+
+namespace SharpFM.Tests.ClipTypes;
+
+public class ClipTypeRegistryTests
+{
+ [Fact]
+ public void For_UnknownFormat_FallsBackToOpaque()
+ {
+ Assert.Same(OpaqueClipStrategy.Instance, ClipTypeRegistry.For("Mac-XMNOPE"));
+ }
+
+ [Fact]
+ public void For_KnownFormat_ReturnsBuiltInStrategy()
+ {
+ Assert.Same(ScriptClipStrategy.Steps, ClipTypeRegistry.For("Mac-XMSS"));
+ }
+}
diff --git a/tests/SharpFM.Tests/ClipTypes/LayoutClipStrategyTests.cs b/tests/SharpFM.Tests/ClipTypes/LayoutClipStrategyTests.cs
new file mode 100644
index 0000000..b3e1de5
--- /dev/null
+++ b/tests/SharpFM.Tests/ClipTypes/LayoutClipStrategyTests.cs
@@ -0,0 +1,51 @@
+using SharpFM.Model.ClipTypes;
+using SharpFM.Model.Parsing;
+
+namespace SharpFM.Tests.ClipTypes;
+
+public class LayoutClipStrategyTests
+{
+ [Fact]
+ public void Identity_IsLayout()
+ {
+ Assert.Equal("Mac-XML2", LayoutClipStrategy.Instance.FormatId);
+ Assert.Equal("Layout", LayoutClipStrategy.Instance.DisplayName);
+ }
+
+ [Fact]
+ public void Parse_ValidLayoutSnippet_ReturnsSuccess()
+ {
+ var result = LayoutClipStrategy.Instance.Parse(
+ "");
+
+ var success = Assert.IsType(result);
+ Assert.IsType(success.Model);
+ Assert.True(success.Report.IsLossless);
+ }
+
+ [Fact]
+ public void Parse_PreservesLayoutXmlVerbatim()
+ {
+ const string xml = "";
+ var result = LayoutClipStrategy.Instance.Parse(xml);
+
+ var success = Assert.IsType(result);
+ var model = Assert.IsType(success.Model);
+ Assert.Equal(xml, model.Xml);
+ }
+
+ [Fact]
+ public void Parse_MalformedXml_ReturnsFailure()
+ {
+ var result = LayoutClipStrategy.Instance.Parse("(result);
+ }
+
+ [Fact]
+ public void Parse_WrongRoot_ReturnsUnsupportedClipType()
+ {
+ var result = LayoutClipStrategy.Instance.Parse("");
+ var failure = Assert.IsType(result);
+ Assert.Equal(ParseDiagnosticKind.UnsupportedClipType, failure.Report.Diagnostics[0].Kind);
+ }
+}
diff --git a/tests/SharpFM.Tests/ClipTypes/OpaqueClipStrategyTests.cs b/tests/SharpFM.Tests/ClipTypes/OpaqueClipStrategyTests.cs
new file mode 100644
index 0000000..43a8c35
--- /dev/null
+++ b/tests/SharpFM.Tests/ClipTypes/OpaqueClipStrategyTests.cs
@@ -0,0 +1,56 @@
+using SharpFM.Model.ClipTypes;
+using SharpFM.Model.Parsing;
+
+namespace SharpFM.Tests.ClipTypes;
+
+public class OpaqueClipStrategyTests
+{
+ [Fact]
+ public void Parse_WellFormedXml_ReturnsSuccessWithLosslessReport()
+ {
+ var result = OpaqueClipStrategy.Instance.Parse("");
+
+ var success = Assert.IsType(result);
+ Assert.IsType(success.Model);
+ Assert.True(success.Report.IsLossless);
+ }
+
+ [Fact]
+ public void Parse_PreservesXmlVerbatim()
+ {
+ const string xml = "text";
+ var result = OpaqueClipStrategy.Instance.Parse(xml);
+
+ var success = Assert.IsType(result);
+ var model = Assert.IsType(success.Model);
+ Assert.Equal(xml, model.Xml);
+ }
+
+ [Fact]
+ public void Parse_EmptyInput_ReturnsFailure()
+ {
+ var result = OpaqueClipStrategy.Instance.Parse("");
+
+ var failure = Assert.IsType(result);
+ Assert.Single(failure.Report.Diagnostics);
+ Assert.Equal(ParseDiagnosticKind.XmlMalformed, failure.Report.Diagnostics[0].Kind);
+ }
+
+ [Fact]
+ public void Parse_MalformedXml_ReturnsFailureWithDiagnostic()
+ {
+ var result = OpaqueClipStrategy.Instance.Parse("");
+
+ var failure = Assert.IsType(result);
+ Assert.Equal(ParseDiagnosticKind.XmlMalformed, failure.Report.Diagnostics[0].Kind);
+ Assert.Equal(ParseDiagnosticSeverity.Error, failure.Report.Diagnostics[0].Severity);
+ }
+
+ [Fact]
+ public void DefaultXml_ProducesParseableSnippet()
+ {
+ var seed = OpaqueClipStrategy.Instance.DefaultXml("anything");
+ var result = OpaqueClipStrategy.Instance.Parse(seed);
+ Assert.IsType(result);
+ }
+}
diff --git a/tests/SharpFM.Tests/ClipTypes/ScriptClipStrategyTests.cs b/tests/SharpFM.Tests/ClipTypes/ScriptClipStrategyTests.cs
new file mode 100644
index 0000000..b111bf6
--- /dev/null
+++ b/tests/SharpFM.Tests/ClipTypes/ScriptClipStrategyTests.cs
@@ -0,0 +1,119 @@
+using SharpFM.Model.ClipTypes;
+using SharpFM.Model.Parsing;
+
+namespace SharpFM.Tests.ClipTypes;
+
+public class ScriptClipStrategyTests
+{
+ [Fact]
+ public void StepsAndScript_HaveDistinctIdentities()
+ {
+ Assert.Equal("Mac-XMSS", ScriptClipStrategy.Steps.FormatId);
+ Assert.Equal("Mac-XMSC", ScriptClipStrategy.Script.FormatId);
+ Assert.Equal("Script Steps", ScriptClipStrategy.Steps.DisplayName);
+ Assert.Equal("Script", ScriptClipStrategy.Script.DisplayName);
+ }
+
+ [Fact]
+ public void Parse_EmptyScriptSnippet_ReturnsLosslessSuccess()
+ {
+ var result = ScriptClipStrategy.Steps.Parse(
+ "");
+
+ var success = Assert.IsType(result);
+ Assert.IsType(success.Model);
+ Assert.True(success.Report.IsLossless);
+ }
+
+ [Fact]
+ public void Parse_KnownStep_RoundTripsLosslessly()
+ {
+ const string xml = """
+
+
+
+
+ $x
+
+
+ """;
+
+ var result = ScriptClipStrategy.Steps.Parse(xml);
+
+ var success = Assert.IsType(result);
+ Assert.True(success.Report.IsLossless,
+ $"expected lossless parse, got: {string.Join("; ", success.Report.Diagnostics.Select(d => d.Message))}");
+ }
+
+ [Fact]
+ public void Parse_UnknownStep_ReportsAsInfoButPreserves()
+ {
+ const string xml = """
+
+
+
+
+
+ """;
+
+ var result = ScriptClipStrategy.Steps.Parse(xml);
+
+ var success = Assert.IsType(result);
+ var unknownStep = success.Report.Diagnostics
+ .Single(d => d.Kind == ParseDiagnosticKind.UnknownStep);
+ Assert.Equal(ParseDiagnosticSeverity.Info, unknownStep.Severity);
+ Assert.Contains("Future Step From FM 25", unknownStep.Message);
+ }
+
+ [Fact]
+ public void Parse_MalformedXml_ReturnsFailure()
+ {
+ var result = ScriptClipStrategy.Steps.Parse("(result);
+ Assert.Equal(ParseDiagnosticKind.XmlMalformed, failure.Report.Diagnostics[0].Kind);
+ }
+
+ [Fact]
+ public void Parse_WrongRootElement_ReturnsFailureWithUnsupportedKind()
+ {
+ var result = ScriptClipStrategy.Steps.Parse("");
+
+ var failure = Assert.IsType(result);
+ Assert.Equal(ParseDiagnosticKind.UnsupportedClipType, failure.Report.Diagnostics[0].Kind);
+ }
+
+ [Fact]
+ public void Parse_EmptyXml_ReturnsFailure()
+ {
+ var result = ScriptClipStrategy.Steps.Parse("");
+ Assert.IsType(result);
+ }
+
+ [Fact]
+ public void Parse_ScriptWrapperFormat_ReadsMetadata()
+ {
+ const string xml = """
+
+
+
+ """;
+
+ var result = ScriptClipStrategy.Script.Parse(xml);
+
+ var success = Assert.IsType(result);
+ var model = Assert.IsType(success.Model);
+ Assert.NotNull(model.Script.Metadata);
+ Assert.Equal("My Script", model.Script.Metadata!.Name);
+ }
+
+ [Fact]
+ public void DefaultXml_ProducesParseableSnippet()
+ {
+ var seed = ScriptClipStrategy.Steps.DefaultXml("anything");
+ var result = ScriptClipStrategy.Steps.Parse(seed);
+ Assert.IsType(result);
+ }
+}
diff --git a/tests/SharpFM.Tests/ClipTypes/TableClipStrategyTests.cs b/tests/SharpFM.Tests/ClipTypes/TableClipStrategyTests.cs
new file mode 100644
index 0000000..292f5aa
--- /dev/null
+++ b/tests/SharpFM.Tests/ClipTypes/TableClipStrategyTests.cs
@@ -0,0 +1,72 @@
+using SharpFM.Model.ClipTypes;
+using SharpFM.Model.Parsing;
+
+namespace SharpFM.Tests.ClipTypes;
+
+public class TableClipStrategyTests
+{
+ [Fact]
+ public void TableAndField_HaveDistinctIdentities()
+ {
+ Assert.Equal("Mac-XMTB", TableClipStrategy.Table.FormatId);
+ Assert.Equal("Mac-XMFD", TableClipStrategy.Field.FormatId);
+ }
+
+ [Fact]
+ public void Parse_ValidTable_ReturnsSuccess()
+ {
+ const string xml = """
+
+
+
+
+
+
+ """;
+
+ var result = TableClipStrategy.Table.Parse(xml);
+
+ var success = Assert.IsType(result);
+ var model = Assert.IsType(success.Model);
+ Assert.Equal("People", model.Table.Name);
+ Assert.Equal(2, model.Table.Fields.Count);
+ }
+
+ [Fact]
+ public void Parse_MalformedXml_ReturnsFailure()
+ {
+ var result = TableClipStrategy.Table.Parse("(result);
+ }
+
+ [Fact]
+ public void Parse_WrongRoot_ReturnsUnsupportedClipType()
+ {
+ var result = TableClipStrategy.Table.Parse("");
+ var failure = Assert.IsType(result);
+ Assert.Equal(ParseDiagnosticKind.UnsupportedClipType, failure.Report.Diagnostics[0].Kind);
+ }
+
+ [Fact]
+ public void Table_DefaultXml_IncludesBaseTableWrapper()
+ {
+ var seed = TableClipStrategy.Table.DefaultXml("My Table");
+ Assert.Contains("(result);
+ }
+}
diff --git a/tests/SharpFM.Tests/Core/FileMakerClipExtensionsTests.cs b/tests/SharpFM.Tests/Core/FileMakerClipExtensionsTests.cs
index 64c6f73..53b66c0 100644
--- a/tests/SharpFM.Tests/Core/FileMakerClipExtensionsTests.cs
+++ b/tests/SharpFM.Tests/Core/FileMakerClipExtensionsTests.cs
@@ -5,15 +5,15 @@ namespace SharpFM.Tests.Core;
public class FileMakerClipExtensionsTests
{
- private static FileMakerClip MakeTableClip(string tableName, params (string name, string dataType)[] fields)
+ private static Clip MakeTableClip(string tableName, params (string name, string dataType)[] fields)
{
var fieldElements = string.Join("", fields.Select(f =>
$""
- + ""
+ + ""
+ ""));
var xml = $"{fieldElements}";
- return new FileMakerClip(tableName, "Mac-XMTB", xml);
+ return Clip.FromXml(tableName, "Mac-XMTB", xml);
}
[Fact]
@@ -72,15 +72,6 @@ public void CreateClass_BinaryField_GeneratesByteArrayProperty()
Assert.Contains("byte[] Photo { get; set; }", code);
}
- [Fact]
- public void CreateClass_UnknownDataType_DefaultsToString()
- {
- var clip = MakeTableClip("Misc", ("Custom", "SomeOtherType"));
- var code = clip.CreateClass();
-
- Assert.Contains("string Custom { get; set; }", code);
- }
-
[Fact]
public void CreateClass_IncludesDataContractAttribute()
{
@@ -114,22 +105,22 @@ public void CreateClass_WithFieldProjectionList_FiltersFields()
}
[Fact]
- public void CreateClass_NullClip_ReturnsEmpty()
+ public void CreateClass_NonTableClip_Throws()
{
- FileMakerClip? clip = null;
- var code = FileMakerClipExtensions.CreateClass(clip!, (FileMakerClip?)null);
- Assert.Equal(string.Empty, code);
+ var clip = Clip.FromXml("script", "Mac-XMSS",
+ "");
+
+ Assert.Throws(() => clip.CreateClass());
}
[Fact]
public void CreateClass_NullableNumberField_GetsQuestionMark()
{
- // NotEmpty=false on a non-string type should produce a nullable
var xml = ""
+ ""
- + ""
+ + ""
+ "";
- var clip = new FileMakerClip("T", "Mac-XMTB", xml);
+ var clip = Clip.FromXml("T", "Mac-XMTB", xml);
var code = clip.CreateClass();
Assert.Contains("int?", code);
diff --git a/tests/SharpFM.Tests/Core/FileMakerClipTests.cs b/tests/SharpFM.Tests/Core/FileMakerClipTests.cs
deleted file mode 100644
index bc0ea8b..0000000
--- a/tests/SharpFM.Tests/Core/FileMakerClipTests.cs
+++ /dev/null
@@ -1,172 +0,0 @@
-using System.Linq;
-using System.Text;
-using SharpFM.Model;
-using Xunit;
-
-namespace SharpFM.Tests.Core;
-
-public class FileMakerClipTests
-{
- private const string SimpleXml = "";
- private const string TableXml =
- ""
- + ""
- + ""
- + ""
- + "first name field"
- + ""
- + ""
- + ""
- + ""
- + "";
-
- private const string LayoutXml =
- ""
- + ""
- + ""
- + ""
- + ""
- + "";
-
- // --- String constructor ---
-
- [Fact]
- public void Constructor_String_SetsNameAndFormat()
- {
- var clip = new FileMakerClip("MyClip", "Mac-XMSS", SimpleXml);
- Assert.Equal("MyClip", clip.Name);
- Assert.Equal("Mac-XMSS", clip.ClipboardFormat);
- }
-
- [Fact]
- public void Constructor_String_PrettifiesXml()
- {
- var clip = new FileMakerClip("test", "Mac-XMSS", SimpleXml);
- Assert.Contains("\n", clip.XmlData);
- }
-
- [Fact]
- public void Constructor_String_InvalidXml_StoresRawString()
- {
- var clip = new FileMakerClip("test", "Mac-XMSS", "not xml at all");
- Assert.Equal("not xml at all", clip.XmlData);
- }
-
- // --- Byte array constructor ---
-
- [Fact]
- public void Constructor_ByteArray_ExtractsNameFromXml()
- {
- var xmlBytes = Encoding.UTF8.GetBytes(SimpleXml);
- var lengthPrefix = System.BitConverter.GetBytes(xmlBytes.Length);
- var data = lengthPrefix.Concat(xmlBytes).ToArray();
-
- var clip = new FileMakerClip("fallback", "Mac-XMSS", data);
- Assert.Contains("fmxmlsnippet", clip.XmlData);
- }
-
- [Fact]
- public void Constructor_ByteArray_EmptyPayload_SetsEmptyXml()
- {
- // 4 bytes length prefix + no actual data
- var data = System.BitConverter.GetBytes(0).Concat(Encoding.UTF8.GetBytes("")).ToArray();
- var clip = new FileMakerClip("fallback", "Mac-XMSS", data);
- Assert.Equal("fallback", clip.Name);
- }
-
- // --- RawData round-trip ---
-
- [Fact]
- public void RawData_ContainsLengthPrefixAndXml()
- {
- var clip = new FileMakerClip("test", "Mac-XMSS", SimpleXml);
- var rawData = clip.RawData;
-
- var length = System.BitConverter.ToInt32(rawData, 0);
- var xmlPart = Encoding.UTF8.GetString(rawData, 4, length);
-
- Assert.Equal(clip.XmlData, xmlPart);
- }
-
- [Fact]
- public void RawData_InvalidatedWhenXmlChanges()
- {
- var clip = new FileMakerClip("test", "Mac-XMSS", SimpleXml);
- var first = clip.RawData;
-
- clip.XmlData = "";
- var second = clip.RawData;
-
- Assert.NotEqual(first, second);
- }
-
- // --- ClipTypes ---
-
- [Fact]
- public void ClipTypes_ContainsExpectedFormats()
- {
- Assert.Contains(FileMakerClip.ClipTypes, ct => ct.KeyId == "Mac-XMSS" && ct.DisplayName == "ScriptSteps");
- Assert.Contains(FileMakerClip.ClipTypes, ct => ct.KeyId == "Mac-XMTB" && ct.DisplayName == "Table");
- Assert.Contains(FileMakerClip.ClipTypes, ct => ct.KeyId == "Mac-XML2" && ct.DisplayName == "Layout");
- }
-
- // --- Fields property ---
-
- [Fact]
- public void Fields_TableClip_ReturnsFieldsWithMetadata()
- {
- var clip = new FileMakerClip("People", "Mac-XMTB", TableXml);
- var fields = clip.Fields.ToList();
-
- Assert.Equal(2, fields.Count);
- Assert.Equal("FirstName", fields[0].Name);
- Assert.Equal("Text", fields[0].DataType);
- Assert.True(fields[0].NotEmpty);
- Assert.Equal("first name field", fields[0].Comment);
- Assert.Equal("Age", fields[1].Name);
- Assert.Equal("Number", fields[1].DataType);
- }
-
- [Fact]
- public void Fields_LayoutClip_ReturnsFieldNames()
- {
- var clip = new FileMakerClip("Detail", "Mac-XML2", LayoutXml);
- var fields = clip.Fields.ToList();
-
- Assert.Equal(2, fields.Count);
- Assert.Equal("FirstName", fields[0].Name);
- Assert.Equal("LastName", fields[1].Name);
- }
-
- [Fact]
- public void Fields_ScriptStepsClip_ReturnsEmpty()
- {
- var clip = new FileMakerClip("test", "Mac-XMSS", SimpleXml);
- Assert.Empty(clip.Fields);
- }
-
- [Fact]
- public void Fields_UnknownFormat_ReturnsEmpty()
- {
- var clip = new FileMakerClip("test", "Unknown-Format", SimpleXml);
- Assert.Empty(clip.Fields);
- }
-
- // --- ClipBytesToPrettyXml ---
-
- [Fact]
- public void ClipBytesToPrettyXml_ValidXml_ReturnsPrettyVersion()
- {
- var bytes = Encoding.UTF8.GetBytes("");
- var result = FileMakerClip.ClipBytesToPrettyXml(bytes);
- Assert.Contains("\n", result);
- Assert.Contains("child", result);
- }
-
- [Fact]
- public void ClipBytesToPrettyXml_EmptyInput_ReturnsEmpty()
- {
- var result = FileMakerClip.ClipBytesToPrettyXml(System.Array.Empty());
- Assert.Equal(string.Empty, result);
- }
-}
diff --git a/tests/SharpFM.Tests/Editors/FallbackXmlEditorTests.cs b/tests/SharpFM.Tests/Editors/FallbackXmlEditorTests.cs
index 8815220..1715c68 100644
--- a/tests/SharpFM.Tests/Editors/FallbackXmlEditorTests.cs
+++ b/tests/SharpFM.Tests/Editors/FallbackXmlEditorTests.cs
@@ -23,15 +23,6 @@ public void ToXml_ReturnsDocumentText()
Assert.Equal("", editor.ToXml());
}
- [Fact]
- public void FromXml_SetsDocumentText()
- {
- var editor = new FallbackXmlEditor("");
- editor.FromXml("");
-
- Assert.Equal("", editor.Document.Text);
- }
-
[Fact]
public void IsPartial_AlwaysFalse()
{
@@ -47,14 +38,11 @@ public void Constructor_HandlesNullXml()
}
[Fact]
- public void ToXml_FromXml_RoundTrip()
+ public void ToXml_RoundTripsViaDocumentText()
{
var xml = "";
var editor = new FallbackXmlEditor(xml);
- var exported = editor.ToXml();
- editor.FromXml(exported);
-
- Assert.Equal(exported, editor.Document.Text);
+ Assert.Equal(xml, editor.ToXml());
}
}
diff --git a/tests/SharpFM.Tests/Editors/ScriptClipEditorTests.cs b/tests/SharpFM.Tests/Editors/ScriptClipEditorTests.cs
index f086ddb..58f6456 100644
--- a/tests/SharpFM.Tests/Editors/ScriptClipEditorTests.cs
+++ b/tests/SharpFM.Tests/Editors/ScriptClipEditorTests.cs
@@ -1,5 +1,6 @@
using System.Linq;
using SharpFM.Editors;
+using SharpFM.Model.Scripting;
using Xunit;
namespace SharpFM.Tests.Editors;
@@ -11,10 +12,13 @@ public class ScriptClipEditorTests
"" +
"Hello";
+ private static ScriptClipEditor MakeEditor(string? xml) =>
+ new(FmScript.FromXml(xml ?? ""));
+
[Fact]
public void Constructor_ParsesXmlToDisplayText()
{
- var editor = new ScriptClipEditor(SampleScriptXml);
+ var editor = MakeEditor(SampleScriptXml);
Assert.NotNull(editor.Document);
// Comment step renders as "# Hello" in display text
@@ -24,7 +28,7 @@ public void Constructor_ParsesXmlToDisplayText()
[Fact]
public void ToXml_RoundTrips()
{
- var editor = new ScriptClipEditor(SampleScriptXml);
+ var editor = MakeEditor(SampleScriptXml);
var xml = editor.ToXml();
Assert.Contains("fmxmlsnippet", xml);
@@ -32,25 +36,10 @@ public void ToXml_RoundTrips()
Assert.False(editor.IsPartial);
}
- [Fact]
- public void FromXml_UpdatesDocument()
- {
- var editor = new ScriptClipEditor(SampleScriptXml);
- var originalText = editor.Document.Text;
-
- var newXml =
- "" +
- "";
- editor.FromXml(newXml);
-
- Assert.NotEqual(originalText, editor.Document.Text);
- Assert.Contains("Beep", editor.Document.Text);
- }
-
[Fact]
public void ToXml_EmptyScript_IsNotPartial()
{
- var editor = new ScriptClipEditor("");
+ var editor = MakeEditor("");
var xml = editor.ToXml();
Assert.False(editor.IsPartial);
@@ -58,9 +47,9 @@ public void ToXml_EmptyScript_IsNotPartial()
}
[Fact]
- public void Constructor_HandlesNullXml()
+ public void Constructor_HandlesEmptyScript()
{
- var editor = new ScriptClipEditor(null);
+ var editor = new ScriptClipEditor(new FmScript([]));
Assert.NotNull(editor.Document);
Assert.NotNull(editor.ToXml());
@@ -77,7 +66,7 @@ public void UnknownStep_IsSealedForXmlEditorOnly()
"" +
"bar" +
"";
- var editor = new ScriptClipEditor(xml);
+ var editor = MakeEditor(xml);
Assert.Single(editor.SealedAnchors);
@@ -101,7 +90,7 @@ public void SealedLineNumbers_PointsAtSealedStepLine()
// The sealed FutureStep is the second display line; the
// colorizer / cog generator / sealed-step layer all read this
// set per paint and must see line 2.
- var editor = new ScriptClipEditor(OneSealedAtLineTwoXml);
+ var editor = MakeEditor(OneSealedAtLineTwoXml);
Assert.Single(editor.SealedLineNumbers);
Assert.Contains(2, editor.SealedLineNumbers);
@@ -110,7 +99,7 @@ public void SealedLineNumbers_PointsAtSealedStepLine()
[Fact]
public void SealedLineEndOffsets_KeyedByLineNumber()
{
- var editor = new ScriptClipEditor(OneSealedAtLineTwoXml);
+ var editor = MakeEditor(OneSealedAtLineTwoXml);
var line2 = editor.Document.GetLineByNumber(2);
Assert.Single(editor.SealedLineEndOffsets);
@@ -120,7 +109,7 @@ public void SealedLineEndOffsets_KeyedByLineNumber()
[Fact]
public void SealedLineNumbers_Empty_WhenNoSealedSteps()
{
- var editor = new ScriptClipEditor(SampleScriptXml);
+ var editor = MakeEditor(SampleScriptXml);
Assert.Empty(editor.SealedLineNumbers);
Assert.Empty(editor.SealedLineEndOffsets);
@@ -132,7 +121,7 @@ public void SealedLineNumbers_RecomputesAfterDocumentEdit()
// Inserting a fresh line above the sealed step should slide its
// line number from 2 → 3. Without cache invalidation the
// dimming colorizer would highlight the wrong physical line.
- var editor = new ScriptClipEditor(OneSealedAtLineTwoXml);
+ var editor = MakeEditor(OneSealedAtLineTwoXml);
Assert.Contains(2, editor.SealedLineNumbers);
editor.Document.Insert(0, "# new top line\n");
@@ -149,7 +138,7 @@ public void SealedLineNumbers_DropsEntryWhenLineDeleted()
// Deleting the entire sealed line drops the anchor (its
// SurviveDeletion flag is false), so the cache should empty
// out on next read.
- var editor = new ScriptClipEditor(OneSealedAtLineTwoXml);
+ var editor = MakeEditor(OneSealedAtLineTwoXml);
var sealedLine = editor.Document.GetLineByNumber(2);
Assert.Contains(2, editor.SealedLineNumbers);
diff --git a/tests/SharpFM.Tests/Editors/TableClipEditorTests.cs b/tests/SharpFM.Tests/Editors/TableClipEditorTests.cs
index 3df376c..eb63850 100644
--- a/tests/SharpFM.Tests/Editors/TableClipEditorTests.cs
+++ b/tests/SharpFM.Tests/Editors/TableClipEditorTests.cs
@@ -13,10 +13,13 @@ public class TableClipEditorTests
"" +
"";
+ private static TableClipEditor MakeEditor(string? xml) =>
+ new(FmTable.FromXml(xml ?? ""));
+
[Fact]
public void Constructor_ParsesFields()
{
- var editor = new TableClipEditor(SampleTableXml);
+ var editor = MakeEditor(SampleTableXml);
Assert.Equal(2, editor.ViewModel.Fields.Count);
Assert.Equal("FirstName", editor.ViewModel.Fields[0].Name);
@@ -26,7 +29,7 @@ public void Constructor_ParsesFields()
[Fact]
public void ToXml_RoundTrips()
{
- var editor = new TableClipEditor(SampleTableXml);
+ var editor = MakeEditor(SampleTableXml);
var xml = editor.ToXml();
Assert.Contains("People", xml);
@@ -38,7 +41,7 @@ public void ToXml_RoundTrips()
[Fact]
public void ToXml_ReflectsAddedField()
{
- var editor = new TableClipEditor(SampleTableXml);
+ var editor = MakeEditor(SampleTableXml);
editor.ViewModel.AddField();
var xml = editor.ToXml();
@@ -48,9 +51,9 @@ public void ToXml_ReflectsAddedField()
}
[Fact]
- public void Constructor_HandlesNullXml()
+ public void Constructor_HandlesEmptyTable()
{
- var editor = new TableClipEditor(null);
+ var editor = new TableClipEditor(new FmTable(""));
Assert.NotNull(editor.ViewModel);
Assert.Empty(editor.ViewModel.Fields);
@@ -59,7 +62,7 @@ public void Constructor_HandlesNullXml()
[Fact]
public void IsPartial_AlwaysFalse()
{
- var editor = new TableClipEditor(SampleTableXml);
+ var editor = MakeEditor(SampleTableXml);
editor.ToXml();
Assert.False(editor.IsPartial);
}
diff --git a/tests/SharpFM.Tests/Parsing/ClipParseReportTests.cs b/tests/SharpFM.Tests/Parsing/ClipParseReportTests.cs
new file mode 100644
index 0000000..79f0a22
--- /dev/null
+++ b/tests/SharpFM.Tests/Parsing/ClipParseReportTests.cs
@@ -0,0 +1,60 @@
+using SharpFM.Model.Parsing;
+
+namespace SharpFM.Tests.Parsing;
+
+public class ClipParseReportTests
+{
+ [Fact]
+ public void Empty_HasZeroDiagnostics()
+ {
+ Assert.Empty(ClipParseReport.Empty.Diagnostics);
+ }
+
+ [Fact]
+ public void Empty_IsLossless()
+ {
+ Assert.True(ClipParseReport.Empty.IsLossless);
+ }
+
+ [Fact]
+ public void Empty_IsSingleton()
+ {
+ Assert.Same(ClipParseReport.Empty, ClipParseReport.Empty);
+ }
+
+ [Fact]
+ public void Report_WithAnyDiagnostic_IsNotLossless()
+ {
+ var report = new ClipParseReport(
+ [
+ new ClipParseDiagnostic(
+ ParseDiagnosticKind.UnknownStepElement,
+ ParseDiagnosticSeverity.Warning,
+ "/fmxmlsnippet/Step[1]/Mystery",
+ "unmodeled child element"),
+ ]);
+
+ Assert.False(report.IsLossless);
+ Assert.Single(report.Diagnostics);
+ }
+
+ [Fact]
+ public void Report_PreservesDiagnosticOrder()
+ {
+ var first = new ClipParseDiagnostic(
+ ParseDiagnosticKind.UnknownStep,
+ ParseDiagnosticSeverity.Warning,
+ "/fmxmlsnippet/Step[1]",
+ "unknown step");
+ var second = new ClipParseDiagnostic(
+ ParseDiagnosticKind.UnknownStepAttribute,
+ ParseDiagnosticSeverity.Info,
+ "/fmxmlsnippet/Step[2]/@mystery",
+ "unmodeled attribute");
+
+ var report = new ClipParseReport([first, second]);
+
+ Assert.Equal(first, report.Diagnostics[0]);
+ Assert.Equal(second, report.Diagnostics[1]);
+ }
+}
diff --git a/tests/SharpFM.Tests/Parsing/ClipParseResultTests.cs b/tests/SharpFM.Tests/Parsing/ClipParseResultTests.cs
new file mode 100644
index 0000000..c020ce4
--- /dev/null
+++ b/tests/SharpFM.Tests/Parsing/ClipParseResultTests.cs
@@ -0,0 +1,62 @@
+using SharpFM.Model.Parsing;
+using SharpFM.Model.Scripting;
+
+namespace SharpFM.Tests.Parsing;
+
+public class ClipParseResultTests
+{
+ [Fact]
+ public void ParseSuccess_CarriesModelAndReport()
+ {
+ var script = new FmScript([]);
+ var model = new ScriptClipModel(script);
+ var result = new ParseSuccess(model, ClipParseReport.Empty);
+
+ Assert.Same(script, ((ScriptClipModel)result.Model).Script);
+ Assert.True(result.Report.IsLossless);
+ }
+
+ [Fact]
+ public void ParseFailure_CarriesReasonAndReport()
+ {
+ var report = new ClipParseReport(
+ [
+ new ClipParseDiagnostic(
+ ParseDiagnosticKind.XmlMalformed,
+ ParseDiagnosticSeverity.Error,
+ "/",
+ "unexpected end of stream"),
+ ]);
+
+ var result = new ParseFailure("invalid xml", report);
+
+ Assert.Equal("invalid xml", result.Reason);
+ Assert.False(result.Report.IsLossless);
+ }
+
+ [Fact]
+ public void Result_PatternMatchesByKind()
+ {
+ ClipParseResult success = new ParseSuccess(
+ new OpaqueClipModel(""),
+ ClipParseReport.Empty);
+ ClipParseResult failure = new ParseFailure("oops", ClipParseReport.Empty);
+
+ Assert.True(success is ParseSuccess);
+ Assert.True(failure is ParseFailure);
+ Assert.False(success is ParseFailure);
+ Assert.False(failure is ParseSuccess);
+ }
+
+ [Fact]
+ public void ClipModel_VariantsAreDistinctTypes()
+ {
+ ClipModel script = new ScriptClipModel(new FmScript([]));
+ ClipModel layout = new LayoutClipModel("");
+ ClipModel opaque = new OpaqueClipModel("");
+
+ Assert.IsType(script);
+ Assert.IsType(layout);
+ Assert.IsType(opaque);
+ }
+}
diff --git a/tests/SharpFM.Tests/Parsing/XmlRoundTripDiffTests.cs b/tests/SharpFM.Tests/Parsing/XmlRoundTripDiffTests.cs
new file mode 100644
index 0000000..e442dac
--- /dev/null
+++ b/tests/SharpFM.Tests/Parsing/XmlRoundTripDiffTests.cs
@@ -0,0 +1,154 @@
+using System.Xml.Linq;
+using SharpFM.Model.Parsing;
+
+namespace SharpFM.Tests.Parsing;
+
+public class XmlRoundTripDiffTests
+{
+ [Fact]
+ public void IdenticalTrees_ProduceNoDiagnostics()
+ {
+ var input = XElement.Parse("x");
+ var output = XElement.Parse("x");
+
+ Assert.Empty(XmlRoundTripDiff.Compute(input, output));
+ }
+
+ [Fact]
+ public void AttributeOrderDoesNotMatter()
+ {
+ var input = XElement.Parse("");
+ var output = XElement.Parse("");
+
+ Assert.Empty(XmlRoundTripDiff.Compute(input, output));
+ }
+
+ [Fact]
+ public void ChildOrderDoesNotMatter_WithinAParent()
+ {
+ var input = XElement.Parse("");
+ var output = XElement.Parse("");
+
+ Assert.Empty(XmlRoundTripDiff.Compute(input, output));
+ }
+
+ [Fact]
+ public void WhitespaceOnlyDifferencesAreIgnored()
+ {
+ var input = XElement.Parse(" hello ");
+ var output = XElement.Parse("hello");
+
+ Assert.Empty(XmlRoundTripDiff.Compute(input, output));
+ }
+
+ [Fact]
+ public void UnmodeledStepElement_ReportsUnknownStepElement()
+ {
+ var input = XElement.Parse("");
+ var output = XElement.Parse("");
+
+ var diags = XmlRoundTripDiff.Compute(input, output);
+
+ var diag = Assert.Single(diags);
+ Assert.Equal(ParseDiagnosticKind.UnknownStepElement, diag.Kind);
+ Assert.Contains("Mystery", diag.Location);
+ }
+
+ [Fact]
+ public void UnmodeledStepAttribute_ReportsUnknownStepAttribute()
+ {
+ var input = XElement.Parse("");
+ var output = XElement.Parse("");
+
+ var diag = Assert.Single(XmlRoundTripDiff.Compute(input, output));
+ Assert.Equal(ParseDiagnosticKind.UnknownStepAttribute, diag.Kind);
+ Assert.Contains("@mystery", diag.Location);
+ }
+
+ [Fact]
+ public void UnmodeledClipElement_ReportsUnknownClipElement()
+ {
+ var input = XElement.Parse("");
+ var output = XElement.Parse("");
+
+ var diag = Assert.Single(XmlRoundTripDiff.Compute(input, output));
+ Assert.Equal(ParseDiagnosticKind.UnknownClipElement, diag.Kind);
+ }
+
+ [Fact]
+ public void UnmodeledClipAttribute_ReportsUnknownClipAttribute()
+ {
+ var input = XElement.Parse("");
+ var output = XElement.Parse("");
+
+ var diag = Assert.Single(XmlRoundTripDiff.Compute(input, output));
+ Assert.Equal(ParseDiagnosticKind.UnknownClipAttribute, diag.Kind);
+ }
+
+ [Fact]
+ public void TextValueDifference_ReportsRoundTripValueMismatch()
+ {
+ var input = XElement.Parse("original");
+ var output = XElement.Parse("changed");
+
+ var diag = Assert.Single(XmlRoundTripDiff.Compute(input, output));
+ Assert.Equal(ParseDiagnosticKind.RoundTripValueMismatch, diag.Kind);
+ }
+
+ [Fact]
+ public void AttributeValueDifference_ReportsRoundTripValueMismatch()
+ {
+ var input = XElement.Parse("");
+ var output = XElement.Parse("");
+
+ var diag = Assert.Single(XmlRoundTripDiff.Compute(input, output));
+ Assert.Equal(ParseDiagnosticKind.RoundTripValueMismatch, diag.Kind);
+ Assert.Contains("@attr", diag.Location);
+ }
+
+ [Fact]
+ public void OutputEmitsExtraElement_ReportsAsInfo()
+ {
+ var input = XElement.Parse("");
+ var output = XElement.Parse("");
+
+ var diag = Assert.Single(XmlRoundTripDiff.Compute(input, output));
+ Assert.Equal(ParseDiagnosticSeverity.Info, diag.Severity);
+ Assert.Contains("default", diag.Message);
+ }
+
+ [Fact]
+ public void DroppedNamespace_ReportsDroppedNamespace()
+ {
+ var input = XElement.Parse("");
+ var output = XElement.Parse("");
+
+ var diag = XmlRoundTripDiff.Compute(input, output)
+ .Single(d => d.Kind == ParseDiagnosticKind.DroppedNamespace);
+ Assert.Contains("urn:x", diag.Message);
+ }
+
+ [Fact]
+ public void Location_UsesPositionalIndexForRepeatedNames()
+ {
+ var input = XElement.Parse("");
+ var output = XElement.Parse("");
+
+ var diags = XmlRoundTripDiff.Compute(input, output);
+ Assert.Equal(2, diags.Count);
+ Assert.Contains(diags, d => d.Location.Contains("Step[1]"));
+ Assert.Contains(diags, d => d.Location.Contains("Step[2]"));
+ }
+
+ [Fact]
+ public void NestedDifferences_AreReportedWithFullPath()
+ {
+ var input = XElement.Parse("");
+ var output = XElement.Parse("");
+
+ var diag = Assert.Single(XmlRoundTripDiff.Compute(input, output));
+ Assert.Contains("Step[1]", diag.Location);
+ Assert.Contains("Inner[1]", diag.Location);
+ Assert.Contains("@mystery", diag.Location);
+ }
+}
diff --git a/tests/SharpFM.Tests/Scripting/Steps/CommentStepTests.cs b/tests/SharpFM.Tests/Scripting/Steps/CommentStepTests.cs
index 1a051c4..79f8ab5 100644
--- a/tests/SharpFM.Tests/Scripting/Steps/CommentStepTests.cs
+++ b/tests/SharpFM.Tests/Scripting/Steps/CommentStepTests.cs
@@ -180,7 +180,7 @@ public void SingleEmptyComment_Snippet_DoesNotCrashOnLoad()
var xml = ""
+ ""
+ "";
- var editor = new SharpFM.Editors.ScriptClipEditor(xml);
+ var editor = new SharpFM.Editors.ScriptClipEditor(SharpFM.Model.Scripting.FmScript.FromXml(xml));
var xmlOut = editor.ToXml();
// Parseable; empty comment survives the round-trip.
diff --git a/tests/SharpFM.Tests/TrustedEditPathTests.cs b/tests/SharpFM.Tests/TrustedEditPathTests.cs
new file mode 100644
index 0000000..8b1c704
--- /dev/null
+++ b/tests/SharpFM.Tests/TrustedEditPathTests.cs
@@ -0,0 +1,122 @@
+using System.Linq;
+using SharpFM.Model;
+using SharpFM.Model.Parsing;
+using SharpFM.Model.Schema;
+using SharpFM.Model.Scripting;
+using SharpFM.Model.Scripting.Steps;
+using Xunit;
+
+namespace SharpFM.Tests;
+
+///
+/// Trusted-edit path: bypasses the strategy
+/// parse + round-trip diff because the editor's model is the source of
+/// truth. Diagnostics that don't depend on structural diff (e.g. RawStep
+/// inventory) are still synthesised so the UI signal stays consistent
+/// with the strategy-driven path.
+///
+public class TrustedEditPathTests
+{
+ [Fact]
+ public void FromEditor_ProducesParseSuccessWithProvidedModel()
+ {
+ var script = new FmScript([]);
+ var model = new ScriptClipModel(script);
+
+ var clip = Clip.FromEditor("X", "Mac-XMSS",
+ "", model);
+
+ var success = Assert.IsType(clip.Parsed);
+ Assert.Same(model, success.Model);
+ }
+
+ [Fact]
+ public void FromEditor_LosslessForCleanScript()
+ {
+ var script = new FmScript(
+ [
+ new SetVariableStep(true, "$x", new SharpFM.Model.Scripting.Values.Calculation("1")),
+ ]);
+ var model = new ScriptClipModel(script);
+
+ var clip = Clip.FromEditor("X", "Mac-XMSS",
+ "", model);
+
+ Assert.True(clip.Parsed.Report.IsLossless);
+ }
+
+ [Fact]
+ public void FromEditor_SurfacesRawStepAsUnknownStep()
+ {
+ var rawStepXml = System.Xml.Linq.XElement.Parse(
+ "");
+ var script = new FmScript([new RawStep(rawStepXml)]);
+ var model = new ScriptClipModel(script);
+
+ var clip = Clip.FromEditor("X", "Mac-XMSS",
+ "", model);
+
+ var success = Assert.IsType(clip.Parsed);
+ var diagnostic = Assert.Single(success.Report.Diagnostics);
+ Assert.Equal(ParseDiagnosticKind.UnknownStep, diagnostic.Kind);
+ Assert.Equal(ParseDiagnosticSeverity.Info, diagnostic.Severity);
+ Assert.Contains("FutureStep", diagnostic.Message);
+ }
+
+ [Fact]
+ public void FromEditor_TableModel_LosslessReport()
+ {
+ var table = new FmTable("People");
+ var model = new TableClipModel(table);
+
+ var clip = Clip.FromEditor("X", "Mac-XMTB",
+ "", model);
+
+ Assert.True(clip.Parsed.Report.IsLossless);
+ }
+
+ [Fact]
+ public void FromEditor_OpaqueModel_LosslessReport()
+ {
+ var model = new OpaqueClipModel("");
+
+ var clip = Clip.FromEditor("X", "Mac-XML2", "", model);
+
+ Assert.True(clip.Parsed.Report.IsLossless);
+ }
+
+ [Fact]
+ public void FromEditor_DoesNotInvokeStrategy_ForMalformedXml()
+ {
+ // Strategy.Parse on this would return ParseFailure (no
+ // root). FromEditor trusts the model + xml regardless of strategy
+ // opinions about the XML — the editor produced both, they're
+ // self-consistent by construction.
+ var script = new FmScript([]);
+ var model = new ScriptClipModel(script);
+
+ var clip = Clip.FromEditor("X", "Mac-XMSS", "", model);
+
+ Assert.IsType(clip.Parsed);
+ }
+
+ [Fact]
+ public void FromEditor_LargeScriptDoesNotInvokeRoundTripDiff()
+ {
+ // Sanity: 500 steps would burn cycles on the strategy path. The
+ // trusted-edit path completes without scanning the produced XML
+ // (we don't pass the strategy here at all).
+ var steps = Enumerable.Range(0, 500)
+ .Select(i => (ScriptStep)new SetVariableStep(
+ true, $"$v{i}", new SharpFM.Model.Scripting.Values.Calculation(i.ToString())))
+ .ToList();
+ var script = new FmScript(steps);
+ var model = new ScriptClipModel(script);
+
+ var clip = Clip.FromEditor("X", "Mac-XMSS",
+ "", model);
+
+ Assert.True(clip.Parsed.Report.IsLossless);
+ Assert.Same(model, ((ParseSuccess)clip.Parsed).Model);
+ }
+}
diff --git a/tests/SharpFM.Tests/ViewModels/ClipTreeNodeViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/ClipTreeNodeViewModelTests.cs
index 723a5da..d0e0c2a 100644
--- a/tests/SharpFM.Tests/ViewModels/ClipTreeNodeViewModelTests.cs
+++ b/tests/SharpFM.Tests/ViewModels/ClipTreeNodeViewModelTests.cs
@@ -9,7 +9,7 @@ public class ClipTreeNodeViewModelTests
{
private static ClipViewModel Clip(string name, params string[] folderPath)
{
- var vm = new ClipViewModel(new FileMakerClip(name, "Mac-XMSS",
+ var vm = new ClipViewModel(SharpFM.Model.Clip.FromXml(name, "Mac-XMSS",
""));
vm.FolderPath = folderPath;
return vm;
diff --git a/tests/SharpFM.Tests/ViewModels/ClipViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/ClipViewModelTests.cs
index a6027ea..2b594aa 100644
--- a/tests/SharpFM.Tests/ViewModels/ClipViewModelTests.cs
+++ b/tests/SharpFM.Tests/ViewModels/ClipViewModelTests.cs
@@ -9,11 +9,8 @@ public class ClipViewModelTests
private static string WrapXml(string steps) =>
$"{steps}";
- private static ClipViewModel CreateScriptClip(string xml)
- {
- var clip = new FileMakerClip("Test", "Mac-XMSS", xml);
- return new ClipViewModel(clip);
- }
+ private static ClipViewModel CreateScriptClip(string xml) =>
+ new(Clip.FromXml("Test", "Mac-XMSS", xml));
[Fact]
public void IsScriptClip_TrueForXMSS()
@@ -25,8 +22,8 @@ public void IsScriptClip_TrueForXMSS()
[Fact]
public void IsScriptClip_FalseForTable()
{
- var clip = new FileMakerClip("Test", "Mac-XMTB", "");
- var vm = new ClipViewModel(clip);
+ var vm = new ClipViewModel(Clip.FromXml(
+ "Test", "Mac-XMTB", ""));
Assert.False(vm.IsScriptClip);
}
@@ -36,7 +33,6 @@ public void ScriptDocument_LazyCreated()
var xml = WrapXml("hello");
var vm = CreateScriptClip(xml);
- // Access ScriptDocument triggers lazy creation
var doc = vm.ScriptDocument;
Assert.NotNull(doc);
Assert.Contains("# hello", doc.Text);
@@ -51,13 +47,12 @@ public void EditorToXml_UpdatesClipXml()
var doc = vm.ScriptDocument;
doc!.Text = "# modified";
- // Editor.ToXml() gives fresh XML from current editor state
var freshXml = vm.Editor.ToXml();
Assert.Contains("modified", freshXml);
}
[Fact]
- public void ReplaceEditor_UpdatesScriptDocument()
+ public void Replace_UpdatesScriptDocument()
{
var xml = WrapXml("original");
var vm = CreateScriptClip(xml);
@@ -65,49 +60,44 @@ public void ReplaceEditor_UpdatesScriptDocument()
_ = vm.ScriptDocument;
var newXml = WrapXml("changed via xml");
- vm.ReplaceEditor(newXml);
+ vm.Replace(newXml);
Assert.Contains("changed via xml", vm.ScriptDocument!.Text);
}
[Fact]
- public void ReplaceEditor_TableClip_RoundTripsXml()
+ public void Replace_TableClip_RoundTripsXml()
{
- var clip = new FileMakerClip("Test", "Mac-XMTB", "");
- var vm = new ClipViewModel(clip);
+ var vm = new ClipViewModel(Clip.FromXml(
+ "Test", "Mac-XMTB",
+ ""));
- vm.ReplaceEditor(vm.Clip.XmlData);
+ vm.Replace(vm.Clip.Xml);
- Assert.Contains("BaseTable", vm.Clip.XmlData);
- Assert.Contains("name=\"T\"", vm.Clip.XmlData);
+ Assert.Contains("BaseTable", vm.Clip.Xml);
+ Assert.Contains("name=\"T\"", vm.Clip.Xml);
}
[Fact]
- public void Clip_XmlData_UpdatesBothClipAndDocument()
+ public void Replace_UpdatesXmlDocumentForFallbackClip()
{
- var xml = WrapXml("");
- var vm = CreateScriptClip(xml);
+ var vm = new ClipViewModel(Clip.FromXml(
+ "Test", "Mac-XML2", ""));
- // Access XML document to create it
- _ = vm.XmlDocument;
+ var newXml = "";
+ vm.Replace(newXml);
- var newXml = WrapXml("new");
- vm.Clip.XmlData = newXml;
-
- Assert.Equal(newXml, vm.Clip.XmlData);
- Assert.Equal(newXml, vm.XmlDocument.Text);
+ Assert.Contains("Hello", vm.Clip.Xml);
}
[Fact]
- public void Clip_Name_FiresPropertyChanged()
+ public void Rename_ProducesRenamedAggregate()
{
var vm = CreateScriptClip(WrapXml(""));
- string? changed = null;
- vm.Clip.PropertyChanged += (_, args) => changed = args.PropertyName;
+ var renamed = vm.Clip.Rename("Renamed");
- vm.Clip.Name = "Renamed";
- Assert.Equal("Renamed", vm.Clip.Name);
- Assert.Equal("Name", changed);
+ Assert.Equal("Renamed", renamed.Name);
+ Assert.Equal(vm.Clip.Xml, renamed.Xml);
}
[Fact]
@@ -123,9 +113,6 @@ public void IsDirty_TrueAfterEditorEdit()
var vm = CreateScriptClip(WrapXml("a"));
vm.ScriptDocument!.Text += "\n# new line";
- // IsDirty is computed live — no need to pump the ContentChanged
- // debouncer; a UI binding watching IsDirty gets notified when
- // ContentChanged fires, but the value itself is always fresh.
Assert.True(vm.IsDirty);
}
@@ -141,13 +128,20 @@ public void MarkSaved_ClearsIsDirty()
}
[Fact]
- public void ReplaceEditor_ResetsIsDirty()
+ public void Replace_ResetsIsDirty()
{
var vm = CreateScriptClip(WrapXml("a"));
vm.ScriptDocument!.Text += "\n# edited";
Assert.True(vm.IsDirty);
- vm.ReplaceEditor(vm.Editor.ToXml());
+ vm.Replace(vm.Editor.ToXml());
Assert.False(vm.IsDirty);
}
+
+ [Fact]
+ public void ParseReport_ReflectsClipParseState()
+ {
+ var vm = CreateScriptClip(WrapXml(""));
+ Assert.True(vm.IsLossless);
+ }
}
diff --git a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelParseFidelityTests.cs b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelParseFidelityTests.cs
new file mode 100644
index 0000000..d7a9b1d
--- /dev/null
+++ b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelParseFidelityTests.cs
@@ -0,0 +1,53 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using SharpFM.ViewModels;
+using Xunit;
+
+namespace SharpFM.Tests.ViewModels;
+
+public class MainWindowViewModelParseFidelityTests
+{
+ private static MainWindowViewModel CreateVm() =>
+ new(NullLoggerFactory.Instance.CreateLogger("test"),
+ new MockClipboardService(),
+ new MockFolderService());
+
+ [Fact]
+ public void NoSelection_ParseFidelityNotVisible()
+ {
+ var vm = CreateVm();
+ Assert.False(vm.ParseFidelityVisible);
+ }
+
+ [Fact]
+ public void SelectedLosslessClip_SummaryReadsLossless()
+ {
+ var vm = CreateVm();
+ vm.NewScriptCommand();
+
+ Assert.True(vm.ParseFidelityVisible);
+ Assert.True(vm.ParseFidelityIsLossless);
+ Assert.Equal("Parsed losslessly", vm.ParseFidelitySummary);
+ }
+
+ [Fact]
+ public void SelectedLossyClip_SummaryEnumeratesIssues()
+ {
+ var vm = CreateVm();
+
+ // RawStep ⇒ UnknownStep diagnostic; survives lossless XML round-trip
+ // but the report calls it out.
+ const string lossyXml =
+ "" +
+ "" +
+ "";
+
+ vm.FileMakerClips.Add(new ClipViewModel(
+ SharpFM.Model.Clip.FromXml("Lossy", "Mac-XMSS", lossyXml)));
+ vm.SelectedClip = vm.FileMakerClips[^1];
+
+ Assert.False(vm.ParseFidelityIsLossless);
+ Assert.Contains("issue", vm.ParseFidelitySummary);
+ Assert.Contains("unknown step", vm.ParseFidelitySummary);
+ }
+}
diff --git a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs
index 313ee6e..00db8c5 100644
--- a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs
+++ b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs
@@ -79,7 +79,7 @@ public void NewTableCommand_TableEditorIsUsable()
// TableEditor should lazy-create from the starter XML
var editor = clip.TableEditor;
Assert.NotNull(editor);
- Assert.Equal("NewTable", editor!.TableName);
+ Assert.Equal("New Table", editor!.TableName);
// AddField command should work
Assert.True(editor.AddFieldCommand.CanExecute(null));
diff --git a/tests/SharpFM.Tests/ViewModels/OpenTabsViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/OpenTabsViewModelTests.cs
index 8f9c945..0996fd2 100644
--- a/tests/SharpFM.Tests/ViewModels/OpenTabsViewModelTests.cs
+++ b/tests/SharpFM.Tests/ViewModels/OpenTabsViewModelTests.cs
@@ -7,7 +7,7 @@ namespace SharpFM.Tests.ViewModels;
public class OpenTabsViewModelTests
{
private static ClipViewModel Clip(string name) =>
- new(new FileMakerClip(name, "Mac-XMSS",
+ new(SharpFM.Model.Clip.FromXml(name, "Mac-XMSS",
"a"));
[Fact]