feat: port p-rec-annot line-number formatting foundation

This commit is contained in:
codex
2026-08-07 08:08:17 +03:00
parent 70ae18981a
commit cce3ff71c5
4 changed files with 202 additions and 0 deletions
@@ -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 };
}
}
@@ -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);
}