merge wt/wp5 into v3/batch2-raw-ui (Wave B UI)

This commit is contained in:
Joseph Doherty
2026-07-16 03:27:23 -04:00
8 changed files with 1867 additions and 0 deletions
@@ -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):
<RawCsvExportModal @bind-Visible="_exportVisible"
DeviceId="@_exportDeviceId" (may be blank if TagGroupId is supplied)
TagGroupId="@_exportTagGroupId" (when set, only that group's subtree is exported)
DriverType="@_exportDriverType" /> *@
@using System.Text
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@inject RawTagCsvExportReader ExportReader
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Export tags — CSV</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="Close"></button>
</div>
<div class="modal-body">
@if (_loading)
{
<p class="text-muted"><span class="spinner-border spinner-border-sm"></span> Reading tags…</p>
}
else if (_error is not null)
{
<div class="alert alert-danger">@_error</div>
}
else
{
<p class="text-muted">
<span class="badge bg-secondary">@_rowCount tags</span>
for <span class="mono">@DriverType</span> device
@if (!string.IsNullOrEmpty(_groupFilter))
{
<span>, group <span class="mono">@_groupFilter</span></span>
}
</p>
<textarea class="form-control mono small" rows="10" readonly>@_csv</textarea>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="Close">Close</button>
@if (!_loading && _error is null)
{
<a class="btn btn-primary" download="@_fileName" href="@_dataUri">Download CSV</a>
}
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown (two-way bound).</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Raised when <see cref="Visible"/> changes (supports <c>@bind-Visible</c>).</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>The device to export. May be blank when <see cref="TagGroupId"/> is supplied.</summary>
[Parameter] public string DeviceId { get; set; } = "";
/// <summary>When set, only tags under this group's subtree are exported; null exports the whole device.</summary>
[Parameter] public string? TagGroupId { get; set; }
/// <summary>The device's driver-type string — selects the per-driver typed column set.</summary>
[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);
}
}
@@ -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):
<RawCsvImportModal @bind-Visible="_importVisible"
DeviceId="@_importDeviceId" (may be blank if TagGroupId is supplied)
TagGroupId="@_importTagGroupId" (target group; its path is prepended to every row)
DriverType="@_importDriverType"
OnSaved="ReloadNodeChildren" />
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)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Import tags — CSV</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="Close"></button>
</div>
<div class="modal-body">
@if (!string.IsNullOrEmpty(_fatal))
{
<div class="alert alert-danger">@_fatal</div>
}
@if (_result is null)
{
<p class="text-muted">
Upload a CSV whose header row carries the columns for a
<span class="mono">@DriverType</span> device.
Target: <span class="mono">@TargetLabel</span>.
</p>
<InputFile OnChange="OnFileSelected" accept=".csv,text/csv" class="form-control" />
@if (_parsing)
{
<p class="text-muted mt-2"><span class="spinner-border spinner-border-sm"></span> Parsing…</p>
}
}
else
{
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<span class="badge bg-secondary">@_result.Rows.Count rows</span>
<span class="badge bg-success">@OkCount valid</span>
@if (ErrorCount > 0)
{
<span class="badge bg-danger">@ErrorCount invalid</span>
}
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="Reset">Choose another file</button>
</div>
@if (ErrorCount > 0)
{
<div class="alert alert-warning py-1">
Import is all-or-nothing — fix or remove the invalid rows before committing.
</div>
}
<div class="table-responsive" style="max-height:50vh;overflow:auto">
<table class="table table-sm table-hover align-middle">
<thead class="sticky-top bg-body">
<tr>
<th style="width:2.5rem"></th>
<th>Row</th>
<th>Name</th>
<th>Group</th>
<th>Verdict</th>
<th>Provenance</th>
<th style="width:2.5rem"></th>
</tr>
</thead>
<tbody>
@foreach (var row in _result.Rows)
{
if (_removed.Contains(row.RowNumber)) { continue; }
<tr class="@(row.Ok ? "" : "table-danger")">
<td>@(row.Ok ? "✓" : "✕")</td>
<td class="mono">@row.RowNumber</td>
<td class="mono">@row.Name</td>
<td class="mono small">@(row.EffectiveGroupPath.Length == 0 ? "(device root)" : row.EffectiveGroupPath)</td>
<td>
@if (row.Ok)
{
<span class="text-success">OK</span>
}
else
{
<span class="text-danger">@row.Error</span>
}
</td>
<td class="small">
@if (row.TypedColumnsUsed.Count > 0)
{
<span class="text-muted">typed: </span>
<span class="mono">@string.Join(", ", row.TypedColumnsUsed)</span>
}
@if (row.FallbackKeys.Count > 0)
{
<br /><span class="text-muted">TagConfigJson: </span>
<span class="mono">@string.Join(", ", row.FallbackKeys)</span>
}
@if (row.TypedOverrodeFallback.Count > 0)
{
<br /><span class="badge bg-info text-dark" title="A typed column overrode the same key in TagConfigJson">
typed wins: @string.Join(", ", row.TypedOverrodeFallback)
</span>
}
</td>
<td>
<button type="button" class="btn btn-sm btn-outline-danger py-0"
title="Remove this row from the import"
@onclick="() => RemoveRow(row.RowNumber)">✕</button>
</td>
</tr>
}
</tbody>
</table>
</div>
@if (_commitErrors.Count > 0)
{
<div class="alert alert-danger mt-2">
<strong>Commit rejected:</strong>
<ul class="mb-0">
@foreach (var e in _commitErrors)
{
<li>@e</li>
}
</ul>
</div>
}
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="Close">Cancel</button>
<button type="button" class="btn btn-primary"
disabled="@(!CanCommit || _committing)"
@onclick="Commit">
@if (_committing)
{
<span class="spinner-border spinner-border-sm"></span>
}
Commit @RemainingCount tag@(RemainingCount == 1 ? "" : "s")
</button>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown (two-way bound).</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Raised when <see cref="Visible"/> changes (supports <c>@bind-Visible</c>).</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>The target device's logical id. May be blank when <see cref="TagGroupId"/> is supplied
/// (the device is then resolved from the group).</summary>
[Parameter] public string DeviceId { get; set; } = "";
/// <summary>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 <c>TagGroupPath</c>.</summary>
[Parameter] public string? TagGroupId { get; set; }
/// <summary>The device's driver-type string — selects the per-driver typed column set.</summary>
[Parameter] public string DriverType { get; set; } = "";
/// <summary>Raised after a successful commit so the coordinator can reload the node's children.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
private RawCsvParseResult? _result;
private readonly HashSet<int> _removed = new();
private List<string> _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);
}
}
@@ -49,6 +49,8 @@ public static class EndpointRouteBuilderExtensions
services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>();
services.AddScoped<IUnsTreeService, UnsTreeService>();
services.AddScoped<IRawTreeService, RawTreeService>();
// WP5 CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude).
services.AddScoped<RawTagCsvExportReader>();
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
// Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the
@@ -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;
/// <summary>
/// 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 <c>/</c>-separated
/// <see cref="RawCsvExportTag.TagGroupPath"/>) into the export shape. Owned by WP5 — it does NOT extend
/// <see cref="IRawTreeService"/> (that prelude is closed); it takes its own
/// <see cref="IDbContextFactory{TContext}"/> so export never mutates. Also resolves a tag-group's path +
/// owning device (the import modal's target-group prefix seam).
/// </summary>
/// <param name="dbFactory">The config-DB context factory.</param>
public sealed class RawTagCsvExportReader(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
{
/// <summary>
/// The owning device + reconstructed path for a tag-group (used by the import modal to prepend a
/// target-group prefix to every imported row).
/// </summary>
/// <param name="DeviceId">The group's owning device id.</param>
/// <param name="GroupPath">The group's <c>/</c>-separated path under the device (device-relative, no device segment).</param>
public readonly record struct TagGroupContext(string DeviceId, string GroupPath);
/// <summary>Resolves the driver-type string of the device's parent driver, or null when unresolved.</summary>
/// <param name="deviceId">The device's logical id.</param>
/// <param name="ct">A cancellation token.</param>
/// <returns>The driver-type string, or null.</returns>
public async Task<string?> 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);
}
/// <summary>
/// Resolves a tag-group's owning device + device-relative path. Walks the <c>ParentTagGroupId</c> chain
/// to the device root and joins the segment names with <c>/</c>. Returns null when the group is unknown.
/// </summary>
/// <param name="tagGroupId">The tag-group's logical id.</param>
/// <param name="ct">A cancellation token.</param>
/// <returns>The owning device + reconstructed path, or null when the group does not exist.</returns>
public async Task<TagGroupContext?> 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);
}
/// <summary>
/// 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).
/// </summary>
/// <param name="deviceId">The device's logical id.</param>
/// <param name="ct">A cancellation token.</param>
/// <returns>The device's tags flattened for export.</returns>
public async Task<IReadOnlyList<RawCsvExportTag>> 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<string, (string Name, string? ParentTagGroupId)> byId)
{
var segments = new Stack<string>();
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);
}
}
@@ -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;
/// <summary>
/// The pure WP5 CSV import/export engine for the Raw tree. Bridges the T0-2 <see cref="CsvParser"/> /
/// <see cref="CsvWriter"/> and the per-driver <see cref="CsvColumnMap"/> to the service's
/// <see cref="RawTagImportRow"/> commit shape: <see cref="Parse"/> turns an uploaded CSV into a
/// per-row review model with validation verdicts; <see cref="Export"/> renders a device's tags back to
/// the same column shape. No I/O, no DB, no Blazor — unit-testable in isolation.
/// <para>
/// A per-tag driver <c>TagConfig</c> JSON is assembled in a fixed precedence:
/// <c>TagConfigJson</c> (base) ← typed driver columns (override) ← the alarm / historize / array flag
/// columns. A typed column therefore <b>wins</b> over a conflicting key carried in the <c>TagConfigJson</c>
/// fallback; the review model flags when that override happened so the operator sees it.
/// </para>
/// </summary>
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
/// <summary>
/// Parses an uploaded CSV into a review model: one <see cref="RawCsvReviewRow"/> per data row with a
/// validation verdict, the assembled <see cref="RawTagImportRow"/> (when valid), and the typed vs.
/// <c>TagConfigJson</c>-fallback provenance. Intra-batch duplicate names (same effective group + name)
/// are flagged on the later row. This is a dry parse — nothing is committed.
/// </summary>
/// <param name="driverType">The target device's driver-type string (selects the typed column map).</param>
/// <param name="csvText">The uploaded CSV document.</param>
/// <param name="groupPathPrefix">The target tag-group's path prefix (from the modal's TagGroupId), prepended
/// to every row's <c>TagGroupPath</c>; null/blank when importing at the device root.</param>
/// <returns>The parse result: header list, per-row review rows, and any fatal (whole-file) error.</returns>
public static RawCsvParseResult Parse(string driverType, string? csvText, string? groupPathPrefix = null)
{
IReadOnlyList<string[]> raw;
try
{
raw = CsvParser.Parse(csvText);
}
catch (FormatException ex)
{
return new RawCsvParseResult(Array.Empty<string>(), Array.Empty<RawCsvReviewRow>(), $"Malformed CSV: {ex.Message}");
}
if (raw.Count == 0)
{
return new RawCsvParseResult(Array.Empty<string>(), Array.Empty<RawCsvReviewRow>(), "The file is empty.");
}
var headers = raw[0];
var headerIndex = new Dictionary<string, int>(StringComparer.Ordinal);
for (var i = 0; i < headers.Length; i++)
{
if (!headerIndex.TryAdd(headers[i], i))
{
return new RawCsvParseResult(headers, Array.Empty<RawCsvReviewRow>(), $"Duplicate header column '{headers[i]}'.");
}
}
if (!headerIndex.ContainsKey("Name"))
{
return new RawCsvParseResult(headers, Array.Empty<RawCsvReviewRow>(), "Required column 'Name' is missing from the header row.");
}
var map = CsvColumnMap.Resolve(driverType);
var reviewed = new List<RawCsvReviewRow>(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<string, int> headerIndex,
string[] cells,
CsvDriverTagMap? map,
string? groupPathPrefix)
{
var row = new Dictionary<string, string>(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<string>();
var overrodeFallback = new List<string>();
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<TagAccessLevel>("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<string>(),
Error = error,
TypedColumnsUsed = Array.Empty<string>(),
FallbackKeys = Array.Empty<string>(),
TypedOverrodeFallback = Array.Empty<string>(),
};
// Flags a same-(group+name) collision within the parsed batch on the SECOND-and-later row.
private static void FlagDuplicates(List<RawCsvReviewRow> 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<string> ReadRootKeys(string json, out bool valid)
{
JsonNode? node;
try
{
node = JsonNode.Parse(json);
}
catch (System.Text.Json.JsonException)
{
valid = false;
return Array.Empty<string>();
}
if (node is not JsonObject obj)
{
valid = false;
return Array.Empty<string>();
}
valid = true;
return obj.Select(kvp => kvp.Key).ToArray();
}
// ---------------------------------------------------------------------------------------- Export
/// <summary>
/// Renders a device's tags to a CSV string in the same column shape <see cref="Parse"/> consumes:
/// the common columns, the driver's typed columns, then the trailing <c>TagConfigJson</c> fallback
/// carrying only the residual keys (those not surfaced by a typed or flag column) so a round-trip is
/// exact and non-redundant.
/// </summary>
/// <param name="driverType">The device's driver-type string (selects the typed column set).</param>
/// <param name="tags">The tags to export.</param>
/// <returns>The CSV document (CRLF, quote-on-demand).</returns>
public static string Export(string driverType, IEnumerable<RawCsvExportTag> tags)
{
var map = CsvColumnMap.Resolve(driverType);
var typedHeaders = map?.Headers ?? Array.Empty<string>();
var header = CsvColumnMap.HeadersFor(driverType);
var rows = new List<string[]> { 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<string> 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<string, string>();
var cells = new List<string>(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<string>())
{
o.Remove(key);
}
foreach (var key in FlagKeys)
{
o.Remove(key);
}
return o.Count == 0 ? "" : TagConfigJson.Serialize(o);
}
}
/// <summary>
/// One tag flattened for CSV export: its effective tag-group path under the device plus the operator-facing
/// fields. Produced by <see cref="RawTagCsvExportReader"/> and consumed by <see cref="RawTagCsvMapper.Export"/>.
/// </summary>
/// <param name="TagGroupPath">The <c>/</c>-separated group path under the device, or null for a device-root tag.</param>
/// <param name="Name">The tag name.</param>
/// <param name="DataType">The OPC UA built-in type name.</param>
/// <param name="AccessLevel">The tag's access-level baseline.</param>
/// <param name="WriteIdempotent">Whether writes are retry-eligible.</param>
/// <param name="PollGroupId">The optional poll-group id.</param>
/// <param name="TagConfig">The raw per-driver TagConfig JSON blob.</param>
public sealed record RawCsvExportTag(
string? TagGroupPath,
string Name,
string DataType,
TagAccessLevel AccessLevel,
bool WriteIdempotent,
string? PollGroupId,
string TagConfig);
/// <summary>
/// One reviewed CSV data row: its 1-based row number, raw cells, validation verdict, and — when valid —
/// the assembled <see cref="RawTagImportRow"/> plus the typed vs. <c>TagConfigJson</c>-fallback provenance
/// the review grid renders.
/// </summary>
public sealed class RawCsvReviewRow
{
/// <summary>The 1-based data-row number in the source file (header is row 0).</summary>
public int RowNumber { get; init; }
/// <summary>The raw cell values as parsed.</summary>
public IReadOnlyList<string> Cells { get; init; } = Array.Empty<string>();
/// <summary>The header row (for aligning <see cref="Cells"/> in the grid).</summary>
public IReadOnlyList<string> Headers { get; init; } = Array.Empty<string>();
/// <summary>The validation error, or <c>null</c> when the row is valid.</summary>
public string? Error { get; set; }
/// <summary>Whether the row is valid (commit-eligible).</summary>
public bool Ok => Error is null;
/// <summary>The assembled import row when valid; <c>null</c> otherwise.</summary>
public RawTagImportRow? ImportRow { get; set; }
/// <summary>The typed-column headers that supplied a value on this row.</summary>
public IReadOnlyList<string> TypedColumnsUsed { get; init; } = Array.Empty<string>();
/// <summary>The root keys present in the row's <c>TagConfigJson</c> fallback.</summary>
public IReadOnlyCollection<string> FallbackKeys { get; init; } = Array.Empty<string>();
/// <summary>The typed columns that overrode a conflicting key carried in <c>TagConfigJson</c>.</summary>
public IReadOnlyList<string> TypedOverrodeFallback { get; init; } = Array.Empty<string>();
/// <summary>The effective tag-group path (prefix + row's TagGroupPath) used for duplicate detection.</summary>
public string EffectiveGroupPath { get; init; } = "";
/// <summary>The tag name (echoed for duplicate detection + grid display).</summary>
public string Name { get; init; } = "";
}
/// <summary>
/// The outcome of <see cref="RawTagCsvMapper.Parse"/>: 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 <see cref="FatalError"/> is set, <see cref="Rows"/> is empty.
/// </summary>
/// <param name="Headers">The parsed header row.</param>
/// <param name="Rows">The per-row review model.</param>
/// <param name="FatalError">A whole-file error, or null when the file parsed.</param>
public sealed record RawCsvParseResult(
IReadOnlyList<string> Headers,
IReadOnlyList<RawCsvReviewRow> Rows,
string? FatalError)
{
/// <summary>Whether the file parsed and every row is valid (commit is unblocked).</summary>
public bool CanCommit => FatalError is null && Rows.Count > 0 && Rows.All(r => r.Ok);
/// <summary>The valid rows' assembled import rows, ready for <c>ImportTagsAsync</c>.</summary>
public IReadOnlyList<RawTagImportRow> ToImportRows()
=> Rows.Where(r => r is { Ok: true, ImportRow: not null }).Select(r => r.ImportRow!).ToArray();
}
@@ -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;
/// <summary>
/// Metadata for one per-driver <b>typed</b> 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
/// <c><![CDATA[<Driver>TagConfigModel]]></c> it corresponds to. <see cref="ModelProperty"/> 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 <c>null</c> for the model-less maps (Galaxy / Calculation), which map
/// a header directly onto a JSON key.
/// </summary>
/// <param name="Header">The CSV column header (PascalCase, e.g. <c>ModbusDataType</c>).</param>
/// <param name="JsonKey">The root TagConfig JSON key this column reads/writes (camelCase, e.g. <c>dataType</c>).</param>
/// <param name="ModelProperty">The backing model property name, or <c>null</c> for a model-less map.</param>
public sealed record CsvTypedColumn(string Header, string JsonKey, string? ModelProperty);
/// <summary>
/// Per-driver mapping between a CSV row's <b>typed</b> columns and a tag's driver-specific
/// <c>TagConfig</c> JSON. The map is the inverse pair the WP5 CSV import/export path pivots on:
/// <see cref="ApplyTypedColumns"/> folds a row's typed columns onto a base TagConfig (typed column wins
/// on conflict); <see cref="ReadTypedColumns"/> projects a stored TagConfig back to its typed cells for
/// export. Lives next to the <c><![CDATA[<Driver>TagConfigModel]]></c> types so model + map evolve
/// together.
/// </summary>
public abstract class CsvDriverTagMap
{
/// <summary>The driver-type string this map serves (a <see cref="DriverTypeNames"/> value).</summary>
public abstract string DriverType { get; }
/// <summary>The backing model type whose scalar surface this map mirrors, or <c>null</c> for a
/// model-less (raw-JSON-key) map (Galaxy / Calculation).</summary>
public abstract Type? ModelType { get; }
/// <summary>The ordered typed columns this driver adds to the common CSV column set.</summary>
public abstract IReadOnlyList<CsvTypedColumn> Columns { get; }
/// <summary>The typed column headers, in order.</summary>
public IReadOnlyList<string> Headers => Columns.Select(c => c.Header).ToArray();
/// <summary>The root TagConfig JSON keys this map owns (used to compute the export residual).</summary>
public IReadOnlyList<string> JsonKeys => Columns.Select(c => c.JsonKey).ToArray();
/// <summary>
/// Folds the typed columns present (non-blank) in <paramref name="row"/> onto
/// <paramref name="baseJson"/> 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 <b>wins</b> over a conflicting key carried in the <c>TagConfigJson</c> base.
/// </summary>
/// <param name="baseJson">The base TagConfig JSON (from the row's <c>TagConfigJson</c> column), or null/blank.</param>
/// <param name="row">The CSV row as a header→value map.</param>
/// <returns>The merged TagConfig JSON string.</returns>
/// <exception cref="FormatException">A typed cell is not a valid value for its column (bad enum / number / bool).</exception>
public abstract string ApplyTypedColumns(string? baseJson, IReadOnlyDictionary<string, string> row);
/// <summary>
/// Projects the typed columns out of a stored TagConfig JSON for export — the inverse of
/// <see cref="ApplyTypedColumns"/>. A column absent from the JSON is omitted from the result.
/// </summary>
/// <param name="tagConfigJson">The tag's stored TagConfig JSON, or null/blank.</param>
/// <returns>The typed columns present, keyed by CSV header.</returns>
public abstract IReadOnlyDictionary<string, string> ReadTypedColumns(string? tagConfigJson);
}
/// <summary>One typed column of a model-backed map: its metadata plus the read/write pair over the
/// concrete <typeparamref name="TModel"/> working model.</summary>
/// <typeparam name="TModel">The driver's TagConfig working-model type.</typeparam>
/// <param name="Meta">The column metadata (header / JSON key / model property).</param>
/// <param name="Get">Projects the model's value to a cell string, or <c>null</c> to omit the column on export.</param>
/// <param name="Set">Parses a non-blank cell onto the model (throws <see cref="FormatException"/> on a bad value).</param>
public sealed record CsvModelColumn<TModel>(
CsvTypedColumn Meta,
Func<TModel, string?> Get,
Action<TModel, string> Set);
/// <summary>
/// A <see cref="CsvDriverTagMap"/> driven by a driver's typed working model. Import loads the base JSON
/// through the model's <c>FromJson</c> (preserving unknown keys), applies each present typed cell via its
/// <see cref="CsvModelColumn{TModel}.Set"/>, and serialises back through the model's <c>ToJson</c> — so
/// the CSV path reuses the exact same preserve-unknown / typed-wins merge the TagModal editor uses.
/// </summary>
/// <typeparam name="TModel">The driver's TagConfig working-model type.</typeparam>
public sealed class ModelBackedCsvDriverTagMap<TModel> : CsvDriverTagMap
{
private readonly Func<string?, TModel> _fromJson;
private readonly Func<TModel, string> _toJson;
private readonly IReadOnlyList<CsvModelColumn<TModel>> _columns;
/// <summary>Builds a model-backed map.</summary>
/// <param name="driverType">The driver-type string served.</param>
/// <param name="fromJson">The model's <c>FromJson</c> loader (preserves unknown keys).</param>
/// <param name="toJson">The model's <c>ToJson</c> serialiser.</param>
/// <param name="columns">The typed columns (metadata + read/write pairs).</param>
public ModelBackedCsvDriverTagMap(
string driverType,
Func<string?, TModel> fromJson,
Func<TModel, string> toJson,
IReadOnlyList<CsvModelColumn<TModel>> columns)
{
DriverType = driverType;
_fromJson = fromJson;
_toJson = toJson;
_columns = columns;
Columns = columns.Select(c => c.Meta).ToArray();
}
/// <inheritdoc/>
public override string DriverType { get; }
/// <inheritdoc/>
public override Type? ModelType => typeof(TModel);
/// <inheritdoc/>
public override IReadOnlyList<CsvTypedColumn> Columns { get; }
/// <inheritdoc/>
public override string ApplyTypedColumns(string? baseJson, IReadOnlyDictionary<string, string> 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);
}
/// <inheritdoc/>
public override IReadOnlyDictionary<string, string> ReadTypedColumns(string? tagConfigJson)
{
var model = _fromJson(tagConfigJson);
var result = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var col in _columns)
{
if (col.Get(model) is { } v)
{
result[col.Meta.Header] = v;
}
}
return result;
}
}
/// <summary>The value shape of a raw-key (model-less) CSV column.</summary>
public enum CsvRawKeyKind
{
/// <summary>A JSON string value.</summary>
String,
/// <summary>A JSON boolean value (parsed case-insensitively from <c>true</c>/<c>false</c>).</summary>
Bool,
/// <summary>A JSON integer value.</summary>
Int,
}
/// <summary>Metadata + value-kind for one column of a raw-key map.</summary>
/// <param name="Meta">The column metadata (header / JSON key; model property is null).</param>
/// <param name="Kind">The JSON value kind the header maps onto.</param>
public sealed record CsvRawKeyColumn(CsvTypedColumn Meta, CsvRawKeyKind Kind);
/// <summary>
/// A <see cref="CsvDriverTagMap"/> 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.
/// </summary>
public sealed class RawKeyCsvDriverTagMap : CsvDriverTagMap
{
private readonly IReadOnlyList<CsvRawKeyColumn> _columns;
/// <summary>Builds a raw-key map.</summary>
/// <param name="driverType">The driver-type string served.</param>
/// <param name="columns">The header→JSON-key columns with their value kinds.</param>
public RawKeyCsvDriverTagMap(string driverType, IReadOnlyList<CsvRawKeyColumn> columns)
{
DriverType = driverType;
_columns = columns;
Columns = columns.Select(c => c.Meta).ToArray();
}
/// <inheritdoc/>
public override string DriverType { get; }
/// <inheritdoc/>
public override Type? ModelType => null;
/// <inheritdoc/>
public override IReadOnlyList<CsvTypedColumn> Columns { get; }
/// <inheritdoc/>
public override string ApplyTypedColumns(string? baseJson, IReadOnlyDictionary<string, string> 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);
}
/// <inheritdoc/>
public override IReadOnlyDictionary<string, string> ReadTypedColumns(string? tagConfigJson)
{
var o = TagConfigJson.ParseOrNew(tagConfigJson);
var result = new Dictionary<string, string>(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<bool>(out var b) ? (b ? "true" : "false") : v.ToString(),
CsvRawKeyKind.Int => v.TryGetValue<long>(out var l)
? l.ToString(CultureInfo.InvariantCulture)
: v.ToString(),
_ => v.TryGetValue<string>(out var s) ? s : v.ToString(),
};
}
}
return result;
}
}
/// <summary>Case-insensitive value parsers/formatters for typed CSV cells, throwing a uniform
/// <see cref="FormatException"/> (naming the column + the offending value) the review grid surfaces.</summary>
public static class CsvValues
{
/// <summary>Parses an enum member name case-insensitively; throws with the legal members listed.</summary>
/// <typeparam name="TEnum">The enum type.</typeparam>
/// <param name="header">The CSV column header (for the error message).</param>
/// <param name="value">The cell value.</param>
/// <returns>The parsed enum value.</returns>
/// <exception cref="FormatException">The value is not a member of <typeparamref name="TEnum"/>.</exception>
public static TEnum ParseEnum<TEnum>(string header, string value) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(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<TEnum>())}).");
/// <summary>Parses an integer (invariant); throws a column-named <see cref="FormatException"/>.</summary>
/// <param name="header">The CSV column header (for the error message).</param>
/// <param name="value">The cell value.</param>
/// <returns>The parsed integer.</returns>
/// <exception cref="FormatException">The value is not an integer.</exception>
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.");
/// <summary>Parses a boolean case-insensitively (<c>true</c>/<c>false</c>); throws a column-named error.</summary>
/// <param name="header">The CSV column header (for the error message).</param>
/// <param name="value">The cell value.</param>
/// <returns>The parsed boolean.</returns>
/// <exception cref="FormatException">The value is not a boolean.</exception>
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).");
/// <summary>Formats a nullable boolean for export: <c>null</c> ⇒ omitted (null cell), else lowercased.</summary>
/// <param name="value">The nullable boolean.</param>
/// <returns>The cell string, or <c>null</c> to omit the column.</returns>
public static string? FormatNullableBool(bool? value) => value is { } b ? (b ? "true" : "false") : null;
}
/// <summary>
/// Registry of the WP5 CSV column dictionary: the common columns every device's export/import carries,
/// plus the per-driver typed <see cref="CsvDriverTagMap"/> resolved by driver type. The single authority
/// for "which columns does a device of driver X have".
/// </summary>
public static class CsvColumnMap
{
/// <summary>The <c>TagConfigJson</c> fallback column header — the base under which typed columns merge.</summary>
public const string TagConfigJsonColumn = "TagConfigJson";
/// <summary>The common column headers that precede any typed driver columns (in order).</summary>
public static IReadOnlyList<string> CommonLeadingColumns { get; } = new[]
{
"Name",
"TagGroupPath",
"DataType",
"AccessLevel",
"WriteIdempotent",
"PollGroup",
"IsHistorized",
"HistorianTagname",
"IsArray",
"ArrayLength",
"Alarm.AlarmType",
"Alarm.Severity",
"Alarm.HistorizeToAveva",
};
/// <summary>All common columns (leading columns + the trailing <c>TagConfigJson</c> fallback).</summary>
public static IReadOnlyList<string> CommonColumns { get; } =
[.. CommonLeadingColumns, TagConfigJsonColumn];
/// <summary>The Calculation (virtual-tag) driver-type string. Not yet a <see cref="DriverTypeNames"/>
/// constant (its factory lands in a later Batch-2 package), so it is pinned here for the CSV surface.</summary>
public const string CalculationDriverType = "Calculation";
private static readonly IReadOnlyDictionary<string, CsvDriverTagMap> Maps = BuildMaps();
/// <summary>All registered driver maps.</summary>
public static IReadOnlyCollection<CsvDriverTagMap> All => (IReadOnlyCollection<CsvDriverTagMap>)Maps.Values;
/// <summary>Resolves the typed map for a driver type, or <c>null</c> when the driver has no typed
/// columns (only the common set applies).</summary>
/// <param name="driverType">The driver-type string.</param>
/// <returns>The map, or <c>null</c>.</returns>
public static CsvDriverTagMap? Resolve(string? driverType)
=> driverType is not null && Maps.TryGetValue(driverType, out var m) ? m : null;
/// <summary>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 <c>TagConfigJson</c> fallback.</summary>
/// <param name="driverType">The device's driver-type string.</param>
/// <returns>The ordered CSV header names.</returns>
public static IReadOnlyList<string> HeadersFor(string? driverType)
{
var typed = Resolve(driverType)?.Headers ?? Array.Empty<string>();
return [.. CommonLeadingColumns, .. typed, TagConfigJsonColumn];
}
private static Dictionary<string, CsvDriverTagMap> BuildMaps()
{
var maps = new CsvDriverTagMap[]
{
new ModelBackedCsvDriverTagMap<ModbusTagConfigModel>(
DriverTypeNames.Modbus, ModbusTagConfigModel.FromJson, m => m.ToJson(),
new[]
{
ModbusCol("Region", "region", nameof(ModbusTagConfigModel.Region),
m => m.Region.ToString(), (m, s) => m.Region = CsvValues.ParseEnum<ModbusRegion>("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>("ModbusDataType", s)),
ModbusCol("ByteOrder", "byteOrder", nameof(ModbusTagConfigModel.ByteOrder),
m => m.ByteOrder.ToString(), (m, s) => m.ByteOrder = CsvValues.ParseEnum<ModbusByteOrder>("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<S7TagConfigModel>(
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>("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<AbCipTagConfigModel>(
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>("AbCipDataType", s)),
AbCipCol("Writable", "writable", nameof(AbCipTagConfigModel.Writable),
m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)),
}),
new ModelBackedCsvDriverTagMap<AbLegacyTagConfigModel>(
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>("AbLegacyDataType", s)),
AbLegacyCol("Writable", "writable", nameof(AbLegacyTagConfigModel.Writable),
m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)),
}),
new ModelBackedCsvDriverTagMap<TwinCATTagConfigModel>(
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>("TwinCATDataType", s)),
TwinCatCol("Writable", "writable", nameof(TwinCATTagConfigModel.Writable),
m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)),
}),
new ModelBackedCsvDriverTagMap<FocasTagConfigModel>(
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>("FocasDataType", s)),
}),
new ModelBackedCsvDriverTagMap<OpcUaClientTagConfigModel>(
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<ModbusTagConfigModel> ModbusCol(
string h, string k, string p, Func<ModbusTagConfigModel, string?> get, Action<ModbusTagConfigModel, string> set)
=> new(new CsvTypedColumn(h, k, p), get, set);
private static CsvModelColumn<S7TagConfigModel> S7Col(
string h, string k, string p, Func<S7TagConfigModel, string?> get, Action<S7TagConfigModel, string> set)
=> new(new CsvTypedColumn(h, k, p), get, set);
private static CsvModelColumn<AbCipTagConfigModel> AbCipCol(
string h, string k, string p, Func<AbCipTagConfigModel, string?> get, Action<AbCipTagConfigModel, string> set)
=> new(new CsvTypedColumn(h, k, p), get, set);
private static CsvModelColumn<AbLegacyTagConfigModel> AbLegacyCol(
string h, string k, string p, Func<AbLegacyTagConfigModel, string?> get, Action<AbLegacyTagConfigModel, string> set)
=> new(new CsvTypedColumn(h, k, p), get, set);
private static CsvModelColumn<TwinCATTagConfigModel> TwinCatCol(
string h, string k, string p, Func<TwinCATTagConfigModel, string?> get, Action<TwinCATTagConfigModel, string> set)
=> new(new CsvTypedColumn(h, k, p), get, set);
private static CsvModelColumn<FocasTagConfigModel> FocasCol(
string h, string k, string p, Func<FocasTagConfigModel, string?> get, Action<FocasTagConfigModel, string> set)
=> new(new CsvTypedColumn(h, k, p), get, set);
private static CsvModelColumn<OpcUaClientTagConfigModel> OpcUaCol(
string h, string k, string p, Func<OpcUaClientTagConfigModel, string?> get, Action<OpcUaClientTagConfigModel, string> set)
=> new(new CsvTypedColumn(h, k, p), get, set);
}
@@ -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;
/// <summary>
/// Drift guard for the WP5 CSV column dictionary (<see cref="CsvColumnMap"/>): every typed column of a
/// model-backed driver map must name a real property on that driver's <c><![CDATA[<Driver>TagConfigModel]]></c>,
/// 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.
/// </summary>
[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);
}
}
@@ -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;
/// <summary>
/// Property test for the WP5 CSV export→import round-trip: <see cref="RawTagCsvMapper.Export"/> a device's
/// tags then re-parse the produced CSV with <see cref="RawTagCsvMapper.Parse"/> and assert the reconstructed
/// tag set is identical (name / group path / data type / access / write flag / poll group and a semantically
/// equal <c>TagConfig</c>). Exercises typed driver columns, the historize/array/alarm flag columns, the
/// residual <c>TagConfigJson</c> fallback (typed-wins), and the model-less (Galaxy) raw-key map.
/// </summary>
[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<RawCsvExportTag>());
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<string>().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<RawCsvExportTag> 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<ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusRegion>(region);
m.Address = address;
m.DataType = Enum.Parse<ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDataType>(dataType);
m.ByteOrder = Enum.Parse<ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusByteOrder>(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;
}
}
}