From 36e6ab590bac31558ac1c98b0dba21d1bafe753c Mon Sep 17 00:00:00 2001
From: Joseph Doherty
Date: Thu, 22 Oct 2026 17:40:40 -0400
Subject: [PATCH] perf(adminui): virtualize the CSV import grid; download
exports from an endpoint (R04 P6)
The import review grid rendered every parsed row and re-scanned all rows three
times per render for its counts; RawCsvReviewView recomputes rows + counts once
per change and the grid renders through Virtualize. The export modal embedded the
whole CSV twice (textarea + base64 data: URI); it now shows a 50-line preview
and links to GET /api/raw/tags.csv (ConfigEditor, like /raw), which runs the same
RawTagCsvExport builder and sends it as an attachment.
Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
---
.../Shared/Raw/RawCsvExportModal.razor | 76 ++++------
.../Shared/Raw/RawCsvImportModal.razor | 33 ++---
.../EndpointRouteBuilderExtensions.cs | 4 +
.../Uns/RawCsvReviewView.cs | 65 +++++++++
.../Uns/RawTagCsvExport.cs | 132 ++++++++++++++++++
.../Raw/ModalStaleOpenGuardTests.cs | 10 +-
.../Raw/RawCsvRenderingTests.cs | 106 ++++++++++++++
7 files changed, 356 insertions(+), 70 deletions(-)
create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawCsvReviewView.cs
create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvExport.cs
create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawCsvRenderingTests.cs
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
index 99ecdad4..f9e080a7 100644
--- 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
@@ -2,15 +2,15 @@
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.
+ CsvColumnMap). The download is a plain same-origin link to GET /api/raw/tags.csv (R04 P6) — the
+ file used to be embedded in the circuit twice (textarea + base64 data: URI); the modal now shows only
+ a bounded preview of the first RawTagCsvExport.PreviewLines lines.
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
@@ -43,7 +43,11 @@
, group @_groupFilter
}
-
+ @if (_truncated)
+ {
+ Preview — first @RawTagCsvExport.PreviewLines lines. Download for the full file.
+ }
+
}
@@ -79,8 +83,9 @@
private bool _loading;
private bool _built;
private string? _error;
- private string _csv = "";
- private string _dataUri = "";
+ private string _preview = "";
+ private bool _truncated;
+ private string _downloadUrl = "";
private string _fileName = "tags.csv";
private int _rowCount;
private string? _groupFilter;
@@ -110,48 +115,22 @@
_error = null;
try
{
- var deviceId = DeviceId;
_groupFilter = null;
-
- if (!string.IsNullOrEmpty(TagGroupId))
- {
- var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
- if (IsStale(generation)) { return; }
- if (ctx is { } c)
- {
- _groupFilter = c.GroupPath;
- if (string.IsNullOrEmpty(deviceId))
- {
- deviceId = c.DeviceId;
- }
- }
- }
-
- if (string.IsNullOrEmpty(deviceId))
+ // The same builder the download endpoint runs, so the preview matches the file.
+ var export = await RawTagCsvExport.BuildAsync(ExportReader, DeviceId, TagGroupId, DriverType);
+ if (IsStale(generation)) { return; }
+ if (export is null)
{
_error = "Could not resolve the target device.";
return;
}
- var tags = await ExportReader.ReadDeviceTagsAsync(deviceId);
- if (IsStale(generation)) { return; }
-
- // 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}";
+ _groupFilter = export.GroupPath;
+ _rowCount = export.RowCount;
+ _fileName = export.FileName;
+ // Only the bounded preview is kept on the component; the full CSV is not held by the circuit.
+ (_preview, _truncated) = RawTagCsvExport.Preview(export.Csv);
+ _downloadUrl = RawTagCsvExport.DownloadUrl(DeviceId, TagGroupId, DriverType);
}
catch (Exception ex)
{
@@ -163,18 +142,13 @@
}
}
- 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;
_openGeneration++;
- _csv = "";
- _dataUri = "";
+ _preview = "";
+ _truncated = false;
+ _downloadUrl = "";
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
index 87f7e633..9a865a82 100644
--- 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
@@ -14,6 +14,7 @@
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 Microsoft.AspNetCore.Components.Web.Virtualization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@inject IRawTreeService Svc
@inject RawTagCsvExportReader ExportReader
@@ -82,10 +83,10 @@
- @foreach (var row in _result.Rows)
- {
- if (_removed.Contains(row.RowNumber)) { continue; }
-
+ @* Virtualized (R04 P6): only the rows in the scrolled view render. *@
+
+
| @(row.Ok ? "✓" : "✕") |
@row.RowNumber |
@row.Name |
@@ -124,7 +125,7 @@
@onclick="() => RemoveRow(row.RowNumber)">✕
- }
+
@@ -184,7 +185,8 @@
[Parameter] public EventCallback OnSaved { get; set; }
private RawCsvParseResult? _result;
- private readonly HashSet _removed = new();
+ // The grid's filtered rows + counts, recomputed per change rather than per render (R04 P6).
+ private readonly RawCsvReviewView _view = new();
private List _commitErrors = new();
private string? _fatal;
private bool _parsing;
@@ -202,9 +204,9 @@
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;
+ private int OkCount => _view.OkCount;
+ private int ErrorCount => _view.ErrorCount;
+ private int RemainingCount => _view.RemainingCount;
// Commit is unblocked only when every remaining row is valid AND at least one row remains.
private bool CanCommit =>
@@ -252,7 +254,7 @@
{
_fatal = null;
_commitErrors = new();
- _removed.Clear();
+ _view.Set(null);
_parsing = true;
var generation = _openGeneration;
@@ -263,12 +265,14 @@
var text = await reader.ReadToEndAsync();
if (IsStale(generation)) { return; }
_result = RawTagCsvMapper.Parse(DriverType, text, _groupPrefix);
+ _view.Set(_result);
_fatal = _result.FatalError;
}
catch (Exception ex)
{
_fatal = $"Could not read the file: {ex.Message}";
_result = null;
+ _view.Set(null);
}
finally
{
@@ -276,14 +280,14 @@
}
}
- private void RemoveRow(int rowNumber) => _removed.Add(rowNumber);
+ private void RemoveRow(int rowNumber) => _view.Remove(rowNumber);
private void Reset()
{
_result = null;
_fatal = null;
_commitErrors = new();
- _removed.Clear();
+ _view.Set(null);
}
private async Task Commit()
@@ -299,10 +303,7 @@
var targetDeviceId = _effectiveDeviceId;
try
{
- var rows = _result.Rows
- .Where(r => r is { Ok: true, ImportRow: not null } && !_removed.Contains(r.RowNumber))
- .Select(r => r.ImportRow!)
- .ToList();
+ var rows = _view.CommitRows();
var outcome = await Svc.ImportTagsAsync(targetDeviceId, rows);
// The write has landed whether or not the modal was closed/reopened meanwhile, so the parent must
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
index cfb8063e..df42a325 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
@@ -43,6 +43,10 @@ public static class EndpointRouteBuilderExtensions
// ambiguous.
app.MapRazorComponents()
.AddInteractiveServerRenderMode();
+
+ // The /raw CSV export modal's download link (R04 P6): the file goes over plain HTTP instead of
+ // being embedded in the circuit. ConfigEditor, like the /raw page itself.
+ app.MapRawTagCsvExport();
return app;
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawCsvReviewView.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawCsvReviewView.cs
new file mode 100644
index 00000000..7e30e32b
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawCsvReviewView.cs
@@ -0,0 +1,65 @@
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
+
+///
+/// The CSV import review grid's view state (R04 P6): the parsed rows minus the ones the operator removed,
+/// plus the OK / invalid / remaining counts. The modal used to re-scan every parsed row three times per
+/// render for the counts and filter removals inside the row loop; this recomputes once per change and
+/// hands Virtualize a ready list, so a 100k-row file renders only the rows in view.
+///
+public sealed class RawCsvReviewView
+{
+ private readonly HashSet _removed = new();
+ private System.Collections.ObjectModel.ReadOnlyCollection _visible = new(new List());
+
+ /// The rows still in the import, in file order.
+ public System.Collections.ObjectModel.ReadOnlyCollection VisibleRows => _visible;
+
+ /// Remaining valid rows.
+ public int OkCount { get; private set; }
+
+ /// Remaining invalid rows.
+ public int ErrorCount { get; private set; }
+
+ /// Remaining rows (valid + invalid).
+ public int RemainingCount => _visible.Count;
+
+ /// The valid remaining rows' import rows, in file order.
+ /// The rows to commit.
+ public IReadOnlyList CommitRows() =>
+ _visible.Where(r => r is { Ok: true, ImportRow: not null }).Select(r => r.ImportRow!).ToList();
+
+ /// Replaces the parsed result (null clears) and forgets any removals.
+ /// The parse result.
+ public void Set(RawCsvParseResult? result)
+ {
+ _removed.Clear();
+ _source = result?.Rows ?? Array.Empty();
+ Recompute();
+ }
+
+ /// Removes one row (by its file row number) from the import.
+ /// The row's 1-based data-row number.
+ public void Remove(int rowNumber)
+ {
+ if (_removed.Add(rowNumber)) Recompute();
+ }
+
+ private IReadOnlyList _source = Array.Empty();
+
+ private void Recompute()
+ {
+ var visible = new List(_source.Count);
+ int ok = 0, bad = 0;
+ foreach (var row in _source)
+ {
+ if (_removed.Contains(row.RowNumber)) continue;
+ visible.Add(row);
+ if (row.Ok) ok++;
+ else bad++;
+ }
+
+ _visible = visible.AsReadOnly();
+ OkCount = ok;
+ ErrorCount = bad;
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvExport.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvExport.cs
new file mode 100644
index 00000000..794d9d5e
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvExport.cs
@@ -0,0 +1,132 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using ZB.MOM.WW.OtOpcUa.Security.Auth;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
+
+/// A built raw-tag CSV export.
+/// The CSV text.
+/// The suggested download file name.
+/// The number of exported tags.
+/// The exported group's device-relative path, or null for a whole-device export.
+public sealed record RawTagCsvExportResult(string Csv, string FileName, int RowCount, string? GroupPath);
+
+///
+/// Builds raw-tag CSV exports, and serves them as a download (R04 P6). The export modal used to embed
+/// the whole CSV in the circuit twice (a textarea plus a base64 data: URI on the anchor), so a
+/// multi-MB export pushed ≈2.3× its size over SignalR into the DOM. The modal now shows a bounded
+/// preview and links to , which streams the file over plain HTTP.
+///
+public static class RawTagCsvExport
+{
+ /// The download endpoint's route.
+ public const string DownloadPath = "/api/raw/tags.csv";
+
+ /// The number of CSV lines the modal previews.
+ public const int PreviewLines = 50;
+
+ /// Builds the export for a device, or for one tag group's subtree.
+ /// The export reader.
+ /// The device; may be blank when is given.
+ /// When set, only this group's subtree is exported.
+ /// The driver type (selects the typed columns); resolved from the device when blank.
+ /// Cancellation token.
+ /// The export, or null when the target device cannot be resolved.
+ public static async Task BuildAsync(
+ RawTagCsvExportReader reader, string? deviceId, string? tagGroupId, string? driverType, CancellationToken ct = default)
+ {
+ string? groupPath = null;
+ if (!string.IsNullOrEmpty(tagGroupId))
+ {
+ var ctx = await reader.ResolveGroupContextAsync(tagGroupId, ct);
+ if (ctx is { } c)
+ {
+ groupPath = c.GroupPath;
+ if (string.IsNullOrEmpty(deviceId)) deviceId = c.DeviceId;
+ }
+ }
+
+ if (string.IsNullOrEmpty(deviceId)) return null;
+
+ if (string.IsNullOrEmpty(driverType))
+ {
+ driverType = await reader.ResolveDriverTypeAsync(deviceId, ct);
+ if (driverType is null) return null;
+ }
+
+ IEnumerable tags = await reader.ReadDeviceTagsAsync(deviceId, ct);
+
+ // When exporting a single group, keep only that group's subtree (its path + descendants).
+ if (!string.IsNullOrEmpty(groupPath))
+ {
+ var prefix = groupPath;
+ tags = tags.Where(t => t.TagGroupPath is not null &&
+ (t.TagGroupPath == prefix || t.TagGroupPath.StartsWith(prefix + "/", StringComparison.Ordinal)));
+ }
+
+ var list = tags.ToList();
+ return new RawTagCsvExportResult(RawTagCsvMapper.Export(driverType, list), BuildFileName(deviceId), list.Count, groupPath);
+ }
+
+ /// The first lines of , without copying the rest.
+ /// The CSV text.
+ /// The maximum number of lines.
+ /// The preview, and whether lines were cut.
+ public static (string Preview, bool Truncated) Preview(string csv, int maxLines = PreviewLines)
+ {
+ var idx = 0;
+ for (var line = 0; line < maxLines; line++)
+ {
+ var next = csv.IndexOf('\n', idx);
+ if (next < 0) return (csv, false);
+ idx = next + 1;
+ }
+
+ return idx >= csv.Length ? (csv, false) : (csv[..idx], true);
+ }
+
+ /// The download URL for an export.
+ /// The device id (may be blank when a group is given).
+ /// The optional tag-group id.
+ /// The optional driver type.
+ /// A relative URL with escaped query values.
+ public static string DownloadUrl(string? deviceId, string? tagGroupId, string? driverType)
+ {
+ var query = new List();
+ if (!string.IsNullOrEmpty(deviceId)) query.Add("deviceId=" + Uri.EscapeDataString(deviceId));
+ if (!string.IsNullOrEmpty(tagGroupId)) query.Add("tagGroupId=" + Uri.EscapeDataString(tagGroupId));
+ if (!string.IsNullOrEmpty(driverType)) query.Add("driverType=" + Uri.EscapeDataString(driverType));
+ return query.Count == 0 ? DownloadPath : DownloadPath + "?" + string.Join("&", query);
+ }
+
+ /// The download file name for a device.
+ /// The device id.
+ /// A file-system-safe name.
+ public static string BuildFileName(string deviceId)
+ {
+ var safe = new string(deviceId.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray());
+ return $"tags-{safe}.csv";
+ }
+
+ ///
+ /// Maps GET /api/raw/tags.csv. It needs the same ConfigEditor policy as the /raw
+ /// page whose export modal links to it — the CSV carries every tag's config, which is exactly what
+ /// that page shows. Browser-cookie authenticated (the anchor is a same-origin navigation).
+ ///
+ /// The endpoint route builder.
+ /// The route handler builder.
+ public static RouteHandlerBuilder MapRawTagCsvExport(this IEndpointRouteBuilder app) =>
+ app.MapGet(DownloadPath, async (
+ string? deviceId, string? tagGroupId, string? driverType,
+ RawTagCsvExportReader reader, CancellationToken ct) =>
+ {
+ var export = await BuildAsync(reader, deviceId, tagGroupId, driverType, ct);
+ // File(..., fileDownloadName) sends Content-Disposition: attachment, so the browser saves it.
+ return export is null
+ ? Results.NotFound()
+ : Results.File(System.Text.Encoding.UTF8.GetBytes(export.Csv), "text/csv; charset=utf-8", export.FileName);
+ })
+ .RequireAuthorization(AdminUiPolicies.ConfigEditor)
+ .WithName("RawTagCsvExport");
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/ModalStaleOpenGuardTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/ModalStaleOpenGuardTests.cs
index c4286a95..8b24e801 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/ModalStaleOpenGuardTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/ModalStaleOpenGuardTests.cs
@@ -87,8 +87,12 @@ public sealed class ModalStaleOpenGuardTests : IDisposable
("OnSaved", EventCallback.Factory.Create(this, () => { saved++; })));
await Open(modal);
- modal.GetType().GetField("_result", BindingFlags.NonPublic | BindingFlags.Instance)!.SetValue(
- modal, RawTagCsvMapper.Parse("Modbus", "Name,DataType,TagConfigJson\nok,Float,\"{\"\"region\"\":\"\"HoldingRegisters\"\",\"\"address\"\":1}\""));
+ var parsed = RawTagCsvMapper.Parse("Modbus", "Name,DataType,TagConfigJson\nok,Float,\"{\"\"region\"\":\"\"HoldingRegisters\"\",\"\"address\"\":1}\"");
+ modal.GetType().GetField("_result", BindingFlags.NonPublic | BindingFlags.Instance)!.SetValue(modal, parsed);
+ // The review grid's view (R04 P6) is what Commit reads its rows and counts from; OnFileSelected
+ // sets both, so a test that bypasses the upload must too.
+ ((RawCsvReviewView)modal.GetType().GetField("_view", BindingFlags.NonPublic | BindingFlags.Instance)!
+ .GetValue(modal)!).Set(parsed);
var commit = (Task)modal.GetType().GetMethod("Commit", BindingFlags.NonPublic | BindingFlags.Instance)!
.Invoke(modal, null)!;
@@ -122,7 +126,7 @@ public sealed class ModalStaleOpenGuardTests : IDisposable
Field(modal, "_groupFilter").ShouldBeNull();
Field(modal, "_rowCount").ShouldBe(0);
- Field(modal, "_dataUri").ShouldBe("");
+ Field(modal, "_downloadUrl").ShouldBe("");
}
[Fact]
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawCsvRenderingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawCsvRenderingTests.cs
new file mode 100644
index 00000000..7635ad14
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawCsvRenderingTests.cs
@@ -0,0 +1,106 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Security.Auth;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
+
+///
+/// R04 P6: the CSV import grid renders through Virtualize from a precomputed view, and the export is
+/// downloaded from an endpoint (bounded preview in the modal) instead of being embedded in the circuit.
+///
+[Trait("Category", "Unit")]
+public sealed class RawCsvRenderingTests
+{
+ private static RawCsvParseResult Result(params (int Row, bool Ok)[] rows) =>
+ new(["Name"], rows.Select(r => new RawCsvReviewRow
+ {
+ RowNumber = r.Row, Name = $"t{r.Row}", Error = r.Ok ? null : "bad",
+ ImportRow = r.Ok ? new RawTagImportRow(null, new RawTagInput($"t{r.Row}", "Int32", Configuration.Enums.TagAccessLevel.Read, false, null, "{}")) : null,
+ }).ToList(), null);
+
+ [Fact]
+ public void Review_view_counts_and_filters_once_per_change()
+ {
+ var view = new RawCsvReviewView();
+ view.Set(Result((1, true), (2, false), (3, true)));
+
+ view.OkCount.ShouldBe(2);
+ view.ErrorCount.ShouldBe(1);
+ view.RemainingCount.ShouldBe(3);
+
+ view.Remove(2);
+ view.ErrorCount.ShouldBe(0);
+ view.VisibleRows.Select(r => r.RowNumber).ShouldBe([1, 3]);
+ view.CommitRows().Select(r => r.Tag.Name).ShouldBe(["t1", "t3"]);
+
+ view.Set(null);
+ view.RemainingCount.ShouldBe(0);
+ // Positive control: a fresh set after a clear shows every row again (removals are forgotten).
+ view.Set(Result((1, true), (2, false)));
+ view.RemainingCount.ShouldBe(2);
+ }
+
+ [Fact]
+ public void Preview_is_bounded_and_flags_truncation()
+ {
+ var csv = string.Concat(Enumerable.Range(0, 200).Select(i => $"row{i}\n"));
+
+ var (preview, truncated) = RawTagCsvExport.Preview(csv, maxLines: 50);
+ truncated.ShouldBeTrue();
+ preview.Split('\n', StringSplitOptions.RemoveEmptyEntries).Length.ShouldBe(50);
+
+ var (all, cut) = RawTagCsvExport.Preview("a\nb\n", maxLines: 50);
+ cut.ShouldBeFalse();
+ all.ShouldBe("a\nb\n");
+ }
+
+ [Fact]
+ public void Download_url_escapes_its_query_values()
+ {
+ RawTagCsvExport.DownloadUrl("dev 1&x", null, "Modbus")
+ .ShouldBe("/api/raw/tags.csv?deviceId=dev%201%26x&driverType=Modbus");
+ RawTagCsvExport.DownloadUrl("", "TG/1", null).ShouldBe("/api/raw/tags.csv?tagGroupId=TG%2F1");
+ }
+
+ [Fact]
+ public void Download_endpoint_requires_the_same_ConfigEditor_policy_as_the_raw_page()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.Services.AddDbContextFactory(o => o.UseInMemoryDatabase("csv-export-metadata"));
+ builder.Services.AddScoped();
+ var app = builder.Build();
+ app.MapRawTagCsvExport();
+
+ var endpoint = ((IEndpointRouteBuilder)app).DataSources.SelectMany(d => d.Endpoints).OfType()
+ .Single(e => e.RoutePattern.RawText == RawTagCsvExport.DownloadPath);
+
+ endpoint.Metadata.GetMetadata().ShouldBeNull();
+ endpoint.Metadata.GetOrderedMetadata().Select(a => a.Policy)
+ .ShouldContain(AdminUiPolicies.ConfigEditor);
+ // Parity with the page that links to it.
+ typeof(ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Raw.GlobalRaw)
+ .GetCustomAttributes(typeof(AuthorizeAttribute), false).Cast()
+ .Single().Policy.ShouldBe(AdminUiPolicies.ConfigEditor);
+ }
+
+ [Fact]
+ public void Modals_no_longer_embed_the_csv_or_render_every_row()
+ {
+ var export = AdminUiSource.Read("Components", "Shared", "Raw", "RawCsvExportModal.razor");
+ export.ShouldNotContain("data:text/csv");
+ export.ShouldContain("RawTagCsvExport.Preview(");
+
+ var import = AdminUiSource.Read("Components", "Shared", "Raw", "RawCsvImportModal.razor");
+ import.ShouldContain("