fix(template-engine): resolve TemplateEngine-006..010 — code-region-aware API/brace scanning, composed-alarm override validation, N+1 fix, doc correction

This commit is contained in:
Joseph Doherty
2026-05-16 21:44:11 -04:00
parent 5672502d83
commit 804697f873
11 changed files with 832 additions and 160 deletions

View File

@@ -119,6 +119,106 @@ public class InstanceServiceTests
Assert.Equal("99", result.Value.OverrideValue);
}
// --- TemplateEngine-008 regression: SetAlarmOverrideAsync validation ---
private static Template TemplateWithAlarms(int id, params TemplateAlarm[] alarms)
{
var t = new Template($"T{id}") { Id = id };
foreach (var a in alarms)
{
a.TemplateId = id;
t.Alarms.Add(a);
}
return t;
}
[Fact]
public async Task SetAlarmOverride_NonExistentAlarm_ReturnsFailure()
{
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(instance);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template>
{
TemplateWithAlarms(1, new TemplateAlarm("HighTemp") { Id = 10 })
});
var result = await _sut.SetAlarmOverrideAsync(1, "Missing", "{}", null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("does not exist", result.Error);
_repoMock.Verify(r => r.AddInstanceAlarmOverrideAsync(
It.IsAny<InstanceAlarmOverride>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task SetAlarmOverride_ComposedLockedAlarm_ReturnsFailure()
{
// The locked alarm lives in a composed module, so it is NOT a direct
// alarm of the instance's template — the old code skipped the lock check.
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(instance);
var module = TemplateWithAlarms(2, new TemplateAlarm("Fault") { Id = 20, IsLocked = true });
var host = new Template("Host") { Id = 1 };
host.Compositions.Add(new TemplateComposition("Pump") { Id = 1, ComposedTemplateId = 2 });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { host, module });
var result = await _sut.SetAlarmOverrideAsync(1, "Pump.Fault", "{}", null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("locked", result.Error, StringComparison.OrdinalIgnoreCase);
_repoMock.Verify(r => r.AddInstanceAlarmOverrideAsync(
It.IsAny<InstanceAlarmOverride>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task SetAlarmOverride_ComposedUnlockedAlarm_ReturnsSuccess()
{
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(instance);
var module = TemplateWithAlarms(2, new TemplateAlarm("Fault") { Id = 20, IsLocked = false });
var host = new Template("Host") { Id = 1 };
host.Compositions.Add(new TemplateComposition("Pump") { Id = 1, ComposedTemplateId = 2 });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { host, module });
_repoMock.Setup(r => r.GetAlarmOverrideAsync(1, "Pump.Fault", It.IsAny<CancellationToken>()))
.ReturnsAsync((InstanceAlarmOverride?)null);
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var result = await _sut.SetAlarmOverrideAsync(1, "Pump.Fault", "{}", 2, "admin");
Assert.True(result.IsSuccess);
_repoMock.Verify(r => r.AddInstanceAlarmOverrideAsync(
It.IsAny<InstanceAlarmOverride>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task SetAlarmOverride_DirectLockedAlarm_ReturnsFailure()
{
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(instance);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template>
{
TemplateWithAlarms(1, new TemplateAlarm("HighTemp") { Id = 10, IsLocked = true })
});
var result = await _sut.SetAlarmOverrideAsync(1, "HighTemp", "{}", null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("locked", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Enable_ExistingInstance_SetsEnabled()
{

View File

@@ -82,6 +82,9 @@ public class TemplateDeletionServiceTests
[Fact]
public async Task CanDeleteTemplate_ComposedByOthers_ReturnsFailure()
{
var composer = new Template("Composer") { Id = 2 };
composer.Compositions.Add(new TemplateComposition("PumpModule") { ComposedTemplateId = 1 });
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Template("Module") { Id = 1 });
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
@@ -90,15 +93,8 @@ public class TemplateDeletionServiceTests
.ReturnsAsync(new List<Template>
{
new("Module") { Id = 1 },
new("Composer") { Id = 2 }
composer
});
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(2, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateComposition>
{
new("PumpModule") { ComposedTemplateId = 1 }
});
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateComposition>());
var result = await _sut.CanDeleteTemplateAsync(1);
@@ -107,6 +103,34 @@ public class TemplateDeletionServiceTests
Assert.Contains("Composer", result.Error);
}
[Fact]
public async Task CanDeleteTemplate_DoesNotIssuePerTemplateCompositionQuery()
{
// TemplateEngine-009: Check 3 must read the Compositions navigation
// already loaded by GetAllTemplatesAsync rather than issuing one
// GetCompositionsByTemplateIdAsync round-trip per template.
var templates = new List<Template>
{
new("Module") { Id = 1 },
new("A") { Id = 2 },
new("B") { Id = 3 },
new("C") { Id = 4 },
};
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Template("Module") { Id = 1 });
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Instance>());
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(templates);
var result = await _sut.CanDeleteTemplateAsync(1);
Assert.True(result.IsSuccess);
_repoMock.Verify(r => r.GetCompositionsByTemplateIdAsync(
It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task CanDeleteTemplate_NotFound_ReturnsFailure()
{
@@ -146,22 +170,15 @@ public class TemplateDeletionServiceTests
.ReturnsAsync(new Template("Busy") { Id = 1 });
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Instance> { new("Inst1") { Id = 1 } });
var composer = new Template("Composer") { Id = 3 };
composer.Compositions.Add(new TemplateComposition("Module") { ComposedTemplateId = 1 });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template>
{
new("Busy") { Id = 1 },
new("Child") { Id = 2, ParentTemplateId = 1 },
new("Composer") { Id = 3 }
composer
});
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateComposition>
{
new("Module") { ComposedTemplateId = 1 }
});
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateComposition>());
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(2, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateComposition>());
var result = await _sut.CanDeleteTemplateAsync(1);