Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawCsvRenderingTests.cs
T
Joseph Doherty b211911f98
v2-ci / unit-tests (push) Failing after 13m22s
test: migrate AdminUI/ControlPlane/Runtime/Host tests to xunit.v3 (#756)
Akka.TestKit.Xunit2 -> Akka.TestKit.Xunit 1.5.62 (xunit.v3 TestKit; needs xunit.v3 3.2.2, set via
VersionOverride on the four projects). Removes the xunit 2.9.3 pin. IAsyncLifetime returns ValueTask,
SqlServerFactAttribute gains the source-info ctor, and xUnit1051 call sites pass
TestContext.Current.CancellationToken. Test counts unchanged.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-10 08:57:33 -05:00

134 lines
5.8 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
[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 async Task Preview_build_maps_only_the_first_rows_but_counts_all()
{
// Review r0 M8: the modal maps only its preview's rows; the full count still comes back.
var dbName = Uns.RawTreeTestDb.SeedFresh();
var reader = new RawTagCsvExportReader(Uns.RawTreeTestDb.Factory(dbName));
var full = await RawTagCsvExport.BuildAsync(reader, Uns.RawTreeTestDb.FolderedDeviceId, null, "Modbus", TestContext.Current.CancellationToken);
var preview = await RawTagCsvExport.BuildAsync(reader, Uns.RawTreeTestDb.FolderedDeviceId, null, "Modbus", ct: TestContext.Current.CancellationToken, maxRows: 1);
full!.RowCount.ShouldBe(2);
Commons.Csv.CsvParser.Parse(full.Csv).Count.ShouldBe(3); // header + 2 (positive control)
preview!.RowCount.ShouldBe(2);
Commons.Csv.CsvParser.Parse(preview.Csv).Count.ShouldBe(2); // header + 1
}
[Fact]
public void Attachment_header_carries_an_rfc5987_name_for_non_ascii_ids()
{
var header = RawTagCsvExport.AttachmentHeader(RawTagCsvExport.BuildFileName("Prüfstand"));
header.ShouldStartWith("attachment");
header.ShouldContain("filename*=UTF-8''tags-Pr%C3%BCfstand.csv");
// Positive control: an ASCII name is still a plain filename.
RawTagCsvExport.AttachmentHeader("tags-dev1.csv").ShouldContain("filename=tags-dev1.csv");
}
[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<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase("csv-export-metadata"));
builder.Services.AddScoped<RawTagCsvExportReader>();
var app = builder.Build();
app.MapRawTagCsvExport();
var endpoint = ((IEndpointRouteBuilder)app).DataSources.SelectMany(d => d.Endpoints).OfType<RouteEndpoint>()
.Single(e => e.RoutePattern.RawText == RawTagCsvExport.DownloadPath);
endpoint.Metadata.GetMetadata<IAllowAnonymous>().ShouldBeNull();
endpoint.Metadata.GetOrderedMetadata<IAuthorizeData>().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<AuthorizeAttribute>()
.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("<Virtualize Items=\"@_view.VisibleRows\"");
import.ShouldNotContain("@foreach (var row in _result.Rows)");
AdminUiSource.Read("EndpointRouteBuilderExtensions.cs").ShouldContain("app.MapRawTagCsvExport();");
}
}