223 lines
9.5 KiB
C#
223 lines
9.5 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
|
|
|
/// <summary>
|
|
/// The SQL-Server-backed half of <see cref="RawTreeServiceRenameWarningTests"/>: pins that the
|
|
/// expression-built <c>SourceCode.Contains(p0) || … || Contains(pN)</c> disjunction
|
|
/// (<c>RawTreeService.AnyPathContained</c>) <b>translates</b> on the real provider, and pins the one
|
|
/// behaviour EF InMemory structurally cannot show — the <b>collation widening</b> the method's
|
|
/// remarks describe.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Under EF InMemory the predicate runs as ordinal <see cref="string.Contains(string)"/>, so a
|
|
/// script naming a RawPath in different casing does NOT match there. On SQL Server the comparison
|
|
/// runs under the column collation — case-insensitive on a default install — and it DOES. That is
|
|
/// a deliberate, documented widening (a false positive costs an operator one glance; a false
|
|
/// negative ships a broken rename), and it is only assertable against a real server.
|
|
/// </para>
|
|
/// <para>
|
|
/// Also crosses the 200-term <c>ScriptScanBatchSize</c> chunk boundary against the real provider,
|
|
/// where a batch too large would be a translation/complexity failure rather than a silent
|
|
/// in-memory loop.
|
|
/// </para>
|
|
/// <para>
|
|
/// Every test seeds its own cluster/folder/driver subtree under a unique token, so the seeds do
|
|
/// not collide and — since the script scan is a whole-table substring search — no test's paths
|
|
/// can be matched by another's scripts.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Collection(nameof(AdminUiSqlServerCollection))]
|
|
public sealed class RawTreeServiceRenameWarningSqlServerTests(AdminUiSqlServerFixture sql)
|
|
{
|
|
/// <summary>Mirrors <c>RawTreeService.ScriptScanBatchSize</c> (private); the boundary under test.</summary>
|
|
private const int ScriptScanBatchSize = 200;
|
|
|
|
[SqlServerFact]
|
|
public async Task A_script_naming_the_path_in_different_case_is_matched_under_the_server_collation()
|
|
{
|
|
var seed = await SeedAsync(token => new Seed(
|
|
Tags: ["flow"],
|
|
Scripts:
|
|
[
|
|
// Same path, opposite casing throughout. Ordinal Contains (EF InMemory) misses this; SQL
|
|
// Server's default CI collation finds it.
|
|
new ScriptSeed("CaseScript", $"var v = ctx.GetTag(\"{RawPath(token, "flow").ToUpperInvariant()}\");"),
|
|
]));
|
|
|
|
var result = await seed.Service.RenameDeviceAsync(seed.DeviceId, "Dev-renamed", seed.DeviceRowVersion);
|
|
|
|
result.Ok.ShouldBeTrue(result.Error);
|
|
result.Warnings.ShouldContain(w => w.Contains("script") && w.Contains("CaseScript"));
|
|
}
|
|
|
|
[SqlServerFact]
|
|
public async Task A_script_naming_the_path_in_the_same_case_is_matched()
|
|
{
|
|
// Control for the case-insensitivity test above: if this one failed too, the previous test would
|
|
// be proving the scan runs at all rather than proving the widening.
|
|
var seed = await SeedAsync(token => new Seed(
|
|
Tags: ["flow"],
|
|
Scripts: [new ScriptSeed("ExactScript", $"var v = ctx.GetTag(\"{RawPath(token, "flow")}\");")]));
|
|
|
|
var result = await seed.Service.RenameDeviceAsync(seed.DeviceId, "Dev-renamed", seed.DeviceRowVersion);
|
|
|
|
result.Ok.ShouldBeTrue(result.Error);
|
|
result.Warnings.ShouldContain(w => w.Contains("script") && w.Contains("ExactScript"));
|
|
}
|
|
|
|
[SqlServerFact]
|
|
public async Task A_script_naming_no_affected_path_is_not_matched()
|
|
{
|
|
// Falsification control: the disjunction must be a real filter server-side, not a predicate that
|
|
// degenerates to "true" (which every match assertion above would still pass against).
|
|
var seed = await SeedAsync(token => new Seed(
|
|
Tags: ["flow"],
|
|
Scripts: [new ScriptSeed("UnrelatedScript", $"var v = ctx.GetTag(\"{RawPath(token, "not-a-tag-here")}\");")]));
|
|
|
|
var result = await seed.Service.RenameDeviceAsync(seed.DeviceId, "Dev-renamed", seed.DeviceRowVersion);
|
|
|
|
result.Ok.ShouldBeTrue(result.Error);
|
|
result.Warnings.ShouldNotContain(w => w.Contains("script"));
|
|
}
|
|
|
|
[SqlServerFact]
|
|
public async Task Matches_are_found_on_both_sides_of_the_scan_batch_boundary()
|
|
{
|
|
// 201 distinct paths ⇒ two batches at the 200-term cap. One script names a path in the first
|
|
// batch, one names the single path that lands in the second: a loop that dropped either batch, or
|
|
// a 200-term OR-chain the provider refused to translate, fails here.
|
|
const int tagCount = ScriptScanBatchSize + 1;
|
|
var names = Enumerable.Range(0, tagCount).Select(i => $"bulk-{i:D3}").ToArray();
|
|
|
|
var seed = await SeedAsync(token => new Seed(
|
|
Tags: names,
|
|
Scripts:
|
|
[
|
|
new ScriptSeed("BatchFirst", $"ctx.GetTag(\"{RawPath(token, names[0])}\");"),
|
|
new ScriptSeed("BatchLast", $"ctx.GetTag(\"{RawPath(token, names[^1])}\");"),
|
|
]));
|
|
|
|
var result = await seed.Service.RenameDeviceAsync(seed.DeviceId, "Dev-renamed", seed.DeviceRowVersion);
|
|
|
|
result.Ok.ShouldBeTrue(result.Error);
|
|
result.Warnings.ShouldContain(
|
|
"2 scripts ('BatchFirst', 'BatchLast') reference tags beneath this node by their raw paths; " +
|
|
"the references will break unless the scripts are updated.");
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------------------- Seed
|
|
|
|
private const string FolderName = "Plant";
|
|
private const string DriverName = "modbus-1";
|
|
private const string DeviceName = "Dev-scan";
|
|
|
|
/// <summary>The RawPath a seeded tag resolves to: Folder/Driver/Device/Tag (the cluster is not in it).</summary>
|
|
private static string RawPath(string token, string tagName) =>
|
|
$"{FolderName}{token}/{DriverName}/{DeviceName}/{tagName}";
|
|
|
|
private sealed record ScriptSeed(string Name, string SourceCode);
|
|
|
|
private sealed record Seed(IReadOnlyList<string> Tags, IReadOnlyList<ScriptSeed> Scripts);
|
|
|
|
private sealed record SeedResult(RawTreeService Service, string DeviceId, byte[] DeviceRowVersion);
|
|
|
|
private async Task<SeedResult> SeedAsync(Func<string, Seed> build)
|
|
{
|
|
// 8 hex chars: unique per test, and short enough for the 64-char id columns.
|
|
var token = Guid.NewGuid().ToString("N")[..8];
|
|
var seed = build(token);
|
|
|
|
var clusterId = $"C-{token}";
|
|
var folderId = $"RF-{token}";
|
|
var driverId = $"DRV-{token}";
|
|
var deviceId = $"DEV-{token}";
|
|
|
|
await using (var db = await sql.Factory.CreateDbContextAsync())
|
|
{
|
|
db.ServerClusters.Add(new ServerCluster
|
|
{
|
|
ClusterId = clusterId,
|
|
Name = $"Cluster {token}",
|
|
Enterprise = "zb",
|
|
Site = "warsaw-west",
|
|
// NodeCount is NOT optional here as it is under EF InMemory: CK_ServerCluster_
|
|
// RedundancyMode_NodeCount pins None ⇒ 1 (and Warm/Hot ⇒ 2).
|
|
NodeCount = 1,
|
|
RedundancyMode = RedundancyMode.None,
|
|
CreatedBy = "test",
|
|
});
|
|
db.RawFolders.Add(new RawFolder
|
|
{
|
|
RawFolderId = folderId,
|
|
ClusterId = clusterId,
|
|
ParentRawFolderId = null,
|
|
Name = $"{FolderName}{token}",
|
|
});
|
|
db.DriverInstances.Add(new DriverInstance
|
|
{
|
|
DriverInstanceId = driverId,
|
|
ClusterId = clusterId,
|
|
RawFolderId = folderId,
|
|
Name = DriverName,
|
|
DriverType = "Modbus",
|
|
DriverConfig = "{}",
|
|
});
|
|
db.Devices.Add(new Device
|
|
{
|
|
DeviceId = deviceId,
|
|
DriverInstanceId = driverId,
|
|
Name = DeviceName,
|
|
DeviceConfig = "{}",
|
|
});
|
|
|
|
for (var i = 0; i < seed.Tags.Count; i++)
|
|
{
|
|
db.Tags.Add(new Tag
|
|
{
|
|
TagId = $"TAG-{token}-{i:D4}",
|
|
DeviceId = deviceId,
|
|
TagGroupId = null,
|
|
Name = seed.Tags[i],
|
|
DataType = "Float",
|
|
AccessLevel = TagAccessLevel.Read,
|
|
TagConfig = "{}",
|
|
});
|
|
}
|
|
|
|
for (var i = 0; i < seed.Scripts.Count; i++)
|
|
{
|
|
db.Scripts.Add(new Script
|
|
{
|
|
ScriptId = $"SC-{token}-{i:D2}",
|
|
Name = seed.Scripts[i].Name,
|
|
SourceCode = seed.Scripts[i].SourceCode,
|
|
SourceHash = $"hash-{token}-{i:D2}",
|
|
});
|
|
}
|
|
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
// SQL Server DOES enforce the rowversion token (EF InMemory does not), so the rename must carry
|
|
// the value the server actually stamped.
|
|
byte[] rowVersion;
|
|
await using (var db = await sql.Factory.CreateDbContextAsync())
|
|
{
|
|
rowVersion = await db.Devices.AsNoTracking()
|
|
.Where(d => d.DeviceId == deviceId)
|
|
.Select(d => d.RowVersion)
|
|
.SingleAsync();
|
|
}
|
|
|
|
return new SeedResult(new RawTreeService(sql.Factory), deviceId, rowVersion);
|
|
}
|
|
}
|