fix(template-engine): resolve TemplateEngine-001/003/004/005, re-triage 002 — recursive composed flattening, fixed-field guard, alarm script refs, dead collision query
This commit is contained in:
@@ -370,4 +370,154 @@ public class FlatteningServiceTests
|
||||
var script = result.Value.Scripts.First(s => s.CanonicalName == "Sample");
|
||||
Assert.Equal("return base;", script.Code);
|
||||
}
|
||||
|
||||
// ── TemplateEngine-001: deep composition nesting ───────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Flatten_ThreeLevelComposition_AttributesAlarmsScriptsAllResolved()
|
||||
{
|
||||
// Station composes Pump (level 1); Pump composes Motor (level 2);
|
||||
// Motor composes Bearing (level 3).
|
||||
var bearing = CreateTemplate(4, "Bearing");
|
||||
bearing.Attributes.Add(new TemplateAttribute("Vibration") { DataType = DataType.Double, Value = "0.1" });
|
||||
bearing.Alarms.Add(new TemplateAlarm("HighVibration")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Vibration\",\"high\":5}",
|
||||
PriorityLevel = 1
|
||||
});
|
||||
bearing.Scripts.Add(new TemplateScript("MonitorBearing", "// monitor") { Id = 40 });
|
||||
|
||||
var motor = CreateTemplate(3, "Motor");
|
||||
motor.Attributes.Add(new TemplateAttribute("Current") { DataType = DataType.Double, Value = "10" });
|
||||
|
||||
var pump = CreateTemplate(2, "Pump");
|
||||
pump.Attributes.Add(new TemplateAttribute("RPM") { DataType = DataType.Double, Value = "1500" });
|
||||
|
||||
var station = CreateTemplate(1, "Station");
|
||||
|
||||
var compositions = new Dictionary<int, IReadOnlyList<TemplateComposition>>
|
||||
{
|
||||
[1] = new List<TemplateComposition> { new("MainPump") { ComposedTemplateId = 2 } },
|
||||
[2] = new List<TemplateComposition> { new("DriveMotor") { ComposedTemplateId = 3 } },
|
||||
[3] = new List<TemplateComposition> { new("FrontBearing") { ComposedTemplateId = 4 } },
|
||||
};
|
||||
var composedChains = new Dictionary<int, IReadOnlyList<Template>>
|
||||
{
|
||||
[2] = [pump],
|
||||
[3] = [motor],
|
||||
[4] = [bearing],
|
||||
};
|
||||
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(instance, [station], compositions, composedChains,
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
// Level 3 attribute must be present with the full path-qualified name.
|
||||
Assert.Contains(result.Value.Attributes,
|
||||
a => a.CanonicalName == "MainPump.DriveMotor.FrontBearing.Vibration");
|
||||
// Level 3 alarm must be present (was dropped entirely before).
|
||||
Assert.Contains(result.Value.Alarms,
|
||||
a => a.CanonicalName == "MainPump.DriveMotor.FrontBearing.HighVibration");
|
||||
// Level 3 script must be present (was dropped entirely before).
|
||||
Assert.Contains(result.Value.Scripts,
|
||||
s => s.CanonicalName == "MainPump.DriveMotor.FrontBearing.MonitorBearing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_NestedComposedAlarm_TriggerAttributePrefixed()
|
||||
{
|
||||
var bearing = CreateTemplate(4, "Bearing");
|
||||
bearing.Attributes.Add(new TemplateAttribute("Vibration") { DataType = DataType.Double, Value = "0.1" });
|
||||
bearing.Alarms.Add(new TemplateAlarm("HighVibration")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Vibration\",\"high\":5}",
|
||||
PriorityLevel = 1
|
||||
});
|
||||
|
||||
var motor = CreateTemplate(3, "Motor");
|
||||
var pump = CreateTemplate(2, "Pump");
|
||||
var station = CreateTemplate(1, "Station");
|
||||
|
||||
var compositions = new Dictionary<int, IReadOnlyList<TemplateComposition>>
|
||||
{
|
||||
[1] = new List<TemplateComposition> { new("MainPump") { ComposedTemplateId = 2 } },
|
||||
[2] = new List<TemplateComposition> { new("DriveMotor") { ComposedTemplateId = 3 } },
|
||||
[3] = new List<TemplateComposition> { new("FrontBearing") { ComposedTemplateId = 4 } },
|
||||
};
|
||||
var composedChains = new Dictionary<int, IReadOnlyList<Template>>
|
||||
{
|
||||
[2] = [pump], [3] = [motor], [4] = [bearing],
|
||||
};
|
||||
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(instance, [station], compositions, composedChains,
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var alarm = result.Value.Alarms.First(a => a.CanonicalName.EndsWith("HighVibration"));
|
||||
// The trigger's attribute reference must carry the full nested prefix.
|
||||
Assert.Contains("MainPump.DriveMotor.FrontBearing.Vibration", alarm.TriggerConfiguration);
|
||||
}
|
||||
|
||||
// ── TemplateEngine-004: alarm on-trigger script resolution ─────────────
|
||||
|
||||
[Fact]
|
||||
public void Flatten_AlarmOnTriggerScript_ResolvedToCanonicalName()
|
||||
{
|
||||
var template = CreateTemplate(1, "Base");
|
||||
template.Scripts.Add(new TemplateScript("HandleAlarm", "// handle") { Id = 50 });
|
||||
template.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
||||
PriorityLevel = 1,
|
||||
OnTriggerScriptId = 50
|
||||
});
|
||||
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(instance, [template],
|
||||
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("HandleAlarm", alarm.OnTriggerScriptCanonicalName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_ComposedAlarmOnTriggerScript_ResolvedWithPrefix()
|
||||
{
|
||||
var composedTemplate = CreateTemplate(2, "Pump");
|
||||
composedTemplate.Scripts.Add(new TemplateScript("PumpAlarmHandler", "// h") { Id = 60 });
|
||||
composedTemplate.Alarms.Add(new TemplateAlarm("PumpFault")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.ValueMatch,
|
||||
TriggerConfiguration = "{\"attributeName\":\"State\",\"value\":\"FAULT\"}",
|
||||
PriorityLevel = 5,
|
||||
OnTriggerScriptId = 60
|
||||
});
|
||||
|
||||
var station = CreateTemplate(1, "Station");
|
||||
|
||||
var compositions = new Dictionary<int, IReadOnlyList<TemplateComposition>>
|
||||
{
|
||||
[1] = new List<TemplateComposition> { new("MainPump") { ComposedTemplateId = 2 } }
|
||||
};
|
||||
var composedChains = new Dictionary<int, IReadOnlyList<Template>>
|
||||
{
|
||||
[2] = [composedTemplate]
|
||||
};
|
||||
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(instance, [station], compositions, composedChains,
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var alarm = result.Value.Alarms.First(a => a.CanonicalName == "MainPump.PumpFault");
|
||||
Assert.Equal("MainPump.PumpAlarmHandler", alarm.OnTriggerScriptCanonicalName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,23 @@ public class TemplateServiceTests
|
||||
Assert.Equal(1, result.Value.ParentTemplateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTemplate_WithParent_DoesNotRunDeadCollisionQuery()
|
||||
{
|
||||
// A freshly created child has no members of its own, and the parent's
|
||||
// members were already collision-validated when they were added — so
|
||||
// create-time collision detection on a child is a guaranteed no-op.
|
||||
// The previous code allocated an unused full-table read; the fix
|
||||
// removes it. This guards against the dead query being reintroduced.
|
||||
var parent = new Template("Base") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(parent);
|
||||
|
||||
var result = await _service.CreateTemplateAsync("Child", null, 1, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
_repoMock.Verify(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTemplate_NonexistentParent_Fails()
|
||||
{
|
||||
@@ -668,6 +685,54 @@ public class TemplateServiceTests
|
||||
Assert.True(result.Value.IsLocked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAttribute_UnlockedAttribute_DataTypeChangeRejected()
|
||||
{
|
||||
// An unlocked attribute must still not be able to change its fixed DataType.
|
||||
var existing = new TemplateAttribute("Temperature")
|
||||
{
|
||||
Id = 1, TemplateId = 1, DataType = DataType.Float, IsLocked = false
|
||||
};
|
||||
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
|
||||
var proposed = new TemplateAttribute("Temperature")
|
||||
{
|
||||
DataType = DataType.Int32, IsLocked = false, Value = "42"
|
||||
};
|
||||
var result = await _service.UpdateAttributeAsync(1, proposed, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("DataType", result.Error);
|
||||
// The fixed field must not have been mutated.
|
||||
Assert.Equal(DataType.Float, existing.DataType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAttribute_UnlockedAttribute_DataSourceReferenceChangeRejected()
|
||||
{
|
||||
var existing = new TemplateAttribute("Temperature")
|
||||
{
|
||||
Id = 1, TemplateId = 1, DataType = DataType.Float, IsLocked = false,
|
||||
DataSourceReference = "/Motor/Temp"
|
||||
};
|
||||
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
|
||||
var proposed = new TemplateAttribute("Temperature")
|
||||
{
|
||||
DataType = DataType.Float, IsLocked = false, Value = "42",
|
||||
DataSourceReference = "/Motor/Other"
|
||||
};
|
||||
var result = await _service.UpdateAttributeAsync(1, proposed, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("DataSourceReference", result.Error);
|
||||
Assert.Equal("/Motor/Temp", existing.DataSourceReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAttribute_ParentLocked_CannotOverride()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user