diff --git a/src/ScadaLink.Transport/Serialization/EntityDtos.cs b/src/ScadaLink.Transport/Serialization/EntityDtos.cs index 58e58c2..53112b6 100644 --- a/src/ScadaLink.Transport/Serialization/EntityDtos.cs +++ b/src/ScadaLink.Transport/Serialization/EntityDtos.cs @@ -78,7 +78,8 @@ public sealed record TemplateAlarmDto( int PriorityLevel, AlarmTriggerType TriggerType, string? TriggerConfiguration, - bool IsLocked); + bool IsLocked, + string? OnTriggerScriptName); public sealed record TemplateScriptDto( string Name, diff --git a/src/ScadaLink.Transport/Serialization/EntitySerializer.cs b/src/ScadaLink.Transport/Serialization/EntitySerializer.cs index 3197ee7..eb6a989 100644 --- a/src/ScadaLink.Transport/Serialization/EntitySerializer.cs +++ b/src/ScadaLink.Transport/Serialization/EntitySerializer.cs @@ -29,37 +29,53 @@ public sealed class EntitySerializer Name: f.Name, ParentName: f.ParentFolderId is { } pid && folderNameById.TryGetValue(pid, out var pname) ? pname : null, SortOrder: f.SortOrder)).ToList(), - Templates: aggregate.Templates.Select(t => new TemplateDto( - Name: t.Name, - FolderName: t.FolderId is { } fid && folderNameById.TryGetValue(fid, out var fname) ? fname : null, - BaseTemplateName: t.ParentTemplateId is { } btid && templateNameById.TryGetValue(btid, out var bname) ? bname : null, - Description: t.Description, - Attributes: t.Attributes.Select(a => new TemplateAttributeDto( - Name: a.Name, - Value: a.Value, - DataType: a.DataType, - IsLocked: a.IsLocked, - Description: a.Description, - DataSourceReference: a.DataSourceReference)).ToList(), - Alarms: t.Alarms.Select(a => new TemplateAlarmDto( - Name: a.Name, - Description: a.Description, - PriorityLevel: a.PriorityLevel, - TriggerType: a.TriggerType, - TriggerConfiguration: a.TriggerConfiguration, - IsLocked: a.IsLocked)).ToList(), - Scripts: t.Scripts.Select(s => new TemplateScriptDto( - Name: s.Name, - Code: s.Code, - TriggerType: s.TriggerType, - TriggerConfiguration: s.TriggerConfiguration, - ParameterDefinitions: s.ParameterDefinitions, - ReturnDefinition: s.ReturnDefinition, - IsLocked: s.IsLocked, - MinTimeBetweenRuns: s.MinTimeBetweenRuns)).ToList(), - Compositions: t.Compositions.Select(c => new TemplateCompositionDto( - InstanceName: c.InstanceName, - ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList())).ToList(), + Templates: aggregate.Templates.Select(t => + { + // Build per-template script-id → name lookup once so the alarm + // projection below resolves OnTriggerScriptId by name in O(1). + // Scripts can only target sibling scripts on the same template + // (TemplateAlarm.OnTriggerScriptId FK is scoped to TemplateId), + // so we don't need a global script index. + var scriptNameById = t.Scripts.ToDictionary(s => s.Id, s => s.Name); + return new TemplateDto( + Name: t.Name, + FolderName: t.FolderId is { } fid && folderNameById.TryGetValue(fid, out var fname) ? fname : null, + BaseTemplateName: t.ParentTemplateId is { } btid && templateNameById.TryGetValue(btid, out var bname) ? bname : null, + Description: t.Description, + Attributes: t.Attributes.Select(a => new TemplateAttributeDto( + Name: a.Name, + Value: a.Value, + DataType: a.DataType, + IsLocked: a.IsLocked, + Description: a.Description, + DataSourceReference: a.DataSourceReference)).ToList(), + Alarms: t.Alarms.Select(a => new TemplateAlarmDto( + Name: a.Name, + Description: a.Description, + PriorityLevel: a.PriorityLevel, + TriggerType: a.TriggerType, + TriggerConfiguration: a.TriggerConfiguration, + IsLocked: a.IsLocked, + // Carry the on-trigger script by NAME — the importer resolves + // this back to a script id once the parent template's scripts + // have been persisted and assigned ids. If the FK doesn't + // resolve in this aggregate (e.g. corrupt/orphan row), the + // name comes through as null and the importer leaves the + // FK null on the imported alarm. + OnTriggerScriptName: a.OnTriggerScriptId is { } sid && scriptNameById.TryGetValue(sid, out var sn) ? sn : null)).ToList(), + Scripts: t.Scripts.Select(s => new TemplateScriptDto( + Name: s.Name, + Code: s.Code, + TriggerType: s.TriggerType, + TriggerConfiguration: s.TriggerConfiguration, + ParameterDefinitions: s.ParameterDefinitions, + ReturnDefinition: s.ReturnDefinition, + IsLocked: s.IsLocked, + MinTimeBetweenRuns: s.MinTimeBetweenRuns)).ToList(), + Compositions: t.Compositions.Select(c => new TemplateCompositionDto( + InstanceName: c.InstanceName, + ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList()); + }).ToList(), SharedScripts: aggregate.SharedScripts.Select(s => new SharedScriptDto( Name: s.Name, Code: s.Code, diff --git a/tests/ScadaLink.Transport.Tests/Serialization/EntitySerializerTests.cs b/tests/ScadaLink.Transport.Tests/Serialization/EntitySerializerTests.cs index eeed2d8..d9b00db 100644 --- a/tests/ScadaLink.Transport.Tests/Serialization/EntitySerializerTests.cs +++ b/tests/ScadaLink.Transport.Tests/Serialization/EntitySerializerTests.cs @@ -79,15 +79,6 @@ public sealed class EntitySerializerTests IsLocked = true, Description = "PSI", }); - basic.Alarms.Add(new TemplateAlarm("High") - { - Id = 1, - TemplateId = 1, - PriorityLevel = 2, - TriggerType = AlarmTriggerType.RangeViolation, - TriggerConfiguration = "{\"threshold\":100}", - IsLocked = false, - }); basic.Scripts.Add(new TemplateScript("OnUpdate", "return 1;") { Id = 1, @@ -98,6 +89,19 @@ public sealed class EntitySerializerTests IsLocked = false, MinTimeBetweenRuns = TimeSpan.FromSeconds(30), }); + basic.Alarms.Add(new TemplateAlarm("High") + { + Id = 1, + TemplateId = 1, + PriorityLevel = 2, + TriggerType = AlarmTriggerType.RangeViolation, + TriggerConfiguration = "{\"threshold\":100}", + IsLocked = false, + // FU-B / #37 — alarm fires "OnUpdate" script when triggered. The FK is + // an in-aggregate script id; the DTO carries the name and the + // importer resolves the FK on the way back in. + OnTriggerScriptId = 1, + }); var assembly = new Template("Assembly") { Id = 2, FolderId = 1 }; assembly.Compositions.Add(new TemplateComposition("MotorA") @@ -115,6 +119,15 @@ public sealed class EntitySerializerTests var sut = new EntitySerializer(); var dto = sut.ToBundleContent(aggregate); + + // FU-B / #37 — verify the DTO carries OnTriggerScriptName by NAME (not id). + // The bundle is portable across environments so a script-id FK can't + // survive a round-trip; resolution back to a script id is the importer's + // job once the parent template's scripts have been re-persisted. + var dtoBasic = Assert.Single(dto.Templates, t => t.Name == "Basic"); + var dtoAlarm = Assert.Single(dtoBasic.Alarms); + Assert.Equal("OnUpdate", dtoAlarm.OnTriggerScriptName); + var roundTripped = sut.FromBundleContent(dto); var rtBasic = Assert.Single(roundTripped.Templates, t => t.Name == "Basic"); @@ -129,6 +142,9 @@ public sealed class EntitySerializerTests Assert.Equal(AlarmTriggerType.RangeViolation, rtAlarm.TriggerType); Assert.Equal("{\"threshold\":100}", rtAlarm.TriggerConfiguration); Assert.Equal(2, rtAlarm.PriorityLevel); + // FromBundleContent leaves OnTriggerScriptId null — the importer + // resolves it post-persist when target-DB script ids are known. + Assert.Null(rtAlarm.OnTriggerScriptId); var rtScript = Assert.Single(rtBasic.Scripts); Assert.Equal("OnUpdate", rtScript.Name);