From 7f6c05016e96d2b41227721cccbba01a528d2c1d Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 27 Apr 2026 21:44:58 -0500 Subject: [PATCH 01/10] feat: add clip parse-result foundation types --- src/SharpFM.Model/Parsing/ClipModel.cs | 25 ++++++++ .../Parsing/ClipParseDiagnostic.cs | 16 +++++ src/SharpFM.Model/Parsing/ClipParseReport.cs | 17 +++++ src/SharpFM.Model/Parsing/ClipParseResult.cs | 19 ++++++ .../Parsing/ParseDiagnosticKind.cs | 36 +++++++++++ .../Parsing/ParseDiagnosticSeverity.cs | 13 ++++ .../Parsing/ClipParseReportTests.cs | 60 ++++++++++++++++++ .../Parsing/ClipParseResultTests.cs | 62 +++++++++++++++++++ 8 files changed, 248 insertions(+) create mode 100644 src/SharpFM.Model/Parsing/ClipModel.cs create mode 100644 src/SharpFM.Model/Parsing/ClipParseDiagnostic.cs create mode 100644 src/SharpFM.Model/Parsing/ClipParseReport.cs create mode 100644 src/SharpFM.Model/Parsing/ClipParseResult.cs create mode 100644 src/SharpFM.Model/Parsing/ParseDiagnosticKind.cs create mode 100644 src/SharpFM.Model/Parsing/ParseDiagnosticSeverity.cs create mode 100644 tests/SharpFM.Tests/Parsing/ClipParseReportTests.cs create mode 100644 tests/SharpFM.Tests/Parsing/ClipParseResultTests.cs 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/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/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); + } +} From 55c47f3ce7aa101c06afe8d11d397bbf683caa89 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 27 Apr 2026 21:48:15 -0500 Subject: [PATCH 02/10] feat: add clip-type strategy registry and Clip aggregate --- src/SharpFM.Model/Clip.cs | 98 +++++++++++++++ .../ClipTypes/ClipTypeRegistry.cs | 86 +++++++++++++ .../ClipTypes/IClipTypeStrategy.cs | 32 +++++ .../ClipTypes/OpaqueClipStrategy.cs | 58 +++++++++ tests/SharpFM.Tests/ClipTests.cs | 117 ++++++++++++++++++ .../ClipTypes/ClipTypeRegistryTests.cs | 78 ++++++++++++ .../ClipTypes/OpaqueClipStrategyTests.cs | 56 +++++++++ 7 files changed, 525 insertions(+) create mode 100644 src/SharpFM.Model/Clip.cs create mode 100644 src/SharpFM.Model/ClipTypes/ClipTypeRegistry.cs create mode 100644 src/SharpFM.Model/ClipTypes/IClipTypeStrategy.cs create mode 100644 src/SharpFM.Model/ClipTypes/OpaqueClipStrategy.cs create mode 100644 tests/SharpFM.Tests/ClipTests.cs create mode 100644 tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryTests.cs create mode 100644 tests/SharpFM.Tests/ClipTypes/OpaqueClipStrategyTests.cs diff --git a/src/SharpFM.Model/Clip.cs b/src/SharpFM.Model/Clip.cs new file mode 100644 index 0000000..34f33e2 --- /dev/null +++ b/src/SharpFM.Model/Clip.cs @@ -0,0 +1,98 @@ +using System; +using System.Linq; +using System.Text; +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. +/// +/// +/// Mutation produces a new instance: reparses against +/// the registered strategy; reuses the existing parse. +/// 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 +{ + /// Display name of the clip. + public string Name { get; } + + /// Wire-format identifier, e.g. "Mac-XMSS". + public string FormatId { get; } + + /// Canonical (pretty-printed) XML body. Always retains whatever the source produced for unknown content. + public string Xml { get; } + + /// Outcome of parsing against the registered strategy for . + public ClipParseResult Parsed { get; } + + 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, ClipParseResult parsed) + { + Name = name; + FormatId = formatId; + Xml = xml; + Parsed = parsed; + } + + /// + /// Construct a clip from raw XML. The XML is canonicalised via + /// (which preserves the input on + /// well-formedness errors), then handed to the registered strategy for + /// . Parse failures are returned in + /// ; this method itself does not throw. + /// + public static Clip FromXml(string name, string formatId, string xml) + { + var canonical = XmlHelpers.PrettyPrint(xml ?? string.Empty); + var strategy = ClipTypeRegistry.For(formatId); + var parsed = strategy.Parse(canonical); + return new Clip(name, formatId, canonical, parsed); + } + + /// + /// 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); + } + + /// Return a fresh clip with replacement XML. Re-parses against the registered strategy. + public Clip WithXml(string newXml) => FromXml(Name, FormatId, newXml); + + /// Return a fresh clip with a new name; parse state is reused since XML is unchanged. + public Clip Rename(string newName) => new(newName, FormatId, Xml, Parsed); +} diff --git a/src/SharpFM.Model/ClipTypes/ClipTypeRegistry.cs b/src/SharpFM.Model/ClipTypes/ClipTypeRegistry.cs new file mode 100644 index 0000000..63c5b8e --- /dev/null +++ b/src/SharpFM.Model/ClipTypes/ClipTypeRegistry.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.Linq; + +namespace SharpFM.Model.ClipTypes; + +/// +/// Static, explicitly-populated registry of +/// implementations keyed by Mac-XM* format id. Built-in strategies are +/// registered once at startup via ; tests +/// reset and re-register through . +/// +/// +/// Reflection-based auto-discovery (the pattern used by StepRegistry) +/// is deliberately not used here — clip types are few, low-cardinality, and +/// explicit registration makes the bootstrapping order obvious. +/// +public static class ClipTypeRegistry +{ + private static readonly object _gate = new(); + private static readonly Dictionary _strategies = new(); + + /// Register a strategy. A duplicate overwrites the prior entry. + public static void Register(IClipTypeStrategy strategy) + { + lock (_gate) + { + _strategies[strategy.FormatId] = strategy; + } + } + + /// + /// 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) + { + lock (_gate) + { + return _strategies.TryGetValue(formatId, out var strategy) + ? strategy + : OpaqueClipStrategy.Instance; + } + } + + /// True if the given format id has a dedicated strategy registered. + public static bool IsRegistered(string formatId) + { + lock (_gate) + { + return _strategies.ContainsKey(formatId); + } + } + + /// All explicitly-registered strategies, in registration order. + public static IReadOnlyList All + { + get + { + lock (_gate) + { + return _strategies.Values.ToList(); + } + } + } + + /// + /// Register every built-in clip-type strategy. Called once at host startup; + /// idempotent thanks to 's overwrite semantics. + /// + public static void RegisterBuiltIns() + { + // Built-in strategies are registered here as they land in subsequent + // commits (script, table, layout). Opaque is the implicit fallback; + // it is not registered. + } + + /// Clear the registry. Tests use this to isolate from production registrations. + internal static void Reset() + { + lock (_gate) + { + _strategies.Clear(); + } + } +} 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/OpaqueClipStrategy.cs b/src/SharpFM.Model/ClipTypes/OpaqueClipStrategy.cs new file mode 100644 index 0000000..cb62154 --- /dev/null +++ b/src/SharpFM.Model/ClipTypes/OpaqueClipStrategy.cs @@ -0,0 +1,58 @@ +using System; +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() { } + + public string FormatId => "Mac-XM??"; + public string DisplayName => "Unknown"; + + public ClipParseResult Parse(string xml) + { + if (string.IsNullOrWhiteSpace(xml)) + { + return new ParseFailure("empty xml", new ClipParseReport( + [ + new ClipParseDiagnostic( + ParseDiagnosticKind.XmlMalformed, + ParseDiagnosticSeverity.Error, + "/", + "input was empty"), + ])); + } + + try + { + XDocument.Parse(xml); + } + catch (XmlException ex) + { + return new ParseFailure("malformed xml", new ClipParseReport( + [ + new ClipParseDiagnostic( + ParseDiagnosticKind.XmlMalformed, + ParseDiagnosticSeverity.Error, + "/", + ex.Message), + ])); + } + + return new ParseSuccess(new OpaqueClipModel(xml), ClipParseReport.Empty); + } + + public string DefaultXml(string clipName) => + ""; +} diff --git a/tests/SharpFM.Tests/ClipTests.cs b/tests/SharpFM.Tests/ClipTests.cs new file mode 100644 index 0000000..72ab994 --- /dev/null +++ b/tests/SharpFM.Tests/ClipTests.cs @@ -0,0 +1,117 @@ +using System.Text; +using SharpFM.Model; +using SharpFM.Model.ClipTypes; +using SharpFM.Model.Parsing; + +namespace SharpFM.Tests; + +public class ClipTests : IDisposable +{ + public ClipTests() + { + ClipTypeRegistry.Reset(); + } + + public void Dispose() + { + ClipTypeRegistry.Reset(); + } + + [Fact] + public void FromXml_UnknownFormat_FallsBackToOpaqueParse() + { + var clip = Clip.FromXml("Untitled", "Mac-XMUNKNOWN", ""); + + 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..78b285b --- /dev/null +++ b/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryTests.cs @@ -0,0 +1,78 @@ +using SharpFM.Model.ClipTypes; +using SharpFM.Model.Parsing; + +namespace SharpFM.Tests.ClipTypes; + +public class ClipTypeRegistryTests : IDisposable +{ + public ClipTypeRegistryTests() + { + ClipTypeRegistry.Reset(); + } + + public void Dispose() + { + ClipTypeRegistry.Reset(); + } + + [Fact] + public void For_UnknownFormat_FallsBackToOpaque() + { + var strategy = ClipTypeRegistry.For("Mac-XMNOPE"); + Assert.Same(OpaqueClipStrategy.Instance, strategy); + } + + [Fact] + public void Register_ThenLookup_ReturnsRegistered() + { + var fake = new FakeStrategy("Mac-XMFAKE"); + ClipTypeRegistry.Register(fake); + + Assert.Same(fake, ClipTypeRegistry.For("Mac-XMFAKE")); + } + + [Fact] + public void Register_DuplicateId_OverwritesPrior() + { + var first = new FakeStrategy("Mac-XMFAKE"); + var second = new FakeStrategy("Mac-XMFAKE"); + ClipTypeRegistry.Register(first); + ClipTypeRegistry.Register(second); + + Assert.Same(second, ClipTypeRegistry.For("Mac-XMFAKE")); + } + + [Fact] + public void IsRegistered_ReflectsExplicitRegistration() + { + Assert.False(ClipTypeRegistry.IsRegistered("Mac-XMFAKE")); + ClipTypeRegistry.Register(new FakeStrategy("Mac-XMFAKE")); + Assert.True(ClipTypeRegistry.IsRegistered("Mac-XMFAKE")); + } + + [Fact] + public void All_DoesNotIncludeOpaqueFallback() + { + Assert.Empty(ClipTypeRegistry.All); + ClipTypeRegistry.Register(new FakeStrategy("Mac-XMFAKE")); + Assert.Single(ClipTypeRegistry.All); + } + + [Fact] + public void Reset_ClearsAllRegistrations() + { + ClipTypeRegistry.Register(new FakeStrategy("Mac-XMFAKE")); + ClipTypeRegistry.Reset(); + Assert.Empty(ClipTypeRegistry.All); + Assert.Same(OpaqueClipStrategy.Instance, ClipTypeRegistry.For("Mac-XMFAKE")); + } + + private sealed class FakeStrategy(string formatId) : IClipTypeStrategy + { + public string FormatId { get; } = formatId; + public string DisplayName => "Fake"; + public ClipParseResult Parse(string xml) => + new ParseSuccess(new OpaqueClipModel(xml), ClipParseReport.Empty); + public string DefaultXml(string clipName) => ""; + } +} 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); + } +} From 3fa1c16a9d637a75d5126fc72b9271ecaffc64bb Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 27 Apr 2026 21:50:18 -0500 Subject: [PATCH 03/10] feat: add round-trip diff substrate for parse-fidelity reporting --- src/SharpFM.Model/Parsing/XmlRoundTripDiff.cs | 175 ++++++++++++++++++ .../Parsing/XmlRoundTripDiffTests.cs | 154 +++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 src/SharpFM.Model/Parsing/XmlRoundTripDiff.cs create mode 100644 tests/SharpFM.Tests/Parsing/XmlRoundTripDiffTests.cs 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/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); + } +} From 48d8aac4c385c956f784942a597113a9d4b57092 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 27 Apr 2026 21:51:59 -0500 Subject: [PATCH 04/10] feat: implement script clip strategy with parse-fidelity reporting --- .../ClipTypes/ScriptClipStrategy.cs | 111 ++++++++++++++++ .../ClipTypes/ScriptClipStrategyTests.cs | 119 ++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 src/SharpFM.Model/ClipTypes/ScriptClipStrategy.cs create mode 100644 tests/SharpFM.Tests/ClipTypes/ScriptClipStrategyTests.cs diff --git a/src/SharpFM.Model/ClipTypes/ScriptClipStrategy.cs b/src/SharpFM.Model/ClipTypes/ScriptClipStrategy.cs new file mode 100644 index 0000000..1d48c72 --- /dev/null +++ b/src/SharpFM.Model/ClipTypes/ScriptClipStrategy.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Xml; +using System.Xml.Linq; +using SharpFM.Model.Parsing; +using SharpFM.Model.Scripting; +using SharpFM.Model.Scripting.Steps; + +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 (string.IsNullOrWhiteSpace(xml)) + { + return Failure(ParseDiagnosticKind.XmlMalformed, "/", "input was empty", "empty xml"); + } + + XElement input; + try + { + input = XElement.Parse(xml); + } + catch (XmlException ex) + { + return Failure(ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "malformed xml"); + } + + if (input.Name.LocalName != "fmxmlsnippet") + { + return Failure( + ParseDiagnosticKind.UnsupportedClipType, + "/" + input.Name.LocalName, + $"expected , found <{input.Name.LocalName}>", + "unsupported root element"); + } + + FmScript script; + try + { + script = FmScript.FromXml(xml); + } + catch (Exception ex) + { + return Failure(ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "failed to parse script"); + } + + var output = XElement.Parse(script.ToXml()); + var diagnostics = new List(XmlRoundTripDiff.Compute(input, output)); + + var rawStepIndex = 0; + foreach (var step in script.Steps) + { + rawStepIndex++; + if (step is RawStep raw) + { + var stepName = raw.Element.Attribute("name")?.Value ?? "Unknown"; + diagnostics.Add(new ClipParseDiagnostic( + ParseDiagnosticKind.UnknownStep, + ParseDiagnosticSeverity.Info, + $"/fmxmlsnippet/Step[{rawStepIndex}]", + $"step '{stepName}' is not modeled by the host; preserved verbatim as RawStep")); + } + } + + var report = diagnostics.Count == 0 + ? ClipParseReport.Empty + : new ClipParseReport(diagnostics); + + return new ParseSuccess(new ScriptClipModel(script), report); + } + + public string DefaultXml(string clipName) => + ""; + + private static ParseFailure Failure( + ParseDiagnosticKind kind, string location, string message, string reason) + { + return new ParseFailure(reason, new ClipParseReport( + [ + new ClipParseDiagnostic(kind, ParseDiagnosticSeverity.Error, location, message), + ])); + } +} 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); + } +} From 936d780c357102b016879043b1f1c5bda4bb255f Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 27 Apr 2026 21:53:51 -0500 Subject: [PATCH 05/10] feat: implement table and layout strategies, register built-ins --- .../ClipTypes/ClipTypeRegistry.cs | 12 ++- .../ClipTypes/LayoutClipStrategy.cs | 64 ++++++++++++ .../ClipTypes/TableClipStrategy.cs | 97 +++++++++++++++++++ .../ClipTypeRegistryBuiltInsTests.cs | 42 ++++++++ .../ClipTypes/LayoutClipStrategyTests.cs | 51 ++++++++++ .../ClipTypes/TableClipStrategyTests.cs | 72 ++++++++++++++ 6 files changed, 334 insertions(+), 4 deletions(-) create mode 100644 src/SharpFM.Model/ClipTypes/LayoutClipStrategy.cs create mode 100644 src/SharpFM.Model/ClipTypes/TableClipStrategy.cs create mode 100644 tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryBuiltInsTests.cs create mode 100644 tests/SharpFM.Tests/ClipTypes/LayoutClipStrategyTests.cs create mode 100644 tests/SharpFM.Tests/ClipTypes/TableClipStrategyTests.cs diff --git a/src/SharpFM.Model/ClipTypes/ClipTypeRegistry.cs b/src/SharpFM.Model/ClipTypes/ClipTypeRegistry.cs index 63c5b8e..aeb45b6 100644 --- a/src/SharpFM.Model/ClipTypes/ClipTypeRegistry.cs +++ b/src/SharpFM.Model/ClipTypes/ClipTypeRegistry.cs @@ -66,13 +66,17 @@ public static IReadOnlyList All /// /// Register every built-in clip-type strategy. Called once at host startup; - /// idempotent thanks to 's overwrite semantics. + /// idempotent thanks to 's overwrite semantics. Adding + /// a new Mac-XM* format is a single additional + /// call here. Opaque is the implicit fallback and is not registered. /// public static void RegisterBuiltIns() { - // Built-in strategies are registered here as they land in subsequent - // commits (script, table, layout). Opaque is the implicit fallback; - // it is not registered. + Register(ScriptClipStrategy.Steps); + Register(ScriptClipStrategy.Script); + Register(TableClipStrategy.Table); + Register(TableClipStrategy.Field); + Register(LayoutClipStrategy.Instance); } /// Clear the registry. Tests use this to isolate from production registrations. diff --git a/src/SharpFM.Model/ClipTypes/LayoutClipStrategy.cs b/src/SharpFM.Model/ClipTypes/LayoutClipStrategy.cs new file mode 100644 index 0000000..550c841 --- /dev/null +++ b/src/SharpFM.Model/ClipTypes/LayoutClipStrategy.cs @@ -0,0 +1,64 @@ +using System; +using System.Xml; +using System.Xml.Linq; +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 (string.IsNullOrWhiteSpace(xml)) + { + return Failure(ParseDiagnosticKind.XmlMalformed, "/", "input was empty", "empty xml"); + } + + XElement input; + try + { + input = XElement.Parse(xml); + } + catch (XmlException ex) + { + return Failure(ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "malformed xml"); + } + + if (input.Name.LocalName != "fmxmlsnippet") + { + return Failure( + ParseDiagnosticKind.UnsupportedClipType, + "/" + input.Name.LocalName, + $"expected , found <{input.Name.LocalName}>", + "unsupported root element"); + } + + return new ParseSuccess(new LayoutClipModel(xml), ClipParseReport.Empty); + } + + public string DefaultXml(string clipName) => + ""; + + private static ParseFailure Failure( + ParseDiagnosticKind kind, string location, string message, string reason) + { + return new ParseFailure(reason, new ClipParseReport( + [ + new ClipParseDiagnostic(kind, ParseDiagnosticSeverity.Error, location, message), + ])); + } +} diff --git a/src/SharpFM.Model/ClipTypes/TableClipStrategy.cs b/src/SharpFM.Model/ClipTypes/TableClipStrategy.cs new file mode 100644 index 0000000..c673fb9 --- /dev/null +++ b/src/SharpFM.Model/ClipTypes/TableClipStrategy.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Xml; +using System.Xml.Linq; +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 (string.IsNullOrWhiteSpace(xml)) + { + return Failure(ParseDiagnosticKind.XmlMalformed, "/", "input was empty", "empty xml"); + } + + XElement input; + try + { + input = XElement.Parse(xml); + } + catch (XmlException ex) + { + return Failure(ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "malformed xml"); + } + + if (input.Name.LocalName != "fmxmlsnippet") + { + return Failure( + ParseDiagnosticKind.UnsupportedClipType, + "/" + input.Name.LocalName, + $"expected , found <{input.Name.LocalName}>", + "unsupported root element"); + } + + FmTable table; + try + { + table = FmTable.FromXml(xml); + } + catch (Exception ex) + { + return Failure(ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "failed to parse table"); + } + + var output = XElement.Parse(table.ToXml()); + var diagnostics = new List(XmlRoundTripDiff.Compute(input, output)); + + var report = diagnostics.Count == 0 + ? ClipParseReport.Empty + : new ClipParseReport(diagnostics); + + return new ParseSuccess(new TableClipModel(table), report); + } + + public string DefaultXml(string clipName) => + _wrapsBaseTable + ? $"" + : ""; + + private static ParseFailure Failure( + ParseDiagnosticKind kind, string location, string message, string reason) + { + return new ParseFailure(reason, new ClipParseReport( + [ + new ClipParseDiagnostic(kind, ParseDiagnosticSeverity.Error, location, message), + ])); + } +} diff --git a/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryBuiltInsTests.cs b/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryBuiltInsTests.cs new file mode 100644 index 0000000..c898455 --- /dev/null +++ b/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryBuiltInsTests.cs @@ -0,0 +1,42 @@ +using SharpFM.Model.ClipTypes; + +namespace SharpFM.Tests.ClipTypes; + +public class ClipTypeRegistryBuiltInsTests : IDisposable +{ + public ClipTypeRegistryBuiltInsTests() + { + ClipTypeRegistry.Reset(); + ClipTypeRegistry.RegisterBuiltIns(); + } + + public void Dispose() + { + ClipTypeRegistry.Reset(); + } + + [Theory] + [InlineData("Mac-XMSS", "Script Steps")] + [InlineData("Mac-XMSC", "Script")] + [InlineData("Mac-XMTB", "Table")] + [InlineData("Mac-XMFD", "Field")] + [InlineData("Mac-XML2", "Layout")] + public void RegisterBuiltIns_RegistersExpectedFormat(string formatId, string displayName) + { + Assert.True(ClipTypeRegistry.IsRegistered(formatId)); + Assert.Equal(displayName, ClipTypeRegistry.For(formatId).DisplayName); + } + + [Fact] + public void RegisterBuiltIns_DoesNotRegisterOpaque() + { + Assert.False(ClipTypeRegistry.IsRegistered(OpaqueClipStrategy.Instance.FormatId)); + } + + [Fact] + public void RegisterBuiltIns_IsIdempotent() + { + ClipTypeRegistry.RegisterBuiltIns(); + Assert.Equal(5, ClipTypeRegistry.All.Count); + } +} 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/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); + } +} From 41e948c2b61b1c2c6bbcf107a4aa164cd19a1365 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 27 Apr 2026 22:12:03 -0500 Subject: [PATCH 06/10] refactor: migrate clip pipeline to Clip aggregate --- src/SharpFM.Model/SharpFM.Model.csproj | 1 + src/SharpFM/App.axaml.cs | 6 + src/SharpFM/Core/FileMakerClipExtensions.cs | 172 ++++++++---------- .../Diagnostics/RawClipboardWindow.axaml.cs | 4 +- src/SharpFM/Editors/ClipEditorViewFactory.cs | 20 ++ src/SharpFM/Editors/FallbackXmlEditor.cs | 5 - src/SharpFM/Editors/IClipEditor.cs | 6 - src/SharpFM/Editors/ScriptClipEditor.cs | 12 +- src/SharpFM/Editors/TableClipEditor.cs | 9 +- src/SharpFM/Services/PluginHost.cs | 46 ++--- src/SharpFM/ViewModels/ClipViewModel.cs | 97 +++++----- src/SharpFM/ViewModels/MainWindowViewModel.cs | 57 +++--- tests/SharpFM.Plugin.Tests/PluginHostTests.cs | 4 +- .../TestAssemblyInitializer.cs | 17 ++ tests/SharpFM.Tests/ClipTests.cs | 13 +- .../ClipTypeRegistryBuiltInsTests.cs | 2 + .../ClipTypes/ClipTypeRegistryTests.cs | 2 + .../ClipTypes/RegistryMutatingCollection.cs | 14 ++ .../Core/FileMakerClipExtensionsTests.cs | 29 +-- .../SharpFM.Tests/Core/FileMakerClipTests.cs | 172 ------------------ .../Editors/FallbackXmlEditorTests.cs | 16 +- .../Editors/ScriptClipEditorTests.cs | 41 ++--- .../Editors/TableClipEditorTests.cs | 15 +- .../Scripting/Steps/CommentStepTests.cs | 2 +- .../SharpFM.Tests/TestAssemblyInitializer.cs | 4 + .../ViewModels/ClipTreeNodeViewModelTests.cs | 2 +- .../ViewModels/ClipViewModelTests.cs | 70 ++++--- .../ViewModels/MainWindowViewModelTests.cs | 2 +- .../ViewModels/OpenTabsViewModelTests.cs | 2 +- 29 files changed, 316 insertions(+), 526 deletions(-) create mode 100644 tests/SharpFM.Plugin.Tests/TestAssemblyInitializer.cs create mode 100644 tests/SharpFM.Tests/ClipTypes/RegistryMutatingCollection.cs delete mode 100644 tests/SharpFM.Tests/Core/FileMakerClipTests.cs 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/App.axaml.cs b/src/SharpFM/App.axaml.cs index dd02f7f..6dd8cb2 100644 --- a/src/SharpFM/App.axaml.cs +++ b/src/SharpFM/App.axaml.cs @@ -4,6 +4,7 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using SharpFM.Model.ClipTypes; using SharpFM.Model.Scripting.Registry; using SharpFM.Models; using SharpFM.Plugin; @@ -32,6 +33,11 @@ public override void OnFrameworkInitializationCompleted() // same initialization runs lazily on first registry access. StepRegistry.Initialize(); + // Clip-type strategies are explicitly registered (no reflection); do + // it once at startup so paste / file load / plugin push all see the + // built-in formats from the first request onward. + ClipTypeRegistry.RegisterBuiltIns(); + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.MainWindow = new MainWindow(); 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..f8f1b85 100644 --- a/src/SharpFM/Editors/FallbackXmlEditor.cs +++ b/src/SharpFM/Editors/FallbackXmlEditor.cs @@ -27,9 +27,4 @@ public FallbackXmlEditor(string? xml) } public string ToXml() => Document.Text; - - public void FromXml(string xml) - { - Document.Text = xml; - } } diff --git a/src/SharpFM/Editors/IClipEditor.cs b/src/SharpFM/Editors/IClipEditor.cs index e968c6b..516075e 100644 --- a/src/SharpFM/Editors/IClipEditor.cs +++ b/src/SharpFM/Editors/IClipEditor.cs @@ -20,12 +20,6 @@ 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. - /// - void FromXml(string xml); - /// /// True if the last produced output from an incomplete or errored parse. /// For example, a half-typed script step that can't fully round-trip. diff --git a/src/SharpFM/Editors/ScriptClipEditor.cs b/src/SharpFM/Editors/ScriptClipEditor.cs index b4836d6..bdac279 100644 --- a/src/SharpFM/Editors/ScriptClipEditor.cs +++ b/src/SharpFM/Editors/ScriptClipEditor.cs @@ -59,9 +59,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,14 +225,6 @@ public string ToXml() return _script.ToXml(); } - public void FromXml(string xml) - { - _script = FmScript.FromXml(xml); - _metadata = _script.Metadata; - Document.Text = _script.ToDisplayText(); - BuildSealedAnchors(); - } - /// /// Reparse the current document into , preserving /// sealed steps via the anchor cache. For each logical step range in diff --git a/src/SharpFM/Editors/TableClipEditor.cs b/src/SharpFM/Editors/TableClipEditor.cs index db27b85..5a0d89c 100644 --- a/src/SharpFM/Editors/TableClipEditor.cs +++ b/src/SharpFM/Editors/TableClipEditor.cs @@ -21,9 +21,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,12 +35,6 @@ 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. - } - private void SubscribeToViewModel(TableEditorViewModel vm) { vm.Fields.CollectionChanged += OnCollectionChanged; 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..517734a 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,11 +26,24 @@ private void NotifyPropertyChanged([CallerMemberName] string propertyName = "") PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } - public FileMakerClip Clip { get; set; } + private Clip _clip; + + /// The current immutable clip aggregate. Replaced wholesale on edits. + 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. + /// The clip-type-specific editor. Receives an already-parsed model from + /// ; no XML parsing happens + /// here. /// public IClipEditor Editor { get; private set; } @@ -44,10 +64,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(); } @@ -67,15 +87,15 @@ public void Dispose() } /// - /// 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. + /// Wholesale replacement: re-parse the clip with new XML and rebuild the + /// editor around the resulting model. Used for all external updates + /// (MCP, plugins, XML viewer). /// - public void ReplaceEditor(string xml) + 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. @@ -86,15 +106,23 @@ public void ReplaceEditor(string xml) // Reverse sync from plugins / external tools 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)); } + /// The fidelity report from the most recent XML→domain parse. + public ClipParseReport ParseReport => _clip.Parsed.Report; + + /// True when no parse loss was detected on the current XML. + public bool IsLossless => ParseReport.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. @@ -113,13 +141,6 @@ public void MarkSaved() 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 +149,25 @@ private void OnEditorContentChanged(object? sender, EventArgs e) => // Dispatcher. In production this is invoked from Editor.ContentChanged. internal void HandleEditorContentChanged() { - Clip.XmlData = Editor.ToXml(); + // Editor edits are display→XML→model. The aggregate's parsed state + // becomes stale; re-derive it via WithXml so ParseReport reflects the + // current XML. + Clip = _clip.WithXml(Editor.ToXml()); 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 +176,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..f0131c8 100644 --- a/src/SharpFM/ViewModels/MainWindowViewModel.cs +++ b/src/SharpFM/ViewModels/MainWindowViewModel.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.Logging; using SharpFM.Models; using SharpFM.Model; +using SharpFM.Model.ClipTypes; using SharpFM.Model.Scripting; using SharpFM.Plugin; using SharpFM.Services; @@ -135,7 +136,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 +171,12 @@ public async Task SaveClipsStorageAsync() { try { - // Ensure XML is up-to-date from editor state before saving + // Sync editor state into the aggregate before snapshotting. 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, }) @@ -210,11 +211,6 @@ public void ExitApplication() } } - private static readonly string EmptyScriptXml = - ""; - - private static readonly string EmptyTableXml = - ""; public void DeleteSelectedClip() { @@ -231,18 +227,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 +262,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 +290,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 +324,9 @@ 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); + // Sync editor state into the aggregate before lifting wire bytes off it. + data.HandleEditorContentChanged(); + await _clipboard.SetDataAsync(data.ClipType, data.Clip.WireBytes); ShowStatus("Copied to FileMaker clipboard"); } catch (Exception e) @@ -361,13 +360,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 +397,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) 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(" + /// Register built-in clip-type strategies once at assembly load time so any + /// test that constructs a sees them. + /// + [ModuleInitializer] + internal static void Initialize() + { + ClipTypeRegistry.RegisterBuiltIns(); + } +} diff --git a/tests/SharpFM.Tests/ClipTests.cs b/tests/SharpFM.Tests/ClipTests.cs index 72ab994..4376896 100644 --- a/tests/SharpFM.Tests/ClipTests.cs +++ b/tests/SharpFM.Tests/ClipTests.cs @@ -1,22 +1,11 @@ using System.Text; using SharpFM.Model; -using SharpFM.Model.ClipTypes; using SharpFM.Model.Parsing; namespace SharpFM.Tests; -public class ClipTests : IDisposable +public class ClipTests { - public ClipTests() - { - ClipTypeRegistry.Reset(); - } - - public void Dispose() - { - ClipTypeRegistry.Reset(); - } - [Fact] public void FromXml_UnknownFormat_FallsBackToOpaqueParse() { diff --git a/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryBuiltInsTests.cs b/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryBuiltInsTests.cs index c898455..8eb63b9 100644 --- a/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryBuiltInsTests.cs +++ b/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryBuiltInsTests.cs @@ -2,6 +2,7 @@ namespace SharpFM.Tests.ClipTypes; +[Collection(RegistryMutatingCollection.Name)] public class ClipTypeRegistryBuiltInsTests : IDisposable { public ClipTypeRegistryBuiltInsTests() @@ -13,6 +14,7 @@ public ClipTypeRegistryBuiltInsTests() public void Dispose() { ClipTypeRegistry.Reset(); + ClipTypeRegistry.RegisterBuiltIns(); } [Theory] diff --git a/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryTests.cs b/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryTests.cs index 78b285b..0065c4b 100644 --- a/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryTests.cs +++ b/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryTests.cs @@ -3,6 +3,7 @@ namespace SharpFM.Tests.ClipTypes; +[Collection(RegistryMutatingCollection.Name)] public class ClipTypeRegistryTests : IDisposable { public ClipTypeRegistryTests() @@ -13,6 +14,7 @@ public ClipTypeRegistryTests() public void Dispose() { ClipTypeRegistry.Reset(); + ClipTypeRegistry.RegisterBuiltIns(); } [Fact] diff --git a/tests/SharpFM.Tests/ClipTypes/RegistryMutatingCollection.cs b/tests/SharpFM.Tests/ClipTypes/RegistryMutatingCollection.cs new file mode 100644 index 0000000..1a758ab --- /dev/null +++ b/tests/SharpFM.Tests/ClipTypes/RegistryMutatingCollection.cs @@ -0,0 +1,14 @@ +using Xunit; + +namespace SharpFM.Tests.ClipTypes; + +/// +/// Test collection for classes that mutate +/// (e.g. Reset). Marked non-parallel so they don't race with tests in other classes +/// that assume the registry is populated. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class RegistryMutatingCollection +{ + public const string Name = "RegistryMutating"; +} 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 = - "" - + "" - + "People::FirstName" - + "People::LastName" - + "" - + ""; - - // --- 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/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/TestAssemblyInitializer.cs b/tests/SharpFM.Tests/TestAssemblyInitializer.cs index efede46..f16ad2b 100644 --- a/tests/SharpFM.Tests/TestAssemblyInitializer.cs +++ b/tests/SharpFM.Tests/TestAssemblyInitializer.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using SharpFM.Model.ClipTypes; namespace SharpFM.Tests; @@ -9,10 +10,13 @@ internal static class TestAssemblyInitializer /// installs . /// Without this, tests that touch only SharpFM.Model types in isolation /// would render steps via the generic path and miss the canonical formatting. + /// Also registers the built-in clip-type strategies so any test that + /// constructs a sees them. /// [ModuleInitializer] internal static void Initialize() { _ = typeof(SharpFM.Scripting.ScriptTextParser).FullName; + ClipTypeRegistry.RegisterBuiltIns(); } } 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/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] From 22af657e4ba1f07db5dbf44d880683d1361405da Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 27 Apr 2026 22:14:46 -0500 Subject: [PATCH 07/10] feat: surface parse-fidelity report in status bar and clip tree --- src/SharpFM/MainWindow.axaml | 21 +++++ src/SharpFM/ViewModels/MainWindowViewModel.cs | 77 +++++++++++++++++++ .../MainWindowViewModelParseFidelityTests.cs | 53 +++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 tests/SharpFM.Tests/ViewModels/MainWindowViewModelParseFidelityTests.cs 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/ViewModels/MainWindowViewModel.cs b/src/SharpFM/ViewModels/MainWindowViewModel.cs index f0131c8..39068b2 100644 --- a/src/SharpFM/ViewModels/MainWindowViewModel.cs +++ b/src/SharpFM/ViewModels/MainWindowViewModel.cs @@ -29,10 +29,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; @@ -79,6 +113,7 @@ public MainWindowViewModel( _trackedActiveTab.PropertyChanged += OnActiveTabPropertyChanged; NotifyPropertyChanged(nameof(SelectedClip)); + ResubscribeSelectedClipParseReport(); }; RootNodes = []; @@ -471,6 +506,48 @@ 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()} {HumanKind(g.Key, g.Count())}") + .ToList(); + + return $"Parsed with {report.Diagnostics.Count} issue(s): {string.Join(", ", byKind)}"; + } + } + + private static string HumanKind(SharpFM.Model.Parsing.ParseDiagnosticKind kind, int count) => + kind switch + { + SharpFM.Model.Parsing.ParseDiagnosticKind.UnknownStep => count == 1 ? "unknown step" : "unknown steps", + SharpFM.Model.Parsing.ParseDiagnosticKind.UnknownStepElement => count == 1 ? "unknown step element" : "unknown step elements", + SharpFM.Model.Parsing.ParseDiagnosticKind.UnknownStepAttribute => count == 1 ? "unknown step attribute" : "unknown step attributes", + SharpFM.Model.Parsing.ParseDiagnosticKind.UnknownClipElement => count == 1 ? "unknown element" : "unknown elements", + SharpFM.Model.Parsing.ParseDiagnosticKind.UnknownClipAttribute => count == 1 ? "unknown attribute" : "unknown attributes", + SharpFM.Model.Parsing.ParseDiagnosticKind.DroppedNamespace => count == 1 ? "dropped namespace" : "dropped namespaces", + SharpFM.Model.Parsing.ParseDiagnosticKind.RoundTripValueMismatch => count == 1 ? "value mismatch" : "value mismatches", + SharpFM.Model.Parsing.ParseDiagnosticKind.XmlMalformed => "malformed xml", + SharpFM.Model.Parsing.ParseDiagnosticKind.UnsupportedClipType => "unsupported clip type", + _ => "issue", + }; + /// /// Open a clip as a preview tab (single-click in the tree). /// 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); + } +} From b76d84d50bf1b3a31535d5f68efb781def78ab83 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 27 Apr 2026 22:15:28 -0500 Subject: [PATCH 08/10] refactor: remove obsolete FileMakerClip and FileMakerField --- src/SharpFM.Model/FileMakerClip.cs | 188 ---------------------------- src/SharpFM.Model/FileMakerField.cs | 19 --- 2 files changed, 207 deletions(-) delete mode 100644 src/SharpFM.Model/FileMakerClip.cs delete mode 100644 src/SharpFM.Model/FileMakerField.cs 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; } -} From cd892280c176704c80c39ba2a588831f9d76fcf8 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 27 Apr 2026 23:27:32 -0500 Subject: [PATCH 09/10] refactor: simplify Clip aggregate and strategies after review --- src/SharpFM.Model/Clip.cs | 84 ++++++++++++++----- .../ClipTypes/ClipStrategyHelpers.cs | 62 ++++++++++++++ .../ClipTypes/LayoutClipStrategy.cs | 36 +------- .../ClipTypes/OpaqueClipStrategy.cs | 23 ++--- .../ClipTypes/ScriptClipStrategy.cs | 40 ++------- .../ClipTypes/TableClipStrategy.cs | 36 +------- .../Parsing/ParseDiagnosticKindExtensions.cs | 34 ++++++++ src/SharpFM.Model/Scripting/Steps/RawStep.cs | 19 +++-- src/SharpFM/ViewModels/ClipViewModel.cs | 33 +------- src/SharpFM/ViewModels/MainWindowViewModel.cs | 23 +---- 10 files changed, 192 insertions(+), 198 deletions(-) create mode 100644 src/SharpFM.Model/ClipTypes/ClipStrategyHelpers.cs create mode 100644 src/SharpFM.Model/Parsing/ParseDiagnosticKindExtensions.cs diff --git a/src/SharpFM.Model/Clip.cs b/src/SharpFM.Model/Clip.cs index 34f33e2..4823958 100644 --- a/src/SharpFM.Model/Clip.cs +++ b/src/SharpFM.Model/Clip.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Text; +using System.Threading; using SharpFM.Model.ClipTypes; using SharpFM.Model.Parsing; using SharpFM.Model.Scripting; @@ -15,25 +16,39 @@ namespace SharpFM.Model; /// any consumer regardless of how the clip arrived. /// /// -/// Mutation produces a new instance: reparses against -/// the registered strategy; reuses the existing parse. /// 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 { - /// Display name of the clip. public string Name { get; } - - /// Wire-format identifier, e.g. "Mac-XMSS". public string FormatId { get; } - - /// Canonical (pretty-printed) XML body. Always retains whatever the source produced for unknown content. public string Xml { get; } - /// Outcome of parsing against the registered strategy for . - public ClipParseResult Parsed { 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; @@ -55,27 +70,35 @@ public byte[] WireBytes } } - private Clip(string name, string formatId, string xml, ClipParseResult parsed) + private Clip(string name, string formatId, string xml, Func parseFactory) { Name = name; FormatId = formatId; Xml = xml; - Parsed = parsed; + _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 - /// (which preserves the input on - /// well-formedness errors), then handed to the registered strategy for - /// . Parse failures are returned in - /// ; this method itself does not throw. + /// ; 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); - var strategy = ClipTypeRegistry.For(formatId); - var parsed = strategy.Parse(canonical); - return new Clip(name, formatId, canonical, parsed); + return new Clip( + name, + formatId, + canonical, + () => ClipTypeRegistry.For(formatId).Parse(canonical)); } /// @@ -90,9 +113,26 @@ public static Clip FromWireBytes(string name, string formatId, byte[] bytes) return FromXml(name, formatId, xml); } - /// Return a fresh clip with replacement XML. Re-parses against the registered strategy. - public Clip WithXml(string newXml) => FromXml(Name, FormatId, newXml); + /// + /// 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 with a new name; parse state is reused since XML is unchanged. - public Clip Rename(string newName) => new(newName, FormatId, Xml, Parsed); + /// 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/ClipTypes/ClipStrategyHelpers.cs b/src/SharpFM.Model/ClipTypes/ClipStrategyHelpers.cs new file mode 100644 index 0000000..051635d --- /dev/null +++ b/src/SharpFM.Model/ClipTypes/ClipStrategyHelpers.cs @@ -0,0 +1,62 @@ +using System.Xml; +using System.Xml.Linq; +using SharpFM.Model.Parsing; + +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; + } +} diff --git a/src/SharpFM.Model/ClipTypes/LayoutClipStrategy.cs b/src/SharpFM.Model/ClipTypes/LayoutClipStrategy.cs index 550c841..8fe3c4b 100644 --- a/src/SharpFM.Model/ClipTypes/LayoutClipStrategy.cs +++ b/src/SharpFM.Model/ClipTypes/LayoutClipStrategy.cs @@ -1,6 +1,3 @@ -using System; -using System.Xml; -using System.Xml.Linq; using SharpFM.Model.Parsing; namespace SharpFM.Model.ClipTypes; @@ -23,42 +20,13 @@ private LayoutClipStrategy() { } public ClipParseResult Parse(string xml) { - if (string.IsNullOrWhiteSpace(xml)) + if (!ClipStrategyHelpers.TryParseFmxmlsnippet(xml, out _, out var failure)) { - return Failure(ParseDiagnosticKind.XmlMalformed, "/", "input was empty", "empty xml"); + return failure; } - - XElement input; - try - { - input = XElement.Parse(xml); - } - catch (XmlException ex) - { - return Failure(ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "malformed xml"); - } - - if (input.Name.LocalName != "fmxmlsnippet") - { - return Failure( - ParseDiagnosticKind.UnsupportedClipType, - "/" + input.Name.LocalName, - $"expected , found <{input.Name.LocalName}>", - "unsupported root element"); - } - return new ParseSuccess(new LayoutClipModel(xml), ClipParseReport.Empty); } public string DefaultXml(string clipName) => ""; - - private static ParseFailure Failure( - ParseDiagnosticKind kind, string location, string message, string reason) - { - return new ParseFailure(reason, new ClipParseReport( - [ - new ClipParseDiagnostic(kind, ParseDiagnosticSeverity.Error, location, message), - ])); - } } diff --git a/src/SharpFM.Model/ClipTypes/OpaqueClipStrategy.cs b/src/SharpFM.Model/ClipTypes/OpaqueClipStrategy.cs index cb62154..46c6043 100644 --- a/src/SharpFM.Model/ClipTypes/OpaqueClipStrategy.cs +++ b/src/SharpFM.Model/ClipTypes/OpaqueClipStrategy.cs @@ -1,4 +1,3 @@ -using System; using System.Xml; using System.Xml.Linq; using SharpFM.Model.Parsing; @@ -17,21 +16,17 @@ public sealed class OpaqueClipStrategy : IClipTypeStrategy 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 new ParseFailure("empty xml", new ClipParseReport( - [ - new ClipParseDiagnostic( - ParseDiagnosticKind.XmlMalformed, - ParseDiagnosticSeverity.Error, - "/", - "input was empty"), - ])); + return ClipStrategyHelpers.Failure( + ParseDiagnosticKind.XmlMalformed, "/", "input was empty", "empty xml"); } try @@ -40,14 +35,8 @@ public ClipParseResult Parse(string xml) } catch (XmlException ex) { - return new ParseFailure("malformed xml", new ClipParseReport( - [ - new ClipParseDiagnostic( - ParseDiagnosticKind.XmlMalformed, - ParseDiagnosticSeverity.Error, - "/", - ex.Message), - ])); + return ClipStrategyHelpers.Failure( + ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "malformed xml"); } return new ParseSuccess(new OpaqueClipModel(xml), ClipParseReport.Empty); diff --git a/src/SharpFM.Model/ClipTypes/ScriptClipStrategy.cs b/src/SharpFM.Model/ClipTypes/ScriptClipStrategy.cs index 1d48c72..162149c 100644 --- a/src/SharpFM.Model/ClipTypes/ScriptClipStrategy.cs +++ b/src/SharpFM.Model/ClipTypes/ScriptClipStrategy.cs @@ -1,7 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Xml; using System.Xml.Linq; using SharpFM.Model.Parsing; using SharpFM.Model.Scripting; @@ -38,28 +36,9 @@ private ScriptClipStrategy(string formatId, string displayName) public ClipParseResult Parse(string xml) { - if (string.IsNullOrWhiteSpace(xml)) + if (!ClipStrategyHelpers.TryParseFmxmlsnippet(xml, out var input, out var failure)) { - return Failure(ParseDiagnosticKind.XmlMalformed, "/", "input was empty", "empty xml"); - } - - XElement input; - try - { - input = XElement.Parse(xml); - } - catch (XmlException ex) - { - return Failure(ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "malformed xml"); - } - - if (input.Name.LocalName != "fmxmlsnippet") - { - return Failure( - ParseDiagnosticKind.UnsupportedClipType, - "/" + input.Name.LocalName, - $"expected , found <{input.Name.LocalName}>", - "unsupported root element"); + return failure; } FmScript script; @@ -69,7 +48,8 @@ public ClipParseResult Parse(string xml) } catch (Exception ex) { - return Failure(ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "failed to parse script"); + return ClipStrategyHelpers.Failure( + ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "failed to parse script"); } var output = XElement.Parse(script.ToXml()); @@ -81,12 +61,11 @@ public ClipParseResult Parse(string xml) rawStepIndex++; if (step is RawStep raw) { - var stepName = raw.Element.Attribute("name")?.Value ?? "Unknown"; diagnostics.Add(new ClipParseDiagnostic( ParseDiagnosticKind.UnknownStep, ParseDiagnosticSeverity.Info, $"/fmxmlsnippet/Step[{rawStepIndex}]", - $"step '{stepName}' is not modeled by the host; preserved verbatim as RawStep")); + $"step '{raw.Name}' is not modeled by the host; preserved verbatim as RawStep")); } } @@ -99,13 +78,4 @@ public ClipParseResult Parse(string xml) public string DefaultXml(string clipName) => ""; - - private static ParseFailure Failure( - ParseDiagnosticKind kind, string location, string message, string reason) - { - return new ParseFailure(reason, new ClipParseReport( - [ - new ClipParseDiagnostic(kind, ParseDiagnosticSeverity.Error, location, message), - ])); - } } diff --git a/src/SharpFM.Model/ClipTypes/TableClipStrategy.cs b/src/SharpFM.Model/ClipTypes/TableClipStrategy.cs index c673fb9..6dbacbe 100644 --- a/src/SharpFM.Model/ClipTypes/TableClipStrategy.cs +++ b/src/SharpFM.Model/ClipTypes/TableClipStrategy.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Xml; using System.Xml.Linq; using SharpFM.Model.Parsing; using SharpFM.Model.Schema; @@ -37,28 +36,9 @@ private TableClipStrategy(string formatId, string displayName, bool wrapsBaseTab public ClipParseResult Parse(string xml) { - if (string.IsNullOrWhiteSpace(xml)) + if (!ClipStrategyHelpers.TryParseFmxmlsnippet(xml, out var input, out var failure)) { - return Failure(ParseDiagnosticKind.XmlMalformed, "/", "input was empty", "empty xml"); - } - - XElement input; - try - { - input = XElement.Parse(xml); - } - catch (XmlException ex) - { - return Failure(ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "malformed xml"); - } - - if (input.Name.LocalName != "fmxmlsnippet") - { - return Failure( - ParseDiagnosticKind.UnsupportedClipType, - "/" + input.Name.LocalName, - $"expected , found <{input.Name.LocalName}>", - "unsupported root element"); + return failure; } FmTable table; @@ -68,7 +48,8 @@ public ClipParseResult Parse(string xml) } catch (Exception ex) { - return Failure(ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "failed to parse table"); + return ClipStrategyHelpers.Failure( + ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "failed to parse table"); } var output = XElement.Parse(table.ToXml()); @@ -85,13 +66,4 @@ public string DefaultXml(string clipName) => _wrapsBaseTable ? $"" : ""; - - private static ParseFailure Failure( - ParseDiagnosticKind kind, string location, string message, string reason) - { - return new ParseFailure(reason, new ClipParseReport( - [ - new ClipParseDiagnostic(kind, ParseDiagnosticSeverity.Error, location, message), - ])); - } } 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/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/ViewModels/ClipViewModel.cs b/src/SharpFM/ViewModels/ClipViewModel.cs index 517734a..354875b 100644 --- a/src/SharpFM/ViewModels/ClipViewModel.cs +++ b/src/SharpFM/ViewModels/ClipViewModel.cs @@ -28,7 +28,6 @@ private void NotifyPropertyChanged([CallerMemberName] string propertyName = "") private Clip _clip; - /// The current immutable clip aggregate. Replaced wholesale on edits. public Clip Clip { get => _clip; @@ -40,11 +39,6 @@ private set } } - /// - /// The clip-type-specific editor. Receives an already-parsed model from - /// ; no XML parsing happens - /// here. - /// public IClipEditor Editor { get; private set; } /// @@ -86,11 +80,7 @@ public void Dispose() _editorView = null; } - /// - /// Wholesale replacement: re-parse the clip with new XML and rebuild the - /// editor around the resulting model. Used for all external updates - /// (MCP, plugins, XML viewer). - /// + /// Re-parse the clip with new XML; used for external updates (MCP, plugins, XML viewer). public void Replace(string xml) { Editor.ContentChanged -= OnEditorContentChanged; @@ -98,13 +88,11 @@ public void Replace(string 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)); @@ -117,27 +105,17 @@ public void Replace(string xml) NotifyPropertyChanged(nameof(IsLossless)); } - /// The fidelity report from the most recent XML→domain parse. public ClipParseReport ParseReport => _clip.Parsed.Report; - /// True when no parse loss was detected on the current XML. public bool IsLossless => ParseReport.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. - /// + /// 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); @@ -149,9 +127,6 @@ private void OnEditorContentChanged(object? sender, EventArgs e) => // Dispatcher. In production this is invoked from Editor.ContentChanged. internal void HandleEditorContentChanged() { - // Editor edits are display→XML→model. The aggregate's parsed state - // becomes stale; re-derive it via WithXml so ParseReport reflects the - // current XML. Clip = _clip.WithXml(Editor.ToXml()); NotifyPropertyChanged(nameof(IsDirty)); NotifyPropertyChanged(nameof(ParseReport)); diff --git a/src/SharpFM/ViewModels/MainWindowViewModel.cs b/src/SharpFM/ViewModels/MainWindowViewModel.cs index 39068b2..f3aab0c 100644 --- a/src/SharpFM/ViewModels/MainWindowViewModel.cs +++ b/src/SharpFM/ViewModels/MainWindowViewModel.cs @@ -14,6 +14,7 @@ using SharpFM.Models; using SharpFM.Model; using SharpFM.Model.ClipTypes; +using SharpFM.Model.Parsing; using SharpFM.Model.Scripting; using SharpFM.Plugin; using SharpFM.Services; @@ -206,7 +207,6 @@ public async Task SaveClipsStorageAsync() { try { - // Sync editor state into the aggregate before snapshotting. foreach (var clip in FileMakerClips) clip.HandleEditorContentChanged(); @@ -219,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}"); @@ -359,7 +357,6 @@ public async Task CopySelectedToClip() try { - // Sync editor state into the aggregate before lifting wire bytes off it. data.HandleEditorContentChanged(); await _clipboard.SetDataAsync(data.ClipType, data.Clip.WireBytes); ShowStatus("Copied to FileMaker clipboard"); @@ -526,28 +523,12 @@ public string ParseFidelitySummary var byKind = report.Diagnostics .GroupBy(d => d.Kind) - .Select(g => $"{g.Count()} {HumanKind(g.Key, g.Count())}") - .ToList(); + .Select(g => $"{g.Count()} {g.Key.ToHumanLabel(g.Count())}"); return $"Parsed with {report.Diagnostics.Count} issue(s): {string.Join(", ", byKind)}"; } } - private static string HumanKind(SharpFM.Model.Parsing.ParseDiagnosticKind kind, int count) => - kind switch - { - SharpFM.Model.Parsing.ParseDiagnosticKind.UnknownStep => count == 1 ? "unknown step" : "unknown steps", - SharpFM.Model.Parsing.ParseDiagnosticKind.UnknownStepElement => count == 1 ? "unknown step element" : "unknown step elements", - SharpFM.Model.Parsing.ParseDiagnosticKind.UnknownStepAttribute => count == 1 ? "unknown step attribute" : "unknown step attributes", - SharpFM.Model.Parsing.ParseDiagnosticKind.UnknownClipElement => count == 1 ? "unknown element" : "unknown elements", - SharpFM.Model.Parsing.ParseDiagnosticKind.UnknownClipAttribute => count == 1 ? "unknown attribute" : "unknown attributes", - SharpFM.Model.Parsing.ParseDiagnosticKind.DroppedNamespace => count == 1 ? "dropped namespace" : "dropped namespaces", - SharpFM.Model.Parsing.ParseDiagnosticKind.RoundTripValueMismatch => count == 1 ? "value mismatch" : "value mismatches", - SharpFM.Model.Parsing.ParseDiagnosticKind.XmlMalformed => "malformed xml", - SharpFM.Model.Parsing.ParseDiagnosticKind.UnsupportedClipType => "unsupported clip type", - _ => "issue", - }; - /// /// Open a clip as a preview tab (single-click in the tree). /// From e0786259b1d4da04e203edb9feec309f6dff0b21 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Tue, 28 Apr 2026 00:12:22 -0500 Subject: [PATCH 10/10] perf: trusted-edit path skips strategy parse on editor edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Editors expose GetModel() so the aggregate can take their parsed state directly. ClipViewModel.HandleEditorContentChanged routes through Clip.FromEditor which wraps the model in ParseSuccess without running the strategy or the round-trip diff. Per-keystroke cost on a 1000-step script drops from ~500ms-2.5s of XML work to the editor's own re-render. - FmScript.ToElement / FmTable.ToElement skip the parse-and-prettify round-trip strategies were doing on the diff path. - ClipDataExtensions routes through the registry instead of calling FmScript.FromXml / FmTable.FromXml directly so plugin-side parsing shares the host's dispatch. - ClipTypeRegistry collapses to an immutable static array. No Register, RegisterBuiltIns, Reset, no _initialized flag, no test isolation collection — clip strategies are fully owned by SharpFM and known at compile time. --- src/SharpFM.Model/Clip.cs | 33 +++++ src/SharpFM.Model/ClipDataExtensions.cs | 30 ++--- .../ClipTypes/ClipStrategyHelpers.cs | 26 ++++ .../ClipTypes/ClipTypeRegistry.cs | 100 ++++---------- .../ClipTypes/ScriptClipStrategy.cs | 21 +-- .../ClipTypes/TableClipStrategy.cs | 5 +- src/SharpFM.Model/Schema/FmTable.cs | 10 +- src/SharpFM.Model/Scripting/FmScript.cs | 12 +- src/SharpFM/App.axaml.cs | 6 - src/SharpFM/Editors/FallbackXmlEditor.cs | 3 + src/SharpFM/Editors/IClipEditor.cs | 10 ++ src/SharpFM/Editors/ScriptClipEditor.cs | 19 +++ src/SharpFM/Editors/TableClipEditor.cs | 3 + src/SharpFM/ViewModels/ClipViewModel.cs | 9 +- .../TestAssemblyInitializer.cs | 17 --- .../ClipTypeRegistryBuiltInsTests.cs | 44 ------- .../ClipTypes/ClipTypeRegistryTests.cs | 70 +--------- .../ClipTypes/RegistryMutatingCollection.cs | 14 -- .../SharpFM.Tests/TestAssemblyInitializer.cs | 4 - tests/SharpFM.Tests/TrustedEditPathTests.cs | 122 ++++++++++++++++++ 20 files changed, 293 insertions(+), 265 deletions(-) delete mode 100644 tests/SharpFM.Plugin.Tests/TestAssemblyInitializer.cs delete mode 100644 tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryBuiltInsTests.cs delete mode 100644 tests/SharpFM.Tests/ClipTypes/RegistryMutatingCollection.cs create mode 100644 tests/SharpFM.Tests/TrustedEditPathTests.cs diff --git a/src/SharpFM.Model/Clip.cs b/src/SharpFM.Model/Clip.cs index 4823958..058a7d8 100644 --- a/src/SharpFM.Model/Clip.cs +++ b/src/SharpFM.Model/Clip.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; @@ -113,6 +114,38 @@ public static Clip FromWireBytes(string name, string formatId, byte[] bytes) 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 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 index 051635d..e559bfa 100644 --- a/src/SharpFM.Model/ClipTypes/ClipStrategyHelpers.cs +++ b/src/SharpFM.Model/ClipTypes/ClipStrategyHelpers.cs @@ -1,6 +1,9 @@ +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; @@ -59,4 +62,27 @@ public static bool TryParseFmxmlsnippet(string xml, out XElement root, out Parse 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 index aeb45b6..bf30cc4 100644 --- a/src/SharpFM.Model/ClipTypes/ClipTypeRegistry.cs +++ b/src/SharpFM.Model/ClipTypes/ClipTypeRegistry.cs @@ -4,87 +4,41 @@ namespace SharpFM.Model.ClipTypes; /// -/// Static, explicitly-populated registry of -/// implementations keyed by Mac-XM* format id. Built-in strategies are -/// registered once at startup via ; tests -/// reset and re-register through . +/// 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 +/// . /// -/// -/// Reflection-based auto-discovery (the pattern used by StepRegistry) -/// is deliberately not used here — clip types are few, low-cardinality, and -/// explicit registration makes the bootstrapping order obvious. -/// public static class ClipTypeRegistry { - private static readonly object _gate = new(); - private static readonly Dictionary _strategies = new(); + public static IReadOnlyList BuiltIns { get; } = + [ + ScriptClipStrategy.Steps, + ScriptClipStrategy.Script, + TableClipStrategy.Table, + TableClipStrategy.Field, + LayoutClipStrategy.Instance, + ]; - /// Register a strategy. A duplicate overwrites the prior entry. - public static void Register(IClipTypeStrategy strategy) - { - lock (_gate) - { - _strategies[strategy.FormatId] = strategy; - } - } + 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) - { - lock (_gate) - { - return _strategies.TryGetValue(formatId, out var strategy) - ? strategy - : OpaqueClipStrategy.Instance; - } - } - - /// True if the given format id has a dedicated strategy registered. - public static bool IsRegistered(string formatId) - { - lock (_gate) - { - return _strategies.ContainsKey(formatId); - } - } - - /// All explicitly-registered strategies, in registration order. - public static IReadOnlyList All - { - get - { - lock (_gate) - { - return _strategies.Values.ToList(); - } - } - } - - /// - /// Register every built-in clip-type strategy. Called once at host startup; - /// idempotent thanks to 's overwrite semantics. Adding - /// a new Mac-XM* format is a single additional - /// call here. Opaque is the implicit fallback and is not registered. - /// - public static void RegisterBuiltIns() - { - Register(ScriptClipStrategy.Steps); - Register(ScriptClipStrategy.Script); - Register(TableClipStrategy.Table); - Register(TableClipStrategy.Field); - Register(LayoutClipStrategy.Instance); - } - - /// Clear the registry. Tests use this to isolate from production registrations. - internal static void Reset() - { - lock (_gate) - { - _strategies.Clear(); - } - } + 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/ScriptClipStrategy.cs b/src/SharpFM.Model/ClipTypes/ScriptClipStrategy.cs index 162149c..0c88d82 100644 --- a/src/SharpFM.Model/ClipTypes/ScriptClipStrategy.cs +++ b/src/SharpFM.Model/ClipTypes/ScriptClipStrategy.cs @@ -1,9 +1,7 @@ using System; using System.Collections.Generic; -using System.Xml.Linq; using SharpFM.Model.Parsing; using SharpFM.Model.Scripting; -using SharpFM.Model.Scripting.Steps; namespace SharpFM.Model.ClipTypes; @@ -52,22 +50,9 @@ public ClipParseResult Parse(string xml) ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "failed to parse script"); } - var output = XElement.Parse(script.ToXml()); - var diagnostics = new List(XmlRoundTripDiff.Compute(input, output)); - - var rawStepIndex = 0; - foreach (var step in script.Steps) - { - rawStepIndex++; - if (step is RawStep raw) - { - diagnostics.Add(new ClipParseDiagnostic( - ParseDiagnosticKind.UnknownStep, - ParseDiagnosticSeverity.Info, - $"/fmxmlsnippet/Step[{rawStepIndex}]", - $"step '{raw.Name}' is not modeled by the host; preserved verbatim as RawStep")); - } - } + var diagnostics = new List( + XmlRoundTripDiff.Compute(input, script.ToElement())); + diagnostics.AddRange(ClipStrategyHelpers.RawStepDiagnostics(script)); var report = diagnostics.Count == 0 ? ClipParseReport.Empty diff --git a/src/SharpFM.Model/ClipTypes/TableClipStrategy.cs b/src/SharpFM.Model/ClipTypes/TableClipStrategy.cs index 6dbacbe..1dab800 100644 --- a/src/SharpFM.Model/ClipTypes/TableClipStrategy.cs +++ b/src/SharpFM.Model/ClipTypes/TableClipStrategy.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Xml.Linq; using SharpFM.Model.Parsing; using SharpFM.Model.Schema; @@ -52,8 +51,8 @@ public ClipParseResult Parse(string xml) ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "failed to parse table"); } - var output = XElement.Parse(table.ToXml()); - var diagnostics = new List(XmlRoundTripDiff.Compute(input, output)); + var diagnostics = new List( + XmlRoundTripDiff.Compute(input, table.ToElement())); var report = diagnostics.Count == 0 ? ClipParseReport.Empty 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/App.axaml.cs b/src/SharpFM/App.axaml.cs index 6dd8cb2..dd02f7f 100644 --- a/src/SharpFM/App.axaml.cs +++ b/src/SharpFM/App.axaml.cs @@ -4,7 +4,6 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; -using SharpFM.Model.ClipTypes; using SharpFM.Model.Scripting.Registry; using SharpFM.Models; using SharpFM.Plugin; @@ -33,11 +32,6 @@ public override void OnFrameworkInitializationCompleted() // same initialization runs lazily on first registry access. StepRegistry.Initialize(); - // Clip-type strategies are explicitly registered (no reflection); do - // it once at startup so paste / file load / plugin push all see the - // built-in formats from the first request onward. - ClipTypeRegistry.RegisterBuiltIns(); - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.MainWindow = new MainWindow(); diff --git a/src/SharpFM/Editors/FallbackXmlEditor.cs b/src/SharpFM/Editors/FallbackXmlEditor.cs index f8f1b85..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; @@ -27,4 +28,6 @@ public FallbackXmlEditor(string? xml) } public string ToXml() => Document.Text; + + public ClipModel GetModel() => new OpaqueClipModel(Document.Text); } diff --git a/src/SharpFM/Editors/IClipEditor.cs b/src/SharpFM/Editors/IClipEditor.cs index 516075e..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; @@ -20,6 +21,15 @@ public interface IClipEditor /// string ToXml(); + /// + /// 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. + /// + ClipModel GetModel(); + /// /// True if the last produced output from an incomplete or errored parse. /// For example, a half-typed script step that can't fully round-trip. diff --git a/src/SharpFM/Editors/ScriptClipEditor.cs b/src/SharpFM/Editors/ScriptClipEditor.cs index bdac279..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; @@ -225,6 +226,24 @@ public string ToXml() return _script.ToXml(); } + public ClipModel GetModel() + { + // 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); + } + /// /// Reparse the current document into , preserving /// sealed steps via the anchor cache. For each logical step range in diff --git a/src/SharpFM/Editors/TableClipEditor.cs b/src/SharpFM/Editors/TableClipEditor.cs index 5a0d89c..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; @@ -35,6 +36,8 @@ public string ToXml() return ViewModel.Table.ToXml(); } + public ClipModel GetModel() => new TableClipModel(ViewModel.Table); + private void SubscribeToViewModel(TableEditorViewModel vm) { vm.Fields.CollectionChanged += OnCollectionChanged; diff --git a/src/SharpFM/ViewModels/ClipViewModel.cs b/src/SharpFM/ViewModels/ClipViewModel.cs index 354875b..aed31e5 100644 --- a/src/SharpFM/ViewModels/ClipViewModel.cs +++ b/src/SharpFM/ViewModels/ClipViewModel.cs @@ -127,7 +127,14 @@ private void OnEditorContentChanged(object? sender, EventArgs e) => // Dispatcher. In production this is invoked from Editor.ContentChanged. internal void HandleEditorContentChanged() { - Clip = _clip.WithXml(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)); diff --git a/tests/SharpFM.Plugin.Tests/TestAssemblyInitializer.cs b/tests/SharpFM.Plugin.Tests/TestAssemblyInitializer.cs deleted file mode 100644 index 60dd4ee..0000000 --- a/tests/SharpFM.Plugin.Tests/TestAssemblyInitializer.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Runtime.CompilerServices; -using SharpFM.Model.ClipTypes; - -namespace SharpFM.Plugin.Tests; - -internal static class TestAssemblyInitializer -{ - /// - /// Register built-in clip-type strategies once at assembly load time so any - /// test that constructs a sees them. - /// - [ModuleInitializer] - internal static void Initialize() - { - ClipTypeRegistry.RegisterBuiltIns(); - } -} diff --git a/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryBuiltInsTests.cs b/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryBuiltInsTests.cs deleted file mode 100644 index 8eb63b9..0000000 --- a/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryBuiltInsTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -using SharpFM.Model.ClipTypes; - -namespace SharpFM.Tests.ClipTypes; - -[Collection(RegistryMutatingCollection.Name)] -public class ClipTypeRegistryBuiltInsTests : IDisposable -{ - public ClipTypeRegistryBuiltInsTests() - { - ClipTypeRegistry.Reset(); - ClipTypeRegistry.RegisterBuiltIns(); - } - - public void Dispose() - { - ClipTypeRegistry.Reset(); - ClipTypeRegistry.RegisterBuiltIns(); - } - - [Theory] - [InlineData("Mac-XMSS", "Script Steps")] - [InlineData("Mac-XMSC", "Script")] - [InlineData("Mac-XMTB", "Table")] - [InlineData("Mac-XMFD", "Field")] - [InlineData("Mac-XML2", "Layout")] - public void RegisterBuiltIns_RegistersExpectedFormat(string formatId, string displayName) - { - Assert.True(ClipTypeRegistry.IsRegistered(formatId)); - Assert.Equal(displayName, ClipTypeRegistry.For(formatId).DisplayName); - } - - [Fact] - public void RegisterBuiltIns_DoesNotRegisterOpaque() - { - Assert.False(ClipTypeRegistry.IsRegistered(OpaqueClipStrategy.Instance.FormatId)); - } - - [Fact] - public void RegisterBuiltIns_IsIdempotent() - { - ClipTypeRegistry.RegisterBuiltIns(); - Assert.Equal(5, ClipTypeRegistry.All.Count); - } -} diff --git a/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryTests.cs b/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryTests.cs index 0065c4b..98e0fe1 100644 --- a/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryTests.cs +++ b/tests/SharpFM.Tests/ClipTypes/ClipTypeRegistryTests.cs @@ -1,80 +1,18 @@ using SharpFM.Model.ClipTypes; -using SharpFM.Model.Parsing; namespace SharpFM.Tests.ClipTypes; -[Collection(RegistryMutatingCollection.Name)] -public class ClipTypeRegistryTests : IDisposable +public class ClipTypeRegistryTests { - public ClipTypeRegistryTests() - { - ClipTypeRegistry.Reset(); - } - - public void Dispose() - { - ClipTypeRegistry.Reset(); - ClipTypeRegistry.RegisterBuiltIns(); - } - [Fact] public void For_UnknownFormat_FallsBackToOpaque() { - var strategy = ClipTypeRegistry.For("Mac-XMNOPE"); - Assert.Same(OpaqueClipStrategy.Instance, strategy); - } - - [Fact] - public void Register_ThenLookup_ReturnsRegistered() - { - var fake = new FakeStrategy("Mac-XMFAKE"); - ClipTypeRegistry.Register(fake); - - Assert.Same(fake, ClipTypeRegistry.For("Mac-XMFAKE")); + Assert.Same(OpaqueClipStrategy.Instance, ClipTypeRegistry.For("Mac-XMNOPE")); } [Fact] - public void Register_DuplicateId_OverwritesPrior() - { - var first = new FakeStrategy("Mac-XMFAKE"); - var second = new FakeStrategy("Mac-XMFAKE"); - ClipTypeRegistry.Register(first); - ClipTypeRegistry.Register(second); - - Assert.Same(second, ClipTypeRegistry.For("Mac-XMFAKE")); - } - - [Fact] - public void IsRegistered_ReflectsExplicitRegistration() - { - Assert.False(ClipTypeRegistry.IsRegistered("Mac-XMFAKE")); - ClipTypeRegistry.Register(new FakeStrategy("Mac-XMFAKE")); - Assert.True(ClipTypeRegistry.IsRegistered("Mac-XMFAKE")); - } - - [Fact] - public void All_DoesNotIncludeOpaqueFallback() - { - Assert.Empty(ClipTypeRegistry.All); - ClipTypeRegistry.Register(new FakeStrategy("Mac-XMFAKE")); - Assert.Single(ClipTypeRegistry.All); - } - - [Fact] - public void Reset_ClearsAllRegistrations() - { - ClipTypeRegistry.Register(new FakeStrategy("Mac-XMFAKE")); - ClipTypeRegistry.Reset(); - Assert.Empty(ClipTypeRegistry.All); - Assert.Same(OpaqueClipStrategy.Instance, ClipTypeRegistry.For("Mac-XMFAKE")); - } - - private sealed class FakeStrategy(string formatId) : IClipTypeStrategy + public void For_KnownFormat_ReturnsBuiltInStrategy() { - public string FormatId { get; } = formatId; - public string DisplayName => "Fake"; - public ClipParseResult Parse(string xml) => - new ParseSuccess(new OpaqueClipModel(xml), ClipParseReport.Empty); - public string DefaultXml(string clipName) => ""; + Assert.Same(ScriptClipStrategy.Steps, ClipTypeRegistry.For("Mac-XMSS")); } } diff --git a/tests/SharpFM.Tests/ClipTypes/RegistryMutatingCollection.cs b/tests/SharpFM.Tests/ClipTypes/RegistryMutatingCollection.cs deleted file mode 100644 index 1a758ab..0000000 --- a/tests/SharpFM.Tests/ClipTypes/RegistryMutatingCollection.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Xunit; - -namespace SharpFM.Tests.ClipTypes; - -/// -/// Test collection for classes that mutate -/// (e.g. Reset). Marked non-parallel so they don't race with tests in other classes -/// that assume the registry is populated. -/// -[CollectionDefinition(Name, DisableParallelization = true)] -public sealed class RegistryMutatingCollection -{ - public const string Name = "RegistryMutating"; -} diff --git a/tests/SharpFM.Tests/TestAssemblyInitializer.cs b/tests/SharpFM.Tests/TestAssemblyInitializer.cs index f16ad2b..efede46 100644 --- a/tests/SharpFM.Tests/TestAssemblyInitializer.cs +++ b/tests/SharpFM.Tests/TestAssemblyInitializer.cs @@ -1,5 +1,4 @@ using System.Runtime.CompilerServices; -using SharpFM.Model.ClipTypes; namespace SharpFM.Tests; @@ -10,13 +9,10 @@ internal static class TestAssemblyInitializer /// installs . /// Without this, tests that touch only SharpFM.Model types in isolation /// would render steps via the generic path and miss the canonical formatting. - /// Also registers the built-in clip-type strategies so any test that - /// constructs a sees them. /// [ModuleInitializer] internal static void Initialize() { _ = typeof(SharpFM.Scripting.ScriptTextParser).FullName; - ClipTypeRegistry.RegisterBuiltIns(); } } 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); + } +}