Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/BundleCommandsStreamingTests.cs
T

237 lines
8.3 KiB
C#

using System.CommandLine;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary>
/// CLI-019 regression tests for <see cref="BundleCommands.StreamBase64ToFile"/>.
/// The pre-fix code did <c>Convert.FromBase64String(...) → File.WriteAllBytes(...)</c>,
/// doubling the bundle's bytes onto the LOH and writing synchronously. The new
/// streaming helper decodes the base64 string in fixed-size chunks straight into
/// a <see cref="FileStream"/>, so peak working set is bounded by the chunk size
/// regardless of how large the bundle is.
/// </summary>
public class BundleCommandsStreamingTests : IDisposable
{
private readonly string _tempPath;
public BundleCommandsStreamingTests()
{
_tempPath = Path.Combine(Path.GetTempPath(), $"bundle-stream-test-{Guid.NewGuid():N}.bin");
}
public void Dispose()
{
if (File.Exists(_tempPath))
{
File.Delete(_tempPath);
}
}
[Fact]
public void StreamBase64ToFile_SmallPayload_RoundTrips()
{
var bytes = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
var base64 = Convert.ToBase64String(bytes);
var written = BundleCommands.StreamBase64ToFile(base64, _tempPath);
Assert.Equal(bytes.Length, written);
var roundTripped = File.ReadAllBytes(_tempPath);
Assert.Equal(bytes, roundTripped);
}
[Fact]
public void StreamBase64ToFile_PayloadCrossesChunkBoundary_RoundTrips()
{
// Build a payload several chunks wide so the slicing loop runs more than
// once, with enough trailing bytes that the final slice is short and
// exercises the padding/short-final-chunk path.
var size = (BundleCommands.Base64StreamChunkChars / 4 * 3) * 3 + 17;
var bytes = new byte[size];
for (var i = 0; i < size; i++) bytes[i] = (byte)(i & 0xFF);
var base64 = Convert.ToBase64String(bytes);
var written = BundleCommands.StreamBase64ToFile(base64, _tempPath);
Assert.Equal(size, written);
var roundTripped = File.ReadAllBytes(_tempPath);
Assert.Equal(bytes, roundTripped);
}
[Fact]
public void StreamBase64ToFile_EmptyString_WritesEmptyFile()
{
var written = BundleCommands.StreamBase64ToFile(string.Empty, _tempPath);
Assert.Equal(0, written);
Assert.True(File.Exists(_tempPath));
Assert.Empty(File.ReadAllBytes(_tempPath));
}
[Fact]
public void StreamBase64ToFile_InvalidBase64_ThrowsFormatException()
{
// '*' is not a valid base64 character, so TryFromBase64Chars returns
// false and the helper throws — the pre-fix code threw FormatException
// from Convert.FromBase64String, so the contract is preserved.
var invalid = "this is not valid base64 !!!*";
Assert.Throws<FormatException>(() => BundleCommands.StreamBase64ToFile(invalid, _tempPath));
}
[Fact]
public void StreamBase64ToFile_NullBase64_Throws()
{
Assert.Throws<ArgumentNullException>(() => BundleCommands.StreamBase64ToFile(null!, _tempPath));
}
[Fact]
public void StreamBase64ToFile_EmptyOutputPath_Throws()
{
Assert.Throws<ArgumentException>(() => BundleCommands.StreamBase64ToFile("AAAA", string.Empty));
}
// ---- M8 (B4): --sites / --instances comma-split + flag parsing ----------
[Fact]
public void ParseNameList_CommaSeparated_SplitsAndTrims()
{
var result = BundleCommands.ParseNameList(" North-01 , East-02 ,West-03 ");
Assert.NotNull(result);
Assert.Equal(new[] { "North-01", "East-02", "West-03" }, result);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void ParseNameList_NullOrBlank_ReturnsNull(string? token)
{
Assert.Null(BundleCommands.ParseNameList(token));
}
[Fact]
public void ParseNameList_EmptyEntries_AreDropped()
{
var result = BundleCommands.ParseNameList("a,,b, ,c");
Assert.Equal(new[] { "a", "b", "c" }, result);
}
[Fact]
public void BundleExport_SitesAndInstancesFlags_ParseWithoutError()
{
var url = new Option<string>("--url") { Recursive = true };
var format = new Option<string>("--format") { Recursive = true };
var username = new Option<string>("--username") { Recursive = true };
var password = new Option<string>("--password") { Recursive = true };
var bundle = BundleCommands.Build(url, format, username, password);
var parse = bundle.Parse(new[]
{
"export", "--output", "/tmp/out.scadabundle",
"--sites", "NORTH-01,East Plant",
"--instances", "NORTH-01.Pump1,NORTH-01.Pump2",
});
Assert.Empty(parse.Errors);
}
// ---- M8 (D3): --map-site / --map-connection spec parsing ----------------
[Fact]
public void ParseSiteMappings_Null_ReturnsEmpty()
{
Assert.Empty(BundleCommands.ParseSiteMappings(null));
}
[Fact]
public void ParseSiteMappings_SrcEqualsDst_MapsToExisting()
{
var specs = BundleCommands.ParseSiteMappings(new[] { "NORTH-01=PLANT-A" });
var spec = Assert.Single(specs);
Assert.Equal("NORTH-01", spec.SourceSiteIdentifier);
Assert.Equal("PLANT-A", spec.TargetSiteIdentifier);
}
[Theory]
[InlineData("NORTH-01")] // no '=' part -> create-new
[InlineData("NORTH-01=")] // empty rhs -> create-new
[InlineData("NORTH-01= ")] // whitespace rhs -> create-new
public void ParseSiteMappings_NoTarget_MeansCreateNew(string token)
{
var specs = BundleCommands.ParseSiteMappings(new[] { token });
var spec = Assert.Single(specs);
Assert.Equal("NORTH-01", spec.SourceSiteIdentifier);
Assert.Null(spec.TargetSiteIdentifier);
}
[Fact]
public void ParseSiteMappings_Repeated_AccumulatesAll()
{
var specs = BundleCommands.ParseSiteMappings(new[] { "A=X", "B" });
Assert.Equal(2, specs.Count);
Assert.Equal("X", specs[0].TargetSiteIdentifier);
Assert.Null(specs[1].TargetSiteIdentifier);
}
[Fact]
public void ParseSiteMappings_EmptySource_Throws()
{
Assert.Throws<FormatException>(() => BundleCommands.ParseSiteMappings(new[] { "=PLANT-A" }));
}
[Fact]
public void ParseConnectionMappings_SrcEqualsDst_MapsToExisting()
{
var specs = BundleCommands.ParseConnectionMappings(new[] { "NORTH-01/OpcA=OpcLive" });
var spec = Assert.Single(specs);
Assert.Equal("NORTH-01", spec.SourceSiteIdentifier);
Assert.Equal("OpcA", spec.SourceConnectionName);
Assert.Equal("OpcLive", spec.TargetConnectionName);
}
[Theory]
[InlineData("NORTH-01/OpcA")] // no '=' part -> create-new
[InlineData("NORTH-01/OpcA=")] // empty rhs -> create-new
public void ParseConnectionMappings_NoTarget_MeansCreateNew(string token)
{
var specs = BundleCommands.ParseConnectionMappings(new[] { token });
var spec = Assert.Single(specs);
Assert.Equal("NORTH-01", spec.SourceSiteIdentifier);
Assert.Equal("OpcA", spec.SourceConnectionName);
Assert.Null(spec.TargetConnectionName);
}
[Theory]
[InlineData("OpcA")] // missing site/name shape
[InlineData("NORTH-01/")] // empty connection name
[InlineData("/OpcA")] // empty site
public void ParseConnectionMappings_MalformedSource_Throws(string token)
{
Assert.Throws<FormatException>(() => BundleCommands.ParseConnectionMappings(new[] { token }));
}
[Fact]
public void BundleImport_MapAndCreateMissingFlags_ParseWithoutError()
{
var url = new Option<string>("--url") { Recursive = true };
var format = new Option<string>("--format") { Recursive = true };
var username = new Option<string>("--username") { Recursive = true };
var password = new Option<string>("--password") { Recursive = true };
var bundle = BundleCommands.Build(url, format, username, password);
var parse = bundle.Parse(new[]
{
"import", "--input", "/tmp/in.scadabundle",
"--map-site", "NORTH-01=PLANT-A",
"--map-connection", "NORTH-01/OpcA=OpcLive",
"--create-missing-sites",
"--create-missing-connections",
});
Assert.Empty(parse.Errors);
}
}