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:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -141,4 +141,19 @@ public class SharedScriptServiceTests
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("Syntax error", result);
|
||||
}
|
||||
|
||||
// --- TemplateEngine-007 regression: string/comment-literal awareness ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("var s = \"a } brace\"; { }")] // brace inside a normal string
|
||||
[InlineData("var s = \"a ) paren ] bracket\";")] // paren/bracket inside a string
|
||||
[InlineData("var s = @\"verbatim } brace\"; { }")] // brace inside a verbatim string
|
||||
[InlineData("var x = 1; var s = $\"hole {x} literal}}\"; { }")] // interpolated string with braces
|
||||
[InlineData("var c = '}'; if (true) { }")] // char literal containing a brace
|
||||
[InlineData("// a stray } here\nvar x = 1;")] // brace inside a line comment
|
||||
[InlineData("/* a stray ) here */ var x = 1;")] // paren inside a block comment
|
||||
public void ValidateSyntax_DelimiterInsideStringOrComment_ReturnsNull(string code)
|
||||
{
|
||||
Assert.Null(SharedScriptService.ValidateSyntax(code));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,4 +71,85 @@ public class ScriptCompilerTests
|
||||
var result = _sut.TryCompile("/* { } */ var x = 1;", "Test");
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
// --- TemplateEngine-007 regression: string-literal awareness ---
|
||||
|
||||
[Fact]
|
||||
public void TryCompile_VerbatimStringWithBrace_NotFlaggedAsMismatched()
|
||||
{
|
||||
// @"..." — backslash is literal, "" is the escape. The closing brace
|
||||
// inside the verbatim string must not affect the brace balance.
|
||||
var result = _sut.TryCompile("var s = @\"a brace } and a \\ slash\"; if (true) { }", "Test");
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCompile_VerbatimStringWithEscapedQuote_NotFlaggedAsMismatched()
|
||||
{
|
||||
// The "" inside a verbatim string is an escaped quote, not a string end.
|
||||
var result = _sut.TryCompile("var s = @\"he said \"\"hi}\"\"\"; { }", "Test");
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCompile_InterpolatedStringWithBraces_NotFlaggedAsMismatched()
|
||||
{
|
||||
// The braces in $"{x}" are interpolation holes; the literal "}}" is an
|
||||
// escaped brace. Neither should unbalance the real braces.
|
||||
var result = _sut.TryCompile("var x = 1; var s = $\"val={x} literal}}\"; if (x>0) { x++; }", "Test");
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCompile_RawStringLiteralWithBraces_NotFlaggedAsMismatched()
|
||||
{
|
||||
// C# 11 raw string literal — the triple quotes delimit, braces inside are text.
|
||||
var result = _sut.TryCompile("var s = \"\"\"a } brace { in raw\"\"\"; { }", "Test");
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCompile_CharLiteralWithBrace_NotFlaggedAsMismatched()
|
||||
{
|
||||
// A '}' char literal must not decrement the brace depth.
|
||||
var result = _sut.TryCompile("var c = '}'; if (true) { }", "Test");
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCompile_GenuineMismatchedBraces_StillDetected()
|
||||
{
|
||||
// Sanity check that the string-aware scan still catches real mismatches.
|
||||
var result = _sut.TryCompile("var s = \"ok\"; if (true) { x++;", "Test");
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("braces", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// --- TemplateEngine-006 regression: forbidden-API scan false positives ---
|
||||
|
||||
[Fact]
|
||||
public void TryCompile_ForbiddenApiTextInsideStringLiteral_NotFlagged()
|
||||
{
|
||||
// "System.IO." appears only inside a string literal — it is inert text,
|
||||
// not a use of the forbidden API, and must not be rejected.
|
||||
var result = _sut.TryCompile("var msg = \"see System.IO.File docs\"; var x = 1;", "Test");
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCompile_ForbiddenApiTextInsideComment_NotFlagged()
|
||||
{
|
||||
// "System.Threading." appears only inside a comment — inert.
|
||||
var result = _sut.TryCompile("// avoid System.Threading.Thread here\nvar x = 1;", "Test");
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCompile_ForbiddenApiInRealCode_StillFlagged()
|
||||
{
|
||||
// Sanity check: a genuine use in code is still rejected.
|
||||
var result = _sut.TryCompile("var x = System.IO.File.ReadAllText(\"a\");", "Test");
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("forbidden", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user