feat(templates): phase 4+5 — inherit/override resolution + lock enforcement

FlatteningService now treats IsInherited rows as placeholders: when a
derived template carries an inherited attribute or script, the live base
value resolves through the ParentTemplateId chain instead of the
(possibly stale) copy. An IsInherited=false row is a real override and
wins as before.

ValidateLockedInDerived runs once per chain (main + composed) and returns
a flatten-time failure if a derived template overrides a base row that
the base marked LockedInDerived.

TemplateService.Update{Attribute,Script}Async reject mid-flight when a
derived target tries to override a LockedInDerived base member, and now
persist IsInherited/LockedInDerived from the proposed payload so the UI
can flip override state or set base-locks via the same endpoints.
This commit is contained in:
Joseph Doherty
2026-05-12 08:50:49 -04:00
parent 8b8b85c839
commit f599809486
4 changed files with 265 additions and 6 deletions

View File

@@ -262,4 +262,112 @@ public class FlatteningServiceTests
Assert.Equal(5, result.Value.SiteId);
Assert.Equal(3, result.Value.AreaId);
}
[Fact]
public void Flatten_InheritedAttributeOnDerived_BaseValueWins()
{
var baseTemplate = CreateTemplate(2, "Sensor");
baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { DataType = DataType.Double, Value = "100.0" });
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
derived.Attributes.Add(new TemplateAttribute("SetPoint")
{
DataType = DataType.Double,
Value = "STALE",
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 setPoint = result.Value.Attributes.First(a => a.CanonicalName == "SetPoint");
Assert.Equal("100.0", setPoint.Value);
}
[Fact]
public void Flatten_OverriddenAttributeOnDerived_DerivedValueWins()
{
var baseTemplate = CreateTemplate(2, "Sensor");
baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { DataType = DataType.Double, Value = "100.0" });
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
derived.Attributes.Add(new TemplateAttribute("SetPoint")
{
DataType = DataType.Double,
Value = "150.0",
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 setPoint = result.Value.Attributes.First(a => a.CanonicalName == "SetPoint");
Assert.Equal("150.0", setPoint.Value);
}
[Fact]
public void Flatten_LockedInDerivedOverride_Fails()
{
var baseTemplate = CreateTemplate(2, "Sensor");
baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint")
{
DataType = DataType.Double,
Value = "100.0",
LockedInDerived = true
});
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
derived.Attributes.Add(new TemplateAttribute("SetPoint")
{
DataType = DataType.Double,
Value = "150.0",
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("SetPoint", result.Error);
}
[Fact]
public void Flatten_InheritedScriptOnDerived_BaseCodeWins()
{
var baseTemplate = CreateTemplate(2, "Sensor");
baseTemplate.Scripts.Add(new TemplateScript("Sample", "return base;"));
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
derived.Scripts.Add(new TemplateScript("Sample", "stale code") { 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 script = result.Value.Scripts.First(s => s.CanonicalName == "Sample");
Assert.Equal("return base;", script.Code);
}
}

View File

@@ -398,6 +398,70 @@ public class TemplateServiceTests
Assert.Contains("derived template", result.Error);
}
[Fact]
public async Task UpdateAttribute_LockedInDerivedBase_RejectsOnDerived()
{
var existing = new TemplateAttribute("SetPoint") { Id = 100, TemplateId = 77, DataType = DataType.Float, IsInherited = true };
var baseTemplate = new Template("Sensor") { Id = 2 };
baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { Id = 10, TemplateId = 2, LockedInDerived = true });
var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(100, 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 TemplateAttribute("SetPoint") { Value = "99", DataType = DataType.Float, IsInherited = false };
var result = await _service.UpdateAttributeAsync(100, proposed, "admin");
Assert.True(result.IsFailure);
Assert.Contains("locked by base template 'Sensor'", result.Error);
}
[Fact]
public async Task UpdateScript_LockedInDerivedBase_RejectsOnDerived()
{
var existing = new TemplateScript("Sample", "stale") { Id = 200, TemplateId = 77, IsInherited = true };
var baseTemplate = new Template("Sensor") { Id = 2 };
baseTemplate.Scripts.Add(new TemplateScript("Sample", "return 1;") { Id = 20, TemplateId = 2, LockedInDerived = true });
var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };
_repoMock.Setup(r => r.GetTemplateScriptByIdAsync(200, 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 TemplateScript("Sample", "return 2;") { IsInherited = false };
var result = await _service.UpdateScriptAsync(200, proposed, "admin");
Assert.True(result.IsFailure);
Assert.Contains("locked by base template 'Sensor'", result.Error);
}
[Fact]
public async Task UpdateAttribute_DerivedOverride_PersistsIsInheritedFalse()
{
var existing = new TemplateAttribute("SetPoint") { Id = 100, TemplateId = 77, DataType = DataType.Float, IsInherited = true };
var baseTemplate = new Template("Sensor") { Id = 2 };
baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { Id = 10, TemplateId = 2 });
var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(100, 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 TemplateAttribute("SetPoint") { Value = "99", DataType = DataType.Float, IsInherited = false };
var result = await _service.UpdateAttributeAsync(100, proposed, "admin");
Assert.True(result.IsSuccess);
Assert.False(result.Value.IsInherited);
Assert.Equal("99", result.Value.Value);
}
[Fact]
public async Task DeleteTemplate_BaseWithDerivatives_BlockedWithSlotNames()
{