diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvExportModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvExportModal.razor new file mode 100644 index 00000000..674e5876 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvExportModal.razor @@ -0,0 +1,170 @@ +@* CSV tag export for a Raw-tree Device / TagGroup (B2-WP5). + + Reads the device's tags (via the WP5-owned RawTagCsvExportReader — no IRawTreeService extension) and + renders them in the same column shape RawCsvImportModal consumes (T0-2 CsvWriter, per-driver + CsvColumnMap). The CSV is offered as a self-contained data-URI download anchor (no JS interop, no + external host — matches the app's CSP posture) plus a preview textarea. + + Modal contract (the RawTree coordinator wires this in place of the OnExportCsv stub): + *@ +@using System.Text +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@inject RawTagCsvExportReader ExportReader + +@if (Visible) +{ + + +} + +@code { + /// Whether the modal is shown (two-way bound). + [Parameter] public bool Visible { get; set; } + + /// Raised when changes (supports @bind-Visible). + [Parameter] public EventCallback VisibleChanged { get; set; } + + /// The device to export. May be blank when is supplied. + [Parameter] public string DeviceId { get; set; } = ""; + + /// When set, only tags under this group's subtree are exported; null exports the whole device. + [Parameter] public string? TagGroupId { get; set; } + + /// The device's driver-type string — selects the per-driver typed column set. + [Parameter] public string DriverType { get; set; } = ""; + + private bool _loading; + private bool _built; + private string? _error; + private string _csv = ""; + private string _dataUri = ""; + private string _fileName = "tags.csv"; + private int _rowCount; + private string? _groupFilter; + + protected override async Task OnParametersSetAsync() + { + if (Visible && !_built) + { + _built = true; + await BuildAsync(); + } + else if (!Visible) + { + _built = false; + } + } + + private async Task BuildAsync() + { + _loading = true; + _error = null; + try + { + var deviceId = DeviceId; + _groupFilter = null; + + if (!string.IsNullOrEmpty(TagGroupId)) + { + var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!); + if (ctx is { } c) + { + _groupFilter = c.GroupPath; + if (string.IsNullOrEmpty(deviceId)) + { + deviceId = c.DeviceId; + } + } + } + + if (string.IsNullOrEmpty(deviceId)) + { + _error = "Could not resolve the target device."; + return; + } + + var tags = await ExportReader.ReadDeviceTagsAsync(deviceId); + + // When exporting a single group, keep only that group's subtree (its path + descendants). + if (!string.IsNullOrEmpty(_groupFilter)) + { + var prefix = _groupFilter; + tags = tags + .Where(t => t.TagGroupPath is not null && + (t.TagGroupPath == prefix || t.TagGroupPath.StartsWith(prefix + "/", StringComparison.Ordinal))) + .ToList(); + } + + _rowCount = tags.Count; + _csv = RawTagCsvMapper.Export(DriverType, tags); + _fileName = BuildFileName(deviceId); + + var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(_csv)); + _dataUri = $"data:text/csv;charset=utf-8;base64,{base64}"; + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _loading = false; + } + } + + private static string BuildFileName(string deviceId) + { + var safe = new string(deviceId.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray()); + return $"tags-{safe}.csv"; + } + + private async Task Close() + { + _built = false; + _csv = ""; + _dataUri = ""; + Visible = false; + await VisibleChanged.InvokeAsync(false); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvImportModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvImportModal.razor new file mode 100644 index 00000000..60c5f650 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvImportModal.razor @@ -0,0 +1,318 @@ +@* Staged CSV tag import for a Raw-tree Device / TagGroup (B2-WP5). + + Flow: upload a file (InputFile) → parse via the T0-2 CsvParser + the per-driver CsvColumnMap → + a REVIEW GRID with a per-row verdict (OK / the validation error) and, per row, which columns were + typed vs. taken from the TagConfigJson fallback → Commit, which calls IRawTreeService.ImportTagsAsync. + ImportTagsAsync is all-or-nothing, so Commit is BLOCKED while any row is invalid — no partial commit. + + Modal contract (the RawTree coordinator wires this in place of the OnImportCsv stub): + + For a Device node: DeviceId = node.EntityId, TagGroupId = null. + For a TagGroup node: TagGroupId = node.EntityId (DeviceId is resolved from it if not passed). *@ +@using Microsoft.AspNetCore.Components.Forms +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@inject IRawTreeService Svc +@inject RawTagCsvExportReader ExportReader + +@if (Visible) +{ + + +} + +@code { + /// Whether the modal is shown (two-way bound). + [Parameter] public bool Visible { get; set; } + + /// Raised when changes (supports @bind-Visible). + [Parameter] public EventCallback VisibleChanged { get; set; } + + /// The target device's logical id. May be blank when is supplied + /// (the device is then resolved from the group). + [Parameter] public string DeviceId { get; set; } = ""; + + /// The target tag-group's logical id, or null to import at the device root. Its device-relative + /// path is prepended to every imported row's TagGroupPath. + [Parameter] public string? TagGroupId { get; set; } + + /// The device's driver-type string — selects the per-driver typed column set. + [Parameter] public string DriverType { get; set; } = ""; + + /// Raised after a successful commit so the coordinator can reload the node's children. + [Parameter] public EventCallback OnSaved { get; set; } + + private RawCsvParseResult? _result; + private readonly HashSet _removed = new(); + private List _commitErrors = new(); + private string? _fatal; + private bool _parsing; + private bool _committing; + private bool _resolved; + + private string _effectiveDeviceId = ""; + private string? _groupPrefix; + + private string TargetLabel => + string.IsNullOrEmpty(_groupPrefix) ? _effectiveDeviceId : $"{_effectiveDeviceId} / {_groupPrefix}"; + + private int OkCount => _result?.Rows.Count(r => r.Ok && !_removed.Contains(r.RowNumber)) ?? 0; + private int ErrorCount => _result?.Rows.Count(r => !r.Ok && !_removed.Contains(r.RowNumber)) ?? 0; + private int RemainingCount => _result?.Rows.Count(r => !_removed.Contains(r.RowNumber)) ?? 0; + + // Commit is unblocked only when every remaining row is valid AND at least one row remains. + private bool CanCommit => + _result is not null && _fatal is null && RemainingCount > 0 && ErrorCount == 0; + + protected override async Task OnParametersSetAsync() + { + if (Visible && !_resolved) + { + await ResolveTargetAsync(); + _resolved = true; + } + else if (!Visible) + { + _resolved = false; + } + } + + // Resolve the effective device id + the group-path prefix once per open. + private async Task ResolveTargetAsync() + { + _effectiveDeviceId = DeviceId; + _groupPrefix = null; + + if (!string.IsNullOrEmpty(TagGroupId)) + { + var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!); + if (ctx is { } c) + { + _groupPrefix = c.GroupPath; + if (string.IsNullOrEmpty(_effectiveDeviceId)) + { + _effectiveDeviceId = c.DeviceId; + } + } + } + } + + private async Task OnFileSelected(InputFileChangeEventArgs e) + { + _fatal = null; + _commitErrors = new(); + _removed.Clear(); + _parsing = true; + + try + { + using var stream = e.File.OpenReadStream(maxAllowedSize: 16 * 1024 * 1024); + using var reader = new StreamReader(stream); + var text = await reader.ReadToEndAsync(); + _result = RawTagCsvMapper.Parse(DriverType, text, _groupPrefix); + _fatal = _result.FatalError; + } + catch (Exception ex) + { + _fatal = $"Could not read the file: {ex.Message}"; + _result = null; + } + finally + { + _parsing = false; + } + } + + private void RemoveRow(int rowNumber) => _removed.Add(rowNumber); + + private void Reset() + { + _result = null; + _fatal = null; + _commitErrors = new(); + _removed.Clear(); + } + + private async Task Commit() + { + if (_result is null || !CanCommit) + { + return; + } + + _committing = true; + _commitErrors = new(); + try + { + var rows = _result.Rows + .Where(r => r is { Ok: true, ImportRow: not null } && !_removed.Contains(r.RowNumber)) + .Select(r => r.ImportRow!) + .ToList(); + + var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows); + if (outcome.Errors.Count > 0) + { + _commitErrors = outcome.Errors.ToList(); + return; + } + + await OnSaved.InvokeAsync(); + await Close(); + } + catch (Exception ex) + { + _commitErrors = new() { ex.Message }; + } + finally + { + _committing = false; + } + } + + private async Task Close() + { + Reset(); + Visible = false; + _resolved = false; + await VisibleChanged.InvokeAsync(false); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index 72452017..7239ee9e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -49,6 +49,8 @@ public static class EndpointRouteBuilderExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + // WP5 CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude). + services.AddScoped(); services.AddSingleton(); services.AddSingleton(); // Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvExportReader.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvExportReader.cs new file mode 100644 index 00000000..d55c334f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvExportReader.cs @@ -0,0 +1,117 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// Read-only enumeration helper for the WP5 CSV export path. Reads a device's tag-groups + tags directly +/// from the config DB and flattens them (with each tag's reconstructed /-separated +/// ) into the export shape. Owned by WP5 — it does NOT extend +/// (that prelude is closed); it takes its own +/// so export never mutates. Also resolves a tag-group's path + +/// owning device (the import modal's target-group prefix seam). +/// +/// The config-DB context factory. +public sealed class RawTagCsvExportReader(IDbContextFactory dbFactory) +{ + /// + /// The owning device + reconstructed path for a tag-group (used by the import modal to prepend a + /// target-group prefix to every imported row). + /// + /// The group's owning device id. + /// The group's /-separated path under the device (device-relative, no device segment). + public readonly record struct TagGroupContext(string DeviceId, string GroupPath); + + /// Resolves the driver-type string of the device's parent driver, or null when unresolved. + /// The device's logical id. + /// A cancellation token. + /// The driver-type string, or null. + public async Task ResolveDriverTypeAsync(string deviceId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + return await (from dev in db.Devices.AsNoTracking() + join drv in db.DriverInstances.AsNoTracking() on dev.DriverInstanceId equals drv.DriverInstanceId + where dev.DeviceId == deviceId + select drv.DriverType).FirstOrDefaultAsync(ct); + } + + /// + /// Resolves a tag-group's owning device + device-relative path. Walks the ParentTagGroupId chain + /// to the device root and joins the segment names with /. Returns null when the group is unknown. + /// + /// The tag-group's logical id. + /// A cancellation token. + /// The owning device + reconstructed path, or null when the group does not exist. + public async Task ResolveGroupContextAsync(string tagGroupId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + var group = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct); + if (group is null) + { + return null; + } + + var byId = await db.TagGroups.AsNoTracking() + .Where(g => g.DeviceId == group.DeviceId) + .ToDictionaryAsync(g => g.TagGroupId, g => (g.Name, g.ParentTagGroupId), ct); + + var path = BuildGroupPath(tagGroupId, byId); + return new TagGroupContext(group.DeviceId, path); + } + + /// + /// Enumerates every tag under a device (across all nested tag-groups), each carrying its + /// device-relative group path, in a stable order (group path, then name). + /// + /// The device's logical id. + /// A cancellation token. + /// The device's tags flattened for export. + public async Task> ReadDeviceTagsAsync(string deviceId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var groups = await db.TagGroups.AsNoTracking() + .Where(g => g.DeviceId == deviceId) + .ToDictionaryAsync(g => g.TagGroupId, g => (g.Name, g.ParentTagGroupId), ct); + + var tags = await db.Tags.AsNoTracking() + .Where(t => t.DeviceId == deviceId) + .ToListAsync(ct); + + return tags + .Select(t => new RawCsvExportTag( + TagGroupPath: t.TagGroupId is null ? null : NullIfEmpty(BuildGroupPath(t.TagGroupId, groups)), + Name: t.Name, + DataType: t.DataType, + AccessLevel: t.AccessLevel, + WriteIdempotent: t.WriteIdempotent, + PollGroupId: t.PollGroupId, + TagConfig: t.TagConfig)) + .OrderBy(x => x.TagGroupPath ?? "", RawPaths.Comparer) + .ThenBy(x => x.Name, RawPaths.Comparer) + .ToList(); + } + + private static string? NullIfEmpty(string s) => s.Length == 0 ? null : s; + + // Joins a group's segment names root→leaf into a device-relative path, following ParentTagGroupId. + private static string BuildGroupPath( + string? groupId, + IReadOnlyDictionary byId) + { + var segments = new Stack(); + var guard = 0; + while (groupId is not null && byId.TryGetValue(groupId, out var g)) + { + segments.Push(g.Name); + groupId = g.ParentTagGroupId; + if (++guard > 1024) + { + break; // defensive: never loop forever on a malformed parent cycle + } + } + + return string.Join(RawPaths.SeparatorString, segments); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs new file mode 100644 index 00000000..c6390206 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs @@ -0,0 +1,457 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Commons.Csv; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// The pure WP5 CSV import/export engine for the Raw tree. Bridges the T0-2 / +/// and the per-driver to the service's +/// commit shape: turns an uploaded CSV into a +/// per-row review model with validation verdicts; renders a device's tags back to +/// the same column shape. No I/O, no DB, no Blazor — unit-testable in isolation. +/// +/// A per-tag driver TagConfig JSON is assembled in a fixed precedence: +/// TagConfigJson (base) ← typed driver columns (override) ← the alarm / historize / array flag +/// columns. A typed column therefore wins over a conflicting key carried in the TagConfigJson +/// fallback; the review model flags when that override happened so the operator sees it. +/// +/// +public static class RawTagCsvMapper +{ + // Root TagConfig keys owned by the flag columns — stripped from the export residual so the + // TagConfigJson fallback never duplicates a value already carried by a dedicated column. + private static readonly string[] FlagKeys = + ["isHistorized", "historianTagname", "isArray", "arrayLength", "alarm"]; + + // ---------------------------------------------------------------------------------------- Import + + /// + /// Parses an uploaded CSV into a review model: one per data row with a + /// validation verdict, the assembled (when valid), and the typed vs. + /// TagConfigJson-fallback provenance. Intra-batch duplicate names (same effective group + name) + /// are flagged on the later row. This is a dry parse — nothing is committed. + /// + /// The target device's driver-type string (selects the typed column map). + /// The uploaded CSV document. + /// The target tag-group's path prefix (from the modal's TagGroupId), prepended + /// to every row's TagGroupPath; null/blank when importing at the device root. + /// The parse result: header list, per-row review rows, and any fatal (whole-file) error. + public static RawCsvParseResult Parse(string driverType, string? csvText, string? groupPathPrefix = null) + { + IReadOnlyList raw; + try + { + raw = CsvParser.Parse(csvText); + } + catch (FormatException ex) + { + return new RawCsvParseResult(Array.Empty(), Array.Empty(), $"Malformed CSV: {ex.Message}"); + } + + if (raw.Count == 0) + { + return new RawCsvParseResult(Array.Empty(), Array.Empty(), "The file is empty."); + } + + var headers = raw[0]; + var headerIndex = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < headers.Length; i++) + { + if (!headerIndex.TryAdd(headers[i], i)) + { + return new RawCsvParseResult(headers, Array.Empty(), $"Duplicate header column '{headers[i]}'."); + } + } + + if (!headerIndex.ContainsKey("Name")) + { + return new RawCsvParseResult(headers, Array.Empty(), "Required column 'Name' is missing from the header row."); + } + + var map = CsvColumnMap.Resolve(driverType); + var reviewed = new List(raw.Count - 1); + + // Skip a wholly-blank trailing/interstitial line (the RFC parser yields [""] for an empty line). + for (var r = 1; r < raw.Count; r++) + { + var cells = raw[r]; + if (cells.Length == 1 && cells[0].Length == 0) + { + continue; + } + + reviewed.Add(BuildRow(r, headers, headerIndex, cells, map, groupPathPrefix)); + } + + FlagDuplicates(reviewed); + return new RawCsvParseResult(headers, reviewed, null); + } + + private static RawCsvReviewRow BuildRow( + int rowNumber, + string[] headers, + IReadOnlyDictionary headerIndex, + string[] cells, + CsvDriverTagMap? map, + string? groupPathPrefix) + { + var row = new Dictionary(StringComparer.Ordinal); + foreach (var (name, idx) in headerIndex) + { + row[name] = idx < cells.Length ? cells[idx] : ""; + } + + string Cell(string name) => row.TryGetValue(name, out var v) ? v.Trim() : ""; + + var typedUsed = new List(); + var overrodeFallback = new List(); + + try + { + // --- Common identity/access fields --- + var name = Cell("Name"); + if (RawPaths.ValidateSegment(name) is { } nameErr) + { + return Invalid(rowNumber, cells, $"Name: {nameErr}"); + } + + var dataType = Cell("DataType"); + if (dataType.Length == 0) + { + return Invalid(rowNumber, cells, "DataType is required."); + } + + var accessRaw = Cell("AccessLevel"); + var access = accessRaw.Length == 0 + ? TagAccessLevel.Read + : CsvValues.ParseEnum("AccessLevel", accessRaw); + + var writeIdemRaw = Cell("WriteIdempotent"); + var writeIdempotent = writeIdemRaw.Length != 0 && CsvValues.ParseBool("WriteIdempotent", writeIdemRaw); + + var pollGroup = Cell("PollGroup"); + var pollGroupId = pollGroup.Length == 0 ? null : pollGroup; + + var groupPath = CombineGroupPath(groupPathPrefix, Cell("TagGroupPath")); + + // --- Assemble the TagConfig JSON: base ← typed ← flags --- + var baseJson = Cell(CsvColumnMap.TagConfigJsonColumn); + var baseKeys = ReadRootKeys(baseJson.Length == 0 ? "{}" : baseJson, out var baseValid); + if (!baseValid) + { + return Invalid(rowNumber, cells, "TagConfigJson is not well-formed JSON."); + } + + if (map is not null) + { + foreach (var col in map.Columns) + { + if (row.TryGetValue(col.Header, out var cv) && !string.IsNullOrWhiteSpace(cv)) + { + typedUsed.Add(col.Header); + if (baseKeys.Contains(col.JsonKey)) + { + overrodeFallback.Add(col.Header); + } + } + } + } + + var json = map?.ApplyTypedColumns(baseJson.Length == 0 ? "{}" : baseJson, row) + ?? (baseJson.Length == 0 ? "{}" : baseJson); + + // Historize flags. + var isHistorized = ParseFlag("IsHistorized", Cell("IsHistorized")); + json = TagHistorizeConfig.Set(json, isHistorized, Cell("HistorianTagname")); + + // Array flags. + var isArray = ParseFlag("IsArray", Cell("IsArray")); + uint? arrayLength = null; + var arrayLenRaw = Cell("ArrayLength"); + if (arrayLenRaw.Length != 0) + { + var n = CsvValues.ParseInt("ArrayLength", arrayLenRaw); + if (n < 0) + { + return Invalid(rowNumber, cells, "ArrayLength must not be negative."); + } + + arrayLength = (uint)n; + } + + if (TagArrayConfig.Validate(isArray, arrayLength) is { } arrErr) + { + return Invalid(rowNumber, cells, arrErr); + } + + json = TagArrayConfig.Set(json, isArray, arrayLength); + + // Alarm flags — an alarm is authored iff Alarm.AlarmType is present. + json = ApplyAlarm(json, Cell("Alarm.AlarmType"), Cell("Alarm.Severity"), Cell("Alarm.HistorizeToAveva")); + + var importRow = new RawTagImportRow(groupPath, new RawTagInput(name, dataType, access, writeIdempotent, pollGroupId, json)); + + return new RawCsvReviewRow + { + RowNumber = rowNumber, + Cells = cells, + Headers = headers, + Error = null, + ImportRow = importRow, + TypedColumnsUsed = typedUsed, + FallbackKeys = baseKeys, + TypedOverrodeFallback = overrodeFallback, + EffectiveGroupPath = groupPath ?? "", + Name = name, + }; + } + catch (FormatException ex) + { + return Invalid(rowNumber, cells, ex.Message); + } + } + + private static RawCsvReviewRow Invalid(int rowNumber, string[] cells, string error) => new() + { + RowNumber = rowNumber, + Cells = cells, + Headers = Array.Empty(), + Error = error, + TypedColumnsUsed = Array.Empty(), + FallbackKeys = Array.Empty(), + TypedOverrodeFallback = Array.Empty(), + }; + + // Flags a same-(group+name) collision within the parsed batch on the SECOND-and-later row. + private static void FlagDuplicates(List rows) + { + var seen = new HashSet<(string, string)>(); + foreach (var row in rows.Where(r => r.Ok)) + { + if (!seen.Add((row.EffectiveGroupPath, row.Name))) + { + var where = row.EffectiveGroupPath.Length == 0 ? "the device root" : $"group '{row.EffectiveGroupPath}'"; + row.Error = $"Duplicate tag name '{row.Name}' under {where} (already appears earlier in this file)."; + row.ImportRow = null; + } + } + } + + private static string? CombineGroupPath(string? prefix, string cell) + { + var suffix = string.IsNullOrWhiteSpace(cell) ? null : cell.Trim(); + prefix = string.IsNullOrWhiteSpace(prefix) ? null : prefix.Trim(); + if (prefix is null) + { + return suffix; + } + + return suffix is null ? prefix : $"{prefix}{RawPaths.SeparatorString}{suffix}"; + } + + private static bool ParseFlag(string header, string cell) + => cell.Length != 0 && CsvValues.ParseBool(header, cell); + + private static string ApplyAlarm(string json, string alarmType, string severityRaw, string histRaw) + { + if (alarmType.Length == 0) + { + return json; + } + + var model = NativeAlarmModel.FromJson(json); + model.IsAlarm = true; + model.AlarmType = alarmType; + model.Severity = severityRaw.Length == 0 ? model.Severity : CsvValues.ParseInt("Alarm.Severity", severityRaw); + model.HistorizeToAveva = histRaw.Length == 0 ? null : CsvValues.ParseBool("Alarm.HistorizeToAveva", histRaw); + return model.ToJson(); + } + + private static IReadOnlyCollection ReadRootKeys(string json, out bool valid) + { + JsonNode? node; + try + { + node = JsonNode.Parse(json); + } + catch (System.Text.Json.JsonException) + { + valid = false; + return Array.Empty(); + } + + if (node is not JsonObject obj) + { + valid = false; + return Array.Empty(); + } + + valid = true; + return obj.Select(kvp => kvp.Key).ToArray(); + } + + // ---------------------------------------------------------------------------------------- Export + + /// + /// Renders a device's tags to a CSV string in the same column shape consumes: + /// the common columns, the driver's typed columns, then the trailing TagConfigJson fallback + /// carrying only the residual keys (those not surfaced by a typed or flag column) so a round-trip is + /// exact and non-redundant. + /// + /// The device's driver-type string (selects the typed column set). + /// The tags to export. + /// The CSV document (CRLF, quote-on-demand). + public static string Export(string driverType, IEnumerable tags) + { + var map = CsvColumnMap.Resolve(driverType); + var typedHeaders = map?.Headers ?? Array.Empty(); + var header = CsvColumnMap.HeadersFor(driverType); + + var rows = new List { header.ToArray() }; + foreach (var tag in tags) + { + rows.Add(BuildExportRow(tag, map, typedHeaders)); + } + + return CsvWriter.WriteToString(rows); + } + + private static string[] BuildExportRow(RawCsvExportTag tag, CsvDriverTagMap? map, IReadOnlyList typedHeaders) + { + var json = string.IsNullOrWhiteSpace(tag.TagConfig) ? "{}" : tag.TagConfig; + + var hist = TagHistorizeConfig.Read(json); + var array = TagArrayConfig.Read(json); + var alarm = NativeAlarmModel.FromJson(json); + var typed = map?.ReadTypedColumns(json) ?? new Dictionary(); + + var cells = new List(CsvColumnMap.CommonLeadingColumns.Count + typedHeaders.Count + 1) + { + tag.Name, + tag.TagGroupPath ?? "", + tag.DataType, + tag.AccessLevel.ToString(), + tag.WriteIdempotent ? "true" : "false", + tag.PollGroupId ?? "", + hist.IsHistorized ? "true" : "", + hist.HistorianTagname, + array.IsArray ? "true" : "", + array.ArrayLength?.ToString(CultureInfo.InvariantCulture) ?? "", + alarm.IsAlarm ? alarm.AlarmType : "", + alarm.IsAlarm ? alarm.Severity.ToString(CultureInfo.InvariantCulture) : "", + alarm.IsAlarm ? (CsvValues.FormatNullableBool(alarm.HistorizeToAveva) ?? "") : "", + }; + + foreach (var h in typedHeaders) + { + cells.Add(typed.TryGetValue(h, out var v) ? v : ""); + } + + cells.Add(ComputeResidual(json, map)); + return cells.ToArray(); + } + + // The TagConfigJson fallback cell: the tag's root keys minus every key a typed or flag column already + // surfaces. Empty object ⇒ blank cell. + private static string ComputeResidual(string json, CsvDriverTagMap? map) + { + var o = TagConfigJson.ParseOrNew(json); + foreach (var key in map?.JsonKeys ?? Array.Empty()) + { + o.Remove(key); + } + + foreach (var key in FlagKeys) + { + o.Remove(key); + } + + return o.Count == 0 ? "" : TagConfigJson.Serialize(o); + } +} + +/// +/// One tag flattened for CSV export: its effective tag-group path under the device plus the operator-facing +/// fields. Produced by and consumed by . +/// +/// The /-separated group path under the device, or null for a device-root tag. +/// The tag name. +/// The OPC UA built-in type name. +/// The tag's access-level baseline. +/// Whether writes are retry-eligible. +/// The optional poll-group id. +/// The raw per-driver TagConfig JSON blob. +public sealed record RawCsvExportTag( + string? TagGroupPath, + string Name, + string DataType, + TagAccessLevel AccessLevel, + bool WriteIdempotent, + string? PollGroupId, + string TagConfig); + +/// +/// One reviewed CSV data row: its 1-based row number, raw cells, validation verdict, and — when valid — +/// the assembled plus the typed vs. TagConfigJson-fallback provenance +/// the review grid renders. +/// +public sealed class RawCsvReviewRow +{ + /// The 1-based data-row number in the source file (header is row 0). + public int RowNumber { get; init; } + + /// The raw cell values as parsed. + public IReadOnlyList Cells { get; init; } = Array.Empty(); + + /// The header row (for aligning in the grid). + public IReadOnlyList Headers { get; init; } = Array.Empty(); + + /// The validation error, or null when the row is valid. + public string? Error { get; set; } + + /// Whether the row is valid (commit-eligible). + public bool Ok => Error is null; + + /// The assembled import row when valid; null otherwise. + public RawTagImportRow? ImportRow { get; set; } + + /// The typed-column headers that supplied a value on this row. + public IReadOnlyList TypedColumnsUsed { get; init; } = Array.Empty(); + + /// The root keys present in the row's TagConfigJson fallback. + public IReadOnlyCollection FallbackKeys { get; init; } = Array.Empty(); + + /// The typed columns that overrode a conflicting key carried in TagConfigJson. + public IReadOnlyList TypedOverrodeFallback { get; init; } = Array.Empty(); + + /// The effective tag-group path (prefix + row's TagGroupPath) used for duplicate detection. + public string EffectiveGroupPath { get; init; } = ""; + + /// The tag name (echoed for duplicate detection + grid display). + public string Name { get; init; } = ""; +} + +/// +/// The outcome of : the parsed header row, the per-row review model, and +/// a fatal (whole-file) error when the document could not be parsed at all (malformed CSV, duplicate/absent +/// required header). When is set, is empty. +/// +/// The parsed header row. +/// The per-row review model. +/// A whole-file error, or null when the file parsed. +public sealed record RawCsvParseResult( + IReadOnlyList Headers, + IReadOnlyList Rows, + string? FatalError) +{ + /// Whether the file parsed and every row is valid (commit is unblocked). + public bool CanCommit => FatalError is null && Rows.Count > 0 && Rows.All(r => r.Ok); + + /// The valid rows' assembled import rows, ready for ImportTagsAsync. + public IReadOnlyList ToImportRows() + => Rows.Where(r => r is { Ok: true, ImportRow: not null }).Select(r => r.ImportRow!).ToArray(); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs new file mode 100644 index 00000000..aa5e71af --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs @@ -0,0 +1,485 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; +using ZB.MOM.WW.OtOpcUa.Driver.Modbus; +using ZB.MOM.WW.OtOpcUa.Driver.S7; +using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +/// +/// Metadata for one per-driver typed CSV column: its CSV header, the root TagConfig JSON key it +/// maps to, and — for the model-backed maps — the name of the property on the driver's +/// TagConfigModel]]> it corresponds to. is the anchor +/// the CSV column-map reflection guard test asserts against so a driver-model rename can never silently +/// drift the CSV surface. It is null for the model-less maps (Galaxy / Calculation), which map +/// a header directly onto a JSON key. +/// +/// The CSV column header (PascalCase, e.g. ModbusDataType). +/// The root TagConfig JSON key this column reads/writes (camelCase, e.g. dataType). +/// The backing model property name, or null for a model-less map. +public sealed record CsvTypedColumn(string Header, string JsonKey, string? ModelProperty); + +/// +/// Per-driver mapping between a CSV row's typed columns and a tag's driver-specific +/// TagConfig JSON. The map is the inverse pair the WP5 CSV import/export path pivots on: +/// folds a row's typed columns onto a base TagConfig (typed column wins +/// on conflict); projects a stored TagConfig back to its typed cells for +/// export. Lives next to the TagConfigModel]]> types so model + map evolve +/// together. +/// +public abstract class CsvDriverTagMap +{ + /// The driver-type string this map serves (a value). + public abstract string DriverType { get; } + + /// The backing model type whose scalar surface this map mirrors, or null for a + /// model-less (raw-JSON-key) map (Galaxy / Calculation). + public abstract Type? ModelType { get; } + + /// The ordered typed columns this driver adds to the common CSV column set. + public abstract IReadOnlyList Columns { get; } + + /// The typed column headers, in order. + public IReadOnlyList Headers => Columns.Select(c => c.Header).ToArray(); + + /// The root TagConfig JSON keys this map owns (used to compute the export residual). + public IReadOnlyList JsonKeys => Columns.Select(c => c.JsonKey).ToArray(); + + /// + /// Folds the typed columns present (non-blank) in onto + /// and returns the merged driver TagConfig JSON. The base is loaded first + /// (so its unrecognised keys survive) and each present typed column then overrides its key — so a + /// typed column wins over a conflicting key carried in the TagConfigJson base. + /// + /// The base TagConfig JSON (from the row's TagConfigJson column), or null/blank. + /// The CSV row as a header→value map. + /// The merged TagConfig JSON string. + /// A typed cell is not a valid value for its column (bad enum / number / bool). + public abstract string ApplyTypedColumns(string? baseJson, IReadOnlyDictionary row); + + /// + /// Projects the typed columns out of a stored TagConfig JSON for export — the inverse of + /// . A column absent from the JSON is omitted from the result. + /// + /// The tag's stored TagConfig JSON, or null/blank. + /// The typed columns present, keyed by CSV header. + public abstract IReadOnlyDictionary ReadTypedColumns(string? tagConfigJson); +} + +/// One typed column of a model-backed map: its metadata plus the read/write pair over the +/// concrete working model. +/// The driver's TagConfig working-model type. +/// The column metadata (header / JSON key / model property). +/// Projects the model's value to a cell string, or null to omit the column on export. +/// Parses a non-blank cell onto the model (throws on a bad value). +public sealed record CsvModelColumn( + CsvTypedColumn Meta, + Func Get, + Action Set); + +/// +/// A driven by a driver's typed working model. Import loads the base JSON +/// through the model's FromJson (preserving unknown keys), applies each present typed cell via its +/// , and serialises back through the model's ToJson — so +/// the CSV path reuses the exact same preserve-unknown / typed-wins merge the TagModal editor uses. +/// +/// The driver's TagConfig working-model type. +public sealed class ModelBackedCsvDriverTagMap : CsvDriverTagMap +{ + private readonly Func _fromJson; + private readonly Func _toJson; + private readonly IReadOnlyList> _columns; + + /// Builds a model-backed map. + /// The driver-type string served. + /// The model's FromJson loader (preserves unknown keys). + /// The model's ToJson serialiser. + /// The typed columns (metadata + read/write pairs). + public ModelBackedCsvDriverTagMap( + string driverType, + Func fromJson, + Func toJson, + IReadOnlyList> columns) + { + DriverType = driverType; + _fromJson = fromJson; + _toJson = toJson; + _columns = columns; + Columns = columns.Select(c => c.Meta).ToArray(); + } + + /// + public override string DriverType { get; } + + /// + public override Type? ModelType => typeof(TModel); + + /// + public override IReadOnlyList Columns { get; } + + /// + public override string ApplyTypedColumns(string? baseJson, IReadOnlyDictionary row) + { + var model = _fromJson(baseJson); + foreach (var col in _columns) + { + if (row.TryGetValue(col.Meta.Header, out var raw) && !string.IsNullOrWhiteSpace(raw)) + { + col.Set(model, raw.Trim()); + } + } + + return _toJson(model); + } + + /// + public override IReadOnlyDictionary ReadTypedColumns(string? tagConfigJson) + { + var model = _fromJson(tagConfigJson); + var result = new Dictionary(StringComparer.Ordinal); + foreach (var col in _columns) + { + if (col.Get(model) is { } v) + { + result[col.Meta.Header] = v; + } + } + + return result; + } +} + +/// The value shape of a raw-key (model-less) CSV column. +public enum CsvRawKeyKind +{ + /// A JSON string value. + String, + + /// A JSON boolean value (parsed case-insensitively from true/false). + Bool, + + /// A JSON integer value. + Int, +} + +/// Metadata + value-kind for one column of a raw-key map. +/// The column metadata (header / JSON key; model property is null). +/// The JSON value kind the header maps onto. +public sealed record CsvRawKeyColumn(CsvTypedColumn Meta, CsvRawKeyKind Kind); + +/// +/// A for drivers with no typed working model (Galaxy, Calculation): each +/// column maps a CSV header directly onto a root TagConfig JSON key, preserving every other key across +/// the round-trip. +/// +public sealed class RawKeyCsvDriverTagMap : CsvDriverTagMap +{ + private readonly IReadOnlyList _columns; + + /// Builds a raw-key map. + /// The driver-type string served. + /// The header→JSON-key columns with their value kinds. + public RawKeyCsvDriverTagMap(string driverType, IReadOnlyList columns) + { + DriverType = driverType; + _columns = columns; + Columns = columns.Select(c => c.Meta).ToArray(); + } + + /// + public override string DriverType { get; } + + /// + public override Type? ModelType => null; + + /// + public override IReadOnlyList Columns { get; } + + /// + public override string ApplyTypedColumns(string? baseJson, IReadOnlyDictionary row) + { + var o = TagConfigJson.ParseOrNew(baseJson); + foreach (var col in _columns) + { + if (row.TryGetValue(col.Meta.Header, out var raw) && !string.IsNullOrWhiteSpace(raw)) + { + var trimmed = raw.Trim(); + o[col.Meta.JsonKey] = col.Kind switch + { + CsvRawKeyKind.Bool => JsonValue.Create(CsvValues.ParseBool(col.Meta.Header, trimmed)), + CsvRawKeyKind.Int => JsonValue.Create(CsvValues.ParseInt(col.Meta.Header, trimmed)), + _ => JsonValue.Create(trimmed), + }; + } + } + + return TagConfigJson.Serialize(o); + } + + /// + public override IReadOnlyDictionary ReadTypedColumns(string? tagConfigJson) + { + var o = TagConfigJson.ParseOrNew(tagConfigJson); + var result = new Dictionary(StringComparer.Ordinal); + foreach (var col in _columns) + { + if (o.TryGetPropertyValue(col.Meta.JsonKey, out var node) && node is JsonValue v) + { + result[col.Meta.Header] = col.Kind switch + { + CsvRawKeyKind.Bool => v.TryGetValue(out var b) ? (b ? "true" : "false") : v.ToString(), + CsvRawKeyKind.Int => v.TryGetValue(out var l) + ? l.ToString(CultureInfo.InvariantCulture) + : v.ToString(), + _ => v.TryGetValue(out var s) ? s : v.ToString(), + }; + } + } + + return result; + } +} + +/// Case-insensitive value parsers/formatters for typed CSV cells, throwing a uniform +/// (naming the column + the offending value) the review grid surfaces. +public static class CsvValues +{ + /// Parses an enum member name case-insensitively; throws with the legal members listed. + /// The enum type. + /// The CSV column header (for the error message). + /// The cell value. + /// The parsed enum value. + /// The value is not a member of . + public static TEnum ParseEnum(string header, string value) where TEnum : struct, Enum + => Enum.TryParse(value, ignoreCase: true, out var v) && Enum.IsDefined(v) + ? v + : throw new FormatException( + $"Column '{header}': '{value}' is not a valid {typeof(TEnum).Name} " + + $"(expected one of: {string.Join(", ", Enum.GetNames())})."); + + /// Parses an integer (invariant); throws a column-named . + /// The CSV column header (for the error message). + /// The cell value. + /// The parsed integer. + /// The value is not an integer. + public static int ParseInt(string header, string value) + => int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i) + ? i + : throw new FormatException($"Column '{header}': '{value}' is not a whole number."); + + /// Parses a boolean case-insensitively (true/false); throws a column-named error. + /// The CSV column header (for the error message). + /// The cell value. + /// The parsed boolean. + /// The value is not a boolean. + public static bool ParseBool(string header, string value) + => bool.TryParse(value, out var b) + ? b + : throw new FormatException($"Column '{header}': '{value}' is not a boolean (expected true or false)."); + + /// Formats a nullable boolean for export: null ⇒ omitted (null cell), else lowercased. + /// The nullable boolean. + /// The cell string, or null to omit the column. + public static string? FormatNullableBool(bool? value) => value is { } b ? (b ? "true" : "false") : null; +} + +/// +/// Registry of the WP5 CSV column dictionary: the common columns every device's export/import carries, +/// plus the per-driver typed resolved by driver type. The single authority +/// for "which columns does a device of driver X have". +/// +public static class CsvColumnMap +{ + /// The TagConfigJson fallback column header — the base under which typed columns merge. + public const string TagConfigJsonColumn = "TagConfigJson"; + + /// The common column headers that precede any typed driver columns (in order). + public static IReadOnlyList CommonLeadingColumns { get; } = new[] + { + "Name", + "TagGroupPath", + "DataType", + "AccessLevel", + "WriteIdempotent", + "PollGroup", + "IsHistorized", + "HistorianTagname", + "IsArray", + "ArrayLength", + "Alarm.AlarmType", + "Alarm.Severity", + "Alarm.HistorizeToAveva", + }; + + /// All common columns (leading columns + the trailing TagConfigJson fallback). + public static IReadOnlyList CommonColumns { get; } = + [.. CommonLeadingColumns, TagConfigJsonColumn]; + + /// The Calculation (virtual-tag) driver-type string. Not yet a + /// constant (its factory lands in a later Batch-2 package), so it is pinned here for the CSV surface. + public const string CalculationDriverType = "Calculation"; + + private static readonly IReadOnlyDictionary Maps = BuildMaps(); + + /// All registered driver maps. + public static IReadOnlyCollection All => (IReadOnlyCollection)Maps.Values; + + /// Resolves the typed map for a driver type, or null when the driver has no typed + /// columns (only the common set applies). + /// The driver-type string. + /// The map, or null. + public static CsvDriverTagMap? Resolve(string? driverType) + => driverType is not null && Maps.TryGetValue(driverType, out var m) ? m : null; + + /// The full ordered header row for a device of the given driver type: the leading common + /// columns, then the driver's typed columns, then the trailing TagConfigJson fallback. + /// The device's driver-type string. + /// The ordered CSV header names. + public static IReadOnlyList HeadersFor(string? driverType) + { + var typed = Resolve(driverType)?.Headers ?? Array.Empty(); + return [.. CommonLeadingColumns, .. typed, TagConfigJsonColumn]; + } + + private static Dictionary BuildMaps() + { + var maps = new CsvDriverTagMap[] + { + new ModelBackedCsvDriverTagMap( + DriverTypeNames.Modbus, ModbusTagConfigModel.FromJson, m => m.ToJson(), + new[] + { + ModbusCol("Region", "region", nameof(ModbusTagConfigModel.Region), + m => m.Region.ToString(), (m, s) => m.Region = CsvValues.ParseEnum("Region", s)), + ModbusCol("Address", "address", nameof(ModbusTagConfigModel.Address), + m => m.Address.ToString(CultureInfo.InvariantCulture), (m, s) => m.Address = CsvValues.ParseInt("Address", s)), + ModbusCol("ModbusDataType", "dataType", nameof(ModbusTagConfigModel.DataType), + m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum("ModbusDataType", s)), + ModbusCol("ByteOrder", "byteOrder", nameof(ModbusTagConfigModel.ByteOrder), + m => m.ByteOrder.ToString(), (m, s) => m.ByteOrder = CsvValues.ParseEnum("ByteOrder", s)), + ModbusCol("BitIndex", "bitIndex", nameof(ModbusTagConfigModel.BitIndex), + m => m.BitIndex.ToString(CultureInfo.InvariantCulture), (m, s) => m.BitIndex = CsvValues.ParseInt("BitIndex", s)), + ModbusCol("StringLength", "stringLength", nameof(ModbusTagConfigModel.StringLength), + m => m.StringLength.ToString(CultureInfo.InvariantCulture), (m, s) => m.StringLength = CsvValues.ParseInt("StringLength", s)), + ModbusCol("Writable", "writable", nameof(ModbusTagConfigModel.Writable), + m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)), + }), + + new ModelBackedCsvDriverTagMap( + DriverTypeNames.S7, S7TagConfigModel.FromJson, m => m.ToJson(), + new[] + { + S7Col("Address", "address", nameof(S7TagConfigModel.Address), + m => m.Address, (m, s) => m.Address = s), + S7Col("S7DataType", "dataType", nameof(S7TagConfigModel.DataType), + m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum("S7DataType", s)), + S7Col("StringLength", "stringLength", nameof(S7TagConfigModel.StringLength), + m => m.StringLength.ToString(CultureInfo.InvariantCulture), (m, s) => m.StringLength = CsvValues.ParseInt("StringLength", s)), + S7Col("Writable", "writable", nameof(S7TagConfigModel.Writable), + m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)), + }), + + new ModelBackedCsvDriverTagMap( + DriverTypeNames.AbCip, AbCipTagConfigModel.FromJson, m => m.ToJson(), + new[] + { + AbCipCol("TagPath", "tagPath", nameof(AbCipTagConfigModel.TagPath), + m => m.TagPath, (m, s) => m.TagPath = s), + AbCipCol("AbCipDataType", "dataType", nameof(AbCipTagConfigModel.DataType), + m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum("AbCipDataType", s)), + AbCipCol("Writable", "writable", nameof(AbCipTagConfigModel.Writable), + m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)), + }), + + new ModelBackedCsvDriverTagMap( + DriverTypeNames.AbLegacy, AbLegacyTagConfigModel.FromJson, m => m.ToJson(), + new[] + { + AbLegacyCol("Address", "address", nameof(AbLegacyTagConfigModel.Address), + m => m.Address, (m, s) => m.Address = s), + AbLegacyCol("AbLegacyDataType", "dataType", nameof(AbLegacyTagConfigModel.DataType), + m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum("AbLegacyDataType", s)), + AbLegacyCol("Writable", "writable", nameof(AbLegacyTagConfigModel.Writable), + m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)), + }), + + new ModelBackedCsvDriverTagMap( + DriverTypeNames.TwinCAT, TwinCATTagConfigModel.FromJson, m => m.ToJson(), + new[] + { + TwinCatCol("SymbolPath", "symbolPath", nameof(TwinCATTagConfigModel.SymbolPath), + m => m.SymbolPath, (m, s) => m.SymbolPath = s), + TwinCatCol("TwinCATDataType", "dataType", nameof(TwinCATTagConfigModel.DataType), + m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum("TwinCATDataType", s)), + TwinCatCol("Writable", "writable", nameof(TwinCATTagConfigModel.Writable), + m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)), + }), + + new ModelBackedCsvDriverTagMap( + DriverTypeNames.FOCAS, FocasTagConfigModel.FromJson, m => m.ToJson(), + new[] + { + FocasCol("Address", "address", nameof(FocasTagConfigModel.Address), + m => m.Address, (m, s) => m.Address = s), + FocasCol("FocasDataType", "dataType", nameof(FocasTagConfigModel.DataType), + m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum("FocasDataType", s)), + }), + + new ModelBackedCsvDriverTagMap( + DriverTypeNames.OpcUaClient, OpcUaClientTagConfigModel.FromJson, m => m.ToJson(), + new[] + { + OpcUaCol("NodeId", "nodeId", nameof(OpcUaClientTagConfigModel.NodeId), + m => m.NodeId, (m, s) => m.NodeId = s), + }), + + new RawKeyCsvDriverTagMap(DriverTypeNames.Galaxy, new[] + { + // Galaxy's driver reference is the PascalCase FullName key (tag_name.AttributeName). + new CsvRawKeyColumn(new CsvTypedColumn("AttributeRef", "FullName", null), CsvRawKeyKind.String), + }), + + new RawKeyCsvDriverTagMap(CalculationDriverType, new[] + { + new CsvRawKeyColumn(new CsvTypedColumn("ScriptId", "scriptId", null), CsvRawKeyKind.String), + new CsvRawKeyColumn(new CsvTypedColumn("ChangeTriggered", "changeTriggered", null), CsvRawKeyKind.Bool), + new CsvRawKeyColumn(new CsvTypedColumn("TimerIntervalMs", "timerIntervalMs", null), CsvRawKeyKind.Int), + }), + }; + + return maps.ToDictionary(m => m.DriverType, m => m, StringComparer.OrdinalIgnoreCase); + } + + // Per-driver column factories — one per model so the Get/Set lambdas stay strongly typed. + private static CsvModelColumn ModbusCol( + string h, string k, string p, Func get, Action set) + => new(new CsvTypedColumn(h, k, p), get, set); + + private static CsvModelColumn S7Col( + string h, string k, string p, Func get, Action set) + => new(new CsvTypedColumn(h, k, p), get, set); + + private static CsvModelColumn AbCipCol( + string h, string k, string p, Func get, Action set) + => new(new CsvTypedColumn(h, k, p), get, set); + + private static CsvModelColumn AbLegacyCol( + string h, string k, string p, Func get, Action set) + => new(new CsvTypedColumn(h, k, p), get, set); + + private static CsvModelColumn TwinCatCol( + string h, string k, string p, Func get, Action set) + => new(new CsvTypedColumn(h, k, p), get, set); + + private static CsvModelColumn FocasCol( + string h, string k, string p, Func get, Action set) + => new(new CsvTypedColumn(h, k, p), get, set); + + private static CsvModelColumn OpcUaCol( + string h, string k, string p, Func get, Action set) + => new(new CsvTypedColumn(h, k, p), get, set); +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CsvColumnMapReflectionTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CsvColumnMapReflectionTests.cs new file mode 100644 index 00000000..e45703e9 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CsvColumnMapReflectionTests.cs @@ -0,0 +1,85 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Drift guard for the WP5 CSV column dictionary (): every typed column of a +/// model-backed driver map must name a real property on that driver's TagConfigModel]]>, +/// and the registered typed-column set per driver must match the batch-plan column dictionary exactly. A +/// model rename or a column-dictionary change breaks this test rather than silently corrupting import/export. +/// +[Trait("Category", "Unit")] +public sealed class CsvColumnMapReflectionTests +{ + [Fact] + public void Every_model_backed_typed_column_maps_to_a_real_model_property() + { + foreach (var map in CsvColumnMap.All.Where(m => m.ModelType is not null)) + { + foreach (var col in map.Columns) + { + col.ModelProperty.ShouldNotBeNull( + $"{map.DriverType} column '{col.Header}' is model-backed but declares no ModelProperty."); + + map.ModelType!.GetProperty(col.ModelProperty!) + .ShouldNotBeNull( + $"{map.DriverType} column '{col.Header}' maps to '{col.ModelProperty}', " + + $"which is not a property on {map.ModelType!.Name}."); + } + } + } + + [Fact] + public void Model_less_maps_declare_no_model_property() + { + foreach (var map in CsvColumnMap.All.Where(m => m.ModelType is null)) + { + foreach (var col in map.Columns) + { + col.ModelProperty.ShouldBeNull( + $"{map.DriverType} column '{col.Header}' is model-less but declares ModelProperty '{col.ModelProperty}'."); + } + } + } + + [Theory] + [InlineData(DriverTypeNames.Modbus, "Region", "Address", "ModbusDataType", "ByteOrder", "BitIndex", "StringLength", "Writable")] + [InlineData(DriverTypeNames.S7, "Address", "S7DataType", "StringLength", "Writable")] + [InlineData(DriverTypeNames.AbCip, "TagPath", "AbCipDataType", "Writable")] + [InlineData(DriverTypeNames.AbLegacy, "Address", "AbLegacyDataType", "Writable")] + [InlineData(DriverTypeNames.TwinCAT, "SymbolPath", "TwinCATDataType", "Writable")] + [InlineData(DriverTypeNames.FOCAS, "Address", "FocasDataType")] + [InlineData(DriverTypeNames.OpcUaClient, "NodeId")] + [InlineData(DriverTypeNames.Galaxy, "AttributeRef")] + [InlineData(CsvColumnMap.CalculationDriverType, "ScriptId", "ChangeTriggered", "TimerIntervalMs")] + public void Registered_typed_columns_match_the_batch_plan_dictionary(string driverType, params string[] expected) + { + var map = CsvColumnMap.Resolve(driverType); + map.ShouldNotBeNull($"No CSV map registered for driver '{driverType}'."); + map!.Headers.ShouldBe(expected); + } + + [Fact] + public void HeadersFor_puts_typed_columns_between_common_leading_and_the_TagConfigJson_fallback() + { + var headers = CsvColumnMap.HeadersFor(DriverTypeNames.Modbus); + + // Leading common columns come first, in order. + headers.Take(CsvColumnMap.CommonLeadingColumns.Count).ShouldBe(CsvColumnMap.CommonLeadingColumns); + // The typed Modbus columns come next. + headers.Skip(CsvColumnMap.CommonLeadingColumns.Count).Take(7) + .ShouldBe(CsvColumnMap.Resolve(DriverTypeNames.Modbus)!.Headers); + // TagConfigJson is always the trailing fallback. + headers[^1].ShouldBe(CsvColumnMap.TagConfigJsonColumn); + } + + [Fact] + public void Unknown_driver_headers_are_the_common_set_only() + { + CsvColumnMap.Resolve("NoSuchDriver").ShouldBeNull(); + CsvColumnMap.HeadersFor("NoSuchDriver").ShouldBe(CsvColumnMap.CommonColumns); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagCsvRoundTripTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagCsvRoundTripTests.cs new file mode 100644 index 00000000..31dc679f --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagCsvRoundTripTests.cs @@ -0,0 +1,233 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Commons.Csv; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Property test for the WP5 CSV export→import round-trip: a device's +/// tags then re-parse the produced CSV with and assert the reconstructed +/// tag set is identical (name / group path / data type / access / write flag / poll group and a semantically +/// equal TagConfig). Exercises typed driver columns, the historize/array/alarm flag columns, the +/// residual TagConfigJson fallback (typed-wins), and the model-less (Galaxy) raw-key map. +/// +[Trait("Category", "Unit")] +public sealed class RawTagCsvRoundTripTests +{ + [Fact] + public void Modbus_device_round_trips_typed_columns_flags_and_residual() + { + // A rich Modbus config: every typed column + historize + array + alarm + a residual ("scale") + // key the TagConfigJson fallback must carry. + var richTag = new RawCsvExportTag( + TagGroupPath: "Bank1/Analog", + Name: "flow-01", + DataType: "Float", + AccessLevel: TagAccessLevel.ReadWrite, + WriteIdempotent: true, + PollGroupId: "fast", + TagConfig: """ + {"region":"InputRegisters","address":10,"dataType":"Float32","byteOrder":"WordSwap", + "bitIndex":0,"stringLength":0,"writable":false,"scale":3.5, + "isHistorized":true,"historianTagname":"MyHist","isArray":true,"arrayLength":4, + "alarm":{"alarmType":"LimitAlarm","severity":800,"historizeToAveva":false}} + """); + + // A device-root tag (null group path) with only typed columns + no flags. + var plainTag = new RawCsvExportTag( + TagGroupPath: null, + Name: "coil-3", + DataType: "Boolean", + AccessLevel: TagAccessLevel.Read, + WriteIdempotent: false, + PollGroupId: null, + TagConfig: ModbusModel("Coils", 3, "Bool", "BigEndian", writable: null)); + + AssertRoundTrip(DriverTypeNames.Modbus, new[] { richTag, plainTag }); + } + + [Fact] + public void OpcUaClient_and_Galaxy_maps_round_trip() + { + var opc = new RawCsvExportTag( + "Line/Motors", "m1", "Double", TagAccessLevel.ReadWrite, false, null, + """{"nodeId":"nsu=urn:plc;s=Motor1.Speed","isHistorized":true}"""); + + AssertRoundTrip(DriverTypeNames.OpcUaClient, new[] { opc }); + + var galaxy = new RawCsvExportTag( + null, "Reactor_Temp", "Float", TagAccessLevel.Read, false, null, + """{"FullName":"Reactor_001.Temp","historianTagname":"Reactor.Temp"}"""); + + AssertRoundTrip(DriverTypeNames.Galaxy, new[] { galaxy }); + } + + [Fact] + public void Export_header_matches_the_driver_column_dictionary() + { + var csv = RawTagCsvMapper.Export(DriverTypeNames.S7, Array.Empty()); + var rows = CsvParser.Parse(csv); + rows.ShouldHaveSingleItem(); // header only + rows[0].ShouldBe(CsvColumnMap.HeadersFor(DriverTypeNames.S7).ToArray()); + } + + [Fact] + public void A_typed_column_wins_over_a_conflicting_TagConfigJson_key() + { + // Base carries address="DB1.DBW99"; the typed Address column carries "DB1.DBW5" — the typed value + // must win, and the review row must flag the override. (S7 Address is a string field.) + var csv = string.Join("\r\n", + "Name,DataType,Address,S7DataType,TagConfigJson", + """flow,Int16,DB1.DBW5,Int16,"{""address"":""DB1.DBW99""}" """.Trim()); + + var result = RawTagCsvMapper.Parse(DriverTypeNames.S7, csv); + result.FatalError.ShouldBeNull(); + var row = result.Rows.ShouldHaveSingleItem(); + row.Ok.ShouldBeTrue(); + row.TypedOverrodeFallback.ShouldContain("Address"); + + var o = JsonNode.Parse(row.ImportRow!.Tag.TagConfig)!.AsObject(); + o["address"]!.GetValue().ShouldBe("DB1.DBW5"); + } + + [Fact] + public void A_bad_enum_row_is_flagged_and_blocks_commit() + { + var csv = string.Join("\r\n", + "Name,DataType,Region,Address,ModbusDataType", + "t1,Int16,NotARegion,0,Int16"); + + var result = RawTagCsvMapper.Parse(DriverTypeNames.Modbus, csv); + var row = result.Rows.ShouldHaveSingleItem(); + row.Ok.ShouldBeFalse(); + row.Error!.ShouldContain("Region"); + result.CanCommit.ShouldBeFalse(); + } + + [Fact] + public void Duplicate_name_in_the_same_group_is_flagged_on_the_second_row() + { + var csv = string.Join("\r\n", + "Name,DataType,TagGroupPath,NodeId", + "dup,Int32,G1,ns=2;s=A", + "dup,Int32,G1,ns=2;s=B"); + + var result = RawTagCsvMapper.Parse(DriverTypeNames.OpcUaClient, csv); + result.Rows[0].Ok.ShouldBeTrue(); + result.Rows[1].Ok.ShouldBeFalse(); + result.Rows[1].Error!.ShouldContain("Duplicate"); + result.CanCommit.ShouldBeFalse(); + } + + [Fact] + public void Group_path_prefix_is_prepended_to_each_row() + { + var csv = string.Join("\r\n", + "Name,DataType,TagGroupPath,NodeId", + "a,Int32,Sub,ns=2;s=A", + "b,Int32,,ns=2;s=B"); + + var result = RawTagCsvMapper.Parse(DriverTypeNames.OpcUaClient, csv, groupPathPrefix: "Root"); + result.Rows[0].ImportRow!.TagGroupPath.ShouldBe("Root/Sub"); + result.Rows[1].ImportRow!.TagGroupPath.ShouldBe("Root"); + } + + // -------------------------------------------------------------------------------------- helpers + + private static void AssertRoundTrip(string driverType, IReadOnlyList tags) + { + var csv = RawTagCsvMapper.Export(driverType, tags); + var parsed = RawTagCsvMapper.Parse(driverType, csv); + + parsed.FatalError.ShouldBeNull(); + parsed.CanCommit.ShouldBeTrue($"every re-imported row should be valid; errors: " + + string.Join(" | ", parsed.Rows.Where(r => !r.Ok).Select(r => r.Error))); + + var imported = parsed.ToImportRows(); + imported.Count.ShouldBe(tags.Count); + + foreach (var original in tags) + { + var match = imported.SingleOrDefault(r => r.Tag.Name == original.Name); + match.ShouldNotBeNull($"tag '{original.Name}' vanished across the round-trip."); + + match!.TagGroupPath.ShouldBe(original.TagGroupPath, $"group path drift for '{original.Name}'."); + match.Tag.DataType.ShouldBe(original.DataType); + match.Tag.AccessLevel.ShouldBe(original.AccessLevel); + match.Tag.WriteIdempotent.ShouldBe(original.WriteIdempotent); + match.Tag.PollGroupId.ShouldBe(original.PollGroupId); + + Canonical(match.Tag.TagConfig).ShouldBe(Canonical(original.TagConfig), + $"TagConfig drift for '{original.Name}'."); + } + } + + // Canonical Modbus config via the real model, so the "expected" already carries the model's full key set. + private static string ModbusModel(string region, int address, string dataType, string byteOrder, bool? writable) + { + var m = ModbusTagConfigModel.FromJson("{}"); + m.Region = Enum.Parse(region); + m.Address = address; + m.DataType = Enum.Parse(dataType); + m.ByteOrder = Enum.Parse(byteOrder); + m.Writable = writable; + return m.ToJson(); + } + + // Order-independent, numeric-normalised JSON canonicaliser for semantic equality. + private static string Canonical(string json) + { + var node = JsonNode.Parse(json); + return Normalize(node)?.ToJsonString() ?? "null"; + } + + private static JsonNode? Normalize(JsonNode? node) + { + switch (node) + { + case JsonObject obj: + { + var sorted = new JsonObject(); + foreach (var key in obj.Select(kvp => kvp.Key).OrderBy(k => k, StringComparer.Ordinal)) + { + sorted[key] = Normalize(obj[key]?.DeepClone()); + } + + return sorted; + } + + case JsonArray arr: + { + var outArr = new JsonArray(); + foreach (var item in arr) + { + outArr.Add(Normalize(item?.DeepClone())); + } + + return outArr; + } + + case JsonValue val: + { + // Collapse numeric representations (int vs uint vs double 4 vs 4.0) to a canonical form. + if (val.GetValueKind() == JsonValueKind.Number && + double.TryParse(val.ToJsonString(), System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var d)) + { + return JsonValue.Create(d); + } + + return val; + } + + default: + return node; + } + } +}