792f16c2d5
Attribute/alarm/script add (collision check) and update (parent-lock lookup) used GetAllTemplatesForAnalysisAsync, materialising every template, attribute, alarm and script in the library per edit. They now call the new ITemplateEngineRepository.GetTemplateAnalysisClosureAsync: the same untracked, body-free analysis projection, restricted to the template's inheritance chain plus the composition closure of every chain member - exactly what TemplateResolver and CollisionDetector walk (a composed module's own parent chain is never climbed, so it is not loaded). Adds use the edited template's closure, updates the parent's. The walk (Commons TemplateAnalysisClosure) loads one batch per closure depth, requests each id once, terminates on cycles and skips dangling edges. Evidence: - TemplateAnalysisClosureTests: for every template of a graph with chains, nested + diamond compositions, a derived module, cycles, dangling edges and colliding unrelated templates, ResolveAllMembers / DetectCollisions / FindMemberByCanonicalName answer identically from the closure and the whole graph (mutation-checked). - TemplateAnalysisClosureRepositoryTests (SQLite, recorded SQL): 6 templates / 12 attributes loaded instead of 47 / 94, in 4 queries (one per depth), untracked, script bodies never selected. - TemplateServiceTests: two new tests with the whole-graph load wired to throw (fail on the old service); every existing add/update test now runs against the closure via AnalysisClosureMock. ReconcileDescendantsAsync still loads the tracked full graph; not in scope. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2409 lines
125 KiB
C#
2409 lines
125 KiB
C#
using Moq;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests;
|
|
|
|
public class TemplateServiceTests
|
|
{
|
|
private readonly Mock<ITemplateEngineRepository> _repoMock;
|
|
private readonly Mock<IAuditService> _auditMock;
|
|
private readonly TemplateService _service;
|
|
private readonly List<int> _closureRequests = new();
|
|
|
|
/// <summary>Initializes the test with mocked repositories and the service under test.</summary>
|
|
public TemplateServiceTests()
|
|
{
|
|
_repoMock = new Mock<ITemplateEngineRepository>();
|
|
_auditMock = new Mock<IAuditService>();
|
|
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
|
|
_repoMock.ServeClosureFromFullGraph(_closureRequests);
|
|
_service = new TemplateService(_repoMock.Object, _auditMock.Object);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Template CRUD with Inheritance
|
|
// ========================================================================
|
|
|
|
/// <summary>Verifies that create template, success.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CreateTemplate_Success()
|
|
{
|
|
var result = await _service.CreateTemplateAsync("Pump", "A pump template", null, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("Pump", result.Value.Name);
|
|
_repoMock.Verify(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
// SaveChangesAsync runs twice — once after the
|
|
// AddTemplateAsync to populate the auto-generated key (so the audit
|
|
// row can carry the real Id), and once after LogAsync to persist the
|
|
// audit row itself.
|
|
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Exactly(2));
|
|
_auditMock.Verify(a => a.LogAsync("admin", "Create", "Template", It.IsAny<string>(), "Pump", It.IsAny<object?>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
/// <summary>Verifies that create template, empty name, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CreateTemplate_EmptyName_Fails()
|
|
{
|
|
var result = await _service.CreateTemplateAsync("", null, null, "admin");
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("required", result.Error);
|
|
}
|
|
|
|
/// <summary>Verifies that create template, with parent, success.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CreateTemplate_WithParent_Success()
|
|
{
|
|
var parent = new Template("Base") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(parent);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { parent });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { parent });
|
|
|
|
var result = await _service.CreateTemplateAsync("Child", null, 1, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(1, result.Value.ParentTemplateId);
|
|
}
|
|
|
|
/// <summary>Verifies that create template, with parent, does not run dead collision query.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
// No whole-graph walk of ANY shape — neither the tracked
|
|
// GetAllTemplatesAsync nor the no-tracking analysis projection that the
|
|
// collision/acyclicity checks now use.
|
|
_repoMock.Verify(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()), Times.Never);
|
|
_repoMock.Verify(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
/// <summary>Verifies that create template, nonexistent parent, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CreateTemplate_NonexistentParent_Fails()
|
|
{
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(999, It.IsAny<CancellationToken>())).ReturnsAsync((Template?)null);
|
|
|
|
var result = await _service.CreateTemplateAsync("Child", null, 999, "admin");
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("not found", result.Error);
|
|
}
|
|
|
|
/// <summary>Verifies that create template, with folder id, sets folder id.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CreateTemplate_WithFolderId_SetsFolderId()
|
|
{
|
|
var folder = new TemplateFolder("Dev") { Id = 7 };
|
|
_repoMock.Setup(r => r.GetFolderByIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>())).ReturnsAsync(folder);
|
|
|
|
var result = await _service.CreateTemplateAsync("X", null, null, "admin", folderId: 7);
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(7, result.Value.FolderId);
|
|
}
|
|
|
|
/// <summary>Verifies that delete template, success.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task DeleteTemplate_Success()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Instance>());
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
|
|
var result = await _service.DeleteTemplateAsync(1, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
_repoMock.Verify(r => r.DeleteTemplateAsync(1, It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
/// <summary>Verifies that delete template, referenced by instances, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task DeleteTemplate_ReferencedByInstances_Fails()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Instance> { new Instance("Pump1") { Id = 1, TemplateId = 1, SiteId = 1 } });
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
|
|
var result = await _service.DeleteTemplateAsync(1, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("instance(s) reference it", result.Error);
|
|
}
|
|
|
|
/// <summary>Verifies that delete template, has children, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task DeleteTemplate_HasChildren_Fails()
|
|
{
|
|
var parent = new Template("Base") { Id = 1 };
|
|
var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(parent);
|
|
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Instance>());
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { parent, child });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { parent, child });
|
|
|
|
var result = await _service.DeleteTemplateAsync(1, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("child template(s) inherit from it", result.Error);
|
|
}
|
|
|
|
/// <summary>Verifies that delete template, composed by other, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task DeleteTemplate_ComposedByOther_Fails()
|
|
{
|
|
var moduleTemplate = new Template("Module") { Id = 1 };
|
|
var composingTemplate = new Template("Composing") { Id = 2 };
|
|
composingTemplate.Compositions.Add(new TemplateComposition("mod1") { Id = 1, TemplateId = 2, ComposedTemplateId = 1 });
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
|
|
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Instance>());
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { moduleTemplate, composingTemplate });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { moduleTemplate, composingTemplate });
|
|
|
|
var result = await _service.DeleteTemplateAsync(1, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("template(s) compose it", result.Error);
|
|
}
|
|
|
|
/// <summary>Verifies that delete template, multiple constraints, reports all not just first.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task DeleteTemplate_MultipleConstraints_ReportsAllNotJustFirst()
|
|
{
|
|
// DeleteTemplateAsync delegates its constraint check
|
|
// to the single TemplateDeletionService implementation, which accumulates
|
|
// every blocking reason instead of returning on the first failing category.
|
|
var template = new Template("Busy") { Id = 1 };
|
|
var composer = new Template("Composer") { Id = 3 };
|
|
composer.Compositions.Add(new TemplateComposition("Module") { Id = 1, TemplateId = 3, ComposedTemplateId = 1 });
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Instance> { new Instance("Inst1") { Id = 1, TemplateId = 1, SiteId = 1 } });
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template>
|
|
{
|
|
template,
|
|
new Template("Child") { Id = 2, ParentTemplateId = 1 },
|
|
composer
|
|
});
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template>
|
|
{
|
|
template,
|
|
new Template("Child") { Id = 2, ParentTemplateId = 1 },
|
|
composer
|
|
});
|
|
|
|
var result = await _service.DeleteTemplateAsync(1, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("instance(s) reference it", result.Error);
|
|
Assert.Contains("child template(s) inherit from it", result.Error);
|
|
Assert.Contains("template(s) compose it", result.Error);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Attribute Definitions with Lock Flags
|
|
// ========================================================================
|
|
|
|
/// <summary>Verifies that add attribute, success.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddAttribute_Success()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
|
|
var attr = new TemplateAttribute("Temperature") { DataType = DataType.Float, Value = "0.0" };
|
|
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("Temperature", result.Value.Name);
|
|
Assert.Equal(1, result.Value.TemplateId);
|
|
}
|
|
|
|
/// <summary>Verifies that add attribute, to derived template with inherited placeholders, succeeds.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddAttribute_ToDerivedTemplateWithInheritedPlaceholders_Succeeds()
|
|
{
|
|
// End-to-end: adding a member to a derived template used to
|
|
// fail: CloneTemplateWithNewAttribute carries the child's IsInherited
|
|
// placeholder rows into DetectCollisions, which counted each one twice (child
|
|
// origin + the parent the inheritance walk re-adds) and reported a spurious
|
|
// "Naming collision" for every inherited attribute — there was no supported
|
|
// API path to extend a derived template. The new attribute must now save.
|
|
var parent = new Template("ReactorSide") { Id = 7 };
|
|
parent.Attributes.Add(new TemplateAttribute("DeltaVac") { Id = 70, TemplateId = 7, DataType = DataType.Float });
|
|
parent.Attributes.Add(new TemplateAttribute("ResultType") { Id = 71, TemplateId = 7, DataType = DataType.String });
|
|
|
|
var child = new Template("LeftReactorSide") { Id = 8, ParentTemplateId = 7 };
|
|
child.Attributes.Add(new TemplateAttribute("DeltaVac") { Id = 80, TemplateId = 8, DataType = DataType.Float, IsInherited = true });
|
|
child.Attributes.Add(new TemplateAttribute("ResultType") { Id = 81, TemplateId = 8, DataType = DataType.String, IsInherited = true });
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(8, It.IsAny<CancellationToken>())).ReturnsAsync(child);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { parent, child });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { parent, child });
|
|
|
|
var attr = new TemplateAttribute("MoveInType") { DataType = DataType.String, Value = "" };
|
|
var result = await _service.AddAttributeAsync(8, attr, "admin");
|
|
|
|
// Guard the message: Result.Error throws on a success result, so only read
|
|
// it when the add actually failed (which is the case this test regresses).
|
|
Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null);
|
|
Assert.Equal("MoveInType", result.Value.Name);
|
|
Assert.Equal(8, result.Value.TemplateId);
|
|
}
|
|
|
|
/// <summary>Verifies that add attribute, duplicate name, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddAttribute_DuplicateName_Fails()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
template.Attributes.Add(new TemplateAttribute("Temperature") { Id = 1, TemplateId = 1, DataType = DataType.Float });
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
|
|
var attr = new TemplateAttribute("Temperature") { DataType = DataType.Float };
|
|
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("already exists", result.Error);
|
|
}
|
|
|
|
// ── (DataType, ElementDataType, Value) validation hoisted into the
|
|
// service so the Central UI's direct write path shares the same
|
|
// server-side guard as ManagementActor.ValidateAttributeTypes. ──
|
|
|
|
/// <summary>Verifies that add attribute, invalid data type, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddAttribute_InvalidDataType_Fails()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
|
|
// An out-of-range enum value is the in-service equivalent of an
|
|
// unrecognised data-type token (the CLI/API path rejects the token before
|
|
// the enum is parsed).
|
|
var attr = new TemplateAttribute("Temperature") { DataType = (DataType)999, Value = "0" };
|
|
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("data type", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
// The attribute must never reach the repository.
|
|
_repoMock.Verify(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
/// <summary>Verifies that add attribute, list missing element type, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddAttribute_ListMissingElementType_Fails()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
|
|
var attr = new TemplateAttribute("SetPoints") { DataType = DataType.List, ElementDataType = null };
|
|
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("element type", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
_repoMock.Verify(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
/// <summary>Verifies that add attribute, list invalid element type, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddAttribute_ListInvalidElementType_Fails()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
|
|
// Binary is a real DataType but not a valid List element scalar.
|
|
var attr = new TemplateAttribute("SetPoints") { DataType = DataType.List, ElementDataType = DataType.Binary };
|
|
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("element type", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
_repoMock.Verify(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
/// <summary>Verifies that add attribute, scalar with element type, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddAttribute_ScalarWithElementType_Fails()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
|
|
var attr = new TemplateAttribute("Temperature") { DataType = DataType.Float, ElementDataType = DataType.Int32, Value = "0" };
|
|
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("ElementDataType", result.Error);
|
|
_repoMock.Verify(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
/// <summary>Verifies that add attribute, list value does not decode, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddAttribute_ListValueDoesNotDecode_Fails()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
|
|
// A valid typed List, but the value carries an element that is not an Int32.
|
|
var attr = new TemplateAttribute("SetPoints")
|
|
{
|
|
DataType = DataType.List, ElementDataType = DataType.Int32, Value = """["abc"]"""
|
|
};
|
|
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("invalid list value", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
_repoMock.Verify(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
/// <summary>Verifies that add attribute, scalar value does not parse, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddAttribute_ScalarValueDoesNotParse_Fails()
|
|
{
|
|
// Mirrors what AttributeValueCodec.Decode would accept for the declared
|
|
// (DataType, ElementDataType). Scalars decode as-is in the codec, so a
|
|
// free-form scalar value is accepted by both the CLI/API guard and the
|
|
// service guard — there is no scalar-string parse here. Documenting the
|
|
// accepted shape: a non-numeric scalar Value is allowed (validated at deploy).
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
|
|
var attr = new TemplateAttribute("Temperature") { DataType = DataType.Int32, Value = "not-a-number" };
|
|
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
|
|
|
// Behaviour-preserving: the codec leaves scalars untouched, so this is
|
|
// accepted (the deploy-time SemanticValidator remains the scalar backstop).
|
|
Assert.True(result.IsSuccess);
|
|
}
|
|
|
|
/// <summary>Verifies that add attribute, valid typed list, succeeds.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddAttribute_ValidTypedList_Succeeds()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
|
|
var attr = new TemplateAttribute("SetPoints")
|
|
{
|
|
DataType = DataType.List, ElementDataType = DataType.Int32, Value = """[10, 20, 30]"""
|
|
};
|
|
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("SetPoints", result.Value.Name);
|
|
Assert.Equal(DataType.List, result.Value.DataType);
|
|
Assert.Equal(DataType.Int32, result.Value.ElementDataType);
|
|
}
|
|
|
|
/// <summary>Verifies that update attribute, invalid list value, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateAttribute_InvalidListValue_Fails()
|
|
{
|
|
// DataType/ElementDataType are fixed on the existing row; the proposed
|
|
// value must still decode against that fixed element type.
|
|
var existing = new TemplateAttribute("SetPoints")
|
|
{
|
|
Id = 1, TemplateId = 1, DataType = DataType.List, ElementDataType = DataType.Int32, Value = """[1, 2]"""
|
|
};
|
|
_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("SetPoints")
|
|
{
|
|
DataType = DataType.List, ElementDataType = DataType.Int32, Value = """["abc"]"""
|
|
};
|
|
var result = await _service.UpdateAttributeAsync(1, proposed, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("invalid list value", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
// The fixed value must not have been mutated.
|
|
Assert.Equal("""[1, 2]""", existing.Value);
|
|
_repoMock.Verify(r => r.UpdateTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
/// <summary>Verifies that update attribute, valid typed list value, succeeds.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateAttribute_ValidTypedListValue_Succeeds()
|
|
{
|
|
var existing = new TemplateAttribute("SetPoints")
|
|
{
|
|
Id = 1, TemplateId = 1, DataType = DataType.List, ElementDataType = DataType.Int32, Value = """[1, 2]"""
|
|
};
|
|
_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("SetPoints")
|
|
{
|
|
DataType = DataType.List, ElementDataType = DataType.Int32, Value = """[10, 20, 30]"""
|
|
};
|
|
var result = await _service.UpdateAttributeAsync(1, proposed, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("""[10, 20, 30]""", result.Value.Value);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Alarm Definitions
|
|
// ========================================================================
|
|
|
|
/// <summary>Verifies that add alarm, success.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddAlarm_Success()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
|
|
var alarm = new TemplateAlarm("HighTemp")
|
|
{
|
|
PriorityLevel = 500,
|
|
TriggerType = AlarmTriggerType.RangeViolation,
|
|
TriggerConfiguration = """{"Max": 100}"""
|
|
};
|
|
var result = await _service.AddAlarmAsync(1, alarm, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("HighTemp", result.Value.Name);
|
|
}
|
|
|
|
/// <summary>Verifies that add alarm, invalid priority, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddAlarm_InvalidPriority_Fails()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
|
|
var alarm = new TemplateAlarm("HighTemp") { PriorityLevel = 1001, TriggerType = AlarmTriggerType.ValueMatch };
|
|
var result = await _service.AddAlarmAsync(1, alarm, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("priority", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
/// <summary>Verifies that update alarm, trigger type fixed, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateAlarm_TriggerTypeFixed_Fails()
|
|
{
|
|
var existing = new TemplateAlarm("HighTemp")
|
|
{
|
|
Id = 1,
|
|
TemplateId = 1,
|
|
TriggerType = AlarmTriggerType.ValueMatch,
|
|
PriorityLevel = 500
|
|
};
|
|
_repoMock.Setup(r => r.GetTemplateAlarmByIdAsync(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 TemplateAlarm("HighTemp")
|
|
{
|
|
TriggerType = AlarmTriggerType.RangeViolation, // Changed!
|
|
PriorityLevel = 600
|
|
};
|
|
var result = await _service.UpdateAlarmAsync(1, proposed, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("TriggerType", result.Error);
|
|
}
|
|
|
|
/// <summary>Verifies that update alarm async, copies is mes reportable.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateAlarmAsync_CopiesIsMesReportable()
|
|
{
|
|
var existing = new TemplateAlarm("HighTemp")
|
|
{
|
|
Id = 1,
|
|
TemplateId = 1,
|
|
TriggerType = AlarmTriggerType.ValueMatch,
|
|
PriorityLevel = 500,
|
|
IsMesReportable = false
|
|
};
|
|
_repoMock.Setup(r => r.GetTemplateAlarmByIdAsync(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 TemplateAlarm("HighTemp")
|
|
{
|
|
TriggerType = AlarmTriggerType.ValueMatch,
|
|
PriorityLevel = 500,
|
|
IsMesReportable = true
|
|
};
|
|
var result = await _service.UpdateAlarmAsync(1, proposed, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.True(result.Value.IsMesReportable);
|
|
Assert.True(existing.IsMesReportable);
|
|
_repoMock.Verify(r => r.UpdateTemplateAlarmAsync(
|
|
It.Is<TemplateAlarm>(a => a.IsMesReportable), It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
/// <summary>Verifies that update alarm async, clears is mes reportable.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateAlarmAsync_ClearsIsMesReportable()
|
|
{
|
|
var existing = new TemplateAlarm("HighTemp")
|
|
{
|
|
Id = 1,
|
|
TemplateId = 1,
|
|
TriggerType = AlarmTriggerType.ValueMatch,
|
|
PriorityLevel = 500,
|
|
IsMesReportable = true
|
|
};
|
|
_repoMock.Setup(r => r.GetTemplateAlarmByIdAsync(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 TemplateAlarm("HighTemp")
|
|
{
|
|
TriggerType = AlarmTriggerType.ValueMatch,
|
|
PriorityLevel = 500,
|
|
IsMesReportable = false
|
|
};
|
|
var result = await _service.UpdateAlarmAsync(1, proposed, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.False(result.Value.IsMesReportable);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Script Definitions
|
|
// ========================================================================
|
|
|
|
/// <summary>Verifies that add script, success.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddScript_Success()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
|
|
var script = new TemplateScript("OnStart", "return true;") { TriggerType = "Startup" };
|
|
var result = await _service.AddScriptAsync(1, script, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("OnStart", result.Value.Name);
|
|
}
|
|
|
|
/// <summary>Verifies that update script, name fixed, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateScript_NameFixed_Fails()
|
|
{
|
|
var existing = new TemplateScript("OnStart", "return true;") { Id = 1, TemplateId = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateScriptByIdAsync(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 TemplateScript("OnStop", "return false;"); // Name changed!
|
|
var result = await _service.UpdateScriptAsync(1, proposed, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("Name", result.Error);
|
|
}
|
|
|
|
/// <summary>Verifies that update script, ui edit path, preserves existing execution timeout seconds.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateScript_UiEditPath_PreservesExistingExecutionTimeoutSeconds()
|
|
{
|
|
// The UI now has an ExecutionTimeoutSeconds input, but it still loads
|
|
// (round-trips) the existing value into that input on edit, so leaving it
|
|
// untouched re-sends the same value rather than null. This test proves the
|
|
// service preserves the timeout end-to-end when the loaded value is re-sent
|
|
// — guarding the UI edit path against silently blanking a configured timeout.
|
|
var existing = new TemplateScript("OnStart", "return true;")
|
|
{
|
|
Id = 1,
|
|
TemplateId = 1,
|
|
ExecutionTimeoutSeconds = 30 // set via Transport import
|
|
};
|
|
_repoMock.Setup(r => r.GetTemplateScriptByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
|
|
// Simulate what the UI now does: round-trip the loaded ExecutionTimeoutSeconds.
|
|
var proposed = new TemplateScript("OnStart", "return false;")
|
|
{
|
|
ExecutionTimeoutSeconds = existing.ExecutionTimeoutSeconds // round-trip
|
|
};
|
|
|
|
var result = await _service.UpdateScriptAsync(1, proposed, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(30, result.Value.ExecutionTimeoutSeconds);
|
|
}
|
|
|
|
/// <summary>Verifies that update script, explicit null timeout, clears execution timeout seconds.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateScript_ExplicitNullTimeout_ClearsExecutionTimeoutSeconds()
|
|
{
|
|
// A deliberate clear (e.g. via Transport or CLI setting null
|
|
// explicitly) must still work — the service must not guard against null.
|
|
var existing = new TemplateScript("OnStart", "return true;")
|
|
{
|
|
Id = 1,
|
|
TemplateId = 1,
|
|
ExecutionTimeoutSeconds = 30
|
|
};
|
|
_repoMock.Setup(r => r.GetTemplateScriptByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
|
|
// Explicit null — caller intentionally clears the timeout.
|
|
var proposed = new TemplateScript("OnStart", "return false;")
|
|
{
|
|
ExecutionTimeoutSeconds = null
|
|
};
|
|
|
|
var result = await _service.UpdateScriptAsync(1, proposed, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Null(result.Value.ExecutionTimeoutSeconds);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Shared Script CRUD (see SharedScriptServiceTests)
|
|
// ========================================================================
|
|
|
|
// ========================================================================
|
|
// Composition with Recursive Nesting
|
|
// ========================================================================
|
|
|
|
/// <summary>Verifies that add composition, success, derives template.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddComposition_Success_DerivesTemplate()
|
|
{
|
|
var moduleTemplate = new Template("Module") { Id = 2 };
|
|
moduleTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { Id = 10, TemplateId = 2, Value = "75", DataType = DataType.Float });
|
|
moduleTemplate.Scripts.Add(new TemplateScript("Compute", "return 1;") { Id = 20, TemplateId = 2 });
|
|
var template = new Template("Parent") { Id = 1 };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template, moduleTemplate });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template, moduleTemplate });
|
|
|
|
Template? captured = null;
|
|
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
|
|
.Callback<Template, CancellationToken>((t, _) => captured = t)
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("myModule", result.Value.InstanceName);
|
|
Assert.NotNull(captured);
|
|
Assert.True(captured!.IsDerived);
|
|
Assert.Equal("myModule", captured.Name); // contained (slot) name, not the qualified path
|
|
Assert.Equal(2, captured.ParentTemplateId);
|
|
Assert.Single(captured.Attributes);
|
|
Assert.True(captured.Attributes.First().IsInherited);
|
|
Assert.Equal("SetPoint", captured.Attributes.First().Name);
|
|
Assert.Single(captured.Scripts);
|
|
Assert.True(captured.Scripts.First().IsInherited);
|
|
_repoMock.Verify(r => r.AddTemplateCompositionAsync(It.IsAny<TemplateComposition>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
/// <summary>Verifies that add composition, cascades child compositions.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddComposition_CascadesChildCompositions()
|
|
{
|
|
// $Probe (base) → $Sensor's "Probe1" derived ← $Sensor composes "Probe1"
|
|
// Composing $Sensor into $Pump as "TempSensor" should produce two derived
|
|
// templates whose stored names are their *contained* names (the slot
|
|
// names) — "TempSensor" and "Probe1" — plus a composition row on the
|
|
// TempSensor derived pointing at the new inner derived.
|
|
var probe = new Template("Probe") { Id = 10 };
|
|
var sensorProbe1 = new Template("Probe1") { Id = 11, IsDerived = true, ParentTemplateId = 10, OwnerCompositionId = 1 };
|
|
var sensor = new Template("Sensor") { Id = 2 };
|
|
sensor.Compositions.Add(new TemplateComposition("Probe1") { Id = 1, TemplateId = 2, ComposedTemplateId = 11 });
|
|
var pump = new Template("Pump") { Id = 1 };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(pump);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(sensor);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(11, It.IsAny<CancellationToken>())).ReturnsAsync(sensorProbe1);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { pump, sensor, probe, sensorProbe1 });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { pump, sensor, probe, sensorProbe1 });
|
|
|
|
var captured = new List<Template>();
|
|
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
|
|
.Callback<Template, CancellationToken>((t, _) => captured.Add(t))
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var capturedCompositions = new List<TemplateComposition>();
|
|
_repoMock.Setup(r => r.AddTemplateCompositionAsync(It.IsAny<TemplateComposition>(), It.IsAny<CancellationToken>()))
|
|
.Callback<TemplateComposition, CancellationToken>((c, _) => capturedCompositions.Add(c))
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var result = await _service.AddCompositionAsync(1, 2, "TempSensor", "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(2, captured.Count);
|
|
Assert.Equal("TempSensor", captured[0].Name);
|
|
Assert.Equal(2, captured[0].ParentTemplateId);
|
|
Assert.Equal("Probe1", captured[1].Name);
|
|
Assert.Equal(11, captured[1].ParentTemplateId);
|
|
Assert.Equal(2, capturedCompositions.Count);
|
|
Assert.Equal("TempSensor", capturedCompositions[0].InstanceName);
|
|
Assert.Equal("Probe1", capturedCompositions[1].InstanceName);
|
|
}
|
|
|
|
/// <summary>Verifies that add composition, base is derived, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddComposition_BaseIsDerived_Fails()
|
|
{
|
|
var derivedBase = new Template("Sensor") { Id = 2, IsDerived = true };
|
|
var template = new Template("Parent") { Id = 1 };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(derivedBase);
|
|
|
|
var result = await _service.AddCompositionAsync(1, 2, "slot", "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("derived template", result.Error);
|
|
}
|
|
|
|
/// <summary>Verifies that add composition, derived contained name, need not be globally unique.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddComposition_DerivedContainedName_NeedNotBeGloballyUnique()
|
|
{
|
|
// A derived template's stored name is its contained (slot) name, which
|
|
// is only unique within its owner — it may freely coincide with an
|
|
// unrelated base template's name. Composing a module as the slot
|
|
// "myModule" succeeds even though a base template "myModule" exists.
|
|
var existing = new Template("myModule") { Id = 99 };
|
|
var moduleTemplate = new Template("Module") { Id = 2 };
|
|
var template = new Template("Parent") { Id = 1 };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template, moduleTemplate, existing });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template, moduleTemplate, existing });
|
|
|
|
var captured = new List<Template>();
|
|
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
|
|
.Callback<Template, CancellationToken>((t, _) => captured.Add(t))
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Single(captured);
|
|
Assert.Equal("myModule", captured[0].Name);
|
|
}
|
|
|
|
/// <summary>Verifies that rename composition, renames slot and derived template.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RenameComposition_RenamesSlotAndDerivedTemplate()
|
|
{
|
|
// The derived template's stored name is the slot's contained name, so
|
|
// renaming the slot renames the derived template to the new short name.
|
|
var composition = new TemplateComposition("OldSlot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
|
var owner = new Template("Pump") { Id = 1 };
|
|
owner.Compositions.Add(composition);
|
|
var derived = new Template("OldSlot") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
|
|
|
var result = await _service.RenameCompositionAsync(50, "NewSlot", "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("NewSlot", result.Value.InstanceName);
|
|
Assert.Equal("NewSlot", derived.Name);
|
|
}
|
|
|
|
/// <summary>Verifies that rename composition, renames only the slots own derived template.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RenameComposition_RenamesOnlyTheSlotsOwnDerivedTemplate()
|
|
{
|
|
// With contained names, a nested derived template's name is its own
|
|
// slot name and is unaffected by renaming an ancestor slot. Renaming
|
|
// the TempSensor slot to MainSensor renames only that derived template;
|
|
// the inner "Probe1" derived is left untouched.
|
|
var innerComp = new TemplateComposition("Probe1") { Id = 51, TemplateId = 77, ComposedTemplateId = 78 };
|
|
var innerDerived = new Template("Probe1") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 11 };
|
|
var composition = new TemplateComposition("TempSensor") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
|
var owner = new Template("Pump") { Id = 1 };
|
|
owner.Compositions.Add(composition);
|
|
var derived = new Template("TempSensor") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
|
|
derived.Compositions.Add(innerComp);
|
|
|
|
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(78, It.IsAny<CancellationToken>())).ReturnsAsync(innerDerived);
|
|
|
|
var result = await _service.RenameCompositionAsync(50, "MainSensor", "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("MainSensor", result.Value.InstanceName);
|
|
Assert.Equal("MainSensor", derived.Name);
|
|
Assert.Equal("Probe1", innerDerived.Name);
|
|
_repoMock.Verify(r => r.UpdateTemplateAsync(
|
|
It.Is<Template>(t => t.Id == 78), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
/// <summary>Verifies that rename composition, duplicate name, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RenameComposition_DuplicateName_Fails()
|
|
{
|
|
var composition = new TemplateComposition("OldSlot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
|
var sibling = new TemplateComposition("NewSlot") { Id = 51, TemplateId = 1, ComposedTemplateId = 78 };
|
|
var owner = new Template("Pump") { Id = 1 };
|
|
owner.Compositions.Add(composition);
|
|
owner.Compositions.Add(sibling);
|
|
|
|
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
|
|
|
|
var result = await _service.RenameCompositionAsync(50, "NewSlot", "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("already exists", result.Error);
|
|
}
|
|
|
|
/// <summary>Verifies that delete composition, cascades nested derived templates.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task DeleteComposition_CascadesNestedDerivedTemplates()
|
|
{
|
|
// Pump.TempSensor is a cascaded derived under Pump (outer derived) that
|
|
// is owned by composition 50. Deleting composition 50 must drop:
|
|
// - the outer derived (Pump)
|
|
// - its nested composition (TempSensor on Pump)
|
|
// - the nested derived (Pump.TempSensor)
|
|
var nestedComp = new TemplateComposition("TempSensor") { Id = 51, TemplateId = 77, ComposedTemplateId = 78 };
|
|
var nestedDerived = new Template("Tank.Pump.TempSensor") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 3 };
|
|
var outerComposition = new TemplateComposition("Pump") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
|
var outerDerived = new Template("Tank.Pump") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
|
|
outerDerived.Compositions.Add(nestedComp);
|
|
|
|
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(outerComposition);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(outerDerived);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(78, It.IsAny<CancellationToken>())).ReturnsAsync(nestedDerived);
|
|
|
|
var result = await _service.DeleteCompositionAsync(50, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
_repoMock.Verify(r => r.DeleteTemplateCompositionAsync(50, It.IsAny<CancellationToken>()), Times.Once);
|
|
_repoMock.Verify(r => r.DeleteTemplateCompositionAsync(51, It.IsAny<CancellationToken>()), Times.Once);
|
|
_repoMock.Verify(r => r.DeleteTemplateAsync(77, It.IsAny<CancellationToken>()), Times.Once);
|
|
_repoMock.Verify(r => r.DeleteTemplateAsync(78, It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
/// <summary>Verifies that delete composition, cascades derived template.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task DeleteComposition_CascadesDerivedTemplate()
|
|
{
|
|
var composition = new TemplateComposition("slot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
|
var derived = new Template("Parent.slot") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
|
|
|
var result = await _service.DeleteCompositionAsync(50, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
_repoMock.Verify(r => r.DeleteTemplateCompositionAsync(50, It.IsAny<CancellationToken>()), Times.Once);
|
|
_repoMock.Verify(r => r.DeleteTemplateAsync(77, It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
/// <summary>Verifies that delete composition, legacy direct base, does not cascade.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task DeleteComposition_LegacyDirectBase_DoesNotCascade()
|
|
{
|
|
var composition = new TemplateComposition("slot") { Id = 60, TemplateId = 1, ComposedTemplateId = 5 };
|
|
var baseTemplate = new Template("Module") { Id = 5, IsDerived = false };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(60, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(5, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
|
|
|
var result = await _service.DeleteCompositionAsync(60, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
_repoMock.Verify(r => r.DeleteTemplateCompositionAsync(60, It.IsAny<CancellationToken>()), Times.Once);
|
|
_repoMock.Verify(r => r.DeleteTemplateAsync(5, It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
/// <summary>Verifies that delete template, derived template, direct delete blocked.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task DeleteTemplate_DerivedTemplate_DirectDeleteBlocked()
|
|
{
|
|
var derived = new Template("Parent.slot") { Id = 77, IsDerived = true };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
|
|
|
var result = await _service.DeleteTemplateAsync(77, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("derived template", result.Error);
|
|
}
|
|
|
|
/// <summary>Verifies that update attribute, locked in derived base, rejects on derived.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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 });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(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);
|
|
}
|
|
|
|
/// <summary>Verifies that update script, locked in derived base, rejects on derived.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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 });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(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);
|
|
}
|
|
|
|
/// <summary>Verifies that add composition, copies alarms as inherited.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddComposition_CopiesAlarmsAsInherited()
|
|
{
|
|
var moduleTemplate = new Template("Module") { Id = 2 };
|
|
moduleTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
|
|
{
|
|
Id = 30,
|
|
TemplateId = 2,
|
|
TriggerType = AlarmTriggerType.RangeViolation,
|
|
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
|
PriorityLevel = 5,
|
|
Description = "Too hot"
|
|
});
|
|
var template = new Template("Parent") { Id = 1 };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template, moduleTemplate });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template, moduleTemplate });
|
|
|
|
Template? captured = null;
|
|
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
|
|
.Callback<Template, CancellationToken>((t, _) => captured = t)
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.NotNull(captured);
|
|
Assert.Single(captured!.Alarms);
|
|
var copied = captured.Alarms.First();
|
|
Assert.Equal("HighTemp", copied.Name);
|
|
Assert.True(copied.IsInherited);
|
|
Assert.False(copied.LockedInDerived);
|
|
Assert.Equal(AlarmTriggerType.RangeViolation, copied.TriggerType);
|
|
Assert.Equal(5, copied.PriorityLevel);
|
|
Assert.Equal("Too hot", copied.Description);
|
|
}
|
|
|
|
/// <summary>Verifies that update alarm, locked in derived base, rejects on derived.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateAlarm_LockedInDerivedBase_RejectsOnDerived()
|
|
{
|
|
var existing = new TemplateAlarm("HighTemp")
|
|
{
|
|
Id = 300,
|
|
TemplateId = 77,
|
|
TriggerType = AlarmTriggerType.RangeViolation,
|
|
PriorityLevel = 5,
|
|
IsInherited = true
|
|
};
|
|
var baseTemplate = new Template("Sensor") { Id = 2 };
|
|
baseTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
|
|
{
|
|
Id = 30,
|
|
TemplateId = 2,
|
|
TriggerType = AlarmTriggerType.RangeViolation,
|
|
LockedInDerived = true
|
|
});
|
|
var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateAlarmByIdAsync(300, 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 });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseTemplate, derived });
|
|
|
|
var proposed = new TemplateAlarm("HighTemp")
|
|
{
|
|
TriggerType = AlarmTriggerType.RangeViolation,
|
|
PriorityLevel = 99,
|
|
IsInherited = false
|
|
};
|
|
var result = await _service.UpdateAlarmAsync(300, proposed, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("locked by base template 'Sensor'", result.Error);
|
|
}
|
|
|
|
/// <summary>Verifies that update alarm, derived override, persists is inherited false.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateAlarm_DerivedOverride_PersistsIsInheritedFalse()
|
|
{
|
|
var existing = new TemplateAlarm("HighTemp")
|
|
{
|
|
Id = 300,
|
|
TemplateId = 77,
|
|
TriggerType = AlarmTriggerType.RangeViolation,
|
|
PriorityLevel = 5,
|
|
IsInherited = true
|
|
};
|
|
var baseTemplate = new Template("Sensor") { Id = 2 };
|
|
baseTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
|
|
{
|
|
Id = 30,
|
|
TemplateId = 2,
|
|
TriggerType = AlarmTriggerType.RangeViolation
|
|
});
|
|
var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateAlarmByIdAsync(300, 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 });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseTemplate, derived });
|
|
|
|
var proposed = new TemplateAlarm("HighTemp")
|
|
{
|
|
TriggerType = AlarmTriggerType.RangeViolation,
|
|
PriorityLevel = 99,
|
|
IsInherited = false
|
|
};
|
|
var result = await _service.UpdateAlarmAsync(300, proposed, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.False(result.Value.IsInherited);
|
|
Assert.Equal(99, result.Value.PriorityLevel);
|
|
}
|
|
|
|
/// <summary>Verifies that update attribute, derived override, persists is inherited false.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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 });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(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);
|
|
}
|
|
|
|
/// <summary>Verifies that delete template, base with derivatives, blocked with slot names.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task DeleteTemplate_BaseWithDerivatives_BlockedWithSlotNames()
|
|
{
|
|
var baseTemplate = new Template("Sensor") { Id = 5 };
|
|
var parent = new Template("Pump") { Id = 1 };
|
|
var composition = new TemplateComposition("TempSensor") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
|
parent.Compositions.Add(composition);
|
|
var derived = new Template("Pump.TempSensor") { Id = 77, ParentTemplateId = 5, IsDerived = true, OwnerCompositionId = 50 };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(5, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
|
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(5, It.IsAny<CancellationToken>())).ReturnsAsync(new List<Instance>());
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseTemplate, parent, derived });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseTemplate, parent, derived });
|
|
|
|
var result = await _service.DeleteTemplateAsync(5, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("derived", result.Error);
|
|
Assert.Contains("'Pump' (as 'TempSensor')", result.Error);
|
|
}
|
|
|
|
/// <summary>Verifies that add composition, duplicate instance name, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddComposition_DuplicateInstanceName_Fails()
|
|
{
|
|
var moduleTemplate = new Template("Module") { Id = 2 };
|
|
var template = new Template("Parent") { Id = 1 };
|
|
template.Compositions.Add(new TemplateComposition("myModule") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
|
|
|
|
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("already exists", result.Error);
|
|
}
|
|
|
|
/// <summary>Verifies that add composition, self composition, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddComposition_SelfComposition_Fails()
|
|
{
|
|
var template = new Template("Self") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
|
|
var result = await _service.AddCompositionAsync(1, 1, "self", "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("compose itself", result.Error);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Locking Rules
|
|
// ========================================================================
|
|
|
|
/// <summary>Verifies that update attribute, locked member, cannot unlock.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateAttribute_LockedMember_CannotUnlock()
|
|
{
|
|
var existing = new TemplateAttribute("Temperature")
|
|
{
|
|
Id = 1, TemplateId = 1, DataType = DataType.Float, IsLocked = true
|
|
};
|
|
_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"
|
|
};
|
|
var result = await _service.UpdateAttributeAsync(1, proposed, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("cannot be unlocked", result.Error);
|
|
}
|
|
|
|
/// <summary>Verifies that update attribute, lock unlocked member, succeeds.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateAttribute_LockUnlockedMember_Succeeds()
|
|
{
|
|
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.Float, IsLocked = true, Value = "42"
|
|
};
|
|
var result = await _service.UpdateAttributeAsync(1, proposed, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.True(result.Value.IsLocked);
|
|
}
|
|
|
|
/// <summary>Verifies that update attribute, unlocked attribute, data type change rejected.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that update attribute, unlocked attribute, data source reference change rejected.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that update attribute, parent locked, cannot override.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateAttribute_ParentLocked_CannotOverride()
|
|
{
|
|
// Parent template with locked attribute
|
|
var parentTemplate = new Template("Base") { Id = 1 };
|
|
parentTemplate.Attributes.Add(new TemplateAttribute("Speed")
|
|
{
|
|
Id = 10, TemplateId = 1, DataType = DataType.Float, IsLocked = true
|
|
});
|
|
|
|
// Child template overriding same attribute
|
|
var childTemplate = new Template("Child") { Id = 2, ParentTemplateId = 1 };
|
|
var childAttr = new TemplateAttribute("Speed")
|
|
{
|
|
Id = 20, TemplateId = 2, DataType = DataType.Float, IsLocked = false
|
|
};
|
|
childTemplate.Attributes.Add(childAttr);
|
|
|
|
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(20, It.IsAny<CancellationToken>())).ReturnsAsync(childAttr);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(childTemplate);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { parentTemplate, childTemplate });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { parentTemplate, childTemplate });
|
|
|
|
var proposed = new TemplateAttribute("Speed")
|
|
{
|
|
DataType = DataType.Float, IsLocked = false, Value = "100"
|
|
};
|
|
var result = await _service.UpdateAttributeAsync(20, proposed, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("locked in parent", result.Error);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Inheritance Override Scope — Cannot remove parent members
|
|
// ========================================================================
|
|
|
|
/// <summary>Verifies that delete attribute, inherited from parent, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task DeleteAttribute_InheritedFromParent_Fails()
|
|
{
|
|
var parentTemplate = new Template("Base") { Id = 1 };
|
|
parentTemplate.Attributes.Add(new TemplateAttribute("Speed")
|
|
{
|
|
Id = 10, TemplateId = 1, DataType = DataType.Float
|
|
});
|
|
|
|
var childTemplate = new Template("Child") { Id = 2, ParentTemplateId = 1 };
|
|
var childAttr = new TemplateAttribute("Speed")
|
|
{
|
|
Id = 20, TemplateId = 2, DataType = DataType.Float
|
|
};
|
|
childTemplate.Attributes.Add(childAttr);
|
|
|
|
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(20, It.IsAny<CancellationToken>())).ReturnsAsync(childAttr);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(childTemplate);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { parentTemplate, childTemplate });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { parentTemplate, childTemplate });
|
|
|
|
var result = await _service.DeleteAttributeAsync(20, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("inherited from parent", result.Error);
|
|
}
|
|
|
|
/// <summary>Verifies that delete attribute, own member, succeeds.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task DeleteAttribute_OwnMember_Succeeds()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
var attr = new TemplateAttribute("CustomAttr")
|
|
{
|
|
Id = 1, TemplateId = 1, DataType = DataType.String
|
|
};
|
|
template.Attributes.Add(attr);
|
|
|
|
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(attr);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
|
|
var result = await _service.DeleteAttributeAsync(1, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Graph Acyclicity
|
|
// ========================================================================
|
|
|
|
/// <summary>Verifies that update template, change parent, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateTemplate_ChangeParent_Fails()
|
|
{
|
|
var parentA = new Template("A") { Id = 1 };
|
|
var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
|
|
|
|
// Attempt to re-parent Child from A (id=1) to B (id=3).
|
|
var result = await _service.UpdateTemplateAsync(2, "Child", null, 3, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("cannot be changed", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
/// <summary>Verifies that update template, omitted parent and description, leaves unchanged.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateTemplate_OmittedParentAndDescription_LeavesUnchanged()
|
|
{
|
|
// A null parent/description means "leave unchanged" (an omitted
|
|
// CLI/API field), not an attempt to clear. Renaming without re-passing the
|
|
// description must not wipe it, and must not trip the parent-immutability guard.
|
|
var child = new Template("Child") { Id = 2, ParentTemplateId = 1, Description = "keep me" };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { child });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { child });
|
|
|
|
var result = await _service.UpdateTemplateAsync(2, "ChildRenamed", null, null, "admin");
|
|
|
|
Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null);
|
|
Assert.Equal("ChildRenamed", result.Value.Name);
|
|
Assert.Equal("keep me", result.Value.Description); // null description left unchanged
|
|
Assert.Equal(1, result.Value.ParentTemplateId); // parent preserved
|
|
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
/// <summary>Verifies that update template, empty description, clears it.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateTemplate_EmptyDescription_ClearsIt()
|
|
{
|
|
// An explicit empty string (distinct from omitted/null) clears the description.
|
|
var t = new Template("T") { Id = 1, Description = "old" };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { t });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { t });
|
|
|
|
var result = await _service.UpdateTemplateAsync(1, "T", "", null, "admin");
|
|
|
|
Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null);
|
|
Assert.Equal("", result.Value.Description);
|
|
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
/// <summary>Verifies that update template, same parent, succeeds.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateTemplate_SameParent_Succeeds()
|
|
{
|
|
var child = new Template("Child") { Id = 2, ParentTemplateId = 1, Description = "old" };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { child });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { child });
|
|
|
|
// Idempotent pass — same parent value sent on update should succeed and apply name/description changes.
|
|
var result = await _service.UpdateTemplateAsync(2, "ChildRenamed", "new", 1, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("ChildRenamed", result.Value.Name);
|
|
Assert.Equal("new", result.Value.Description);
|
|
Assert.Equal(1, result.Value.ParentTemplateId);
|
|
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
/// <summary>Verifies that add composition, circular chain, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddComposition_CircularChain_Fails()
|
|
{
|
|
// A composes B, B composes C, try to make C compose A => cycle
|
|
var templateC = new Template("C") { Id = 3 };
|
|
var templateB = new Template("B") { Id = 2 };
|
|
templateB.Compositions.Add(new TemplateComposition("c1") { Id = 2, TemplateId = 2, ComposedTemplateId = 3 });
|
|
var templateA = new Template("A") { Id = 1 };
|
|
templateA.Compositions.Add(new TemplateComposition("b1") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(3, It.IsAny<CancellationToken>())).ReturnsAsync(templateC);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(templateA);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { templateA, templateB, templateC });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { templateA, templateB, templateC });
|
|
|
|
var result = await _service.AddCompositionAsync(3, 1, "a1", "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Move template between folders
|
|
// ========================================================================
|
|
|
|
/// <summary>Verifies that move template, to folder, returns success.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task MoveTemplate_ToFolder_ReturnsSuccess()
|
|
{
|
|
var t = new Template("X") { Id = 1, FolderId = null };
|
|
var folder = new TemplateFolder("Dev") { Id = 7 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
|
|
_repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { t });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { t });
|
|
|
|
var result = await _service.MoveTemplateAsync(1, 7, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(7, result.Value.FolderId);
|
|
}
|
|
|
|
/// <summary>Verifies that move template, to root, returns success.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task MoveTemplate_ToRoot_ReturnsSuccess()
|
|
{
|
|
var t = new Template("X") { Id = 1, FolderId = 7 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { t });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { t });
|
|
|
|
var result = await _service.MoveTemplateAsync(1, null, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Null(result.Value.FolderId);
|
|
}
|
|
|
|
/// <summary>Verifies that move template, target folder missing, returns failure.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task MoveTemplate_TargetFolderMissing_ReturnsFailure()
|
|
{
|
|
var t = new Template("X") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
|
|
_repoMock.Setup(r => r.GetFolderByIdAsync(99, It.IsAny<CancellationToken>())).ReturnsAsync((TemplateFolder?)null);
|
|
|
|
var result = await _service.MoveTemplateAsync(1, 99, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("not found", result.Error);
|
|
}
|
|
|
|
/// <summary>Verifies that move template, name collision at destination, fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task MoveTemplate_NameCollisionAtDestination_Fails()
|
|
{
|
|
// Moving "Pump" into a folder that already contains a
|
|
// template named "Pump" (case-insensitive) must be rejected before the
|
|
// FolderId is persisted. Mirrors TemplateFolderService.MoveFolderAsync
|
|
// sibling-name uniqueness, and pins the design rule that naming
|
|
// collisions are design-time errors.
|
|
var moving = new Template("Pump") { Id = 1, FolderId = null };
|
|
var existing = new Template("pump") { Id = 2, FolderId = 7 }; // case-insensitive clash
|
|
var folder = new TemplateFolder("Dev") { Id = 7 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(moving);
|
|
_repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { moving, existing });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { moving, existing });
|
|
|
|
var result = await _service.MoveTemplateAsync(1, 7, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("already exists", result.Error);
|
|
// The destination FolderId must NOT have been written when the move is rejected.
|
|
Assert.Null(moving.FolderId);
|
|
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
/// <summary>Verifies that move template, no collision at destination, succeeds.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task MoveTemplate_NoCollisionAtDestination_Succeeds()
|
|
{
|
|
// Companion case: a sibling with the same name in a
|
|
// *different* folder is not a collision. The move must succeed.
|
|
var moving = new Template("Pump") { Id = 1, FolderId = null };
|
|
var unrelated = new Template("Pump") { Id = 2, FolderId = 8 }; // different folder
|
|
var folder = new TemplateFolder("Dev") { Id = 7 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(moving);
|
|
_repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { moving, unrelated });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { moving, unrelated });
|
|
|
|
var result = await _service.MoveTemplateAsync(1, 7, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(7, result.Value.FolderId);
|
|
}
|
|
|
|
/// <summary>Verifies that update attribute, locked in derived downgrade, on base, rejected.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateAttribute_LockedInDerivedDowngrade_OnBase_Rejected()
|
|
{
|
|
// LockedInDerived is a one-way ratchet on base
|
|
// templates. Once true, it cannot be cleared — otherwise existing
|
|
// derived overrides that were previously blocked would become
|
|
// retroactively legal.
|
|
var baseTemplate = new Template("Sensor") { Id = 2 }; // base — not derived
|
|
var existing = new TemplateAttribute("SetPoint")
|
|
{
|
|
Id = 100,
|
|
TemplateId = 2,
|
|
DataType = DataType.Float,
|
|
LockedInDerived = true
|
|
};
|
|
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(100, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
|
|
|
var proposed = new TemplateAttribute("SetPoint")
|
|
{
|
|
DataType = DataType.Float,
|
|
LockedInDerived = false // attempt to clear the lock
|
|
};
|
|
var result = await _service.UpdateAttributeAsync(100, proposed, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("locked-in-derived", result.Error);
|
|
Assert.True(existing.LockedInDerived); // unchanged
|
|
}
|
|
|
|
// ========================================================================
|
|
// Create* audit rows must carry the real entity Id,
|
|
// not the literal "0" that EF Core leaves on the entity until
|
|
// SaveChangesAsync populates the auto-generated key.
|
|
// ========================================================================
|
|
|
|
/// <summary>Verifies that create template, audit row carries real template id not literal zero.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CreateTemplate_AuditRowCarriesRealTemplateIdNotLiteralZero()
|
|
{
|
|
// Simulate EF Core's identity-key population: the repository's
|
|
// AddTemplateAsync stamps Id during SaveChangesAsync. The previous
|
|
// order (Add → LogAsync → Save) queued the audit row before the Id
|
|
// existed, so every Create audit entry persisted with EntityId = "0"
|
|
// and the audit trail lost its link back to the created template.
|
|
// Current order: save first, then log with the real Id, then save the
|
|
// audit row.
|
|
Template? added = null;
|
|
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
|
|
.Callback<Template, CancellationToken>((t, _) => added = t)
|
|
.Returns(Task.CompletedTask);
|
|
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
|
.Callback<CancellationToken>(_ =>
|
|
{
|
|
// First SaveChangesAsync after Add: assign the identity key.
|
|
if (added != null && added.Id == 0) added.Id = 42;
|
|
})
|
|
.ReturnsAsync(1);
|
|
|
|
string? auditedEntityId = null;
|
|
_auditMock.Setup(a => a.LogAsync(
|
|
It.IsAny<string>(),
|
|
"Create",
|
|
"Template",
|
|
It.IsAny<string>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<object?>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<string, string, string, string, string, object?, CancellationToken>(
|
|
(_, _, _, entityId, _, _, _) => auditedEntityId = entityId)
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var result = await _service.CreateTemplateAsync("Pump", null, null, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("42", auditedEntityId);
|
|
Assert.NotEqual("0", auditedEntityId);
|
|
}
|
|
|
|
/// <summary>Verifies that add attribute, audit row carries real attribute id not literal zero.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddAttribute_AuditRowCarriesRealAttributeIdNotLiteralZero()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
|
|
TemplateAttribute? added = null;
|
|
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
|
.Callback<TemplateAttribute, CancellationToken>((a, _) => added = a)
|
|
.Returns(Task.CompletedTask);
|
|
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
|
.Callback<CancellationToken>(_ =>
|
|
{
|
|
if (added != null && added.Id == 0) added.Id = 77;
|
|
})
|
|
.ReturnsAsync(1);
|
|
|
|
string? auditedEntityId = null;
|
|
_auditMock.Setup(a => a.LogAsync(
|
|
It.IsAny<string>(),
|
|
"Create",
|
|
"TemplateAttribute",
|
|
It.IsAny<string>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<object?>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<string, string, string, string, string, object?, CancellationToken>(
|
|
(_, _, _, entityId, _, _, _) => auditedEntityId = entityId)
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var attr = new TemplateAttribute("Temperature") { DataType = DataType.Float, Value = "0.0" };
|
|
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("77", auditedEntityId);
|
|
Assert.NotEqual("0", auditedEntityId);
|
|
}
|
|
|
|
/// <summary>Verifies that add alarm, audit row carries real alarm id not literal zero.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddAlarm_AuditRowCarriesRealAlarmIdNotLiteralZero()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
|
|
TemplateAlarm? added = null;
|
|
_repoMock.Setup(r => r.AddTemplateAlarmAsync(It.IsAny<TemplateAlarm>(), It.IsAny<CancellationToken>()))
|
|
.Callback<TemplateAlarm, CancellationToken>((a, _) => added = a)
|
|
.Returns(Task.CompletedTask);
|
|
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
|
.Callback<CancellationToken>(_ =>
|
|
{
|
|
if (added != null && added.Id == 0) added.Id = 99;
|
|
})
|
|
.ReturnsAsync(1);
|
|
|
|
string? auditedEntityId = null;
|
|
_auditMock.Setup(a => a.LogAsync(
|
|
It.IsAny<string>(),
|
|
"Create",
|
|
"TemplateAlarm",
|
|
It.IsAny<string>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<object?>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<string, string, string, string, string, object?, CancellationToken>(
|
|
(_, _, _, entityId, _, _, _) => auditedEntityId = entityId)
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var alarm = new TemplateAlarm("HighTemp")
|
|
{
|
|
PriorityLevel = 500,
|
|
TriggerType = AlarmTriggerType.RangeViolation,
|
|
TriggerConfiguration = """{"Max": 100}"""
|
|
};
|
|
var result = await _service.AddAlarmAsync(1, alarm, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("99", auditedEntityId);
|
|
Assert.NotEqual("0", auditedEntityId);
|
|
}
|
|
|
|
/// <summary>Verifies that add script, audit row carries real script id not literal zero.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddScript_AuditRowCarriesRealScriptIdNotLiteralZero()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
|
|
TemplateScript? added = null;
|
|
_repoMock.Setup(r => r.AddTemplateScriptAsync(It.IsAny<TemplateScript>(), It.IsAny<CancellationToken>()))
|
|
.Callback<TemplateScript, CancellationToken>((s, _) => added = s)
|
|
.Returns(Task.CompletedTask);
|
|
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
|
.Callback<CancellationToken>(_ =>
|
|
{
|
|
if (added != null && added.Id == 0) added.Id = 123;
|
|
})
|
|
.ReturnsAsync(1);
|
|
|
|
string? auditedEntityId = null;
|
|
_auditMock.Setup(a => a.LogAsync(
|
|
It.IsAny<string>(),
|
|
"Create",
|
|
"TemplateScript",
|
|
It.IsAny<string>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<object?>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<string, string, string, string, string, object?, CancellationToken>(
|
|
(_, _, _, entityId, _, _, _) => auditedEntityId = entityId)
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var script = new TemplateScript("OnStart", "return true;") { TriggerType = "Startup" };
|
|
var result = await _service.AddScriptAsync(1, script, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("123", auditedEntityId);
|
|
Assert.NotEqual("0", auditedEntityId);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Inherited-member propagation & resync
|
|
// ========================================================================
|
|
|
|
private static Template BaseTemplate(int id, string name) => new(name) { Id = id };
|
|
private static Template DerivedTemplate(int id, string name, int parentId) =>
|
|
new(name) { Id = id, ParentTemplateId = parentId, IsDerived = true };
|
|
|
|
/// <summary>Verifies that resync, materializes missing inherited attribute.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Resync_MaterializesMissingInheritedAttribute()
|
|
{
|
|
// The reported shape: a base member added after the child was derived
|
|
// never reached the child's stored rows. Resync materializes it.
|
|
var baseT = BaseTemplate(7, "Base");
|
|
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" });
|
|
baseT.Attributes.Add(new TemplateAttribute("B") { Id = 71, TemplateId = 7, DataType = DataType.String, Value = "x" });
|
|
|
|
var child = DerivedTemplate(8, "Child", 7);
|
|
child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.Float, Value = "1", IsInherited = true });
|
|
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseT, child });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseT, child });
|
|
var added = new List<TemplateAttribute>();
|
|
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
|
.Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a))
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var result = await _service.ResyncInheritedMembersAsync(8, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(1, result.Value.MembersAdded);
|
|
Assert.Equal(1, result.Value.TemplatesChanged);
|
|
var b = Assert.Single(added);
|
|
Assert.Equal("B", b.Name);
|
|
Assert.Equal(8, b.TemplateId);
|
|
Assert.True(b.IsInherited);
|
|
Assert.Equal("x", b.Value);
|
|
Assert.Equal(DataType.String, b.DataType);
|
|
}
|
|
|
|
/// <summary>Verifies that resync, removes orphaned inherited row.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Resync_RemovesOrphanedInheritedRow()
|
|
{
|
|
var baseT = BaseTemplate(7, "Base");
|
|
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" });
|
|
|
|
var child = DerivedTemplate(8, "Child", 7);
|
|
child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.Float, Value = "1", IsInherited = true });
|
|
// Orphan: an inherited placeholder whose base member was removed.
|
|
child.Attributes.Add(new TemplateAttribute("Gone") { Id = 81, TemplateId = 8, DataType = DataType.String, Value = "old", IsInherited = true });
|
|
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseT, child });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseT, child });
|
|
var deleted = new List<int>();
|
|
_repoMock.Setup(r => r.DeleteTemplateAttributeAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
.Callback<int, CancellationToken>((id, _) => deleted.Add(id))
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var result = await _service.ResyncInheritedMembersAsync(8, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(1, result.Value.MembersRemoved);
|
|
Assert.Equal(81, Assert.Single(deleted));
|
|
}
|
|
|
|
/// <summary>Verifies that resync, updates drifted inherited row.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Resync_UpdatesDriftedInheritedRow()
|
|
{
|
|
var baseT = BaseTemplate(7, "Base");
|
|
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.String, Value = "NEW" });
|
|
|
|
var child = DerivedTemplate(8, "Child", 7);
|
|
var childA = new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.String, Value = "OLD", IsInherited = true };
|
|
child.Attributes.Add(childA);
|
|
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseT, child });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseT, child });
|
|
var updated = new List<TemplateAttribute>();
|
|
_repoMock.Setup(r => r.UpdateTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
|
.Callback<TemplateAttribute, CancellationToken>((a, _) => updated.Add(a))
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var result = await _service.ResyncInheritedMembersAsync(8, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(1, result.Value.MembersUpdated);
|
|
Assert.Same(childA, Assert.Single(updated));
|
|
Assert.Equal("NEW", childA.Value); // re-synced in place to the live base value
|
|
}
|
|
|
|
/// <summary>Verifies that resync, leaves authored overrides untouched.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Resync_LeavesAuthoredOverridesUntouched()
|
|
{
|
|
var baseT = BaseTemplate(7, "Base");
|
|
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.String, Value = "base" });
|
|
|
|
var child = DerivedTemplate(8, "Child", 7);
|
|
// Authored override (IsInherited == false) with a divergent value.
|
|
child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.String, Value = "override", IsInherited = false });
|
|
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseT, child });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseT, child });
|
|
|
|
var result = await _service.ResyncInheritedMembersAsync(8, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(0, result.Value.MembersAdded);
|
|
Assert.Equal(0, result.Value.MembersUpdated);
|
|
Assert.Equal(0, result.Value.MembersRemoved);
|
|
_repoMock.Verify(r => r.UpdateTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
_repoMock.Verify(r => r.DeleteTemplateAttributeAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
/// <summary>Verifies that resync, on base, cascades to all derived children.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Resync_OnBase_CascadesToAllDerivedChildren()
|
|
{
|
|
var baseT = BaseTemplate(7, "ReactorSide");
|
|
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" });
|
|
baseT.Attributes.Add(new TemplateAttribute("B") { Id = 71, TemplateId = 7, DataType = DataType.String, Value = "x" });
|
|
|
|
var left = DerivedTemplate(8, "Left", 7);
|
|
left.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.Float, Value = "1", IsInherited = true });
|
|
var right = DerivedTemplate(9, "Right", 7);
|
|
right.Attributes.Add(new TemplateAttribute("A") { Id = 90, TemplateId = 9, DataType = DataType.Float, Value = "1", IsInherited = true });
|
|
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseT, left, right });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseT, left, right });
|
|
var added = new List<TemplateAttribute>();
|
|
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
|
.Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a))
|
|
.Returns(Task.CompletedTask);
|
|
|
|
// Resyncing the base repairs the whole subtree.
|
|
var result = await _service.ResyncInheritedMembersAsync(7, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(2, result.Value.MembersAdded); // B materialized on Left and Right
|
|
Assert.Equal(2, result.Value.TemplatesChanged);
|
|
Assert.Contains(added, a => a.TemplateId == 8 && a.Name == "B" && a.IsInherited);
|
|
Assert.Contains(added, a => a.TemplateId == 9 && a.Name == "B" && a.IsInherited);
|
|
}
|
|
|
|
/// <summary>Verifies that resync, materializes missing inherited script and native source.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Resync_MaterializesMissingInheritedScriptAndNativeSource()
|
|
{
|
|
// Scripts and native alarm sources use the same inherit model and are
|
|
// counted by the staleness summary, so reconcile must materialize them too.
|
|
var baseT = BaseTemplate(7, "Base");
|
|
baseT.Scripts.Add(new TemplateScript("OnStart", "return 1;") { Id = 70, TemplateId = 7, TriggerType = "Startup" });
|
|
baseT.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Src") { Id = 75, TemplateId = 7, ConnectionName = "opc", SourceReference = "ns=2;s=X" });
|
|
|
|
var child = DerivedTemplate(8, "Child", 7); // no inherited rows yet
|
|
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseT, child });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseT, child });
|
|
var addedScripts = new List<TemplateScript>();
|
|
var addedSources = new List<TemplateNativeAlarmSource>();
|
|
_repoMock.Setup(r => r.AddTemplateScriptAsync(It.IsAny<TemplateScript>(), It.IsAny<CancellationToken>()))
|
|
.Callback<TemplateScript, CancellationToken>((s, _) => addedScripts.Add(s)).Returns(Task.CompletedTask);
|
|
_repoMock.Setup(r => r.AddTemplateNativeAlarmSourceAsync(It.IsAny<TemplateNativeAlarmSource>(), It.IsAny<CancellationToken>()))
|
|
.Callback<TemplateNativeAlarmSource, CancellationToken>((s, _) => addedSources.Add(s)).Returns(Task.CompletedTask);
|
|
|
|
var result = await _service.ResyncInheritedMembersAsync(8, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(2, result.Value.MembersAdded);
|
|
var script = Assert.Single(addedScripts);
|
|
Assert.Equal("OnStart", script.Name);
|
|
Assert.Equal("return 1;", script.Code);
|
|
Assert.True(script.IsInherited);
|
|
Assert.Equal(8, script.TemplateId);
|
|
var src = Assert.Single(addedSources);
|
|
Assert.Equal("Src", src.Name);
|
|
Assert.Equal("ns=2;s=X", src.SourceReference);
|
|
Assert.True(src.IsInherited);
|
|
Assert.Equal(8, src.TemplateId);
|
|
}
|
|
|
|
/// <summary>Verifies that reconcile descendants, propagates new base member to child.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task ReconcileDescendants_PropagatesNewBaseMemberToChild()
|
|
{
|
|
// Base already carries the new member C; reconciling its descendants
|
|
// materializes C onto the child (the engine behind auto-propagation).
|
|
var baseT = BaseTemplate(7, "Base");
|
|
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" });
|
|
baseT.Attributes.Add(new TemplateAttribute("C") { Id = 72, TemplateId = 7, DataType = DataType.String, Value = "c" });
|
|
|
|
var child = DerivedTemplate(8, "Child", 7);
|
|
child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.Float, Value = "1", IsInherited = true });
|
|
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseT, child });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseT, child });
|
|
var added = new List<TemplateAttribute>();
|
|
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
|
.Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a)).Returns(Task.CompletedTask);
|
|
|
|
await _service.ReconcileDescendantsAsync(7, "admin");
|
|
|
|
var c = Assert.Single(added);
|
|
Assert.Equal("C", c.Name);
|
|
Assert.Equal(8, c.TemplateId);
|
|
Assert.True(c.IsInherited);
|
|
}
|
|
|
|
/// <summary>Verifies that add attribute, propagates new member to derived child.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddAttribute_PropagatesNewMemberToDerivedChild()
|
|
{
|
|
// End-to-end: adding a member to a base auto-propagates an inherited
|
|
// placeholder to its derived children.
|
|
var baseT = BaseTemplate(7, "Base");
|
|
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" });
|
|
var child = DerivedTemplate(8, "Child", 7);
|
|
child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.Float, Value = "1", IsInherited = true });
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(baseT);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseT, child });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseT, child });
|
|
|
|
var added = new List<TemplateAttribute>();
|
|
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
|
.Callback<TemplateAttribute, CancellationToken>((a, _) =>
|
|
{
|
|
added.Add(a);
|
|
// Simulate persistence so the post-add reconcile re-fetch sees the
|
|
// base's new member and propagates it down.
|
|
if (a.TemplateId == 7) baseT.Attributes.Add(a);
|
|
})
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var newAttr = new TemplateAttribute("C") { DataType = DataType.String, Value = "c" };
|
|
var result = await _service.AddAttributeAsync(7, newAttr, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Contains(added, a => a.TemplateId == 7 && a.Name == "C" && !a.IsInherited); // base's own
|
|
Assert.Contains(added, a => a.TemplateId == 8 && a.Name == "C" && a.IsInherited); // child placeholder
|
|
}
|
|
|
|
/// <summary>Verifies that create derived template async, copies is mes reportable onto placeholder.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CreateDerivedTemplateAsync_CopiesIsMesReportableOntoPlaceholder()
|
|
{
|
|
// Deriving a template (compose → BuildDerivedTemplate) materializes the
|
|
// base's alarms as IsInherited placeholders; the MES-reportable flag has
|
|
// to ride along or the derived template reads "not reportable" and an
|
|
// edit to any other field would persist that false.
|
|
var moduleTemplate = new Template("Module") { Id = 2 };
|
|
moduleTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
|
|
{
|
|
Id = 30,
|
|
TemplateId = 2,
|
|
TriggerType = AlarmTriggerType.RangeViolation,
|
|
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
|
PriorityLevel = 5,
|
|
Description = "Too hot",
|
|
IsMesReportable = true
|
|
});
|
|
var template = new Template("Parent") { Id = 1 };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template, moduleTemplate });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template, moduleTemplate });
|
|
|
|
Template? captured = null;
|
|
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
|
|
.Callback<Template, CancellationToken>((t, _) => captured = t)
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.NotNull(captured);
|
|
var copied = Assert.Single(captured!.Alarms);
|
|
Assert.True(copied.IsInherited);
|
|
Assert.True(copied.IsMesReportable);
|
|
}
|
|
|
|
/// <summary>Verifies that reconcile descendants, propagates is mes reportable change.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task ReconcileDescendants_PropagatesIsMesReportableChange()
|
|
{
|
|
// Two shapes in one reconcile: an EXISTING placeholder that drifted
|
|
// (base flipped false→true after the derive) must be healed, and a base
|
|
// alarm added later must arrive on the child carrying the flag.
|
|
var baseT = BaseTemplate(7, "Base");
|
|
baseT.Alarms.Add(new TemplateAlarm("HighTemp")
|
|
{
|
|
Id = 70,
|
|
TemplateId = 7,
|
|
TriggerType = AlarmTriggerType.RangeViolation,
|
|
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
|
PriorityLevel = 5,
|
|
IsMesReportable = true
|
|
});
|
|
baseT.Alarms.Add(new TemplateAlarm("LowTemp")
|
|
{
|
|
Id = 71,
|
|
TemplateId = 7,
|
|
TriggerType = AlarmTriggerType.RangeViolation,
|
|
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"low\":10}",
|
|
PriorityLevel = 6,
|
|
IsMesReportable = true
|
|
});
|
|
|
|
var child = DerivedTemplate(8, "Child", 7);
|
|
child.Alarms.Add(new TemplateAlarm("HighTemp")
|
|
{
|
|
Id = 80,
|
|
TemplateId = 8,
|
|
TriggerType = AlarmTriggerType.RangeViolation,
|
|
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
|
PriorityLevel = 5,
|
|
IsInherited = true,
|
|
IsMesReportable = false // stale placeholder written before the base flipped
|
|
});
|
|
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseT, child });
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseT, child });
|
|
|
|
var added = new List<TemplateAlarm>();
|
|
_repoMock.Setup(r => r.AddTemplateAlarmAsync(It.IsAny<TemplateAlarm>(), It.IsAny<CancellationToken>()))
|
|
.Callback<TemplateAlarm, CancellationToken>((a, _) => added.Add(a)).Returns(Task.CompletedTask);
|
|
var updated = new List<TemplateAlarm>();
|
|
_repoMock.Setup(r => r.UpdateTemplateAlarmAsync(It.IsAny<TemplateAlarm>(), It.IsAny<CancellationToken>()))
|
|
.Callback<TemplateAlarm, CancellationToken>((a, _) => updated.Add(a)).Returns(Task.CompletedTask);
|
|
|
|
await _service.ReconcileDescendantsAsync(7, "admin");
|
|
|
|
// Drift detected and healed on the existing placeholder.
|
|
var healed = Assert.Single(updated, a => a.Name == "HighTemp");
|
|
Assert.True(healed.IsMesReportable);
|
|
Assert.True(healed.IsInherited);
|
|
// Newly propagated member carries the flag.
|
|
var propagated = Assert.Single(added, a => a.Name == "LowTemp");
|
|
Assert.Equal(8, propagated.TemplateId);
|
|
Assert.True(propagated.IsInherited);
|
|
Assert.True(propagated.IsMesReportable);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Gitea #66: member add/update loads only the analysis closure
|
|
// ========================================================================
|
|
|
|
private void WholeAnalysisGraphMustNotLoad() =>
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new InvalidOperationException("member add/update loaded the whole analysis graph"));
|
|
|
|
/// <summary>
|
|
/// Member adds validate collisions against the template's own closure (here: itself plus
|
|
/// its composed module) and never touch the whole-library analysis graph.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task MemberAdds_CheckCollisionsAgainstClosure_NeverLoadWholeGraph()
|
|
{
|
|
var module = new Template("Motor") { Id = 2 };
|
|
module.Attributes.Add(new TemplateAttribute("Temp") { Id = 20, TemplateId = 2 });
|
|
var template = new Template("Pump") { Id = 1 };
|
|
template.Compositions.Add(new TemplateComposition("m") { Id = 5, TemplateId = 1, ComposedTemplateId = 2 });
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template, module });
|
|
_repoMock.Setup(r => r.GetTemplateAnalysisClosureAsync(1, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template, module });
|
|
WholeAnalysisGraphMustNotLoad();
|
|
|
|
// A canonical-name collision with the composed module is still caught from the closure.
|
|
var colliding = await _service.AddAttributeAsync(
|
|
1, new TemplateAttribute("m.Temp") { DataType = DataType.Float, Value = "0" }, "admin");
|
|
Assert.True(colliding.IsFailure);
|
|
Assert.Contains("Naming collision on 'm.Temp'", colliding.Error);
|
|
|
|
Assert.True((await _service.AddAttributeAsync(
|
|
1, new TemplateAttribute("Speed") { DataType = DataType.Float, Value = "0" }, "admin")).IsSuccess);
|
|
Assert.True((await _service.AddAlarmAsync(
|
|
1, new TemplateAlarm("Hi") { PriorityLevel = 500, TriggerType = AlarmTriggerType.ValueMatch }, "admin")).IsSuccess);
|
|
Assert.True((await _service.AddScriptAsync(
|
|
1, new TemplateScript("OnStart", "return true;") { TriggerType = "Startup" }, "admin")).IsSuccess);
|
|
|
|
_repoMock.Verify(r => r.GetTemplateAnalysisClosureAsync(1, It.IsAny<CancellationToken>()), Times.Exactly(4));
|
|
_repoMock.Verify(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Member updates resolve the parent lock from the PARENT's closure: a member locked on the
|
|
/// grandparent is found through the chain without loading the whole graph.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task MemberUpdates_ResolveParentLockFromParentClosure_NeverLoadWholeGraph()
|
|
{
|
|
var grand = new Template("Grand") { Id = 1 };
|
|
grand.Attributes.Add(new TemplateAttribute("Speed") { Id = 10, TemplateId = 1, DataType = DataType.Float, IsLocked = true });
|
|
grand.Alarms.Add(new TemplateAlarm("Hi") { Id = 11, TemplateId = 1, IsLocked = true, TriggerType = AlarmTriggerType.ValueMatch });
|
|
grand.Scripts.Add(new TemplateScript("Calc", string.Empty) { Id = 12, TemplateId = 1, IsLocked = true });
|
|
var parent = new Template("Parent") { Id = 2, ParentTemplateId = 1 };
|
|
var child = new Template("Child") { Id = 3, ParentTemplateId = 2 };
|
|
var childAttr = new TemplateAttribute("Speed") { Id = 30, TemplateId = 3, DataType = DataType.Float };
|
|
var childAlarm = new TemplateAlarm("Hi") { Id = 31, TemplateId = 3, TriggerType = AlarmTriggerType.ValueMatch };
|
|
var childScript = new TemplateScript("Calc", "return 1;") { Id = 32, TemplateId = 3 };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(30, It.IsAny<CancellationToken>())).ReturnsAsync(childAttr);
|
|
_repoMock.Setup(r => r.GetTemplateAlarmByIdAsync(31, It.IsAny<CancellationToken>())).ReturnsAsync(childAlarm);
|
|
_repoMock.Setup(r => r.GetTemplateScriptByIdAsync(32, It.IsAny<CancellationToken>())).ReturnsAsync(childScript);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(3, It.IsAny<CancellationToken>())).ReturnsAsync(child);
|
|
_repoMock.Setup(r => r.GetTemplateAnalysisClosureAsync(2, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { parent, grand });
|
|
WholeAnalysisGraphMustNotLoad();
|
|
|
|
var attr = await _service.UpdateAttributeAsync(
|
|
30, new TemplateAttribute("Speed") { DataType = DataType.Float, Value = "1" }, "admin");
|
|
var alarm = await _service.UpdateAlarmAsync(
|
|
31, new TemplateAlarm("Hi") { TriggerType = AlarmTriggerType.ValueMatch }, "admin");
|
|
var script = await _service.UpdateScriptAsync(32, new TemplateScript("Calc", "return 2;"), "admin");
|
|
|
|
Assert.Contains("locked in parent", attr.Error);
|
|
Assert.Contains("locked in parent", alarm.Error);
|
|
Assert.Contains("locked in parent", script.Error);
|
|
_repoMock.Verify(r => r.GetTemplateAnalysisClosureAsync(2, It.IsAny<CancellationToken>()), Times.Exactly(3));
|
|
_repoMock.Verify(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
}
|