From 7f72163091712e9dd8d69693a0ccd13d3acf5958 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sat, 16 May 2026 22:31:22 -0500 Subject: [PATCH 1/5] feat: add semantic-validator framework --- src/SharpFM.Model/Clip.cs | 38 ++++++-- src/SharpFM.Model/Parsing/ClipParseReport.cs | 19 +++- .../Validation/IClipSemanticValidator.cs | 28 ++++++ .../Validation/SemanticValidatorRegistry.cs | 90 +++++++++++++++++++ tests/SharpFM.Tests/ClipTests.cs | 62 +++++++++++++ .../Parsing/ClipParseReportTests.cs | 39 ++++++++ .../SemanticValidatorRegistryTests.cs | 80 +++++++++++++++++ 7 files changed, 344 insertions(+), 12 deletions(-) create mode 100644 src/SharpFM.Model/Validation/IClipSemanticValidator.cs create mode 100644 src/SharpFM.Model/Validation/SemanticValidatorRegistry.cs create mode 100644 tests/SharpFM.Tests/Validation/SemanticValidatorRegistryTests.cs diff --git a/src/SharpFM.Model/Clip.cs b/src/SharpFM.Model/Clip.cs index 058a7d8..8c340ff 100644 --- a/src/SharpFM.Model/Clip.cs +++ b/src/SharpFM.Model/Clip.cs @@ -6,6 +6,7 @@ using SharpFM.Model.ClipTypes; using SharpFM.Model.Parsing; using SharpFM.Model.Scripting; +using SharpFM.Model.Validation; namespace SharpFM.Model; @@ -99,7 +100,23 @@ public static Clip FromXml(string name, string formatId, string xml) name, formatId, canonical, - () => ClipTypeRegistry.For(formatId).Parse(canonical)); + () => WithSemanticDiagnostics(formatId, ClipTypeRegistry.For(formatId).Parse(canonical))); + } + + private static ClipParseResult WithSemanticDiagnostics(string formatId, ClipParseResult result) + { + if (result is not ParseSuccess success) + { + return result; + } + + var semantic = SemanticValidatorRegistry.Run(formatId, success.Model); + if (semantic.Count == 0) + { + return success; + } + + return success with { Report = success.Report with { SemanticDiagnostics = semantic } }; } /// @@ -130,20 +147,23 @@ public static Clip FromWireBytes(string name, string formatId, byte[] bytes) /// public static Clip FromEditor(string name, string formatId, string xml, ClipModel model) { - var report = ReportForEditorModel(model); + var report = ReportForEditorModel(formatId, model); return new Clip(name, formatId, xml, new ParseSuccess(model, report)); } - private static ClipParseReport ReportForEditorModel(ClipModel model) + private static ClipParseReport ReportForEditorModel(string formatId, ClipModel model) { - if (model is ScriptClipModel script) + IReadOnlyList structural = model is ScriptClipModel script + ? ClipStrategyHelpers.RawStepDiagnostics(script.Script).ToList() + : Array.Empty(); + var semantic = SemanticValidatorRegistry.Run(formatId, model); + + if (structural.Count == 0 && semantic.Count == 0) { - var diagnostics = ClipStrategyHelpers.RawStepDiagnostics(script.Script).ToList(); - return diagnostics.Count == 0 - ? ClipParseReport.Empty - : new ClipParseReport(diagnostics); + return ClipParseReport.Empty; } - return ClipParseReport.Empty; + + return new ClipParseReport(structural) { SemanticDiagnostics = semantic }; } /// diff --git a/src/SharpFM.Model/Parsing/ClipParseReport.cs b/src/SharpFM.Model/Parsing/ClipParseReport.cs index b3e083a..09aca5a 100644 --- a/src/SharpFM.Model/Parsing/ClipParseReport.cs +++ b/src/SharpFM.Model/Parsing/ClipParseReport.cs @@ -4,14 +4,27 @@ namespace SharpFM.Model.Parsing; /// /// Aggregate of every produced by parsing one -/// clip. Lossless when empty; consumers check rather -/// than inspecting the collection directly. +/// clip. Structural describe round-trip fidelity loss +/// detected by ; +/// describe domain-rule violations emitted by validators registered in +/// . The two axes are kept +/// distinct so consumers (status bar, tree glyph, plugins) can render structural +/// fidelity and semantic correctness independently. /// 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. + /// + /// Domain-rule violations from semantic validators. Defaults to empty so + /// the existing positional constructor stays source-compatible. + /// + public IReadOnlyList SemanticDiagnostics { get; init; } = []; + + /// True if the parse produced no structural diagnostics. public bool IsLossless => Diagnostics.Count == 0; + + /// True if no semantic validators flagged the parsed model. + public bool IsSemanticallyValid => SemanticDiagnostics.Count == 0; } diff --git a/src/SharpFM.Model/Validation/IClipSemanticValidator.cs b/src/SharpFM.Model/Validation/IClipSemanticValidator.cs new file mode 100644 index 0000000..da587eb --- /dev/null +++ b/src/SharpFM.Model/Validation/IClipSemanticValidator.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using SharpFM.Model.Parsing; + +namespace SharpFM.Model.Validation; + +/// +/// Inspects a parsed and emits diagnostics for +/// domain-rule violations that cannot detect — +/// e.g. FileMaker variable-name conventions or calculation-syntax errors. +/// Validators are pure, must not throw, and run on every successful parse. +/// +public interface IClipSemanticValidator +{ + /// + /// Sentinel entry that opts the validator into + /// every clip type. + /// + public const string AllFormats = "*"; + + /// + /// Format ids this validator applies to (e.g. "Mac-XMSS"). Use + /// to opt into every clip type. + /// + IReadOnlyCollection FormatIds { get; } + + /// Return any domain-rule violations found in . + IReadOnlyList Validate(ClipModel model); +} diff --git a/src/SharpFM.Model/Validation/SemanticValidatorRegistry.cs b/src/SharpFM.Model/Validation/SemanticValidatorRegistry.cs new file mode 100644 index 0000000..d11cdda --- /dev/null +++ b/src/SharpFM.Model/Validation/SemanticValidatorRegistry.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using SharpFM.Model.Parsing; + +namespace SharpFM.Model.Validation; + +/// +/// Static registry of s the host runs after +/// every successful clip parse. is the production set; +/// the registry deliberately ships empty so domain rules can land one at a time +/// without bundling the framework PR. +/// +public static class SemanticValidatorRegistry +{ + [ThreadStatic] + private static IReadOnlyList? _overrideForTest; + + /// Validators that run on every parse in production. + public static IReadOnlyList BuiltIns { get; } = []; + + /// + /// Run the built-in validators for against + /// and return the combined diagnostics. Returns + /// a shared empty list when nothing matches. + /// + public static IReadOnlyList Run(string formatId, ClipModel model) => + Run(formatId, model, _overrideForTest ?? BuiltIns); + + /// + /// Per-thread override of for integration tests + /// that need to assert validators actually run through the parse pipeline. + /// Dispose to restore. Production code never sets this. + /// + internal static IDisposable OverrideForTest(IReadOnlyList validators) + { + _overrideForTest = validators; + return new Resetter(); + } + + private sealed class Resetter : IDisposable + { + public void Dispose() => _overrideForTest = null; + } + + /// + /// Same as but with an explicit + /// validator set — exposed for tests so the public + /// stays static and immutable. + /// + internal static IReadOnlyList Run( + string formatId, + ClipModel model, + IReadOnlyList validators) + { + if (validators.Count == 0) + { + return Array.Empty(); + } + + List? acc = null; + foreach (var v in validators) + { + if (!Applies(v, formatId)) + { + continue; + } + + var diags = v.Validate(model); + if (diags.Count == 0) + { + continue; + } + + (acc ??= []).AddRange(diags); + } + return (IReadOnlyList?)acc ?? Array.Empty(); + } + + private static bool Applies(IClipSemanticValidator validator, string formatId) + { + foreach (var id in validator.FormatIds) + { + if (id == IClipSemanticValidator.AllFormats || id == formatId) + { + return true; + } + } + return false; + } +} diff --git a/tests/SharpFM.Tests/ClipTests.cs b/tests/SharpFM.Tests/ClipTests.cs index 4376896..45b6b94 100644 --- a/tests/SharpFM.Tests/ClipTests.cs +++ b/tests/SharpFM.Tests/ClipTests.cs @@ -1,6 +1,9 @@ +using System.Collections.Generic; using System.Text; using SharpFM.Model; using SharpFM.Model.Parsing; +using SharpFM.Model.Scripting; +using SharpFM.Model.Validation; namespace SharpFM.Tests; @@ -103,4 +106,63 @@ public void Rename_ReusesParsedResult() Assert.Equal("Y", renamed.Name); Assert.Equal(original.Xml, renamed.Xml); } + + [Fact] + public void FromXml_RunsSemanticValidators_AndFoldsDiagnosticsIntoReport() + { + var fake = new SeenItValidator(); + using var _ = SemanticValidatorRegistry.OverrideForTest([fake]); + + var clip = Clip.FromXml("X", "Mac-XMUNKNOWN", ""); + var report = clip.Parsed.Report; + + Assert.True(fake.Called); + Assert.Single(report.SemanticDiagnostics); + Assert.True(report.IsLossless); + Assert.False(report.IsSemanticallyValid); + } + + [Fact] + public void FromEditor_RunsSemanticValidators_AndFoldsDiagnosticsIntoReport() + { + var fake = new SeenItValidator(); + using var _ = SemanticValidatorRegistry.OverrideForTest([fake]); + + var clip = Clip.FromEditor("X", "Mac-XMSS", "", new ScriptClipModel(new FmScript([]))); + var report = clip.Parsed.Report; + + Assert.True(fake.Called); + Assert.Single(report.SemanticDiagnostics); + } + + [Fact] + public void FromXml_ParseFailure_DoesNotInvokeSemanticValidators() + { + var fake = new SeenItValidator(); + using var _ = SemanticValidatorRegistry.OverrideForTest([fake]); + + var clip = Clip.FromXml("X", "Mac-XMUNKNOWN", ""); + + Assert.IsType(clip.Parsed); + Assert.False(fake.Called); + } + + private sealed class SeenItValidator : IClipSemanticValidator + { + public bool Called { get; private set; } + public IReadOnlyCollection FormatIds => [IClipSemanticValidator.AllFormats]; + + public IReadOnlyList Validate(ClipModel model) + { + Called = true; + return + [ + new ClipParseDiagnostic( + ParseDiagnosticKind.UnknownStep, + ParseDiagnosticSeverity.Info, + "/test", + "seen"), + ]; + } + } } diff --git a/tests/SharpFM.Tests/Parsing/ClipParseReportTests.cs b/tests/SharpFM.Tests/Parsing/ClipParseReportTests.cs index 79f0a22..ec6752d 100644 --- a/tests/SharpFM.Tests/Parsing/ClipParseReportTests.cs +++ b/tests/SharpFM.Tests/Parsing/ClipParseReportTests.cs @@ -57,4 +57,43 @@ public void Report_PreservesDiagnosticOrder() Assert.Equal(first, report.Diagnostics[0]); Assert.Equal(second, report.Diagnostics[1]); } + + [Fact] + public void SemanticDiagnostics_DefaultsToEmpty() + { + var report = new ClipParseReport([]); + + Assert.Empty(report.SemanticDiagnostics); + } + + [Fact] + public void Empty_SemanticDiagnostics_IsEmpty() + { + Assert.Empty(ClipParseReport.Empty.SemanticDiagnostics); + } + + [Fact] + public void IsSemanticallyValid_TrueWhenSemanticDiagnosticsEmpty() + { + Assert.True(ClipParseReport.Empty.IsSemanticallyValid); + } + + [Fact] + public void IsSemanticallyValid_FalseWhenAnySemanticDiagnostic() + { + var report = new ClipParseReport([]) + { + SemanticDiagnostics = + [ + new ClipParseDiagnostic( + ParseDiagnosticKind.UnknownStep, + ParseDiagnosticSeverity.Warning, + "/fmxmlsnippet/Step[1]/Name", + "variable name missing $ prefix"), + ], + }; + + Assert.False(report.IsSemanticallyValid); + Assert.True(report.IsLossless); + } } diff --git a/tests/SharpFM.Tests/Validation/SemanticValidatorRegistryTests.cs b/tests/SharpFM.Tests/Validation/SemanticValidatorRegistryTests.cs new file mode 100644 index 0000000..0ecfe58 --- /dev/null +++ b/tests/SharpFM.Tests/Validation/SemanticValidatorRegistryTests.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using SharpFM.Model.Parsing; +using SharpFM.Model.Scripting; +using SharpFM.Model.Validation; + +namespace SharpFM.Tests.Validation; + +public class SemanticValidatorRegistryTests +{ + [Fact] + public void BuiltIns_IsEmpty() + { + // No concrete rules ship in this PR; the framework is the contract. + Assert.Empty(SemanticValidatorRegistry.BuiltIns); + } + + [Fact] + public void Run_WithEmptyRegistry_ReturnsEmpty() + { + var diags = SemanticValidatorRegistry.Run( + "Mac-XMSS", + new ScriptClipModel(new FmScript([]))); + + Assert.Empty(diags); + } + + [Fact] + public void Run_DispatchesByFormatId() + { + var scriptOnly = new RecordingValidator("script-only", ["Mac-XMSS"]); + var tableOnly = new RecordingValidator("table-only", ["Mac-XMTB"]); + var wildcard = new RecordingValidator("wildcard", [IClipSemanticValidator.AllFormats]); + var validators = new IClipSemanticValidator[] { scriptOnly, tableOnly, wildcard }; + var script = new ScriptClipModel(new FmScript([])); + + var diags = SemanticValidatorRegistry.Run("Mac-XMSS", script, validators); + + Assert.Equal(2, diags.Count); + Assert.True(scriptOnly.Invoked); + Assert.False(tableOnly.Invoked); + Assert.True(wildcard.Invoked); + } + + [Fact] + public void Run_SkipsValidatorsThatReturnEmpty() + { + var silent = new RecordingValidator("silent", [IClipSemanticValidator.AllFormats], emit: false); + var loud = new RecordingValidator("loud", [IClipSemanticValidator.AllFormats]); + + var diags = SemanticValidatorRegistry.Run( + "Mac-XMSS", + new ScriptClipModel(new FmScript([])), + [silent, loud]); + + Assert.Single(diags); + } + + private sealed class RecordingValidator( + string label, + IReadOnlyCollection formats, + bool emit = true) : IClipSemanticValidator + { + public bool Invoked { get; private set; } + public IReadOnlyCollection FormatIds => formats; + + public IReadOnlyList Validate(ClipModel model) + { + Invoked = true; + if (!emit) return []; + return + [ + new ClipParseDiagnostic( + ParseDiagnosticKind.UnknownStep, + ParseDiagnosticSeverity.Info, + "/test", + label), + ]; + } + } +} From b8297ed9fb0da8643f15398a95507b017591486b Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sat, 16 May 2026 22:33:49 -0500 Subject: [PATCH 2/5] feat: expose parse-fidelity report on ClipData --- src/SharpFM.Model/ClipData.cs | 9 +++ src/SharpFM/Services/PluginHost.cs | 27 ++++--- tests/SharpFM.Plugin.Tests/PluginHostTests.cs | 73 +++++++++++++++++++ 3 files changed, 97 insertions(+), 12 deletions(-) diff --git a/src/SharpFM.Model/ClipData.cs b/src/SharpFM.Model/ClipData.cs index 4b2d307..65f0e6a 100644 --- a/src/SharpFM.Model/ClipData.cs +++ b/src/SharpFM.Model/ClipData.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using SharpFM.Model.Parsing; namespace SharpFM.Model; @@ -18,4 +19,12 @@ public record ClipData(string Name, string ClipType, string Xml) /// (subdirectories, record columns, URL path, etc.). /// public IReadOnlyList FolderPath { get; init; } = []; + + /// + /// Parse-fidelity report attached when the host produces this snapshot. + /// Repository-loaded clips that have never been parsed default to + /// ; the host populates the real report + /// when bridging from a live aggregate. + /// + public ClipParseReport ParseReport { get; init; } = ClipParseReport.Empty; } diff --git a/src/SharpFM/Services/PluginHost.cs b/src/SharpFM/Services/PluginHost.cs index 3c86a25..fc421ae 100644 --- a/src/SharpFM/Services/PluginHost.cs +++ b/src/SharpFM/Services/PluginHost.cs @@ -60,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.Xml); + return ToClipData(clip); } } @@ -69,9 +69,10 @@ public ClipData? SelectedClip public event EventHandler? ClipCollectionChanged; public IReadOnlyList AllClips => - _viewModel.FileMakerClips - .Select(c => new ClipData(c.Clip.Name, c.ClipType, c.Clip.Xml)) - .ToList(); + _viewModel.FileMakerClips.Select(ToClipData).ToList(); + + private static ClipData ToClipData(ClipViewModel clip) => + new(clip.Clip.Name, clip.ClipType, clip.Clip.Xml) { ParseReport = clip.ParseReport }; public ILogger CreateLogger(string categoryName) => _loggerFactory.CreateLogger(categoryName); @@ -86,15 +87,16 @@ public void UpdateSelectedClipXml(string xml, string originPluginId) => clip.Replace(xml); - var info = new ClipData(clip.Clip.Name, clip.ClipType, clip.Clip.Xml); - ClipContentChanged?.Invoke(this, new ClipContentChangedArgs(info, originPluginId, false)); + ClipContentChanged?.Invoke( + this, + new ClipContentChangedArgs(ToClipData(clip), originPluginId, false)); }); public ClipData? GetClip(string clipName) { var clip = FindClipByName(clipName); if (clip is null) return null; - return new ClipData(clip.Clip.Name, clip.ClipType, clip.Clip.Xml); + return ToClipData(clip); } public void UpdateClipXml(string clipName, string xml, string originPluginId) => @@ -105,8 +107,9 @@ public void UpdateClipXml(string clipName, string xml, string originPluginId) => clip.Replace(xml); - var info = new ClipData(clip.Clip.Name, clip.ClipType, clip.Clip.Xml); - ClipContentChanged?.Invoke(this, new ClipContentChangedArgs(info, originPluginId, false)); + ClipContentChanged?.Invoke( + this, + new ClipContentChangedArgs(ToClipData(clip), originPluginId, false)); }); public void CreateClip(string name, string clipType, string? xml = null) @@ -189,8 +192,8 @@ 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.Xml); - var isPartial = clip.Editor.IsPartial; - ClipContentChanged?.Invoke(this, new ClipContentChangedArgs(info, "editor", isPartial)); + ClipContentChanged?.Invoke( + this, + new ClipContentChangedArgs(ToClipData(clip), "editor", clip.Editor.IsPartial)); } } diff --git a/tests/SharpFM.Plugin.Tests/PluginHostTests.cs b/tests/SharpFM.Plugin.Tests/PluginHostTests.cs index 6d42d03..1796b78 100644 --- a/tests/SharpFM.Plugin.Tests/PluginHostTests.cs +++ b/tests/SharpFM.Plugin.Tests/PluginHostTests.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using SharpFM.Model; +using SharpFM.Model.Parsing; using SharpFM.Plugin; using SharpFM.Services; using SharpFM.ViewModels; @@ -185,4 +186,76 @@ public void ShowStatus_SetsViewModelStatus() Assert.Equal("Plugin says hello", vm.StatusMessage); } + + // --- Item 1: ClipData.ParseReport --- + + [Fact] + public void SelectedClip_ExposesParseReport_FromClip() + { + var vm = CreateVm(); + var host = new PluginHost(vm, NullLoggerFactory.Instance); + + vm.NewScriptCommand(); + + Assert.NotNull(host.SelectedClip); + Assert.NotNull(host.SelectedClip!.ParseReport); + Assert.True(host.SelectedClip.ParseReport.IsLossless); + } + + [Fact] + public void SelectedClip_ParseReport_SurfacesDiagnostics() + { + var vm = CreateVm(); + var host = new PluginHost(vm, NullLoggerFactory.Instance); + vm.NewScriptCommand(); + + host.UpdateSelectedClipXml( + "", + "test-plugin"); + + var report = host.SelectedClip!.ParseReport; + Assert.False(report.IsLossless); + Assert.NotEmpty(report.Diagnostics); + } + + [Fact] + public void AllClips_PopulatesParseReport() + { + var vm = CreateVm(); + var host = new PluginHost(vm, NullLoggerFactory.Instance); + vm.NewScriptCommand(); + vm.NewTableCommand(); + + Assert.All(host.AllClips, c => Assert.NotNull(c.ParseReport)); + } + + [Fact] + public void GetClip_PopulatesParseReport() + { + var vm = CreateVm(); + var host = new PluginHost(vm, NullLoggerFactory.Instance); + vm.NewScriptCommand(); + + var clip = host.GetClip("New Script"); + + Assert.NotNull(clip); + Assert.NotNull(clip!.ParseReport); + } + + [Fact] + public void ClipContentChanged_FromUpdate_CarriesParseReport() + { + var vm = CreateVm(); + var host = new PluginHost(vm, NullLoggerFactory.Instance); + vm.NewScriptCommand(); + ClipContentChangedArgs? args = null; + host.ClipContentChanged += (_, e) => args = e; + + host.UpdateSelectedClipXml( + "", + "test-plugin"); + + Assert.NotNull(args); + Assert.NotNull(args!.Clip.ParseReport); + } } From c2345ec0e6acd0632f2e55f8ed824afb33845b8e Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sat, 16 May 2026 22:37:33 -0500 Subject: [PATCH 3/5] feat: add IPluginHost.ValidateClipXml for pre-flight parse-fidelity checks --- src/SharpFM.Plugin/IPluginHost.cs | 10 ++++ src/SharpFM/Services/PluginHost.cs | 4 ++ src/SharpFM/Services/PluginUIHost.cs | 1 + tests/SharpFM.Plugin.Tests/PluginHostTests.cs | 52 +++++++++++++++++++ .../PluginServiceTests.cs | 1 + .../XmlViewerPluginTests.cs | 1 + .../ViewModels/MainWindowViewModelTests.cs | 1 + 7 files changed, 70 insertions(+) diff --git a/src/SharpFM.Plugin/IPluginHost.cs b/src/SharpFM.Plugin/IPluginHost.cs index 8a8e4fe..7998a02 100644 --- a/src/SharpFM.Plugin/IPluginHost.cs +++ b/src/SharpFM.Plugin/IPluginHost.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using SharpFM.Model; +using SharpFM.Model.Parsing; using SharpFM.Model.Schema; using SharpFM.Model.Scripting; @@ -63,6 +64,15 @@ public interface IPluginHost /// ClipData? GetClip(string clipName); + /// + /// Parse with the strategy registered for + /// and return the resulting fidelity report. + /// Pure — does not load or mutate any clip in the host. Lets plugins + /// pre-flight XML before pushing it through + /// or . + /// + ClipParseReport ValidateClipXml(string clipType, string xml); + /// /// Replace the XML content of any loaded clip by name. /// If the clip is currently selected, syncs the change to the editor. diff --git a/src/SharpFM/Services/PluginHost.cs b/src/SharpFM/Services/PluginHost.cs index fc421ae..4d24bc5 100644 --- a/src/SharpFM/Services/PluginHost.cs +++ b/src/SharpFM/Services/PluginHost.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; using SharpFM.Model; using SharpFM.Model.ClipTypes; +using SharpFM.Model.Parsing; using SharpFM.Model.Schema; using SharpFM.Model.Scripting; using SharpFM.Plugin; @@ -99,6 +100,9 @@ public void UpdateSelectedClipXml(string xml, string originPluginId) => return ToClipData(clip); } + public ClipParseReport ValidateClipXml(string clipType, string xml) => + ClipTypeRegistry.For(clipType).Parse(xml).Report; + public void UpdateClipXml(string clipName, string xml, string originPluginId) => EnsureUiThread(() => { diff --git a/src/SharpFM/Services/PluginUIHost.cs b/src/SharpFM/Services/PluginUIHost.cs index 67c7e0a..3e6bc97 100644 --- a/src/SharpFM/Services/PluginUIHost.cs +++ b/src/SharpFM/Services/PluginUIHost.cs @@ -88,6 +88,7 @@ public event System.EventHandler? ClipCollectionChanged } public void ShowStatus(string message) => _baseHost.ShowStatus(message); public Model.ClipData? GetClip(string clipName) => _baseHost.GetClip(clipName); + public Model.Parsing.ClipParseReport ValidateClipXml(string clipType, string xml) => _baseHost.ValidateClipXml(clipType, xml); public void UpdateClipXml(string clipName, string xml, string originPluginId) => _baseHost.UpdateClipXml(clipName, xml, originPluginId); public void CreateClip(string name, string clipType, string? xml = null) => _baseHost.CreateClip(name, clipType, xml); public bool RemoveClip(string clipName) => _baseHost.RemoveClip(clipName); diff --git a/tests/SharpFM.Plugin.Tests/PluginHostTests.cs b/tests/SharpFM.Plugin.Tests/PluginHostTests.cs index 1796b78..7f6b780 100644 --- a/tests/SharpFM.Plugin.Tests/PluginHostTests.cs +++ b/tests/SharpFM.Plugin.Tests/PluginHostTests.cs @@ -258,4 +258,56 @@ public void ClipContentChanged_FromUpdate_CarriesParseReport() Assert.NotNull(args); Assert.NotNull(args!.Clip.ParseReport); } + + // --- Item 2: ValidateClipXml --- + + [Fact] + public void ValidateClipXml_Lossless_OnCleanScriptStepsXml() + { + var vm = CreateVm(); + var host = new PluginHost(vm, NullLoggerFactory.Instance); + + var report = host.ValidateClipXml( + "Mac-XMSS", + ""); + + Assert.True(report.IsLossless); + } + + [Fact] + public void ValidateClipXml_ReportsXmlMalformed_OnGarbage() + { + var vm = CreateVm(); + var host = new PluginHost(vm, NullLoggerFactory.Instance); + + var report = host.ValidateClipXml("Mac-XMSS", ""); + + Assert.False(report.IsLossless); + Assert.Contains( + report.Diagnostics, + d => d.Kind == ParseDiagnosticKind.XmlMalformed); + } + + [Fact] + public void ValidateClipXml_FallsBackToOpaque_OnUnknownType() + { + var vm = CreateVm(); + var host = new PluginHost(vm, NullLoggerFactory.Instance); + + var report = host.ValidateClipXml("Mac-XMUNKNOWN", ""); + + Assert.True(report.IsLossless); + } + + [Fact] + public void ValidateClipXml_DoesNotMutateClipCollection() + { + var vm = CreateVm(); + var host = new PluginHost(vm, NullLoggerFactory.Instance); + var beforeCount = host.AllClips.Count; + + host.ValidateClipXml("Mac-XMSS", ""); + + Assert.Equal(beforeCount, host.AllClips.Count); + } } diff --git a/tests/SharpFM.Plugin.Tests/PluginServiceTests.cs b/tests/SharpFM.Plugin.Tests/PluginServiceTests.cs index fd18ac9..6ae8ec7 100644 --- a/tests/SharpFM.Plugin.Tests/PluginServiceTests.cs +++ b/tests/SharpFM.Plugin.Tests/PluginServiceTests.cs @@ -18,6 +18,7 @@ public class MockPluginHost : IPluginHost public event EventHandler? ClipCollectionChanged; public ILogger CreateLogger(string categoryName) => NullLogger.Instance; public ClipData? GetClip(string clipName) => AllClips.FirstOrDefault(c => c.Name.Equals(clipName, StringComparison.OrdinalIgnoreCase)); + public SharpFM.Model.Parsing.ClipParseReport ValidateClipXml(string clipType, string xml) => SharpFM.Model.Parsing.ClipParseReport.Empty; public void UpdateClipXml(string clipName, string xml, string originPluginId) { } public void CreateClip(string name, string clipType, string? xml = null) { } public bool RemoveClip(string clipName) => false; diff --git a/tests/SharpFM.Plugin.Tests/XmlViewerPluginTests.cs b/tests/SharpFM.Plugin.Tests/XmlViewerPluginTests.cs index b350db6..7b36187 100644 --- a/tests/SharpFM.Plugin.Tests/XmlViewerPluginTests.cs +++ b/tests/SharpFM.Plugin.Tests/XmlViewerPluginTests.cs @@ -152,6 +152,7 @@ public void UpdateSelectedClipXml(string xml, string originPluginId) public Microsoft.Extensions.Logging.ILogger CreateLogger(string categoryName) => Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; public ClipData? GetClip(string clipName) => AllClips.FirstOrDefault(c => c.Name.Equals(clipName, StringComparison.OrdinalIgnoreCase)); + public SharpFM.Model.Parsing.ClipParseReport ValidateClipXml(string clipType, string xml) => SharpFM.Model.Parsing.ClipParseReport.Empty; public void CreateClip(string name, string clipType, string? xml = null) { } public bool RemoveClip(string clipName) => false; public void UpdateClipXml(string clipName, string xml, string originPluginId) { LastUpdatedXml = xml; LastOriginPluginId = originPluginId; } diff --git a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs index 00db8c5..6ef159a 100644 --- a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs +++ b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs @@ -282,6 +282,7 @@ private class MockPluginHost : IPluginHost #pragma warning restore CS0067 public ILogger CreateLogger(string categoryName) => NullLogger.Instance; public Model.ClipData? GetClip(string clipName) => null; + public Model.Parsing.ClipParseReport ValidateClipXml(string clipType, string xml) => Model.Parsing.ClipParseReport.Empty; public void UpdateClipXml(string clipName, string xml, string originPluginId) { } public void CreateClip(string name, string clipType, string? xml = null) { } public bool RemoveClip(string clipName) => false; From adf41fd5ac345066cff69d8438f61944f0bedc83 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Sat, 16 May 2026 22:52:19 -0500 Subject: [PATCH 4/5] feat: add rename command for selected clip --- src/SharpFM/App.axaml.cs | 7 +- src/SharpFM/Dialogs/IInputPrompt.cs | 17 ++ src/SharpFM/Dialogs/InputDialog.axaml | 43 +++ src/SharpFM/Dialogs/InputDialog.axaml.cs | 55 ++++ src/SharpFM/Dialogs/NullInputPrompt.cs | 15 ++ src/SharpFM/Dialogs/WindowInputPrompt.cs | 16 ++ src/SharpFM/MainWindow.axaml | 247 +++++++++--------- src/SharpFM/Services/PluginHost.cs | 13 +- src/SharpFM/ViewModels/ClipViewModel.cs | 3 + src/SharpFM/ViewModels/MainWindowViewModel.cs | 29 +- .../ViewModels/ClipViewModelTests.cs | 39 +++ .../ViewModels/MainWindowViewModelTests.cs | 71 ++++- 12 files changed, 422 insertions(+), 133 deletions(-) create mode 100644 src/SharpFM/Dialogs/IInputPrompt.cs create mode 100644 src/SharpFM/Dialogs/InputDialog.axaml create mode 100644 src/SharpFM/Dialogs/InputDialog.axaml.cs create mode 100644 src/SharpFM/Dialogs/NullInputPrompt.cs create mode 100644 src/SharpFM/Dialogs/WindowInputPrompt.cs diff --git a/src/SharpFM/App.axaml.cs b/src/SharpFM/App.axaml.cs index dd02f7f..211c402 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.Dialogs; using SharpFM.Model.Scripting.Registry; using SharpFM.Models; using SharpFM.Plugin; @@ -41,13 +42,15 @@ public override void OnFrameworkInitializationCompleted() services.AddSingleton(x => new ClipboardService(desktop.MainWindow)); Services = services.BuildServiceProvider(); + var inputPrompt = new WindowInputPrompt(desktop.MainWindow); var viewModel = new MainWindowViewModel( logger, Services.GetRequiredService(), - Services.GetRequiredService()); + Services.GetRequiredService(), + inputPrompt); // Load plugins - var pluginHost = new PluginHost(viewModel, loggerFactory); + var pluginHost = new PluginHost(viewModel, loggerFactory, inputPrompt); var pluginUIHost = new PluginUIHost(pluginHost); var pluginConfigService = new PluginConfigService(logger); var pluginService = new PluginService(logger, pluginConfigService); diff --git a/src/SharpFM/Dialogs/IInputPrompt.cs b/src/SharpFM/Dialogs/IInputPrompt.cs new file mode 100644 index 0000000..a9cc024 --- /dev/null +++ b/src/SharpFM/Dialogs/IInputPrompt.cs @@ -0,0 +1,17 @@ +using System.Threading.Tasks; + +namespace SharpFM.Dialogs; + +/// +/// Host-supplied input prompt used by view-models (and the plugin host) to +/// ask the user for a single line of text. Abstracted so view-model tests +/// can substitute a fake without standing up an Avalonia window. +/// +public interface IInputPrompt +{ + /// + /// Show a modal prompt and return the user's input, or null if + /// they cancelled. + /// + Task PromptAsync(string title, string prompt, string defaultValue); +} diff --git a/src/SharpFM/Dialogs/InputDialog.axaml b/src/SharpFM/Dialogs/InputDialog.axaml new file mode 100644 index 0000000..f87787f --- /dev/null +++ b/src/SharpFM/Dialogs/InputDialog.axaml @@ -0,0 +1,43 @@ + + + + + + + + + +