From cce3ff71c529d8224ce0a2cb3d547ee373c9f64e Mon Sep 17 00:00:00 2001 From: codex Date: Fri, 7 Aug 2026 08:08:17 +0300 Subject: [PATCH] feat: port p-rec-annot line-number formatting foundation --- .../src/PRecAnnot.Abstractions/Contracts.cs | 28 +++++++ .../LineNumberAnnotationFormatter.cs | 78 +++++++++++++++++++ .../LineNumberAnnotationSettingsLoader.cs | 46 +++++++++++ .../LineNumberAnnotationFormatterTests.cs | 50 ++++++++++++ 4 files changed, 202 insertions(+) create mode 100644 Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineNumberAnnotationFormatter.cs create mode 100644 Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineNumberAnnotationSettingsLoader.cs create mode 100644 Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/LineNumberAnnotationFormatterTests.cs diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs index 0ba60de..8e3285f 100644 --- a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Abstractions/Contracts.cs @@ -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 sealed record Bounds2Dto(Point2Dto Minimum, Point2Dto Maximum); +public sealed record LineNumberAnnotationSettings +{ + public required string NullOrEmpty { get; init; } + public required IReadOnlyList Annotations { get; init; } +} + +public sealed record LineNumberAnnotationDefinition +{ + public required string Nickname { get; init; } + public IReadOnlyDictionary Variables { get; init; } = new Dictionary(); + public required IReadOnlyList 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 fields); +} + public sealed record GeometrySnapshot { public Point3Dto? WorldPosition { get; init; } diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineNumberAnnotationFormatter.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineNumberAnnotationFormatter.cs new file mode 100644 index 0000000..0e46720 --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineNumberAnnotationFormatter.cs @@ -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 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 variables, IReadOnlyDictionary 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 variables, IReadOnlyDictionary 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 variables, IReadOnlyDictionary 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; + } +} diff --git a/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineNumberAnnotationSettingsLoader.cs b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineNumberAnnotationSettingsLoader.cs new file mode 100644 index 0000000..28f8dcd --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/src/PRecAnnot.Core/LineNumberAnnotationSettingsLoader.cs @@ -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(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 }; + } +} diff --git a/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/LineNumberAnnotationFormatterTests.cs b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/LineNumberAnnotationFormatterTests.cs new file mode 100644 index 0000000..87eac68 --- /dev/null +++ b/Plant3D2026/P_Rec_Annot/tests/PRecAnnot.Core.Tests/LineNumberAnnotationFormatterTests.cs @@ -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 = "ToUpperEngineeringItems.ServiceinterruptWATERW" }), 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("-LNelectric_heatingEHspaceP3dLineGroup.Number"); + 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(() => new LineNumberAnnotationSettingsLoader().Load("-LN")); + Assert.Equal("SETTINGS_INVALID:21", error.Message); + } + + private static LineNumberAnnotationDefinition Definition(params LineNumberAnnotationTag[] tags) => new() { Nickname = "LN", Variables = new Dictionary { ["electric_heating"] = "EH" }, Tags = tags }; + private static IReadOnlyDictionary Fields(params (string Key, string Value)[] values) => values.ToDictionary(x => x.Key, x => (string?)x.Value, StringComparer.OrdinalIgnoreCase); +}