From b2eb01e3988deded5f8b9eb7d08ddf49a68e29cc Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Tue, 14 Jul 2026 01:31:20 -0500 Subject: [PATCH] feat(scripting): add severity-aware Problems panel Lists every parse-fidelity diagnostic for the selected clip in a bottom-docked panel instead of the status bar's aggregate-only summary, with an inline XML snippet at the diagnostic's location. - Add ClipParseLocationResolver to resolve a diagnostic's XPath-style location back to XML for the panel's detail pane. - Add ClipParseReport.HighestSeverity and wire severity-aware glyphs/colors into the panel rows, the clip-tree badge, and the status bar summary (previously a flat orange "!" regardless of severity). - Sort panel rows worst-severity-first and show a per-severity count breakdown in the header instead of a flat total. --- .../Parsing/ClipParseLocationResolver.cs | 87 +++++++++ src/SharpFM.Model/Parsing/ClipParseReport.cs | 12 ++ .../Parsing/ParseDiagnosticSeverity.cs | 6 + .../ParseDiagnosticSeverityExtensions.cs | 23 +++ src/SharpFM.Model/Parsing/XmlRoundTripDiff.cs | 5 +- .../Parsing/XmlTextTruncation.cs | 12 ++ .../Validation/IClipSemanticValidator.cs | 9 +- src/SharpFM/MainWindow.axaml | 118 ++++++++++-- src/SharpFM/ViewModels/ClipViewModel.cs | 18 +- src/SharpFM/ViewModels/MainWindowViewModel.cs | 24 ++- .../ParseDiagnosticSeverityDisplay.cs | 31 ++++ .../ViewModels/ProblemsPanelViewModel.cs | 140 ++++++++++++++ .../Parsing/ClipParseLocationResolverTests.cs | 171 ++++++++++++++++++ .../Parsing/ClipParseReportTests.cs | 79 ++++++++ .../ParseDiagnosticSeverityExtensionsTests.cs | 18 ++ .../ViewModels/ClipViewModelTests.cs | 32 ++++ .../MainWindowViewModelParseFidelityTests.cs | 57 +++++- .../MainWindowViewModelProblemsPanelTests.cs | 65 +++++++ .../ParseDiagnosticSeverityDisplayTests.cs | 35 ++++ .../ViewModels/ParseFidelityTestXml.cs | 21 +++ .../ViewModels/ProblemsPanelViewModelTests.cs | 149 +++++++++++++++ 21 files changed, 1080 insertions(+), 32 deletions(-) create mode 100644 src/SharpFM.Model/Parsing/ClipParseLocationResolver.cs create mode 100644 src/SharpFM.Model/Parsing/ParseDiagnosticSeverityExtensions.cs create mode 100644 src/SharpFM.Model/Parsing/XmlTextTruncation.cs create mode 100644 src/SharpFM/ViewModels/ParseDiagnosticSeverityDisplay.cs create mode 100644 src/SharpFM/ViewModels/ProblemsPanelViewModel.cs create mode 100644 tests/SharpFM.Tests/Parsing/ClipParseLocationResolverTests.cs create mode 100644 tests/SharpFM.Tests/Parsing/ParseDiagnosticSeverityExtensionsTests.cs create mode 100644 tests/SharpFM.Tests/ViewModels/MainWindowViewModelProblemsPanelTests.cs create mode 100644 tests/SharpFM.Tests/ViewModels/ParseDiagnosticSeverityDisplayTests.cs create mode 100644 tests/SharpFM.Tests/ViewModels/ParseFidelityTestXml.cs create mode 100644 tests/SharpFM.Tests/ViewModels/ProblemsPanelViewModelTests.cs diff --git a/src/SharpFM.Model/Parsing/ClipParseLocationResolver.cs b/src/SharpFM.Model/Parsing/ClipParseLocationResolver.cs new file mode 100644 index 00000000..652368bd --- /dev/null +++ b/src/SharpFM.Model/Parsing/ClipParseLocationResolver.cs @@ -0,0 +1,87 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace SharpFM.Model.Parsing; + +/// +/// Resolves a path back to the XML +/// it points at, so a diagnostic can show its actual context. Walks the same +/// /Root/Child[n]/@Attr grammar emits +/// against a freshly parsed copy of the clip's current XML. +/// +public static class ClipParseLocationResolver +{ + private const string NotFound = "(not found in current XML)"; + private const int MaxLength = 300; + + private static readonly Regex IndexedSegment = new( + @"^(?[^\[\]/@]+)\[(?\d+)\]$", + RegexOptions.Compiled); + + /// + /// Resolve against . + /// Never throws: malformed XML, a path whose root name doesn't match, or a + /// segment that no longer resolves (e.g. it named something only present + /// in the round-tripped output, never in the source XML this walks) all + /// fall back to a short placeholder. + /// + public static string Resolve(string rawXml, string location) + { + XElement root; + try + { + root = XElement.Parse(rawXml); + } + catch (Exception) + { + return NotFound; + } + + var segments = location.TrimStart('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + { + return root.ToString().Truncate(MaxLength); + } + + if (segments[0] != root.Name.LocalName) + { + return NotFound; + } + + var current = root; + for (var i = 1; i < segments.Length; i++) + { + var segment = segments[i]; + + if (segment.StartsWith('@')) + { + var attr = current.Attribute(segment[1..]); + return attr is null ? NotFound : $"{attr.Name.LocalName}=\"{attr.Value}\"".Truncate(MaxLength); + } + + var match = IndexedSegment.Match(segment); + if (!match.Success) + { + return NotFound; + } + + var name = match.Groups["name"].Value; + var index = int.Parse(match.Groups["index"].Value); + var candidate = current.Elements() + .Where(e => e.Name.LocalName == name) + .Skip(index - 1) + .FirstOrDefault(); + + if (candidate is null) + { + return NotFound; + } + + current = candidate; + } + + return current.ToString().Truncate(MaxLength); + } +} diff --git a/src/SharpFM.Model/Parsing/ClipParseReport.cs b/src/SharpFM.Model/Parsing/ClipParseReport.cs index 09aca5ab..831d72e7 100644 --- a/src/SharpFM.Model/Parsing/ClipParseReport.cs +++ b/src/SharpFM.Model/Parsing/ClipParseReport.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; namespace SharpFM.Model.Parsing; @@ -27,4 +28,15 @@ public sealed record ClipParseReport(IReadOnlyList Diagnost /// True if no semantic validators flagged the parsed model. public bool IsSemanticallyValid => SemanticDiagnostics.Count == 0; + + /// + /// Most severe diagnostic across both and + /// , or null when there are none. Relies + /// on 's declared order (see that + /// enum's doc comment) so the numerically smallest value is the worst; + /// on a nullable sequence + /// returns null for an empty sequence, so no separate empty-check is needed. + /// + public ParseDiagnosticSeverity? HighestSeverity => + Diagnostics.Concat(SemanticDiagnostics).Select(d => (ParseDiagnosticSeverity?)d.Severity).Min(); } diff --git a/src/SharpFM.Model/Parsing/ParseDiagnosticSeverity.cs b/src/SharpFM.Model/Parsing/ParseDiagnosticSeverity.cs index 896087dd..14928b5a 100644 --- a/src/SharpFM.Model/Parsing/ParseDiagnosticSeverity.cs +++ b/src/SharpFM.Model/Parsing/ParseDiagnosticSeverity.cs @@ -5,6 +5,12 @@ namespace SharpFM.Model.Parsing; /// : that type belongs to /// the display-text validator (display→XML), this one to XML→domain parse fidelity. /// +/// +/// Declared worst-to-least-severe on purpose: +/// and the Problems panel's sort/breakdown (ProblemsPanelViewModel) both +/// rely on the ordinal — the numerically smallest value wins. Inserting a new +/// value out of severity order, or reordering these, will silently change both. +/// public enum ParseDiagnosticSeverity { Error, diff --git a/src/SharpFM.Model/Parsing/ParseDiagnosticSeverityExtensions.cs b/src/SharpFM.Model/Parsing/ParseDiagnosticSeverityExtensions.cs new file mode 100644 index 00000000..26bc6fd2 --- /dev/null +++ b/src/SharpFM.Model/Parsing/ParseDiagnosticSeverityExtensions.cs @@ -0,0 +1,23 @@ +namespace SharpFM.Model.Parsing; + +/// +/// Human-readable labels for . Lives next +/// to the enum so any consumer surfacing a severity breakdown (Problems panel +/// header, future MCP tools) shares the same wording. Mirrors +/// 's convention for the sibling +/// enum. +/// +public static class ParseDiagnosticSeverityExtensions +{ + /// + /// Format as a noun phrase, choosing the + /// singular form when is 1 and the plural + /// otherwise. Info doesn't pluralize. + /// + public static string Noun(this ParseDiagnosticSeverity severity, int count) => severity switch + { + ParseDiagnosticSeverity.Error => count == 1 ? "error" : "errors", + ParseDiagnosticSeverity.Warning => count == 1 ? "warning" : "warnings", + _ => "info", + }; +} diff --git a/src/SharpFM.Model/Parsing/XmlRoundTripDiff.cs b/src/SharpFM.Model/Parsing/XmlRoundTripDiff.cs index c0a94b83..aa7d08f1 100644 --- a/src/SharpFM.Model/Parsing/XmlRoundTripDiff.cs +++ b/src/SharpFM.Model/Parsing/XmlRoundTripDiff.cs @@ -14,7 +14,7 @@ namespace SharpFM.Model.Parsing; /// 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 +/// are noise from pretty-printing). Differences are categorized by parent /// element so consumers see /// vs. rather than a flat /// "something differs" stream. @@ -170,6 +170,5 @@ private static ParseDiagnosticKind KindForUnmodeledAttribute(string parentLocalN private const int TruncateLength = 60; - private static string Truncate(string s) => - s.Length <= TruncateLength ? s : s[..TruncateLength] + "…"; + private static string Truncate(string s) => s.Truncate(TruncateLength); } diff --git a/src/SharpFM.Model/Parsing/XmlTextTruncation.cs b/src/SharpFM.Model/Parsing/XmlTextTruncation.cs new file mode 100644 index 00000000..a74d4245 --- /dev/null +++ b/src/SharpFM.Model/Parsing/XmlTextTruncation.cs @@ -0,0 +1,12 @@ +namespace SharpFM.Model.Parsing; + +/// +/// Shared truncation for XML text/attribute values surfaced in diagnostic +/// messages or resolved snippets, so a large value doesn't blow up a message +/// or the Problems panel's detail pane. +/// +internal static class XmlTextTruncation +{ + public static string Truncate(this string s, int maxLength) => + s.Length <= maxLength ? s : s[..maxLength] + "…"; +} diff --git a/src/SharpFM.Model/Validation/IClipSemanticValidator.cs b/src/SharpFM.Model/Validation/IClipSemanticValidator.cs index da587eb3..e6bb7679 100644 --- a/src/SharpFM.Model/Validation/IClipSemanticValidator.cs +++ b/src/SharpFM.Model/Validation/IClipSemanticValidator.cs @@ -23,6 +23,13 @@ public interface IClipSemanticValidator /// IReadOnlyCollection FormatIds { get; } - /// Return any domain-rule violations found in . + /// + /// Return any domain-rule violations found in . + /// Each diagnostic's must use + /// the same XPath-style grammar + /// emits (/Root/Child[n]/@Attr) — the Problems panel resolves it + /// back to XML via , which + /// only understands that grammar. + /// IReadOnlyList Validate(ClipModel model); } diff --git a/src/SharpFM/MainWindow.axaml b/src/SharpFM/MainWindow.axaml index b883bf87..bd1a0903 100644 --- a/src/SharpFM/MainWindow.axaml +++ b/src/SharpFM/MainWindow.axaml @@ -22,6 +22,7 @@ + + + + +