feat(template-engine): resolve TemplateEngine-002 — per-slot alarm override for derived templates
Adds IsInherited/LockedInDerived to the TemplateAlarm entity (mirroring the attribute/script override model), an EF migration, base-alarm copy-on-derive, inherited-alarm flattening skip, and LockedInDerived override-rejection validation.
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 10 |
|
| Open findings | 9 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ Regression tests: `Flatten_ThreeLevelComposition_AttributesAlarmsScriptsAllResol
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:799` |
|
| Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:799` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -116,22 +116,25 @@ already do.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved (re-triaged 2026-05-16)._ Partially mis-stated and out of the
|
Resolved 2026-05-16 (commit `<pending>`): implemented the per-slot alarm
|
||||||
current fix scope. Correction to the description: composed/inherited alarms
|
override mechanism as a coordinated `Commons` + `ConfigurationDatabase` +
|
||||||
are **not** dropped from the flattened deployment output — `FlatteningService`
|
`TemplateEngine` change, mirroring the existing attribute/script override
|
||||||
resolves alarms from the entire inheritance chain (`ResolveInheritedAlarms`
|
design. Added `IsInherited` / `LockedInDerived` to the `TemplateAlarm` POCO
|
||||||
walks `templateChain`, which includes the base of a derived template), so an
|
(`ScadaLink.Commons`) and an EF migration `AddDerivedAlarmFields` adding two
|
||||||
instance of a derived template still receives the base template's alarms. The
|
`bit NOT NULL DEFAULT 0` columns to `TemplateAlarms`. `BuildDerivedTemplate`
|
||||||
real, valid gap is narrower: there is no per-slot **alarm override**
|
now copies base alarms as `IsInherited = true` placeholder rows.
|
||||||
mechanism. The fix genuinely requires adding `IsInherited` / `LockedInDerived`
|
`FlatteningService.ResolveInheritedAlarms` skips `IsInherited` placeholder
|
||||||
fields to the `TemplateAlarm` entity, which lives in `ScadaLink.Commons`
|
rows so they no longer shadow the live base alarm, and `ValidateLockedInDerived`
|
||||||
(a different module). Adding an alarm copy loop to `BuildDerivedTemplate`
|
now rejects a derived override of a `LockedInDerived` base alarm.
|
||||||
without those fields would be actively harmful: copied alarm rows on the
|
`UpdateAlarmAsync` honours the base `LockedInDerived` lock and persists
|
||||||
derived template would shadow the live base alarm with stale data during
|
`IsInherited` / `LockedInDerived`, exactly as `UpdateAttributeAsync` /
|
||||||
flattening (`ResolveInheritedAlarms` has no `IsInherited` skip for alarms,
|
`UpdateScriptAsync` do. Regression tests:
|
||||||
unlike attributes/scripts). Resolving this safely is a cross-module change
|
`Flatten_InheritedAlarmOnDerived_BaseValueWins`,
|
||||||
(`Commons` + `TemplateEngine`) and must be scheduled as a coordinated edit;
|
`Flatten_OverriddenAlarmOnDerived_DerivedValueWins`,
|
||||||
left **Open** pending that.
|
`Flatten_LockedInDerivedAlarmOverride_Fails`,
|
||||||
|
`AddComposition_CopiesAlarmsAsInherited`,
|
||||||
|
`UpdateAlarm_LockedInDerivedBase_RejectsOnDerived`,
|
||||||
|
`UpdateAlarm_DerivedOverride_PersistsIsInheritedFalse`.
|
||||||
|
|
||||||
### TemplateEngine-003 — `UpdateAttributeAsync` lets a non-locked attribute change its fixed DataType / DataSourceReference
|
### TemplateEngine-003 — `UpdateAttributeAsync` lets a non-locked attribute change its fixed DataType / DataSourceReference
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,21 @@ public class TemplateAlarm
|
|||||||
public string? TriggerConfiguration { get; set; }
|
public string? TriggerConfiguration { get; set; }
|
||||||
public int? OnTriggerScriptId { get; set; }
|
public int? OnTriggerScriptId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when this row was copied from the base template and has not been
|
||||||
|
/// overridden on the derived template. Changes to the base flow downward
|
||||||
|
/// for inherited rows; an explicit override flips this to false.
|
||||||
|
/// Always false on base (non-derived) templates.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsInherited { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set on a base alarm. When true, derived templates may not override the
|
||||||
|
/// alarm — the row is rendered readonly with a 🔒 in the derived UI, and
|
||||||
|
/// any attempt to update it through the API is rejected.
|
||||||
|
/// </summary>
|
||||||
|
public bool LockedInDerived { get; set; }
|
||||||
|
|
||||||
public TemplateAlarm(string name)
|
public TemplateAlarm(string name)
|
||||||
{
|
{
|
||||||
Name = name ?? throw new ArgumentNullException(nameof(name));
|
Name = name ?? throw new ArgumentNullException(nameof(name));
|
||||||
|
|||||||
1348
src/ScadaLink.ConfigurationDatabase/Migrations/20260517000628_AddDerivedAlarmFields.Designer.cs
generated
Normal file
1348
src/ScadaLink.ConfigurationDatabase/Migrations/20260517000628_AddDerivedAlarmFields.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddDerivedAlarmFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsInherited",
|
||||||
|
table: "TemplateAlarms",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "LockedInDerived",
|
||||||
|
table: "TemplateAlarms",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsInherited",
|
||||||
|
table: "TemplateAlarms");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LockedInDerived",
|
||||||
|
table: "TemplateAlarms");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -917,9 +917,15 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
|||||||
.HasMaxLength(2000)
|
.HasMaxLength(2000)
|
||||||
.HasColumnType("nvarchar(2000)");
|
.HasColumnType("nvarchar(2000)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsInherited")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("IsLocked")
|
b.Property<bool>("IsLocked")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("LockedInDerived")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
|
|||||||
@@ -180,13 +180,14 @@ public class FlatteningService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reports any LockedInDerived violations across the chain — i.e., a base
|
/// Reports any LockedInDerived violations across the chain — i.e., a base
|
||||||
/// attribute/script marked LockedInDerived that a downstream derived
|
/// attribute/alarm/script marked LockedInDerived that a downstream derived
|
||||||
/// template overrides (IsInherited=false). Returns null on success or an
|
/// template overrides (IsInherited=false). Returns null on success or an
|
||||||
/// error message describing the first offending entries.
|
/// error message describing the first offending entries.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string? ValidateLockedInDerived(IReadOnlyList<Template> templateChain)
|
private static string? ValidateLockedInDerived(IReadOnlyList<Template> templateChain)
|
||||||
{
|
{
|
||||||
var attrLocks = new Dictionary<string, Template>(StringComparer.Ordinal);
|
var attrLocks = new Dictionary<string, Template>(StringComparer.Ordinal);
|
||||||
|
var alarmLocks = new Dictionary<string, Template>(StringComparer.Ordinal);
|
||||||
var scriptLocks = new Dictionary<string, Template>(StringComparer.Ordinal);
|
var scriptLocks = new Dictionary<string, Template>(StringComparer.Ordinal);
|
||||||
var errors = new List<string>();
|
var errors = new List<string>();
|
||||||
|
|
||||||
@@ -202,6 +203,14 @@ public class FlatteningService
|
|||||||
errors.Add($"Attribute '{attr.Name}' is LockedInDerived by base template '{lockingTemplate.Name}' and cannot be overridden by '{template.Name}'.");
|
errors.Add($"Attribute '{attr.Name}' is LockedInDerived by base template '{lockingTemplate.Name}' and cannot be overridden by '{template.Name}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var alarm in template.Alarms)
|
||||||
|
{
|
||||||
|
if (alarm.LockedInDerived)
|
||||||
|
alarmLocks[alarm.Name] = template;
|
||||||
|
else if (!alarm.IsInherited && alarmLocks.TryGetValue(alarm.Name, out var lockingTemplate) && lockingTemplate.Id != template.Id)
|
||||||
|
errors.Add($"Alarm '{alarm.Name}' is LockedInDerived by base template '{lockingTemplate.Name}' and cannot be overridden by '{template.Name}'.");
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var script in template.Scripts)
|
foreach (var script in template.Scripts)
|
||||||
{
|
{
|
||||||
if (script.LockedInDerived)
|
if (script.LockedInDerived)
|
||||||
@@ -385,8 +394,16 @@ public class FlatteningService
|
|||||||
|
|
||||||
foreach (var alarm in template.Alarms)
|
foreach (var alarm in template.Alarms)
|
||||||
{
|
{
|
||||||
if (result.TryGetValue(alarm.Name, out var existing) && existing.IsLocked)
|
if (result.TryGetValue(alarm.Name, out var existing))
|
||||||
|
{
|
||||||
|
if (existing.IsLocked)
|
||||||
continue;
|
continue;
|
||||||
|
// IsInherited rows on a derived template are placeholders
|
||||||
|
// that must not shadow the live base alarm; they only
|
||||||
|
// contribute a row when the base lacks one.
|
||||||
|
if (alarm.IsInherited)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// HiLo per-setpoint override: derived templates can supply a
|
// HiLo per-setpoint override: derived templates can supply a
|
||||||
// partial TriggerConfiguration (e.g., just `hi`) and have the
|
// partial TriggerConfiguration (e.g., just `hi`) and have the
|
||||||
|
|||||||
@@ -398,6 +398,16 @@ public class TemplateService
|
|||||||
if (parentMember != null && parentMember.IsLocked)
|
if (parentMember != null && parentMember.IsLocked)
|
||||||
return Result<TemplateAlarm>.Failure(
|
return Result<TemplateAlarm>.Failure(
|
||||||
$"Alarm '{existing.Name}' is locked in parent and cannot be overridden.");
|
$"Alarm '{existing.Name}' is locked in parent and cannot be overridden.");
|
||||||
|
|
||||||
|
// Derived templates may not override alarms the base marked LockedInDerived.
|
||||||
|
if (template.IsDerived)
|
||||||
|
{
|
||||||
|
var baseTemplate = await _repository.GetTemplateByIdAsync(template.ParentTemplateId.Value, cancellationToken);
|
||||||
|
var baseAlarm = baseTemplate?.Alarms.FirstOrDefault(a => a.Name == existing.Name);
|
||||||
|
if (baseAlarm != null && baseAlarm.LockedInDerived)
|
||||||
|
return Result<TemplateAlarm>.Failure(
|
||||||
|
$"Alarm '{existing.Name}' is locked by base template '{baseTemplate!.Name}' and cannot be overridden.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate fixed fields
|
// Validate fixed fields
|
||||||
@@ -411,6 +421,10 @@ public class TemplateService
|
|||||||
existing.Description = proposed.Description;
|
existing.Description = proposed.Description;
|
||||||
existing.OnTriggerScriptId = proposed.OnTriggerScriptId;
|
existing.OnTriggerScriptId = proposed.OnTriggerScriptId;
|
||||||
existing.IsLocked = proposed.IsLocked;
|
existing.IsLocked = proposed.IsLocked;
|
||||||
|
if (template?.IsDerived == true)
|
||||||
|
existing.IsInherited = proposed.IsInherited;
|
||||||
|
else
|
||||||
|
existing.LockedInDerived = proposed.LockedInDerived;
|
||||||
// Name and TriggerType are NOT updated (fixed)
|
// Name and TriggerType are NOT updated (fixed)
|
||||||
|
|
||||||
await _repository.UpdateTemplateAlarmAsync(existing, cancellationToken);
|
await _repository.UpdateTemplateAlarmAsync(existing, cancellationToken);
|
||||||
@@ -818,6 +832,21 @@ public class TemplateService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var alarm in baseTemplate.Alarms)
|
||||||
|
{
|
||||||
|
derived.Alarms.Add(new TemplateAlarm(alarm.Name)
|
||||||
|
{
|
||||||
|
Description = alarm.Description,
|
||||||
|
PriorityLevel = alarm.PriorityLevel,
|
||||||
|
IsLocked = alarm.IsLocked,
|
||||||
|
TriggerType = alarm.TriggerType,
|
||||||
|
TriggerConfiguration = alarm.TriggerConfiguration,
|
||||||
|
OnTriggerScriptId = alarm.OnTriggerScriptId,
|
||||||
|
IsInherited = true,
|
||||||
|
LockedInDerived = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var script in baseTemplate.Scripts)
|
foreach (var script in baseTemplate.Scripts)
|
||||||
{
|
{
|
||||||
derived.Scripts.Add(new TemplateScript(script.Name, script.Code)
|
derived.Scripts.Add(new TemplateScript(script.Name, script.Code)
|
||||||
|
|||||||
@@ -371,6 +371,110 @@ public class FlatteningServiceTests
|
|||||||
Assert.Equal("return base;", script.Code);
|
Assert.Equal("return base;", script.Code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── TemplateEngine-002: per-slot alarm override ────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Flatten_InheritedAlarmOnDerived_BaseValueWins()
|
||||||
|
{
|
||||||
|
var baseTemplate = CreateTemplate(2, "Sensor");
|
||||||
|
baseTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||||
|
{
|
||||||
|
TriggerType = AlarmTriggerType.RangeViolation,
|
||||||
|
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
||||||
|
PriorityLevel = 5
|
||||||
|
});
|
||||||
|
|
||||||
|
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
|
||||||
|
derived.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||||
|
{
|
||||||
|
TriggerType = AlarmTriggerType.RangeViolation,
|
||||||
|
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":999}",
|
||||||
|
PriorityLevel = 99,
|
||||||
|
IsInherited = true
|
||||||
|
});
|
||||||
|
|
||||||
|
var instance = CreateInstance();
|
||||||
|
var result = _sut.Flatten(
|
||||||
|
instance,
|
||||||
|
[derived, baseTemplate],
|
||||||
|
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||||
|
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||||
|
new Dictionary<int, DataConnection>());
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
var alarm = result.Value.Alarms.First(a => a.CanonicalName == "HighTemp");
|
||||||
|
Assert.Equal(5, alarm.PriorityLevel);
|
||||||
|
Assert.Equal("{\"attributeName\":\"Temp\",\"high\":100}", alarm.TriggerConfiguration);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Flatten_OverriddenAlarmOnDerived_DerivedValueWins()
|
||||||
|
{
|
||||||
|
var baseTemplate = CreateTemplate(2, "Sensor");
|
||||||
|
baseTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||||
|
{
|
||||||
|
TriggerType = AlarmTriggerType.RangeViolation,
|
||||||
|
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
||||||
|
PriorityLevel = 5
|
||||||
|
});
|
||||||
|
|
||||||
|
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
|
||||||
|
derived.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||||
|
{
|
||||||
|
TriggerType = AlarmTriggerType.RangeViolation,
|
||||||
|
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":120}",
|
||||||
|
PriorityLevel = 42,
|
||||||
|
IsInherited = false
|
||||||
|
});
|
||||||
|
|
||||||
|
var instance = CreateInstance();
|
||||||
|
var result = _sut.Flatten(
|
||||||
|
instance,
|
||||||
|
[derived, baseTemplate],
|
||||||
|
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||||
|
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||||
|
new Dictionary<int, DataConnection>());
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
var alarm = result.Value.Alarms.First(a => a.CanonicalName == "HighTemp");
|
||||||
|
Assert.Equal(42, alarm.PriorityLevel);
|
||||||
|
Assert.Equal("{\"attributeName\":\"Temp\",\"high\":120}", alarm.TriggerConfiguration);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Flatten_LockedInDerivedAlarmOverride_Fails()
|
||||||
|
{
|
||||||
|
var baseTemplate = CreateTemplate(2, "Sensor");
|
||||||
|
baseTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||||
|
{
|
||||||
|
TriggerType = AlarmTriggerType.RangeViolation,
|
||||||
|
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
||||||
|
PriorityLevel = 5,
|
||||||
|
LockedInDerived = true
|
||||||
|
});
|
||||||
|
|
||||||
|
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
|
||||||
|
derived.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||||
|
{
|
||||||
|
TriggerType = AlarmTriggerType.RangeViolation,
|
||||||
|
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":120}",
|
||||||
|
PriorityLevel = 42,
|
||||||
|
IsInherited = false
|
||||||
|
});
|
||||||
|
|
||||||
|
var instance = CreateInstance();
|
||||||
|
var result = _sut.Flatten(
|
||||||
|
instance,
|
||||||
|
[derived, baseTemplate],
|
||||||
|
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||||
|
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||||
|
new Dictionary<int, DataConnection>());
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("LockedInDerived", result.Error);
|
||||||
|
Assert.Contains("HighTemp", result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
// ── TemplateEngine-001: deep composition nesting ───────────────────────
|
// ── TemplateEngine-001: deep composition nesting ───────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -566,6 +566,123 @@ public class TemplateServiceTests
|
|||||||
Assert.Contains("locked by base template 'Sensor'", result.Error);
|
Assert.Contains("locked by base template 'Sensor'", result.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddComposition_CopiesAlarmsAsInherited()
|
||||||
|
{
|
||||||
|
var moduleTemplate = new Template("Module") { Id = 2 };
|
||||||
|
moduleTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||||
|
{
|
||||||
|
Id = 30,
|
||||||
|
TemplateId = 2,
|
||||||
|
TriggerType = AlarmTriggerType.RangeViolation,
|
||||||
|
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
||||||
|
PriorityLevel = 5,
|
||||||
|
Description = "Too hot"
|
||||||
|
});
|
||||||
|
var template = new Template("Parent") { Id = 1 };
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { template, moduleTemplate });
|
||||||
|
|
||||||
|
Template? captured = null;
|
||||||
|
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<Template, CancellationToken>((t, _) => captured = t)
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
Assert.Single(captured!.Alarms);
|
||||||
|
var copied = captured.Alarms.First();
|
||||||
|
Assert.Equal("HighTemp", copied.Name);
|
||||||
|
Assert.True(copied.IsInherited);
|
||||||
|
Assert.False(copied.LockedInDerived);
|
||||||
|
Assert.Equal(AlarmTriggerType.RangeViolation, copied.TriggerType);
|
||||||
|
Assert.Equal(5, copied.PriorityLevel);
|
||||||
|
Assert.Equal("Too hot", copied.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAlarm_LockedInDerivedBase_RejectsOnDerived()
|
||||||
|
{
|
||||||
|
var existing = new TemplateAlarm("HighTemp")
|
||||||
|
{
|
||||||
|
Id = 300,
|
||||||
|
TemplateId = 77,
|
||||||
|
TriggerType = AlarmTriggerType.RangeViolation,
|
||||||
|
PriorityLevel = 5,
|
||||||
|
IsInherited = true
|
||||||
|
};
|
||||||
|
var baseTemplate = new Template("Sensor") { Id = 2 };
|
||||||
|
baseTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||||
|
{
|
||||||
|
Id = 30,
|
||||||
|
TemplateId = 2,
|
||||||
|
TriggerType = AlarmTriggerType.RangeViolation,
|
||||||
|
LockedInDerived = true
|
||||||
|
});
|
||||||
|
var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetTemplateAlarmByIdAsync(300, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { baseTemplate, derived });
|
||||||
|
|
||||||
|
var proposed = new TemplateAlarm("HighTemp")
|
||||||
|
{
|
||||||
|
TriggerType = AlarmTriggerType.RangeViolation,
|
||||||
|
PriorityLevel = 99,
|
||||||
|
IsInherited = false
|
||||||
|
};
|
||||||
|
var result = await _service.UpdateAlarmAsync(300, proposed, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("locked by base template 'Sensor'", result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAlarm_DerivedOverride_PersistsIsInheritedFalse()
|
||||||
|
{
|
||||||
|
var existing = new TemplateAlarm("HighTemp")
|
||||||
|
{
|
||||||
|
Id = 300,
|
||||||
|
TemplateId = 77,
|
||||||
|
TriggerType = AlarmTriggerType.RangeViolation,
|
||||||
|
PriorityLevel = 5,
|
||||||
|
IsInherited = true
|
||||||
|
};
|
||||||
|
var baseTemplate = new Template("Sensor") { Id = 2 };
|
||||||
|
baseTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||||
|
{
|
||||||
|
Id = 30,
|
||||||
|
TemplateId = 2,
|
||||||
|
TriggerType = AlarmTriggerType.RangeViolation
|
||||||
|
});
|
||||||
|
var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetTemplateAlarmByIdAsync(300, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { baseTemplate, derived });
|
||||||
|
|
||||||
|
var proposed = new TemplateAlarm("HighTemp")
|
||||||
|
{
|
||||||
|
TriggerType = AlarmTriggerType.RangeViolation,
|
||||||
|
PriorityLevel = 99,
|
||||||
|
IsInherited = false
|
||||||
|
};
|
||||||
|
var result = await _service.UpdateAlarmAsync(300, proposed, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.False(result.Value.IsInherited);
|
||||||
|
Assert.Equal(99, result.Value.PriorityLevel);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateAttribute_DerivedOverride_PersistsIsInheritedFalse()
|
public async Task UpdateAttribute_DerivedOverride_PersistsIsInheritedFalse()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user