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("