feat: port p-rec-annot line-number formatting foundation
This commit is contained in:
@@ -22,6 +22,34 @@ public readonly record struct Point3Dto(double X, double Y, double Z);
|
|||||||
public readonly record struct Vector2Dto(double X, double Y);
|
public readonly record struct Vector2Dto(double X, double Y);
|
||||||
public sealed record Bounds2Dto(Point2Dto Minimum, Point2Dto Maximum);
|
public sealed record Bounds2Dto(Point2Dto Minimum, Point2Dto Maximum);
|
||||||
|
|
||||||
|
public sealed record LineNumberAnnotationSettings
|
||||||
|
{
|
||||||
|
public required string NullOrEmpty { get; init; }
|
||||||
|
public required IReadOnlyList<LineNumberAnnotationDefinition> Annotations { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record LineNumberAnnotationDefinition
|
||||||
|
{
|
||||||
|
public required string Nickname { get; init; }
|
||||||
|
public IReadOnlyDictionary<string, string> Variables { get; init; } = new Dictionary<string, string>();
|
||||||
|
public required IReadOnlyList<LineNumberAnnotationTag> Tags { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record LineNumberAnnotationTag
|
||||||
|
{
|
||||||
|
public required string Field { get; init; }
|
||||||
|
public string Divider { get; init; } = string.Empty;
|
||||||
|
public bool SaveWhenEmpty { get; init; } = true;
|
||||||
|
public string IgnoreValue { get; init; } = string.Empty;
|
||||||
|
public string ProcessingXml { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record LineNumberFormattingResult(bool Success, string Text, string? ErrorCode = null);
|
||||||
|
public interface ILineNumberAnnotationFormatter
|
||||||
|
{
|
||||||
|
LineNumberFormattingResult Format(LineNumberAnnotationDefinition definition, IReadOnlyDictionary<string, string?> fields);
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record GeometrySnapshot
|
public sealed record GeometrySnapshot
|
||||||
{
|
{
|
||||||
public Point3Dto? WorldPosition { get; init; }
|
public Point3Dto? WorldPosition { get; init; }
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Xml.Linq;
|
||||||
|
using PRecAnnot.Abstractions;
|
||||||
|
|
||||||
|
namespace PRecAnnot.Core;
|
||||||
|
|
||||||
|
// Source-backed port of GetLinenumberAnnotationText. Plant/DataLinks access
|
||||||
|
// stays at the adapter boundary; this service is deterministic and testable.
|
||||||
|
public sealed class LineNumberAnnotationFormatter : ILineNumberAnnotationFormatter
|
||||||
|
{
|
||||||
|
public LineNumberFormattingResult Format(LineNumberAnnotationDefinition definition, IReadOnlyDictionary<string, string?> fields)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(definition);
|
||||||
|
ArgumentNullException.ThrowIfNull(fields);
|
||||||
|
var text = new System.Text.StringBuilder();
|
||||||
|
foreach (var tag in definition.Tags)
|
||||||
|
{
|
||||||
|
if (!fields.TryGetValue(tag.Field, out var value)) return new(false, string.Empty, "FIELD_NOT_FOUND:" + tag.Field);
|
||||||
|
value ??= string.Empty;
|
||||||
|
if (!string.IsNullOrWhiteSpace(tag.ProcessingXml))
|
||||||
|
{
|
||||||
|
var processed = Process(value, tag.ProcessingXml, definition.Variables, fields, tag.Field);
|
||||||
|
if (!processed.Success) return processed;
|
||||||
|
value = processed.Text;
|
||||||
|
}
|
||||||
|
if (string.Equals(value, tag.IgnoreValue, StringComparison.Ordinal)) value = string.Empty;
|
||||||
|
if (string.IsNullOrEmpty(value) && !tag.SaveWhenEmpty) continue;
|
||||||
|
if (text.Length > 0) text.Append(tag.Divider == "space" ? " " : tag.Divider);
|
||||||
|
text.Append(string.IsNullOrEmpty(value) ? definition.Nickname : value);
|
||||||
|
}
|
||||||
|
return new(true, text.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LineNumberFormattingResult Process(string value, string processingXml, IReadOnlyDictionary<string, string> variables, IReadOnlyDictionary<string, string?> fields, string field)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var root = XElement.Parse(processingXml);
|
||||||
|
if (root.Name.LocalName != "processing" || !root.Elements("function").Any()) return new(false, string.Empty, "PROCESSING_INVALID");
|
||||||
|
foreach (var function in root.Elements("function"))
|
||||||
|
{
|
||||||
|
var result = Apply(function, value, variables, fields, field);
|
||||||
|
if (!result.Success) return result;
|
||||||
|
value = result.Text;
|
||||||
|
}
|
||||||
|
return new(true, value);
|
||||||
|
}
|
||||||
|
catch (System.Xml.XmlException) { return new(false, string.Empty, "PROCESSING_INVALID"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LineNumberFormattingResult Apply(XElement function, string value, IReadOnlyDictionary<string, string> variables, IReadOnlyDictionary<string, string?> fields, string field)
|
||||||
|
{
|
||||||
|
var name = function.Element("name")?.Value;
|
||||||
|
var mode = function.Element("mod")?.Value;
|
||||||
|
var aliases = function.Elements("aliases").ToDictionary(x => x.Element("key")?.Value ?? string.Empty, x => x.Element("value")?.Value ?? string.Empty, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var attrs = function.Elements("attr").Select(a => Resolve(a.Element("value")?.Value, variables, fields)).ToArray();
|
||||||
|
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(mode) || attrs.Any(x => x is null)) return new(false, string.Empty, "PROCESSING_INVALID");
|
||||||
|
var found = false;
|
||||||
|
switch (name)
|
||||||
|
{
|
||||||
|
case "ToUpper": found = aliases.TryGetValue(attrs[0]!.ToUpperInvariant(), out value!); break;
|
||||||
|
case "NotContains":
|
||||||
|
if (attrs.Length < 2) return new(false, string.Empty, "PROCESSING_INVALID");
|
||||||
|
if (attrs[1]!.Contains(attrs[0]!, StringComparison.Ordinal)) { value = string.Empty; found = true; }
|
||||||
|
else found = aliases.TryGetValue(value, out value!);
|
||||||
|
break;
|
||||||
|
case "Empty": if (string.IsNullOrEmpty(attrs[0])) value = string.Empty; found = true; break;
|
||||||
|
default: return new(false, string.Empty, "PROCESSING_UNKNOWN_FUNCTION:" + name);
|
||||||
|
}
|
||||||
|
return mode == "interrupt" && !found ? new(false, string.Empty, "PROCESSING_ALIAS_NOT_FOUND:" + field) : new(true, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Resolve(string? value, IReadOnlyDictionary<string, string> variables, IReadOnlyDictionary<string, string?> fields)
|
||||||
|
{
|
||||||
|
if (value is null) return null;
|
||||||
|
if (value.Length >= 2 && value[0] == '%' && value[^1] == '%') return variables.TryGetValue(value[1..^1], out var resolved) ? resolved : null;
|
||||||
|
return fields.TryGetValue(value, out var field) ? field ?? string.Empty : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System.Xml.Linq;
|
||||||
|
using PRecAnnot.Abstractions;
|
||||||
|
|
||||||
|
namespace PRecAnnot.Core;
|
||||||
|
|
||||||
|
public sealed class LineNumberAnnotationSettingsLoader
|
||||||
|
{
|
||||||
|
public LineNumberAnnotationSettings Load(string xml)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(xml);
|
||||||
|
var root = XElement.Parse(xml);
|
||||||
|
if (root.Name.LocalName != "root") throw new InvalidDataException("SETTINGS_ROOT_INVALID");
|
||||||
|
var nullOrEmpty = root.Element("nullOrEmpty")?.Value;
|
||||||
|
if (nullOrEmpty is null) throw new InvalidDataException("SETTINGS_INVALID:10");
|
||||||
|
var annotations = root.Elements("annotation").Select(ParseAnnotation).ToArray();
|
||||||
|
if (annotations.Length == 0) throw new InvalidDataException("SETTINGS_INVALID:11");
|
||||||
|
return new LineNumberAnnotationSettings { NullOrEmpty = nullOrEmpty, Annotations = annotations };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LineNumberAnnotationDefinition ParseAnnotation(XElement annotation)
|
||||||
|
{
|
||||||
|
var nickname = annotation.Element("nickname")?.Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(nickname)) throw new InvalidDataException("SETTINGS_INVALID:20");
|
||||||
|
var tags = annotation.Elements("tag").Select(ParseTag).ToArray();
|
||||||
|
if (tags.Length == 0) throw new InvalidDataException("SETTINGS_INVALID:21");
|
||||||
|
var variables = annotation.Elements("set").Select(x => new KeyValuePair<string, string>(x.Element("key")?.Value ?? string.Empty, x.Element("value")?.Value ?? string.Empty)).ToArray();
|
||||||
|
if (variables.Length == 0) throw new InvalidDataException("SETTINGS_INVALID:22");
|
||||||
|
if (variables.Any(x => string.IsNullOrWhiteSpace(x.Key))) throw new InvalidDataException("SETTINGS_INVALID:40");
|
||||||
|
return new LineNumberAnnotationDefinition { Nickname = nickname, Variables = variables.ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal), Tags = tags };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LineNumberAnnotationTag ParseTag(XElement tag)
|
||||||
|
{
|
||||||
|
var divider = tag.Element("devider")?.Value;
|
||||||
|
var saver = tag.Element("saver")?.Value;
|
||||||
|
var field = tag.Element("field")?.Value;
|
||||||
|
var ignore = tag.Element("ignore")?.Value;
|
||||||
|
var processing = tag.Element("processing")?.Value;
|
||||||
|
if (divider is null) throw new InvalidDataException("SETTINGS_INVALID:30");
|
||||||
|
if (saver is null) throw new InvalidDataException("SETTINGS_INVALID:31");
|
||||||
|
if (string.IsNullOrWhiteSpace(field)) throw new InvalidDataException("SETTINGS_INVALID:32");
|
||||||
|
if (ignore is null) throw new InvalidDataException("SETTINGS_INVALID:33");
|
||||||
|
if (processing is null) throw new InvalidDataException("SETTINGS_INVALID:34");
|
||||||
|
return new LineNumberAnnotationTag { Field = field, Divider = divider, SaveWhenEmpty = string.IsNullOrEmpty(saver), IgnoreValue = ignore, ProcessingXml = processing };
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
using PRecAnnot.Abstractions;
|
||||||
|
using PRecAnnot.Core;
|
||||||
|
|
||||||
|
namespace PRecAnnot.Core.Tests;
|
||||||
|
|
||||||
|
public sealed class LineNumberAnnotationFormatterTests
|
||||||
|
{
|
||||||
|
private readonly LineNumberAnnotationFormatter _formatter = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatsTags_UsingLegacyDividerAndFieldValues()
|
||||||
|
{
|
||||||
|
var result = _formatter.Format(Definition(new LineNumberAnnotationTag { Field = "P3dLineGroup.Service", Divider = "space" }, new LineNumberAnnotationTag { Field = "P3dLineGroup.Number", Divider = "-" }), Fields(("P3dLineGroup.Service", "W"), ("P3dLineGroup.Number", "101")));
|
||||||
|
Assert.Equal(new(true, "W-101"), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToUpper_UsesLegacyAliasMap()
|
||||||
|
{
|
||||||
|
var result = _formatter.Format(Definition(new LineNumberAnnotationTag { Field = "EngineeringItems.Service", ProcessingXml = "<processing><function><name>ToUpper</name><attr><value>EngineeringItems.Service</value></attr><mod>interrupt</mod><aliases><key>WATER</key><value>W</value></aliases></function></processing>" }), Fields(("EngineeringItems.Service", "water")));
|
||||||
|
Assert.Equal(new(true, "W"), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingField_ReturnsPreciseError()
|
||||||
|
{
|
||||||
|
var result = _formatter.Format(Definition(new LineNumberAnnotationTag { Field = "P3dLineGroup.Number" }), Fields());
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal("FIELD_NOT_FOUND:P3dLineGroup.Number", result.ErrorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SettingsLoader_PreservesLegacyTagDefaults()
|
||||||
|
{
|
||||||
|
var settings = new LineNumberAnnotationSettingsLoader().Load("<root><nullOrEmpty>-</nullOrEmpty><annotation><nickname>LN</nickname><set><key>electric_heating</key><value>EH</value></set><tag><devider>space</devider><saver></saver><field>P3dLineGroup.Number</field><ignore></ignore><processing></processing></tag></annotation></root>");
|
||||||
|
Assert.Equal("-", settings.NullOrEmpty);
|
||||||
|
Assert.True(settings.Annotations.Single().Tags.Single().SaveWhenEmpty);
|
||||||
|
Assert.Equal("space", settings.Annotations.Single().Tags.Single().Divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SettingsLoader_RejectsLegacyInvalidAnnotation()
|
||||||
|
{
|
||||||
|
var error = Assert.Throws<InvalidDataException>(() => new LineNumberAnnotationSettingsLoader().Load("<root><nullOrEmpty>-</nullOrEmpty><annotation><nickname>LN</nickname></annotation></root>"));
|
||||||
|
Assert.Equal("SETTINGS_INVALID:21", error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LineNumberAnnotationDefinition Definition(params LineNumberAnnotationTag[] tags) => new() { Nickname = "LN", Variables = new Dictionary<string, string> { ["electric_heating"] = "EH" }, Tags = tags };
|
||||||
|
private static IReadOnlyDictionary<string, string?> Fields(params (string Key, string Value)[] values) => values.ToDictionary(x => x.Key, x => (string?)x.Value, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user