2e64af26ef
Staleness is not a liveness proof: DriverInstanceActor publishes on a 30 s tick and dedups on a health fingerprint, so a running Faulted, idle or tagless driver deleted in /raw (undeployed) went stale and was evicted. DeployedDriverSet reads, per node, the latest and latest-Applied NodeDeploymentState deployments plus the latest Sealed one, parses each artifact's DriverInstances once (cached by deployment id, pruned to the live set, only the blob column of an uncached row is read), and /hosts unions it with the authored set. The staleness clause stays as a second guard. An unreadable artifact or a failed read means 'unknown' and skips eviction. The Hosts.razor comment claiming '~12 health publishes' is corrected. N-new-2: the CSV download's Content-Disposition carries an RFC 5987 filename* via ContentDispositionHeaderValue.SetHttpFileName. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
134 lines
5.7 KiB
C#
134 lines
5.7 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");
|
|
var preview = await RawTagCsvExport.BuildAsync(reader, Uns.RawTreeTestDb.FolderedDeviceId, null, "Modbus", 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();");
|
|
}
|
|
}
|