fix(transport): transport LockedInDerived on template attributes/alarms/scripts (additive DTO fields)
Adds a trailing optional `bool LockedInDerived = false` to TemplateAttributeDto, TemplateAlarmDto and TemplateScriptDto (mirrors the ExecutionTimeoutSeconds additive precedent — no schemaVersion bump). Populated at export (ToBundleContent), consumed on import (FromBundleContent, BuildTemplate, and all three SyncTemplate*Async changed-predicate + copy + new-entity paths, incl. audit payloads). Old-form bundles that lack the property still deserialize to false. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
+57
@@ -197,4 +197,61 @@ public sealed class TemplateScriptFidelityTests : IDisposable
|
||||
a.Action == "TemplateScriptUpdated" && a.EntityName == "Pump.cadence"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_preserves_LockedInDerived_on_attribute_alarm_script()
|
||||
{
|
||||
// #05-T4: LockedInDerived must survive export→import on all three child kinds.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("Pump") { Description = "fresh" };
|
||||
t.Attributes.Add(new TemplateAttribute("Pressure")
|
||||
{
|
||||
DataType = DataType.Double,
|
||||
LockedInDerived = true,
|
||||
});
|
||||
t.Alarms.Add(new TemplateAlarm("High")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
LockedInDerived = true,
|
||||
});
|
||||
t.Scripts.Add(new TemplateScript("cadence", "return 1;")
|
||||
{
|
||||
LockedInDerived = true,
|
||||
});
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
var sessionId = await ExportAndLoadAsync();
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.Templates.RemoveRange(ctx.Templates);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Add, null) },
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = await ctx.Templates
|
||||
.Where(t => t.Name == "Pump")
|
||||
.Include(t => t.Attributes)
|
||||
.Include(t => t.Alarms)
|
||||
.Include(t => t.Scripts)
|
||||
.SingleAsync();
|
||||
Assert.True(t.Attributes.Single(a => a.Name == "Pressure").LockedInDerived);
|
||||
Assert.True(t.Alarms.Single(a => a.Name == "High").LockedInDerived);
|
||||
Assert.True(t.Scripts.Single(s => s.Name == "cadence").LockedInDerived);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||
|
||||
@@ -158,6 +159,99 @@ public sealed class EntitySerializerTests
|
||||
Assert.Equal("MotorA", rtComp.InstanceName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_template_preserves_LockedInDerived_on_attribute_alarm_script()
|
||||
{
|
||||
var basic = new Template("Basic") { Id = 1 };
|
||||
basic.Attributes.Add(new TemplateAttribute("Pressure")
|
||||
{
|
||||
Id = 1,
|
||||
TemplateId = 1,
|
||||
DataType = DataType.Double,
|
||||
LockedInDerived = true,
|
||||
});
|
||||
basic.Alarms.Add(new TemplateAlarm("High")
|
||||
{
|
||||
Id = 1,
|
||||
TemplateId = 1,
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
LockedInDerived = true,
|
||||
});
|
||||
basic.Scripts.Add(new TemplateScript("OnUpdate", "return 1;")
|
||||
{
|
||||
Id = 1,
|
||||
TemplateId = 1,
|
||||
LockedInDerived = true,
|
||||
});
|
||||
|
||||
var aggregate = MakeEmptyAggregate() with { Templates = new[] { basic } };
|
||||
|
||||
var sut = new EntitySerializer();
|
||||
var dto = sut.ToBundleContent(aggregate);
|
||||
|
||||
// Export carries the flag on all three child DTOs.
|
||||
var dtoBasic = Assert.Single(dto.Templates);
|
||||
Assert.True(Assert.Single(dtoBasic.Attributes).LockedInDerived);
|
||||
Assert.True(Assert.Single(dtoBasic.Alarms).LockedInDerived);
|
||||
Assert.True(Assert.Single(dtoBasic.Scripts).LockedInDerived);
|
||||
|
||||
// FromBundleContent restores it onto the entities.
|
||||
var rtBasic = Assert.Single(sut.FromBundleContent(dto).Templates);
|
||||
Assert.True(Assert.Single(rtBasic.Attributes).LockedInDerived);
|
||||
Assert.True(Assert.Single(rtBasic.Alarms).LockedInDerived);
|
||||
Assert.True(Assert.Single(rtBasic.Scripts).LockedInDerived);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldForm_bundle_json_without_LockedInDerived_deserializes_to_false()
|
||||
{
|
||||
// A bundle written before LockedInDerived existed: the property is absent
|
||||
// from every template-child object. It must deserialize to false, not throw.
|
||||
// PascalCase keys + string enum names — the on-wire form BundleJsonOptions
|
||||
// produces (no naming policy, JsonStringEnumConverter, case-sensitive). All
|
||||
// required collections present; LockedInDerived absent on every child.
|
||||
const string json = """
|
||||
{
|
||||
"TemplateFolders": [],
|
||||
"Templates": [
|
||||
{
|
||||
"Name": "Legacy",
|
||||
"FolderName": null,
|
||||
"BaseTemplateName": null,
|
||||
"Description": null,
|
||||
"Attributes": [
|
||||
{ "Name": "A", "Value": "1", "DataType": "Double", "IsLocked": false,
|
||||
"Description": null, "DataSourceReference": null }
|
||||
],
|
||||
"Alarms": [
|
||||
{ "Name": "Alm", "Description": null, "PriorityLevel": 1,
|
||||
"TriggerType": "RangeViolation", "TriggerConfiguration": null, "IsLocked": false,
|
||||
"OnTriggerScriptName": null }
|
||||
],
|
||||
"Scripts": [
|
||||
{ "Name": "S", "Code": "return 1;", "TriggerType": null,
|
||||
"TriggerConfiguration": null, "ParameterDefinitions": null,
|
||||
"ReturnDefinition": null, "IsLocked": false }
|
||||
],
|
||||
"Compositions": []
|
||||
}
|
||||
],
|
||||
"SharedScripts": [],
|
||||
"ExternalSystems": [],
|
||||
"DatabaseConnections": [],
|
||||
"NotificationLists": [],
|
||||
"SmtpConfigs": [],
|
||||
"ApiMethods": []
|
||||
}
|
||||
""";
|
||||
|
||||
var content = JsonSerializer.Deserialize<BundleContentDto>(json, BundleJsonOptions.Default)!;
|
||||
var template = Assert.Single(content.Templates);
|
||||
Assert.False(Assert.Single(template.Attributes).LockedInDerived);
|
||||
Assert.False(Assert.Single(template.Alarms).LockedInDerived);
|
||||
Assert.False(Assert.Single(template.Scripts).LockedInDerived);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_template_folder_preserves_hierarchy()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user