fix(transport): carry derived composition-slot templates and rebuild them through the compose flow (ScadaBridge#118)
Bundles did not record that a template was a derived slot template, so the
importer re-created every slot as a BASE template named after the slot.
- Export: TemplateDto gains an additive, null-omitted `Derivation`
(owner path + base path by stable names); a derived template carries only
its own rows (overrides/additions, not inherited placeholders). The closure
pulls in each derived template's base/owner; base orders first.
schemaVersion 1.1 -> 1.2, bundleFormatVersion stays 1.
- Import: base templates go through the ordinary passes; derived templates are
ordered topologically (DerivedTemplatePlan; cycle / duplicate / malformed =
preview Blocker + SemanticValidationException before anything is staged),
their owner and base resolved by path (#100 root rule), and the slot is
created with TemplateService.AddCompositionAsync, synced in place when it
already exists with the same base, or deleted+recomposed when the base
differs; own rows applied as overrides, then ResyncInheritedMembersAsync.
Missing owner/base fails that record (BundleImportDerivedTemplateUnresolved
audit + ImportResult warning), never creating a base in its place.
- Preview/diff: derived rows are keyed by qualified name and compared against
the target slot at the same owner path (own rows + base), so an unchanged
slot is Identical and a same-named base template is never matched.
- Pre-#118 bundles import exactly as before (new fixture
pre-118-derived-plaintext.scadabundle, written by a13aabc7). The field is in
content, pinned by the signed manifest's contentHash.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
BIN
Binary file not shown.
+678
@@ -0,0 +1,678 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge#118 — derived (composition-slot) templates travel as derived templates: the
|
||||
/// bundle carries each one's owner path, base path and own rows, and the importer rebuilds
|
||||
/// the slot through the TemplateService compose flow instead of creating a base template
|
||||
/// named after the slot. Driven through the real export → load → preview → apply pipeline.
|
||||
/// </summary>
|
||||
public sealed class DerivedTemplateTransportTests : IDisposable
|
||||
{
|
||||
private readonly ServiceProvider _provider;
|
||||
|
||||
/// <summary>Initializes the test class with an in-memory database and the Transport services.</summary>
|
||||
public DerivedTemplateTransportTests() => _provider = BuildProvider(new Dictionary<string, string?>());
|
||||
|
||||
/// <summary>Releases the resources held by this test class.</summary>
|
||||
public void Dispose() => _provider.Dispose();
|
||||
|
||||
private static ServiceProvider BuildProvider(IDictionary<string, string?> settings)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(settings).Build());
|
||||
var dbName = $"DerivedTemplateTransportTests_{Guid.NewGuid()}";
|
||||
services.AddDbContext<ScadaBridgeDbContext>(opts => opts
|
||||
.UseInMemoryDatabase(dbName)
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
|
||||
services.AddScoped(sp => new ScadaBridgeDbContext(
|
||||
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
||||
sp.GetRequiredService<IDataProtectionProvider>()));
|
||||
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
||||
services.AddScoped<ISiteRepository, SiteRepository>();
|
||||
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||
services.AddScoped<IAuditService, AuditService>();
|
||||
services.AddTransport();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
// ── seeding through the real compose flow ───────────────────────────────────
|
||||
|
||||
private static async Task<T> WithServiceAsync<T>(ServiceProvider provider, Func<TemplateService, ScadaBridgeDbContext, Task<T>> body)
|
||||
{
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
var svc = new TemplateService(
|
||||
scope.ServiceProvider.GetRequiredService<ITemplateEngineRepository>(),
|
||||
scope.ServiceProvider.GetRequiredService<IAuditService>());
|
||||
return await body(svc, scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>());
|
||||
}
|
||||
|
||||
private static T Ok<T>(Result<T> result)
|
||||
{
|
||||
Assert.True(result.IsSuccess, result.IsSuccess ? null : result.Error);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Motor (Speed=1, Mode="a", script Run, alarm High → Run) and Pump composing Motor as
|
||||
/// slot M1; on the slot, Speed is overridden to 5 and an own attribute Extra is added.
|
||||
/// Returns the derived slot template's id.
|
||||
/// </summary>
|
||||
private static Task<int> SeedPumpWithDerivedMotorAsync(ServiceProvider provider) =>
|
||||
WithServiceAsync(provider, async (svc, ctx) =>
|
||||
{
|
||||
var motor = Ok(await svc.CreateTemplateAsync("Motor", "motor base", null, "seed"));
|
||||
Ok(await svc.AddAttributeAsync(motor.Id, new TemplateAttribute("Speed") { DataType = DataType.Int32, Value = "1" }, "seed"));
|
||||
Ok(await svc.AddAttributeAsync(motor.Id, new TemplateAttribute("Mode") { DataType = DataType.String, Value = "a" }, "seed"));
|
||||
var run = Ok(await svc.AddScriptAsync(motor.Id, new TemplateScript("Run", "return 1;") { TriggerType = "Startup" }, "seed"));
|
||||
Ok(await svc.AddAlarmAsync(motor.Id, new TemplateAlarm("High")
|
||||
{
|
||||
PriorityLevel = 500,
|
||||
TriggerType = AlarmTriggerType.ValueMatch,
|
||||
TriggerConfiguration = "{\"attribute\":\"Speed\",\"matchValue\":\"9\"}",
|
||||
OnTriggerScriptId = run.Id,
|
||||
}, "seed"));
|
||||
|
||||
var pump = Ok(await svc.CreateTemplateAsync("Pump", "pump", null, "seed"));
|
||||
var slot = Ok(await svc.AddCompositionAsync(pump.Id, motor.Id, "M1", "seed"));
|
||||
var derivedId = slot.ComposedTemplateId;
|
||||
|
||||
Ok(await svc.AddAttributeAsync(derivedId,
|
||||
new TemplateAttribute("Extra") { DataType = DataType.String, Value = "x" }, "seed"));
|
||||
var speed = await ctx.TemplateAttributes.AsNoTracking().SingleAsync(a => a.TemplateId == derivedId && a.Name == "Speed");
|
||||
Ok(await svc.UpdateAttributeAsync(speed.Id,
|
||||
new TemplateAttribute("Speed") { DataType = DataType.Int32, Value = "5", IsInherited = false }, "seed"));
|
||||
return derivedId;
|
||||
});
|
||||
|
||||
// ── export / load / preview / apply ─────────────────────────────────────────
|
||||
|
||||
private static async Task<byte[]> ExportAsync(ServiceProvider provider, string? passphrase = null, params string[] baseTemplateNames)
|
||||
{
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var ids = await ctx.Templates.Where(t => !t.IsDerived && (baseTemplateNames.Length == 0 || baseTemplateNames.Contains(t.Name)))
|
||||
.Select(t => t.Id).ToListAsync();
|
||||
var selection = new ExportSelection(
|
||||
TemplateIds: ids,
|
||||
SharedScriptIds: Array.Empty<int>(), ExternalSystemIds: Array.Empty<int>(),
|
||||
DatabaseConnectionIds: Array.Empty<int>(), NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: Array.Empty<int>(), ApiMethodIds: Array.Empty<int>(),
|
||||
IncludeDependencies: true);
|
||||
var stream = await scope.ServiceProvider.GetRequiredService<IBundleExporter>()
|
||||
.ExportAsync(selection, "alice", "dev", passphrase);
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static async Task WipeTemplatesAsync(ServiceProvider provider)
|
||||
{
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.TemplateCompositions.RemoveRange(ctx.TemplateCompositions);
|
||||
ctx.TemplateAttributes.RemoveRange(ctx.TemplateAttributes);
|
||||
ctx.TemplateAlarms.RemoveRange(ctx.TemplateAlarms);
|
||||
ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts);
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
ctx.AuditLogEntries.RemoveRange(ctx.AuditLogEntries);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task<BundleSession> LoadAsync(ServiceProvider provider, byte[] bundle, string? passphrase = null)
|
||||
{
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
return await scope.ServiceProvider.GetRequiredService<IBundleImporter>()
|
||||
.LoadAsync(new MemoryStream(bundle, writable: false), passphrase);
|
||||
}
|
||||
|
||||
private static async Task<ImportPreview> PreviewAsync(ServiceProvider provider, Guid sessionId)
|
||||
{
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
return await scope.ServiceProvider.GetRequiredService<IBundleImporter>().PreviewAsync(sessionId);
|
||||
}
|
||||
|
||||
/// <summary>Applies with the Central UI's default mapping (New→Add, Modified→Overwrite, Identical→Skip).</summary>
|
||||
private static async Task<ImportResult> ApplyDefaultsAsync(
|
||||
ServiceProvider provider, Guid sessionId, ImportPreview preview, params ImportResolution[] overrides)
|
||||
{
|
||||
var map = new Dictionary<(string, string), ImportResolution>();
|
||||
foreach (var item in preview.Items)
|
||||
{
|
||||
var action = item.Kind switch
|
||||
{
|
||||
ConflictKind.New => ResolutionAction.Add,
|
||||
ConflictKind.Modified => ResolutionAction.Overwrite,
|
||||
_ => ResolutionAction.Skip,
|
||||
};
|
||||
map[(item.EntityType, item.Name)] = new ImportResolution(item.EntityType, item.Name, action, null);
|
||||
}
|
||||
foreach (var o in overrides) map[(o.EntityType, o.Name)] = o;
|
||||
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
return await scope.ServiceProvider.GetRequiredService<IBundleImporter>()
|
||||
.ApplyAsync(sessionId, map.Values.ToList(), user: "bob");
|
||||
}
|
||||
|
||||
private static async Task<ImportResult> RoundTripAsync(ServiceProvider provider, byte[] bundle, params ImportResolution[] overrides)
|
||||
{
|
||||
var session = await LoadAsync(provider, bundle);
|
||||
var preview = await PreviewAsync(provider, session.SessionId);
|
||||
Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker);
|
||||
return await ApplyDefaultsAsync(provider, session.SessionId, preview, overrides);
|
||||
}
|
||||
|
||||
private static async Task<Template> LoadSlotAsync(ScadaBridgeDbContext ctx, string owner, string slot)
|
||||
{
|
||||
var ownerT = await ctx.Templates.Include(t => t.Compositions).SingleAsync(t => t.Name == owner && !t.IsDerived);
|
||||
var comp = Assert.Single(ownerT.Compositions, c => c.InstanceName == slot);
|
||||
return await ctx.Templates
|
||||
.Include(t => t.Attributes).Include(t => t.Alarms).Include(t => t.Scripts).Include(t => t.Compositions)
|
||||
.SingleAsync(t => t.Id == comp.ComposedTemplateId);
|
||||
}
|
||||
|
||||
// ── bundle surgery (hand-edited / crafted bundles) ──────────────────────────
|
||||
|
||||
private static BundleContentDto ReadContent(byte[] bundle)
|
||||
{
|
||||
using var zip = new ZipArchive(new MemoryStream(bundle), ZipArchiveMode.Read);
|
||||
using var stream = zip.GetEntry("content.json")!.Open();
|
||||
return JsonSerializer.Deserialize<BundleContentDto>(stream, BundleJsonOptions.Default)!;
|
||||
}
|
||||
|
||||
private async Task<Guid> PackAndLoadAsync(BundleContentDto content)
|
||||
{
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
|
||||
var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
|
||||
var summary = new BundleSummary(
|
||||
Templates: content.Templates.Count,
|
||||
TemplateFolders: 0, SharedScripts: 0, ExternalSystems: 0, DbConnections: 0,
|
||||
NotificationLists: 0, SmtpConfigs: 0, ApiMethods: 0);
|
||||
var manifest = manifestBuilder.Build("dev", "alice", "1.0.0", null, summary,
|
||||
Array.Empty<ManifestContentEntry>(), serializer.SerializeContentBytes(content));
|
||||
await using var packed = serializer.Pack(content, manifest, passphrase: null, encryptor: null);
|
||||
using var ms = new MemoryStream();
|
||||
await packed.CopyToAsync(ms);
|
||||
return (await LoadAsync(_provider, ms.ToArray())).SessionId;
|
||||
}
|
||||
|
||||
// ── tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Export → wipe → import of a base + derived pair: the slot comes back as a DERIVED
|
||||
/// template owned by its composition and inheriting from the base, with its override and
|
||||
/// its own addition intact and the base's members as inherited placeholders — not as a
|
||||
/// base template named after the slot.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Base_and_derived_pair_round_trips_with_the_slot_still_derived()
|
||||
{
|
||||
await SeedPumpWithDerivedMotorAsync(_provider);
|
||||
var bundle = await ExportAsync(_provider, null, "Pump");
|
||||
|
||||
// The bundle marks the slot derived and carries only its own rows.
|
||||
var dto = Assert.Single(ReadContent(bundle).Templates, t => t.Name == "M1");
|
||||
Assert.NotNull(dto.Derivation);
|
||||
Assert.Equal("Pump", dto.Derivation!.OwnerTemplateName);
|
||||
Assert.Equal(new[] { "M1" }, dto.Derivation.SlotPath);
|
||||
Assert.Equal("Motor", dto.Derivation.BaseTemplateName);
|
||||
Assert.Empty(dto.Derivation.BaseSlotPath);
|
||||
Assert.Equal(new[] { "Extra", "Speed" }, dto.Attributes.Select(a => a.Name).OrderBy(n => n));
|
||||
Assert.Empty(dto.Scripts);
|
||||
// Base templates carry no derivation (their wire shape is unchanged).
|
||||
Assert.All(ReadContent(bundle).Templates.Where(t => t.Name != "M1"), t => Assert.Null(t.Derivation));
|
||||
|
||||
await WipeTemplatesAsync(_provider);
|
||||
var session = await LoadAsync(_provider, bundle);
|
||||
var preview = await PreviewAsync(_provider, session.SessionId);
|
||||
Assert.Contains(preview.Items, i => i.Name == "Pump.M1" && i.Kind == ConflictKind.New);
|
||||
Assert.DoesNotContain(preview.Items, i => i.Name == "M1");
|
||||
var result = await ApplyDefaultsAsync(_provider, session.SessionId, preview);
|
||||
Assert.Empty(result.Warnings);
|
||||
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "M1" && !t.IsDerived), "the slot must not become a base template");
|
||||
var motor = await ctx.Templates.Include(t => t.Scripts).SingleAsync(t => t.Name == "Motor");
|
||||
var pump = await ctx.Templates.Include(t => t.Compositions).SingleAsync(t => t.Name == "Pump");
|
||||
var slot = await LoadSlotAsync(ctx, "Pump", "M1");
|
||||
Assert.True(slot.IsDerived);
|
||||
Assert.Equal("M1", slot.Name);
|
||||
Assert.Equal(motor.Id, slot.ParentTemplateId);
|
||||
Assert.Equal(Assert.Single(pump.Compositions).Id, slot.OwnerCompositionId);
|
||||
|
||||
var speed = slot.Attributes.Single(a => a.Name == "Speed");
|
||||
Assert.False(speed.IsInherited);
|
||||
Assert.Equal("5", speed.Value);
|
||||
var extra = slot.Attributes.Single(a => a.Name == "Extra");
|
||||
Assert.False(extra.IsInherited);
|
||||
Assert.Equal("x", extra.Value);
|
||||
var mode = slot.Attributes.Single(a => a.Name == "Mode");
|
||||
Assert.True(mode.IsInherited);
|
||||
Assert.Equal("a", mode.Value);
|
||||
Assert.True(slot.Scripts.Single(s => s.Name == "Run").IsInherited);
|
||||
Assert.True(slot.Alarms.Single(a => a.Name == "High").IsInherited);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derived-ness is part of the identity for the differential import: re-importing the
|
||||
/// same bundle reports the slot Identical (so it is skipped), and a target base template
|
||||
/// that merely shares the slot's name is neither matched nor touched.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Derived_identity_is_the_owner_path_not_the_slot_name()
|
||||
{
|
||||
await SeedPumpWithDerivedMotorAsync(_provider);
|
||||
var bundle = await ExportAsync(_provider, null, "Pump");
|
||||
|
||||
// Same environment: everything is Identical, the slot under its qualified name.
|
||||
var again = await LoadAsync(_provider, bundle);
|
||||
var identical = await PreviewAsync(_provider, again.SessionId);
|
||||
Assert.All(identical.Items, i => Assert.Equal(ConflictKind.Identical, i.Kind));
|
||||
Assert.Contains(identical.Items, i => i.Name == "Pump.M1");
|
||||
|
||||
// A changed override reads Modified on the derived row only.
|
||||
await WithServiceAsync(_provider, async (svc, ctx) =>
|
||||
{
|
||||
var slot = await LoadSlotAsync(ctx, "Pump", "M1");
|
||||
var speed = slot.Attributes.Single(a => a.Name == "Speed");
|
||||
Ok(await svc.UpdateAttributeAsync(speed.Id,
|
||||
new TemplateAttribute("Speed") { DataType = DataType.Int32, Value = "7", IsInherited = false }, "seed"));
|
||||
return 0;
|
||||
});
|
||||
var modified = await PreviewAsync(_provider, (await LoadAsync(_provider, bundle)).SessionId);
|
||||
Assert.Equal(ConflictKind.Modified, modified.Items.Single(i => i.Name == "Pump.M1").Kind);
|
||||
Assert.Equal(ConflictKind.Identical, modified.Items.Single(i => i.Name == "Pump").Kind);
|
||||
|
||||
// Fresh target holding an unrelated BASE template named like the slot.
|
||||
await WipeTemplatesAsync(_provider);
|
||||
await WithServiceAsync(_provider, async (svc, _) =>
|
||||
{
|
||||
var m1 = Ok(await svc.CreateTemplateAsync("M1", "unrelated", null, "seed"));
|
||||
Ok(await svc.AddAttributeAsync(m1.Id, new TemplateAttribute("Own") { DataType = DataType.String, Value = "keep" }, "seed"));
|
||||
return 0;
|
||||
});
|
||||
var session = await LoadAsync(_provider, bundle);
|
||||
var preview = await PreviewAsync(_provider, session.SessionId);
|
||||
Assert.Equal(ConflictKind.New, preview.Items.Single(i => i.Name == "Pump.M1").Kind);
|
||||
await ApplyDefaultsAsync(_provider, session.SessionId, preview);
|
||||
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var unrelated = await db.Templates.Include(t => t.Attributes).SingleAsync(t => t.Name == "M1" && !t.IsDerived);
|
||||
Assert.Equal("unrelated", unrelated.Description);
|
||||
Assert.Equal(new[] { "Own" }, unrelated.Attributes.Select(a => a.Name));
|
||||
Assert.True((await LoadSlotAsync(db, "Pump", "M1")).IsDerived);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A nested slot (Station composes Pump as P, the cascade creates Station.P.M1 deriving
|
||||
/// from Pump.M1) round-trips: its base is the source-side DERIVED template, addressed by
|
||||
/// path, and its own override survives.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Nested_cascade_slot_round_trips_onto_the_source_side_derived_base()
|
||||
{
|
||||
await SeedPumpWithDerivedMotorAsync(_provider);
|
||||
await WithServiceAsync(_provider, async (svc, ctx) =>
|
||||
{
|
||||
var pump = await ctx.Templates.SingleAsync(t => t.Name == "Pump" && !t.IsDerived);
|
||||
var station = Ok(await svc.CreateTemplateAsync("Station", null, null, "seed"));
|
||||
Ok(await svc.AddCompositionAsync(station.Id, pump.Id, "P", "seed"));
|
||||
var p = await LoadSlotAsync(ctx, "Station", "P");
|
||||
var nestedComp = Assert.Single(p.Compositions);
|
||||
var nestedSpeed = await ctx.TemplateAttributes.AsNoTracking()
|
||||
.SingleAsync(a => a.TemplateId == nestedComp.ComposedTemplateId && a.Name == "Speed");
|
||||
Ok(await svc.UpdateAttributeAsync(nestedSpeed.Id,
|
||||
new TemplateAttribute("Speed") { DataType = DataType.Int32, Value = "9", IsInherited = false }, "seed"));
|
||||
return 0;
|
||||
});
|
||||
|
||||
var bundle = await ExportAsync(_provider, null, "Station");
|
||||
var nested = Assert.Single(ReadContent(bundle).Templates, t => t.Derivation?.QualifiedName() == "Station.P.M1");
|
||||
Assert.Equal("Pump.M1", nested.Derivation!.BaseQualifiedName());
|
||||
|
||||
await WipeTemplatesAsync(_provider);
|
||||
Assert.Empty((await RoundTripAsync(_provider, bundle)).Warnings);
|
||||
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var pumpM1 = await LoadSlotAsync(ctx, "Pump", "M1");
|
||||
var p = await LoadSlotAsync(ctx, "Station", "P");
|
||||
var stationPm1 = await ctx.Templates.Include(t => t.Attributes)
|
||||
.SingleAsync(t => t.Id == Assert.Single(p.Compositions).ComposedTemplateId);
|
||||
Assert.True(stationPm1.IsDerived);
|
||||
Assert.Equal(pumpM1.Id, stationPm1.ParentTemplateId);
|
||||
Assert.Equal("9", stationPm1.Attributes.Single(a => a.Name == "Speed").Value);
|
||||
Assert.False(stationPm1.Attributes.Single(a => a.Name == "Speed").IsInherited);
|
||||
Assert.Equal(3, await ctx.Templates.CountAsync(t => !t.IsDerived)); // Motor, Pump, Station
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The importer orders derived templates itself (owner and base first): a hand-edited
|
||||
/// bundle listing every derived template — nested slot first — ahead of the base
|
||||
/// templates still imports correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Derived_templates_listed_before_their_base_and_owner_still_import()
|
||||
{
|
||||
await SeedPumpWithDerivedMotorAsync(_provider);
|
||||
await WithServiceAsync(_provider, async (svc, ctx) =>
|
||||
{
|
||||
var pump = await ctx.Templates.SingleAsync(t => t.Name == "Pump" && !t.IsDerived);
|
||||
var station = Ok(await svc.CreateTemplateAsync("Station", null, null, "seed"));
|
||||
Ok(await svc.AddCompositionAsync(station.Id, pump.Id, "P", "seed"));
|
||||
return 0;
|
||||
});
|
||||
var content = ReadContent(await ExportAsync(_provider, null, "Station"));
|
||||
var reordered = content.Templates
|
||||
.OrderByDescending(t => t.Derivation?.SlotPath.Count ?? 0) // deepest derived first, bases last
|
||||
.ToList();
|
||||
Assert.Equal("Station.P.M1", reordered[0].Derivation?.QualifiedName());
|
||||
|
||||
await WipeTemplatesAsync(_provider);
|
||||
var sessionId = await PackAndLoadAsync(content with { Templates = reordered });
|
||||
var preview = await PreviewAsync(_provider, sessionId);
|
||||
var result = await ApplyDefaultsAsync(_provider, sessionId, preview);
|
||||
Assert.Empty(result.Warnings);
|
||||
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var p = await LoadSlotAsync(ctx, "Station", "P");
|
||||
Assert.True(p.IsDerived);
|
||||
var nested = await ctx.Templates.SingleAsync(t => t.Id == Assert.Single(p.Compositions).ComposedTemplateId);
|
||||
Assert.Equal((await LoadSlotAsync(ctx, "Pump", "M1")).Id, nested.ParentTemplateId);
|
||||
Assert.Equal(0, await ctx.Templates.CountAsync(t => (t.Name == "M1" || t.Name == "P") && !t.IsDerived));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A derived template whose base is in neither the bundle (Skip) nor the target fails
|
||||
/// that one record with a clear audit row and result warning; no base template is created
|
||||
/// in its place, and the rest of the import lands.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Missing_base_fails_the_derived_record_with_an_audit_row()
|
||||
{
|
||||
await SeedPumpWithDerivedMotorAsync(_provider);
|
||||
var bundle = await ExportAsync(_provider, null, "Pump");
|
||||
await WipeTemplatesAsync(_provider);
|
||||
|
||||
var result = await RoundTripAsync(_provider, bundle, new ImportResolution("Template", "Motor", ResolutionAction.Skip, null));
|
||||
|
||||
var warning = Assert.Single(result.Warnings);
|
||||
Assert.Contains("Pump.M1", warning, StringComparison.Ordinal);
|
||||
Assert.Contains("base 'Motor'", warning, StringComparison.Ordinal);
|
||||
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "Motor" || t.Name == "M1"));
|
||||
Assert.Empty((await ctx.Templates.Include(t => t.Compositions).SingleAsync(t => t.Name == "Pump")).Compositions);
|
||||
var audit = await ctx.AuditLogEntries.SingleAsync(a => a.Action == "BundleImportDerivedTemplateUnresolved");
|
||||
Assert.Equal("Pump.M1", audit.EntityName);
|
||||
Assert.Equal(result.BundleImportId, audit.BundleImportId);
|
||||
Assert.Contains("no base template was created", audit.AfterStateJson!, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A base that is NOT in the bundle but already exists on the target is composed against.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Base_already_in_the_target_is_composed_against()
|
||||
{
|
||||
await SeedPumpWithDerivedMotorAsync(_provider);
|
||||
var content = ReadContent(await ExportAsync(_provider, null, "Pump"));
|
||||
var withoutMotor = content with { Templates = content.Templates.Where(t => t.Name != "Motor").ToList() };
|
||||
|
||||
// Target: only Motor survives.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.TemplateCompositions.RemoveRange(ctx.TemplateCompositions);
|
||||
ctx.Templates.RemoveRange(ctx.Templates.Where(t => t.Name != "Motor"));
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sessionId = await PackAndLoadAsync(withoutMotor);
|
||||
var result = await ApplyDefaultsAsync(_provider, sessionId, await PreviewAsync(_provider, sessionId));
|
||||
Assert.Empty(result.Warnings);
|
||||
|
||||
await using var assertScope = _provider.CreateAsyncScope();
|
||||
var db = assertScope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var slot = await LoadSlotAsync(db, "Pump", "M1");
|
||||
Assert.Equal((await db.Templates.SingleAsync(t => t.Name == "Motor")).Id, slot.ParentTemplateId);
|
||||
Assert.Equal("5", slot.Attributes.Single(a => a.Name == "Speed").Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overwrite syncs an existing slot IN PLACE (same derived row, so nested slots that
|
||||
/// derive from it keep their FK): a changed override is applied and an own row the
|
||||
/// bundle no longer carries is reverted / removed by the inherited-member resync.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Overwrite_syncs_the_existing_slot_in_place()
|
||||
{
|
||||
var derivedId = await SeedPumpWithDerivedMotorAsync(_provider);
|
||||
var bundle = await ExportAsync(_provider, null, "Pump");
|
||||
|
||||
await WithServiceAsync(_provider, async (svc, ctx) =>
|
||||
{
|
||||
var slot = await LoadSlotAsync(ctx, "Pump", "M1");
|
||||
Ok(await svc.UpdateAttributeAsync(slot.Attributes.Single(a => a.Name == "Speed").Id,
|
||||
new TemplateAttribute("Speed") { DataType = DataType.Int32, Value = "7", IsInherited = false }, "seed"));
|
||||
Ok(await svc.UpdateAttributeAsync(slot.Attributes.Single(a => a.Name == "Mode").Id,
|
||||
new TemplateAttribute("Mode") { DataType = DataType.String, Value = "z", IsInherited = false }, "seed"));
|
||||
// (AddAttributeAsync on a slot with overrides trips the collision detector, so the
|
||||
// stale own row is staged directly — the shape an older edit could leave behind.)
|
||||
ctx.TemplateAttributes.Add(new TemplateAttribute("Stale") { TemplateId = slot.Id, DataType = DataType.String, Value = "s" });
|
||||
// The owner is Modified too, so its composition pass runs (Overwrite) — and must
|
||||
// leave the derived slot's composition row to the derived pass.
|
||||
(await ctx.Templates.SingleAsync(t => t.Name == "Pump" && !t.IsDerived)).Description = "changed";
|
||||
await ctx.SaveChangesAsync();
|
||||
return 0;
|
||||
});
|
||||
|
||||
var session = await LoadAsync(_provider, bundle);
|
||||
var preview = await PreviewAsync(_provider, session.SessionId);
|
||||
Assert.Equal(ConflictKind.Modified, preview.Items.Single(i => i.Name == "Pump").Kind);
|
||||
Assert.Equal(ConflictKind.Modified, preview.Items.Single(i => i.Name == "Pump.M1").Kind);
|
||||
await ApplyDefaultsAsync(_provider, session.SessionId, preview);
|
||||
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var after = await LoadSlotAsync(ctx, "Pump", "M1");
|
||||
Assert.Equal(derivedId, after.Id);
|
||||
Assert.Equal("5", after.Attributes.Single(a => a.Name == "Speed").Value);
|
||||
var mode = after.Attributes.Single(a => a.Name == "Mode");
|
||||
Assert.True(mode.IsInherited);
|
||||
Assert.Equal("a", mode.Value);
|
||||
Assert.DoesNotContain(after.Attributes, a => a.Name == "Stale");
|
||||
Assert.Equal(1, await ctx.Templates.CountAsync(t => t.IsDerived));
|
||||
Assert.Equal("pump", (await ctx.Templates.SingleAsync(t => t.Name == "Pump")).Description);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A derivation cycle (two slots each deriving from the other) is a preview Blocker and
|
||||
/// an apply-time <see cref="SemanticValidationException"/> naming both, raised before
|
||||
/// anything is written.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Derivation_cycle_is_a_clear_import_error()
|
||||
{
|
||||
static TemplateDto Slot(string slot, string baseSlot) =>
|
||||
new(slot, null, null, null, Array.Empty<TemplateAttributeDto>(), Array.Empty<TemplateAlarmDto>(),
|
||||
Array.Empty<TemplateScriptDto>(), Array.Empty<TemplateCompositionDto>())
|
||||
{
|
||||
Derivation = new TemplateDerivationDto("Pump", new[] { slot }, "Pump", new[] { baseSlot }),
|
||||
};
|
||||
var pump = new TemplateDto("Pump", null, null, null, Array.Empty<TemplateAttributeDto>(),
|
||||
Array.Empty<TemplateAlarmDto>(), Array.Empty<TemplateScriptDto>(),
|
||||
new[] { new TemplateCompositionDto("X", "X"), new TemplateCompositionDto("Y", "Y") });
|
||||
var content = new BundleContentDto(
|
||||
Array.Empty<TemplateFolderDto>(), new[] { pump, Slot("X", "Y"), Slot("Y", "X") },
|
||||
Array.Empty<SharedScriptDto>(), Array.Empty<ExternalSystemDto>(), Array.Empty<DatabaseConnectionDto>(),
|
||||
Array.Empty<NotificationListDto>(), Array.Empty<SmtpConfigDto>(), Array.Empty<ApiMethodDto>());
|
||||
|
||||
var sessionId = await PackAndLoadAsync(content);
|
||||
var preview = await PreviewAsync(_provider, sessionId);
|
||||
var blocker = Assert.Single(preview.Items, i => i.Kind == ConflictKind.Blocker);
|
||||
Assert.Contains("cycle", blocker.BlockerReason, StringComparison.Ordinal);
|
||||
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||
scope.ServiceProvider.GetRequiredService<IBundleImporter>().ApplyAsync(sessionId,
|
||||
new[] { new ImportResolution("Template", "Pump", ResolutionAction.Add, null) }, user: "bob"));
|
||||
var error = Assert.Single(ex.Errors);
|
||||
Assert.Contains("cycle", error, StringComparison.Ordinal);
|
||||
Assert.Contains("Pump.X", error, StringComparison.Ordinal);
|
||||
Assert.Contains("Pump.Y", error, StringComparison.Ordinal);
|
||||
|
||||
await using var check = _provider.CreateAsyncScope();
|
||||
Assert.False(await check.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>().Templates.AnyAsync());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Format compatibility: a bundle exported by the pre-#118 code (no derivation field —
|
||||
/// <c>Fixtures/pre-118-derived-plaintext.scadabundle</c>, a Pump whose slot M1 was a
|
||||
/// derived template of Motor with Speed overridden to 5) imports exactly as it always did:
|
||||
/// the slot arrives as a base template named <c>M1</c> inheriting from Motor and composed
|
||||
/// by Pump. Never regenerate the fixture — its value is that old code wrote it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Pre_118_bundle_without_the_field_imports_exactly_as_before()
|
||||
{
|
||||
var bundle = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Fixtures", "pre-118-derived-plaintext.scadabundle"));
|
||||
Assert.All(ReadContent(bundle).Templates, t => Assert.Null(t.Derivation));
|
||||
|
||||
var session = await LoadAsync(_provider, bundle);
|
||||
Assert.Equal("1.1", session.Manifest.SchemaVersion);
|
||||
var preview = await PreviewAsync(_provider, session.SessionId);
|
||||
Assert.Equal(new[] { "M1", "Motor", "Pump" }, preview.Items.Select(i => i.Name).OrderBy(n => n, StringComparer.Ordinal));
|
||||
Assert.All(preview.Items, i => Assert.Equal(ConflictKind.New, i.Kind));
|
||||
var result = await ApplyDefaultsAsync(_provider, session.SessionId, preview);
|
||||
Assert.Equal(3, result.Added);
|
||||
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var motor = await ctx.Templates.SingleAsync(t => t.Name == "Motor");
|
||||
var m1 = await ctx.Templates.Include(t => t.Attributes).SingleAsync(t => t.Name == "M1");
|
||||
Assert.False(m1.IsDerived);
|
||||
Assert.Null(m1.OwnerCompositionId);
|
||||
Assert.Equal(motor.Id, m1.ParentTemplateId);
|
||||
Assert.Equal("5", Assert.Single(m1.Attributes).Value);
|
||||
var pump = await ctx.Templates.Include(t => t.Compositions).SingleAsync(t => t.Name == "Pump");
|
||||
Assert.Equal(m1.Id, Assert.Single(pump.Compositions).ComposedTemplateId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A signed bundle carrying derivations verifies and imports the slot as derived; the
|
||||
/// derivation sits inside the content the signed manifest's content hash pins, so editing
|
||||
/// it is refused — with the old hash (content mismatch) and with a recomputed hash (the
|
||||
/// signature over the manifest no longer verifies).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Signed_bundle_with_derivations_verifies_and_pins_the_field()
|
||||
{
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var settings = new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaBridge:Transport:Signing:Enabled"] = "true",
|
||||
["ScadaBridge:Transport:Signing:PrivateKey"] = key.ExportPkcs8PrivateKeyPem(),
|
||||
["ScadaBridge:Transport:Signing:TrustedKeys:0:Name"] = "self",
|
||||
["ScadaBridge:Transport:Signing:TrustedKeys:0:PublicKey"] = key.ExportSubjectPublicKeyInfoPem(),
|
||||
["ScadaBridge:Transport:Signing:RequireSignedBundles"] = "true",
|
||||
};
|
||||
await using var provider = BuildProvider(settings);
|
||||
await SeedPumpWithDerivedMotorAsync(provider);
|
||||
var bundle = await ExportAsync(provider, null, "Pump");
|
||||
Assert.Contains("\"Derivation\"", ReadEntry(bundle, "content.json"), StringComparison.Ordinal);
|
||||
Assert.Contains("\"SchemaVersion\":\"1.2\"", ReadEntry(bundle, "manifest.json"), StringComparison.Ordinal);
|
||||
|
||||
await WipeTemplatesAsync(provider);
|
||||
var session = await LoadAsync(provider, bundle);
|
||||
Assert.Equal(BundleSignatureStatus.Verified, session.SignatureStatus);
|
||||
var preview = await PreviewAsync(provider, session.SessionId);
|
||||
await ApplyDefaultsAsync(provider, session.SessionId, preview);
|
||||
await using (var scope = provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
Assert.True((await LoadSlotAsync(ctx, "Pump", "M1")).IsDerived);
|
||||
}
|
||||
|
||||
// Tamper with the derivation only.
|
||||
var tamperedContent = ReadEntry(bundle, "content.json").Replace("\"BaseTemplateName\":\"Motor\",\"BaseSlotPath\"",
|
||||
"\"BaseTemplateName\":\"Pump\",\"BaseSlotPath\"", StringComparison.Ordinal);
|
||||
Assert.NotEqual(ReadEntry(bundle, "content.json"), tamperedContent);
|
||||
|
||||
var staleHash = ReplaceEntries(bundle, ("content.json", tamperedContent));
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => LoadAsync(provider, staleHash));
|
||||
|
||||
var newHash = "sha256:" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(tamperedContent))).ToLowerInvariant();
|
||||
var manifest = ReadEntry(bundle, "manifest.json");
|
||||
var oldHash = JsonDocument.Parse(manifest).RootElement.GetProperty("ContentHash").GetString()!;
|
||||
var rehashed = ReplaceEntries(bundle,
|
||||
("content.json", tamperedContent), ("manifest.json", manifest.Replace(oldHash, newHash, StringComparison.Ordinal)));
|
||||
await Assert.ThrowsAsync<BundleSignatureException>(() => LoadAsync(provider, rehashed));
|
||||
}
|
||||
|
||||
private static string ReadEntry(byte[] bundle, string name)
|
||||
{
|
||||
using var zip = new ZipArchive(new MemoryStream(bundle), ZipArchiveMode.Read);
|
||||
using var reader = new StreamReader(zip.GetEntry(name)!.Open());
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
private static byte[] ReplaceEntries(byte[] bundle, params (string Name, string Text)[] replacements)
|
||||
{
|
||||
var output = new MemoryStream();
|
||||
using (var source = new ZipArchive(new MemoryStream(bundle), ZipArchiveMode.Read))
|
||||
using (var target = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
foreach (var entry in source.Entries)
|
||||
{
|
||||
var replacement = replacements.FirstOrDefault(r => r.Name == entry.FullName);
|
||||
var newEntry = target.CreateEntry(entry.FullName);
|
||||
using var dest = newEntry.Open();
|
||||
if (replacement.Name is not null)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(replacement.Text);
|
||||
dest.Write(bytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var src = entry.Open();
|
||||
src.CopyTo(dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -337,6 +337,31 @@ public sealed class DependencyResolverTests
|
||||
Assert.Equal("Top", result.Templates[2].Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge#118: a derived composition-slot template pulls in its BASE (the importer
|
||||
/// rebuilds the slot by composing that base), and the base is ordered before the slot.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Resolve_pulls_in_the_base_of_a_derived_slot_and_orders_it_first()
|
||||
{
|
||||
var motor = new Template("Motor") { Id = 40 };
|
||||
var slot = new Template("M1") { Id = 41, IsDerived = true, ParentTemplateId = 40, OwnerCompositionId = 7 };
|
||||
var pump = new Template("Pump") { Id = 42 };
|
||||
pump.Compositions.Add(new TemplateComposition("M1") { Id = 7, TemplateId = 42, ComposedTemplateId = 41 });
|
||||
|
||||
StubTemplate(pump);
|
||||
StubTemplate(slot);
|
||||
StubTemplate(motor);
|
||||
StubAllSharedScripts();
|
||||
StubAllExternalSystems();
|
||||
StubAllFolders();
|
||||
|
||||
var result = await Sut().ResolveAsync(SelectTemplates(42), CancellationToken.None);
|
||||
|
||||
Assert.Equal(new[] { "Motor", "M1", "Pump" }, result.Templates.Select(t => t.Name));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Site/instance-scoped expansion.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import;
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge#118 — <see cref="DerivedTemplatePlan"/> orders a bundle's derived templates
|
||||
/// after their owner and base whatever the bundle order, and reports cycles and malformed
|
||||
/// derivations as errors.
|
||||
/// </summary>
|
||||
public sealed class DerivedTemplatePlanTests
|
||||
{
|
||||
private static TemplateDto Base(string name) =>
|
||||
new(name, null, null, null, Array.Empty<TemplateAttributeDto>(), Array.Empty<TemplateAlarmDto>(),
|
||||
Array.Empty<TemplateScriptDto>(), Array.Empty<TemplateCompositionDto>());
|
||||
|
||||
private static TemplateDto Derived(string owner, string[] slots, string baseName, params string[] baseSlots) =>
|
||||
Base(slots[^1]) with { Derivation = new TemplateDerivationDto(owner, slots, baseName, baseSlots) };
|
||||
|
||||
/// <summary>Nested slots and cascade slots come after their owner and their (derived) base.</summary>
|
||||
[Fact]
|
||||
public void Orders_owner_and_base_before_the_derived_template()
|
||||
{
|
||||
var plan = DerivedTemplatePlan.Build(new[]
|
||||
{
|
||||
Derived("Station", new[] { "P", "M1" }, "Pump", "M1"), // owner Station.P, base Pump.M1
|
||||
Base("Station"),
|
||||
Derived("Station", new[] { "P" }, "Pump"),
|
||||
Derived("Pump", new[] { "M1" }, "Motor"),
|
||||
});
|
||||
|
||||
Assert.Empty(plan.Errors);
|
||||
var order = plan.Ordered.Select(DerivedTemplatePlan.Key).ToList();
|
||||
Assert.Equal(3, order.Count);
|
||||
Assert.True(order.IndexOf("Station.P") < order.IndexOf("Station.P.M1"));
|
||||
Assert.True(order.IndexOf("Pump.M1") < order.IndexOf("Station.P.M1"));
|
||||
}
|
||||
|
||||
/// <summary>A two-node and a one-node cycle are both reported, naming the templates.</summary>
|
||||
[Fact]
|
||||
public void Cycles_are_errors()
|
||||
{
|
||||
var two = DerivedTemplatePlan.Build(new[]
|
||||
{
|
||||
Derived("Pump", new[] { "X" }, "Pump", "Y"),
|
||||
Derived("Pump", new[] { "Y" }, "Pump", "X"),
|
||||
});
|
||||
var error = Assert.Single(two.Errors);
|
||||
Assert.Contains("cycle", error, StringComparison.Ordinal);
|
||||
Assert.Contains("Pump.X", error, StringComparison.Ordinal);
|
||||
Assert.Contains("Pump.Y", error, StringComparison.Ordinal);
|
||||
|
||||
var self = DerivedTemplatePlan.Build(new[] { Derived("Pump", new[] { "X" }, "Pump", "X") });
|
||||
Assert.Contains("cycle", Assert.Single(self.Errors), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Malformed derivations and duplicate identities are errors; base templates are ignored.</summary>
|
||||
[Fact]
|
||||
public void Malformed_and_duplicate_derivations_are_errors()
|
||||
{
|
||||
var plan = DerivedTemplatePlan.Build(new[]
|
||||
{
|
||||
Base("Pump"),
|
||||
Base("M1") with { Derivation = new TemplateDerivationDto("Pump", Array.Empty<string>(), "Motor", Array.Empty<string>()) },
|
||||
Derived("Pump", new[] { "M2" }, "Motor"),
|
||||
Derived("Pump", new[] { "M2" }, "Motor"),
|
||||
});
|
||||
|
||||
Assert.Equal(2, plan.Errors.Count);
|
||||
Assert.Contains(plan.Errors, e => e.Contains("malformed", StringComparison.Ordinal));
|
||||
Assert.Contains(plan.Errors, e => e.Contains("more than once", StringComparison.Ordinal));
|
||||
Assert.Equal("Pump", DerivedTemplatePlan.Key(Base("Pump")));
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ public sealed class ManifestBuilderTests
|
||||
Assert.Equal("1.0.0", manifest.ScadaBridgeVersion);
|
||||
Assert.Equal(1, manifest.BundleFormatVersion);
|
||||
// Schema minor bumped 1.0 → 1.1; format version stays 1.
|
||||
Assert.Equal("1.1", manifest.SchemaVersion);
|
||||
Assert.Equal("1.2", manifest.SchemaVersion);
|
||||
Assert.Null(manifest.Encryption);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user