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:
Joseph Doherty
2026-05-16 19:57:28 -04:00
parent 71c0564ec0
commit 74aae53500
5 changed files with 506 additions and 130 deletions

View File

@@ -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);
}
}

View File

@@ -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()
{