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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -246,9 +334,9 @@
Text="{Binding Clip.ClipTypeDisplay}" />
diff --git a/src/SharpFM/ViewModels/ClipViewModel.cs b/src/SharpFM/ViewModels/ClipViewModel.cs
index 6ba1dd42..0bef378b 100644
--- a/src/SharpFM/ViewModels/ClipViewModel.cs
+++ b/src/SharpFM/ViewModels/ClipViewModel.cs
@@ -3,6 +3,7 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Avalonia.Controls;
+using Avalonia.Media;
using AvaloniaEdit.Document;
using SharpFM.Editors;
using SharpFM.Model;
@@ -104,14 +105,28 @@ public void Replace(string xml)
NotifyPropertyChanged(nameof(ScriptDocument));
NotifyPropertyChanged(nameof(TableEditor));
NotifyPropertyChanged(nameof(XmlDocument));
+ NotifyFidelityChanged();
+ }
+
+ private void NotifyFidelityChanged()
+ {
NotifyPropertyChanged(nameof(ParseReport));
NotifyPropertyChanged(nameof(IsLossless));
+ NotifyPropertyChanged(nameof(HighestSeverity));
+ NotifyPropertyChanged(nameof(FidelityGlyph));
+ NotifyPropertyChanged(nameof(FidelityBrush));
}
public ClipParseReport ParseReport => _clip.Parsed.Report;
public bool IsLossless => ParseReport.IsLossless;
+ public ParseDiagnosticSeverity? HighestSeverity => ParseReport.HighestSeverity;
+
+ public string FidelityGlyph => HighestSeverity?.Glyph() ?? "";
+
+ public IBrush? FidelityBrush => HighestSeverity?.Brush();
+
/// Captures the current XML as the saved baseline; clears the dirty indicator.
public void MarkSaved()
{
@@ -139,8 +154,7 @@ internal void HandleEditorContentChanged()
var model = Editor.GetModel();
Clip = Clip.FromEditor(_clip.Name, _clip.FormatId, xml, model);
NotifyPropertyChanged(nameof(IsDirty));
- NotifyPropertyChanged(nameof(ParseReport));
- NotifyPropertyChanged(nameof(IsLossless));
+ NotifyFidelityChanged();
EditorContentChanged?.Invoke(this, EventArgs.Empty);
}
diff --git a/src/SharpFM/ViewModels/MainWindowViewModel.cs b/src/SharpFM/ViewModels/MainWindowViewModel.cs
index 141b2ef0..1bcdc2f8 100644
--- a/src/SharpFM/ViewModels/MainWindowViewModel.cs
+++ b/src/SharpFM/ViewModels/MainWindowViewModel.cs
@@ -9,6 +9,7 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Media;
using Avalonia.Threading;
using Microsoft.Extensions.Logging;
using SharpFM.Dialogs;
@@ -58,8 +59,16 @@ private void ResubscribeSelectedClipParseReport()
}
NotifyPropertyChanged(nameof(ParseFidelityVisible));
+ NotifyParseFidelityChanged();
+ }
+
+ private void NotifyParseFidelityChanged()
+ {
NotifyPropertyChanged(nameof(ParseFidelityIsLossless));
NotifyPropertyChanged(nameof(ParseFidelitySummary));
+ NotifyPropertyChanged(nameof(ParseFidelityGlyph));
+ NotifyPropertyChanged(nameof(ParseFidelityBrush));
+ ProblemsPanel.RefreshFrom(SelectedClip);
}
private void OnSelectedClipPropertyChanged(object? sender, PropertyChangedEventArgs e)
@@ -68,8 +77,7 @@ private void OnSelectedClipPropertyChanged(object? sender, PropertyChangedEventA
|| e.PropertyName == nameof(ClipViewModel.IsLossless)
|| e.PropertyName == nameof(ClipViewModel.Clip))
{
- NotifyPropertyChanged(nameof(ParseFidelityIsLossless));
- NotifyPropertyChanged(nameof(ParseFidelitySummary));
+ NotifyParseFidelityChanged();
}
}
@@ -863,12 +871,24 @@ public void OpenFolderAsSelection(IReadOnlyList folderPath)
SelectedFolderPath = folderPath;
}
+ /// Backs the bottom-docked Problems panel listing every parse diagnostic for the selected clip.
+ public ProblemsPanelViewModel ProblemsPanel { get; } = new();
+
+ /// Shows or hides the Problems panel.
+ public void ToggleProblemsPanel() => ProblemsPanel.IsPanelVisible = !ProblemsPanel.IsPanelVisible;
+
/// 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;
+ /// Severity-appropriate glyph for the status bar (empty when lossless).
+ public string ParseFidelityGlyph => SelectedClip?.FidelityGlyph ?? "";
+
+ /// Severity-appropriate color for .
+ public IBrush? ParseFidelityBrush => SelectedClip?.FidelityBrush;
+
///
/// 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".
diff --git a/src/SharpFM/ViewModels/ParseDiagnosticSeverityDisplay.cs b/src/SharpFM/ViewModels/ParseDiagnosticSeverityDisplay.cs
new file mode 100644
index 00000000..9c73a2fa
--- /dev/null
+++ b/src/SharpFM/ViewModels/ParseDiagnosticSeverityDisplay.cs
@@ -0,0 +1,31 @@
+using Avalonia.Media;
+using SharpFM.Model.Parsing;
+
+namespace SharpFM.ViewModels;
+
+///
+/// Shared mapping from to how it
+/// renders in the UI — every place a severity needs a glyph or color
+/// (Problems panel rows, the status bar, the clip-tree badge) goes through
+/// this instead of re-switching on the enum. Lives here rather than next to
+/// the enum in SharpFM.Model because requires the
+/// Avalonia reference that project must not carry; the count-driven noun
+/// phrase has no such dependency and lives on
+/// instead.
+///
+public static class ParseDiagnosticSeverityDisplay
+{
+ public static string Glyph(this ParseDiagnosticSeverity severity) => severity switch
+ {
+ ParseDiagnosticSeverity.Error => "✕",
+ ParseDiagnosticSeverity.Warning => "!",
+ _ => "i",
+ };
+
+ public static IBrush Brush(this ParseDiagnosticSeverity severity) => severity switch
+ {
+ ParseDiagnosticSeverity.Error => Brushes.IndianRed,
+ ParseDiagnosticSeverity.Warning => Brushes.DarkOrange,
+ _ => Brushes.Gray,
+ };
+}
diff --git a/src/SharpFM/ViewModels/ProblemsPanelViewModel.cs b/src/SharpFM/ViewModels/ProblemsPanelViewModel.cs
new file mode 100644
index 00000000..bb06a025
--- /dev/null
+++ b/src/SharpFM/ViewModels/ProblemsPanelViewModel.cs
@@ -0,0 +1,140 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using Avalonia.Media;
+using SharpFM.Model.Parsing;
+
+namespace SharpFM.ViewModels;
+
+///
+/// One row in the Problems panel — a plus
+/// which axis of it came from and the display
+/// labels the panel needs.
+///
+public sealed record ProblemRow(
+ ParseDiagnosticKind Kind,
+ ParseDiagnosticSeverity Severity,
+ string Location,
+ string Message,
+ bool IsSemantic)
+{
+ public string SeverityGlyph => Severity.Glyph();
+
+ public IBrush SeverityBrush => Severity.Brush();
+
+ public string KindLabel => Kind.ToHumanLabel(1);
+}
+
+///
+/// Backs the bottom-docked Problems panel: the full list of parse diagnostics
+/// for the selected clip (unlike the status bar's aggregate-only summary),
+/// plus the raw XML at the currently selected diagnostic's location.
+///
+public sealed class ProblemsPanelViewModel : INotifyPropertyChanged
+{
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private void NotifyPropertyChanged([CallerMemberName] string propertyName = "") =>
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+
+ public ObservableCollection Diagnostics { get; } = [];
+
+ public int Count => Diagnostics.Count;
+
+ ///
+ /// E.g. "Problems (1 error, 2 info)" — a severity breakdown rather
+ /// than a flat count, so the count itself signals how alarmed to be.
+ /// Ordered worst-first, relying on 's
+ /// declared order (see that enum's doc comment).
+ ///
+ public string HeaderText
+ {
+ get
+ {
+ if (Count == 0) return "Problems (0)";
+ var breakdown = Diagnostics
+ .GroupBy(r => r.Severity)
+ .OrderBy(g => g.Key)
+ .Select(g =>
+ {
+ var count = g.Count();
+ return $"{count} {g.Key.Noun(count)}";
+ });
+ return $"Problems ({string.Join(", ", breakdown)})";
+ }
+ }
+
+ private ProblemRow? _selectedDiagnostic;
+ private string? _rawXml;
+
+ public ProblemRow? SelectedDiagnostic
+ {
+ get => _selectedDiagnostic;
+ set
+ {
+ if (Equals(_selectedDiagnostic, value)) return;
+ _selectedDiagnostic = value;
+ SelectedXmlSnippet = value is null || _rawXml is null
+ ? null
+ : ClipParseLocationResolver.Resolve(_rawXml, value.Location);
+ NotifyPropertyChanged();
+ NotifyPropertyChanged(nameof(SelectedXmlSnippet));
+ }
+ }
+
+ /// Raw XML resolved at 's location; cached alongside the selection.
+ public string? SelectedXmlSnippet { get; private set; }
+
+ private bool _isPanelVisible;
+
+ public bool IsPanelVisible
+ {
+ get => _isPanelVisible;
+ set
+ {
+ if (_isPanelVisible == value) return;
+ _isPanelVisible = value;
+ NotifyPropertyChanged();
+ }
+ }
+
+ ///
+ /// Rebuild the diagnostic list for (or clear it for
+ /// null). A no-op, selection-preserving skip when the diagnostics haven't
+ /// actually changed since the last refresh — the common case while typing,
+ /// since most edits don't change the fidelity report.
+ ///
+ public void RefreshFrom(ClipViewModel? clip)
+ {
+ _rawXml = clip?.Clip.Xml;
+
+ var rows = clip is null
+ ? []
+ : clip.ParseReport.Diagnostics.Select(d => ToRow(d, isSemantic: false))
+ .Concat(clip.ParseReport.SemanticDiagnostics.Select(d => ToRow(d, isSemantic: true)))
+ // Worst severity first, relying on ParseDiagnosticSeverity's
+ // declared order (see that enum's doc comment); OrderBy is
+ // stable so same-severity rows keep their original relative order.
+ .OrderBy(r => r.Severity)
+ .ToList();
+
+ if (rows.SequenceEqual(Diagnostics))
+ {
+ return;
+ }
+
+ Diagnostics.Clear();
+ foreach (var row in rows)
+ {
+ Diagnostics.Add(row);
+ }
+
+ SelectedDiagnostic = null;
+ NotifyPropertyChanged(nameof(Count));
+ NotifyPropertyChanged(nameof(HeaderText));
+ }
+
+ private static ProblemRow ToRow(ClipParseDiagnostic diag, bool isSemantic) =>
+ new(diag.Kind, diag.Severity, diag.Location, diag.Message, isSemantic);
+}
diff --git a/tests/SharpFM.Tests/Parsing/ClipParseLocationResolverTests.cs b/tests/SharpFM.Tests/Parsing/ClipParseLocationResolverTests.cs
new file mode 100644
index 00000000..3ae8373a
--- /dev/null
+++ b/tests/SharpFM.Tests/Parsing/ClipParseLocationResolverTests.cs
@@ -0,0 +1,171 @@
+using System.Linq;
+using System.Xml.Linq;
+using SharpFM.Model.Parsing;
+
+namespace SharpFM.Tests.Parsing;
+
+public class ClipParseLocationResolverTests
+{
+ [Fact]
+ public void NestedElement_Resolves()
+ {
+ const string xml = "";
+
+ var result = ClipParseLocationResolver.Resolve(xml, "/fmxmlsnippet/Step[2]/Inner[1]");
+
+ Assert.Contains("Inner", result);
+ }
+
+ [Fact]
+ public void Attribute_Resolves()
+ {
+ const string xml = "";
+
+ var result = ClipParseLocationResolver.Resolve(xml, "/root/@attr");
+
+ Assert.Equal("attr=\"value\"", result);
+ }
+
+ [Fact]
+ public void BareSlash_ResolvesToWholeDocument()
+ {
+ const string xml = "";
+
+ var result = ClipParseLocationResolver.Resolve(xml, "/");
+
+ Assert.Contains("root", result);
+ }
+
+ [Fact]
+ public void RootOnlyPath_ResolvesToRootElement()
+ {
+ const string xml = "";
+
+ var result = ClipParseLocationResolver.Resolve(xml, "/root");
+
+ Assert.Contains("root", result);
+ Assert.Contains("attr", result);
+ }
+
+ [Fact]
+ public void MissingChild_FallsBackGracefully()
+ {
+ const string xml = "";
+
+ var result = ClipParseLocationResolver.Resolve(xml, "/root/Missing[1]");
+
+ Assert.Equal("(not found in current XML)", result);
+ }
+
+ [Fact]
+ public void MismatchedRootName_FallsBackGracefully()
+ {
+ const string xml = "";
+
+ var result = ClipParseLocationResolver.Resolve(xml, "/somethingElse");
+
+ Assert.Equal("(not found in current XML)", result);
+ }
+
+ [Fact]
+ public void OrphanOutputElementLocation_FallsBackGracefully()
+ {
+ // XmlRoundTripDiff emits a bare, index-less segment for elements that
+ // exist only in the round-tripped output, never in the source XML
+ // this resolver walks — so it can never be found here.
+ const string xml = "";
+
+ var result = ClipParseLocationResolver.Resolve(xml, "/root/DefaultedChild");
+
+ Assert.Equal("(not found in current XML)", result);
+ }
+
+ [Fact]
+ public void MissingAttribute_FallsBackGracefully()
+ {
+ const string xml = "";
+
+ var result = ClipParseLocationResolver.Resolve(xml, "/root/@missing");
+
+ Assert.Equal("(not found in current XML)", result);
+ }
+
+ [Fact]
+ public void MalformedXml_FallsBackGracefully()
+ {
+ const string xml = "";
+
+ var result = ClipParseLocationResolver.Resolve(xml, "/root");
+
+ Assert.Equal("(not found in current XML)", result);
+ }
+
+ [Fact]
+ public void LongResult_IsTruncated()
+ {
+ var longValue = new string('x', 500);
+ var xml = $"";
+
+ var result = ClipParseLocationResolver.Resolve(xml, "/root/@attr");
+
+ Assert.True(result.Length < 500);
+ Assert.EndsWith("…", result);
+ }
+
+ // The tests above assert the resolver's own path grammar in isolation.
+ // These instead feed it real XmlRoundTripDiff.Compute output, so a future
+ // change to that path-building format (XmlRoundTripDiff.cs) that the
+ // resolver doesn't also track would show up as a failure here.
+
+ [Fact]
+ public void RealAttributeMismatchDiagnostic_Resolves()
+ {
+ var input = XElement.Parse("");
+ var output = XElement.Parse("");
+ var diag = XmlRoundTripDiff.Compute(input, output).Single();
+
+ var result = ClipParseLocationResolver.Resolve(input.ToString(), diag.Location);
+
+ Assert.Equal("attr=\"a\"", result);
+ }
+
+ [Fact]
+ public void RealUnmodeledNestedElementDiagnostic_Resolves()
+ {
+ var input = XElement.Parse("");
+ var output = XElement.Parse("");
+ var diag = XmlRoundTripDiff.Compute(input, output).Single();
+
+ var result = ClipParseLocationResolver.Resolve(input.ToString(), diag.Location);
+
+ Assert.Contains("Mystery", result);
+ }
+
+ [Fact]
+ public void RealDroppedNamespaceDiagnostic_Resolves()
+ {
+ var input = XElement.Parse("");
+ var output = XElement.Parse("");
+ var diag = XmlRoundTripDiff.Compute(input, output)
+ .Single(d => d.Kind == ParseDiagnosticKind.DroppedNamespace);
+
+ var result = ClipParseLocationResolver.Resolve(input.ToString(), diag.Location);
+
+ Assert.Contains("root", result);
+ }
+
+ [Fact]
+ public void RealOrphanOutputElementDiagnostic_FallsBackGracefully()
+ {
+ // Only exists in the round-tripped output, never in the source XML
+ // this resolver walks — confirms the fallback for the real shape
+ // XmlRoundTripDiff emits, not just a hand-typed approximation of it.
+ var input = XElement.Parse("");
+ var output = XElement.Parse("");
+ var diag = XmlRoundTripDiff.Compute(input, output).Single();
+
+ var result = ClipParseLocationResolver.Resolve(input.ToString(), diag.Location);
+
+ Assert.Equal("(not found in current XML)", result);
+ }
+}
diff --git a/tests/SharpFM.Tests/Parsing/ClipParseReportTests.cs b/tests/SharpFM.Tests/Parsing/ClipParseReportTests.cs
index ec6752d9..784805d4 100644
--- a/tests/SharpFM.Tests/Parsing/ClipParseReportTests.cs
+++ b/tests/SharpFM.Tests/Parsing/ClipParseReportTests.cs
@@ -96,4 +96,83 @@ public void IsSemanticallyValid_FalseWhenAnySemanticDiagnostic()
Assert.False(report.IsSemanticallyValid);
Assert.True(report.IsLossless);
}
+
+ [Fact]
+ public void HighestSeverity_NullWhenNoDiagnostics()
+ {
+ Assert.Null(ClipParseReport.Empty.HighestSeverity);
+ }
+
+ [Fact]
+ public void HighestSeverity_InfoOnly_IsInfo()
+ {
+ var report = new ClipParseReport(
+ [
+ new ClipParseDiagnostic(
+ ParseDiagnosticKind.RoundTripValueMismatch,
+ ParseDiagnosticSeverity.Info,
+ "/fmxmlsnippet/Step[1]/Restore",
+ "output emitted a default"),
+ ]);
+
+ Assert.Equal(ParseDiagnosticSeverity.Info, report.HighestSeverity);
+ }
+
+ [Fact]
+ public void HighestSeverity_MixedInfoAndWarning_IsWarning()
+ {
+ var report = new ClipParseReport(
+ [
+ new ClipParseDiagnostic(
+ ParseDiagnosticKind.RoundTripValueMismatch,
+ ParseDiagnosticSeverity.Info,
+ "/fmxmlsnippet/Step[1]/Restore",
+ "output emitted a default"),
+ new ClipParseDiagnostic(
+ ParseDiagnosticKind.UnknownStepElement,
+ ParseDiagnosticSeverity.Warning,
+ "/fmxmlsnippet/Step[2]/Mystery",
+ "unmodeled child element"),
+ ]);
+
+ Assert.Equal(ParseDiagnosticSeverity.Warning, report.HighestSeverity);
+ }
+
+ [Fact]
+ public void HighestSeverity_WithError_IsError()
+ {
+ var report = new ClipParseReport(
+ [
+ new ClipParseDiagnostic(
+ ParseDiagnosticKind.RoundTripValueMismatch,
+ ParseDiagnosticSeverity.Info,
+ "/fmxmlsnippet/Step[1]/Restore",
+ "output emitted a default"),
+ new ClipParseDiagnostic(
+ ParseDiagnosticKind.XmlMalformed,
+ ParseDiagnosticSeverity.Error,
+ "/",
+ "not well-formed"),
+ ]);
+
+ Assert.Equal(ParseDiagnosticSeverity.Error, report.HighestSeverity);
+ }
+
+ [Fact]
+ public void HighestSeverity_ConsidersSemanticDiagnosticsToo()
+ {
+ var report = new ClipParseReport([])
+ {
+ SemanticDiagnostics =
+ [
+ new ClipParseDiagnostic(
+ ParseDiagnosticKind.UnknownStep,
+ ParseDiagnosticSeverity.Info,
+ "/fmxmlsnippet/Step[1]/Name",
+ "informational note"),
+ ],
+ };
+
+ Assert.Equal(ParseDiagnosticSeverity.Info, report.HighestSeverity);
+ }
}
diff --git a/tests/SharpFM.Tests/Parsing/ParseDiagnosticSeverityExtensionsTests.cs b/tests/SharpFM.Tests/Parsing/ParseDiagnosticSeverityExtensionsTests.cs
new file mode 100644
index 00000000..9ffb1be7
--- /dev/null
+++ b/tests/SharpFM.Tests/Parsing/ParseDiagnosticSeverityExtensionsTests.cs
@@ -0,0 +1,18 @@
+using SharpFM.Model.Parsing;
+
+namespace SharpFM.Tests.Parsing;
+
+public class ParseDiagnosticSeverityExtensionsTests
+{
+ [Theory]
+ [InlineData(ParseDiagnosticSeverity.Error, 1, "error")]
+ [InlineData(ParseDiagnosticSeverity.Error, 2, "errors")]
+ [InlineData(ParseDiagnosticSeverity.Warning, 1, "warning")]
+ [InlineData(ParseDiagnosticSeverity.Warning, 2, "warnings")]
+ [InlineData(ParseDiagnosticSeverity.Info, 1, "info")]
+ [InlineData(ParseDiagnosticSeverity.Info, 2, "info")]
+ public void Noun_PluralizesExceptInfo(ParseDiagnosticSeverity severity, int count, string expected)
+ {
+ Assert.Equal(expected, severity.Noun(count));
+ }
+}
diff --git a/tests/SharpFM.Tests/ViewModels/ClipViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/ClipViewModelTests.cs
index 101be3a4..726a6247 100644
--- a/tests/SharpFM.Tests/ViewModels/ClipViewModelTests.cs
+++ b/tests/SharpFM.Tests/ViewModels/ClipViewModelTests.cs
@@ -1,4 +1,6 @@
+using Avalonia.Media;
using SharpFM.Model;
+using SharpFM.Model.Parsing;
using SharpFM.ViewModels;
using Xunit;
@@ -183,4 +185,34 @@ public void ParseReport_ReflectsClipParseState()
var vm = CreateScriptClip(WrapXml(""));
Assert.True(vm.IsLossless);
}
+
+ [Fact]
+ public void LosslessClip_HasNoFidelityGlyph()
+ {
+ var vm = CreateScriptClip(WrapXml(""));
+
+ Assert.Null(vm.HighestSeverity);
+ Assert.Equal("", vm.FidelityGlyph);
+ Assert.Null(vm.FidelityBrush);
+ }
+
+ [Fact]
+ public void InfoOnlyClip_HasGrayGlyph()
+ {
+ var vm = CreateScriptClip(ParseFidelityTestXml.InfoOnlyStepXml);
+
+ Assert.Equal(ParseDiagnosticSeverity.Info, vm.HighestSeverity);
+ Assert.Equal("i", vm.FidelityGlyph);
+ Assert.Equal(Brushes.Gray, vm.FidelityBrush);
+ }
+
+ [Fact]
+ public void WarningClip_HasOrangeGlyph()
+ {
+ var vm = CreateScriptClip(ParseFidelityTestXml.WarningStepXml);
+
+ Assert.Equal(ParseDiagnosticSeverity.Warning, vm.HighestSeverity);
+ Assert.Equal("!", vm.FidelityGlyph);
+ Assert.Equal(Brushes.DarkOrange, vm.FidelityBrush);
+ }
}
diff --git a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelParseFidelityTests.cs b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelParseFidelityTests.cs
index d7a9b1d1..ce2ddb7d 100644
--- a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelParseFidelityTests.cs
+++ b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelParseFidelityTests.cs
@@ -1,5 +1,6 @@
-using Microsoft.Extensions.Logging;
+using Avalonia.Media;
using Microsoft.Extensions.Logging.Abstractions;
+using SharpFM.Model;
using SharpFM.ViewModels;
using Xunit;
@@ -35,19 +36,57 @@ 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)));
+ Clip.FromXml("Lossy", "Mac-XMSS", ParseFidelityTestXml.InfoOnlyStepXml)));
vm.SelectedClip = vm.FileMakerClips[^1];
Assert.False(vm.ParseFidelityIsLossless);
Assert.Contains("issue", vm.ParseFidelitySummary);
Assert.Contains("unknown step", vm.ParseFidelitySummary);
}
+
+ [Fact]
+ public void NoSelection_HasNoFidelityGlyph()
+ {
+ var vm = CreateVm();
+
+ Assert.Equal("", vm.ParseFidelityGlyph);
+ Assert.Null(vm.ParseFidelityBrush);
+ }
+
+ [Fact]
+ public void SelectedLosslessClip_HasNoFidelityGlyph()
+ {
+ var vm = CreateVm();
+ vm.NewScriptCommand();
+
+ Assert.Equal("", vm.ParseFidelityGlyph);
+ Assert.Null(vm.ParseFidelityBrush);
+ }
+
+ [Fact]
+ public void SelectedInfoOnlyClip_HasGrayGlyph()
+ {
+ var vm = CreateVm();
+
+ vm.FileMakerClips.Add(new ClipViewModel(
+ Clip.FromXml("Lossy", "Mac-XMSS", ParseFidelityTestXml.InfoOnlyStepXml)));
+ vm.SelectedClip = vm.FileMakerClips[^1];
+
+ Assert.Equal("i", vm.ParseFidelityGlyph);
+ Assert.Equal(Brushes.Gray, vm.ParseFidelityBrush);
+ }
+
+ [Fact]
+ public void SelectedWarningClip_HasOrangeGlyph()
+ {
+ var vm = CreateVm();
+
+ vm.FileMakerClips.Add(new ClipViewModel(
+ Clip.FromXml("Warning", "Mac-XMSS", ParseFidelityTestXml.WarningStepXml)));
+ vm.SelectedClip = vm.FileMakerClips[^1];
+
+ Assert.Equal("!", vm.ParseFidelityGlyph);
+ Assert.Equal(Brushes.DarkOrange, vm.ParseFidelityBrush);
+ }
}
diff --git a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelProblemsPanelTests.cs b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelProblemsPanelTests.cs
new file mode 100644
index 00000000..973c795d
--- /dev/null
+++ b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelProblemsPanelTests.cs
@@ -0,0 +1,65 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using SharpFM.Model;
+using SharpFM.ViewModels;
+using Xunit;
+
+namespace SharpFM.Tests.ViewModels;
+
+public class MainWindowViewModelProblemsPanelTests
+{
+ private static MainWindowViewModel CreateVm() =>
+ new(NullLoggerFactory.Instance.CreateLogger("test"),
+ new MockClipboardService(),
+ new MockFolderService());
+
+ // RawStep ⇒ UnknownStep diagnostic; same fixture as MainWindowViewModelParseFidelityTests.
+ private const string LossyXml =
+ "" +
+ "" +
+ "";
+
+ [Fact]
+ public void NoSelection_ProblemsPanelIsEmpty()
+ {
+ var vm = CreateVm();
+
+ Assert.Empty(vm.ProblemsPanel.Diagnostics);
+ }
+
+ [Fact]
+ public void SelectingLossyClip_PopulatesProblemsPanel()
+ {
+ var vm = CreateVm();
+ vm.FileMakerClips.Add(new ClipViewModel(Clip.FromXml("Lossy", "Mac-XMSS", LossyXml)));
+
+ vm.SelectedClip = vm.FileMakerClips[^1];
+
+ Assert.Single(vm.ProblemsPanel.Diagnostics);
+ }
+
+ [Fact]
+ public void SwitchingToLosslessClip_ClearsProblemsPanel()
+ {
+ var vm = CreateVm();
+ vm.FileMakerClips.Add(new ClipViewModel(Clip.FromXml("Lossy", "Mac-XMSS", LossyXml)));
+ vm.SelectedClip = vm.FileMakerClips[^1];
+ Assert.Single(vm.ProblemsPanel.Diagnostics);
+
+ vm.NewScriptCommand();
+
+ Assert.Empty(vm.ProblemsPanel.Diagnostics);
+ }
+
+ [Fact]
+ public void ToggleProblemsPanel_FlipsVisibility()
+ {
+ var vm = CreateVm();
+ Assert.False(vm.ProblemsPanel.IsPanelVisible);
+
+ vm.ToggleProblemsPanel();
+ Assert.True(vm.ProblemsPanel.IsPanelVisible);
+
+ vm.ToggleProblemsPanel();
+ Assert.False(vm.ProblemsPanel.IsPanelVisible);
+ }
+}
diff --git a/tests/SharpFM.Tests/ViewModels/ParseDiagnosticSeverityDisplayTests.cs b/tests/SharpFM.Tests/ViewModels/ParseDiagnosticSeverityDisplayTests.cs
new file mode 100644
index 00000000..a62f88c3
--- /dev/null
+++ b/tests/SharpFM.Tests/ViewModels/ParseDiagnosticSeverityDisplayTests.cs
@@ -0,0 +1,35 @@
+using SharpFM.Model.Parsing;
+using SharpFM.ViewModels;
+using Xunit;
+
+namespace SharpFM.Tests.ViewModels;
+
+public class ParseDiagnosticSeverityDisplayTests
+{
+ [Theory]
+ [InlineData(ParseDiagnosticSeverity.Error, "✕")]
+ [InlineData(ParseDiagnosticSeverity.Warning, "!")]
+ [InlineData(ParseDiagnosticSeverity.Info, "i")]
+ public void Glyph_MapsEachSeverity(ParseDiagnosticSeverity severity, string expected)
+ {
+ Assert.Equal(expected, severity.Glyph());
+ }
+
+ [Fact]
+ public void Brush_MapsErrorToRed()
+ {
+ Assert.Equal(Avalonia.Media.Brushes.IndianRed, ParseDiagnosticSeverity.Error.Brush());
+ }
+
+ [Fact]
+ public void Brush_MapsWarningToDarkOrange()
+ {
+ Assert.Equal(Avalonia.Media.Brushes.DarkOrange, ParseDiagnosticSeverity.Warning.Brush());
+ }
+
+ [Fact]
+ public void Brush_MapsInfoToGray()
+ {
+ Assert.Equal(Avalonia.Media.Brushes.Gray, ParseDiagnosticSeverity.Info.Brush());
+ }
+}
diff --git a/tests/SharpFM.Tests/ViewModels/ParseFidelityTestXml.cs b/tests/SharpFM.Tests/ViewModels/ParseFidelityTestXml.cs
new file mode 100644
index 00000000..dc70ba59
--- /dev/null
+++ b/tests/SharpFM.Tests/ViewModels/ParseFidelityTestXml.cs
@@ -0,0 +1,21 @@
+namespace SharpFM.Tests.ViewModels;
+
+///
+/// Shared XML fixtures for tests asserting parse-fidelity/severity behavior
+/// across ClipViewModel, MainWindowViewModel, and
+/// ProblemsPanelViewModel.
+///
+internal static class ParseFidelityTestXml
+{
+ /// RawStep ⇒ Info-severity UnknownStep diagnostic; survives lossless XML round-trip but the report calls it out.
+ public const string InfoOnlyStepXml =
+ "" +
+ "" +
+ "";
+
+ /// Beep takes no children; an unmodeled child ⇒ Warning-severity UnknownStepElement.
+ public const string WarningStepXml =
+ "" +
+ "" +
+ "";
+}
diff --git a/tests/SharpFM.Tests/ViewModels/ProblemsPanelViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/ProblemsPanelViewModelTests.cs
new file mode 100644
index 00000000..1e9f6b3a
--- /dev/null
+++ b/tests/SharpFM.Tests/ViewModels/ProblemsPanelViewModelTests.cs
@@ -0,0 +1,149 @@
+using SharpFM.Model;
+using SharpFM.Model.Parsing;
+using SharpFM.ViewModels;
+using Xunit;
+
+namespace SharpFM.Tests.ViewModels;
+
+public class ProblemsPanelViewModelTests
+{
+ [Fact]
+ public void NoClip_HasNoDiagnostics()
+ {
+ var vm = new ProblemsPanelViewModel();
+
+ vm.RefreshFrom(null);
+
+ Assert.Empty(vm.Diagnostics);
+ Assert.Equal(0, vm.Count);
+ }
+
+ [Fact]
+ public void LossyClip_PopulatesDiagnostics()
+ {
+ var vm = new ProblemsPanelViewModel();
+ var clip = new ClipViewModel(Clip.FromXml("Lossy", "Mac-XMSS", ParseFidelityTestXml.InfoOnlyStepXml));
+
+ vm.RefreshFrom(clip);
+
+ var row = Assert.Single(vm.Diagnostics);
+ Assert.Equal(ParseDiagnosticKind.UnknownStep, row.Kind);
+ Assert.False(row.IsSemantic);
+ Assert.False(string.IsNullOrEmpty(row.Location));
+ Assert.False(string.IsNullOrEmpty(row.Message));
+ Assert.Equal(1, vm.Count);
+ }
+
+ [Fact]
+ public void LosslessClip_HasNoDiagnostics()
+ {
+ var vm = new ProblemsPanelViewModel();
+ var clip = new ClipViewModel(Clip.FromXml(
+ "Clean", "Mac-XMSS", ""));
+
+ vm.RefreshFrom(clip);
+
+ Assert.Empty(vm.Diagnostics);
+ }
+
+ [Fact]
+ public void SelectingDiagnostic_ResolvesXmlSnippet()
+ {
+ var vm = new ProblemsPanelViewModel();
+ var clip = new ClipViewModel(Clip.FromXml("Lossy", "Mac-XMSS", ParseFidelityTestXml.InfoOnlyStepXml));
+ vm.RefreshFrom(clip);
+
+ vm.SelectedDiagnostic = vm.Diagnostics[0];
+
+ Assert.NotNull(vm.SelectedXmlSnippet);
+ Assert.Contains("Step", vm.SelectedXmlSnippet);
+ }
+
+ [Fact]
+ public void NoSelection_HasNoXmlSnippet()
+ {
+ var vm = new ProblemsPanelViewModel();
+
+ Assert.Null(vm.SelectedXmlSnippet);
+ }
+
+ [Fact]
+ public void RefreshFrom_ClearsPreviousDiagnostics()
+ {
+ var vm = new ProblemsPanelViewModel();
+ var lossy = new ClipViewModel(Clip.FromXml("Lossy", "Mac-XMSS", ParseFidelityTestXml.InfoOnlyStepXml));
+ vm.RefreshFrom(lossy);
+ Assert.NotEmpty(vm.Diagnostics);
+
+ vm.RefreshFrom(null);
+
+ Assert.Empty(vm.Diagnostics);
+ Assert.Null(vm.SelectedDiagnostic);
+ }
+
+ [Fact]
+ public void IsPanelVisible_DefaultsToFalse()
+ {
+ var vm = new ProblemsPanelViewModel();
+
+ Assert.False(vm.IsPanelVisible);
+ }
+
+ [Fact]
+ public void RefreshFrom_WithUnchangedDiagnostics_PreservesSelection()
+ {
+ var vm = new ProblemsPanelViewModel();
+ var clip = new ClipViewModel(Clip.FromXml("Lossy", "Mac-XMSS", ParseFidelityTestXml.InfoOnlyStepXml));
+ vm.RefreshFrom(clip);
+ vm.SelectedDiagnostic = vm.Diagnostics[0];
+
+ // Same clip, same diagnostics — e.g. a debounced edit tick that didn't
+ // change the fidelity report. Selection and its resolved snippet must
+ // survive since nothing actually changed.
+ vm.RefreshFrom(clip);
+
+ Assert.NotNull(vm.SelectedDiagnostic);
+ Assert.NotNull(vm.SelectedXmlSnippet);
+ }
+
+ // Info diagnostic (RawStep) appears first in source order, Warning
+ // (unmodeled child on Beep) second — exercises that RefreshFrom actually
+ // reorders rather than happening to already be sorted.
+ private const string MixedSeverityXml =
+ "" +
+ "" +
+ "" +
+ "";
+
+ [Fact]
+ public void RefreshFrom_SortsWorstSeverityFirst()
+ {
+ var vm = new ProblemsPanelViewModel();
+ var clip = new ClipViewModel(Clip.FromXml("Mixed", "Mac-XMSS", MixedSeverityXml));
+
+ vm.RefreshFrom(clip);
+
+ Assert.Equal(2, vm.Diagnostics.Count);
+ Assert.Equal(ParseDiagnosticSeverity.Warning, vm.Diagnostics[0].Severity);
+ Assert.Equal(ParseDiagnosticSeverity.Info, vm.Diagnostics[1].Severity);
+ }
+
+ [Fact]
+ public void HeaderText_ShowsSeverityBreakdown()
+ {
+ var vm = new ProblemsPanelViewModel();
+ var clip = new ClipViewModel(Clip.FromXml("Mixed", "Mac-XMSS", MixedSeverityXml));
+
+ vm.RefreshFrom(clip);
+
+ Assert.Equal("Problems (1 warning, 1 info)", vm.HeaderText);
+ }
+
+ [Fact]
+ public void HeaderText_WhenEmpty_ShowsZero()
+ {
+ var vm = new ProblemsPanelViewModel();
+
+ Assert.Equal("Problems (0)", vm.HeaderText);
+ }
+}